@volter/editor-blender 0.1.151 → 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/contributions/blender-model-opening.tsx +25 -51
- package/contributions/blender-node-editor.document.tsx +2 -1
- package/contributions/blender-play-area.tsx +3 -4
- package/contributions/blender-properties-model.ts +44 -1
- package/contributions/blender-properties-view.tsx +24 -34
- package/contributions/blender-runtime.document.tsx +59 -385
- package/contributions/blender-timeline.document.tsx +6 -4
- package/contributions/blender-version.status.tsx +2 -8
- package/contributions/blender.page.ts +12 -11
- package/contributions/blender.status.tsx +1 -7
- package/contributions/models.finder.ts +4 -11
- package/dist-node/serving.mjs +0 -2
- package/host/blender-runtime-host.ts +290 -76
- package/node/blender-mcp.ts +3 -2
- package/package.json +56 -44
- package/serving/blender-routes.ts +0 -5
- package/src/api.ts +2 -2
- package/src/play-mode.ts +10 -65
- package/src/play-tool.ts +44 -0
- package/src/model-play-extension.ts +0 -173
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
4
4
|
import {
|
|
5
5
|
BlenderRuntime,
|
|
6
|
+
DocumentNotOpened,
|
|
6
7
|
type PresentAnswer,
|
|
7
8
|
type SavedDocument,
|
|
8
9
|
type SaveScript,
|
|
9
10
|
} from '@volter/blender-engine/browser';
|
|
10
|
-
import type { BlenderAssetLibrary, GltfExportAnswer, GltfExportKind, GltfExportPrevious, RenderRequest
|
|
11
|
+
import type { BlenderAssetLibrary, DocumentOpened, GltfExportAnswer, GltfExportKind, GltfExportPrevious, RenderRequest } from '@volter/blender-engine/browser/protocol';
|
|
11
12
|
import type {
|
|
12
13
|
BlenderActionClip,
|
|
13
14
|
BlenderSceneMovie,
|
|
@@ -291,6 +292,11 @@ async function runtimeHolding(blend: string): Promise<BlenderRuntime> {
|
|
|
291
292
|
return runtime;
|
|
292
293
|
}
|
|
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
|
+
|
|
294
300
|
/** Bring the project's `.blend` up to the model the engine holds, for a reader that takes the
|
|
295
301
|
* file's bytes as the model's (a cook's or an export's hash beside what the engine exported). */
|
|
296
302
|
export async function flushModelDocument(blend: string): Promise<void> {
|
|
@@ -334,13 +340,24 @@ const savedListeners = new Set<ModelDocumentSavedListener>();
|
|
|
334
340
|
/** The listeners' work on each runtime's saves, not yet done. */
|
|
335
341
|
const savedWork = new Map<BlenderRuntime, Set<Promise<unknown>>>();
|
|
336
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
|
+
|
|
337
354
|
function modelDocumentSaved(owner: BlenderRuntime, saved: SavedDocument): void {
|
|
338
355
|
const files = saved.script?.error === null ? saved.script.files : [];
|
|
339
356
|
const heard: ModelDocumentSaved = {
|
|
340
357
|
blend: saved.document,
|
|
341
358
|
script: saved.script === null ? null : { output: saved.script.output, error: saved.script.error, unstaged: saved.script.unstaged },
|
|
342
359
|
async carry() {
|
|
343
|
-
for (const file of files) await editorHost().files.write(file.path, file.bytes);
|
|
360
|
+
for (const file of files) if (!(await holds(file.path, file.bytes))) await editorHost().files.write(file.path, file.bytes);
|
|
344
361
|
},
|
|
345
362
|
};
|
|
346
363
|
for (const listener of [...savedListeners]) {
|
|
@@ -361,72 +378,213 @@ export function subscribeModelDocumentSaved(listener: ModelDocumentSavedListener
|
|
|
361
378
|
return () => { savedListeners.delete(listener); };
|
|
362
379
|
}
|
|
363
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
|
+
|
|
364
386
|
/**
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* throws, and the
|
|
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.
|
|
369
401
|
*/
|
|
370
402
|
async function retireRuntime(owner: BlenderRuntime): Promise<void> {
|
|
371
403
|
await owner.stop({ ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
|
|
372
|
-
|
|
404
|
+
await savedWorkDone(owner);
|
|
373
405
|
}
|
|
374
406
|
|
|
375
407
|
/**
|
|
376
|
-
* THE ASSET LIBRARIES EVERY
|
|
377
|
-
*
|
|
378
|
-
* its
|
|
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.
|
|
379
412
|
*/
|
|
380
413
|
const assetLibraries = new Map<string, BlenderAssetLibrary>();
|
|
381
414
|
|
|
382
415
|
export function setBlenderAssetLibrary(library: BlenderAssetLibrary): () => void {
|
|
383
416
|
assetLibraries.set(library.name, library);
|
|
384
|
-
if (
|
|
417
|
+
if (runtime?.project != null) void runtime.setAssetLibrary(library).catch(refusedLibrary(library));
|
|
385
418
|
return () => { if (assetLibraries.get(library.name) === library) assetLibraries.delete(library.name); };
|
|
386
419
|
}
|
|
387
420
|
|
|
388
421
|
const refusedLibrary = (library: BlenderAssetLibrary) => (error: unknown): void =>
|
|
389
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');
|
|
390
423
|
|
|
391
|
-
/**
|
|
392
|
-
*
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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(); };
|
|
397
513
|
}
|
|
398
514
|
|
|
399
515
|
export async function snapshotModelDocument(blend: string): Promise<Uint8Array> {
|
|
400
516
|
return (await runtimeHolding(blend)).snapshotDocument();
|
|
401
517
|
}
|
|
402
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. */
|
|
403
540
|
export async function revertModelDocument(blend: string): Promise<void> {
|
|
404
|
-
await (
|
|
541
|
+
await reopenModelDocument(blend);
|
|
405
542
|
}
|
|
406
543
|
|
|
544
|
+
/** The document opened again as `bytes`, its unsaved state (a backup). */
|
|
407
545
|
export async function restoreModelDocument(blend: string, bytes: Uint8Array): Promise<void> {
|
|
408
|
-
await (
|
|
546
|
+
await reopenModelDocument(blend, bytes);
|
|
409
547
|
}
|
|
410
548
|
|
|
411
549
|
/**
|
|
412
550
|
* OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
|
|
413
|
-
* path run backwards.
|
|
414
|
-
*
|
|
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.
|
|
415
556
|
*
|
|
416
|
-
*
|
|
417
|
-
* a refused open must never expose the previous model under a new
|
|
418
|
-
* identity.
|
|
419
|
-
*
|
|
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.
|
|
420
561
|
*/
|
|
421
562
|
let modelOpenTail: Promise<void> = Promise.resolve();
|
|
422
563
|
let modelStartup: { readonly session: BlenderRuntime; readonly project: string; readonly blend: string } | null = null;
|
|
423
564
|
|
|
565
|
+
/** How many model opens, switches and retirements are queued or under way. */
|
|
566
|
+
let modelLifecycles = 0;
|
|
567
|
+
|
|
424
568
|
function queueModelLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
569
|
+
modelLifecycles += 1;
|
|
570
|
+
notifyBlenderModelState();
|
|
425
571
|
const result = modelOpenTail.then(operation);
|
|
426
572
|
modelOpenTail = result.then(() => {}, () => {});
|
|
573
|
+
void modelOpenTail.then(() => { modelLifecycles -= 1; notifyBlenderModelState(); });
|
|
427
574
|
return result;
|
|
428
575
|
}
|
|
429
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
|
+
|
|
430
588
|
export async function openModelDocumentBlend(
|
|
431
589
|
binding: ModelDocumentBinding,
|
|
432
590
|
publish: () => void,
|
|
@@ -466,29 +624,46 @@ async function openBoundModelDocument(
|
|
|
466
624
|
if (project === null) throw new Error('Opening a model requires a project path.');
|
|
467
625
|
if (!modelDocumentMayOpen(binding)) return false;
|
|
468
626
|
const previous = runtime;
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
632
|
// saving fails it stays alive; neither its history nor its ownership moves.
|
|
472
|
-
|
|
473
|
-
const end = beginBlenderWork(`saving ${previous.document ?? 'the previous model'} before opening ${binding.blend}${reload ? ' (a reload)' : ''}`);
|
|
633
|
+
const end = handoff(reload);
|
|
474
634
|
try { await retireRuntime(previous); } finally { end(); }
|
|
475
635
|
if (runtime === previous) terminateBlenderRuntime();
|
|
476
636
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
477
637
|
}
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
+
}
|
|
482
662
|
const starting = { session, project, blend: binding.blend };
|
|
483
663
|
modelStartup = starting;
|
|
484
664
|
try {
|
|
485
665
|
publish();
|
|
486
|
-
await
|
|
487
|
-
} catch (error) {
|
|
488
|
-
// Discard a failed start so reopening creates a new worker. A session owned by another
|
|
489
|
-
// document remains alive and retains its resource.
|
|
490
|
-
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
491
|
-
throw error;
|
|
666
|
+
await opened;
|
|
492
667
|
} finally {
|
|
493
668
|
if (modelStartup === starting) modelStartup = null;
|
|
494
669
|
}
|
|
@@ -514,10 +689,10 @@ async function openBoundModelDocument(
|
|
|
514
689
|
* THEY NEVER START THE ENGINE. A panel asking what the engine holds must not
|
|
515
690
|
* start Blender, so these answer `null` when this tab
|
|
516
691
|
* has no started session — exactly the read `blender-status` makes, and for
|
|
517
|
-
* the same reason.
|
|
692
|
+
* the same reason — or a started one that holds no model yet.
|
|
518
693
|
*/
|
|
519
694
|
export function blenderSessionStarted(): boolean {
|
|
520
|
-
return runtime?.project != null && modelDocumentConflict() === null;
|
|
695
|
+
return runtime?.project != null && runtime.document !== null && modelDocumentConflict() === null;
|
|
521
696
|
}
|
|
522
697
|
|
|
523
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. */
|
|
@@ -1076,6 +1251,8 @@ interface RuntimeView {
|
|
|
1076
1251
|
|
|
1077
1252
|
let runtime: BlenderRuntime | null = null;
|
|
1078
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;
|
|
1079
1256
|
|
|
1080
1257
|
function invalidateBlenderHistory(): void {
|
|
1081
1258
|
if (historyResources.size) editorHost().history.invalidate([...historyResources]);
|
|
@@ -1087,23 +1264,36 @@ let captureLifetime: AbortController | null = null;
|
|
|
1087
1264
|
* terminate, and the host door only exists inside a running editor. */
|
|
1088
1265
|
let watchingSessionEnd = false;
|
|
1089
1266
|
|
|
1090
|
-
/**
|
|
1091
|
-
|
|
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 {
|
|
1092
1271
|
try {
|
|
1093
|
-
const end = editorHost().session.beginWork(
|
|
1272
|
+
const end = editorHost().session.beginWork(label, internal ? { internal } : undefined);
|
|
1094
1273
|
return () => { try { end(); } catch { /* preserve the operation's result */ } };
|
|
1095
1274
|
} catch { return () => {}; }
|
|
1096
1275
|
}
|
|
1097
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
|
+
|
|
1098
1288
|
function terminateBlenderRuntime(): void {
|
|
1099
|
-
const end = beginBlenderWork('
|
|
1289
|
+
const end = beginBlenderWork('releasing the runtime', true);
|
|
1100
1290
|
try {
|
|
1101
|
-
|
|
1291
|
+
forgetDocument();
|
|
1102
1292
|
captureLifetime?.abort();
|
|
1103
1293
|
captureLifetime = null;
|
|
1104
1294
|
runtime?.terminate();
|
|
1105
1295
|
runtime = null;
|
|
1106
|
-
|
|
1296
|
+
if (startingSession !== null) setStarting(null);
|
|
1107
1297
|
} finally { end(); }
|
|
1108
1298
|
}
|
|
1109
1299
|
|
|
@@ -1161,9 +1351,18 @@ async function runtimeView(): Promise<RuntimeView> {
|
|
|
1161
1351
|
);
|
|
1162
1352
|
}
|
|
1163
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. */
|
|
1164
1356
|
export function blenderRuntime(): BlenderRuntime {
|
|
1165
1357
|
const conflict = modelDocumentConflict();
|
|
1166
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 {
|
|
1167
1366
|
if (runtime !== null) return runtime;
|
|
1168
1367
|
watchSessionEnd();
|
|
1169
1368
|
const lifetime = new AbortController();
|
|
@@ -1186,7 +1385,8 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1186
1385
|
};
|
|
1187
1386
|
lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
|
|
1188
1387
|
runtime = new BlenderRuntime({
|
|
1189
|
-
|
|
1388
|
+
// (the worker's own steps: the session's phase, never the person's status bar)
|
|
1389
|
+
work: (label) => beginBlenderWork(label, true),
|
|
1190
1390
|
documentSaving: ({ document, note }) => {
|
|
1191
1391
|
if (!lifetime.signal.aborted) setModelDocumentSaveNotice(document, note);
|
|
1192
1392
|
},
|
|
@@ -1203,8 +1403,11 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1203
1403
|
}
|
|
1204
1404
|
if (!entry.resource) throw new Error('A Blender edit has no document resource');
|
|
1205
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;
|
|
1206
1408
|
const restore = async (direction: 'undo' | 'redo'): Promise<boolean> => {
|
|
1207
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`);
|
|
1208
1411
|
const moved = await owner.historyStep(entry.id, direction);
|
|
1209
1412
|
noteBlenderRnaChanged();
|
|
1210
1413
|
// The restored frame arrives before the Outliner's scheduled read.
|
|
@@ -1270,7 +1473,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1270
1473
|
return {};
|
|
1271
1474
|
}
|
|
1272
1475
|
const documentId = presentationDocumentId();
|
|
1273
|
-
const endWait = beginBlenderWork('waiting for Model presenter');
|
|
1476
|
+
const endWait = beginBlenderWork('waiting for the Model presenter', true);
|
|
1274
1477
|
let view: RuntimeView;
|
|
1275
1478
|
try { view = await runtimeView(); } finally { endWait(); }
|
|
1276
1479
|
lifetime.signal.throwIfAborted();
|
|
@@ -1285,7 +1488,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1285
1488
|
throw new Error('Blender capture source changed before render presentation');
|
|
1286
1489
|
const presenter = rendering?.view ?? view;
|
|
1287
1490
|
// Report the presenter’s holding before applying this frame.
|
|
1288
|
-
const endApply = beginBlenderWork('applying frame to Model');
|
|
1491
|
+
const endApply = beginBlenderWork('applying a frame to the Model', true);
|
|
1289
1492
|
const applyAt = performance.now();
|
|
1290
1493
|
const applied = (() => {
|
|
1291
1494
|
try { return presenter.applyFrame(frame); }
|
|
@@ -1483,7 +1686,10 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1483
1686
|
data: {
|
|
1484
1687
|
started: runtime?.project != null,
|
|
1485
1688
|
document: host.documents.context(presentationDocumentId()) !== undefined,
|
|
1486
|
-
|
|
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,
|
|
1487
1693
|
lastOutput: runtime?.lastOutput ?? [],
|
|
1488
1694
|
},
|
|
1489
1695
|
};
|
|
@@ -1538,33 +1744,41 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1538
1744
|
'opens that document (the Model document ships with @volter/editor-blender).',
|
|
1539
1745
|
};
|
|
1540
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
|
+
}
|
|
1541
1780
|
const session = blenderRuntime();
|
|
1542
1781
|
switch (cmd.type) {
|
|
1543
|
-
case 'blender-start': {
|
|
1544
|
-
// THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
|
|
1545
|
-
// project's document is its own `src/models/<name>.blend`, and a
|
|
1546
|
-
// `blender-start` that named none is asking for the session this tab
|
|
1547
|
-
// is showing, not for a second file beside it.
|
|
1548
|
-
// After a script's Save As to a file no Model lists, the session this tab
|
|
1549
|
-
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1550
|
-
// asks for it, not for the file the tab was opened on.
|
|
1551
|
-
// The shown file is read as `modelDocumentConflict` reads it (active tab first): the bound pane lags a
|
|
1552
|
-
// tab that just followed a Save As (`../src/start-document.ts`).
|
|
1553
|
-
const document = startDocument({
|
|
1554
|
-
requested: requestedDocument,
|
|
1555
|
-
shown: shownModelBlend(),
|
|
1556
|
-
follow: unlistedFollow !== null && unlistedFollow.owner === session ? unlistedFollow : null,
|
|
1557
|
-
fallback: DEFAULT_BLENDER_DOCUMENT,
|
|
1558
|
-
});
|
|
1559
|
-
try {
|
|
1560
|
-
return { ok: true, data: { ...(await startSession(session, project!, document)) } };
|
|
1561
|
-
} catch (error) {
|
|
1562
|
-
// As the document's own open does: a session whose start failed is discarded, so the
|
|
1563
|
-
// next `blender-start` makes a new one instead of awaiting the same failure.
|
|
1564
|
-
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
1565
|
-
throw error;
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
1782
|
case 'blender-execute': {
|
|
1569
1783
|
// Use the in-page script door so every bpy write bumps the shared RNA version. Wire callers receive the original MCP text.
|
|
1570
1784
|
const answer = await blenderExecute(string(cmd, 'code'));
|
package/node/blender-mcp.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ListToolsRequestSchema,
|
|
24
24
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
25
25
|
import { EditorClient } from '@volter/live/client';
|
|
26
|
+
import { sessionFetch } from '@volter/live/registry-format';
|
|
26
27
|
import type { EditorView } from '@volter/sdk';
|
|
27
28
|
import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
|
|
28
29
|
import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
|
|
@@ -158,7 +159,7 @@ class Mirror {
|
|
|
158
159
|
// only hands over a body. See `/__editor/blender-file`.
|
|
159
160
|
const transferId = randomUUID();
|
|
160
161
|
await tab.commandHeld<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
|
|
161
|
-
const spooled = await
|
|
162
|
+
const spooled = await sessionFetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
|
|
162
163
|
if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
|
|
163
164
|
const bytes = Buffer.from(await spooled.arrayBuffer());
|
|
164
165
|
if (shipped === null) {
|
|
@@ -189,7 +190,7 @@ class Mirror {
|
|
|
189
190
|
// server validates each one as a project-relative path of its own.
|
|
190
191
|
if (typeof listing.document === 'string' && listing.document !== '')
|
|
191
192
|
query.append('inputs', listing.document);
|
|
192
|
-
const recorded = await
|
|
193
|
+
const recorded = await sessionFetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
|
|
193
194
|
method: 'POST',
|
|
194
195
|
body: new Uint8Array(bytes),
|
|
195
196
|
});
|