@volter/editor-blender 0.1.123 → 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 +12 -0
- package/contributions/blender-model-opening.tsx +56 -0
- package/contributions/blender-outliner-authoring.ts +18 -1
- package/contributions/blender-runtime.document.tsx +102 -21
- package/host/blender-runtime-host.ts +80 -11
- package/package.json +7 -7
- package/src/model-document-preview.ts +46 -0
- package/src/model-opening-error.ts +12 -0
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
|
-
|
|
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 <
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
573
|
+
playing = false,
|
|
493
574
|
onPlayReady,
|
|
494
575
|
onPlayReturn,
|
|
495
|
-
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly
|
|
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
|
-
...(
|
|
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,
|
|
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 ||
|
|
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,
|
|
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 (!
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
!
|
|
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={
|
|
889
|
-
style={
|
|
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
|
-
{...(
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
73
|
-
"@volter/blender-engine": "0.1.
|
|
74
|
-
"@volter/editor-threejs": "0.5.
|
|
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.
|
|
79
|
-
"@volter/editor-project": "0.5.
|
|
80
|
-
"@volter/editor-sdk": "0.5.
|
|
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
|
+
}
|