@volter/editor-blender 0.1.124 → 0.1.125

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/README.md CHANGED
@@ -12,6 +12,18 @@ that list through the host's contribution loader.
12
12
  Run `npm run typecheck -w @volter/editor-blender` through the active World.
13
13
  See LICENSE for the AGPL code and Blender-derived GPL icon notices.
14
14
 
15
+ Switching `.blend` documents drains accepted work and saves the previous file
16
+ before retiring its worker and opening the next file. Opens, explicit stops and
17
+ graceful session shutdown share one lifecycle queue. A failed save retains the
18
+ previous worker; a superseded file selection never becomes the current owner.
19
+ If the native workspace remounts the same file's pane during startup, its new
20
+ pane restores the presenter immediately instead of waiting behind the boot
21
+ that needs it. Context cleanup belongs to each publication, so a retiring pane
22
+ cannot withdraw its replacement's publication of the shared view.
23
+ While opening, the document shows the last available model photograph as a
24
+ labeled read-only preview and a loading panel. Without a preview it shows a
25
+ full status surface. A failed open offers Retry and Return to previous model.
26
+
15
27
  This is a private migration staging package. Full editor startup, live editing,
16
28
  wire/branding migration and existing-consumer cutover remain unfinished. Passing
17
29
  a package build is not proof that the complete editor is ready to release.
