@volter/editor-blender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,1256 @@
1
+ /**
2
+ * The editor tab's Blender session and the control-channel doors onto it —
3
+ * Blender's EDITOR half, in `@volter/editor-blender` (WORK.md §Skews as packages under
4
+ * the Code-OSS frame, item 6). Its thirteen verbs are
5
+ * `../contributions/blender.command.ts`, a `workspace.command` contribution;
6
+ * what it needs of the running editor it asks THROUGH THE DOOR,
7
+ * `@volter/editor-sdk/host` — the document context it
8
+ * presents into, the session it lives and dies with. It imports no host module
9
+ * (WORKBENCH.md §The invariants, "Direction": a package imports only the SDK
10
+ * and other packages' exports), which is what lets this lane ship against a
11
+ * workbench it was not built in.
12
+ *
13
+ * `vgai blender-mcp` is transport only: every `execute_blender_code`,
14
+ * `get_scene_info`, `get_object_info` and `get_viewport_screenshot` arrives
15
+ * here as a `blender-*` command and is answered by Blender running in this
16
+ * tab's worker (`@volter/blender-engine/browser`). The model exists in that worker
17
+ * and nowhere else; the Model document (`document:blender:runtime`, the
18
+ * project's `blender-runtime` contribution) only displays the frames the
19
+ * worker presents. A screenshot is the tab photographing that document
20
+ * through `capture-active-document`, after `blender-screenshot-view` has
21
+ * presented the model with the viewport's own camera.
22
+ *
23
+ * A RENDER photographs a detached revision through its own camera. three.js is
24
+ * the renderer (ARCHITECTURE-CORE, "No second implementation of a substrate
25
+ * capability ships"), so `bpy.ops.render.render()` arrives here as a present
26
+ * whose capture carries the SCENE camera and `scene.render`'s resolution, and
27
+ * the PNG goes straight back to the operator that asked — the one case where
28
+ * presenting answers with pixels instead of remembering a view.
29
+ */
30
+
31
+ import {
32
+ BlenderRuntime,
33
+ type CaptureRequest,
34
+ type PresentAnswer,
35
+ } from '@volter/blender-engine/browser';
36
+ import type {
37
+ BlenderActionClip,
38
+ BlenderNodeTree,
39
+ BlenderOutlinerTree,
40
+ BlenderOutlinerWrite,
41
+ BlenderRig,
42
+ BlenderRnaContext,
43
+ BlenderRnaView,
44
+ BlenderRnaWrite,
45
+ BlenderUvLayout,
46
+ } from '@volter/blender-engine/browser/rna';
47
+ import { AGX_LOOK_TABLES, agxEncodeFrame } from '@volter/blender-engine/browser/three/blender-agx';
48
+ import { displayTableUrl } from '@volter/blender-engine/browser/three/blender-display-lut';
49
+ import { filmicEncodeFrame } from '@volter/blender-engine/browser/three/blender-filmic';
50
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
51
+ import { standardEncodeFrame } from '@volter/blender-engine/browser/three/blender-standard';
52
+ import type { EditorCommandResult } from '@volter/editor-sdk/commands';
53
+ import { editorHost } from '@volter/editor-sdk/host';
54
+ import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
55
+ import {
56
+ captureSceneImage,
57
+ captureSceneLinear,
58
+ type LinearCaptureFrame,
59
+ } from '@volter/editor-threejs/capture/scene';
60
+ import { fitClipPlanes } from '@volter/editor-threejs/viewport/clip-planes';
61
+ import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
62
+ import * as THREE from 'three';
63
+ import {
64
+ type NodeViewState,
65
+ nodeViewState,
66
+ refuseNodeViewGesture,
67
+ requestNodeViewAll,
68
+ setNodeViewState,
69
+ } from '../src/node-view-state';
70
+
71
+ /** Pinned OCIO display tables, loaded once. Missing data is a render failure. */
72
+ const displayTables = new Map<string, Promise<Uint16Array>>();
73
+ function displayTable(file: string): Promise<Uint16Array> {
74
+ let pending = displayTables.get(file);
75
+ if (!pending) {
76
+ pending = fetch(displayTableUrl(file)).then(async (response) => {
77
+ if (!response.ok)
78
+ throw new Error(`Blender display table ${file}: ${response.status} ${response.statusText}`);
79
+ return new Uint16Array(await response.arrayBuffer());
80
+ });
81
+ displayTables.set(file, pending);
82
+ }
83
+ return pending;
84
+ }
85
+
86
+ /** Raw bytes of a base64 payload, as the half-float words a linear frame is. */
87
+ function halfFloatFrame(base64: string): Uint16Array {
88
+ const binary = atob(base64);
89
+ const bytes = new Uint8Array(binary.length);
90
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
91
+ return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
92
+ }
93
+
94
+ /** Resolve a scene-linear frame through Blender's pinned display transform.
95
+ *
96
+ * The frame is the session's own capture, or — for a COMPOSITED render — the
97
+ * one `linearInput` carries, because the compositor's output is scene-referred
98
+ * and the view transform runs over it, exactly as `pipeline.cc` orders the two.
99
+ */
100
+ async function displayPhotograph(
101
+ linearFrame: LinearCaptureFrame | null,
102
+ render: {
103
+ width: number;
104
+ height: number;
105
+ exposure: number;
106
+ toneMapping: string;
107
+ look?: string;
108
+ linear?: boolean;
109
+ transparent?: boolean;
110
+ linearInput?: { base64: string; width: number; height: number };
111
+ },
112
+ ): Promise<{ base64: string; mimeType: string }> {
113
+ const look = render.look ?? 'None';
114
+ const filmic = render.toneMapping === 'filmic';
115
+ const standard = render.toneMapping === 'none';
116
+ if (!standard && render.toneMapping !== 'agx' && !filmic)
117
+ // `neutral` is three's own curve and has no scene-linear implementation
118
+ // here — refused BY NAME rather than answered with a different transform.
119
+ throw new Error(
120
+ `Blender's Khronos PBR Neutral view transform has no scene-linear implementation in the ` +
121
+ `browser, so it cannot resolve a composited or scene-referred frame (implemented: ` +
122
+ `Standard, AgX, Filmic)`,
123
+ );
124
+ // Standard is a curve, not a table (`blender-standard.ts`); the other two are
125
+ // the config's own baked LUTs.
126
+ const file = standard
127
+ ? null
128
+ : filmic
129
+ ? 'filmic-srgb.lut'
130
+ : look === 'None'
131
+ ? 'agx-base-srgb.lut'
132
+ : AGX_LOOK_TABLES[look as keyof typeof AGX_LOOK_TABLES];
133
+ if (file === undefined)
134
+ throw new Error(`Blender display transform has no table for look ${look}`);
135
+ const lut = file ? await displayTable(file) : null;
136
+ const provided = render.linearInput;
137
+ const linear = provided
138
+ ? {
139
+ pixels: halfFloatFrame(provided.base64),
140
+ width: provided.width,
141
+ height: provided.height,
142
+ }
143
+ : linearFrame;
144
+ if (!linear) throw new Error('Blender display transform requires a linear capture');
145
+ const { pixels, width, height } = linear;
146
+ const bytes = standard
147
+ ? standardEncodeFrame(pixels, width * height, render.exposure)
148
+ : filmic
149
+ ? filmicEncodeFrame(lut!, pixels, width * height, render.exposure)
150
+ : agxEncodeFrame(lut!, pixels, width * height, {
151
+ exposure: render.exposure,
152
+ composedLook: look !== 'None',
153
+ });
154
+ const canvas = document.createElement('canvas');
155
+ canvas.width = width;
156
+ canvas.height = height;
157
+ const context = canvas.getContext('2d');
158
+ if (!context) throw new Error('Blender display transform could not create its image canvas');
159
+ const image = context.createImageData(width, height);
160
+ // GL reads bottom-up; a PNG wants the first row first.
161
+ const stride = width * 4;
162
+ for (let y = 0; y < height; y++) {
163
+ const source = (height - 1 - y) * stride;
164
+ image.data.set(bytes.subarray(source, source + stride), y * stride);
165
+ }
166
+ context.putImageData(image, 0, 0);
167
+ const dataUrl = canvas.toDataURL('image/png');
168
+ const answer: {
169
+ base64: string;
170
+ mimeType: string;
171
+ linearBase64?: string;
172
+ linearWidth?: number;
173
+ linearHeight?: number;
174
+ } = { base64: dataUrl.slice(dataUrl.indexOf(',') + 1), mimeType: 'image/png' };
175
+ // THE SCENE-REFERRED FRAME RIDES ALONG when the caller asked for it, which is
176
+ // what an EXR is written from. It is the SAME capture, not a second render:
177
+ // a file promising scene radiance must hold the values the scene had, and
178
+ // these are the only ones that ever existed.
179
+ if (render.linear === true) {
180
+ const raw = new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength);
181
+ let binary = '';
182
+ for (let i = 0; i < raw.length; i += 0x8000)
183
+ binary += String.fromCharCode(...raw.subarray(i, i + 0x8000));
184
+ answer.linearBase64 = btoa(binary);
185
+ answer.linearWidth = width;
186
+ answer.linearHeight = height;
187
+ }
188
+ return answer;
189
+ }
190
+
191
+ export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
192
+
193
+ /**
194
+ * THE MODEL DOCUMENT THIS TAB IS PRESENTING INTO, and the `.blend` it opened.
195
+ *
196
+ * A `model` entry's document is bound to the project's own `.blend`
197
+ * (`models.finder.ts` lists them; `blender-runtime.document.tsx` opens the one
198
+ * its entry names), so the presenter's address is no longer a constant. A
199
+ * project with no `.blend` of its own still gets ONE Model document at the
200
+ * standing `blender:runtime` address — that is what `blender-start` presents
201
+ * into and what the battery photographs — and then this stays null and every
202
+ * read falls back to the constant above.
203
+ *
204
+ * It is a module variable rather than a host door because both sides are THIS
205
+ * package: the document that binds it and the session that reads it ship
206
+ * together, so there is nothing for the SDK to carry.
207
+ */
208
+ let boundModel: { readonly documentId: string; readonly entryId: string; readonly blend: string } | null = null;
209
+
210
+ /** Called by the Model document when it mounts (and with `null` when it
211
+ * unmounts): the document id the host published its context under, and the
212
+ * project-relative `.blend` the engine should have open. */
213
+ export function bindModelDocument(bound: { documentId: string; entryId: string; blend: string } | null): void {
214
+ boundModel = bound;
215
+ }
216
+
217
+ /** The document id a present must reach: the open Model document's, or the
218
+ * standing address. */
219
+ function presentationDocumentId(): string {
220
+ return boundModel?.documentId ?? BLENDER_RUNTIME_DOCUMENT_ID;
221
+ }
222
+
223
+ /** The same id, for this package's own Properties sections: they read the
224
+ * published `BlenderRuntimeView` through `documents.context` exactly as the
225
+ * presenter does, so both sides name the document one way. */
226
+ export function blenderPresentationDocumentId(): string {
227
+ return presentationDocumentId();
228
+ }
229
+
230
+ /**
231
+ * OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
232
+ * path run backwards. `session.py` opens the named file at start and saves
233
+ * back to it, so naming it here IS opening it.
234
+ *
235
+ * Idempotent and quiet: the runtime binds one project and one document for the
236
+ * tab's lifetime, so a second Model document mounting over a live session
237
+ * simply presents what that session holds. A project with no open session has
238
+ * no engine to open anything in, and says nothing.
239
+ */
240
+ export async function openModelDocumentBlend(documentId: string, blend: string, entryId: string): Promise<void> {
241
+ bindModelDocument({ documentId, entryId, blend });
242
+ const host = editorHost();
243
+ if (!host.session.open()) return;
244
+ const project = host.projectLocalState.projectRootPath();
245
+ if (project === null) return;
246
+ await blenderRuntime().start(project, blend);
247
+ // AND SHOW WHAT IT OPENED. `start` binds the document and loads the file; it
248
+ // does not present, because presenting is what a MUTATION does
249
+ // (`session.py::dispatch`). So until this line a freshly opened Model
250
+ // document displayed nothing until an execute happened to run — I1 measured
251
+ // it, and "open a model, see the model" is the document's own request rather
252
+ // than a side effect to hope for.
253
+ await blenderRuntime().present();
254
+ }
255
+
256
+ /**
257
+ * THE RNA DOOR, IN-PAGE. The Properties sections
258
+ * (`../contributions/blender-properties-*`) call these three rather than the
259
+ * `blender-rna*` commands: same session, same `session.py` functions, one
260
+ * fewer hop, and no `EditorCommandResult` envelope to unwrap in a render.
261
+ * The COMMANDS remain the wire's door onto the same calls, so a `vgai eval`
262
+ * and the panel read one thing.
263
+ *
264
+ * THEY NEVER START THE ENGINE. A panel asking what the engine holds must not
265
+ * be the reason a 880 MB Blender boots, so these answer `null` when this tab
266
+ * has no started session — exactly the read `blender-status` makes, and for
267
+ * the same reason.
268
+ */
269
+ export function blenderSessionStarted(): boolean {
270
+ return runtime?.project != null;
271
+ }
272
+
273
+ /**
274
+ * THE DOCUMENT'S RNA VERSION — "a tree's freshness is the tree's, not the
275
+ * presented frame's" (orchestrator ruling 3, 2026-09-19, WORK.md §Blender in
276
+ * the tab is Blender, I5).
277
+ *
278
+ * Every view over this engine draws something RNA answers, and until this
279
+ * existed the only "the engine moved" signal in the page was a PRESENTED
280
+ * FRAME: `blender-properties-model.ts` subscribes to the Model document's
281
+ * frames, and its version was what the node view re-read on. MEASURED
282
+ * 2026-09-19 by I5's socket-panel step: collapsing a panel through
283
+ * `blender-rna-set` changes `Node.panel_states[n].is_collapsed`, which changes
284
+ * nothing the presenter draws — so no frame ships, no version moves, and the
285
+ * node view kept drawing the old tree until the document was reopened. It is
286
+ * the same shape as I3's "a present is not a reliable signal for a column"
287
+ * (`hide_render`), and the answer is the same: the WRITE asks for the re-read.
288
+ *
289
+ * So the version is minted HERE, at the door every RNA write goes through,
290
+ * and it is bumped by the write rather than by the picture. Both halves of
291
+ * each door bump it once: the in-page function is what the wire's
292
+ * `blender-rna-set` / `blender-outliner-set` case calls, so a `vgai eval` and
293
+ * a panel click are one path. `blender-execute` bumps it too — arbitrary bpy
294
+ * can change anything RNA answers, and a view that went stale under a probe's
295
+ * own script would be the same defect one layer out.
296
+ *
297
+ * WHAT SUBSCRIBES: `blender-properties-model.ts` (which invalidates its cached
298
+ * datablock views and re-reads the context, exactly as it does on a frame),
299
+ * and every view in `@volter/editor-blender/contributions` that draws RNA.
300
+ */
301
+ let rnaVersion = 0;
302
+ const rnaListeners = new Set<() => void>();
303
+
304
+ export function blenderRnaVersion(): number {
305
+ return rnaVersion;
306
+ }
307
+
308
+ export function subscribeBlenderRna(listener: () => void): () => void {
309
+ rnaListeners.add(listener);
310
+ return () => {
311
+ rnaListeners.delete(listener);
312
+ };
313
+ }
314
+
315
+ /** The engine's RNA moved. Called by the write doors below, and by the
316
+ * properties model when a PRESENTED FRAME says the engine ran — one version
317
+ * for both, because a view cannot tell the two apart and should not have to. */
318
+ export function noteBlenderRnaChanged(): void {
319
+ rnaVersion += 1;
320
+ for (const listener of [...rnaListeners]) listener();
321
+ }
322
+
323
+ export async function blenderRna(path: string, names?: number): Promise<BlenderRnaView | null> {
324
+ if (!blenderSessionStarted()) return null;
325
+ return blenderRuntime().rna(path, names);
326
+ }
327
+
328
+ export async function blenderRnaContext(
329
+ object?: string,
330
+ collection?: string,
331
+ ): Promise<BlenderRnaContext | null> {
332
+ if (!blenderSessionStarted()) return null;
333
+ return blenderRuntime().rnaContext(object, collection);
334
+ }
335
+
336
+ export async function blenderRnaSet(
337
+ path: string,
338
+ property: string,
339
+ value: unknown,
340
+ index?: number,
341
+ ): Promise<BlenderRnaWrite | null> {
342
+ if (!blenderSessionStarted()) return null;
343
+ const written = await blenderRuntime().rnaSet(path, property, value, index);
344
+ noteBlenderRnaChanged();
345
+ return written;
346
+ }
347
+
348
+ /**
349
+ * THE SCRIPT DOOR, in-page — one bpy script, run the way the MCP add-on runs
350
+ * one (`session.py::execute`: a namespace of its own, output captured, and a
351
+ * PRESENT after it, so the picture that comes back is the engine's own
352
+ * reading).
353
+ *
354
+ * This is what an OPERATOR goes through. The Outliner's structural verbs —
355
+ * add, delete, duplicate — are `bpy.ops.*` calls and nothing else: Blender
356
+ * runs the operator and we only name it (`blender-outliner-authoring.ts`'s
357
+ * `StructureProvider`). Writing a second implementation of `object.delete`'s
358
+ * unparenting, or of `primitive_uv_sphere_add`'s defaults, is the thing this
359
+ * door exists to make unnecessary.
360
+ *
361
+ * SAME SHAPE AS {@link blenderRnaSet}, and for its reason: the RNA version is
362
+ * minted at the door, so the wire's `blender-execute` case calls THIS rather
363
+ * than `session.execute` beside it — arbitrary bpy can change anything RNA
364
+ * answers, and a view left drawing the tree it had is the defect the version
365
+ * exists for. Like every door here it never starts the engine.
366
+ *
367
+ * The answer is `session.py::execute`'s own record — `executed`, the captured
368
+ * `result` text, and `error` when the script raised. A REFUSAL IS NOT A THROW:
369
+ * Blender's traceback comes back in `error`, verbatim, and a caller that wants
370
+ * it to be an exception raises its own (the structure provider does, so the
371
+ * ack it hands the shell carries Blender's sentence).
372
+ *
373
+ * IT DOES NOT GUARD ON `blenderSessionStarted()`, and that is the one way it
374
+ * differs from the read doors above. They are called from a panel's render and
375
+ * must answer "nothing to show" rather than spawn a session; a script is always
376
+ * a deliberate act, and the engine's own refusal — "The Blender session has not
377
+ * been started with a project (blender-start)" — is the honest answer to one
378
+ * made too early. A silent `null` there would have turned the wire's own loud
379
+ * refusal into an empty result.
380
+ *
381
+ * WHAT THE SESSION ACTUALLY ANSWERS WITH IS THE MCP DOOR'S TEXT, not
382
+ * `session.py::execute`'s `{executed, result, error}` record: the worker
383
+ * flattens it into ONE STRING on the way out — `Code executed successfully:
384
+ * <stdout>` or `Error executing code: <traceback>` (`worker.ts:250-260`, "a
385
+ * script's failure is TEXT, not a rejection"), because that is what the MCP
386
+ * tool returns to an agent. So this parses it, once, here. MEASURED 2026-09-21:
387
+ * a first version read `answer.executed`/`answer.error` off the string, where
388
+ * both are `undefined` — every operator RAN and every caller was told it had
389
+ * failed, which is how an added sphere reached `bpy.data.objects` and the
390
+ * Outliner and was never selected. `sessionDocumentPath` below parses the same
391
+ * two prefixes and records the same lesson from its own bug; this is the
392
+ * second time, so the parse lives at the door now and both read it.
393
+ */
394
+ export async function blenderExecute(code: string): Promise<BlenderExecuteAnswer> {
395
+ const text = await blenderRuntime().execute(code);
396
+ noteBlenderRnaChanged();
397
+ const failed = /^Error executing code:/.exec(text);
398
+ return {
399
+ text,
400
+ executed: failed === null,
401
+ result: failed === null ? text.replace(/^Code executed successfully: ?/, '') : '',
402
+ error: failed === null ? null : text.replace(/^Error executing code: ?/, ''),
403
+ };
404
+ }
405
+
406
+ /** One script's answer, with the MCP door's text split from what it means.
407
+ * `text` is what the wire and an MCP client get, verbatim; the other three are
408
+ * what a UI caller needs, and the `error` half is the one it must not drop. */
409
+ export interface BlenderExecuteAnswer {
410
+ /** The door's own line, as `worker.ts` composed it. */
411
+ readonly text: string;
412
+ readonly executed: boolean;
413
+ /** The script's captured stdout, with the success prefix removed. */
414
+ readonly result: string;
415
+ /** Blender's own traceback line, or null. */
416
+ readonly error: string | null;
417
+ }
418
+
419
+ /** THE TREE DOOR, in-page — Blender's View Layer tree, which the Model
420
+ * document's hierarchy provider draws (`../contributions/blender-outliner-*`).
421
+ * Same rule as the three above: it never starts the engine. */
422
+ export async function blenderOutliner(
423
+ selected?: readonly string[],
424
+ ): Promise<BlenderOutlinerTree | null> {
425
+ if (!blenderSessionStarted()) return null;
426
+ return blenderRuntime().outliner(selected);
427
+ }
428
+
429
+ /** THE NODE-TREE DOOR, in-page — one material's shader node tree, which the
430
+ * Shading workspace's node view draws (`../contributions/blender-node-*`).
431
+ * Same rule as the doors above: it never starts the engine. */
432
+ export async function blenderNodeTree(options?: {
433
+ path?: string;
434
+ material?: string;
435
+ }): Promise<BlenderNodeTree | null> {
436
+ if (!blenderSessionStarted()) return null;
437
+ return blenderRuntime().nodeTree(options);
438
+ }
439
+
440
+ /** THE UV DOOR, in-page — one mesh's UV layout, which the UV Editing
441
+ * workspace's view draws (`../contributions/blender-uv-*`). Same rule as the
442
+ * doors above: it never starts the engine. */
443
+ export async function blenderUvLayout(options?: {
444
+ object?: string;
445
+ uvLayer?: string;
446
+ }): Promise<BlenderUvLayout | null> {
447
+ if (!blenderSessionStarted()) return null;
448
+ return blenderRuntime().uvLayout(options);
449
+ }
450
+
451
+ /** THE RIG DOOR, in-page — one mesh's skin binding, which the presenter turns
452
+ * into a `THREE.SkinnedMesh` (`../contributions/blender-runtime-skin.ts`).
453
+ * WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20): this is
454
+ * called once per rig, never per played frame. Same rule as the doors above:
455
+ * it never starts the engine. */
456
+ export async function blenderRig(options?: { object?: string }): Promise<BlenderRig | null> {
457
+ if (!blenderSessionStarted()) return null;
458
+ return blenderRuntime().rig(options);
459
+ }
460
+
461
+ /** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
462
+ * scene's frame range and the Timeline's summary columns. Called when the
463
+ * action's own revision moves, never per played frame. */
464
+ export async function blenderActionClip(options?: {
465
+ object?: string;
466
+ bake?: boolean;
467
+ }): Promise<BlenderActionClip | null> {
468
+ if (!blenderSessionStarted()) return null;
469
+ return blenderRuntime().actionClip(options);
470
+ }
471
+
472
+ export async function blenderOutlinerSet(
473
+ path: string,
474
+ column: string,
475
+ value: boolean,
476
+ ): Promise<BlenderOutlinerWrite | null> {
477
+ if (!blenderSessionStarted()) return null;
478
+ const written = await blenderRuntime().outlinerSet(path, column, value);
479
+ noteBlenderRnaChanged();
480
+ return written;
481
+ }
482
+
483
+ /**
484
+ * THE NODE EDITOR'S VERBS, published ONCE and reached two ways (U8's ruling 1,
485
+ * 2026-09-19). Under the Code-OSS frame each is a `vgai.blender-node-view.<verb>`
486
+ * command the bridge dispatches into the view; standalone `vgai edit`, which has
487
+ * no command service, reaches the SAME table through the session's
488
+ * `blender-node-view` verb below. One table, two doors — the shape
489
+ * `key-actions.ts`'s action table already has, and the reason the next
490
+ * three read-only editors (UV Editing, Animation, Texture Paint) add NO session
491
+ * verb of their own: they register here instead.
492
+ *
493
+ * `state` reads it; `look` follows a node with the N-panel (`node`, null to look
494
+ * at none); `view-all` is Blender's Home; `zoom` takes tree units per CSS px;
495
+ * `pan` takes the tree-space point at the view's centre (`cx`/`cy`). Every
496
+ * EDITING gesture is present and REFUSED by name with the same sentence the
497
+ * pointer handlers give, so the read-only ruling is provable from the product
498
+ * rather than merely implemented.
499
+ */
500
+ export const NODE_VIEW_VERBS: ViewVerbContribution = {
501
+ view: 'blender-node-view',
502
+ title: 'Node Editor',
503
+ verbs: (
504
+ [
505
+ ['state', undefined],
506
+ ['look', undefined],
507
+ ['view-all', 'Node Editor: Frame All'],
508
+ ['zoom', undefined],
509
+ ['pan', undefined],
510
+ ['move', undefined],
511
+ ['link', undefined],
512
+ ['set-value', undefined],
513
+ ['use-nodes', undefined],
514
+ ['collapse-panel', undefined],
515
+ ] as const
516
+ ).map(([id, title]) => ({
517
+ id,
518
+ ...(title ? { title } : {}),
519
+ run: (args?: Record<string, unknown>) =>
520
+ driveNodeView({ type: 'blender-node-view', ...args, action: id }),
521
+ })),
522
+ };
523
+
524
+ /**
525
+ * `blender-node-view` — the node editor's actions and its state, as one verb.
526
+ *
527
+ * `action` is `state` (the default — read it), `look` (`node`: which node the
528
+ * N-panel follows, null to look at none), `view-all` (Blender's Home), `zoom`
529
+ * (`zoom`: tree units per CSS px) or `pan` (`cx`/`cy`: the tree-space point at
530
+ * the view's centre). Anything else is refused BY NAME, and a gesture that
531
+ * would EDIT is refused with the same sentence the pointer handlers give.
532
+ */
533
+ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewState {
534
+ const action = typeof cmd['action'] === 'string' ? cmd['action'] : 'state';
535
+ switch (action) {
536
+ case 'state':
537
+ break;
538
+ case 'look':
539
+ setNodeViewState({
540
+ looked: typeof cmd['node'] === 'string' ? cmd['node'] : null,
541
+ refusal: null,
542
+ });
543
+ break;
544
+ case 'view-all':
545
+ requestNodeViewAll();
546
+ // Framing clears a refusal the way every other non-editing action does.
547
+ // Measured live 2026-09-19: without this, a refusal outlived the gesture
548
+ // that raised it and the status line still carried it three actions
549
+ // later.
550
+ setNodeViewState({ refusal: null });
551
+ break;
552
+ case 'zoom': {
553
+ const zoom = typeof cmd['zoom'] === 'number' ? cmd['zoom'] : 1;
554
+ setNodeViewState({ transform: { ...nodeViewState().transform, zoom }, refusal: null });
555
+ break;
556
+ }
557
+ case 'pan': {
558
+ const current = nodeViewState().transform;
559
+ setNodeViewState({
560
+ transform: {
561
+ ...current,
562
+ cx: typeof cmd['cx'] === 'number' ? cmd['cx'] : current.cx,
563
+ cy: typeof cmd['cy'] === 'number' ? cmd['cy'] : current.cy,
564
+ },
565
+ refusal: null,
566
+ });
567
+ break;
568
+ }
569
+ // EVERY EDITING GESTURE, ANSWERED BY NAME. These are the same four
570
+ // refusals the pointer handlers raise, reachable from the session so the
571
+ // ruling is provable rather than merely implemented.
572
+ case 'move':
573
+ refuseNodeViewGesture(
574
+ `Moving "${String(cmd['node'] ?? 'a node')}" writes Node.location — editing parity is not the program.`,
575
+ );
576
+ break;
577
+ case 'link':
578
+ refuseNodeViewGesture(
579
+ 'Dragging between sockets would make a link — editing parity is not the program.',
580
+ );
581
+ break;
582
+ case 'set-value':
583
+ refuseNodeViewGesture(
584
+ `Changing "${String(cmd['socket'] ?? 'a socket')}" writes the socket's default_value — editing parity is not the program.`,
585
+ );
586
+ break;
587
+ case 'use-nodes':
588
+ refuseNodeViewGesture(
589
+ 'Use Nodes writes Material.use_nodes — editing parity is not the program.',
590
+ );
591
+ break;
592
+ // A SOCKET PANEL'S HEADER. Blender's own callback is
593
+ // `panel_state->flag ^= NODE_PANEL_COLLAPSED` followed by
594
+ // `BKE_main_ensure_invariants` (`node_draw.cc:1903-1911`) — a write to the
595
+ // node tree the document would save, so the traced panels are DRAWN in the
596
+ // state the engine reports and never toggled from here.
597
+ case 'collapse-panel':
598
+ refuseNodeViewGesture(
599
+ `Collapsing "${String(cmd['panel'] ?? 'a panel')}" writes the panel's is_collapsed — editing parity is not the program.`,
600
+ );
601
+ break;
602
+ default:
603
+ refuseNodeViewGesture(
604
+ `blender-node-view: no action "${action}" — state, look, view-all, zoom, pan, and the refusals move, link, set-value, use-nodes, collapse-panel.`,
605
+ );
606
+ }
607
+ return nodeViewState();
608
+ }
609
+
610
+ interface RuntimeView {
611
+ applyFrame(frame: unknown): unknown;
612
+ snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
613
+ /** Own a detached revision for render lighting, never the interactive view. */
614
+ captureSnapshot(): ReturnType<BlenderRuntimeView['captureSnapshot']>;
615
+ /** Keep the description of the frame just applied, and the two poses of every
616
+ * render photographed from it (`blender-runtime-view.ts`). Both are what an
617
+ * outside grader reads through the document's own REPL; neither is read by
618
+ * the product. A document published against this id that does not answer
619
+ * them is refused by name below rather than silently recording nothing. */
620
+ recordPresentation(description: unknown): void;
621
+ recordPhotograph(record: {
622
+ sent: { position: number[]; target: number[]; up: number[] };
623
+ photographed: { position: number[]; target: number[]; up: number[] };
624
+ render: { width: number; height: number; fov: number; orthographic: boolean };
625
+ }): void;
626
+ }
627
+
628
+ let runtime: BlenderRuntime | null = null;
629
+ let captureLifetime: AbortController | null = null;
630
+ /** Whether this module has asked the host to tell it when the session ends.
631
+ * Once per page, taken on the first runtime — before one there is nothing to
632
+ * terminate, and the host door only exists inside a running editor. */
633
+ let watchingSessionEnd = false;
634
+
635
+ function terminateBlenderRuntime(): void {
636
+ captureLifetime?.abort();
637
+ captureLifetime = null;
638
+ runtime?.terminate();
639
+ runtime = null;
640
+ lastCapture = null;
641
+ }
642
+
643
+ // THE ENGINE DIES WITH THE SESSION, whether or not the tab can. A page told
644
+ // `tab-close` calls `window.close()`, which Chrome refuses for a tab a person
645
+ // opened, and falls back to a "Session ended" notice -- a repaint of the
646
+ // document that terminates nothing. The Blender worker kept its whole engine
647
+ // resident behind that notice: measured 2026-09-17, two orphaned tabs from
648
+ // two editor restarts held 13 GB and 7 GB between them and put the box into a
649
+ // swap storm (26 GB of swap, kernel_task at 245%). `session.onEnded` is the
650
+ // host's one signal for every session end, graceful or not, so the worker is
651
+ // terminated there.
652
+ function watchSessionEnd(): void {
653
+ if (watchingSessionEnd) return;
654
+ watchingSessionEnd = true;
655
+ editorHost().session.onEnded(terminateBlenderRuntime);
656
+ }
657
+ let lastCapture: CaptureRequest | null = null;
658
+
659
+ const isRuntimeView = (value: unknown): value is RuntimeView =>
660
+ typeof value === 'object' &&
661
+ value !== null &&
662
+ typeof (value as RuntimeView).applyFrame === 'function' &&
663
+ typeof (value as RuntimeView).snapshot === 'function' &&
664
+ typeof (value as RuntimeView).captureSnapshot === 'function' &&
665
+ typeof (value as RuntimeView).recordPresentation === 'function' &&
666
+ typeof (value as RuntimeView).recordPhotograph === 'function';
667
+
668
+ async function runtimeView(): Promise<RuntimeView> {
669
+ const { documents } = editorHost();
670
+ const published = await documents.waitForContext(presentationDocumentId(), 15_000);
671
+ if (isRuntimeView(published)) return published;
672
+ throw new Error(
673
+ 'The Blender Model document is not open, or the open one is not a Model this engine can present ' +
674
+ 'to (it must answer applyFrame, captureSnapshot, recordPresentation and recordPhotograph): present ' +
675
+ '{kind: "document", id: "blender:runtime"} ' +
676
+ 'first (it ships with the editor; `vgai blender-mcp` presents it before its first call).',
677
+ );
678
+ }
679
+
680
+ export function blenderRuntime(): BlenderRuntime {
681
+ if (runtime !== null) return runtime;
682
+ watchSessionEnd();
683
+ const lifetime = new AbortController();
684
+ captureLifetime = lifetime;
685
+ let photographing = false;
686
+ runtime = new BlenderRuntime({
687
+ present: async (frame, description, capture) => {
688
+ const documentId = presentationDocumentId();
689
+ const view = await runtimeView();
690
+ lifetime.signal.throwIfAborted();
691
+ if (presentationDocumentId() !== documentId)
692
+ throw new Error('Blender document changed before its frame could be presented');
693
+ // WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
694
+ // beside whatever this present produced. The session only has a RECORD of
695
+ // what it sent; this view is the authority on what it actually holds, and
696
+ // the two come apart whenever the Model document is rebuilt under a
697
+ // still-running worker (`blender-runtime-view.ts::applyFrame` says how).
698
+ // A document that answers `applyFrame` without one reports no `held` at
699
+ // all, rather than a wrong `null`.
700
+ const applied = view.applyFrame(frame) as { held?: unknown } | null | undefined;
701
+ const reports = typeof applied === 'object' && applied !== null && 'held' in applied;
702
+ const held = reports ? (applied.held as { session: string; revision: number } | null) : null;
703
+ const answer = (capture: unknown): PresentAnswer => ({
704
+ ...(capture === undefined ? {} : { capture }),
705
+ ...(reports ? { held } : {}),
706
+ });
707
+ // THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
708
+ // a refused frame is not displayed and must not be described as if it
709
+ // were.
710
+ view.recordPresentation(description);
711
+ if (!capture) return answer(undefined);
712
+ lastCapture = capture;
713
+ if (!capture.render) return answer(undefined);
714
+ const presented = view.snapshot();
715
+ // A COMPOSITED FRAME IS NOT PHOTOGRAPHED. The compositor already
716
+ // produced the scene-referred pixels, so nothing here frames, lights or
717
+ // renders anything: the view transform runs over the values handed in
718
+ // and answers with the PNG. Everything below is the photograph path and
719
+ // would only re-render a frame that is about to be thrown away.
720
+ const assertBinding = () => {
721
+ lifetime.signal.throwIfAborted();
722
+ if (presentationDocumentId() !== documentId || view.snapshot() !== presented)
723
+ throw new Error('Blender capture document or source revision changed before completion');
724
+ };
725
+ if (capture.render.linearInput) {
726
+ const display = await displayPhotograph(null, capture.render);
727
+ assertBinding();
728
+ return answer(display);
729
+ }
730
+ // THE PHOTOGRAPH HAS ITS OWN CAMERA, and the modeling viewport is never
731
+ // touched by a render. Blender ships the scene camera's full pose in
732
+ // BLENDER'S frame (Z-up, `session.py::_photograph`); every mesh hangs
733
+ // under the Model root, whose world matrix carries the Z-up -> Y-up
734
+ // permutation (`blender-runtime-view.ts`). So the pose is transformed
735
+ // through THAT matrix -- copied into the detached snapshot, never written out
736
+ // here as a quarter turn -- and a fresh camera is placed on it.
737
+ //
738
+ // MEASURED 2026-09-18, and the reason this code exists: handing the raw
739
+ // Blender numbers to `presentEditorView` read them as three.js world
740
+ // space, so a camera at Blender (0, -3, 0.42) looking +Y photographed the
741
+ // model's underside from 3 m below the floor, and a straight-down camera
742
+ // produced a level side view. Every hero image the battery ever graded
743
+ // came from a misplaced camera. The old path also moved the VIEWPORT onto
744
+ // the render camera and moved it back; a render that never touches the
745
+ // viewport needs no such dance, so it is gone rather than made safe.
746
+ const { render, position, target, up } = capture;
747
+ if (!position || !target || !up)
748
+ throw new Error(
749
+ 'A Blender render capture must carry the scene camera position, target and up',
750
+ );
751
+ if (photographing) throw new Error('A Blender render capture is already in progress');
752
+ const snapshot = view.captureSnapshot();
753
+ photographing = true;
754
+ lifetime.signal.addEventListener('abort', snapshot.dispose, { once: true });
755
+ try {
756
+ assertBinding();
757
+ const scene = new THREE.Scene();
758
+ scene.add(snapshot.root);
759
+ scene.updateMatrixWorld(true);
760
+ const toDocument = snapshot.root.matrixWorld;
761
+ const toBlender = new THREE.Matrix4().copy(toDocument).invert();
762
+ // Points move with the full matrix; `up` is a DIRECTION and must not pick
763
+ // up the root's translation.
764
+ const documentBasis = new THREE.Matrix3().setFromMatrix4(toDocument);
765
+ const blenderBasis = new THREE.Matrix3().setFromMatrix4(toBlender);
766
+ const eye = new THREE.Vector3(position[0], position[1], position[2]).applyMatrix4(
767
+ toDocument,
768
+ );
769
+ const focus = new THREE.Vector3(target[0], target[1], target[2]).applyMatrix4(toDocument);
770
+ const upward = new THREE.Vector3(up[0], up[1], up[2]).applyMatrix3(documentBasis);
771
+ const aspect =
772
+ Math.min(2048, Math.round(render.width)) / Math.min(2048, Math.round(render.height));
773
+ // Fit clipping to this snapshot and photograph's eye, independently of
774
+ // the user's navigation. Editor furniture never enters this scene.
775
+ const bounds = contentWorldBounds(snapshot.root).getBoundingSphere(new THREE.Sphere());
776
+ const documentView = fitClipPlanes(eye.distanceTo(bounds.center), bounds.radius);
777
+ let renderCamera: THREE.Camera;
778
+ if (render.orthographic) {
779
+ // An ORTHO scene camera. `fov` carries `ortho_scale` as the VERTICAL
780
+ // extent in Blender units (`_vertical_extent` applies `sensor_fit`), so
781
+ // the frustum is stated outright instead of being reverse-derived from
782
+ // a perspective camera's distance and angle.
783
+ const halfHeight = render.fov / 2;
784
+ renderCamera = new THREE.OrthographicCamera(
785
+ -halfHeight * aspect,
786
+ halfHeight * aspect,
787
+ halfHeight,
788
+ -halfHeight,
789
+ documentView.near,
790
+ documentView.far,
791
+ );
792
+ } else {
793
+ renderCamera = new THREE.PerspectiveCamera(
794
+ render.fov,
795
+ aspect,
796
+ documentView.near,
797
+ documentView.far,
798
+ );
799
+ }
800
+ renderCamera.up.copy(upward);
801
+ renderCamera.position.copy(eye);
802
+ renderCamera.lookAt(focus);
803
+ renderCamera.updateMatrixWorld(true);
804
+ // THE POSE THIS PHOTOGRAPH USED, BACK IN BLENDER'S FRAME. Python asserts
805
+ // it equals what it sent, with no tolerance: the model root's matrix is an
806
+ // exact signed axis permutation, so a vector through it and its inverse is
807
+ // bit-identical. A mismatch means the conversion above is wrong, and the
808
+ // whole point of this instrument is that a misplaced render camera is
809
+ // otherwise invisible -- photograph-vs-Cycles is a declared difference, so
810
+ // the battery's image rows hid it for the entire life of this lane.
811
+ const photographedFrom = {
812
+ position: eye.clone().applyMatrix4(toBlender).toArray(),
813
+ target: focus.clone().applyMatrix4(toBlender).toArray(),
814
+ up: upward.clone().applyMatrix3(blenderBasis).toArray(),
815
+ };
816
+ // RECORDED BEFORE THE PICTURE IS TAKEN, and before Python compares the
817
+ // two poses. The assertion on the other side THROWS on a mismatch, and
818
+ // an exception carries the defect out of reach of a harness grading the
819
+ // run afterwards; both poses have to survive it.
820
+ view.recordPhotograph({
821
+ sent: { position, target, up },
822
+ photographed: photographedFrom,
823
+ render: {
824
+ width: render.width,
825
+ height: render.height,
826
+ fov: render.fov,
827
+ orthographic: render.orthographic,
828
+ },
829
+ });
830
+ // THE SCENE'S OWN VIEW TRANSFORM, for the duration of the photograph.
831
+ // Blender states one (`view_settings.view_transform`, AgX by default) and
832
+ // three.js has a curve of the same name, so the render is tone mapped the
833
+ // way the scene asks rather than the way the modeling viewport prefers —
834
+ // without this, every lit pixel differs by the gap between two unrelated
835
+ // curves and no difference in the image can be attributed to anything
836
+ // else. Python refuses a transform with no curve here, so the map is total.
837
+ const mappings: Record<string, THREE.ToneMapping> = {
838
+ none: THREE.NoToneMapping,
839
+ agx: THREE.AgXToneMapping,
840
+ neutral: THREE.NeutralToneMapping,
841
+ filmic: THREE.NoToneMapping,
842
+ };
843
+ await snapshot.prepare(renderCamera);
844
+ assertBinding();
845
+ const captureOptions = {
846
+ width: render.width,
847
+ height: render.height,
848
+ transparent: render.transparent === true,
849
+ toneMapping: mappings[render.toneMapping] ?? THREE.AgXToneMapping,
850
+ exposure: render.exposure,
851
+ };
852
+ // BLENDER'S OWN VIEW TRANSFORM, off the linear frame. three's AgX is
853
+ // Filament's approximation and lands 23 to 48 levels of 255 away from
854
+ // Blender (42 at middle grey); the config's actual transform is a 57^3
855
+ // LUT over a log allocation, and a LOOK grades in a log space with two
856
+ // LUT inversions in it. Neither is reachable from resolved bytes, and
857
+ // both are reachable from the half-float target `captureImage` already
858
+ // allocates -- so an AgX render reads that and runs
859
+ // `blender-agx.ts`, which is verified byte-for-byte against OCIO.
860
+ // AND WHENEVER THE SCENE-REFERRED FRAME IS WANTED, whatever the
861
+ // transform: `captureImage` answers with resolved bytes and nothing
862
+ // else, so a Standard render asked for an EXR — or asked to be
863
+ // composited — has no linear frame in it at all. Reading the
864
+ // half-float target is the only path that has one.
865
+ const display =
866
+ render.toneMapping === 'agx' || render.toneMapping === 'filmic' || render.linear === true
867
+ ? await displayPhotograph(
868
+ captureSceneLinear(scene, renderCamera, captureOptions),
869
+ render,
870
+ )
871
+ : null;
872
+ assertBinding();
873
+ if (display) return answer({ ...display, camera: photographedFrom });
874
+ const dataUrl = captureSceneImage(scene, renderCamera, captureOptions);
875
+ if (!dataUrl) throw new Error('Blender render could not capture its image');
876
+ return answer({
877
+ base64: dataUrl.slice(dataUrl.indexOf(',') + 1),
878
+ mimeType: 'image/png',
879
+ camera: photographedFrom,
880
+ });
881
+ } finally {
882
+ photographing = false;
883
+ lifetime.signal.removeEventListener('abort', snapshot.dispose);
884
+ snapshot.dispose();
885
+ }
886
+ },
887
+ // The worker's OWN stdout and stderr, forwarded so a developer can read
888
+ // Python's output where the page's output is. This is the page console on
889
+ // purpose: the editor's console door is for conditions an agent must
890
+ // resolve, and `blender_tools.py` deliberately keeps a failing SCRIPT's
891
+ // traceback out of it.
892
+ // biome-ignore lint/suspicious/noConsole: the Blender worker's log is page output by design.
893
+ log: (level, text) => (level === 'error' ? console.error : console.log)(`[blender] ${text}`),
894
+ });
895
+ const session = runtime;
896
+ // MEASUREMENT, PUBLISHED THE MOMENT THE SESSION EXISTS. The worker cannot say
897
+ // how long it has been stuck — the loop that would send the number is the loop
898
+ // that is stuck — so this side keeps the clock and the host carries it out on
899
+ // the heartbeat, the one channel that still beats through a blocked main
900
+ // thread. `vgai status` prints the block. Published as a READ of the live
901
+ // meter rather than a snapshot, so the host always asks the running session:
902
+ // an in-flight call's age has to be computed at the moment it is reported.
903
+ // The page's own long-task half is the HOST's, behind this same door.
904
+ editorHost().session.reportWorkerCallMeter(() => session.metrics());
905
+ return runtime;
906
+ }
907
+
908
+ /** The view the last `blender-screenshot-view` asked the tab to photograph. */
909
+ export function lastBlenderCapture(): CaptureRequest | null {
910
+ return lastCapture;
911
+ }
912
+
913
+ /**
914
+ * THE SESSION'S DOCUMENT when `blender-start` does not name one.
915
+ *
916
+ * Persistence is ON by default, because the job this lane exists to do is to
917
+ * not lose what a script modelled: before this, closing the tab lost it, with
918
+ * no `.blend` anywhere. `models/` rather than `public/` is what says a
919
+ * document is the SOURCE a shipped artifact is exported from, not the artifact
920
+ * — which is also why it carries no provenance record (`vgai blender-mcp`'s
921
+ * Mirror owns that line, for `public/`).
922
+ *
923
+ * It is the fallback only. A project that declares the `model` finder lists
924
+ * its OWN `.blend` files, and the document opened for one of those binds the
925
+ * session to it ({@link openModelDocumentBlend}).
926
+ */
927
+ const DEFAULT_BLENDER_DOCUMENT = 'models/model.blend';
928
+
929
+ const string = (cmd: Record<string, unknown>, key: string): string => {
930
+ const value = cmd[key];
931
+ if (typeof value !== 'string')
932
+ throw new Error(`${String(cmd['type'])} requires a string "${key}"`);
933
+ return value;
934
+ };
935
+
936
+ /**
937
+ * The project-relative path of the `.blend` the SESSION'S PYTHON currently
938
+ * holds — `bpy.data.filepath`, which a bake script's own `open_mainfile`
939
+ * retargets and the session's bound document does not. Answers `{}` when there
940
+ * is no file (a scene modelled from scratch has never been saved) or when the
941
+ * path is outside the project, because a record is better absent than wrong.
942
+ */
943
+ async function sessionDocumentPath(session: {
944
+ execute(code: string): Promise<string>;
945
+ }): Promise<{ document?: string }> {
946
+ const project = editorHost().projectLocalState.projectRootPath();
947
+ if (project === null) return {};
948
+ let answer: string;
949
+ try {
950
+ answer = await session.execute('import bpy\nprint(bpy.data.filepath)\n');
951
+ } catch {
952
+ return {};
953
+ }
954
+ // PARSE WHAT `execute` ACTUALLY ANSWERS, which is the MCP door's shape and
955
+ // not raw stdout: `Code executed successfully: <stdout>` on ONE line, or
956
+ // `Error executing code: …`. A first version filtered for lines starting
957
+ // with `/` and therefore matched nothing, because the path sits after that
958
+ // prefix on the same line — and a document that cannot be read is
959
+ // indistinguishable from a session that has none, so the failure was silent
960
+ // and the nine bakes recorded no input at all.
961
+ if (/^Error executing code:/m.test(answer)) return {};
962
+ const filepath =
963
+ answer
964
+ .replace(/^Code executed successfully:/, '')
965
+ .split('\n')
966
+ .map((line) => line.trim())
967
+ .filter((line) => line.startsWith('/'))
968
+ .pop() ?? '';
969
+ if (filepath === '') return {};
970
+ const root = project.endsWith('/') ? project : `${project}/`;
971
+ if (!filepath.startsWith(root)) return {};
972
+ return { document: filepath.slice(root.length) };
973
+ }
974
+
975
+ export async function handleBlenderCommand(cmd: {
976
+ type: string;
977
+ [key: string]: unknown;
978
+ }): Promise<EditorCommandResult> {
979
+ const host = editorHost();
980
+ // A read of this tab, answered ABOVE `blenderRuntime()` because that call
981
+ // spawns the worker: this is the one Blender command that must not create
982
+ // the session it is asked about. A caller uses it to find out whether the
983
+ // session it last spoke to survived (an editor restart takes it).
984
+ if (cmd.type === 'blender-status') {
985
+ return {
986
+ ok: true,
987
+ data: {
988
+ started: runtime?.project != null,
989
+ document: host.documents.context(presentationDocumentId()) !== undefined,
990
+ },
991
+ };
992
+ }
993
+ // Every other verb presents, photographs or models through an open project
994
+ // session. `blender-status` above deliberately answers without one: it is
995
+ // the read a caller makes to find out what survived.
996
+ if (!host.session.open())
997
+ return {
998
+ ok: false,
999
+ error: `${cmd.type} needs an open project session; this editor page has none.`,
1000
+ };
1001
+ try {
1002
+ const project = cmd.type === 'blender-start' ? string(cmd, 'project') : null;
1003
+ if (cmd.type === 'blender-stop' || (cmd.type === 'blender-start' && cmd['fresh'] === true)) {
1004
+ terminateBlenderRuntime();
1005
+ if (cmd.type === 'blender-stop') return { ok: true, data: { stopped: true } };
1006
+ }
1007
+ if (cmd.type === 'blender-start' && !host.documents.context(presentationDocumentId())) {
1008
+ // THE MODEL DOCUMENT, OPENED BY ADDRESS. `workspace.open` is the door's
1009
+ // own reveal verb — the same `{kind, …}` address the view protocol
1010
+ // routes, settled and awaited until the document is ready to be driven —
1011
+ // so this lane names a document, never a presenter. A `false` is the
1012
+ // build's answer that nothing here opens that address; the Model
1013
+ // document is this package's own contribution, so it is a build without
1014
+ // `@volter/editor-blender` rather than a broken call.
1015
+ const opened = await host.workspace.open({ kind: 'document', id: 'blender:runtime' });
1016
+ if (!opened)
1017
+ return {
1018
+ ok: false,
1019
+ error:
1020
+ 'blender-start could not open the Blender Model document: nothing in this editor ' +
1021
+ "opens {kind: 'document', id: 'blender:runtime'} (it ships with @volter/editor-blender).",
1022
+ };
1023
+ }
1024
+ const session = blenderRuntime();
1025
+ switch (cmd.type) {
1026
+ case 'blender-start': {
1027
+ // THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
1028
+ // project's document is its own `src/models/<name>.blend`, and a
1029
+ // `blender-start` that named none is asking for the session this tab
1030
+ // is showing, not for a second file beside it.
1031
+ const document =
1032
+ typeof cmd['document'] === 'string'
1033
+ ? cmd['document']
1034
+ : (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT);
1035
+ return { ok: true, data: { ...(await session.start(project!, document)) } };
1036
+ }
1037
+ case 'blender-execute': {
1038
+ // THROUGH THE IN-PAGE DOOR, not `session.execute` beside it — the same
1039
+ // rule `blender-rna-set` below follows, and for the same reason: the
1040
+ // RNA version is minted at the door (ruling 3, 2026-09-19), and
1041
+ // ARBITRARY bpy can change anything RNA answers, so a script that
1042
+ // reached the engine around it would leave every view drawing the tree
1043
+ // it had. One path for a `vgai eval`, an MCP call and a panel's own
1044
+ // operator.
1045
+ // THE DOOR'S TEXT, verbatim — `execute_blender_code`'s MCP contract is
1046
+ // that one string, so the wire keeps answering it while the in-page
1047
+ // callers read the parsed halves beside it.
1048
+ const answer = await blenderExecute(string(cmd, 'code'));
1049
+ return { ok: true, data: { result: answer.text } };
1050
+ }
1051
+ case 'blender-scene-info':
1052
+ return { ok: true, data: { result: await session.sceneInfo() } };
1053
+ case 'blender-object-info':
1054
+ return { ok: true, data: { result: await session.objectInfo(string(cmd, 'name')) } };
1055
+ // THE RNA DOOR. Three verbs over `session.py`'s `rna_view` /
1056
+ // `rna_context` / `rna_set` — the same door the Properties sections call
1057
+ // in-page through {@link blenderRna} below, so the panel and the wire
1058
+ // read one thing. `data` carries the answer whole: unlike
1059
+ // `blender-scene-info`, whose MCP contract is a text blob, these answer a
1060
+ // PANEL, so the rows cross as rows.
1061
+ case 'blender-rna': {
1062
+ const names = typeof cmd['names'] === 'number' ? cmd['names'] : undefined;
1063
+ return { ok: true, data: { result: await session.rna(string(cmd, 'path'), names) } };
1064
+ }
1065
+ case 'blender-rna-context': {
1066
+ const object = typeof cmd['object'] === 'string' ? cmd['object'] : undefined;
1067
+ const collection = typeof cmd['collection'] === 'string' ? cmd['collection'] : undefined;
1068
+ return { ok: true, data: { result: await session.rnaContext(object, collection) } };
1069
+ }
1070
+ case 'blender-rna-set': {
1071
+ const index = typeof cmd['index'] === 'number' ? cmd['index'] : undefined;
1072
+ // THROUGH THE IN-PAGE DOOR, not `session.rnaSet` beside it: the door is
1073
+ // where the RNA version is minted (ruling 3, 2026-09-19), and a write
1074
+ // that reached the engine around it would leave every view drawing the
1075
+ // tree it had — which is exactly the defect the version exists for.
1076
+ return {
1077
+ ok: true,
1078
+ data: {
1079
+ result: await blenderRnaSet(
1080
+ string(cmd, 'path'),
1081
+ string(cmd, 'property'),
1082
+ cmd['value'],
1083
+ index,
1084
+ ),
1085
+ },
1086
+ };
1087
+ }
1088
+ // THE TREE DOOR on the wire, beside the RNA one: `blender-outliner`
1089
+ // answers Blender's View Layer tree and `blender-outliner-set` writes one
1090
+ // restriction column, so a `vgai eval` reads exactly what the hierarchy
1091
+ // panel draws.
1092
+ case 'blender-outliner': {
1093
+ const selected = Array.isArray(cmd['selected'])
1094
+ ? (cmd['selected'] as unknown[]).filter(
1095
+ (name): name is string => typeof name === 'string',
1096
+ )
1097
+ : undefined;
1098
+ return { ok: true, data: { result: await session.outliner(selected) } };
1099
+ }
1100
+ // THE NODE-TREE DOOR on the wire, beside the other two: one material's
1101
+ // shader node tree, whole, so a `vgai eval` reads exactly what the node
1102
+ // view draws.
1103
+ case 'blender-node-tree':
1104
+ return {
1105
+ ok: true,
1106
+ data: {
1107
+ result: await session.nodeTree({
1108
+ ...(typeof cmd['path'] === 'string' ? { path: cmd['path'] } : {}),
1109
+ ...(typeof cmd['material'] === 'string' ? { material: cmd['material'] } : {}),
1110
+ }),
1111
+ },
1112
+ };
1113
+ // THE UV DOOR on the wire, beside the node one: one mesh's UV layout,
1114
+ // so a `vgai eval` reads exactly what the UV view draws.
1115
+ case 'blender-uv-layout':
1116
+ return {
1117
+ ok: true,
1118
+ data: {
1119
+ result: await session.uvLayout({
1120
+ ...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
1121
+ ...(typeof cmd['uvLayer'] === 'string' ? { uvLayer: cmd['uvLayer'] } : {}),
1122
+ }),
1123
+ },
1124
+ };
1125
+ // THE RIG AND CLIP DOORS on the wire, beside the UV one: the skin
1126
+ // binding and the action as three.js tracks, so a `vgai eval` reads
1127
+ // exactly what the presenter bound and what the Timeline plays.
1128
+ case 'blender-rig':
1129
+ return {
1130
+ ok: true,
1131
+ data: {
1132
+ result: await session.rig({
1133
+ ...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
1134
+ }),
1135
+ },
1136
+ };
1137
+ case 'blender-action-clip':
1138
+ return {
1139
+ ok: true,
1140
+ data: {
1141
+ result: await session.actionClip({
1142
+ ...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
1143
+ ...(typeof cmd['bake'] === 'boolean' ? { bake: cmd['bake'] } : {}),
1144
+ }),
1145
+ },
1146
+ };
1147
+ // THE NODE VIEW, read and driven. It touches no engine call: the view
1148
+ // is a projection of the tree the door already answered, so its actions
1149
+ // are pure state (`src/node-view-state.ts`).
1150
+ //
1151
+ // THE SESSION IS THE STANDALONE DOOR ONTO `NODE_VIEW_VERBS`, not a second
1152
+ // implementation (U8's ruling 1). Under the Code-OSS frame each verb is a
1153
+ // `vgai.blender-node-view.<verb>` command; standalone `vgai edit` has no
1154
+ // command service, so this verb routes the SAME table. `invokeViewVerb`
1155
+ // throws the view's own refusal, which is the sentence this door already
1156
+ // answered with.
1157
+ case 'blender-node-view': {
1158
+ const action = typeof cmd['action'] === 'string' ? cmd['action'] : 'state';
1159
+ // An action the TABLE does not carry is still the VIEW's refusal, not the
1160
+ // registry's: I5's proof is that an unknown action names the verb's whole
1161
+ // vocabulary IN THE VIEW's own frame warning, and a thrown registry message
1162
+ // would move that refusal somewhere the view does not draw. So the registry
1163
+ // answers when it can and `driveNodeView`'s own default answers when it
1164
+ // cannot — one implementation either way.
1165
+ const known = NODE_VIEW_VERBS.verbs.some((verb) => verb.id === action);
1166
+ return {
1167
+ ok: true,
1168
+ data: {
1169
+ result: known
1170
+ ? (invokeViewVerb('blender-node-view', action, cmd) as NodeViewState)
1171
+ : driveNodeView(cmd),
1172
+ },
1173
+ };
1174
+ }
1175
+ case 'blender-outliner-set':
1176
+ return {
1177
+ ok: true,
1178
+ data: {
1179
+ // The in-page door, for the reason `blender-rna-set` above takes it.
1180
+ result: await blenderOutlinerSet(
1181
+ string(cmd, 'path'),
1182
+ string(cmd, 'column'),
1183
+ cmd['value'] !== false,
1184
+ ),
1185
+ },
1186
+ };
1187
+ case 'blender-screenshot-view': {
1188
+ const maxSize = typeof cmd['maxSize'] === 'number' ? cmd['maxSize'] : 1000;
1189
+ // The package owns this address. The MCP transport must not guess a
1190
+ // standing document when the session is presenting a project's model.
1191
+ const document = { kind: 'document', id: boundModel?.entryId ?? 'blender:runtime' };
1192
+ return { ok: true, data: { ...await session.screenshotView(maxSize), document } };
1193
+ }
1194
+ case 'blender-read-file': {
1195
+ // The bytes go out as a BODY, not inside this command's answer -- see
1196
+ // `/__editor/blender-file` in `server/routes/relay.ts` for the
1197
+ // measurement that moved them. The caller mints the transfer id, so
1198
+ // the page never names anything on the host's disk.
1199
+ const bytes = await session.readFile(string(cmd, 'path'));
1200
+ const transfer = string(cmd, 'transferId');
1201
+ const posted = await fetch(`/__editor/blender-file?id=${encodeURIComponent(transfer)}`, {
1202
+ method: 'POST',
1203
+ headers: { 'content-type': 'application/octet-stream' },
1204
+ body: new Blob([bytes as BlobPart]),
1205
+ });
1206
+ if (!posted.ok)
1207
+ return { ok: false, error: `Transfer ${transfer} was refused: ${posted.status}` };
1208
+ return { ok: true, data: { bytes: bytes.length } };
1209
+ }
1210
+ case 'blender-write-file': {
1211
+ const binary = atob(string(cmd, 'base64'));
1212
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
1213
+ await session.writeFile(string(cmd, 'path'), bytes);
1214
+ return { ok: true, data: { written: bytes.length } };
1215
+ }
1216
+ case 'blender-list-files': {
1217
+ // WHO to attribute these files to, answered in the same breath as the
1218
+ // listing. The write-back door (`vgai blender-mcp`'s Mirror) records a
1219
+ // file it lands under `public/` in the project's provenance ledger, and
1220
+ // what it can honestly name is this session and the model state it had
1221
+ // reached — taken here, at the instant of the listing, rather than
1222
+ // asked for afterwards when another call may have moved it.
1223
+ const entries = await session.listFiles(string(cmd, 'path'));
1224
+ const presented = session.presented;
1225
+ return {
1226
+ ok: true,
1227
+ data: {
1228
+ entries,
1229
+ ...(presented ? { session: presented.session, revision: presented.revision } : {}),
1230
+ // AND WHAT THE BYTES WERE MADE FROM. A session-written `.glb` is
1231
+ // exported from a `.blend`, and that path is the one fact that
1232
+ // lets a reader go DOWN by kind later — from a prefab's glTF to
1233
+ // the model document it came from (ARCHITECTURE-CORE §Roots,
1234
+ // "Drilling goes DOWN by kind"). Taken at the instant of the
1235
+ // listing, for the same reason the session identity is.
1236
+ //
1237
+ // IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
1238
+ // is a measurement rather than a preference. `boundModel` is the
1239
+ // document the TAB opened; a bake script opens its own file with
1240
+ // `wm.open_mainfile`, which `bind_document` deliberately leaves
1241
+ // alone (it retargets `bpy.data.filepath`, not the session's
1242
+ // document). Measured 2026-09-19: reading `boundModel` recorded
1243
+ // the SAME `.blend` as the input of all three of first-person's
1244
+ // characters — whichever one the tab happened to show — which is
1245
+ // worse than no record, because it is a confident wrong answer.
1246
+ // `bpy.data.filepath` is what the export actually came out of.
1247
+ ...(await sessionDocumentPath(session)),
1248
+ },
1249
+ };
1250
+ }
1251
+ }
1252
+ return { ok: false, error: `Unknown Blender command ${cmd.type}` };
1253
+ } catch (error) {
1254
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1255
+ }
1256
+ }