@volter/blender-engine 0.1.149 → 0.1.151

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 CHANGED
@@ -34,14 +34,15 @@ this package covered by any commercial licence Volter AI, Inc. offers
34
34
  Blender's authors'.
35
35
 
36
36
  CORRESPONDING SOURCE. The binary in `wasm/` is built from official Blender
37
- v5.2.0 (`fbe6228777e7d9afefcd61a413844e790ae75db7`) plus the changes reviewed
38
- at `a4dff7ba16a0cca24504d81ed0086457493e48dc`. The public source snapshot is
39
- `94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c` in:
37
+ v5.2.0 (`fbe6228777e7d9afefcd61a413844e790ae75db7`) plus Volter's changes, at
38
+ the public source commit `241d224a32033b16b383d5065c6fbc819fcdc1d5` in:
40
39
 
41
40
  https://github.com/volter-ai/blender
42
41
 
43
- The public release `blender-5.2.0-wasm.1` retains the matching source, build
44
- instructions, dependency source archives, notices and bundle.
42
+ The public release `blender-5.2.0-wasm.5` retains the matching source archive,
43
+ build instructions, notices and bundle; the dependency source archives the
44
+ source builds with are unchanged since `blender-5.2.0-wasm.1`, which retains
45
+ them. Both stay available for as long as this package is distributed.
45
46
  `wasm/BUNDLE.json` records the source pin and artifact hashes;
46
47
  `wasm/DEPENDENCY-LICENSES.txt` retains dependency notices. Blender's authorship
47
48
  and the GPL terms below are unchanged.