@@ -0,0 +1,56 @@
1
+ import type { ModelDocumentPreview } from '../src/model-document-preview';
2
+ import { modelOpeningErrorMessage } from '../src/model-opening-error';
3
+ import { Button, StateSurface, fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
4
+ import type { AuthoringAdapter, EditorNode } from '@volter/editor-project/adapter';
5
+ import { AssetEditorSubject } from '@volter/editor-sdk/kit/components/AssetEditorShell';
6
+ import { useMemo } from 'react';
7
+
8
+ /** A loading/error surface has no authoring stage and publishes no model. */
9
+ export function BlenderModelOpening({ documentId, path, error, preview, retry, returnToPreview, preparingView = false }: {
10
+ readonly documentId: string;
11
+ readonly path: string;
12
+ readonly error: string | null;
13
+ readonly preview: ModelDocumentPreview | null;
14
+ readonly retry: () => void;
15
+ readonly returnToPreview: () => void;
16
+ readonly preparingView?: boolean;
17
+ }) {
18
+ const title = error ? 'Could not open model' : preparingView ? 'Preparing model view' : 'Opening model';
19
+ // This document has not published an authoring stage yet. Its panels still
20
+ // belong to it, rather than the empty project's "Declare a root" fallback.
21
+ // A boundary row is informational; no old model objects remain editable.
22
+ const adapter = useMemo<AuthoringAdapter>(() => {
23
+ const node: EditorNode = { id: `opening:${documentId}`, label: title, secondaryLabel: path,
24
+ role: 'boundary', kind: 'object', parentId: null, childIds: [] };
25
+ return { capabilities: { transform: false, inspectorFields: false, persist: false },
26
+ hierarchy: { roots: () => [node], node: id => id === node.id ? node : null },
27
+ subscribe: () => () => {} };
28
+ }, [documentId, path, title]);
29
+ return (
30
+ <div data-testid="blender-model-opening" aria-busy={!error}
31
+ style={{ position: 'absolute', inset: 0, overflow: 'auto', background: themeVars.surface.panel, color: themeVars.content.primary }}>
32
+ {!preparingView && <AssetEditorSubject key={title} documentId={documentId} type="Model" title={path}
33
+ status={error ? modelOpeningErrorMessage(error) : title}
34
+ selection={() => ({ adapter, nodeId: null })} />}
35
+ {preview && <img src={preview.image} alt={`Last view of ${preview.path}`} draggable={false}
36
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />}
37
+ <div style={{ position: 'relative', minHeight: '100%', display: 'flex', flexDirection: 'column', justifyContent: preview ? 'flex-end' : 'center', alignItems: preview ? 'flex-start' : 'center', boxSizing: 'border-box', padding: spaceVar[8], gap: spaceVar[4] }}>
38
+ {preview && <div style={{ padding: `${spaceVar[2]} ${spaceVar[4]}`, background: themeVars.surface.chrome, border: `1px solid ${themeVars.boundary.default}`, borderRadius: themeVars.shape.small, maxWidth: '100%', overflowWrap: 'anywhere' }}>
39
+ Previous model: {preview.path} · Preview only
40
+ </div>}
41
+ <StateSurface tone={error ? 'error' : 'loading'}
42
+ style={{ width: 'min(100%, 32rem)', background: themeVars.surface.chrome, border: `1px solid ${themeVars.boundary.default}`, borderRadius: themeVars.shape.small, overflowWrap: 'anywhere' }}
43
+ title={<span style={{ fontSize: fontSizeVar.lg }}>{title}</span>}
44
+ description={<>
45
+ <div style={{ color: themeVars.content.primary, marginBottom: spaceVar[3] }}>{path}</div>
46
+ {error ? modelOpeningErrorMessage(error) : preparingView ? 'The file is open. Preparing its viewport.' : preview ? 'The previous model stays visible while this file opens.' : 'Preparing Blender and opening this file. The model will appear here when it is ready.'}
47
+ </>}
48
+ action={error ? <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: spaceVar[3] }}>
49
+ {preview && <Button onClick={returnToPreview}>Return to previous model</Button>}
50
+ <Button variant="secondary" onClick={retry}>Retry opening</Button>
51
+ </div> : <progress aria-label={`Opening ${path}`} style={{ width: 'min(100%, 18rem)', accentColor: themeVars.accent.default }} />}
52
+ />
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -1104,6 +1104,10 @@ export function blenderOutlinerAuthoringFor(
1104
1104
  * been read, so the same engine selection must be re-derived when the tree
1105
1105
  * moves under it. */
1106
1106
  let lastEngineKey: string | null = null;
1107
+ // Tree loading publications can arrive before a gesture's write presents.
1108
+ // Keep its optimistic selection until the latest write settles; an older
1109
+ // write's frame must not replace a newer gesture either.
1110
+ let pendingSelection: symbol | null = null;
1107
1111
  /** The world matrix each live gesture started from — see `beginEdit`. */
1108
1112
  const gestureStart = new Map<THREE.Object3D, THREE.Matrix4>();
1109
1113
  // Retain the subject until endEdit, even if a concurrent native edit removes
@@ -1243,6 +1247,7 @@ export function blenderOutlinerAuthoringFor(
1243
1247
  * disagreement this whole unit exists to end.
1244
1248
  */
1245
1249
  const syncFromEngine = (): void => {
1250
+ if (disposed || pendingSelection !== null) return;
1246
1251
  const engine = blenderEngineSelection();
1247
1252
  const key = `${blenderOutlinerVersion()}${engine.active ?? ''}${[...engine.selected]
1248
1253
  .sort()
@@ -1260,6 +1265,9 @@ export function blenderOutlinerAuthoringFor(
1260
1265
  const selection: SelectionProvider = {
1261
1266
  get: () => [...selected],
1262
1267
  set: (ids) => {
1268
+ if (disposed) return;
1269
+ const request = Symbol('Blender selection');
1270
+ pendingSelection = request;
1263
1271
  // NORMALIZED, because ids arrive in BOTH SPACES (the header's "both
1264
1272
  // ways"). A hierarchy click and this adapter's own verbs speak ROW ids;
1265
1273
  // the viewport's MARQUEE speaks the presentation's, because it picks
@@ -1305,7 +1313,16 @@ export function blenderOutlinerAuthoringFor(
1305
1313
  );
1306
1314
  // The ACTIVE is the LAST row the gesture named, which is the shell's own
1307
1315
  // reading of `selectedEntityId` and Blender's of an active object.
1308
- void writeBlenderSelection(names, names.length === 0 ? null : names[names.length - 1]!);
1316
+ void writeBlenderSelection(names, names.length === 0 ? null : names[names.length - 1]!)
1317
+ .catch(error => editorHost().console.error(
1318
+ `Blender refused the selection write: ${String(error)}`, 'blender-outliner',
1319
+ ))
1320
+ .finally(() => {
1321
+ if (disposed || pendingSelection !== request) return;
1322
+ pendingSelection = null;
1323
+ lastEngineKey = null;
1324
+ syncFromEngine();
1325
+ });
1309
1326
  },
1310
1327
  };
1311
1328
 
@@ -50,6 +50,7 @@ import {
50
50
  Suspense,
51
51
  useCallback,
52
52
  useEffect,
53
+ useLayoutEffect,
53
54
  useMemo,
54
55
  useRef,
55
56
  useState,
@@ -65,6 +66,7 @@ import {
65
66
  blenderViewShading,
66
67
  openModelDocumentBlend,
67
68
  modelDocumentMayOpen,
69
+ modelDocumentOwnsPresentation,
68
70
  } from '../host/blender-runtime-host';
69
71
  import { BlenderObjectModeHeader } from './blender-header-menus';
70
72
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
@@ -86,6 +88,15 @@ import {
86
88
  import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
87
89
  import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
88
90
  import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
91
+ import { BlenderModelOpening } from './blender-model-opening';
92
+ import { modelOpeningErrorMessage } from '../src/model-opening-error';
93
+ import {
94
+ clearModelDocumentPreview,
95
+ modelDocumentPreview,
96
+ rememberModelDocumentPreview,
97
+ subscribeModelDocumentPreview,
98
+ type ModelDocumentPreview,
99
+ } from '../src/model-document-preview';
89
100
 
90
101
  /** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
91
102
  const overlaysOpened = new WeakSet<object>();
@@ -211,9 +222,63 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
211
222
  const entryId = document?.id;
212
223
  const key = JSON.stringify([documentId, entryId, blend]);
213
224
  const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
225
+ const [attempt, setAttempt] = useState(0);
226
+ const project = editorHost().projectLocalState.projectRootPath();
227
+ const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
228
+ const viewportVersion = useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
229
+ const lastFrame = useRef<ModelDocumentPreview | null>(null);
214
230
  const callbacks = useRef({ notify, publishContext });
215
231
  callbacks.current = { notify, publishContext };
216
232
 
233
+ useEffect(() => editorHost().session.onEnded(clearModelDocumentPreview), []);
234
+ // Retain a bounded photograph while the stage is healthy. Teardown can
235
+ // already have changed its scene/camera, so it is too late to re-render then.
236
+ useEffect(() => {
237
+ if (active === false || !documentId || !entryId || !project || opened?.key !== key || opened.error) return;
238
+ const stage = object3DDocumentSession(documentId);
239
+ if (!stage) return;
240
+ let cancelled = false;
241
+ let timer: ReturnType<typeof setTimeout> | undefined;
242
+ const photograph = async () => {
243
+ try {
244
+ const presented = view.snapshot();
245
+ const viewport = documentViewport(documentId);
246
+ if (!viewport || stage.root !== view.root || !modelDocumentOwnsPresentation(documentId, entryId, blend, presented)) return;
247
+ const frame = await stage.requestPresentedFrame();
248
+ if (cancelled || view.snapshot() !== presented ||
249
+ object3DDocumentSession(documentId) !== stage || stage.root !== view.root || documentViewport(documentId) !== viewport ||
250
+ !modelDocumentOwnsPresentation(documentId, entryId, blend, presented)) return;
251
+ if (frame && frame.width > 0 && frame.height > 0) {
252
+ const copy = window.document.createElement('canvas');
253
+ copy.width = Math.min(frame.width, 1024);
254
+ copy.height = Math.max(1, Math.round(copy.width * frame.height / frame.width));
255
+ const context = copy.getContext('2d');
256
+ if (context) {
257
+ context.drawImage(frame, 0, 0, copy.width, copy.height);
258
+ lastFrame.current = { project, entryId, path: blend ?? 'Model', image: copy.toDataURL('image/png'), capturedAt: performance.now() };
259
+ rememberModelDocumentPreview(lastFrame.current);
260
+ }
261
+ }
262
+ } catch {
263
+ // An unavailable frame leaves the last good photograph intact.
264
+ } finally {
265
+ if (!cancelled) timer = setTimeout(() => { void photograph(); }, 1000);
266
+ }
267
+ };
268
+ void photograph();
269
+ return () => { cancelled = true; clearTimeout(timer); };
270
+ }, [active, blend, documentId, entryId, project, key, opened, viewportVersion]);
271
+
272
+ // Publish only an image, never the old file's authoring stage or context.
273
+ useLayoutEffect(() => {
274
+ if (active === false || !documentId || !entryId || !project || opened?.key !== key || opened.error) return;
275
+ return () => {
276
+ if (!editorHost().session.open()) return;
277
+ const frame = lastFrame.current;
278
+ if (frame?.project === project && frame.entryId === entryId) rememberModelDocumentPreview(frame);
279
+ };
280
+ }, [active, blend, documentId, entryId, project, key, opened]);
281
+
217
282
  useEffect(() => {
218
283
  if (active === false || !documentId) return;
219
284
  let cancelled = false;
@@ -250,7 +315,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
250
315
  unpublish = undefined;
251
316
  const detail = error instanceof Error ? error.message : String(error);
252
317
  setOpened({ key, error: detail });
253
- callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${blend ?? 'the model'}`, detail });
318
+ editorHost().console.error(detail, 'blender-open');
319
+ callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${blend ?? 'the model'}`, detail: modelOpeningErrorMessage(detail) });
254
320
  } finally {
255
321
  starting = false;
256
322
  }
@@ -272,12 +338,24 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
272
338
  unpublish?.();
273
339
  unbind();
274
340
  };
275
- }, [active, blend, documentId, entryId, key]);
341
+ }, [active, blend, documentId, entryId, key, attempt]);
276
342
 
277
343
  if (active === false || !documentId) return null;
278
- if (opened?.key !== key) return <div role="status">Opening model…</div>;
279
- if (opened.error) return <div role="alert">{opened.error}</div>;
280
- return <BlenderModelViewport {...props} />;
344
+ if (opened?.key !== key || opened.error) return <BlenderModelOpening
345
+ documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
346
+ retry={() => { setOpened(null); setAttempt(value => value + 1); }}
347
+ returnToPreview={() => {
348
+ if (!preview) return;
349
+ void editorHost().workspace.open({ kind: 'document', id: preview.entryId }).then(opened => {
350
+ if (!opened) throw new Error('The previous model is no longer available in this project.');
351
+ }).catch(error => callbacks.current.notify?.({ tone: 'error', title: 'Could not return to the previous model', detail: String(error) }));
352
+ }}
353
+ />;
354
+ return <>
355
+ <BlenderModelViewport {...props} />
356
+ {!documentViewport(documentId) && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
357
+ preparingView preview={preview} retry={() => {}} returnToPreview={() => {}} />}
358
+ </>;
281
359
  }
282
360
 
283
361
  /**
@@ -385,9 +463,12 @@ function BlenderModelViewport(props: ToolContributionProps) {
385
463
  if (!modelViewport) return;
386
464
  // registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
387
465
  const capture = modelViewport.capture;
388
- modelViewport.capture = (size) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
466
+ const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
467
+ modelViewport.capture = playCapture;
389
468
  return () => {
390
- modelViewport.capture = capture;
469
+ if (modelViewport.capture !== playCapture) return;
470
+ if (capture) modelViewport.capture = capture;
471
+ else delete modelViewport.capture;
391
472
  };
392
473
  }, [game, documentId]);
393
474
  if (!documentId) return null;
@@ -402,7 +483,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
402
483
  </div>
403
484
  {game && (
404
485
  <div key="play" style={{ position: 'absolute', inset: 12 }} data-testid="blender-play-area">
405
- <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} play onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
486
+ <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
406
487
  </div>
407
488
  )}
408
489
  {second && (
@@ -489,10 +570,10 @@ function BlenderViewportArea({
489
570
  surfaces,
490
571
  view,
491
572
  main,
492
- play = false,
573
+ playing = false,
493
574
  onPlayReady,
494
575
  onPlayReturn,
495
- }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly play?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
576
+ }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
496
577
  const reads = readsOf(view);
497
578
  const build = useCallback(
498
579
  () => ({
@@ -502,7 +583,7 @@ function BlenderViewportArea({
502
583
  // and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing:
503
584
  // its script moves it every frame, and a source that announces nothing is drawn every
504
585
  // frame (`ToolObject3DPreviewSource.onChange`).
505
- ...(play
586
+ ...(playing
506
587
  ? {}
507
588
  : {
508
589
  onChange(listener: () => void) {
@@ -516,7 +597,7 @@ function BlenderViewportArea({
516
597
  }),
517
598
  dispose() {},
518
599
  }),
519
- [view, play],
600
+ [view, playing],
520
601
  );
521
602
  const blend = document?.source?.path;
522
603
  // Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
@@ -557,7 +638,7 @@ function BlenderViewportArea({
557
638
  */
558
639
  useEffect(() => {
559
640
  // A playing copy's camera is its script's.
560
- if (main || play || !documentId) return;
641
+ if (main || playing || !documentId) return;
561
642
  let entered = false;
562
643
  const enter = (): void => {
563
644
  if (entered) return;
@@ -572,7 +653,7 @@ function BlenderViewportArea({
572
653
  stopSessions();
573
654
  stopFrames();
574
655
  };
575
- }, [main, play, documentId, view]);
656
+ }, [main, playing, documentId, view]);
576
657
  /**
577
658
  * THE PLAYING COPY IS MOVED BY THE PROJECT'S PLAY SCRIPT (the document Play extension), on this
578
659
  * stage's own frame hook, once the stage stands. The stage's orbit stands down while it runs,
@@ -587,7 +668,7 @@ function BlenderViewportArea({
587
668
  const playReturnRef = useRef(onPlayReturn);
588
669
  playReturnRef.current = onPlayReturn;
589
670
  useEffect(() => {
590
- if (!play || !documentId) return;
671
+ if (!playing || !documentId) return;
591
672
  if (blend === undefined) {
592
673
  notifyRef.current?.({
593
674
  tone: 'error',
@@ -599,7 +680,7 @@ function BlenderViewportArea({
599
680
  const modelId = documentId.replace(/#play$/, '');
600
681
  const layers = window.document.createElement('div');
601
682
  Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
602
- layers.dataset.testid = 'model-play-roots';
683
+ layers.dataset['testid'] = 'model-play-roots';
603
684
  surfaceRef.current?.appendChild(layers);
604
685
  setPlayReady(false);
605
686
  let stopped = false;
@@ -666,7 +747,7 @@ function BlenderViewportArea({
666
747
  layers.remove();
667
748
  if (orbit) orbit.enabled = true;
668
749
  };
669
- }, [play, documentId, blend, view]);
750
+ }, [playing, documentId, blend, view]);
670
751
  /**
671
752
  * A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
672
753
  * on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
@@ -836,7 +917,7 @@ function BlenderViewportArea({
836
917
  */
837
918
  const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
838
919
  const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
839
- !play && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
920
+ !playing && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
840
921
  const onPointerDownCapture = (event: ReactPointerEvent): void => {
841
922
  if (!cursorChord(event)) {
842
923
  cursorPress.current = null;
@@ -885,8 +966,8 @@ function BlenderViewportArea({
885
966
  return (
886
967
  <div
887
968
  ref={surfaceRef}
888
- data-play-ready={play ? playReady : undefined}
889
- style={play ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
969
+ data-play-ready={playing ? playReady : undefined}
970
+ style={playing ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
890
971
  onPointerDownCapture={onPointerDownCapture}
891
972
  onPointerUpCapture={onPointerUpCapture}
892
973
  onPointerCancelCapture={dropCursorPress}
@@ -915,7 +996,7 @@ function BlenderViewportArea({
915
996
  // "Inspection parity", I3).
916
997
  // A PLAYING COPY HAS NO AUTHORING: it is read-only to the editor, and what moves it is its
917
998
  // script. With none given the stage is the native read-only one.
918
- {...(play ? {} : { authoring })}
999
+ {...(playing ? {} : { authoring })}
919
1000
  cameraDirection={[0.8187, 0.4458, 0.3617]}
920
1001
  // FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
921
1002
  // three fits.
@@ -324,6 +324,22 @@ export function modelDocumentMayOpen(binding: ModelDocumentBinding): boolean {
324
324
  return !anotherModel || active === binding.documentId;
325
325
  }
326
326
 
327
+ /** A retained photograph must belong to the file and revision still on screen,
328
+ * not a retiring pane or the next worker's first frame. */
329
+ export function modelDocumentOwnsPresentation(
330
+ documentId: string,
331
+ entryId: string,
332
+ blend: string | undefined,
333
+ presented: { readonly session: string; readonly revision: number } | null,
334
+ ): boolean {
335
+ const binding = boundModel;
336
+ const latest = runtime?.presented;
337
+ return editorHost().session.open() && !!binding && binding.documentId === documentId && binding.entryId === entryId &&
338
+ binding.blend === blend && modelDocumentMayOpen(binding) &&
339
+ runtime?.project === editorHost().projectLocalState.projectRootPath() && runtime.document === blend &&
340
+ !!presented && !!latest && presented.session === latest.session && presented.revision === latest.revision;
341
+ }
342
+
327
343
  /**
328
344
  * OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
329
345
  * path run backwards. `session.py` opens the named file at start and saves
@@ -334,22 +350,73 @@ export function modelDocumentMayOpen(binding: ModelDocumentBinding): boolean {
334
350
  * identity. Boot itself can present, so the context must exist before awaiting
335
351
  * boot completion. Return false when the requesting pane has since unmounted.
336
352
  */
353
+ let modelOpenTail: Promise<void> = Promise.resolve();
354
+ let modelStartup: { readonly session: BlenderRuntime; readonly project: string; readonly blend: string } | null = null;
355
+
356
+ function queueModelLifecycle<T>(operation: () => Promise<T>): Promise<T> {
357
+ const result = modelOpenTail.then(operation);
358
+ modelOpenTail = result.then(() => {}, () => {});
359
+ return result;
360
+ }
361
+
337
362
  export async function openModelDocumentBlend(
338
363
  binding: ModelDocumentBinding,
339
364
  publish: () => void,
365
+ ): Promise<boolean> {
366
+ let published = false;
367
+ const publishOnce = () => {
368
+ if (published) return;
369
+ publish();
370
+ published = true;
371
+ };
372
+ const host = editorHost();
373
+ const starting = modelStartup;
374
+ // A native workspace restore can remount this pane before boot's first
375
+ // frame. Its predecessor has unpublished, but still owns the queued boot:
376
+ // waiting behind it before publishing would make boot wait on itself.
377
+ // Only the SAME starting resource can restore its presenter here. Another
378
+ // file still waits for the old worker's save/retirement in the queue.
379
+ if (starting && starting.session === runtime && host.session.open() &&
380
+ starting.project === host.projectLocalState.projectRootPath() &&
381
+ starting.blend === binding.blend && modelDocumentMayOpen(binding)) {
382
+ publishOnce();
383
+ }
384
+ // Only one handoff may own stop/start. Re-check the mounted requester after
385
+ // every wait: a rapid B -> C selection must not let B publish into C.
386
+ return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce));
387
+ }
388
+
389
+ async function openBoundModelDocument(
390
+ binding: ModelDocumentBinding,
391
+ publish: () => void,
340
392
  ): Promise<boolean> {
341
393
  const host = editorHost();
342
394
  if (!host.session.open()) throw new Error('Opening a model requires an open project session.');
343
395
  const project = host.projectLocalState.projectRootPath();
344
396
  if (project === null) throw new Error('Opening a model requires a project path.');
345
397
  if (!modelDocumentMayOpen(binding)) return false;
398
+ const previous = runtime;
399
+ if (previous && (previous.project !== project || previous.document !== binding.blend)) {
400
+ // stop drains accepted work and flushes the OLD worker's bound file. If
401
+ // saving fails it stays alive; neither its history nor its ownership moves.
402
+ const end = beginBlenderWork('saving previous model before opening another');
403
+ try { await previous.stop(); } finally { end(); }
404
+ if (runtime === previous) terminateBlenderRuntime();
405
+ if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
406
+ }
346
407
  const session = blenderRuntime();
347
408
  // start claims its resource synchronously; its first frame may arrive before
348
409
  // the returned promise resolves. The getter above rejects a conflicting file.
349
410
  const started = session.start(project, binding.blend);
350
- publish();
351
- await started;
352
- if (boundModel !== binding) return false;
411
+ const starting = { session, project, blend: binding.blend };
412
+ modelStartup = starting;
413
+ try {
414
+ publish();
415
+ await started;
416
+ } finally {
417
+ if (modelStartup === starting) modelStartup = null;
418
+ }
419
+ if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
353
420
  // bind_document presents the opened file before start resolves. Do not
354
421
  // immediately evaluate/export it again: on Stoneguard that redundant RPC
355
422
  // took 13.8 seconds after the resumable startup had already finished.
@@ -357,12 +424,12 @@ export async function openModelDocumentBlend(
357
424
  // nothing at start. Compare the view's actual holding with the runtime's
358
425
  // revision so both cases still request the frame they need.
359
426
  const shown = (await runtimeView()).snapshot();
360
- if (boundModel !== binding) return false;
427
+ if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
361
428
  const latest = session.presented;
362
429
  if (!shown || !latest || shown.session !== latest.session || shown.revision !== latest.revision) {
363
430
  await session.present();
364
431
  }
365
- return boundModel === binding;
432
+ return host.session.open() && modelDocumentMayOpen(binding);
366
433
  }
367
434
 
368
435
  /**
@@ -802,12 +869,15 @@ function terminateBlenderRuntime(): void {
802
869
  function watchSessionEnd(): void {
803
870
  if (watchingSessionEnd) return;
804
871
  watchingSessionEnd = true;
805
- editorHost().session.onBeforeClose(async () => {
806
- await runtime?.stop();
807
- terminateBlenderRuntime();
808
- });
872
+ editorHost().session.onBeforeClose(() => queueModelLifecycle(stopBlenderRuntime));
809
873
  editorHost().session.onEnded(terminateBlenderRuntime);
810
874
  }
875
+
876
+ async function stopBlenderRuntime(): Promise<void> {
877
+ const owner = runtime;
878
+ await owner?.stop();
879
+ if (runtime === owner) terminateBlenderRuntime();
880
+ }
811
881
  let lastCapture: CaptureRequest | null = null;
812
882
 
813
883
  const isRuntimeView = (value: unknown): value is RuntimeView =>
@@ -1208,8 +1278,7 @@ export async function handleBlenderCommand(cmd: {
1208
1278
  let requestedDocument = typeof cmd['document'] === 'string' ? cmd['document'] : undefined;
1209
1279
  if (cmd.type === 'blender-stop' || (cmd.type === 'blender-start' && cmd['fresh'] === true)) {
1210
1280
  // Do not invalidate history or discard the worker if persistence fails.
1211
- await runtime?.stop();
1212
- terminateBlenderRuntime();
1281
+ await queueModelLifecycle(stopBlenderRuntime);
1213
1282
  if (cmd.type === 'blender-stop') return { ok: true, data: { stopped: true } };
1214
1283
  }
1215
1284
  if (cmd.type === 'blender-start' && !host.documents.context(presentationDocumentId())) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.124",
5
+ "version": "0.1.125",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -69,15 +69,15 @@
69
69
  "serving": "./dist-node/serving.mjs"
70
70
  },
71
71
  "dependencies": {
72
- "@modelcontextprotocol/sdk": "1.30.0",
73
- "@volter/blender-engine": "0.1.122",
74
- "@volter/editor-threejs": "0.5.185",
72
+ "@modelcontextprotocol/sdk": "1.32.1",
73
+ "@volter/blender-engine": "0.1.123",
74
+ "@volter/editor-threejs": "0.5.186",
75
75
  "zod": "^4.3.6"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/editor-live": "0.5.185",
79
- "@volter/editor-project": "0.5.185",
80
- "@volter/editor-sdk": "0.5.185",
78
+ "@volter/editor-live": "0.5.186",
79
+ "@volter/editor-project": "0.5.186",
80
+ "@volter/editor-sdk": "0.5.186",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
83
  "three": "^0.180.0",
@@ -0,0 +1,46 @@
1
+ /** One last photograph, never a live model or an editable document context. */
2
+ export interface ModelDocumentPreview {
3
+ readonly project: string;
4
+ readonly entryId: string;
5
+ readonly path: string;
6
+ readonly image: string;
7
+ readonly capturedAt: number;
8
+ }
9
+
10
+ let preview: ModelDocumentPreview | null = null;
11
+ const listeners = new Set<() => void>();
12
+ let queued = false;
13
+
14
+ function changed(): void {
15
+ // A pane captures during layout cleanup; notify other roots after the commit.
16
+ if (queued) return;
17
+ queued = true;
18
+ queueMicrotask(() => {
19
+ queued = false;
20
+ for (const listener of [...listeners]) listener();
21
+ });
22
+ }
23
+
24
+ export function rememberModelDocumentPreview(value: ModelDocumentPreview): void {
25
+ // Retained panes may clean up after their replacement. An older photograph
26
+ // must not overwrite the most recent visible model's frame.
27
+ if (preview?.project === value.project && preview.capturedAt > value.capturedAt) return;
28
+ if (preview === value) return;
29
+ preview = value;
30
+ changed();
31
+ }
32
+
33
+ export function clearModelDocumentPreview(): void {
34
+ if (!preview) return;
35
+ preview = null;
36
+ changed();
37
+ }
38
+
39
+ export function modelDocumentPreview(project: string | null): ModelDocumentPreview | null {
40
+ return preview?.project === project ? preview : null;
41
+ }
42
+
43
+ export function subscribeModelDocumentPreview(listener: () => void): () => void {
44
+ listeners.add(listener);
45
+ return () => { listeners.delete(listener); };
46
+ }
@@ -0,0 +1,12 @@
1
+ /** Worker details remain in the console. The opening panel explains recovery. */
2
+ export function modelOpeningErrorMessage(detail: string): string {
3
+ if (/file format is not supported|not a blend file/i.test(detail))
4
+ return 'This file is not a supported Blender model. Choose a valid .blend file or return to the previous model.';
5
+ if (/EACCES|permission denied|read-only file system/i.test(detail))
6
+ return 'Blender could not access the model file. Check its file permissions, then try again.';
7
+ if (/ENOENT|no such file|file not found/i.test(detail))
8
+ return 'The model file could not be found. Check that it still exists, or return to the previous model.';
9
+ if (/failed to fetch|networkerror|network error/i.test(detail))
10
+ return 'Blender could not finish loading. Check your connection, then try again.';
11
+ return 'Blender could not open this model. Try again, or return to the previous model. Details are available in the console.';
12
+ }