@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.
@@ -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, RuntimeStart } from '@volter/blender-engine/browser/protocol';
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
- * A RUNTIME RETIRED WITH ITS SAVES' WORK DONE, for every retirement (another document opened, a reload, a stop, the
366
- * session's close): its last save runs the save script in its turn as every save does and is heard, and then the
367
- * listeners' work on every save the runtime made, which reads files and not Blender, is waited for. A save that fails
368
- * throws, and the runtime stays alive with its document.
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
- for (let held = savedWork.get(owner); held !== undefined; held = savedWork.get(owner)) await Promise.all([...held]);
404
+ await savedWorkDone(owner);
373
405
  }
374
406
 
375
407
  /**
376
- * THE ASSET LIBRARIES EVERY SESSION'S BLENDER HAS (Preferences > File Paths > Asset Libraries), by name: set in the
377
- * running session at once, and in each session after it starts (`startSession`). Blender's preferences live as long as
378
- * its session, and each `.blend` opened is a new session. The removal leaves a running session's library as it is.
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 (blenderSessionStarted()) void blenderRuntime().setAssetLibrary(library).catch(refusedLibrary(library));
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
- /** A session's start, then the asset libraries every session has. Called where a start is asked for; the start
392
- * claims its resource synchronously, before the first await. */
393
- async function startSession(session: BlenderRuntime, project: string, document: string): Promise<RuntimeStart> {
394
- const started = await session.start(project, document);
395
- for (const library of assetLibraries.values()) await session.setAssetLibrary(library).catch(refusedLibrary(library));
396
- return started;
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 (await runtimeHolding(blend)).revertDocument();
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 (await runtimeHolding(blend)).restoreDocument(bytes);
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. `session.py` opens the named file at start and saves
414
- * back to it, so naming it here IS opening it.
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
- * The worker owns one file. Validate ownership before publishing a context;
417
- * a refused open must never expose the previous model under a new tab's
418
- * identity. Boot itself can present, so the context must exist before awaiting
419
- * boot completion. Return false when the requesting pane has since unmounted.
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
- if (previous && (reload || previous.project !== project || previous.document !== binding.blend)) {
470
- // The OLD worker's bound file is saved and its saves' work done before it stops. If
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
- // (named, both, so the session journal says which document gave way to which)
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 session = blenderRuntime();
479
- // start claims its resource synchronously; its first frame may arrive before
480
- // the returned promise resolves. The getter above rejects a conflicting file.
481
- const started = startSession(session, project, binding.blend);
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 started;
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
- /** Diagnostics must not prevent teardown if the host has already detached. */
1091
- function beginBlenderWork(label: string): () => void {
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(`Blender: ${label}`);
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('invalidating history and releasing runtime');
1289
+ const end = beginBlenderWork('releasing the runtime', true);
1100
1290
  try {
1101
- invalidateBlenderHistory();
1291
+ forgetDocument();
1102
1292
  captureLifetime?.abort();
1103
1293
  captureLifetime = null;
1104
1294
  runtime?.terminate();
1105
1295
  runtime = null;
1106
- clearModelDocumentSaving();
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
- work: beginBlenderWork,
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
- instancing: runtime?.project != null ? (await runtimeView()).drawStatistics() : null,
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'));
@@ -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 fetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
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 fetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
193
+ const recorded = await sessionFetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
193
194
  method: 'POST',
194
195
  body: new Uint8Array(bytes),
195
196
  });