@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.
- package/LICENSE +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- 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
|
+
}
|