@@ -218,9 +218,12 @@ export async function startEmscriptenBlenderEngine(
218
218
  let bootError: unknown = null;
219
219
  const started = performance.now();
220
220
  let readyLine: string | null = null;
221
+ /** The steps of the session's start it has said it began (`@@VOLTER-PHASE`), not yet made load boundaries. */
222
+ const phases: string[] = [];
221
223
  // Route Blender’s stdout and stderr to page output; session conditions use their own reporting channel.
222
224
  const say = (_level: 'log' | 'error', text: string) => {
223
225
  if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
226
+ if (text.startsWith('@@VOLTER-PHASE ')) { phases.push(text.slice('@@VOLTER-PHASE '.length)); return; }
224
227
  options.log(sessionLevel(text), text);
225
228
  };
226
229
  // Observe the factory’s WebAssembly.Memory and read a fresh buffer view after pthread heap growth. Module.HEAPU8 can retain its previous length until the glue refreshes it.
@@ -305,7 +308,9 @@ export async function startEmscriptenBlenderEngine(
305
308
  // long before the session exists. The ready line is what says it does.
306
309
  while (readyLine === null) {
307
310
  if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
308
- await sleep(5);
311
+ const phase = phases.shift();
312
+ if (phase !== undefined) await checkpoint(`session/${phase}`);
313
+ else await sleep(5);
309
314
  }
310
315
  const bootMs = performance.now() - started;
311
316
  const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
@@ -449,6 +449,8 @@ export async function startWaliBlenderEngine(
449
449
  for (const line of text.split('\n')) {
450
450
  if (line === '') continue;
451
451
  if (line.startsWith('@@VOLTER-READY ')) readyLine = line.slice('@@VOLTER-READY '.length);
452
+ // (a start step's mark is a load boundary for a staged load, which this start is not)
453
+ if (line.startsWith('@@VOLTER-PHASE ')) continue;
452
454
  // Route Blender streams to page output; named session conditions use their own channel.
453
455
  options.log(sessionLevel(line), line);
454
456
  }
package/browser/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { CaptureRequest, FileEntry, RuntimeStart, NativePreviewRequest, NativePreviewResult, NativeDocumentInfo, FileListSnapshot } from './protocol';
2
2
  export {
3
3
  BlenderRuntime,
4
+ DocumentNotOpened,
4
5
  type BlenderRuntimeOptions,
5
6
  type PresentAnswer,
6
7
  type SavedDocument,
@@ -12,16 +12,15 @@ export type WorkerRequest =
12
12
  /** The document's bytes as a save would write them, for a backup: nothing is carried to the
13
13
  * project and the document stays dirty. */
14
14
  | { id: number; op: 'snapshot-document' }
15
- /** Reopen the document from the project's file, discarding what it holds. */
16
- | { id: number; op: 'revert-document' }
17
- /** Reopen the document as `bytes` (a backup), which is then its unsaved state. */
18
- | { id: number; op: 'restore-document'; bytes: Uint8Array }
15
+ /** Open `document` in the running Blender, as Blender's own File > Open does, from the project's file, or as
16
+ * `bytes` (a backup), which is then its unsaved state. `document` is the session's `.blend` from then on,
17
+ * PROJECT-RELATIVE (`models/model.blend`): an existing one is opened, an absent one opens as Blender's empty file and
18
+ * is created by the first save. Naming the document the session holds reopens it (a revert, a restore). */
19
+ | { id: number; op: 'open-document'; document: string; bytes?: Uint8Array }
19
20
  | { id: number; op: 'history-begin' | 'history-end' }
20
21
  | { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
21
- /** `document` is the session's `.blend`, PROJECT-RELATIVE (`models/model.blend`
22
- * by default). An existing one is opened at start; an absent one starts
23
- * empty and is created by the first save. */
24
- | { id: number; op: 'start'; project: string; document?: string }
22
+ /** Blender started at `project`, holding no document until `open-document` names one. */
23
+ | { id: number; op: 'start'; project: string }
25
24
  | { id: number; op: 'execute'; code: string; history?: boolean; label?: string }
26
25
  /** A native Cycles photograph, isolated from authoring/history and the
27
26
  * Three frame exporter. This is a preview request, not a modeling edit. */
@@ -340,9 +339,13 @@ export interface RuntimeStart {
340
339
  python: string;
341
340
  /** The engine implementation that answered: standalone Emscripten or WALI on browser-substrate. */
342
341
  skew: 'emscripten' | 'wali';
343
- /** The session's document, project-relative, when one was named. */
344
- document?: string;
342
+ }
343
+
344
+ /** What `open-document` answers (`session.py::bind_document`). */
345
+ export interface DocumentOpened {
346
+ /** The session's document, project-relative. */
347
+ document: string;
345
348
  /** True when the project already held that `.blend` and it was OPENED —
346
- * false when the session started empty and will create it on first save. */
347
- opened?: boolean;
349
+ * false when it opened as Blender's empty file and is created by the first save. */
350
+ opened: boolean;
348
351
  }
@@ -5,10 +5,12 @@ import { HEAP_GROWTH_REFUSED } from './protocol';
5
5
  /**
6
6
  * The tab-side handle on a Blender session: spawns the worker, forwards wire
7
7
  * requests, and hands every presented frame to whoever displays it.
8
- * One instance per editor tab; the session and its model die with the worker.
8
+ * One instance per editor tab; the documents it opens one after another share
9
+ * its Blender, and the session and its model die with the worker.
9
10
  */
10
11
  import type {
11
12
  CaptureRequest,
13
+ DocumentOpened,
12
14
  RuntimeStart,
13
15
  WorkerReply,
14
16
  WorkerRequest,
@@ -39,6 +41,15 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
39
41
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
40
42
 
41
43
 
44
+ /** An open that failed before Blender's own open (the path, the file's staging): Blender is as it was, holding the
45
+ * document it held. */
46
+ export class DocumentNotOpened extends Error {}
47
+
48
+ /** Calls a Blender giving up its document still takes ({@link BlenderRuntime.releaseDocument}): a backup's read of
49
+ * the document it saves, and an asset library in its preferences, which no file holds. Each runs in its turn,
50
+ * before the next document's open. */
51
+ const RELEASE_ADMITS: ReadonlySet<string> = new Set(['snapshot-document', 'asset-library']);
52
+
42
53
  /** A script every save of a document runs in the save's own turn (`BlenderRuntime.saveDocument`), as the caller names
43
54
  * it for the document saved: null for none. */
44
55
  export type SaveScript = (document: string) => Promise<{ readonly source: string; readonly args: readonly string[] }> | null;
@@ -178,9 +189,12 @@ export class BlenderRuntime {
178
189
  /** The script runs this runtime has made, each in a directory of its own. */
179
190
  #scriptRuns = 0;
180
191
  #terminated = false;
192
+ /** The document being given up for the next one ({@link releaseDocument}), and its last save. */
193
+ #release: { readonly document: string; readonly next: string; readonly saved: Promise<SavedDocument | null> } | null = null;
181
194
  #dirty = false;
182
195
  readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
183
- if (!this.#dirty && this.#acceptedRequests === 0) return;
196
+ // (a Blender holding no document has nothing to lose: its start is not work in progress)
197
+ if (this.#document === null || (!this.#dirty && this.#acceptedRequests === 0)) return;
184
198
  event.preventDefault();
185
199
  event.returnValue = '';
186
200
  };
@@ -247,9 +261,10 @@ export class BlenderRuntime {
247
261
  return this.#project;
248
262
  }
249
263
 
250
- /** The document owned by this worker, including while boot is pending: the startup one, until
251
- * a script's Save As leaves Blender holding another project `.blend` and the session follows
252
- * it there (`session.py::follow_open_file`). */
264
+ /** The document owned by this worker, from the moment its open is asked for: the one
265
+ * {@link openDocument} last named, until a script's Save As leaves Blender holding another
266
+ * project `.blend` and the session follows it there (`session.py::follow_open_file`). Null
267
+ * while Blender holds none. */
253
268
  get document(): string | null {
254
269
  return this.#document;
255
270
  }
@@ -260,30 +275,15 @@ export class BlenderRuntime {
260
275
  return [...this.#lastLines];
261
276
  }
262
277
 
263
- /** Idempotent: the first call boots Blender at `project`'s
264
- * absolute path; later calls await it (a different path is refused).
265
- *
266
- * `document` is the session's `.blend`, project-relative. An explicit
267
- * different file is a resource conflict, including while boot is pending.
268
- * Omitting it on an already-started handle means await that same session. */
269
- start(project: string, document?: string): Promise<RuntimeStart> {
278
+ /** Idempotent: the first call boots Blender at `project`'s absolute path, holding no
279
+ * document; later calls await it (a different path is refused). */
280
+ start(project: string): Promise<RuntimeStart> {
270
281
  if (this.#project !== null && this.#project !== project)
271
282
  return Promise.reject(
272
283
  new Error(`The Blender session is bound to ${this.#project}, not ${project}`),
273
284
  );
274
- if (document !== undefined && this.#document !== null && this.#document !== document)
275
- return Promise.reject(
276
- new Error(
277
- `Blender resource conflict: the session holds ${this.#document}; cannot open ${document}`,
278
- ),
279
- );
280
285
  this.#project = project;
281
- this.#document ??= document ?? 'models/model.blend';
282
- this.#started ??= (this.#request({
283
- op: 'start',
284
- project,
285
- ...(document === undefined ? {} : { document }),
286
- }) as Promise<RuntimeStart>).catch((error: unknown) => {
286
+ this.#started ??= (this.#request({ op: 'start', project }) as Promise<RuntimeStart>).catch((error: unknown) => {
287
287
  this.#startFailed = true;
288
288
  throw error;
289
289
  });
@@ -510,9 +510,11 @@ export class BlenderRuntime {
510
510
  /** A save of the document, and in the same turn, before any other request reaches this Blender, the script `after`
511
511
  * names for the document it wrote (as the worker holds it once saved, a move accepted before it included), run on
512
512
  * the scene just saved, in a working directory of its own, its files carried back in its answer. Null when there
513
- * was nothing to save. */
513
+ * was nothing to save. While the document is being given up, the save is the release's own: this resolves when it
514
+ * lands, with null (its listeners hear it once, from the release), and fails with it. */
514
515
  async saveDocument(after?: SaveScript): Promise<SavedDocument | null> {
515
516
  await this.#ready();
517
+ if (this.#release !== null) return this.#release.saved.then(() => null);
516
518
  return this.#inTurn('flush-document', false, () => this.#save(after));
517
519
  }
518
520
 
@@ -522,16 +524,56 @@ export class BlenderRuntime {
522
524
  return (await this.#request({ op: 'snapshot-document' })) as Uint8Array;
523
525
  }
524
526
 
525
- /** Reopen the document from its project file, discarding its changes. */
526
- async revertDocument(): Promise<void> {
527
- await this.#ready();
528
- await this.#request({ op: 'revert-document' });
527
+ /**
528
+ * Open `document` (project-relative) in this Blender, once it has started, as its File > Open does: from
529
+ * the project's file, or as `bytes`, its unsaved state (a backup). Naming the document it holds reopens
530
+ * it, discarding its changes (a revert, a restore). The handle claims the document at the call, so a
531
+ * request made after it waits for the open and finds it; the one held before is the caller's to save
532
+ * first ({@link releaseDocument}). A failure before Blender's own open rejects with {@link DocumentNotOpened}, and
533
+ * the handle names the document it held again.
534
+ */
535
+ openDocument(document: string, bytes?: Uint8Array): Promise<DocumentOpened> {
536
+ const held = this.#document;
537
+ this.#document = document;
538
+ const request = { op: 'open-document', document, ...(bytes === undefined ? {} : { bytes }) } as const;
539
+ return this.#ready().then(() => this.#inTurn('open-document', false, async () => {
540
+ // The open the release was for: the document given up is no longer held.
541
+ if (this.#release?.next === document) this.#release = null;
542
+ // From this turn on, a frame presented is the opened file's: the last one's is no longer on screen as its.
543
+ this.#presented = null;
544
+ const answer = (await this.#wire(request)) as DocumentOpened | { readonly notOpened: string };
545
+ if (!('notOpened' in answer)) return answer;
546
+ if (this.#document === document) this.#document = held;
547
+ throw new DocumentNotOpened(answer.notOpened);
548
+ }, document));
529
549
  }
530
550
 
531
- /** Reopen the document as `bytes`, its unsaved state. */
532
- async restoreDocument(bytes: Uint8Array): Promise<void> {
533
- await this.#ready();
534
- await this.#request({ op: 'restore-document', bytes });
551
+ /**
552
+ * THE DOCUMENT GIVEN UP FOR `next`, ITS LAST SAVE FIRST, as a stop's is: queued behind every request accepted before
553
+ * it, with `retire.after`'s script in its turn, heard by `landed`. From the call on, no edit is taken, so none lands
554
+ * after the save, until `next`'s {@link openDocument} has its turn or {@link keepDocument} returns to the document;
555
+ * a save asked for meanwhile is this one, and a backup's read and an asset library still run in their turns
556
+ * (`RELEASE_ADMITS`). A save that fails, or a listener that throws, keeps the document, and edits are taken again.
557
+ */
558
+ releaseDocument(next: string, retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
559
+ if (this.#terminated || this.#stopping) return Promise.reject(new Error('The Blender session is stopping or terminated'));
560
+ const document = this.#document;
561
+ if (document === null) return Promise.resolve();
562
+ const saved = this.#ready().then(() => this.#inTurn('flush-document', true, () => this.#save(retire?.after)));
563
+ const release = { document, next, saved };
564
+ this.#release = release;
565
+ return saved
566
+ .then((value) => { if (value !== null) retire?.landed(value); })
567
+ .catch((error: unknown) => {
568
+ if (this.#release === release) this.#release = null;
569
+ throw error;
570
+ });
571
+ }
572
+
573
+ /** The document given up ({@link releaseDocument}) is kept after all, while the handle still names it: edits are
574
+ * taken again. Once the next document's open is asked for, that open ends the release instead. */
575
+ keepDocument(): void {
576
+ if (this.#release !== null && this.#release.document === this.#document) this.#release = null;
535
577
  }
536
578
 
537
579
  async readFile(path: string): Promise<Uint8Array> {
@@ -563,6 +605,11 @@ export class BlenderRuntime {
563
605
  stop(retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
564
606
  if (this.#terminated) return Promise.resolve();
565
607
  if (this.#stopping) return this.#stopping;
608
+ // Holding no document, it has nothing to save, and its start is not waited for.
609
+ if (this.#document === null) {
610
+ this.terminate();
611
+ return Promise.resolve();
612
+ }
566
613
  this.#stopping = (async () => {
567
614
  // A start that failed opened no document, so there is nothing to flush: its stale
568
615
  // rejection must not refuse every later close.
@@ -580,9 +627,9 @@ export class BlenderRuntime {
580
627
 
581
628
  /** The save, and its script on the scene it wrote, inside one turn. */
582
629
  async #save(after: SaveScript | undefined): Promise<SavedDocument | null> {
583
- const { saved } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean };
584
- const document = this.#document;
585
- if (!saved || document === null) return null;
630
+ // The document the worker wrote, as it says: this handle may already name the next one.
631
+ const { saved, document } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean; document?: string };
632
+ if (!saved || document === undefined) return null;
586
633
  const named = after?.(document) ?? null;
587
634
  if (named === null) return { document, script: null };
588
635
  let script: { readonly source: string; readonly args: readonly string[] };
@@ -696,9 +743,12 @@ export class BlenderRuntime {
696
743
  return this.#inTurn(request.op, false, () => this.#wire(request));
697
744
  }
698
745
  /** One turn of the queue: `run` alone talks to the worker until it is done, whatever it sends. */
699
- #inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>): Promise<T> {
746
+ #inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>, opens?: string): Promise<T> {
700
747
  if (this.#terminated || (this.#stopping && !shutdown))
701
748
  return Promise.reject(new Error('The Blender session is stopping or terminated'));
749
+ const release = this.#release;
750
+ if (release !== null && !shutdown && !RELEASE_ADMITS.has(op) && opens !== release.next)
751
+ return Promise.reject(new Error(`Blender is saving ${release.document} before opening ${release.next}, and takes no edit until ${release.next} is open.`));
702
752
  const began = performance.now();
703
753
  let endWork = () => {};
704
754
  this.#acceptedRequests++;
@@ -1,5 +1,10 @@
1
1
  """The tab’s long-lived Blender session. A directory protocol carries requests and replies while one bpy.data remains alive on Blender’s pthread. The page owns in/ and reply/; the guest owns out/ and ask/. Each writer cleans its own files after acknowledgement and removes .done last. Explicit saves write Blender’s native format and let worker.ts return changed output to the project."""
2
2
 
3
+ import sys
4
+
5
+ # The session module begins loading (a start step: see `main`).
6
+ print("@@VOLTER-PHASE module", file=sys.stderr, flush=True)
7
+
3
8
  import array
4
9
  import base64
5
10
  import hashlib
@@ -8,7 +13,6 @@ import json
8
13
  import math
9
14
  import os
10
15
  import re
11
- import sys
12
16
  import time
13
17
  import traceback
14
18
  from contextlib import redirect_stdout
@@ -107,6 +111,7 @@ def _load_post(_arg):
107
111
  # The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
108
112
  # into the Main that just went away); this is the session's half.
109
113
  SESSION.forget()
114
+ SESSION.renew()
110
115
  SESSION.loaded()
111
116
  # Factory reset reloads add-ons. Preserve native Cycles and re-register
112
117
  # only the headless GPU presenters we own.
@@ -1421,10 +1426,10 @@ class Session:
1421
1426
  self._known = {}
1422
1427
  # The last frame's accounting (`_present`), read by `dispatch`.
1423
1428
  self.last_shipped = None
1424
- # THE SESSION'S DOCUMENT: the `.blend` it opens at start and saves back
1429
+ # THE SESSION'S DOCUMENT: the `.blend` it last opened and saves back
1425
1430
  # into. Absolute (the module filesystem mirrors the project at its own
1426
1431
  # host path) plus the project-relative spelling the tab needs to name
1427
- # the destination. None until `blender-start` states one.
1432
+ # the destination. None until `open-document` names one.
1428
1433
  self.document = None
1429
1434
  self.document_relative = None
1430
1435
  # Set by a present that left the document behind the model; cleared by
@@ -1452,6 +1457,15 @@ class Session:
1452
1457
  self._known.clear()
1453
1458
  self._action_revisions = {}
1454
1459
 
1460
+ def renew(self):
1461
+ """A NEW PRESENTATION SESSION FOR EACH FILE LOAD: the editor's open of a document, a
1462
+ script's `open_mainfile` or `read_homefile` alike. A load restarts the revisions the export
1463
+ door numbers its datablocks with, and the view keeps a mesh whose session and revision it
1464
+ already holds (`blender-runtime-view.ts`), so another file's "Cube" at the same revision
1465
+ would be drawn as the last one's. A new id makes the view clear what it held and refuse the
1466
+ last file's late frames."""
1467
+ self.session = "blender-%d" % time.time_ns()
1468
+
1455
1469
  def _kept_action_revisions(self, door):
1456
1470
  """Keep action revisions stable across evaluation-only updates. An edit advances
1457
1471
  _ACTION_EDITS and permits the native revision to replace the retained revision. All
@@ -1905,23 +1919,33 @@ class Session:
1905
1919
  def bind_document(self, relative_path):
1906
1920
  """Name the session's document and OPEN it when the project has one.
1907
1921
 
1908
- One session holds one document. The path is stated at start and moves
1909
- only where Blender itself moved: a script's own `save_as_mainfile` (or
1910
- `open_mainfile`) to another `.blend` in the project retargets
1911
- `bpy.data.filepath`, and `follow_open_file` moves the document with it
1912
- after the call.
1922
+ The session holds one document at a time: the one the editor last opened
1923
+ here (`open-document`), and it moves otherwise only where Blender itself
1924
+ moved: a script's own `save_as_mainfile` (or `open_mainfile`) to another
1925
+ `.blend` in the project retargets `bpy.data.filepath`, and
1926
+ `follow_open_file` moves the document with it after the call.
1913
1927
 
1914
1928
  `open_mainfile` runs the session's `_load_post` like any other load
1915
1929
  (the export door's revision table is reset, the render engine ids are
1916
1930
  retaken), and the one present afterwards is what puts the reopened
1917
- model on the tab's screen.
1931
+ model on the tab's screen, as a new presentation session (`renew`).
1918
1932
  """
1919
1933
  self.document_relative = relative_path
1920
1934
  self.document = os.path.join(self.project, relative_path)
1921
1935
  self._foreign = False
1922
1936
  self._foreign_said = False
1937
+ self._outside_file = None
1923
1938
  if not os.path.exists(self.document):
1924
- return {"document": relative_path, "opened": False}
1939
+ # A DOCUMENT NOT YET WRITTEN OPENS EMPTY: Blender's empty file
1940
+ # (`read_homefile(use_empty=True)`: its scenes, windows and workspaces alone), loaded as the session's
1941
+ # own open, so its first save writes the new model, never the factory startup's cube, light and camera
1942
+ # a fresh worker holds, or the file it held before.
1943
+ self._binding = True
1944
+ try:
1945
+ bpy.ops.wm.read_homefile(use_empty=True)
1946
+ finally:
1947
+ self._binding = False
1948
+ return {"document": relative_path, "opened": False, "presented": self._present_opened(relative_path)}
1925
1949
  _asked({"checkpoint": "document-open"})
1926
1950
  _mark("open:before")
1927
1951
  _blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
@@ -1941,10 +1965,19 @@ class Session:
1941
1965
  # Remove the staged .blend after Blender loads it to release its WasmFS bytes before
1942
1966
  # presentation. The worker keeps its host stamp, and the next save rewrites the file.
1943
1967
  os.remove(self.document)
1944
- # A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
1945
- # failed the open would leave a saved document unopenable and every MCP call refused.
1946
- # The model is in Blender, so the session keeps it and says loudly that the view is behind;
1947
- # the next edit presents again, and can remove whatever the presenter could not take.
1968
+ presented = self._present_opened(relative_path)
1969
+ return {"document": relative_path, "opened": True,
1970
+ "objects": len(bpy.data.objects),
1971
+ "size": size, "presented": presented}
1972
+
1973
+ def _present_opened(self, relative_path):
1974
+ """The first present of a document just opened, and whether it was taken.
1975
+
1976
+ A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
1977
+ failed the open would leave a saved document unopenable and every MCP call refused.
1978
+ The model is in Blender, so the session keeps it and says loudly that the view is behind;
1979
+ the next edit presents again, and can remove whatever the presenter could not take.
1980
+ """
1948
1981
  presented = True
1949
1982
  try:
1950
1983
  self.present()
@@ -1959,9 +1992,7 @@ class Session:
1959
1992
  "document does not show it yet: %r" % (relative_path, thrown))
1960
1993
  # The load did not dirty anything: what is in memory IS the file.
1961
1994
  self.save_due = False
1962
- return {"document": relative_path, "opened": True,
1963
- "objects": len(bpy.data.objects),
1964
- "size": size, "presented": presented}
1995
+ return presented
1965
1996
 
1966
1997
  def loaded(self):
1967
1998
  """Track file loads that replace the session’s document model. The session’s own document
@@ -5828,23 +5859,19 @@ def _dispatch(request):
5828
5859
  return answer
5829
5860
  if op == "save-document":
5830
5861
  return SESSION.save_document()
5831
- if op == "reopen-document":
5832
- # The document's file in the engine's filesystem was just rewritten (a revert, a
5833
- # backup); open it again as the model.
5834
- return SESSION.bind_document(SESSION.document_relative)
5862
+ if op == "open-document":
5863
+ # The worker just staged the document's file into the engine's filesystem (another
5864
+ # `.blend`, or this one again for a revert or a backup); open it as the model.
5865
+ return SESSION.bind_document(request["document"])
5835
5866
  if op == "start":
5836
5867
  SESSION.project = request.get("project", SESSION.project)
5837
5868
  try:
5838
5869
  os.chdir(SESSION.project)
5839
5870
  except OSError:
5840
5871
  pass
5841
- answer = {"session": SESSION.session, "python": sys.version,
5842
- "blender": bpy.app.version_string, "engines": ENGINES,
5843
- "unavailableEngines": UNAVAILABLE_ENGINES}
5844
- document = request.get("document")
5845
- if document:
5846
- answer.update(SESSION.bind_document(document))
5847
- return answer
5872
+ return {"session": SESSION.session, "python": sys.version,
5873
+ "blender": bpy.app.version_string, "engines": ENGINES,
5874
+ "unavailableEngines": UNAVAILABLE_ENGINES}
5848
5875
  raise ValueError("Unknown session op %r" % (op,))
5849
5876
 
5850
5877
 
@@ -5931,6 +5958,7 @@ def _enable_addons():
5931
5958
  import addon_utils
5932
5959
  sys.path.insert(0, _ADDONS)
5933
5960
  for name in sorted(os.listdir(_ADDONS)):
5961
+ _say("@@VOLTER-PHASE addon/%s" % name)
5934
5962
  failed = []
5935
5963
  addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
5936
5964
  if failed:
@@ -5942,10 +5970,14 @@ def main():
5942
5970
  global ENGINES, UNAVAILABLE_ENGINES
5943
5971
  if _blender_web is None:
5944
5972
  raise RuntimeError("The session worker needs Blender with the _blender_web native door")
5973
+ # Each step of the session's start says it began (`@@VOLTER-PHASE`): the page makes each a load boundary, so the
5974
+ # wait for the ready line is timed step by step, Blender's and Python's own start being the wait before the first.
5975
+ _say("@@VOLTER-PHASE session")
5945
5976
  _prepare_directories()
5946
5977
  bpy.app.handlers.load_post.append(_load_post)
5947
5978
  bpy.app.handlers.undo_post.append(_history_post)
5948
5979
  bpy.app.handlers.redo_post.append(_history_post)
5980
+ _say("@@VOLTER-PHASE engines")
5949
5981
  ENGINES, UNAVAILABLE_ENGINES = _register_engine()
5950
5982
  _enable_addons()
5951
5983
  _say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
@@ -1185,16 +1185,7 @@ export class BlenderRuntimeView {
1185
1185
  }
1186
1186
 
1187
1187
  stageMode(): string | null {
1188
- return this.playing ? 'PLAY' : this.mode;
1189
- }
1190
-
1191
- /** PLAY IS A MODE OF THE STAGE: while the document plays, the keys are the player's, and the
1192
- * mode this context publishes is what the frame's key rules stand down on
1193
- * (`volter.stage.mode != 'PLAY'`). Blender's own mode returns on stop. */
1194
- private playing = false;
1195
-
1196
- setPlaying(playing: boolean): void {
1197
- this.playing = playing;
1188
+ return this.mode;
1198
1189
  }
1199
1190
 
1200
1191
  /**
@@ -2594,13 +2585,6 @@ export class BlenderRuntimeView {
2594
2585
  return this.frame;
2595
2586
  }
2596
2587
 
2597
- /** The frame this view holds with no objects in it: its World, render and view settings. */
2598
- private worldFrame(): Frame {
2599
- const source = this.frame;
2600
- if (!source) throw new Error('Blender capture requires a presented frame');
2601
- return structuredClone({ ...source, objects: [], meshes: {}, materials: {}, instances: [], lights: {}, volumes: {}, images: {}, armatures: {}, weights: null });
2602
- }
2603
-
2604
2588
  /** Everything this view holds, as ONE self-contained frame another view can be built from:
2605
2589
  * every mesh's resident geometry and every resident image, with no reference to what a
2606
2590
  * receiver already holds. */
@@ -2717,26 +2701,6 @@ export class BlenderRuntimeView {
2717
2701
  };
2718
2702
  }
2719
2703
 
2720
- /**
2721
- * THE WORLD OF THE MODEL, FOR PLAY: a view of the World and the scene's settings this view holds now, with none of
2722
- * the model's objects (a game over the standard artifact brings its own, RFC 0007), handed nothing afterwards.
2723
- * Blender's data and this view never see it, and disposing it is the whole undo.
2724
- */
2725
- detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2726
- const detached = new BlenderRuntimeView();
2727
- if (this.frame) detached.applyFrame(this.worldFrame());
2728
- let disposed = false;
2729
- return {
2730
- view: detached,
2731
- dispose: () => {
2732
- if (disposed) return;
2733
- disposed = true;
2734
- detached.dispose();
2735
- detached.root.removeFromParent();
2736
- },
2737
- };
2738
- }
2739
-
2740
2704
  /**
2741
2705
  * A revision-owned render presenter, never a clone of the mounted scene.
2742
2706
  * Copy the resident draw arrays and image bytes synchronously, then rebuild
package/browser/worker.ts CHANGED
@@ -57,9 +57,9 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
57
57
 
58
58
  // ---- The session's document.
59
59
  //
60
- // One session, one `.blend`. `documentPath` is the PROJECT-RELATIVE spelling —
61
- // the only one that crosses to the server, which joins it to its own root so
62
- // no host path is ever on the wire.
60
+ // One `.blend` at a time, the one `open-document` last named (or a script's Save As moved
61
+ // to). `documentPath` is the PROJECT-RELATIVE spelling — the only one that crosses to the
62
+ // server, which joins it to its own root so no host path is ever on the wire.
63
63
  let documentPath: string | null = null;
64
64
  let documentDirty = false;
65
65
  /** The revision the last reported change was. */
@@ -256,32 +256,64 @@ async function saveDocument(): Promise<boolean> {
256
256
  }
257
257
 
258
258
  /**
259
- * REOPEN THE DOCUMENT from the project's file, or as `backup` (its unsaved state at a restart).
260
- * The project's copy is staged in as it is at start, so the stager keeps its host stamp and does
261
- * not fetch the file again once Blender has read and removed it (`session.py::bind_document`); a
262
- * backup is then written over it. Blender presents what it opened.
259
+ * OPEN `document` IN THE RUNNING BLENDER from the project's file, or as `backup` (its unsaved state at a
260
+ * restart): another `.blend`, or the one it holds again (a revert, a restore). The project's copy is staged
261
+ * in fresh, so the stager keeps its host stamp and does not fetch the file again once Blender has read and
262
+ * removed it (`session.py::bind_document`); a backup is then written over it. Blender presents what it opened.
263
+ *
264
+ * A failure before Blender's own open (the path, the file's staging, a backup with no project file) leaves
265
+ * Blender as it was, holding the document it held, and answers `notOpened` with why; one inside Blender's
266
+ * open throws.
263
267
  */
264
- async function reopenDocument(backup?: Uint8Array): Promise<void> {
265
- if (!engine || !projectRoot || documentPath === null) throw new Error('The Blender session holds no document to reopen');
266
- const path = `${projectRoot}/${documentPath}`;
267
- staged.delete(path);
268
+ async function openDocument(document: string, backup?: Uint8Array): Promise<unknown> {
269
+ if (!engine || !projectRoot) throw new Error('The Blender session has not started');
268
270
  try {
269
- await engine.files.unlink(path);
270
- } catch {
271
- /* the open already removed it */
271
+ await stageDocument(engine, projectRoot, document, backup);
272
+ } catch (error) {
273
+ return { notOpened: describeThrown(error) };
272
274
  }
273
- await stageProjectFiles(engine.files, projectRoot);
274
- const host = staged.get(path)?.host;
275
- if (host === undefined) throw new Error(`${documentPath} could not be read from the project`);
275
+ documentPath = document;
276
+ const opened = await engine.request({ op: 'open-document', document });
277
+ setDocumentDirty(backup !== undefined);
278
+ return opened;
279
+ }
280
+
281
+ /** `document`'s file staged into the engine for its open, from the project or as `backup`. */
282
+ async function stageDocument(engine: BlenderEngine, projectRoot: string, document: string, backup?: Uint8Array): Promise<void> {
283
+ // The document is the project's own file and is named the project's own way. An absolute path here
284
+ // would be a host path on the wire and a destination the page chose, which this transport never carries.
285
+ if (!isDocumentPath(document))
286
+ throw new Error(
287
+ `The Blender document must be a project-relative .blend path with no traversal ` +
288
+ `(models/model.blend); got ${JSON.stringify(document)}`,
289
+ );
290
+ // The file given up and the one opened leave the engine and are staged again from the project: the
291
+ // one given up was saved there, and its copy here is the session's no longer (a script reads it, and a
292
+ // listing of the session's own files leaves it out, as it does every project file staged in).
293
+ for (const path of new Set([documentPath, document].flatMap((held) => held === null ? [] : [`${projectRoot}/${held}`]))) {
294
+ staged.delete(path);
295
+ try {
296
+ await engine.files.unlink(path);
297
+ } catch {
298
+ /* not in the engine: never staged, or the open already removed it */
299
+ }
300
+ }
301
+ const path = `${projectRoot}/${document}`;
302
+ // The document itself not reaching the engine is a refusal, not a footnote: the open would
303
+ // fail on a missing file (or open a stale copy) without saying why.
304
+ const failed = await stageProjectFiles(engine.files, projectRoot);
305
+ const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
306
+ if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
307
+ await loadCheckpoint?.('project-imported');
276
308
  if (backup) {
309
+ const host = staged.get(path)?.host;
310
+ if (host === undefined) throw new Error(`${document} could not be read from the project`);
277
311
  await engine.files.writeFile(path, backup);
278
312
  staged.set(path, { host, engine: await stampOf(engine.files, path) });
279
313
  }
280
- await engine.request({ op: 'reopen-document' });
281
- setDocumentDirty(backup !== undefined);
282
314
  }
283
315
 
284
- async function startBlender(project: string, document?: string): Promise<unknown> {
316
+ async function startBlender(project: string): Promise<unknown> {
285
317
  // The engine is named through a holder rather than the module-level
286
318
  // `engine`, because `ask` is handed to the engine before the engine exists.
287
319
  const holder: { engine: BlenderEngine | null } = { engine: null };
@@ -372,22 +404,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
372
404
  holder.engine = started;
373
405
  await loadCheckpoint?.("engine-ready");
374
406
  engine = started;
375
- // Stage the project’s files before the session starts, including the .blend opened by its first
376
- // operation. Later execute calls stage their inputs on entry.
377
- if (document) {
378
- // The document itself not reaching the engine is a refusal, not a footnote: the open below
379
- // would fail on a missing file (or open a stale copy) without saying why.
380
- const failed = await stageProjectFiles(started.files, project);
381
- const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
382
- if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
383
- }
384
- await loadCheckpoint?.("project-imported");
385
- const banner = (await started.request({
386
- op: 'start',
387
- project,
388
- ...(document ? { document } : {}),
389
- })) as Record<string, unknown>;
390
- if (document) documentPath = document;
407
+ // The project's files are staged by each call that reads them: a document's open, a script's run.
408
+ const banner = (await started.request({ op: 'start', project })) as Record<string, unknown>;
391
409
  session = {
392
410
  sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
393
411
  objectInfo: async (name: string) =>
@@ -428,19 +446,11 @@ export function projectMount(project: string): string {
428
446
  return /^[A-Za-z]:[\\/]/.test(project) ? `/${project.replaceAll('\\', '/')}` : project;
429
447
  }
430
448
 
431
- async function start(hostProject: string, document?: string): Promise<unknown> {
449
+ async function start(hostProject: string): Promise<unknown> {
432
450
  if (session) throw new Error('The Blender session is already started');
433
451
  const project = projectMount(hostProject);
434
452
  if (!project.startsWith('/'))
435
453
  throw new Error("The Blender session needs the project's absolute path");
436
- // The document is the project's own file and is named the project's own way.
437
- // An absolute path here would be a host path on the wire and a destination
438
- // the page chose, which is the one thing this transport does not carry.
439
- if (document !== undefined && !isDocumentPath(document))
440
- throw new Error(
441
- `The Blender document must be a project-relative .blend path with no traversal ` +
442
- `(models/model.blend); got ${JSON.stringify(document)}`,
443
- );
444
454
  const served = await blenderIsServed();
445
455
  if (!served.available)
446
456
  throw new Error(
@@ -449,7 +459,7 @@ async function start(hostProject: string, document?: string): Promise<unknown> {
449
459
  '(packages/blender-engine/wasm, or the directory VOLTER_BLENDER_WASM_DIR names); nothing stands in for it.',
450
460
  );
451
461
  projectRoot = project;
452
- return startBlender(project, document);
462
+ return startBlender(project);
453
463
  }
454
464
 
455
465
  /** A project-relative `.blend`, with no traversal and no absolute root. The
@@ -663,7 +673,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
663
673
  switch (request.op) {
664
674
  case 'start': {
665
675
  if (loadJob) throw new Error('The Blender load has already started');
666
- return pullWork('start', () => start(request.project, request.document));
676
+ return pullWork('start', () => start(request.project));
667
677
  }
668
678
  case 'load-next':
669
679
  if (!loadJob) throw new Error('No Blender load to continue');
@@ -691,20 +701,20 @@ async function handle(request: WorkerRequest): Promise<unknown> {
691
701
  case 'flush-document':
692
702
  // A flush lands what is pending; a document nothing changed stays the
693
703
  // file it was opened from.
704
+ // (it names what it saved: the tab's handle may already name the next document)
694
705
  if (!documentDirty) return { saved: false };
695
- return { saved: await saveDocument() };
706
+ {
707
+ const document = documentPath;
708
+ return { saved: await saveDocument(), ...(document === null ? {} : { document }) };
709
+ }
696
710
  case 'snapshot-document': {
697
711
  if (documentPath === null) throw new Error('The Blender session holds no document');
698
712
  const snapshot = await engineDocument(documentPath, () => {});
699
713
  if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
700
714
  return snapshot.bytes;
701
715
  }
702
- case 'revert-document':
703
- await reopenDocument();
704
- return undefined;
705
- case 'restore-document':
706
- await reopenDocument(request.bytes);
707
- return undefined;
716
+ case 'open-document':
717
+ return pullWork('open-document', () => openDocument(request.document, request.bytes));
708
718
  case 'history-begin':
709
719
  case 'history-end':
710
720
  return ask({ op: request.op });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.149",
5
+ "version": "0.1.151",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },