@volter/editor-blender 0.1.150 → 0.1.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +99 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +64 -0
- package/contributions/blender.status.tsx +8 -150
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +3 -30
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -1,38 +1,17 @@
|
|
|
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/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
|
-
* `volter 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
|
-
* VOLTER_THREE photographs a detached revision through its own camera.
|
|
24
|
-
* Native CYCLES renders inside the Blender worker. A native preview is a
|
|
25
|
-
* read of that document through Cycles, separate from its Three presentation.
|
|
26
|
-
*/
|
|
1
|
+
/** The tab’s Blender session and SDK host doors. blender.verbs.ts registers the page commands; Blender runs in the worker and the Model displays its frames. VOLTER_THREE photographs detached revisions through its camera; native Cycles renders inside the worker. */
|
|
27
2
|
|
|
3
|
+
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
28
4
|
import {
|
|
29
5
|
BlenderRuntime,
|
|
30
|
-
|
|
6
|
+
DocumentNotOpened,
|
|
31
7
|
type PresentAnswer,
|
|
8
|
+
type SavedDocument,
|
|
9
|
+
type SaveScript,
|
|
32
10
|
} from '@volter/blender-engine/browser';
|
|
33
|
-
import type { RenderRequest } from '@volter/blender-engine/browser/protocol';
|
|
11
|
+
import type { BlenderAssetLibrary, DocumentOpened, GltfExportAnswer, GltfExportKind, GltfExportPrevious, RenderRequest } from '@volter/blender-engine/browser/protocol';
|
|
34
12
|
import type {
|
|
35
13
|
BlenderActionClip,
|
|
14
|
+
BlenderSceneMovie,
|
|
36
15
|
BlenderNodeTree,
|
|
37
16
|
BlenderOutlinerTree,
|
|
38
17
|
BlenderOutlinerWrite,
|
|
@@ -43,9 +22,10 @@ import type {
|
|
|
43
22
|
} from '@volter/blender-engine/browser/rna';
|
|
44
23
|
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
45
24
|
import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
46
|
-
import type
|
|
47
|
-
import { editorHost } from '@volter/sdk/host';
|
|
48
|
-
import {
|
|
25
|
+
import { commandJobs, type EditorCommandResult } from '@volter/sdk/commands';
|
|
26
|
+
import { editorHost, type EditorHostProject } from '@volter/sdk/host';
|
|
27
|
+
import { waitUntil } from '@volter/sdk/wait-until';
|
|
28
|
+
import type { ViewVerb } from '@volter/sdk/commands';
|
|
49
29
|
import {
|
|
50
30
|
captureSceneImage,
|
|
51
31
|
captureSceneLinear,
|
|
@@ -53,6 +33,10 @@ import {
|
|
|
53
33
|
type LinearCaptureFrame,
|
|
54
34
|
} from '@volter/editor-threejs/capture/scene';
|
|
55
35
|
import {blenderRenderCamera} from '@volter/blender-engine/browser/three/blender-render-camera';
|
|
36
|
+
import { beginMovie, type Movie } from '@volter/editor-movie';
|
|
37
|
+
import { mixSceneSound } from './scene-sound';
|
|
38
|
+
import { clearModelDocumentSaving, setModelDocumentSaveNotice } from '../src/model-document-saving';
|
|
39
|
+
import { startDocument } from '../src/start-document';
|
|
56
40
|
import * as THREE from 'three';
|
|
57
41
|
import { blenderEngineSelection, refreshBlenderOutliner } from '../contributions/blender-outliner-model';
|
|
58
42
|
import {
|
|
@@ -65,9 +49,7 @@ import {
|
|
|
65
49
|
|
|
66
50
|
/** Raw bytes of a base64 payload, as the half-float words a linear frame is. */
|
|
67
51
|
function halfFloatFrame(base64: string): Uint16Array {
|
|
68
|
-
const
|
|
69
|
-
const bytes = new Uint8Array(binary.length);
|
|
70
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
52
|
+
const bytes = bytesFromBase64(base64);
|
|
71
53
|
return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
|
|
72
54
|
}
|
|
73
55
|
|
|
@@ -180,21 +162,7 @@ export async function photographSnapshot(
|
|
|
180
162
|
|
|
181
163
|
export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
|
|
182
164
|
|
|
183
|
-
/**
|
|
184
|
-
* THE MODEL DOCUMENT THIS TAB IS PRESENTING INTO, and the `.blend` it opened.
|
|
185
|
-
*
|
|
186
|
-
* A `model` entry's document is bound to the project's own `.blend`
|
|
187
|
-
* (`models.finder.ts` lists them; `blender-runtime.document.tsx` opens the one
|
|
188
|
-
* its entry names), so the presenter's address is no longer a constant. A
|
|
189
|
-
* project with no `.blend` of its own still gets ONE Model document at the
|
|
190
|
-
* standing `blender:runtime` address — that is what `blender-start` presents
|
|
191
|
-
* into and what the battery photographs — and then this stays null and every
|
|
192
|
-
* read falls back to the constant above.
|
|
193
|
-
*
|
|
194
|
-
* It is a module variable rather than a host door because both sides are THIS
|
|
195
|
-
* package: the document that binds it and the session that reads it ship
|
|
196
|
-
* together, so there is nothing for the SDK to carry.
|
|
197
|
-
*/
|
|
165
|
+
/** The bound Model document supplies its presentation address and .blend path. With no project .blend, use the standing blender:runtime document. Both producer and reader belong to this package. */
|
|
198
166
|
export interface ModelDocumentBinding {
|
|
199
167
|
readonly documentId: string;
|
|
200
168
|
readonly entryId: string;
|
|
@@ -229,11 +197,7 @@ function shownModelBlend(): string | undefined {
|
|
|
229
197
|
: boundModel?.blend;
|
|
230
198
|
}
|
|
231
199
|
|
|
232
|
-
/**
|
|
233
|
-
* A script's Save As into a project `.blend` that no Model document lists (`followMovedDocument`): Blender edits
|
|
234
|
-
* that file and saves there, and the tab it left (`from`) keeps showing Blender, since there is no tab to move to.
|
|
235
|
-
* Without this, every later call was refused as a conflict naming a file no tab can show.
|
|
236
|
-
*/
|
|
200
|
+
/** Follow a script’s Save As even when no Model document lists the new project file. Keep the existing tab bound to the session’s current file. */
|
|
237
201
|
let unlistedFollow: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
238
202
|
|
|
239
203
|
function modelDocumentConflict(): string | null {
|
|
@@ -241,9 +205,10 @@ function modelDocumentConflict(): string | null {
|
|
|
241
205
|
const requested = shownModelBlend();
|
|
242
206
|
if (!requested || !held || requested === held) return null;
|
|
243
207
|
if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
|
|
244
|
-
// Say how to get Blender onto the document the editor shows, not only how to go back:
|
|
245
|
-
//
|
|
246
|
-
//
|
|
208
|
+
// Say how to get Blender onto the document the editor shows, not only how to go back: "return to <held>"
|
|
209
|
+
// alone reads as the Blender door being unusable.
|
|
210
|
+
// (`session.py`'s `_another_document_shown` reads "but the editor is showing": a first present refused so is a
|
|
211
|
+
// document switch, not a failure of the document's.)
|
|
247
212
|
// Plain words: people see this in error notices too. Re-opening the document already showing does not
|
|
248
213
|
// remount a failed open (blender-runtime.document.tsx), so the steps that do are named instead.
|
|
249
214
|
return `Blender is editing ${held}, but the editor is showing ${requested}, which Blender has not loaded. ` +
|
|
@@ -273,6 +238,16 @@ export function modelDocumentMayOpen(binding: ModelDocumentBinding): boolean {
|
|
|
273
238
|
return !anotherModel || active === binding.documentId;
|
|
274
239
|
}
|
|
275
240
|
|
|
241
|
+
/** THE `.blend` A MODEL VIEW SHOWS NOW: the runtime's document, once `view` holds that runtime's
|
|
242
|
+
* latest present; null between opening a file and its first present (the view still shows the
|
|
243
|
+
* last one). */
|
|
244
|
+
export function presentedModelBlend(view: { snapshot(): { readonly session: string; readonly revision: number } | null }): string | null {
|
|
245
|
+
const latest = runtime?.presented;
|
|
246
|
+
const shown = view.snapshot();
|
|
247
|
+
if (!runtime || !latest || !shown || shown.session !== latest.session || shown.revision !== latest.revision) return null;
|
|
248
|
+
return runtime.document ?? null;
|
|
249
|
+
}
|
|
250
|
+
|
|
276
251
|
/** A retained photograph must belong to the file and revision still on screen,
|
|
277
252
|
* not a retiring pane or the next worker's first frame. */
|
|
278
253
|
export function modelDocumentOwnsPresentation(
|
|
@@ -289,28 +264,331 @@ export function modelDocumentOwnsPresentation(
|
|
|
289
264
|
!!presented && !!latest && presented.session === latest.session && presented.revision === latest.revision;
|
|
290
265
|
}
|
|
291
266
|
|
|
267
|
+
/**
|
|
268
|
+
* THE MODEL DOCUMENT'S WORKING COPY — what the workbench calls for the `.blend` the engine holds
|
|
269
|
+
* (the `workingCopy` of `blender-runtime.document.tsx`). The engine is its one writer: a save is
|
|
270
|
+
* the worker's own, over the session's delta route, and the rest reopen the document inside the
|
|
271
|
+
* engine.
|
|
272
|
+
*/
|
|
273
|
+
const modelDirtyListeners = new Set<() => void>();
|
|
274
|
+
|
|
275
|
+
export function subscribeModelDocumentDirty(listener: () => void): () => void {
|
|
276
|
+
modelDirtyListeners.add(listener);
|
|
277
|
+
return () => { modelDirtyListeners.delete(listener); };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function modelDocumentDirty(blend: string): boolean {
|
|
281
|
+
return runtime?.document === blend && runtime.dirty;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** How long the workbench's call waits for the engine to hold the model: a cold boot of the engine. */
|
|
285
|
+
const MODEL_HELD_MS = 120_000;
|
|
286
|
+
|
|
287
|
+
/** The runtime holding `blend`, once it does. */
|
|
288
|
+
async function runtimeHolding(blend: string): Promise<BlenderRuntime> {
|
|
289
|
+
if (!(await waitUntil(() => runtime?.document === blend, MODEL_HELD_MS)) || !runtime) {
|
|
290
|
+
throw new Error(`Blender does not hold ${blend}.`);
|
|
291
|
+
}
|
|
292
|
+
return runtime;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The project's `.blend` the engine holds now, or `null` while it holds none. */
|
|
296
|
+
export function heldModelDocument(): string | null {
|
|
297
|
+
return runtime?.document ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Bring the project's `.blend` up to the model the engine holds, for a reader that takes the
|
|
301
|
+
* file's bytes as the model's (a cook's or an export's hash beside what the engine exported). */
|
|
302
|
+
export async function flushModelDocument(blend: string): Promise<void> {
|
|
303
|
+
const owner = runtime;
|
|
304
|
+
if (owner?.document !== blend || !owner.dirty) return;
|
|
305
|
+
const saved = await owner.saveDocument(saveScript ?? undefined);
|
|
306
|
+
if (saved !== null) modelDocumentSaved(owner, saved);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function saveModelDocument(blend: string): Promise<void> {
|
|
310
|
+
const owner = await runtimeHolding(blend);
|
|
311
|
+
const saved = await owner.saveDocument(saveScript ?? undefined);
|
|
312
|
+
if (saved !== null) modelDocumentSaved(owner, saved);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** THE SCRIPT EVERY SAVE RUNS, in the save's own turn, on the scene it wrote (`BlenderRuntime.saveDocument`), as its
|
|
316
|
+
* setter names it for the document saved; one at a time, until its removal. */
|
|
317
|
+
let saveScript: SaveScript | null = null;
|
|
318
|
+
|
|
319
|
+
export function setModelDocumentSaveScript(script: SaveScript): () => void {
|
|
320
|
+
if (saveScript) throw new Error('A save script is already set for model documents.');
|
|
321
|
+
saveScript = script;
|
|
322
|
+
return () => { if (saveScript === script) saveScript = null; };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A SAVE THAT LANDED A `.blend` IN THE PROJECT, as its listeners hear it: the file it wrote, what the save script did
|
|
326
|
+
* on the scene it wrote (null where none ran: `setModelDocumentSaveScript`), and the carry of that script's files into
|
|
327
|
+
* the project at the same paths, as an export's are (`blender-gltf-export.verbs.ts`), which the listener runs when it
|
|
328
|
+
* is ready for them. A script that raised has nothing to carry. */
|
|
329
|
+
export interface ModelDocumentSaved {
|
|
330
|
+
readonly blend: string;
|
|
331
|
+
readonly script: { readonly output: string; readonly error: string | null; readonly unstaged: readonly string[] } | null;
|
|
332
|
+
carry(): Promise<void>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** EACH SAVE THAT LANDED A `.blend` IN THE PROJECT: the working copy's (a person's Save, the workbench's autosave), an
|
|
336
|
+
* export's flush and the save that retires a document alike. What a listener answers is its work on that save (the
|
|
337
|
+
* carry and what follows it), which reads files, not Blender: a retirement waits for it ({@link retireRuntime}). */
|
|
338
|
+
type ModelDocumentSavedListener = (saved: ModelDocumentSaved) => Promise<unknown> | void;
|
|
339
|
+
const savedListeners = new Set<ModelDocumentSavedListener>();
|
|
340
|
+
/** The listeners' work on each runtime's saves, not yet done. */
|
|
341
|
+
const savedWork = new Map<BlenderRuntime, Set<Promise<unknown>>>();
|
|
342
|
+
|
|
343
|
+
/** Whether the project's file already holds these bytes. A save script's export of an unchanged scene (a call that
|
|
344
|
+
* read the model, a save with nothing changed) writes the same bytes, and a rewrite of them is still a change to
|
|
345
|
+
* what watches the file: the game's dev server reloads every page on it. So an unchanged file is left as it is. */
|
|
346
|
+
async function holds(path: string, bytes: Uint8Array): Promise<boolean> {
|
|
347
|
+
const files = editorHost().files;
|
|
348
|
+
// (a file that can't be read is written, as every carry wrote it before)
|
|
349
|
+
if ((await files.stat(path).catch(() => null))?.size !== bytes.byteLength) return false;
|
|
350
|
+
const held = await files.readBytes(path).catch(() => null);
|
|
351
|
+
return held !== null && held.byteLength === bytes.byteLength && held.every((byte, at) => byte === bytes[at]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function modelDocumentSaved(owner: BlenderRuntime, saved: SavedDocument): void {
|
|
355
|
+
const files = saved.script?.error === null ? saved.script.files : [];
|
|
356
|
+
const heard: ModelDocumentSaved = {
|
|
357
|
+
blend: saved.document,
|
|
358
|
+
script: saved.script === null ? null : { output: saved.script.output, error: saved.script.error, unstaged: saved.script.unstaged },
|
|
359
|
+
async carry() {
|
|
360
|
+
for (const file of files) if (!(await holds(file.path, file.bytes))) await editorHost().files.write(file.path, file.bytes);
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
for (const listener of [...savedListeners]) {
|
|
364
|
+
const work = listener(heard);
|
|
365
|
+
if (!work) continue;
|
|
366
|
+
const held = savedWork.get(owner) ?? new Set<Promise<unknown>>();
|
|
367
|
+
savedWork.set(owner, held);
|
|
368
|
+
const settled = work.then(() => {}, () => {}).finally(() => {
|
|
369
|
+
held.delete(settled);
|
|
370
|
+
if (held.size === 0 && savedWork.get(owner) === held) savedWork.delete(owner);
|
|
371
|
+
});
|
|
372
|
+
held.add(settled);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function subscribeModelDocumentSaved(listener: ModelDocumentSavedListener): () => void {
|
|
377
|
+
savedListeners.add(listener);
|
|
378
|
+
return () => { savedListeners.delete(listener); };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The listeners' work on every save `owner` made, which reads files and not Blender, done. */
|
|
382
|
+
async function savedWorkDone(owner: BlenderRuntime): Promise<void> {
|
|
383
|
+
for (let held = savedWork.get(owner); held !== undefined; held = savedWork.get(owner)) await Promise.all([...held]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* THE DOCUMENT BLENDER HOLDS, RELEASED WITH ITS SAVES' WORK DONE, before Blender opens another `.blend`: its last
|
|
388
|
+
* save runs the save script in its turn as every save does and is heard, Blender takes no other call from then until
|
|
389
|
+
* the next open (`BlenderRuntime.releaseDocument`), so no edit lands unsaved, and the listeners' work on its saves is
|
|
390
|
+
* waited for. A save that fails throws, and Blender keeps the document, its history and its ownership.
|
|
391
|
+
*/
|
|
392
|
+
async function releaseDocument(owner: BlenderRuntime, next: string): Promise<void> {
|
|
393
|
+
await owner.releaseDocument(next, { ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
|
|
394
|
+
await savedWorkDone(owner);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A RUNTIME RETIRED WITH ITS SAVES' WORK DONE, for every retirement (a reload, a stop, the session's close): its last
|
|
399
|
+
* save is the document's as {@link releaseDocument}'s is, taken as the runtime stops. A save that fails throws, and the
|
|
400
|
+
* runtime stays alive with its document.
|
|
401
|
+
*/
|
|
402
|
+
async function retireRuntime(owner: BlenderRuntime): Promise<void> {
|
|
403
|
+
await owner.stop({ ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
|
|
404
|
+
await savedWorkDone(owner);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* THE ASSET LIBRARIES EVERY BLENDER HAS (Preferences > File Paths > Asset Libraries), by name: set in the running
|
|
409
|
+
* Blender at once (once it is up, if it is starting), and in each Blender after it starts ({@link startedRuntime}).
|
|
410
|
+
* Blender's preferences live as long as its worker, whatever `.blend` it opens. The removal leaves a running Blender's
|
|
411
|
+
* library as it is.
|
|
412
|
+
*/
|
|
413
|
+
const assetLibraries = new Map<string, BlenderAssetLibrary>();
|
|
414
|
+
|
|
415
|
+
export function setBlenderAssetLibrary(library: BlenderAssetLibrary): () => void {
|
|
416
|
+
assetLibraries.set(library.name, library);
|
|
417
|
+
if (runtime?.project != null) void runtime.setAssetLibrary(library).catch(refusedLibrary(library));
|
|
418
|
+
return () => { if (assetLibraries.get(library.name) === library) assetLibraries.delete(library.name); };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const refusedLibrary = (library: BlenderAssetLibrary) => (error: unknown): void =>
|
|
422
|
+
editorHost().console.error(`Blender did not take the asset library ${library.name} (${library.directory}): ${error instanceof Error ? error.message : String(error)}`, 'blender-asset-library');
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* THE PAGE'S BLENDER, started at `project` if it is not yet: its boot claimed now, holding no document, and the asset
|
|
426
|
+
* libraries every Blender has set once it is up. Whatever needs it started waits on its start (`BlenderRuntime.start`,
|
|
427
|
+
* one per worker), so an open that arrives while it starts waits for it rather than starting another. A start that
|
|
428
|
+
* fails is discarded here, and whoever waited on it says why; the next one makes a new Blender.
|
|
429
|
+
*/
|
|
430
|
+
function startedRuntime(project: string): BlenderRuntime {
|
|
431
|
+
const session = pageRuntime();
|
|
432
|
+
if (session.project !== null) return session;
|
|
433
|
+
setStarting(session);
|
|
434
|
+
void session.start(project).then(async () => {
|
|
435
|
+
if (startingSession === session) setStarting(null);
|
|
436
|
+
for (const library of assetLibraries.values()) {
|
|
437
|
+
if (runtime !== session) return;
|
|
438
|
+
await session.setAssetLibrary(library).catch(refusedLibrary(library));
|
|
439
|
+
}
|
|
440
|
+
}, () => {
|
|
441
|
+
if (startingSession === session) setStarting(null);
|
|
442
|
+
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
443
|
+
});
|
|
444
|
+
return session;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The page's Blender while its start is under way, which an open waits on before its own file's load. */
|
|
448
|
+
let startingSession: BlenderRuntime | null = null;
|
|
449
|
+
const startingListeners = new Set<() => void>();
|
|
450
|
+
function notifyBlenderModelState(): void {
|
|
451
|
+
for (const listener of [...startingListeners]) listener();
|
|
452
|
+
}
|
|
453
|
+
function setStarting(session: BlenderRuntime | null): void {
|
|
454
|
+
startingSession = session;
|
|
455
|
+
notifyBlenderModelState();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Whether the page's Blender is starting: the first open, or one after a reload or a failure, waits on it. */
|
|
459
|
+
export function blenderStarting(): boolean {
|
|
460
|
+
return startingSession !== null && startingSession === runtime;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Hear Blender start, and start or finish changing the model it holds. */
|
|
464
|
+
export function subscribeBlenderStarting(listener: () => void): () => void {
|
|
465
|
+
startingListeners.add(listener);
|
|
466
|
+
return () => { startingListeners.delete(listener); };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** `document` opened in `session`, Blender's own open; claimed at the call. A Blender that could not open it is
|
|
470
|
+
* discarded, so the next open makes a new one rather than reaching a half-opened model. */
|
|
471
|
+
async function openInRuntime(session: BlenderRuntime, document: string): Promise<DocumentOpened> {
|
|
472
|
+
try {
|
|
473
|
+
return await session.openDocument(document);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (runtime === session) terminateBlenderRuntime();
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
type DocumentTable = Awaited<ReturnType<EditorHostProject['documentTable']>>;
|
|
481
|
+
|
|
482
|
+
/** A project's table lists a model: a `.blend` a finder found, the `model:` entries (`models.finder.ts`). */
|
|
483
|
+
const listsModel = (table: DocumentTable): boolean =>
|
|
484
|
+
table.entries.some((entry) => entry.kind === 'model' && entry.source?.path.endsWith('.blend') === true);
|
|
485
|
+
|
|
486
|
+
/** The project Blender was started early for, once per project this page opens (until its session ends). */
|
|
487
|
+
let startedEarlyFor: string | null = null;
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* BLENDER STARTED AS THE PROJECT OPENS, once its table lists a model: started holding no document, so the first model
|
|
491
|
+
* opened waits only for what is left of the start and then for its own file's load, and every later one opens in the
|
|
492
|
+
* same Blender ({@link openModelDocumentBlend}). Read from the table the project has published and each one after it,
|
|
493
|
+
* without asking for one: this package activates on its finder (`onFinder:modelsFromBlendFiles`) as the project's
|
|
494
|
+
* first table is resolved. Taken in turn with the documents' opens and retirements, so an open already under way
|
|
495
|
+
* starts Blender itself. Once per project the page opens; returns the unsubscribe.
|
|
496
|
+
*/
|
|
497
|
+
export function startBlenderWithProject(): () => void {
|
|
498
|
+
const host = editorHost();
|
|
499
|
+
const ended = host.session.onEnded(() => { startedEarlyFor = null; });
|
|
500
|
+
const tables = host.project.onDocumentTable((table) => {
|
|
501
|
+
if (!host.session.open() || !listsModel(table)) return;
|
|
502
|
+
const project = host.projectLocalState.projectRootPath();
|
|
503
|
+
if (project === null || startedEarlyFor === project) return;
|
|
504
|
+
startedEarlyFor = project;
|
|
505
|
+
void queueModelLifecycle(async () => {
|
|
506
|
+
if (runtime?.project != null || !host.session.open() || host.projectLocalState.projectRootPath() !== project) return;
|
|
507
|
+
// (said in the session journal, so a model's open can be read against the start it waited for)
|
|
508
|
+
const end = beginBlenderWork('Starting Blender');
|
|
509
|
+
startedRuntime(project).start(project).then(end, end);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
return () => { tables(); ended(); };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function snapshotModelDocument(blend: string): Promise<Uint8Array> {
|
|
516
|
+
return (await runtimeHolding(blend)).snapshotDocument();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* THE DOCUMENT OPENED AGAIN, in turn with the documents' opens and switches: from its project file (a revert,
|
|
521
|
+
* discarding its changes) or as `bytes`, its unsaved state (a backup's restore). A failure before Blender's own open
|
|
522
|
+
* (its file's staging) keeps the running Blender with the document it held; one inside Blender's open discards it,
|
|
523
|
+
* as any failed open does.
|
|
524
|
+
*/
|
|
525
|
+
async function reopenModelDocument(blend: string, bytes?: Uint8Array): Promise<void> {
|
|
526
|
+
// (waited for outside the queue, which the open of `blend` may still be in)
|
|
527
|
+
const session = await runtimeHolding(blend);
|
|
528
|
+
await queueModelLifecycle(async () => {
|
|
529
|
+
if (runtime !== session || session.document !== blend) throw new Error(`Blender does not hold ${blend}.`);
|
|
530
|
+
try {
|
|
531
|
+
await session.openDocument(blend, bytes);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (!(error instanceof DocumentNotOpened) && runtime === session) terminateBlenderRuntime();
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The document opened again from its project file, discarding its changes. */
|
|
540
|
+
export async function revertModelDocument(blend: string): Promise<void> {
|
|
541
|
+
await reopenModelDocument(blend);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** The document opened again as `bytes`, its unsaved state (a backup). */
|
|
545
|
+
export async function restoreModelDocument(blend: string, bytes: Uint8Array): Promise<void> {
|
|
546
|
+
await reopenModelDocument(blend, bytes);
|
|
547
|
+
}
|
|
548
|
+
|
|
292
549
|
/**
|
|
293
550
|
* OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
|
|
294
|
-
* path run backwards.
|
|
295
|
-
*
|
|
551
|
+
* path run backwards. One Blender serves the page's documents one after
|
|
552
|
+
* another, as a desktop Blender does: opening another `.blend` saves the one it
|
|
553
|
+
* holds and opens the next in it (`open-document`, Blender's own open, which
|
|
554
|
+
* `session.py` saves back to). A reload, another project or a Blender whose start
|
|
555
|
+
* failed takes a new worker.
|
|
296
556
|
*
|
|
297
|
-
*
|
|
298
|
-
* a refused open must never expose the previous model under a new
|
|
299
|
-
* identity.
|
|
300
|
-
*
|
|
557
|
+
* Blender holds one file at a time. Validate ownership before publishing a
|
|
558
|
+
* context; a refused open must never expose the previous model under a new
|
|
559
|
+
* tab's identity. The open itself presents, so the context must exist before
|
|
560
|
+
* awaiting it. Return false when the requesting pane has since unmounted.
|
|
301
561
|
*/
|
|
302
562
|
let modelOpenTail: Promise<void> = Promise.resolve();
|
|
303
563
|
let modelStartup: { readonly session: BlenderRuntime; readonly project: string; readonly blend: string } | null = null;
|
|
304
564
|
|
|
565
|
+
/** How many model opens, switches and retirements are queued or under way. */
|
|
566
|
+
let modelLifecycles = 0;
|
|
567
|
+
|
|
305
568
|
function queueModelLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
569
|
+
modelLifecycles += 1;
|
|
570
|
+
notifyBlenderModelState();
|
|
306
571
|
const result = modelOpenTail.then(operation);
|
|
307
572
|
modelOpenTail = result.then(() => {}, () => {});
|
|
573
|
+
void modelOpenTail.then(() => { modelLifecycles -= 1; notifyBlenderModelState(); });
|
|
308
574
|
return result;
|
|
309
575
|
}
|
|
310
576
|
|
|
577
|
+
/** Whether Blender is starting or changing the model it holds (an open, a switch, a retirement): a read of its
|
|
578
|
+
* model waits for it rather than failing. */
|
|
579
|
+
export function blenderModelChanging(): boolean {
|
|
580
|
+
return modelLifecycles > 0 || blenderStarting();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Why Blender holds another document than the editor shows, in a person's words, or null. */
|
|
584
|
+
export function blenderModelConflict(): string | null {
|
|
585
|
+
return modelDocumentConflict();
|
|
586
|
+
}
|
|
587
|
+
|
|
311
588
|
export async function openModelDocumentBlend(
|
|
312
589
|
binding: ModelDocumentBinding,
|
|
313
590
|
publish: () => void,
|
|
591
|
+
reload = false,
|
|
314
592
|
): Promise<boolean> {
|
|
315
593
|
let published = false;
|
|
316
594
|
const publishOnce = () => {
|
|
@@ -332,12 +610,13 @@ export async function openModelDocumentBlend(
|
|
|
332
610
|
}
|
|
333
611
|
// Only one handoff may own stop/start. Re-check the mounted requester after
|
|
334
612
|
// every wait: a rapid B -> C selection must not let B publish into C.
|
|
335
|
-
return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce));
|
|
613
|
+
return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce, reload));
|
|
336
614
|
}
|
|
337
615
|
|
|
338
616
|
async function openBoundModelDocument(
|
|
339
617
|
binding: ModelDocumentBinding,
|
|
340
618
|
publish: () => void,
|
|
619
|
+
reload: boolean,
|
|
341
620
|
): Promise<boolean> {
|
|
342
621
|
const host = editorHost();
|
|
343
622
|
if (!host.session.open()) throw new Error('Opening a model requires an open project session.');
|
|
@@ -345,40 +624,51 @@ async function openBoundModelDocument(
|
|
|
345
624
|
if (project === null) throw new Error('Opening a model requires a project path.');
|
|
346
625
|
if (!modelDocumentMayOpen(binding)) return false;
|
|
347
626
|
const previous = runtime;
|
|
348
|
-
|
|
349
|
-
|
|
627
|
+
// (each document named, so the session journal says which document gave way to which)
|
|
628
|
+
const handoff = (reloading: boolean) =>
|
|
629
|
+
beginBlenderWork(`Saving ${previous?.document ?? 'the previous model'} before ${reloading ? 'reopening' : 'opening'} ${binding.blend}`);
|
|
630
|
+
if (previous && (reload || previous.startFailed || (previous.project !== null && previous.project !== project))) {
|
|
631
|
+
// A NEW BLENDER: the old worker's file is saved and its saves' work done before it stops. If
|
|
350
632
|
// saving fails it stays alive; neither its history nor its ownership moves.
|
|
351
|
-
const end =
|
|
352
|
-
try { await previous
|
|
633
|
+
const end = handoff(reload);
|
|
634
|
+
try { await retireRuntime(previous); } finally { end(); }
|
|
353
635
|
if (runtime === previous) terminateBlenderRuntime();
|
|
354
636
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
355
637
|
}
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
638
|
+
const switching = previous?.document != null && previous.document !== binding.blend ? previous : null;
|
|
639
|
+
let session: BlenderRuntime;
|
|
640
|
+
let opened: Promise<unknown>;
|
|
641
|
+
try {
|
|
642
|
+
if (switching) {
|
|
643
|
+
// ANOTHER `.blend` IN THE SAME BLENDER: the one it holds is saved and its saves' work done
|
|
644
|
+
// first. If saving fails Blender keeps it; neither its history nor its ownership moves.
|
|
645
|
+
const end = handoff(false);
|
|
646
|
+
try { await releaseDocument(switching, binding.blend); } finally { end(); }
|
|
647
|
+
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
648
|
+
// What was the old document's goes with it, as a worker's does when it stops: its history
|
|
649
|
+
// (Blender's own load resets it too), its save notices and a Save As the tab had yet to follow.
|
|
650
|
+
forgetDocument();
|
|
651
|
+
}
|
|
652
|
+
session = startedRuntime(project);
|
|
653
|
+
// The open claims the document synchronously; its first frame may arrive before the
|
|
654
|
+
// returned promise resolves. A document Blender already holds (a remounted pane, a tab that
|
|
655
|
+
// followed a Save As) waits only for the start it may still be making.
|
|
656
|
+
opened = session.document === binding.blend ? session.start(project) : openInRuntime(session, binding.blend);
|
|
657
|
+
} finally {
|
|
658
|
+
// Every way out of a switch ends its release: the next document's open, once asked for, ends it in its own
|
|
659
|
+
// turn; any other way out (abandoned, failed) keeps the document Blender holds.
|
|
660
|
+
switching?.keepDocument();
|
|
661
|
+
}
|
|
360
662
|
const starting = { session, project, blend: binding.blend };
|
|
361
663
|
modelStartup = starting;
|
|
362
664
|
try {
|
|
363
665
|
publish();
|
|
364
|
-
await
|
|
365
|
-
} catch (error) {
|
|
366
|
-
// A session whose start failed keeps that failure and never starts. Discard it, so that
|
|
367
|
-
// "Retry opening" makes a new session and a new worker; without this every retry awaited the
|
|
368
|
-
// same failed start. A refusal that is not a failed start (another document holds the
|
|
369
|
-
// session) leaves the session alone: it is someone else's.
|
|
370
|
-
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
371
|
-
throw error;
|
|
666
|
+
await opened;
|
|
372
667
|
} finally {
|
|
373
668
|
if (modelStartup === starting) modelStartup = null;
|
|
374
669
|
}
|
|
375
670
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
376
|
-
// bind_document presents
|
|
377
|
-
// immediately evaluate/export it again: on Stoneguard that redundant RPC
|
|
378
|
-
// took 13.8 seconds after the resumable startup had already finished.
|
|
379
|
-
// A reopened pane has a NEW view, though, and an absent file presents
|
|
380
|
-
// nothing at start. Compare the view's actual holding with the runtime's
|
|
381
|
-
// revision so both cases still request the frame they need.
|
|
671
|
+
// bind_document presents before start resolves. Request another frame only when the view does not hold the runtime’s current revision, including reopened panes and initially absent files.
|
|
382
672
|
const shown = (await runtimeView()).snapshot();
|
|
383
673
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
384
674
|
const latest = session.presented;
|
|
@@ -397,42 +687,15 @@ async function openBoundModelDocument(
|
|
|
397
687
|
* and the panel read one thing.
|
|
398
688
|
*
|
|
399
689
|
* THEY NEVER START THE ENGINE. A panel asking what the engine holds must not
|
|
400
|
-
*
|
|
690
|
+
* start Blender, so these answer `null` when this tab
|
|
401
691
|
* has no started session — exactly the read `blender-status` makes, and for
|
|
402
|
-
* the same reason.
|
|
692
|
+
* the same reason — or a started one that holds no model yet.
|
|
403
693
|
*/
|
|
404
694
|
export function blenderSessionStarted(): boolean {
|
|
405
|
-
return runtime?.project != null && modelDocumentConflict() === null;
|
|
695
|
+
return runtime?.project != null && runtime.document !== null && modelDocumentConflict() === null;
|
|
406
696
|
}
|
|
407
697
|
|
|
408
|
-
/**
|
|
409
|
-
* THE DOCUMENT'S RNA VERSION — "a tree's freshness is the tree's, not the
|
|
410
|
-
* presented frame's" (orchestrator ruling 3, 2026-09-19, WORK.md §Blender in
|
|
411
|
-
* the tab is Blender, I5).
|
|
412
|
-
*
|
|
413
|
-
* Every view over this engine draws something RNA answers, and until this
|
|
414
|
-
* existed the only "the engine moved" signal in the page was a PRESENTED
|
|
415
|
-
* FRAME: `blender-properties-model.ts` subscribes to the Model document's
|
|
416
|
-
* frames, and its version was what the node view re-read on. MEASURED
|
|
417
|
-
* 2026-09-19 by I5's socket-panel step: collapsing a panel through
|
|
418
|
-
* `blender-rna-set` changes `Node.panel_states[n].is_collapsed`, which changes
|
|
419
|
-
* nothing the presenter draws — so no frame ships, no version moves, and the
|
|
420
|
-
* node view kept drawing the old tree until the document was reopened. It is
|
|
421
|
-
* the same shape as I3's "a present is not a reliable signal for a column"
|
|
422
|
-
* (`hide_render`), and the answer is the same: the WRITE asks for the re-read.
|
|
423
|
-
*
|
|
424
|
-
* So the version is minted HERE, at the door every RNA write goes through,
|
|
425
|
-
* and it is bumped by the write rather than by the picture. Both halves of
|
|
426
|
-
* each door bump it once: the in-page function is what the wire's
|
|
427
|
-
* `blender-rna-set` / `blender-outliner-set` case calls, so a `cyclotron eval` and
|
|
428
|
-
* a panel click are one path. `blender-execute` bumps it too — arbitrary bpy
|
|
429
|
-
* can change anything RNA answers, and a view that went stale under a probe's
|
|
430
|
-
* own script would be the same defect one layer out.
|
|
431
|
-
*
|
|
432
|
-
* WHAT SUBSCRIBES: `blender-properties-model.ts` (which invalidates its cached
|
|
433
|
-
* datablock views and re-reads the context, exactly as it does on a frame),
|
|
434
|
-
* and every view in `@volter/editor-blender/contributions` that draws RNA.
|
|
435
|
-
*/
|
|
698
|
+
/** The RNA version invalidates every view over this engine. Bump it at script, RNA and Outliner write doors, including writes that change no rendered geometry; presented frames bump it too. */
|
|
436
699
|
let rnaVersion = 0;
|
|
437
700
|
const rnaListeners = new Set<() => void>();
|
|
438
701
|
|
|
@@ -455,9 +718,9 @@ export function noteBlenderRnaChanged(): void {
|
|
|
455
718
|
for (const listener of [...rnaListeners]) listener();
|
|
456
719
|
}
|
|
457
720
|
|
|
458
|
-
export async function blenderRna(path: string
|
|
721
|
+
export async function blenderRna(path: string): Promise<BlenderRnaView | null> {
|
|
459
722
|
if (!blenderSessionStarted()) return null;
|
|
460
|
-
return blenderRuntime().rna(path
|
|
723
|
+
return blenderRuntime().rna(path);
|
|
461
724
|
}
|
|
462
725
|
|
|
463
726
|
export async function blenderRnaContext(
|
|
@@ -487,51 +750,7 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
|
|
|
487
750
|
await blenderRuntime().viewShading(shading);
|
|
488
751
|
}
|
|
489
752
|
|
|
490
|
-
/**
|
|
491
|
-
* THE SCRIPT DOOR, in-page — one bpy script, run the way the MCP add-on runs
|
|
492
|
-
* one (`session.py::execute`: a namespace of its own, output captured, and a
|
|
493
|
-
* PRESENT after it, so the picture that comes back is the engine's own
|
|
494
|
-
* reading).
|
|
495
|
-
*
|
|
496
|
-
* This is what an OPERATOR goes through. The Outliner's structural verbs —
|
|
497
|
-
* add, delete, duplicate — are `bpy.ops.*` calls and nothing else: Blender
|
|
498
|
-
* runs the operator and we only name it (`blender-outliner-authoring.ts`'s
|
|
499
|
-
* `StructureProvider`). Writing a second implementation of `object.delete`'s
|
|
500
|
-
* unparenting, or of `primitive_uv_sphere_add`'s defaults, is the thing this
|
|
501
|
-
* door exists to make unnecessary.
|
|
502
|
-
*
|
|
503
|
-
* SAME SHAPE AS {@link blenderRnaSet}, and for its reason: the RNA version is
|
|
504
|
-
* minted at the door, so the wire's `blender-execute` case calls THIS rather
|
|
505
|
-
* than `session.execute` beside it — arbitrary bpy can change anything RNA
|
|
506
|
-
* answers, and a view left drawing the tree it had is the defect the version
|
|
507
|
-
* exists for. Like every door here it never starts the engine.
|
|
508
|
-
*
|
|
509
|
-
* The answer is `session.py::execute`'s own record — `executed`, the captured
|
|
510
|
-
* `result` text, and `error` when the script raised. A REFUSAL IS NOT A THROW:
|
|
511
|
-
* Blender's traceback comes back in `error`, verbatim, and a caller that wants
|
|
512
|
-
* it to be an exception raises its own (the structure provider does, so the
|
|
513
|
-
* ack it hands the shell carries Blender's sentence).
|
|
514
|
-
*
|
|
515
|
-
* IT DOES NOT GUARD ON `blenderSessionStarted()`, and that is the one way it
|
|
516
|
-
* differs from the read doors above. They are called from a panel's render and
|
|
517
|
-
* must answer "nothing to show" rather than spawn a session; a script is always
|
|
518
|
-
* a deliberate act, and the engine's own refusal — "The Blender session has not
|
|
519
|
-
* been started with a project (blender-start)" — is the honest answer to one
|
|
520
|
-
* made too early. A silent `null` there would have turned the wire's own loud
|
|
521
|
-
* refusal into an empty result.
|
|
522
|
-
*
|
|
523
|
-
* WHAT THE SESSION ACTUALLY ANSWERS WITH IS THE MCP DOOR'S TEXT, not
|
|
524
|
-
* `session.py::execute`'s `{executed, result, error}` record: the worker
|
|
525
|
-
* flattens it into ONE STRING on the way out — `Code executed successfully:
|
|
526
|
-
* <stdout>` or `Error executing code: <traceback>` (`worker.ts:250-260`, "a
|
|
527
|
-
* script's failure is TEXT, not a rejection"), because that is what the MCP
|
|
528
|
-
* tool returns to an agent. So this parses it, once, here. MEASURED 2026-09-21:
|
|
529
|
-
* a first version read `answer.executed`/`answer.error` off the string, where
|
|
530
|
-
* both are `undefined` — every operator RAN and every caller was told it had
|
|
531
|
-
* failed, which is how an added sphere reached `bpy.data.objects` and the
|
|
532
|
-
* Outliner and was never selected. Read-only metadata uses dedicated native
|
|
533
|
-
* reads rather than this execute path, which always presents a mutation.
|
|
534
|
-
*/
|
|
753
|
+
/** Execute one bpy script in the current session and bump the RNA version. The worker returns MCP text: Code executed successfully: <stdout> or Error executing code: <traceback>. Parse it once into executed/result/error for in-page callers; preserve the text for wire callers. A call before session startup receives the engine’s refusal. */
|
|
535
754
|
export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
|
|
536
755
|
const session = blenderRuntime();
|
|
537
756
|
const text = await session.execute(code, history, label);
|
|
@@ -547,6 +766,187 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
547
766
|
};
|
|
548
767
|
}
|
|
549
768
|
|
|
769
|
+
// ---------------------------------------------------------------- a film, rendered
|
|
770
|
+
|
|
771
|
+
/** WHERE A RENDER'S TIME GOES, one record per photograph a render or a film takes: the frame's
|
|
772
|
+
* transfer (from its first staged part to its present; none when nothing new was staged), building
|
|
773
|
+
* it into the view it is photographed from, and the photograph itself. Read by `render-movie`
|
|
774
|
+
* beside Blender's own time, so a slow film says which side is slow. */
|
|
775
|
+
const renderTimings: { stageMs: number; applyMs: number; photoMs: number }[] = [];
|
|
776
|
+
/** When the frame being transferred staged its first part, render or viewport; taken at its present. */
|
|
777
|
+
let frameStagedAt: number | null = null;
|
|
778
|
+
|
|
779
|
+
/** The film being encoded (`renderBlenderMovie`), one at a time, and the photographs of the frames
|
|
780
|
+
* rendered and not yet encoded, in order (base64 PNG). */
|
|
781
|
+
let film: { readonly movie: Movie; readonly photographs: string[] } | null = null;
|
|
782
|
+
|
|
783
|
+
async function movieFrames(numbers: readonly number[]): Promise<{ encoded: number; timing: { executeMs: number; stageMs: number; applyMs: number; photoMs: number; encodeMs: number } }> {
|
|
784
|
+
const current = film;
|
|
785
|
+
if (!current) throw new Error('No film is being rendered.');
|
|
786
|
+
const { movie } = current;
|
|
787
|
+
// ONE CALL TAKES THE CHUNK: each frame set, then photographed as the viewport presents it through
|
|
788
|
+
// the scene camera (session.py's `photograph_frame`): the present ships only what moved.
|
|
789
|
+
const code = `
|
|
790
|
+
import bpy, sys
|
|
791
|
+
photograph = next(m.photograph_frame for m in list(sys.modules.values()) if hasattr(m, "photograph_frame"))
|
|
792
|
+
s = bpy.context.scene
|
|
793
|
+
kept = s.frame_current
|
|
794
|
+
try:
|
|
795
|
+
for f in ${JSON.stringify(numbers)}:
|
|
796
|
+
s.frame_set(f)
|
|
797
|
+
photograph(${movie.width}, ${movie.height})
|
|
798
|
+
finally:
|
|
799
|
+
s.frame_set(kept)
|
|
800
|
+
`;
|
|
801
|
+
const timedFrom = renderTimings.length;
|
|
802
|
+
const executeAt = performance.now();
|
|
803
|
+
const answer = await blenderExecute(code, false, 'render-movie');
|
|
804
|
+
const executeMs = performance.now() - executeAt;
|
|
805
|
+
const taken = renderTimings.slice(timedFrom);
|
|
806
|
+
const encodeAt = performance.now();
|
|
807
|
+
if (!answer.executed) throw new Error(`Blender could not render frames ${numbers[0]}-${numbers.at(-1)}: ${answer.error}`);
|
|
808
|
+
const shots = current.photographs.splice(0);
|
|
809
|
+
if (shots.length !== numbers.length) throw new Error(`Blender rendered ${numbers.length} frames and ${shots.length} photographs were taken.`);
|
|
810
|
+
for (const shot of shots) {
|
|
811
|
+
const png = bytesFromBase64(shot);
|
|
812
|
+
const bitmap = await createImageBitmap(new Blob([png as BlobPart], { type: 'image/png' }));
|
|
813
|
+
try { await movie.frame(bitmap); } finally { bitmap.close(); }
|
|
814
|
+
}
|
|
815
|
+
const mean = (pick: (t: (typeof taken)[number]) => number): number => (taken.length ? taken.reduce((n, t) => n + pick(t), 0) / taken.length : 0);
|
|
816
|
+
const per = numbers.length || 1;
|
|
817
|
+
return { encoded: movie.frames, timing: { executeMs: executeMs / per, stageMs: mean((t) => t.stageMs), applyMs: mean((t) => t.applyMs), photoMs: mean((t) => t.photoMs), encodeMs: (performance.now() - encodeAt) / per } };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** The scene's sound over frames `from` to `to`, mixed, and a movie begun with it encoded first: its track starts at
|
|
821
|
+
* the movie's first frame, so a movie from a later frame hears the sound from that frame on. */
|
|
822
|
+
async function beginFilm(size: { readonly width: number; readonly height: number }, fps: number, from: number, to: number): Promise<{ strips: number; speakers: number } | null> {
|
|
823
|
+
const session = blenderRuntime();
|
|
824
|
+
const host = editorHost();
|
|
825
|
+
const project = host.projectLocalState.projectRootPath();
|
|
826
|
+
const sound = await mixSceneSound(await session.soundScene(from, to), async (file) => {
|
|
827
|
+
if ('packed' in file) return session.readFile(file.packed);
|
|
828
|
+
// a file in the project is read from the project; the engine mirrors the project at its own path
|
|
829
|
+
return project !== null && file.path.startsWith(`${project}/`)
|
|
830
|
+
? host.files.readBytes(file.path.slice(project.length + 1))
|
|
831
|
+
: session.readFile(file.path);
|
|
832
|
+
});
|
|
833
|
+
const movie = await beginMovie({ ...size, fps, ...(sound ? { audio: { sampleRate: sound.sampleRate, channels: sound.planes.length } } : {}) });
|
|
834
|
+
try {
|
|
835
|
+
if (sound) await movie.audio(sound.planes);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
movie.cancel();
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
film?.movie.cancel();
|
|
841
|
+
film = { movie, photographs: [] };
|
|
842
|
+
return sound ? { strips: sound.strips, speakers: sound.speakers } : null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** What a movie render is asked for: the project file it is written to, the `.blend` it renders (the open one by
|
|
846
|
+
* default), its first and last frames (frame numbers or marker names; the scene's range by default), its width (the
|
|
847
|
+
* scene's resolution by default; the height keeps the scene's aspect), and the frame rate a caller's own movie is at,
|
|
848
|
+
* which the scene's must equal. */
|
|
849
|
+
interface BlenderMovieRequest {
|
|
850
|
+
readonly out: string;
|
|
851
|
+
readonly document?: string;
|
|
852
|
+
readonly from?: string | number | undefined;
|
|
853
|
+
readonly to?: string | number | undefined;
|
|
854
|
+
readonly width?: number;
|
|
855
|
+
readonly fps?: number;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Where a movie render stands. */
|
|
859
|
+
interface BlenderMovieRender {
|
|
860
|
+
readonly out: string;
|
|
861
|
+
readonly state: 'rendering' | 'done' | 'failed';
|
|
862
|
+
readonly frame: number;
|
|
863
|
+
readonly frames: number;
|
|
864
|
+
readonly first?: number;
|
|
865
|
+
readonly last?: number;
|
|
866
|
+
readonly width?: number;
|
|
867
|
+
readonly height?: number;
|
|
868
|
+
readonly fps?: number;
|
|
869
|
+
/** The scene's sound the movie carries, or null when the scene plays none. */
|
|
870
|
+
readonly sound?: { readonly strips: number; readonly speakers: number } | null;
|
|
871
|
+
/** What the render cannot do as asked (a scene with no camera). */
|
|
872
|
+
readonly warnings?: readonly string[];
|
|
873
|
+
/** Where the last chunk's time went, per frame, in milliseconds: Blender's render call and, inside it, the frame's
|
|
874
|
+
* transfer, its build into the photograph view and the photograph; then the encode. */
|
|
875
|
+
readonly timing?: { readonly executeMs: number; readonly stageMs: number; readonly applyMs: number; readonly photoMs: number; readonly encodeMs: number };
|
|
876
|
+
readonly codec?: string;
|
|
877
|
+
readonly bytes?: number;
|
|
878
|
+
readonly error?: string;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** The movie renders, by the project file each writes. */
|
|
882
|
+
const movieRenders = commandJobs<BlenderMovieRender>();
|
|
883
|
+
|
|
884
|
+
/** The Timeline of `request.document` (or the open document) rendered with the scene's sound into `request.out`. */
|
|
885
|
+
async function renderBlenderMovie(request: BlenderMovieRequest, report: (render: BlenderMovieRender) => void): Promise<void> {
|
|
886
|
+
let render: BlenderMovieRender = { out: request.out, state: 'rendering', frame: 0, frames: 0 };
|
|
887
|
+
const host = editorHost();
|
|
888
|
+
if (request.document !== undefined) {
|
|
889
|
+
const opened = await host.documents.openForFile(request.document);
|
|
890
|
+
if (opened === null) throw new Error(`No Model document is ${request.document}.`);
|
|
891
|
+
await host.documents.waitForContext(opened);
|
|
892
|
+
}
|
|
893
|
+
const probe = await blenderExecute(`
|
|
894
|
+
import bpy, json
|
|
895
|
+
s = bpy.context.scene
|
|
896
|
+
r = s.render
|
|
897
|
+
print(json.dumps({"start": s.frame_start, "end": s.frame_end, "fps": r.fps / (r.fps_base or 1), "width": r.resolution_x, "height": r.resolution_y,
|
|
898
|
+
"percent": r.resolution_percentage, "camera": s.camera.name if s.camera else None,
|
|
899
|
+
"markers": {m.name: m.frame for m in s.timeline_markers}, "cameraMarkers": [m.frame for m in s.timeline_markers if m.camera]}))
|
|
900
|
+
`, false, 'render-movie');
|
|
901
|
+
if (!probe.executed) throw new Error(`Blender could not read the scene: ${probe.error}`);
|
|
902
|
+
const scene = JSON.parse(probe.result.slice(probe.result.indexOf('{'))) as {
|
|
903
|
+
start: number; end: number; fps: number; width: number; height: number; percent: number; camera: string | null;
|
|
904
|
+
markers: Record<string, number>; cameraMarkers: number[];
|
|
905
|
+
};
|
|
906
|
+
if (request.fps !== undefined && Math.abs(scene.fps - request.fps) > 1e-6)
|
|
907
|
+
throw new Error(`The scene plays at ${scene.fps} fps and the movie is at ${request.fps}: set the scene's frame rate to the movie's.`);
|
|
908
|
+
const frameOf = (at: string | number | undefined, fallback: number): number => {
|
|
909
|
+
if (at === undefined) return fallback;
|
|
910
|
+
if (typeof at === 'number') return at;
|
|
911
|
+
if (/^-?\d+$/.test(at)) return Number(at);
|
|
912
|
+
const marker = scene.markers[at];
|
|
913
|
+
if (marker === undefined) throw new Error(`The Timeline has no marker "${at}"; it has ${Object.keys(scene.markers).map((name) => `"${name}"`).join(', ') || 'none'}.`);
|
|
914
|
+
return marker;
|
|
915
|
+
};
|
|
916
|
+
const first = frameOf(request.from, scene.start);
|
|
917
|
+
const last = frameOf(request.to, scene.end);
|
|
918
|
+
if (last < first) throw new Error(`It ends (frame ${last}) before it starts (frame ${first}).`);
|
|
919
|
+
const width = request.width ?? Math.round(scene.width * (scene.percent / 100));
|
|
920
|
+
const height = Math.round(width * (scene.height / scene.width));
|
|
921
|
+
const warnings = !scene.camera && !scene.cameraMarkers.some((at) => at <= first)
|
|
922
|
+
? ['The scene has no camera; Blender renders through the scene camera or a marker-bound one.'] : [];
|
|
923
|
+
const sound = await beginFilm({ width, height }, scene.fps, first, last);
|
|
924
|
+
const movie = film!.movie;
|
|
925
|
+
render = { ...render, frames: last - first + 1, first, last, width: movie.width, height: movie.height, fps: scene.fps, sound, codec: movie.codec, warnings };
|
|
926
|
+
report(render);
|
|
927
|
+
for (let frame = first; frame <= last; frame += 12) {
|
|
928
|
+
const { encoded, timing } = await movieFrames(Array.from({ length: Math.min(12, last - frame + 1) }, (_, i) => frame + i));
|
|
929
|
+
render = { ...render, frame: encoded, timing };
|
|
930
|
+
report(render);
|
|
931
|
+
}
|
|
932
|
+
film = null;
|
|
933
|
+
const bytes = await movie.end();
|
|
934
|
+
await host.files.write(request.out, bytes);
|
|
935
|
+
report({ ...render, state: 'done', bytes: bytes.length });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Start rendering a movie, answering at once with where it stands. One render at a time. */
|
|
939
|
+
function startBlenderMovie(request: BlenderMovieRequest): BlenderMovieRender {
|
|
940
|
+
if (movieRenders.running()) throw new Error('A movie is already rendering.');
|
|
941
|
+
if (!/\.webm$/i.test(request.out)) throw new Error(`A movie is written as .webm, not ${request.out}.`);
|
|
942
|
+
return movieRenders.start(request.out, { out: request.out, state: 'rendering', frame: 0, frames: 0 }, (report) =>
|
|
943
|
+
renderBlenderMovie(request, report).catch((thrown: unknown) => {
|
|
944
|
+
film?.movie.cancel();
|
|
945
|
+
film = null;
|
|
946
|
+
throw thrown;
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
|
|
550
950
|
/**
|
|
551
951
|
* A Save As the worker reported (`BlenderRuntimeOptions.documentMoved`) that the open Model tab
|
|
552
952
|
* has not followed yet. Taken by the {@link blenderExecute} whose script moved it, once that
|
|
@@ -554,6 +954,9 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
554
954
|
*/
|
|
555
955
|
let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
556
956
|
|
|
957
|
+
/** A followed Save As waiting for the table to list its new file (`followMovedDocument`); calling it stops waiting. */
|
|
958
|
+
let awaitingListing: (() => void) | null = null;
|
|
959
|
+
|
|
557
960
|
/**
|
|
558
961
|
* THE MODEL TAB FOLLOWS BLENDER'S SAVE AS. The runtime already holds the new file and its saves
|
|
559
962
|
* go there (`session.py::follow_open_file`); what is left is the tab. Until it moves, the editor
|
|
@@ -563,13 +966,17 @@ let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; rea
|
|
|
563
966
|
* its binding. Re-activating the old tab later opens the old file the ordinary way.
|
|
564
967
|
*
|
|
565
968
|
* Only a tab that was showing the old file (or the standing Model of a project that had no
|
|
566
|
-
* `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A
|
|
567
|
-
*
|
|
969
|
+
* `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A file the
|
|
970
|
+
* project's finders list may not be in the project folder yet: a script's call through the
|
|
971
|
+
* project's Blender MCP answers before its Mirror lands the files the script saved, so the tab
|
|
972
|
+
* follows when the table first lists it. A new file outside every finder's `include` is said, not
|
|
973
|
+
* guessed at.
|
|
568
974
|
*/
|
|
569
975
|
async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
|
|
570
976
|
const move = unfollowedMove;
|
|
571
977
|
if (!move || move.owner !== owner) return;
|
|
572
978
|
unfollowedMove = null;
|
|
979
|
+
awaitingListing?.();
|
|
573
980
|
const host = editorHost();
|
|
574
981
|
// The tab showing Blender: the file Blender left, or, after an earlier Save As to an unlisted file, the tab
|
|
575
982
|
// that one left.
|
|
@@ -580,35 +987,56 @@ async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
|
|
|
580
987
|
const shown = shownModelBlend();
|
|
581
988
|
return shown === shownFrom || (shown === undefined && host.documents.activeId() === BLENDER_RUNTIME_DOCUMENT_ID);
|
|
582
989
|
};
|
|
990
|
+
const listing = (table: Awaited<ReturnType<EditorHostProject['documentTable']>>) =>
|
|
991
|
+
table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
|
|
992
|
+
/** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
|
|
993
|
+
const keepShowing = () => {
|
|
994
|
+
if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
995
|
+
};
|
|
996
|
+
const follow = async (id: string) => {
|
|
997
|
+
if (await host.workspace.open({ kind: 'document', id })) return;
|
|
998
|
+
keepShowing();
|
|
999
|
+
host.console.warn(`Blender now has ${move.document} open, but its Model document ${id} did not open; the ${shownFrom} tab keeps showing Blender, which edits ${move.document}.`, 'blender-document');
|
|
1000
|
+
};
|
|
1001
|
+
const failed = (error: unknown) => {
|
|
1002
|
+
keepShowing();
|
|
1003
|
+
host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
|
|
1004
|
+
};
|
|
583
1005
|
if (!stillShowingOld()) return;
|
|
584
1006
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!stillShowingOld()) return;
|
|
590
|
-
unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
591
|
-
host.console.warn(
|
|
592
|
-
`Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
|
|
593
|
-
`(the project's model finder does not include its folder), so the ${shownFrom} tab keeps showing ` +
|
|
594
|
-
`Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
|
|
595
|
-
`under src/models/ to give it a Model tab of its own.`,
|
|
596
|
-
'blender-document',
|
|
597
|
-
);
|
|
1007
|
+
const entry = listing(await host.project.documentTable());
|
|
1008
|
+
if (!stillShowingOld()) return;
|
|
1009
|
+
if (entry) {
|
|
1010
|
+
await follow(entry.id);
|
|
598
1011
|
return;
|
|
599
1012
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1013
|
+
// Meanwhile the tab works on the file Blender holds.
|
|
1014
|
+
unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
1015
|
+
if (host.project.finderIncludes(move.document)) {
|
|
1016
|
+
const stop = host.project.onDocumentTable((table) => {
|
|
1017
|
+
const listed = listing(table);
|
|
1018
|
+
if (!listed) return;
|
|
1019
|
+
release();
|
|
1020
|
+
if (!stillShowingOld()) return;
|
|
1021
|
+
unlistedFollow = null;
|
|
1022
|
+
follow(listed.id).catch(failed);
|
|
1023
|
+
});
|
|
1024
|
+
const release = () => {
|
|
1025
|
+
stop();
|
|
1026
|
+
if (awaitingListing === release) awaitingListing = null;
|
|
1027
|
+
};
|
|
1028
|
+
awaitingListing = release;
|
|
1029
|
+
return;
|
|
604
1030
|
}
|
|
1031
|
+
host.console.warn(
|
|
1032
|
+
`Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
|
|
1033
|
+
`(no document finder in the project's adapter includes it), so the ${shownFrom} tab keeps showing ` +
|
|
1034
|
+
`Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
|
|
1035
|
+
`where the adapter's finder lists models to give it a Model tab of its own.`,
|
|
1036
|
+
'blender-document',
|
|
1037
|
+
);
|
|
605
1038
|
} catch (error) {
|
|
606
|
-
|
|
607
|
-
host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
|
|
608
|
-
}
|
|
609
|
-
/** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
|
|
610
|
-
function keepShowing(): void {
|
|
611
|
-
if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move!.document };
|
|
1039
|
+
failed(error);
|
|
612
1040
|
}
|
|
613
1041
|
}
|
|
614
1042
|
|
|
@@ -648,26 +1076,19 @@ export async function blenderOutliner(
|
|
|
648
1076
|
/** THE NODE-TREE DOOR, in-page — one material's shader node tree, which the
|
|
649
1077
|
* Shading workspace's node view draws (`../contributions/blender-node-*`).
|
|
650
1078
|
* Same rule as the doors above: it never starts the engine. */
|
|
651
|
-
export async function blenderNodeTree(
|
|
652
|
-
path?: string;
|
|
653
|
-
material?: string;
|
|
654
|
-
}): Promise<BlenderNodeTree | null> {
|
|
1079
|
+
export async function blenderNodeTree(): Promise<BlenderNodeTree | null> {
|
|
655
1080
|
if (!blenderSessionStarted()) return null;
|
|
656
|
-
return blenderRuntime().nodeTree(
|
|
1081
|
+
return blenderRuntime().nodeTree();
|
|
657
1082
|
}
|
|
658
1083
|
|
|
659
1084
|
/** THE UV DOOR, in-page — one mesh's UV layout, which the UV Editing
|
|
660
1085
|
* workspace's view draws (`../contributions/blender-uv-*`). Same rule as the
|
|
661
1086
|
* doors above: it never starts the engine. */
|
|
662
|
-
export async function blenderUvLayout(
|
|
663
|
-
object?: string;
|
|
664
|
-
uvLayer?: string;
|
|
665
|
-
}): Promise<BlenderUvLayout | null> {
|
|
1087
|
+
export async function blenderUvLayout(): Promise<BlenderUvLayout | null> {
|
|
666
1088
|
if (!blenderSessionStarted()) return null;
|
|
667
|
-
return blenderRuntime().uvLayout(
|
|
1089
|
+
return blenderRuntime().uvLayout();
|
|
668
1090
|
}
|
|
669
1091
|
|
|
670
|
-
|
|
671
1092
|
/** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
|
|
672
1093
|
* scene's frame range and the Timeline's summary columns. Called when the
|
|
673
1094
|
* action's own revision moves, never per played frame. */
|
|
@@ -683,6 +1104,30 @@ export async function blenderActionClip(options?: {
|
|
|
683
1104
|
return blenderRuntime().actionClip(options);
|
|
684
1105
|
}
|
|
685
1106
|
|
|
1107
|
+
/** THE DOCUMENT AS A COMPONENT OR A LEVEL, by Blender's own glTF exporter (`session.py`'s
|
|
1108
|
+
* `gltf-export`, RFC 0007), and the bytes of a file it wrote in the module filesystem, for the
|
|
1109
|
+
* caller to carry into the project. Neither starts the engine. */
|
|
1110
|
+
export async function blenderGltfExport(options: { readonly kind: GltfExportKind; readonly force?: boolean; readonly previous?: GltfExportPrevious; readonly blendSha256?: string; readonly luts?: Readonly<Record<string, string>> }): Promise<GltfExportAnswer | null> {
|
|
1111
|
+
if (!blenderSessionStarted()) return null;
|
|
1112
|
+
return blenderRuntime().gltfExport(options);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** Present the document once now (after a game over the standard artifact ends: `blender-runtime.document.tsx`). */
|
|
1116
|
+
export async function blenderPresentDocument(): Promise<void> {
|
|
1117
|
+
if (!blenderSessionStarted()) return;
|
|
1118
|
+
await blenderRuntime().present();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export async function blenderEngineFile(path: string): Promise<Uint8Array> {
|
|
1122
|
+
return blenderRuntime().readFile(path);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Read the scene animation for the Timeline preview and glTF export (`session.py`'s
|
|
1126
|
+
* `rna_scene_movie`). Returns null without starting an absent engine session. */
|
|
1127
|
+
export async function blenderSceneMovie(): Promise<BlenderSceneMovie | null> {
|
|
1128
|
+
if (!blenderSessionStarted()) return null;
|
|
1129
|
+
return blenderRuntime().sceneMovie();
|
|
1130
|
+
}
|
|
686
1131
|
|
|
687
1132
|
export async function blenderOutlinerSet(
|
|
688
1133
|
path: string,
|
|
@@ -695,46 +1140,11 @@ export async function blenderOutlinerSet(
|
|
|
695
1140
|
return written;
|
|
696
1141
|
}
|
|
697
1142
|
|
|
698
|
-
/**
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
* `blender-node-view` verb below. One table, two doors — the shape
|
|
704
|
-
* `key-actions.ts`'s action table already has, and the reason the next
|
|
705
|
-
* three read-only editors (UV Editing, Animation, Texture Paint) add NO session
|
|
706
|
-
* verb of their own: they register here instead.
|
|
707
|
-
*
|
|
708
|
-
* `state` reads it; `look` follows a node with the N-panel (`node`, null to look
|
|
709
|
-
* at none); `view-all` is Blender's Home; `zoom` takes tree units per CSS px;
|
|
710
|
-
* `pan` takes the tree-space point at the view's centre (`cx`/`cy`). Every
|
|
711
|
-
* EDITING gesture is present and REFUSED by name with the same sentence the
|
|
712
|
-
* pointer handlers give, so the read-only ruling is provable from the product
|
|
713
|
-
* rather than merely implemented.
|
|
714
|
-
*/
|
|
715
|
-
export const NODE_VIEW_VERBS: ViewVerbContribution = {
|
|
716
|
-
view: 'blender-node-view',
|
|
717
|
-
title: 'Node Editor',
|
|
718
|
-
verbs: (
|
|
719
|
-
[
|
|
720
|
-
['state', undefined],
|
|
721
|
-
['look', undefined],
|
|
722
|
-
['view-all', 'Node Editor: Frame All'],
|
|
723
|
-
['zoom', undefined],
|
|
724
|
-
['pan', undefined],
|
|
725
|
-
['move', undefined],
|
|
726
|
-
['link', undefined],
|
|
727
|
-
['set-value', undefined],
|
|
728
|
-
['use-nodes', undefined],
|
|
729
|
-
['collapse-panel', undefined],
|
|
730
|
-
] as const
|
|
731
|
-
).map(([id, title]) => ({
|
|
732
|
-
id,
|
|
733
|
-
...(title ? { title } : {}),
|
|
734
|
-
run: (args?: Record<string, unknown>) =>
|
|
735
|
-
driveNodeView({ type: 'blender-node-view', ...args, action: id }),
|
|
736
|
-
})),
|
|
737
|
-
};
|
|
1143
|
+
/** Node-view actions share driveNodeView between page commands and the session relay. State reads, focus, framing, zoom and pan update inspection state; editing gestures refuse by name. */
|
|
1144
|
+
export const NODE_VIEW_VERBS: readonly ViewVerb[] = (['state', 'look', 'view-all', 'zoom', 'pan', 'move', 'link', 'set-value', 'use-nodes', 'collapse-panel'] as const).map((id) => ({
|
|
1145
|
+
id,
|
|
1146
|
+
run: (args?: Record<string, unknown>) => driveNodeView({ type: 'blender-node-view', ...args, action: id }),
|
|
1147
|
+
}));
|
|
738
1148
|
|
|
739
1149
|
/**
|
|
740
1150
|
* `blender-node-view` — the node editor's actions and its state, as one verb.
|
|
@@ -758,10 +1168,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
758
1168
|
break;
|
|
759
1169
|
case 'view-all':
|
|
760
1170
|
requestNodeViewAll();
|
|
761
|
-
//
|
|
762
|
-
// Measured live 2026-09-19: without this, a refusal outlived the gesture
|
|
763
|
-
// that raised it and the status line still carried it three actions
|
|
764
|
-
// later.
|
|
1171
|
+
// A successful framing action clears the prior refusal.
|
|
765
1172
|
setNodeViewState({ refusal: null });
|
|
766
1173
|
break;
|
|
767
1174
|
case 'zoom': {
|
|
@@ -781,9 +1188,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
781
1188
|
});
|
|
782
1189
|
break;
|
|
783
1190
|
}
|
|
784
|
-
//
|
|
785
|
-
// refusals the pointer handlers raise, reachable from the session so the
|
|
786
|
-
// ruling is provable rather than merely implemented.
|
|
1191
|
+
// Expose the same named editing refusals through commands and pointer handlers.
|
|
787
1192
|
case 'move':
|
|
788
1193
|
refuseNodeViewGesture(
|
|
789
1194
|
`Moving "${String(cmd['node'] ?? 'a node')}" writes Node.location — editing parity is not the program.`,
|
|
@@ -825,7 +1230,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
825
1230
|
interface RuntimeView {
|
|
826
1231
|
readonly root: THREE.Group;
|
|
827
1232
|
stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
|
|
828
|
-
applyFrame(frame: unknown):
|
|
1233
|
+
applyFrame(frame: unknown): ReturnType<BlenderRuntimeView['applyFrame']>;
|
|
829
1234
|
snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
|
|
830
1235
|
drawStatistics(): ReturnType<BlenderRuntimeView['drawStatistics']>;
|
|
831
1236
|
setDrawBatching(enabled: boolean): void;
|
|
@@ -846,6 +1251,8 @@ interface RuntimeView {
|
|
|
846
1251
|
|
|
847
1252
|
let runtime: BlenderRuntime | null = null;
|
|
848
1253
|
const historyResources = new Set<string>();
|
|
1254
|
+
/** Which document Blender holds, counted: each one given up ({@link forgetDocument}) is a new count. */
|
|
1255
|
+
let heldDocument = 0;
|
|
849
1256
|
|
|
850
1257
|
function invalidateBlenderHistory(): void {
|
|
851
1258
|
if (historyResources.size) editorHost().history.invalidate([...historyResources]);
|
|
@@ -857,35 +1264,40 @@ let captureLifetime: AbortController | null = null;
|
|
|
857
1264
|
* terminate, and the host door only exists inside a running editor. */
|
|
858
1265
|
let watchingSessionEnd = false;
|
|
859
1266
|
|
|
860
|
-
/**
|
|
861
|
-
|
|
1267
|
+
/** Blender's work as a wait (`session.beginWork`): one a person follows ("Starting Blender") is theirs and the
|
|
1268
|
+
* session's, and an engine's own step (`internal`) the session's alone. Reporting it must not prevent teardown if the
|
|
1269
|
+
* host has already detached. */
|
|
1270
|
+
function beginBlenderWork(label: string, internal = false): () => void {
|
|
862
1271
|
try {
|
|
863
|
-
const end = editorHost().session.beginWork(
|
|
1272
|
+
const end = editorHost().session.beginWork(label, internal ? { internal } : undefined);
|
|
864
1273
|
return () => { try { end(); } catch { /* preserve the operation's result */ } };
|
|
865
1274
|
} catch { return () => {}; }
|
|
866
1275
|
}
|
|
867
1276
|
|
|
1277
|
+
/** What belongs to the document Blender held and goes with it: its history, its save notices and a Save As the tab
|
|
1278
|
+
* had yet to follow. */
|
|
1279
|
+
function forgetDocument(): void {
|
|
1280
|
+
heldDocument += 1;
|
|
1281
|
+
invalidateBlenderHistory();
|
|
1282
|
+
clearModelDocumentSaving();
|
|
1283
|
+
unfollowedMove = null;
|
|
1284
|
+
unlistedFollow = null;
|
|
1285
|
+
awaitingListing?.();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
868
1288
|
function terminateBlenderRuntime(): void {
|
|
869
|
-
const end = beginBlenderWork('
|
|
1289
|
+
const end = beginBlenderWork('releasing the runtime', true);
|
|
870
1290
|
try {
|
|
871
|
-
|
|
1291
|
+
forgetDocument();
|
|
872
1292
|
captureLifetime?.abort();
|
|
873
1293
|
captureLifetime = null;
|
|
874
1294
|
runtime?.terminate();
|
|
875
1295
|
runtime = null;
|
|
876
|
-
|
|
1296
|
+
if (startingSession !== null) setStarting(null);
|
|
877
1297
|
} finally { end(); }
|
|
878
1298
|
}
|
|
879
1299
|
|
|
880
|
-
//
|
|
881
|
-
// `tab-close` calls `window.close()`, which Chrome refuses for a tab a person
|
|
882
|
-
// opened, and falls back to a "Session ended" notice -- a repaint of the
|
|
883
|
-
// document that terminates nothing. The Blender worker kept its whole engine
|
|
884
|
-
// resident behind that notice: measured 2026-09-17, two orphaned tabs from
|
|
885
|
-
// two editor restarts held 13 GB and 7 GB between them and put the box into a
|
|
886
|
-
// swap storm (26 GB of swap, kernel_task at 245%). `session.onEnded` is the
|
|
887
|
-
// host's one signal for every session end, graceful or not, so the worker is
|
|
888
|
-
// terminated there.
|
|
1300
|
+
// Terminate the Blender worker on session.onEnded, including when the browser refuses window.close on a person-opened tab.
|
|
889
1301
|
function watchSessionEnd(): void {
|
|
890
1302
|
if (watchingSessionEnd) return;
|
|
891
1303
|
watchingSessionEnd = true;
|
|
@@ -895,10 +1307,9 @@ function watchSessionEnd(): void {
|
|
|
895
1307
|
|
|
896
1308
|
async function stopBlenderRuntime(): Promise<void> {
|
|
897
1309
|
const owner = runtime;
|
|
898
|
-
await owner
|
|
1310
|
+
if (owner) await retireRuntime(owner);
|
|
899
1311
|
if (runtime === owner) terminateBlenderRuntime();
|
|
900
1312
|
}
|
|
901
|
-
let lastCapture: CaptureRequest | null = null;
|
|
902
1313
|
|
|
903
1314
|
const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
904
1315
|
typeof value === 'object' &&
|
|
@@ -913,13 +1324,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
|
913
1324
|
|
|
914
1325
|
async function runtimeView(): Promise<RuntimeView> {
|
|
915
1326
|
const { documents } = editorHost();
|
|
916
|
-
//
|
|
917
|
-
// on the Model document's binding (`presentationDocumentId`), and at startup
|
|
918
|
-
// a present can begin before that pane has bound: reading the id once made
|
|
919
|
-
// it wait the whole window for `document:blender:runtime`, which a
|
|
920
|
-
// file-backed Model never publishes, while its own document published beside
|
|
921
|
-
// it -- the battery's preflight failed exactly so, 18 s after its first
|
|
922
|
-
// command (3 s of boot, then the full wait).
|
|
1327
|
+
// Re-read the Model binding while waiting: a presentation can begin before its pane publishes the final document address.
|
|
923
1328
|
const deadline = Date.now() + 15_000;
|
|
924
1329
|
let id = presentationDocumentId();
|
|
925
1330
|
let publication = 'no context';
|
|
@@ -946,9 +1351,18 @@ async function runtimeView(): Promise<RuntimeView> {
|
|
|
946
1351
|
);
|
|
947
1352
|
}
|
|
948
1353
|
|
|
1354
|
+
/** The page's Blender for a call on its document: refused while it is on another document than the editor shows, and
|
|
1355
|
+
* while it has started holding none. */
|
|
949
1356
|
export function blenderRuntime(): BlenderRuntime {
|
|
950
1357
|
const conflict = modelDocumentConflict();
|
|
951
1358
|
if (conflict) throw new Error(conflict);
|
|
1359
|
+
if (runtime?.project != null && runtime.document === null)
|
|
1360
|
+
throw new Error('Blender has started and holds no model yet: open a Model document (blender-start opens the one the editor shows).');
|
|
1361
|
+
return pageRuntime();
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** The page's one Blender handle, made if there is none (not started: {@link startedRuntime} starts it). */
|
|
1365
|
+
function pageRuntime(): BlenderRuntime {
|
|
952
1366
|
if (runtime !== null) return runtime;
|
|
953
1367
|
watchSessionEnd();
|
|
954
1368
|
const lifetime = new AbortController();
|
|
@@ -965,12 +1379,20 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
965
1379
|
const discardStreamedFrame = () => {
|
|
966
1380
|
const staged = streamedFrame;
|
|
967
1381
|
streamedFrame = null;
|
|
1382
|
+
frameStagedAt = null;
|
|
968
1383
|
if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
|
|
969
1384
|
discardRenderFrame();
|
|
970
1385
|
};
|
|
971
1386
|
lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
|
|
972
1387
|
runtime = new BlenderRuntime({
|
|
973
|
-
|
|
1388
|
+
// (the worker's own steps: the session's phase, never the person's status bar)
|
|
1389
|
+
work: (label) => beginBlenderWork(label, true),
|
|
1390
|
+
documentSaving: ({ document, note }) => {
|
|
1391
|
+
if (!lifetime.signal.aborted) setModelDocumentSaveNotice(document, note);
|
|
1392
|
+
},
|
|
1393
|
+
documentDirty: () => {
|
|
1394
|
+
for (const listener of [...modelDirtyListeners]) listener();
|
|
1395
|
+
},
|
|
974
1396
|
history: (entries) => {
|
|
975
1397
|
const owner = runtime;
|
|
976
1398
|
if (!owner) throw new Error('Blender history arrived before its runtime');
|
|
@@ -981,8 +1403,11 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
981
1403
|
}
|
|
982
1404
|
if (!entry.resource) throw new Error('A Blender edit has no document resource');
|
|
983
1405
|
historyResources.add(entry.resource);
|
|
1406
|
+
// (the document it was made in, through a script's Save As, which keeps Blender's history; not one opened since)
|
|
1407
|
+
const held = heldDocument;
|
|
984
1408
|
const restore = async (direction: 'undo' | 'redo'): Promise<boolean> => {
|
|
985
1409
|
if (runtime !== owner) throw new Error('This Blender history belongs to a closed worker');
|
|
1410
|
+
if (heldDocument !== held) throw new Error(`This Blender history belongs to ${entry.resource}, which Blender no longer holds`);
|
|
986
1411
|
const moved = await owner.historyStep(entry.id, direction);
|
|
987
1412
|
noteBlenderRnaChanged();
|
|
988
1413
|
// The restored frame arrives before the Outliner's scheduled read.
|
|
@@ -1013,6 +1438,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1013
1438
|
if (part.evaluation === 'render') {
|
|
1014
1439
|
if (!renderFrame) {
|
|
1015
1440
|
if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
|
|
1441
|
+
frameStagedAt = performance.now();
|
|
1016
1442
|
const detached = BlenderRuntimeView.forPhotograph();
|
|
1017
1443
|
view.root.updateWorldMatrix(true, false);
|
|
1018
1444
|
detached.root.matrix.copy(view.root.matrixWorld);
|
|
@@ -1026,6 +1452,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1026
1452
|
if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
|
|
1027
1453
|
if (streamedFrame && streamedFrame.view !== view)
|
|
1028
1454
|
throw new Error('Blender presenter changed during frame transfer');
|
|
1455
|
+
if (!streamedFrame) frameStagedAt = performance.now();
|
|
1029
1456
|
view.stageFrame(part);
|
|
1030
1457
|
streamedFrame = { view, session: part.session, revision: part.revision };
|
|
1031
1458
|
},
|
|
@@ -1046,7 +1473,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1046
1473
|
return {};
|
|
1047
1474
|
}
|
|
1048
1475
|
const documentId = presentationDocumentId();
|
|
1049
|
-
const endWait = beginBlenderWork('waiting for Model presenter');
|
|
1476
|
+
const endWait = beginBlenderWork('waiting for the Model presenter', true);
|
|
1050
1477
|
let view: RuntimeView;
|
|
1051
1478
|
try { view = await runtimeView(); } finally { endWait(); }
|
|
1052
1479
|
lifetime.signal.throwIfAborted();
|
|
@@ -1060,31 +1487,26 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1060
1487
|
if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
|
|
1061
1488
|
throw new Error('Blender capture source changed before render presentation');
|
|
1062
1489
|
const presenter = rendering?.view ?? view;
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
// the two come apart whenever the Model document is rebuilt under a
|
|
1067
|
-
// still-running worker (`blender-runtime-view.ts::applyFrame` says how).
|
|
1068
|
-
// A document that answers `applyFrame` without one reports no `held` at
|
|
1069
|
-
// all, rather than a wrong `null`.
|
|
1070
|
-
const endApply = beginBlenderWork('applying frame to Model');
|
|
1490
|
+
// Report the presenter’s holding before applying this frame.
|
|
1491
|
+
const endApply = beginBlenderWork('applying a frame to the Model', true);
|
|
1492
|
+
const applyAt = performance.now();
|
|
1071
1493
|
const applied = (() => {
|
|
1072
|
-
try { return presenter.applyFrame(frame)
|
|
1494
|
+
try { return presenter.applyFrame(frame); }
|
|
1073
1495
|
finally { endApply(); }
|
|
1074
1496
|
})();
|
|
1497
|
+
const appliedAt = performance.now();
|
|
1498
|
+
const stagedAt = frameStagedAt;
|
|
1499
|
+
frameStagedAt = null;
|
|
1075
1500
|
streamedFrame = null;
|
|
1076
|
-
const reports = typeof applied === 'object' && applied !== null && 'held' in applied;
|
|
1077
|
-
const held = reports ? (applied.held as { session: string; revision: number } | null) : null;
|
|
1078
1501
|
const answer = (capture: unknown): PresentAnswer => ({
|
|
1079
1502
|
...(capture === undefined ? {} : { capture }),
|
|
1080
|
-
|
|
1503
|
+
held: applied.held,
|
|
1081
1504
|
});
|
|
1082
1505
|
// THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
|
|
1083
1506
|
// a refused frame is not displayed and must not be described as if it
|
|
1084
1507
|
// were.
|
|
1085
1508
|
presenter.recordPresentation(description);
|
|
1086
1509
|
if (!capture) return answer(undefined);
|
|
1087
|
-
lastCapture = capture;
|
|
1088
1510
|
if (!capture.render) return answer(undefined);
|
|
1089
1511
|
const presented = view.snapshot();
|
|
1090
1512
|
// A COMPOSITED FRAME IS NOT PHOTOGRAPHED. The compositor already
|
|
@@ -1110,14 +1532,10 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1110
1532
|
// through THAT matrix -- copied into the detached snapshot, never written out
|
|
1111
1533
|
// here as a quarter turn -- and a fresh camera is placed on it.
|
|
1112
1534
|
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// produced a level side view. Every hero image the battery ever graded
|
|
1118
|
-
// came from a misplaced camera. The old path also moved the VIEWPORT onto
|
|
1119
|
-
// the render camera and moved it back; a render that never touches the
|
|
1120
|
-
// viewport needs no such dance, so it is gone rather than made safe.
|
|
1535
|
+
// Handing the raw Blender numbers to `presentEditorView` would read them
|
|
1536
|
+
// as three.js world space and photograph the model from a misplaced
|
|
1537
|
+
// camera, and a render that never touches the viewport needs no move of
|
|
1538
|
+
// the VIEWPORT onto the render camera and back.
|
|
1121
1539
|
const { render, position, target, up } = capture;
|
|
1122
1540
|
if (!position || !target || !up)
|
|
1123
1541
|
throw new Error(
|
|
@@ -1151,22 +1569,13 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1151
1569
|
renderCamera.position.copy(eye);
|
|
1152
1570
|
renderCamera.lookAt(focus);
|
|
1153
1571
|
renderCamera.updateMatrixWorld(true);
|
|
1154
|
-
//
|
|
1155
|
-
// it equals what it sent, with no tolerance: the model root's matrix is an
|
|
1156
|
-
// exact signed axis permutation, so a vector through it and its inverse is
|
|
1157
|
-
// bit-identical. A mismatch means the conversion above is wrong, and the
|
|
1158
|
-
// whole point of this instrument is that a misplaced render camera is
|
|
1159
|
-
// otherwise invisible -- photograph-vs-Cycles is a declared difference, so
|
|
1160
|
-
// the battery's image rows hid it for the entire life of this lane.
|
|
1572
|
+
// Transform the photograph’s pose back through the exact signed axis permutation. Python compares it with the submitted pose and rejects any mismatch.
|
|
1161
1573
|
const photographedFrom = {
|
|
1162
1574
|
position: eye.clone().applyMatrix4(toBlender).toArray(),
|
|
1163
1575
|
target: focus.clone().applyMatrix4(toBlender).toArray(),
|
|
1164
1576
|
up: upward.clone().applyMatrix3(blenderBasis).toArray(),
|
|
1165
1577
|
};
|
|
1166
|
-
//
|
|
1167
|
-
// two poses. The assertion on the other side THROWS on a mismatch, and
|
|
1168
|
-
// an exception carries the defect out of reach of a harness grading the
|
|
1169
|
-
// run afterwards; both poses have to survive it.
|
|
1578
|
+
// Retain both poses before capture and Python validation, including a refused photograph.
|
|
1170
1579
|
view.recordPhotograph({
|
|
1171
1580
|
sent: { position, target, up },
|
|
1172
1581
|
photographed: photographedFrom,
|
|
@@ -1177,7 +1586,15 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1177
1586
|
orthographic: render.orthographic,
|
|
1178
1587
|
},
|
|
1179
1588
|
});
|
|
1589
|
+
const photoAt = performance.now();
|
|
1180
1590
|
const display = await photographSnapshot(scene, renderCamera, snapshot, render, assertBinding);
|
|
1591
|
+
// Queue each photograph directly for the movie encoder.
|
|
1592
|
+
if (film) film.photographs.push(display.base64);
|
|
1593
|
+
// (a film's frames are viewport photographs, `rendering` null: they are timed too)
|
|
1594
|
+
if (rendering || film) {
|
|
1595
|
+
renderTimings.push({ stageMs: stagedAt === null ? 0 : applyAt - stagedAt, applyMs: appliedAt - applyAt, photoMs: performance.now() - photoAt });
|
|
1596
|
+
if (renderTimings.length > 64) renderTimings.shift();
|
|
1597
|
+
}
|
|
1181
1598
|
return answer({ ...display, camera: photographedFrom });
|
|
1182
1599
|
} finally {
|
|
1183
1600
|
photographing = false;
|
|
@@ -1220,25 +1637,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1220
1637
|
return runtime;
|
|
1221
1638
|
}
|
|
1222
1639
|
|
|
1223
|
-
/** The
|
|
1224
|
-
export function lastBlenderCapture(): CaptureRequest | null {
|
|
1225
|
-
return lastCapture;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* THE SESSION'S DOCUMENT when `blender-start` does not name one.
|
|
1230
|
-
*
|
|
1231
|
-
* Persistence is ON by default, because the job this lane exists to do is to
|
|
1232
|
-
* not lose what a script modelled: before this, closing the tab lost it, with
|
|
1233
|
-
* no `.blend` anywhere. `models/` rather than `public/` is what says a
|
|
1234
|
-
* document is the SOURCE a shipped artifact is exported from, not the artifact
|
|
1235
|
-
* — which is also why it carries no provenance record (`volter blender-mcp`'s
|
|
1236
|
-
* Mirror owns that line, for `public/`).
|
|
1237
|
-
*
|
|
1238
|
-
* It is the fallback only. A project that declares the `model` finder lists
|
|
1239
|
-
* its OWN `.blend` files, and the document opened for one of those binds the
|
|
1240
|
-
* session to it ({@link openModelDocumentBlend}).
|
|
1241
|
-
*/
|
|
1640
|
+
/** The default session document persists under models/ as source. A project’s declared Model file takes precedence. Public exported artifacts are recorded by the MCP mirror. */
|
|
1242
1641
|
const DEFAULT_BLENDER_DOCUMENT = 'models/model.blend';
|
|
1243
1642
|
|
|
1244
1643
|
const string = (cmd: Record<string, unknown>, key: string): string => {
|
|
@@ -1287,7 +1686,10 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1287
1686
|
data: {
|
|
1288
1687
|
started: runtime?.project != null,
|
|
1289
1688
|
document: host.documents.context(presentationDocumentId()) !== undefined,
|
|
1290
|
-
|
|
1689
|
+
// (the `.blend` Blender holds: none after a start as the project opened, before a model's open)
|
|
1690
|
+
holds: runtime?.document ?? null,
|
|
1691
|
+
instancing: runtime?.project != null && host.documents.context(presentationDocumentId()) !== undefined
|
|
1692
|
+
? (await runtimeView()).drawStatistics() : null,
|
|
1291
1693
|
lastOutput: runtime?.lastOutput ?? [],
|
|
1292
1694
|
},
|
|
1293
1695
|
};
|
|
@@ -1342,44 +1744,43 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1342
1744
|
'opens that document (the Model document ships with @volter/editor-blender).',
|
|
1343
1745
|
};
|
|
1344
1746
|
}
|
|
1747
|
+
if (cmd.type === 'blender-start') {
|
|
1748
|
+
const conflict = modelDocumentConflict();
|
|
1749
|
+
if (conflict) throw new Error(conflict);
|
|
1750
|
+
// A start that fails is discarded (`startedRuntime`), so the next `blender-start` makes a
|
|
1751
|
+
// new Blender instead of awaiting the same failure.
|
|
1752
|
+
const session = startedRuntime(project!);
|
|
1753
|
+
// THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
|
|
1754
|
+
// project's document is its own `src/models/<name>.blend`, and a
|
|
1755
|
+
// `blender-start` that named none is asking for the session this tab
|
|
1756
|
+
// is showing, not for a second file beside it.
|
|
1757
|
+
// After a script's Save As to a file no Model lists, the session this tab
|
|
1758
|
+
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1759
|
+
// asks for it, not for the file the tab was opened on.
|
|
1760
|
+
// The shown file is read as `modelDocumentConflict` reads it (active tab first): the bound pane lags a
|
|
1761
|
+
// tab that just followed a Save As (`../src/start-document.ts`).
|
|
1762
|
+
const document = startDocument({
|
|
1763
|
+
requested: requestedDocument,
|
|
1764
|
+
shown: shownModelBlend(),
|
|
1765
|
+
follow: unlistedFollow !== null && unlistedFollow.owner === session ? unlistedFollow : null,
|
|
1766
|
+
fallback: DEFAULT_BLENDER_DOCUMENT,
|
|
1767
|
+
});
|
|
1768
|
+
const started = await session.start(project!);
|
|
1769
|
+
// A Model document's own open puts its file in this Blender, and this waits for it; a Blender
|
|
1770
|
+
// still holding none (the standing Model of a project with no `.blend`) opens the one asked for.
|
|
1771
|
+
const opened = await queueModelLifecycle(async (): Promise<DocumentOpened | null> => {
|
|
1772
|
+
if (runtime !== session) throw new Error('Blender was discarded before it held a model (its start or open failed); start it again.');
|
|
1773
|
+
if (session.document === null) return openInRuntime(session, document);
|
|
1774
|
+
if (session.document !== document)
|
|
1775
|
+
throw new Error(`Blender resource conflict: the session holds ${session.document}; cannot open ${document}`);
|
|
1776
|
+
return null;
|
|
1777
|
+
});
|
|
1778
|
+
return { ok: true, data: { ...started, ...(opened ?? { document }) } };
|
|
1779
|
+
}
|
|
1345
1780
|
const session = blenderRuntime();
|
|
1346
1781
|
switch (cmd.type) {
|
|
1347
|
-
case 'blender-start': {
|
|
1348
|
-
// THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
|
|
1349
|
-
// project's document is its own `src/models/<name>.blend`, and a
|
|
1350
|
-
// `blender-start` that named none is asking for the session this tab
|
|
1351
|
-
// is showing, not for a second file beside it.
|
|
1352
|
-
// After a script's Save As to a file no Model lists, the session this tab
|
|
1353
|
-
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1354
|
-
// asks for it, not for the file the tab was opened on.
|
|
1355
|
-
const shown =
|
|
1356
|
-
unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
|
|
1357
|
-
? unlistedFollow.document
|
|
1358
|
-
: boundModel?.blend;
|
|
1359
|
-
const document =
|
|
1360
|
-
requestedDocument !== undefined
|
|
1361
|
-
? requestedDocument
|
|
1362
|
-
: (shown ?? DEFAULT_BLENDER_DOCUMENT);
|
|
1363
|
-
try {
|
|
1364
|
-
return { ok: true, data: { ...(await session.start(project!, document)) } };
|
|
1365
|
-
} catch (error) {
|
|
1366
|
-
// As the document's own open does: a session whose start failed is discarded, so the
|
|
1367
|
-
// next `blender-start` makes a new one instead of awaiting the same failure.
|
|
1368
|
-
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
1369
|
-
throw error;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
1782
|
case 'blender-execute': {
|
|
1373
|
-
//
|
|
1374
|
-
// rule `blender-rna-set` below follows, and for the same reason: the
|
|
1375
|
-
// RNA version is minted at the door (ruling 3, 2026-09-19), and
|
|
1376
|
-
// ARBITRARY bpy can change anything RNA answers, so a script that
|
|
1377
|
-
// reached the engine around it would leave every view drawing the tree
|
|
1378
|
-
// it had. One path for a `cyclotron eval`, an MCP call and a panel's own
|
|
1379
|
-
// operator.
|
|
1380
|
-
// THE DOOR'S TEXT, verbatim — `execute_blender_code`'s MCP contract is
|
|
1381
|
-
// that one string, so the wire keeps answering it while the in-page
|
|
1382
|
-
// callers read the parsed halves beside it.
|
|
1783
|
+
// Use the in-page script door so every bpy write bumps the shared RNA version. Wire callers receive the original MCP text.
|
|
1383
1784
|
const answer = await blenderExecute(string(cmd, 'code'));
|
|
1384
1785
|
return { ok: true, data: { result: answer.text } };
|
|
1385
1786
|
}
|
|
@@ -1404,10 +1805,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1404
1805
|
}
|
|
1405
1806
|
case 'blender-rna-set': {
|
|
1406
1807
|
const index = typeof cmd['index'] === 'number' ? cmd['index'] : undefined;
|
|
1407
|
-
//
|
|
1408
|
-
// where the RNA version is minted (ruling 3, 2026-09-19), and a write
|
|
1409
|
-
// that reached the engine around it would leave every view drawing the
|
|
1410
|
-
// tree it had — which is exactly the defect the version exists for.
|
|
1808
|
+
// Use the in-page RNA write door so every write bumps the shared version.
|
|
1411
1809
|
return {
|
|
1412
1810
|
ok: true,
|
|
1413
1811
|
data: {
|
|
@@ -1473,30 +1871,11 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1473
1871
|
// is a projection of the tree the door already answered, so its actions
|
|
1474
1872
|
// are pure state (`src/node-view-state.ts`).
|
|
1475
1873
|
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
// answered with.
|
|
1482
|
-
case 'blender-node-view': {
|
|
1483
|
-
const action = typeof cmd['action'] === 'string' ? cmd['action'] : 'state';
|
|
1484
|
-
// An action the TABLE does not carry is still the VIEW's refusal, not the
|
|
1485
|
-
// registry's: I5's proof is that an unknown action names the verb's whole
|
|
1486
|
-
// vocabulary IN THE VIEW's own frame warning, and a thrown registry message
|
|
1487
|
-
// would move that refusal somewhere the view does not draw. So the registry
|
|
1488
|
-
// answers when it can and `driveNodeView`'s own default answers when it
|
|
1489
|
-
// cannot — one implementation either way.
|
|
1490
|
-
const known = NODE_VIEW_VERBS.verbs.some((verb) => verb.id === action);
|
|
1491
|
-
return {
|
|
1492
|
-
ok: true,
|
|
1493
|
-
data: {
|
|
1494
|
-
result: known
|
|
1495
|
-
? (invokeViewVerb('blender-node-view', action, cmd) as NodeViewState)
|
|
1496
|
-
: driveNodeView(cmd),
|
|
1497
|
-
},
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1874
|
+
// The same table the `volter.blender-node-view.<verb>` commands run
|
|
1875
|
+
// (`NODE_VIEW_VERBS`): every action is `driveNodeView`'s, and an action it
|
|
1876
|
+
// does not carry is refused by its own default, naming the vocabulary.
|
|
1877
|
+
case 'blender-node-view':
|
|
1878
|
+
return { ok: true, data: { result: driveNodeView(cmd) } };
|
|
1500
1879
|
case 'blender-outliner-set':
|
|
1501
1880
|
return {
|
|
1502
1881
|
ok: true,
|
|
@@ -1528,10 +1907,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1528
1907
|
return { ok: true, data: { ...preview } };
|
|
1529
1908
|
}
|
|
1530
1909
|
case 'blender-read-file': {
|
|
1531
|
-
//
|
|
1532
|
-
// `/__editor/blender-file` in `server/routes/relay.ts` for the
|
|
1533
|
-
// measurement that moved them. The caller mints the transfer id, so
|
|
1534
|
-
// the page never names anything on the host's disk.
|
|
1910
|
+
// Transfer bytes in the HTTP body under a caller-minted id; return only the size in the command answer.
|
|
1535
1911
|
const bytes = await session.readFile(string(cmd, 'path'));
|
|
1536
1912
|
const transfer = string(cmd, 'transferId');
|
|
1537
1913
|
const posted = await fetch(`/__editor/blender-file?id=${encodeURIComponent(transfer)}`, {
|
|
@@ -1543,9 +1919,32 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1543
1919
|
return { ok: false, error: `Transfer ${transfer} was refused: ${posted.status}` };
|
|
1544
1920
|
return { ok: true, data: { bytes: bytes.length } };
|
|
1545
1921
|
}
|
|
1922
|
+
// A FILM, RENDERED (`cyclotron render-movie`, a film's Blender shots): started here, answering at once, and
|
|
1923
|
+
// followed until it ends (`renderBlenderMovie`).
|
|
1924
|
+
case 'blender-render-movie': {
|
|
1925
|
+
const optional = (key: string): string | number | undefined => {
|
|
1926
|
+
const value = cmd[key];
|
|
1927
|
+
if (value === undefined || typeof value === 'number' || typeof value === 'string') return value;
|
|
1928
|
+
throw new Error(`${key} is a frame number or a marker name.`);
|
|
1929
|
+
};
|
|
1930
|
+
const width = cmd['width'];
|
|
1931
|
+
const fps = cmd['fps'];
|
|
1932
|
+
const document = cmd['document'];
|
|
1933
|
+
return {
|
|
1934
|
+
ok: true,
|
|
1935
|
+
data: { ...startBlenderMovie({
|
|
1936
|
+
out: string(cmd, 'out'),
|
|
1937
|
+
...(typeof document === 'string' ? { document } : {}),
|
|
1938
|
+
from: optional('from'), to: optional('to'),
|
|
1939
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
1940
|
+
...(typeof fps === 'number' ? { fps } : {}),
|
|
1941
|
+
}) },
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
case 'blender-render-status':
|
|
1945
|
+
return { ok: true, data: { ...(await movieRenders.status(string(cmd, 'out'))) } };
|
|
1546
1946
|
case 'blender-write-file': {
|
|
1547
|
-
const
|
|
1548
|
-
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1947
|
+
const bytes = bytesFromBase64(string(cmd, 'base64'));
|
|
1549
1948
|
await session.writeFile(string(cmd, 'path'), bytes);
|
|
1550
1949
|
return { ok: true, data: { written: bytes.length } };
|
|
1551
1950
|
}
|
|
@@ -1563,23 +1962,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1563
1962
|
entries,
|
|
1564
1963
|
session: document.session,
|
|
1565
1964
|
revision: document.revision,
|
|
1566
|
-
//
|
|
1567
|
-
// exported from a `.blend`, and that path is the one fact that
|
|
1568
|
-
// lets a reader go DOWN by kind later — from a prefab's glTF to
|
|
1569
|
-
// the model document it came from (ARCHITECTURE-CORE §Roots,
|
|
1570
|
-
// "Drilling goes DOWN by kind"). Taken at the instant of the
|
|
1571
|
-
// listing, for the same reason the session identity is.
|
|
1572
|
-
//
|
|
1573
|
-
// IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
|
|
1574
|
-
// is a measurement rather than a preference. `boundModel` is the
|
|
1575
|
-
// document the TAB opened; a bake script opens its own file with
|
|
1576
|
-
// `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
|
|
1577
|
-
// the tab only after the call (`followMovedDocument`), and never
|
|
1578
|
-
// for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
|
|
1579
|
-
// the SAME `.blend` as the input of all three of first-person's
|
|
1580
|
-
// characters — whichever one the tab happened to show — which is
|
|
1581
|
-
// worse than no record, because it is a confident wrong answer.
|
|
1582
|
-
// `bpy.data.filepath` is what the export actually came out of.
|
|
1965
|
+
// Read bpy.data.filepath at listing time as the exported files’ input. A script may have opened a different file before the tab follows that move.
|
|
1583
1966
|
...sessionDocumentPath(document.filepath),
|
|
1584
1967
|
},
|
|
1585
1968
|
};
|