@volter/editor-blender 0.1.96 → 0.1.98
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.
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from './blender-outliner-authoring';
|
|
61
61
|
import { blenderOutlinerVersion, subscribeBlenderOutliner } from './blender-outliner-model';
|
|
62
62
|
import { areaSplit, setAreaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
63
|
+
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
63
64
|
|
|
64
65
|
type MenuId = 'view' | 'select' | 'add' | 'object';
|
|
65
66
|
|
|
@@ -273,6 +274,9 @@ export function BlenderObjectModeHeader({
|
|
|
273
274
|
() => (documentId === undefined ? false : areaSplit(documentId)),
|
|
274
275
|
() => false,
|
|
275
276
|
);
|
|
277
|
+
const playExtension = useSyncExternalStore(subscribeDocumentPlayExtensions, () => documentPlayExtension('model'), () => null);
|
|
278
|
+
const PlayControl = playExtension?.Control;
|
|
279
|
+
const PlayMenu = playExtension?.Menu;
|
|
276
280
|
const handle = blenderOutlinerHandle(documentId);
|
|
277
281
|
// NOTHING RATHER THAN A DEAD BAR: with no Outliner published for this
|
|
278
282
|
// document there is no subject for any of these rows, and a menu of rows
|
|
@@ -381,6 +385,7 @@ export function BlenderObjectModeHeader({
|
|
|
381
385
|
Close Area
|
|
382
386
|
</MenuItem>
|
|
383
387
|
</MenuSubmenu>
|
|
388
|
+
{PlayMenu && <><MenuSeparator /><PlayMenu documentId={documentId} onClose={() => setOpen(null)} /></>}
|
|
384
389
|
</MenuWord>
|
|
385
390
|
{/* SELECT — `VIEW3D_MT_select_object`, `space_view3d.py:1713-1715`. The
|
|
386
391
|
three rows are `object.select_all` with action SELECT / DESELECT /
|
|
@@ -511,6 +516,7 @@ export function BlenderObjectModeHeader({
|
|
|
511
516
|
Delete
|
|
512
517
|
</MenuItem>
|
|
513
518
|
</MenuWord>
|
|
519
|
+
{PlayControl && <PlayControl documentId={documentId} onClose={() => setOpen(null)} />}
|
|
514
520
|
</EditorToolbar>
|
|
515
521
|
);
|
|
516
522
|
}
|
|
@@ -1862,7 +1862,14 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1862
1862
|
},
|
|
1863
1863
|
};
|
|
1864
1864
|
liveOutliners.add(handle);
|
|
1865
|
-
|
|
1865
|
+
// A factory is called during the stage's React render. Notify the other roots
|
|
1866
|
+
// after that render finishes; synchronously publishing here re-enters a commit.
|
|
1867
|
+
let disposed = false;
|
|
1868
|
+
const announce = setTimeout(() => {
|
|
1869
|
+
if (disposed) return;
|
|
1870
|
+
liveOutlinersChanged();
|
|
1871
|
+
syncFromEngine();
|
|
1872
|
+
}, 0);
|
|
1866
1873
|
|
|
1867
1874
|
// THE ENGINE'S SELECTION ARRIVES ON ITS OWN, and this is what listens for
|
|
1868
1875
|
// it: a frame (every present carries `selected`/`active`) and a tree read
|
|
@@ -1874,11 +1881,12 @@ export function blenderOutlinerAuthoringFor(
|
|
|
1874
1881
|
// follow the engine whether or not a panel is currently mounted over it.
|
|
1875
1882
|
const stopEngineFrames = onBlenderFrame(syncFromEngine);
|
|
1876
1883
|
const stopEngineTree = subscribeBlenderOutliner(syncFromEngine);
|
|
1877
|
-
syncFromEngine();
|
|
1878
1884
|
|
|
1879
1885
|
return {
|
|
1880
1886
|
adapter,
|
|
1881
1887
|
dispose: () => {
|
|
1888
|
+
disposed = true;
|
|
1889
|
+
clearTimeout(announce);
|
|
1882
1890
|
stopEngineFrames();
|
|
1883
1891
|
stopEngineTree();
|
|
1884
1892
|
liveOutliners.delete(handle);
|
|
@@ -59,6 +59,9 @@ import * as THREE from 'three';
|
|
|
59
59
|
import {
|
|
60
60
|
bindModelDocument,
|
|
61
61
|
blenderExecute,
|
|
62
|
+
blenderRna,
|
|
63
|
+
blenderRnaContext,
|
|
64
|
+
subscribeBlenderRna,
|
|
62
65
|
blenderViewShading,
|
|
63
66
|
openModelDocumentBlend,
|
|
64
67
|
modelDocumentMayOpen,
|
|
@@ -67,6 +70,8 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
|
|
|
67
70
|
import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
|
|
68
71
|
import { blenderSkin } from './blender-runtime-skin';
|
|
69
72
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
73
|
+
import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
|
|
74
|
+
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
|
|
70
75
|
import {
|
|
71
76
|
documentSecondAreaHeader,
|
|
72
77
|
setDocumentSecondArea,
|
|
@@ -80,6 +85,7 @@ import {
|
|
|
80
85
|
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
81
86
|
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
82
87
|
import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-store-registry';
|
|
88
|
+
import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
|
|
83
89
|
|
|
84
90
|
/** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
|
|
85
91
|
const overlaysOpened = new WeakSet<object>();
|
|
@@ -281,9 +287,47 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
281
287
|
* Blender's standard always-on render preview is set up: Rendered shading, through the scene
|
|
282
288
|
* camera. Each area has its own stage, shading, navigation and camera view; the model, its
|
|
283
289
|
* selection and its history are the document's.
|
|
290
|
+
*
|
|
291
|
+
* PLAYING (the composed Play tool), the area shows a DETACHED copy of the model
|
|
292
|
+
* (`BlenderRuntimeView.detach`) on a stage of its own, which the project's play script moves
|
|
293
|
+
* (the document Play extension). The model's own stage stays mounted underneath, hidden, so Stop
|
|
294
|
+
* returns to the same view of the same model: nothing a game does reaches Blender.
|
|
284
295
|
*/
|
|
285
296
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
286
297
|
const { documentId } = props;
|
|
298
|
+
// Rendered areas and Play use the scene's display transform, just as a
|
|
299
|
+
// photograph does. Solid keeps its studio transform. RNA also announces
|
|
300
|
+
// colour settings that change without changing the presented geometry.
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!documentId) return;
|
|
303
|
+
let cancelled = false;
|
|
304
|
+
let revision = 0;
|
|
305
|
+
let applied: { mapper: string; exposure: number } | null = null;
|
|
306
|
+
const read = async () => {
|
|
307
|
+
const mine = ++revision;
|
|
308
|
+
const context = await blenderRnaContext();
|
|
309
|
+
if (!context) return;
|
|
310
|
+
const settings = await blenderRna(`${context.scene}.view_settings`);
|
|
311
|
+
if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
|
|
312
|
+
const rows = settings.groups.flatMap(group => group.rows);
|
|
313
|
+
const transform = rows.find(row => row.identifier === 'view_transform')?.value;
|
|
314
|
+
const stops = rows.find(row => row.identifier === 'exposure')?.value;
|
|
315
|
+
const mapper = transform === 'Standard' ? 'none' : transform === 'Filmic' ? 'filmic' : 'agx';
|
|
316
|
+
const exposure = typeof stops === 'number' ? 2 ** stops : 1;
|
|
317
|
+
if (applied?.mapper === mapper && applied.exposure === exposure) return;
|
|
318
|
+
applied = { mapper, exposure };
|
|
319
|
+
for (const id of [documentId, `${documentId}#area-2`, `${documentId}#play`]) {
|
|
320
|
+
setViewPresentation(id, { modes: {
|
|
321
|
+
rendered: { lighting: { tone: { mapper, exposure } } },
|
|
322
|
+
preview: { lighting: { tone: { mapper, exposure } } },
|
|
323
|
+
} });
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
|
|
327
|
+
const stopRna = subscribeBlenderRna(update);
|
|
328
|
+
update();
|
|
329
|
+
return () => { cancelled = true; stopRna(); };
|
|
330
|
+
}, [documentId]);
|
|
287
331
|
const split = useSyncExternalStore(
|
|
288
332
|
subscribeAreaSplit,
|
|
289
333
|
() => (documentId ? areaSplit(documentId) : false),
|
|
@@ -304,22 +348,67 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
304
348
|
second.dispose();
|
|
305
349
|
};
|
|
306
350
|
}, [split, documentId]);
|
|
351
|
+
const playing = useSyncExternalStore(
|
|
352
|
+
subscribeDocumentPlayExtensions,
|
|
353
|
+
() => (documentId ? documentPlayExtension('model')?.playing(documentId) ?? false : false),
|
|
354
|
+
() => false,
|
|
355
|
+
);
|
|
356
|
+
const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
|
|
357
|
+
const [playedReady, setPlayedReady] = useState(false);
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!playing || !documentId) return;
|
|
360
|
+
const playId = `${documentId}#play`;
|
|
361
|
+
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
362
|
+
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
363
|
+
const copy = view.detach();
|
|
364
|
+
setPlayedReady(false);
|
|
365
|
+
setPlayed(copy);
|
|
366
|
+
// The stage's mode is PLAY for as long as the copy stands, so the keys are the player's.
|
|
367
|
+
view.setPlaying(true);
|
|
368
|
+
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
369
|
+
return () => {
|
|
370
|
+
setPlayed(null);
|
|
371
|
+
copy.dispose();
|
|
372
|
+
view.setPlaying(false);
|
|
373
|
+
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
374
|
+
};
|
|
375
|
+
}, [playing, documentId]);
|
|
376
|
+
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!documentId) return;
|
|
379
|
+
return () => documentPlayExtension('model')?.setPlaying(documentId, false);
|
|
380
|
+
}, [documentId]);
|
|
381
|
+
const game = playing ? played : null;
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (!game || !documentId) return;
|
|
384
|
+
const modelViewport = documentViewport(documentId);
|
|
385
|
+
if (!modelViewport) return;
|
|
386
|
+
// registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
|
|
387
|
+
const capture = modelViewport.capture;
|
|
388
|
+
modelViewport.capture = (size) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
|
|
389
|
+
return () => {
|
|
390
|
+
modelViewport.capture = capture;
|
|
391
|
+
};
|
|
392
|
+
}, [game, documentId]);
|
|
307
393
|
if (!documentId) return null;
|
|
308
394
|
const second = split ? follower : null;
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// and the two stand 2 px apart, Blender's gap between areas. Unsplit, both wrappers are
|
|
312
|
-
// `contents` and the one stage is placed as it always was.
|
|
395
|
+
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
396
|
+
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
313
397
|
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
314
398
|
return (
|
|
315
|
-
<div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
316
|
-
<div style={second ? area : { display: 'contents' }}>
|
|
399
|
+
<div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
400
|
+
<div key="model" style={game ? { position: 'absolute', inset: 12, zIndex: 2, opacity: playedReady ? 0 : 1, pointerEvents: playedReady ? 'none' : 'auto', transition: 'opacity 160ms ease-out' } : second ? area : { display: 'contents' }}>
|
|
317
401
|
<BlenderViewportArea {...props} view={view} main />
|
|
318
402
|
</div>
|
|
403
|
+
{game && (
|
|
404
|
+
<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)} />
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
319
408
|
{second && (
|
|
320
|
-
<div style={area} data-testid="blender-second-area">
|
|
409
|
+
<div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
|
|
321
410
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
322
|
-
<SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
|
|
411
|
+
{!game && <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />}
|
|
323
412
|
</div>
|
|
324
413
|
)}
|
|
325
414
|
</div>
|
|
@@ -396,28 +485,38 @@ function BlenderViewportArea({
|
|
|
396
485
|
active,
|
|
397
486
|
document,
|
|
398
487
|
documentId,
|
|
488
|
+
notify,
|
|
399
489
|
surfaces,
|
|
400
490
|
view,
|
|
401
491
|
main,
|
|
402
|
-
|
|
492
|
+
play = false,
|
|
493
|
+
onPlayReady,
|
|
494
|
+
onPlayReturn,
|
|
495
|
+
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly play?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
|
|
403
496
|
const reads = readsOf(view);
|
|
404
497
|
const build = useCallback(
|
|
405
498
|
() => ({
|
|
406
499
|
root: view.root,
|
|
407
|
-
prepareDraw: (camera: THREE.Camera, options?: {interactive: boolean; height: number}) => view.prepareDraw(camera, options),
|
|
500
|
+
prepareDraw: (camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}) => view.prepareDraw(camera, options),
|
|
408
501
|
// What the view draws changes with each frame and the work after it,
|
|
409
|
-
// and with the skin's poses when the Timeline scrubs.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
502
|
+
// and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing:
|
|
503
|
+
// its script moves it every frame, and a source that announces nothing is drawn every
|
|
504
|
+
// frame (`ToolObject3DPreviewSource.onChange`).
|
|
505
|
+
...(play
|
|
506
|
+
? {}
|
|
507
|
+
: {
|
|
508
|
+
onChange(listener: () => void) {
|
|
509
|
+
const stopView = view.onChange(listener);
|
|
510
|
+
const stopSkin = blenderSkin.subscribe(listener);
|
|
511
|
+
return () => {
|
|
512
|
+
stopView();
|
|
513
|
+
stopSkin();
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
}),
|
|
418
517
|
dispose() {},
|
|
419
518
|
}),
|
|
420
|
-
[view],
|
|
519
|
+
[view, play],
|
|
421
520
|
);
|
|
422
521
|
const blend = document?.source?.path;
|
|
423
522
|
// Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
|
|
@@ -457,7 +556,8 @@ function BlenderViewportArea({
|
|
|
457
556
|
* person's.
|
|
458
557
|
*/
|
|
459
558
|
useEffect(() => {
|
|
460
|
-
|
|
559
|
+
// A playing copy's camera is its script's.
|
|
560
|
+
if (main || play || !documentId) return;
|
|
461
561
|
let entered = false;
|
|
462
562
|
const enter = (): void => {
|
|
463
563
|
if (entered) return;
|
|
@@ -472,7 +572,101 @@ function BlenderViewportArea({
|
|
|
472
572
|
stopSessions();
|
|
473
573
|
stopFrames();
|
|
474
574
|
};
|
|
475
|
-
}, [main, documentId, view]);
|
|
575
|
+
}, [main, play, documentId, view]);
|
|
576
|
+
/**
|
|
577
|
+
* THE PLAYING COPY IS MOVED BY THE PROJECT'S PLAY SCRIPT (the document Play extension), on this
|
|
578
|
+
* stage's own frame hook, once the stage stands. The stage's orbit stands down while it runs,
|
|
579
|
+
* so the script's camera is not argued with, and Escape stops the game.
|
|
580
|
+
*/
|
|
581
|
+
const notifyRef = useRef(notify);
|
|
582
|
+
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
583
|
+
const [playReady, setPlayReady] = useState(false);
|
|
584
|
+
notifyRef.current = notify;
|
|
585
|
+
const playReadyRef = useRef(onPlayReady);
|
|
586
|
+
playReadyRef.current = onPlayReady;
|
|
587
|
+
const playReturnRef = useRef(onPlayReturn);
|
|
588
|
+
playReturnRef.current = onPlayReturn;
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
if (!play || !documentId) return;
|
|
591
|
+
if (blend === undefined) {
|
|
592
|
+
notifyRef.current?.({
|
|
593
|
+
tone: 'error',
|
|
594
|
+
title: 'This model has no play script',
|
|
595
|
+
detail: 'A play script stands beside a model\'s .blend file, and this model has no file of its own.',
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const modelId = documentId.replace(/#play$/, '');
|
|
600
|
+
const layers = window.document.createElement('div');
|
|
601
|
+
Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
|
|
602
|
+
layers.dataset.testid = 'model-play-roots';
|
|
603
|
+
surfaceRef.current?.appendChild(layers);
|
|
604
|
+
setPlayReady(false);
|
|
605
|
+
let stopped = false;
|
|
606
|
+
let preparing = false;
|
|
607
|
+
let stopScript: (() => void) | null = null;
|
|
608
|
+
let orbit: { enabled: boolean } | null = null;
|
|
609
|
+
const start = (): void => {
|
|
610
|
+
if (stopScript || preparing || stopped) return;
|
|
611
|
+
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
612
|
+
if (!stage || !view.root.parent) return;
|
|
613
|
+
preparing = true;
|
|
614
|
+
orbit = stage.rig().orbit;
|
|
615
|
+
orbit.enabled = false;
|
|
616
|
+
void view.prepareRendered(stage.rig().drawCamera()).then(() => {
|
|
617
|
+
if (stopped) return;
|
|
618
|
+
stopScript = documentPlayExtension('model')?.run({
|
|
619
|
+
documentId: modelId,
|
|
620
|
+
container: layers,
|
|
621
|
+
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
622
|
+
returning: () => { if (!stopped) playReturnRef.current?.(); },
|
|
623
|
+
sourcePath: blend,
|
|
624
|
+
root: view.root,
|
|
625
|
+
camera: () => stage.rig().drawCamera(),
|
|
626
|
+
editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
|
|
627
|
+
onFrame: (fn) => stage.onFrame(fn),
|
|
628
|
+
report: (title, detail) => {
|
|
629
|
+
editorHost().console.error(`${title}: ${detail}`, 'blender-play');
|
|
630
|
+
notifyRef.current?.({ tone: 'error', title, detail });
|
|
631
|
+
},
|
|
632
|
+
}) ?? null;
|
|
633
|
+
}, error => {
|
|
634
|
+
if (stopped) return;
|
|
635
|
+
editorHost().console.error(`Rendered stage preparation failed: ${String(error)}`, 'model-play');
|
|
636
|
+
documentPlayExtension('model')?.setPlaying(modelId, false);
|
|
637
|
+
});
|
|
638
|
+
};
|
|
639
|
+
start();
|
|
640
|
+
let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
|
|
641
|
+
let stopPrepareFrames: (() => void) | undefined;
|
|
642
|
+
const waitForDraw = () => {
|
|
643
|
+
const stage = viewportStages().find(one => one.documentId === documentId);
|
|
644
|
+
if (stage === waitingStage) return;
|
|
645
|
+
stopPrepareFrames?.();
|
|
646
|
+
waitingStage = stage;
|
|
647
|
+
stopPrepareFrames = stage?.onFrame(start);
|
|
648
|
+
start();
|
|
649
|
+
};
|
|
650
|
+
waitForDraw();
|
|
651
|
+
const stopStages = onViewportStages(waitForDraw);
|
|
652
|
+
const onEscape = (event: KeyboardEvent): void => {
|
|
653
|
+
if (event.key === 'Escape' && surfaceAcceptsKey(event)) {
|
|
654
|
+
const extension = documentPlayExtension('model');
|
|
655
|
+
if (extension?.escape) extension.escape(modelId);
|
|
656
|
+
else extension?.setPlaying(modelId, false);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
window.addEventListener('keydown', onEscape, true);
|
|
660
|
+
return () => {
|
|
661
|
+
stopped = true;
|
|
662
|
+
stopStages();
|
|
663
|
+
stopPrepareFrames?.();
|
|
664
|
+
window.removeEventListener('keydown', onEscape, true);
|
|
665
|
+
stopScript?.();
|
|
666
|
+
layers.remove();
|
|
667
|
+
if (orbit) orbit.enabled = true;
|
|
668
|
+
};
|
|
669
|
+
}, [play, documentId, blend, view]);
|
|
476
670
|
/**
|
|
477
671
|
* A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
|
|
478
672
|
* on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
|
|
@@ -610,8 +804,11 @@ function BlenderViewportArea({
|
|
|
610
804
|
stopFrame = stage ? stage.onFrame(() => view.refreshRendered()) : null;
|
|
611
805
|
framed = stage;
|
|
612
806
|
}
|
|
613
|
-
const { lighting } = viewPresentation(documentId);
|
|
614
|
-
|
|
807
|
+
const { lighting, drawMode } = viewPresentation(documentId);
|
|
808
|
+
const preview = drawMode === 'preview';
|
|
809
|
+
const sceneLighting = preview ? { world: lighting.source === 'scene', lights: lighting.preview.sceneLights } : undefined;
|
|
810
|
+
const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
|
|
811
|
+
view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, preview ? 'viewport' : 'render');
|
|
615
812
|
// BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
|
|
616
813
|
// presenter draws every surface by it (`blender-workbench-material.ts`).
|
|
617
814
|
view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
|
|
@@ -639,7 +836,7 @@ function BlenderViewportArea({
|
|
|
639
836
|
*/
|
|
640
837
|
const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
|
|
641
838
|
const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
|
|
642
|
-
event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
|
|
839
|
+
!play && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
|
|
643
840
|
const onPointerDownCapture = (event: ReactPointerEvent): void => {
|
|
644
841
|
if (!cursorChord(event)) {
|
|
645
842
|
cursorPress.current = null;
|
|
@@ -687,7 +884,9 @@ function BlenderViewportArea({
|
|
|
687
884
|
const Surface = surfaces.Object3DAuthoring;
|
|
688
885
|
return (
|
|
689
886
|
<div
|
|
690
|
-
|
|
887
|
+
ref={surfaceRef}
|
|
888
|
+
data-play-ready={play ? playReady : undefined}
|
|
889
|
+
style={play ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
|
|
691
890
|
onPointerDownCapture={onPointerDownCapture}
|
|
692
891
|
onPointerUpCapture={onPointerUpCapture}
|
|
693
892
|
onPointerCancelCapture={dropCursorPress}
|
|
@@ -714,7 +913,9 @@ function BlenderViewportArea({
|
|
|
714
913
|
// provider; the default adapter it delegates to still answers everything
|
|
715
914
|
// about the presentation (WORK.md §Blender in the tab is Blender,
|
|
716
915
|
// "Inspection parity", I3).
|
|
717
|
-
|
|
916
|
+
// A PLAYING COPY HAS NO AUTHORING: it is read-only to the editor, and what moves it is its
|
|
917
|
+
// script. With none given the stage is the native read-only one.
|
|
918
|
+
{...(play ? {} : { authoring })}
|
|
718
919
|
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
719
920
|
// FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
|
|
720
921
|
// three fits.
|
|
@@ -730,7 +931,13 @@ function BlenderViewportArea({
|
|
|
730
931
|
const saved = view.savedView();
|
|
731
932
|
if (!saved) return null;
|
|
732
933
|
const camera = { fov: blenderViewFieldOfView(saved.lens) };
|
|
733
|
-
return main && saved.drawMode ? {
|
|
934
|
+
return main && saved.drawMode ? {
|
|
935
|
+
camera, drawMode: saved.drawMode,
|
|
936
|
+
modes: { preview: {
|
|
937
|
+
lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
|
|
938
|
+
backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
|
|
939
|
+
} },
|
|
940
|
+
} : { camera };
|
|
734
941
|
})()}
|
|
735
942
|
// Every entry this document opens is a `model` stage, the standing `blender:runtime`
|
|
736
943
|
// address included (its id carries no `model:` prefix).
|
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.98",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
73
|
-
"@volter/blender-engine": "0.1.
|
|
74
|
-
"@volter/editor-threejs": "0.5.
|
|
73
|
+
"@volter/blender-engine": "0.1.96",
|
|
74
|
+
"@volter/editor-threejs": "0.5.158",
|
|
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.158",
|
|
79
|
+
"@volter/editor-project": "0.5.158",
|
|
80
|
+
"@volter/editor-sdk": "0.5.158",
|
|
81
81
|
"react": "^19.0.0",
|
|
82
82
|
"react-dom": "^19.0.0",
|
|
83
83
|
"three": "^0.180.0",
|