@volter/editor-blender 0.1.151 → 0.1.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contributions/blender-model-opening.tsx +25 -51
- package/contributions/blender-node-editor.document.tsx +2 -1
- package/contributions/blender-play-area.tsx +3 -4
- package/contributions/blender-properties-model.ts +44 -1
- package/contributions/blender-properties-view.tsx +24 -34
- package/contributions/blender-runtime.document.tsx +59 -385
- package/contributions/blender-timeline.document.tsx +6 -4
- package/contributions/blender-version.status.tsx +2 -8
- package/contributions/blender.page.ts +12 -11
- package/contributions/blender.status.tsx +1 -7
- package/contributions/models.finder.ts +4 -11
- package/dist-node/serving.mjs +0 -2
- package/host/blender-runtime-host.ts +290 -76
- package/node/blender-mcp.ts +3 -2
- package/package.json +56 -44
- package/serving/blender-routes.ts +0 -5
- package/src/api.ts +2 -2
- package/src/play-mode.ts +10 -65
- package/src/play-tool.ts +44 -0
- package/src/model-play-extension.ts +0 -173
|
@@ -31,13 +31,14 @@ import {
|
|
|
31
31
|
useLayoutEffect,
|
|
32
32
|
useMemo,
|
|
33
33
|
useRef,
|
|
34
|
-
type RefObject,
|
|
35
34
|
useState,
|
|
36
35
|
useSyncExternalStore,
|
|
37
36
|
} from 'react';
|
|
38
37
|
import * as THREE from 'three';
|
|
39
38
|
import {
|
|
40
39
|
bindModelDocument,
|
|
40
|
+
blenderStarting,
|
|
41
|
+
subscribeBlenderStarting,
|
|
41
42
|
blenderExecute,
|
|
42
43
|
subscribeBlenderRna,
|
|
43
44
|
blenderViewShading,
|
|
@@ -47,7 +48,6 @@ import {
|
|
|
47
48
|
blenderActionClip,
|
|
48
49
|
blenderSceneMovie,
|
|
49
50
|
blenderRnaVersion,
|
|
50
|
-
blenderPresentDocument,
|
|
51
51
|
modelDocumentDirty,
|
|
52
52
|
restoreModelDocument,
|
|
53
53
|
revertModelDocument,
|
|
@@ -60,9 +60,7 @@ import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './b
|
|
|
60
60
|
import { blenderSkin } from './blender-runtime-skin';
|
|
61
61
|
|
|
62
62
|
import { areaSplit, subscribeAreaSplit } from '../src/area-split';
|
|
63
|
-
import { noteModelDocument
|
|
64
|
-
import { type DocumentPlayEditArea, modelPlayExtension, subscribeModelPlayExtension } from '../src/model-play-extension';
|
|
65
|
-
import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/workspace-document-registry';
|
|
63
|
+
import { noteModelDocument } from '../src/play-mode';
|
|
66
64
|
import {
|
|
67
65
|
documentSecondAreaHeader,
|
|
68
66
|
setDocumentSecondArea,
|
|
@@ -77,18 +75,15 @@ import {
|
|
|
77
75
|
} from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
|
|
78
76
|
import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
|
|
79
77
|
import { stageStore, subscribeStageStores } from '@volter/sdk/stage-store-registry';
|
|
80
|
-
import { surfaceAcceptsKey } from '@volter/sdk/surface-keyboard';
|
|
81
78
|
import { BlenderModelOpening } from './blender-model-opening';
|
|
82
79
|
import { modelOpeningErrorMessage } from '../src/model-opening-error';
|
|
83
|
-
import {
|
|
84
|
-
import {
|
|
80
|
+
import { Button, EditorBanner } from '@volter/sdk/widgets';
|
|
81
|
+
import { useProgress } from '@volter/sdk/progress';
|
|
85
82
|
import { modelDocumentSaveNotice, subscribeModelDocumentSaving } from '../src/model-document-saving';
|
|
86
83
|
import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/startup-failure';
|
|
87
84
|
|
|
88
85
|
/** This document's model open, as a source in the page's startup-failure registry. */
|
|
89
86
|
const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
|
|
90
|
-
/** How long "Opening model…" stands before the pane also says what it is waiting on. */
|
|
91
|
-
const MODEL_OPEN_NOTE_AFTER_MS = 30_000;
|
|
92
87
|
import {
|
|
93
88
|
clearModelDocumentPreview,
|
|
94
89
|
modelDocumentPreview,
|
|
@@ -97,15 +92,6 @@ import {
|
|
|
97
92
|
type ModelDocumentPreview,
|
|
98
93
|
} from '../src/model-document-preview';
|
|
99
94
|
|
|
100
|
-
/** A detached copy's identity as a React key (the play area is keyed on the copy it draws). */
|
|
101
|
-
const playCopyKeys = new WeakMap<object, number>();
|
|
102
|
-
let nextPlayCopyKey = 0;
|
|
103
|
-
function playCopyKey(copy: object): number {
|
|
104
|
-
let key = playCopyKeys.get(copy);
|
|
105
|
-
if (key === undefined) { key = ++nextPlayCopyKey; playCopyKeys.set(copy, key); }
|
|
106
|
-
return key;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
95
|
/** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
|
|
110
96
|
const overlaysOpened = new WeakSet<object>();
|
|
111
97
|
|
|
@@ -234,9 +220,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
234
220
|
const entryId = document?.id;
|
|
235
221
|
const key = JSON.stringify([documentId, entryId, blend]);
|
|
236
222
|
const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
|
|
237
|
-
/** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
|
|
238
|
-
const [stillOpening, setStillOpening] = useState<string | null>(null);
|
|
239
223
|
const [attempt, setAttempt] = useState(0);
|
|
224
|
+
/** Open the model again, as often as it is asked: an error's Retry, the open's wait's Retry, or a reopen. */
|
|
225
|
+
const retry = useCallback(() => { setOpened(null); setAttempt(value => value + 1); }, []);
|
|
226
|
+
const blenderStart = useSyncExternalStore(subscribeBlenderStarting, blenderStarting, () => false);
|
|
240
227
|
const saveNotice = useSyncExternalStore(subscribeModelDocumentSaving, () => modelDocumentSaveNotice(blend), () => null);
|
|
241
228
|
const project = editorHost().projectLocalState.projectRootPath();
|
|
242
229
|
const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
|
|
@@ -246,10 +233,6 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
246
233
|
callbacks.current = { notify, publishContext };
|
|
247
234
|
|
|
248
235
|
useEffect(() => editorHost().session.onEnded(clearModelDocumentPreview), []);
|
|
249
|
-
// WHILE ITS FILE OPENS, the Game panel's verbs wait for this document rather than answer that no model is open
|
|
250
|
-
// (`../src/play-mode.ts`, `noteModelOpening`); once presented, its viewport serves the panel (`noteModelDocument`).
|
|
251
|
-
const presented = opened?.key === key;
|
|
252
|
-
useEffect(() => (active === false || !documentId || presented ? undefined : noteModelOpening(documentId)), [active, documentId, presented]);
|
|
253
236
|
// Retain a bounded photograph while the stage is healthy. Teardown can
|
|
254
237
|
// already have changed its scene/camera, so it is too late to re-render then.
|
|
255
238
|
useEffect(() => {
|
|
@@ -274,7 +257,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
274
257
|
const context = copy.getContext('2d');
|
|
275
258
|
if (context) {
|
|
276
259
|
context.drawImage(frame, 0, 0, copy.width, copy.height);
|
|
277
|
-
// Encode the PNG with toBlob off the main thread to keep
|
|
260
|
+
// Encode the PNG with toBlob off the main thread to keep frames responsive.
|
|
278
261
|
const image = await pngDataUrl(copy);
|
|
279
262
|
if (cancelled || !image) return;
|
|
280
263
|
lastFrame.current = { project, entryId, path: blend ?? 'Model', image, capturedAt: performance.now() };
|
|
@@ -309,25 +292,22 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
309
292
|
const unbind = bindModelDocument(binding);
|
|
310
293
|
const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
|
|
311
294
|
setOpened(null);
|
|
312
|
-
setStillOpening(null);
|
|
313
295
|
// Native focus can arrive after the contributed pane mounts. A declined
|
|
314
296
|
// open is not an in-flight load: retry when the host activates this model,
|
|
315
|
-
// rather than leaving
|
|
297
|
+
// rather than leaving the open's wait latched forever. Utility focus
|
|
316
298
|
// after a successful open must not tear down the model's published view.
|
|
317
299
|
const documents = editorHost().documents;
|
|
318
300
|
let starting = false;
|
|
319
301
|
let finished = false;
|
|
320
|
-
let declined = false;
|
|
321
302
|
const path = blend ?? 'the model';
|
|
322
303
|
const open = async () => {
|
|
323
304
|
if (cancelled || starting || finished) return;
|
|
324
|
-
if (binding && !modelDocumentMayOpen(binding))
|
|
305
|
+
if (binding && !modelDocumentMayOpen(binding)) return;
|
|
325
306
|
starting = true;
|
|
326
|
-
declined = false;
|
|
327
307
|
try {
|
|
328
308
|
if (binding) {
|
|
329
309
|
const reload = attempt > 0 && modelDocumentSaveNotice(binding.blend) !== null;
|
|
330
|
-
if (!await openModelDocumentBlend(binding, publish, reload))
|
|
310
|
+
if (!await openModelDocumentBlend(binding, publish, reload)) return;
|
|
331
311
|
} else {
|
|
332
312
|
// The standing Model document is the explicit blender-start target.
|
|
333
313
|
publish();
|
|
@@ -358,13 +338,6 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
358
338
|
starting = false;
|
|
359
339
|
}
|
|
360
340
|
};
|
|
361
|
-
// A long open reports progress while its request runs. Retry is offered only for an actual failure, avoiding a second queued open.
|
|
362
|
-
const slow = setInterval(() => {
|
|
363
|
-
if (cancelled || finished) return;
|
|
364
|
-
setStillOpening(declined
|
|
365
|
-
? `Still waiting: Blender is holding another model, and opens ${path} when it is free.`
|
|
366
|
-
: `Still opening ${path}: Blender is working (a large file, or a cold start). ${commandLine('status')} shows what it is busy with.`);
|
|
367
|
-
}, MODEL_OPEN_NOTE_AFTER_MS);
|
|
368
341
|
// Opening publishes context to other React roots. Start outside this
|
|
369
342
|
// effect (or a host activation listener's synchronous notification stack)
|
|
370
343
|
// so those roots cannot synchronously commit through a pending commit here.
|
|
@@ -378,18 +351,28 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
378
351
|
requestOpen();
|
|
379
352
|
return () => {
|
|
380
353
|
cancelled = true;
|
|
381
|
-
clearInterval(slow);
|
|
382
354
|
unsubscribe();
|
|
383
355
|
unpublish?.();
|
|
384
356
|
unbind();
|
|
385
357
|
};
|
|
386
358
|
}, [active, blend, documentId, entryId, key, attempt]);
|
|
387
359
|
|
|
360
|
+
// THE OPEN'S ONE WAIT, from asking for the file until its viewport is drawn: Blender's start
|
|
361
|
+
// first when it is still under way (the first open, or one after a reload or a failure), then
|
|
362
|
+
// the file's own load, then its view. Retry opens it again, as often as it is chosen.
|
|
363
|
+
const failed = opened?.key === key && opened.error !== null;
|
|
364
|
+
const preparing = opened?.key === key && !failed && !!documentId && !documentViewport(documentId);
|
|
365
|
+
useProgress(active !== false && !!documentId && !failed && (opened?.key !== key || preparing) ? {
|
|
366
|
+
documentId,
|
|
367
|
+
title: `Opening ${blend ?? 'the model'}`,
|
|
368
|
+
message: preparing ? 'Preparing its view' : blenderStart ? 'Starting Blender' : undefined,
|
|
369
|
+
actions: [{ label: 'Retry opening', run: retry }],
|
|
370
|
+
} : null);
|
|
371
|
+
|
|
388
372
|
if (active === false || !documentId) return null;
|
|
389
373
|
if (opened?.key !== key || opened.error) return <BlenderModelOpening
|
|
390
374
|
documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
|
|
391
|
-
|
|
392
|
-
retry={() => { setOpened(null); setAttempt(value => value + 1); }}
|
|
375
|
+
retry={retry}
|
|
393
376
|
returnToPreview={() => {
|
|
394
377
|
if (!preview) return;
|
|
395
378
|
void editorHost().workspace.open({ kind: 'document', id: preview.entryId }).then(opened => {
|
|
@@ -399,19 +382,13 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
399
382
|
/>;
|
|
400
383
|
return <>
|
|
401
384
|
<BlenderModelViewport {...props} />
|
|
402
|
-
{saveNotice && <
|
|
403
|
-
style={{ position: 'absolute', top:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
<Button variant="secondary" onClick={() => { setOpened(null); setAttempt(value => value + 1); }}>
|
|
410
|
-
Reopen saved document
|
|
411
|
-
</Button>
|
|
412
|
-
</div>}
|
|
413
|
-
{!documentViewport(documentId) && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
|
|
414
|
-
preparingView preview={preview} retry={() => {}} returnToPreview={() => {}} />}
|
|
385
|
+
{saveNotice && <EditorBanner tone="warning" data-testid="blender-model-not-saving"
|
|
386
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2 }}
|
|
387
|
+
actions={<Button variant="secondary" onClick={retry}>Reopen saved document</Button>}>
|
|
388
|
+
<strong>Model is not being saved.</strong> <span style={{ userSelect: 'text' }}>{saveNotice}</span> Reopening replaces this scene with the last saved file.
|
|
389
|
+
</EditorBanner>}
|
|
390
|
+
{preparing && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
|
|
391
|
+
preparingView preview={preview} retry={retry} returnToPreview={() => {}} />}
|
|
415
392
|
</>;
|
|
416
393
|
}
|
|
417
394
|
|
|
@@ -422,26 +399,21 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
|
|
|
422
399
|
* Blender's standard always-on render preview is set up: Rendered shading, through the scene
|
|
423
400
|
* camera. Each area has its own stage, shading, navigation and camera view; the model, its
|
|
424
401
|
* selection and its history are the document's.
|
|
425
|
-
*
|
|
426
|
-
* PLAYING (the composed Play tool), the area shows a DETACHED copy of the model's World
|
|
427
|
-
* (`BlenderRuntimeView.detach`) on a stage of its own, where the level's game draws (the document Play extension).
|
|
428
|
-
* The model's own stage stays mounted underneath, hidden, so Stop returns to the same view of the same model:
|
|
429
|
-
* nothing a game does reaches Blender.
|
|
430
402
|
*/
|
|
431
403
|
function BlenderModelViewport(props: ToolContributionProps) {
|
|
432
404
|
const { documentId } = props;
|
|
433
405
|
const blend = props.document?.source?.path;
|
|
434
|
-
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
|
|
435
|
-
//
|
|
406
|
+
// THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`): Game or Animation,
|
|
407
|
+
// by whether the project has a game.
|
|
436
408
|
useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
|
|
437
|
-
// Rendered areas
|
|
409
|
+
// Rendered areas use the display transform carried by the presented frame. Solid uses its studio transform. Document preparation waits for that frame’s transform before photographs.
|
|
438
410
|
useEffect(() => {
|
|
439
411
|
if (!documentId) return;
|
|
440
412
|
let cancelled = false;
|
|
441
413
|
let applied: BlenderDisplayTransform | null = null;
|
|
442
414
|
let wanted: string | null = null;
|
|
443
415
|
let settled: Promise<void> = Promise.resolve();
|
|
444
|
-
const ids = [documentId, `${documentId}#area-2
|
|
416
|
+
const ids = [documentId, `${documentId}#area-2`];
|
|
445
417
|
const attach = () => {
|
|
446
418
|
for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied);
|
|
447
419
|
};
|
|
@@ -512,209 +484,18 @@ function BlenderModelViewport(props: ToolContributionProps) {
|
|
|
512
484
|
second.dispose();
|
|
513
485
|
};
|
|
514
486
|
}, [split, documentId]);
|
|
515
|
-
const playing = useSyncExternalStore(
|
|
516
|
-
subscribeModelPlayExtension,
|
|
517
|
-
() => (documentId ? modelPlayExtension()?.playing(documentId) ?? false : false),
|
|
518
|
-
() => false,
|
|
519
|
-
);
|
|
520
|
-
const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
|
|
521
|
-
const playAspect = useSyncExternalStore(
|
|
522
|
-
subscribeModelPlayExtension,
|
|
523
|
-
() => (documentId ? modelPlayExtension()?.aspectRatio?.(documentId) ?? null : null),
|
|
524
|
-
() => null,
|
|
525
|
-
);
|
|
526
|
-
const [playedReady, setPlayedReady] = useState(false);
|
|
527
|
-
/** THE PLAY OVERLAY: a layer over the editing area and the play area alike, where the Play tool puts its view
|
|
528
|
-
* controls (`DocumentPlayStage.overlay`), so they stand whichever area is in front. It takes no press itself;
|
|
529
|
-
* the controls in it take their own. */
|
|
530
|
-
const playOverlay = useRef<HTMLDivElement>(null);
|
|
531
|
-
/** WHERE THE MODEL'S OWN AREA STANDS WHILE THE GAME RUNS (`DocumentPlayStage.edit`): hidden behind the game, live in
|
|
532
|
-
* front of it, or drawing in a box of the stage (a tile), taking no press; null while the tool has placed it
|
|
533
|
-
* nowhere, and again when the game ends. A tool that places it switches it at once; otherwise it fades as the game
|
|
534
|
-
* draws its first frame. */
|
|
535
|
-
const [editArea, setEditArea] = useState<DocumentPlayEditArea | null>(null);
|
|
536
|
-
/**
|
|
537
|
-
* RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
|
|
538
|
-
* below is detached per generation, so a restart disposes the played copy and detaches a fresh
|
|
539
|
-
* one from the model, which never moved, while playing stays true throughout.
|
|
540
|
-
*
|
|
541
|
-
* THE OLD GAME STAYS ON SCREEN UNTIL THE NEW ONE HAS DRAWN. Between the two the new copy's
|
|
542
|
-
* stage is still preparing its rendered draw, and the model area is what lies under the play
|
|
543
|
-
* area — so a restart would flash the editing model between two games. The generation is
|
|
544
|
-
* therefore taken in two steps: a layout effect photographs the old play stage while it still
|
|
545
|
-
* stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
|
|
546
|
-
* detaches the new copy. The photograph covers the play area until the new runner's first
|
|
547
|
-
* frame (`onPlayReady`), and the model area stays hidden throughout.
|
|
548
|
-
*/
|
|
549
|
-
const generation = useSyncExternalStore(
|
|
550
|
-
subscribeModelPlayExtension,
|
|
551
|
-
() => (documentId ? modelPlayExtension()?.transport?.generation(documentId) ?? 0 : 0),
|
|
552
|
-
() => 0,
|
|
553
|
-
);
|
|
554
|
-
const [shownGeneration, setShownGeneration] = useState(generation);
|
|
555
|
-
const [restartStill, setRestartStill] = useState<string | null>(null);
|
|
556
|
-
const restarting = useRef(false);
|
|
557
|
-
// A restarted game that has not drawn yet: its stage holds no game to photograph, so a second
|
|
558
|
-
// Restart in that window keeps the cover it has (or none) rather than photographing a blank.
|
|
559
|
-
const awaitingDraw = useRef(false);
|
|
560
|
-
useLayoutEffect(() => {
|
|
561
|
-
if (generation === shownGeneration) return;
|
|
562
|
-
if (playing && documentId) {
|
|
563
|
-
restarting.current = true;
|
|
564
|
-
if (!awaitingDraw.current) {
|
|
565
|
-
let still: string | null = null;
|
|
566
|
-
try { still = documentViewport(`${documentId}#play`)?.capture?.() ?? null; }
|
|
567
|
-
catch { /* No photograph: the new stage shows as it prepares, still never the model. */ }
|
|
568
|
-
setRestartStill(still);
|
|
569
|
-
}
|
|
570
|
-
awaitingDraw.current = true;
|
|
571
|
-
}
|
|
572
|
-
setShownGeneration(generation);
|
|
573
|
-
}, [generation, shownGeneration, playing, documentId]);
|
|
574
|
-
/**
|
|
575
|
-
* A RESTARTED GAME THAT FAILS never draws (its script did not start, or threw on its first
|
|
576
|
-
* update), so the cover would stand over it while the panel said Playing. The runner says so
|
|
577
|
-
* on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
|
|
578
|
-
*/
|
|
579
|
-
const transport = modelPlayExtension()?.transport;
|
|
580
|
-
const subscribeFailure = useCallback(
|
|
581
|
-
(listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
|
|
582
|
-
[transport],
|
|
583
|
-
);
|
|
584
|
-
const failure = useSyncExternalStore(
|
|
585
|
-
subscribeFailure,
|
|
586
|
-
() => (documentId && transport ? transport.clock(documentId).failure ?? null : null),
|
|
587
|
-
() => null,
|
|
588
|
-
);
|
|
589
|
-
useEffect(() => {
|
|
590
|
-
if (failure === null) return;
|
|
591
|
-
awaitingDraw.current = false;
|
|
592
|
-
setRestartStill(null);
|
|
593
|
-
}, [failure]);
|
|
594
|
-
/**
|
|
595
|
-
* COPIES A RESTART RETIRED, disposed only once the play area that drew them has unmounted:
|
|
596
|
-
* the commit that renders the new copy removes the old area first, and this runs after it.
|
|
597
|
-
* Disposed earlier, the old stage could draw a disposed copy for a frame.
|
|
598
|
-
*/
|
|
599
|
-
const retired = useRef<ReturnType<AreaView['detach']>[]>([]);
|
|
600
|
-
useEffect(() => {
|
|
601
|
-
if (!playing || !documentId) { restarting.current = false; return; }
|
|
602
|
-
const playId = `${documentId}#play`;
|
|
603
|
-
// A game is seen as the render is: Rendered shading, chosen before the stage binds.
|
|
604
|
-
setViewPresentation(playId, { drawMode: 'rendered' });
|
|
605
|
-
// The game brings its own objects: the copy is the World alone.
|
|
606
|
-
const copy = view.detach();
|
|
607
|
-
const restart = restarting.current;
|
|
608
|
-
restarting.current = false;
|
|
609
|
-
// A restart keeps the model area hidden: the old game's photograph stands in until ready.
|
|
610
|
-
if (!restart) setPlayedReady(false);
|
|
611
|
-
setPlayed(copy);
|
|
612
|
-
if (!restart) {
|
|
613
|
-
// The stage's mode is PLAY for as long as a copy stands, so the keys are the player's.
|
|
614
|
-
view.setPlaying(true);
|
|
615
|
-
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
616
|
-
}
|
|
617
|
-
return () => {
|
|
618
|
-
// Read at cleanup: the layout effect above has already marked a restart, whose new copy
|
|
619
|
-
// takes over in this same commit with the play mode unchanged.
|
|
620
|
-
if (restarting.current && modelPlayExtension()?.playing(documentId)) {
|
|
621
|
-
retired.current.push(copy);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
copy.dispose();
|
|
625
|
-
for (const old of retired.current.splice(0)) old.dispose();
|
|
626
|
-
awaitingDraw.current = false;
|
|
627
|
-
setEditArea(null);
|
|
628
|
-
setPlayed(null);
|
|
629
|
-
setRestartStill(null);
|
|
630
|
-
view.setPlaying(false);
|
|
631
|
-
notifyWorkspaceDocumentSelectionChanged(documentId);
|
|
632
|
-
// Present the document after Play ends. Preserve action revisions so the next edit does not rebake unchanged clips.
|
|
633
|
-
void blenderPresentDocument().catch(() => {});
|
|
634
|
-
};
|
|
635
|
-
}, [playing, documentId, shownGeneration]);
|
|
636
|
-
useEffect(() => {
|
|
637
|
-
for (const old of retired.current.filter(one => one !== played)) old.dispose();
|
|
638
|
-
retired.current = retired.current.filter(one => one === played);
|
|
639
|
-
}, [played]);
|
|
640
|
-
// And whatever is still retired when the document itself goes (declared after the copy's own
|
|
641
|
-
// effect, so its cleanup has already handed over anything mid-restart).
|
|
642
|
-
useEffect(() => () => { for (const old of retired.current.splice(0)) old.dispose(); }, []);
|
|
643
|
-
// The document closing or going inactive ends its game; a game never outlives its stage.
|
|
644
|
-
useEffect(() => {
|
|
645
|
-
if (!documentId) return;
|
|
646
|
-
return () => modelPlayExtension()?.setPlaying(documentId, false);
|
|
647
|
-
}, [documentId]);
|
|
648
|
-
const game = playing ? played : null;
|
|
649
|
-
const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
|
|
650
|
-
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
|
|
651
|
-
width: `min(100cqw, calc(100cqh * ${playAspect}))`,
|
|
652
|
-
height: `min(100cqh, calc(100cqw / ${playAspect}))`,
|
|
653
|
-
} as const;
|
|
654
|
-
useEffect(() => {
|
|
655
|
-
if (!game || !documentId) return;
|
|
656
|
-
const modelViewport = documentViewport(documentId);
|
|
657
|
-
if (!modelViewport) return;
|
|
658
|
-
// registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
|
|
659
|
-
const capture = modelViewport.capture;
|
|
660
|
-
const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) =>
|
|
661
|
-
documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
|
|
662
|
-
modelViewport.capture = playCapture;
|
|
663
|
-
return () => {
|
|
664
|
-
if (modelViewport.capture !== playCapture) return;
|
|
665
|
-
if (capture) modelViewport.capture = capture;
|
|
666
|
-
else delete modelViewport.capture;
|
|
667
|
-
};
|
|
668
|
-
}, [game, documentId]);
|
|
669
|
-
// (the model's keys are the player's while the game is in front, and the editor's while the area is live)
|
|
670
|
-
useEffect(() => { if (game) view.setPlaying(editArea !== 'live'); }, [game, editArea, view]);
|
|
671
487
|
if (!documentId) return null;
|
|
672
488
|
const second = split ? follower : null;
|
|
673
|
-
// Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
|
|
674
|
-
// its pose/session and repeats the second area's initial scene-camera setup.
|
|
675
489
|
const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
|
|
676
|
-
// Make the hidden Model area inert so its subtree receives neither pointer presses nor focus. A live overlay or tile uses the box supplied by the Play tool.
|
|
677
|
-
const tile = game !== null && typeof editArea === 'object' ? editArea : null;
|
|
678
|
-
const hidden = game !== null && playedReady && (editArea === null || editArea === 'hidden');
|
|
679
|
-
const modelBox = tile === null ? { inset: 12 } : {
|
|
680
|
-
left: `calc(12px + (100% - 24px) * ${tile.left})`, top: `calc(12px + (100% - 24px) * ${tile.top})`,
|
|
681
|
-
width: `calc((100% - 24px) * ${tile.width})`, height: `calc((100% - 24px) * ${tile.height})`,
|
|
682
|
-
};
|
|
683
490
|
return (
|
|
684
|
-
<div style={
|
|
685
|
-
<div key="model"
|
|
491
|
+
<div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
|
|
492
|
+
<div key="model" style={second ? area : { display: 'contents' }}>
|
|
686
493
|
<BlenderViewportArea {...props} view={view} main />
|
|
687
494
|
</div>
|
|
688
|
-
{game && (
|
|
689
|
-
// Keyed on the COPY, so a restart mounts a new stage and runner exactly when the new copy
|
|
690
|
-
// is the one rendered — never over the old, disposed one.
|
|
691
|
-
<div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
|
|
692
|
-
<div data-volter-play-frame style={playFrame}>
|
|
693
|
-
<BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
|
|
694
|
-
onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }}
|
|
695
|
-
onPlayEdit={setEditArea} playOverlay={playOverlay} />
|
|
696
|
-
</div>
|
|
697
|
-
</div>
|
|
698
|
-
)}
|
|
699
|
-
{game && <div key="play-overlay" ref={playOverlay} data-testid="blender-play-overlay" style={{ position: 'absolute', inset: 12, zIndex: 5, pointerEvents: 'none' }} />}
|
|
700
|
-
{game && failure !== null && (
|
|
701
|
-
// Display a failed Play start over the area until it draws; the Game panel repeats the same refusal.
|
|
702
|
-
<div key="play-failure" role="alert" data-testid="blender-play-failure"
|
|
703
|
-
style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
704
|
-
background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
|
|
705
|
-
color: themeVars.content.primary, fontSize: fontSizeVar.sm, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }}>
|
|
706
|
-
<strong style={{ color: themeVars.semantic.danger }}>Play is not running.</strong> {failure} Restart (or save the script) to try again, or Stop.
|
|
707
|
-
</div>
|
|
708
|
-
)}
|
|
709
|
-
{game && restartStill !== null && (
|
|
710
|
-
<div key="restart-still" style={{ position: 'absolute', inset: 12, containerType: 'size', zIndex: 3, pointerEvents: 'none' }} data-testid="blender-play-restart-still">
|
|
711
|
-
<img alt="" src={restartStill} style={{ ...playFrame, display: 'block', objectFit: 'fill' }} />
|
|
712
|
-
</div>
|
|
713
|
-
)}
|
|
714
495
|
{second && (
|
|
715
|
-
<div key="second" style={
|
|
496
|
+
<div key="second" style={area} data-testid="blender-second-area">
|
|
716
497
|
<BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
|
|
717
|
-
|
|
498
|
+
<SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
|
|
718
499
|
</div>
|
|
719
500
|
)}
|
|
720
501
|
</div>
|
|
@@ -785,40 +566,28 @@ function BlenderViewportArea({
|
|
|
785
566
|
active,
|
|
786
567
|
document,
|
|
787
568
|
documentId,
|
|
788
|
-
notify,
|
|
789
569
|
surfaces,
|
|
790
570
|
view,
|
|
791
571
|
main,
|
|
792
|
-
|
|
793
|
-
onPlayReady,
|
|
794
|
-
onPlayEdit,
|
|
795
|
-
playOverlay,
|
|
796
|
-
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void;
|
|
797
|
-
readonly onPlayEdit?: (area: DocumentPlayEditArea) => void; readonly playOverlay?: RefObject<HTMLDivElement | null> }) {
|
|
572
|
+
}: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
|
|
798
573
|
const reads = readsOf(view);
|
|
799
574
|
const build = useCallback(
|
|
800
575
|
() => ({
|
|
801
576
|
root: view.root,
|
|
802
577
|
prepareDraw: (camera: THREE.Camera, options?: BlenderDrawOptions) => view.prepareDraw(camera, options),
|
|
803
578
|
// What the view draws changes with each frame and the work after it,
|
|
804
|
-
// and with the skin's poses when the Timeline scrubs.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
return () => {
|
|
814
|
-
stopView();
|
|
815
|
-
stopSkin();
|
|
816
|
-
};
|
|
817
|
-
},
|
|
818
|
-
}),
|
|
579
|
+
// and with the skin's poses when the Timeline scrubs.
|
|
580
|
+
onChange(listener: () => void) {
|
|
581
|
+
const stopView = view.onChange(listener);
|
|
582
|
+
const stopSkin = blenderSkin.subscribe(listener);
|
|
583
|
+
return () => {
|
|
584
|
+
stopView();
|
|
585
|
+
stopSkin();
|
|
586
|
+
};
|
|
587
|
+
},
|
|
819
588
|
dispose() {},
|
|
820
589
|
}),
|
|
821
|
-
[view
|
|
590
|
+
[view],
|
|
822
591
|
);
|
|
823
592
|
const blend = document?.source?.path;
|
|
824
593
|
// Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
|
|
@@ -858,8 +627,7 @@ function BlenderViewportArea({
|
|
|
858
627
|
* person's.
|
|
859
628
|
*/
|
|
860
629
|
useEffect(() => {
|
|
861
|
-
|
|
862
|
-
if (main || playing || !documentId) return;
|
|
630
|
+
if (main || !documentId) return;
|
|
863
631
|
let entered = false;
|
|
864
632
|
const enter = (): void => {
|
|
865
633
|
if (entered) return;
|
|
@@ -874,98 +642,7 @@ function BlenderViewportArea({
|
|
|
874
642
|
stopSessions();
|
|
875
643
|
stopFrames();
|
|
876
644
|
};
|
|
877
|
-
}, [main,
|
|
878
|
-
/**
|
|
879
|
-
* THE LEVEL'S GAME PLAYS IN THIS STAGE (the document Play extension), once the stage stands: the Play tool draws the
|
|
880
|
-
* game in its container, in place of the hidden copy, and Escape stops the game.
|
|
881
|
-
*/
|
|
882
|
-
const notifyRef = useRef(notify);
|
|
883
|
-
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
884
|
-
const [playReady, setPlayReady] = useState(false);
|
|
885
|
-
notifyRef.current = notify;
|
|
886
|
-
const playReadyRef = useRef(onPlayReady);
|
|
887
|
-
playReadyRef.current = onPlayReady;
|
|
888
|
-
const playEditRef = useRef(onPlayEdit);
|
|
889
|
-
playEditRef.current = onPlayEdit;
|
|
890
|
-
useEffect(() => {
|
|
891
|
-
if (!playing || !documentId) return;
|
|
892
|
-
if (blend === undefined) {
|
|
893
|
-
notifyRef.current?.({
|
|
894
|
-
tone: 'error',
|
|
895
|
-
title: 'This model has no game',
|
|
896
|
-
detail: 'A level\'s game is its project\'s, and this model has no file of its own in the project.',
|
|
897
|
-
});
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
const modelId = documentId.replace(/#play$/, '');
|
|
901
|
-
const layers = window.document.createElement('div');
|
|
902
|
-
Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
|
|
903
|
-
layers.dataset['testid'] = 'model-play-roots';
|
|
904
|
-
surfaceRef.current?.appendChild(layers);
|
|
905
|
-
setPlayReady(false);
|
|
906
|
-
let stopped = false;
|
|
907
|
-
let stopScript: (() => void) | null = null;
|
|
908
|
-
let hiddenCopy: boolean | null = null;
|
|
909
|
-
const start = (): void => {
|
|
910
|
-
if (stopScript || stopped) return;
|
|
911
|
-
const stage = viewportStages().find((one) => one.documentId === documentId);
|
|
912
|
-
if (!stage || !view.root.parent) return;
|
|
913
|
-
hiddenCopy = view.root.visible;
|
|
914
|
-
view.root.visible = false;
|
|
915
|
-
stopScript = modelPlayExtension()?.run({
|
|
916
|
-
documentId: modelId,
|
|
917
|
-
container: layers,
|
|
918
|
-
ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
|
|
919
|
-
edit: (area) => { if (!stopped) playEditRef.current?.(area); },
|
|
920
|
-
...(playOverlay?.current ? { overlay: playOverlay.current } : {}),
|
|
921
|
-
sourcePath: blend,
|
|
922
|
-
report: (title, detail) => {
|
|
923
|
-
editorHost().console.error(`${title}: ${detail}`, 'blender-play');
|
|
924
|
-
notifyRef.current?.({ tone: 'error', title, detail });
|
|
925
|
-
},
|
|
926
|
-
}) ?? null;
|
|
927
|
-
};
|
|
928
|
-
start();
|
|
929
|
-
let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
|
|
930
|
-
let stopPrepareFrames: (() => void) | undefined;
|
|
931
|
-
const waitForDraw = () => {
|
|
932
|
-
const stage = viewportStages().find(one => one.documentId === documentId);
|
|
933
|
-
if (stage === waitingStage) return;
|
|
934
|
-
stopPrepareFrames?.();
|
|
935
|
-
waitingStage = stage;
|
|
936
|
-
stopPrepareFrames = stage?.onFrame(start);
|
|
937
|
-
start();
|
|
938
|
-
};
|
|
939
|
-
waitForDraw();
|
|
940
|
-
const stopStages = onViewportStages(waitForDraw);
|
|
941
|
-
const onEscape = (event: KeyboardEvent): void => {
|
|
942
|
-
if (event.key !== 'Escape' || !surfaceAcceptsKey(event)) return;
|
|
943
|
-
// Decided once the event has finished dispatching: a game that claims Escape (a
|
|
944
|
-
// game's pause, registered after this listener) prevents its default, and the Escape that
|
|
945
|
-
// frees a game's mouse look is the person taking their mouse back. Neither stops Play.
|
|
946
|
-
if (globalThis.document.pointerLockElement || performance.now() - lastPointerUnlockAt < 500) return;
|
|
947
|
-
setTimeout(() => {
|
|
948
|
-
if (event.defaultPrevented) return;
|
|
949
|
-
const extension = modelPlayExtension();
|
|
950
|
-
if (extension?.escape) extension.escape(modelId);
|
|
951
|
-
else extension?.setPlaying(modelId, false);
|
|
952
|
-
}, 0);
|
|
953
|
-
};
|
|
954
|
-
let lastPointerUnlockAt = -Infinity;
|
|
955
|
-
const onLockChange = (): void => { if (!globalThis.document.pointerLockElement) lastPointerUnlockAt = performance.now(); };
|
|
956
|
-
globalThis.document.addEventListener('pointerlockchange', onLockChange);
|
|
957
|
-
window.addEventListener('keydown', onEscape, true);
|
|
958
|
-
return () => {
|
|
959
|
-
globalThis.document.removeEventListener('pointerlockchange', onLockChange);
|
|
960
|
-
stopped = true;
|
|
961
|
-
stopStages();
|
|
962
|
-
stopPrepareFrames?.();
|
|
963
|
-
window.removeEventListener('keydown', onEscape, true);
|
|
964
|
-
stopScript?.();
|
|
965
|
-
if (hiddenCopy !== null) { view.root.visible = hiddenCopy; hiddenCopy = null; }
|
|
966
|
-
layers.remove();
|
|
967
|
-
};
|
|
968
|
-
}, [playing, documentId, blend, view]);
|
|
645
|
+
}, [main, documentId, view]);
|
|
969
646
|
/**
|
|
970
647
|
* A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
|
|
971
648
|
* on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
|
|
@@ -1114,7 +791,7 @@ function BlenderViewportArea({
|
|
|
1114
791
|
const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
|
|
1115
792
|
// Shading changes lighting, not which objects an editing viewport shows.
|
|
1116
793
|
// Only the detached game uses render visibility, as a clean capture does.
|
|
1117
|
-
view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting,
|
|
794
|
+
view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, 'viewport');
|
|
1118
795
|
// BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
|
|
1119
796
|
// presenter draws every surface by it (`blender-workbench-material.ts`).
|
|
1120
797
|
view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
|
|
@@ -1129,7 +806,7 @@ function BlenderViewportArea({
|
|
|
1129
806
|
view.holdRendered(null);
|
|
1130
807
|
view.setWorkbench(false);
|
|
1131
808
|
};
|
|
1132
|
-
}, [documentId, view
|
|
809
|
+
}, [documentId, view]);
|
|
1133
810
|
/**
|
|
1134
811
|
* SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
|
|
1135
812
|
* `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
|
|
@@ -1142,7 +819,7 @@ function BlenderViewportArea({
|
|
|
1142
819
|
*/
|
|
1143
820
|
const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
|
|
1144
821
|
const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
|
|
1145
|
-
|
|
822
|
+
event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
|
|
1146
823
|
const onPointerDownCapture = (event: ReactPointerEvent): void => {
|
|
1147
824
|
if (!cursorChord(event)) {
|
|
1148
825
|
cursorPress.current = null;
|
|
@@ -1190,9 +867,7 @@ function BlenderViewportArea({
|
|
|
1190
867
|
const Surface = surfaces.Object3DAuthoring;
|
|
1191
868
|
return (
|
|
1192
869
|
<div
|
|
1193
|
-
|
|
1194
|
-
data-play-ready={playing ? playReady : undefined}
|
|
1195
|
-
style={playing ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
|
|
870
|
+
style={{ display: 'contents' }}
|
|
1196
871
|
onPointerDownCapture={onPointerDownCapture}
|
|
1197
872
|
onPointerUpCapture={onPointerUpCapture}
|
|
1198
873
|
onPointerCancelCapture={dropCursorPress}
|
|
@@ -1201,7 +876,6 @@ function BlenderViewportArea({
|
|
|
1201
876
|
>
|
|
1202
877
|
<Surface
|
|
1203
878
|
active={active ?? true}
|
|
1204
|
-
fillContainer={playing}
|
|
1205
879
|
documentId={documentId}
|
|
1206
880
|
sourcePath={blend ?? 'blender:runtime'}
|
|
1207
881
|
displayName={document?.label ?? 'Model'}
|
|
@@ -1212,8 +886,8 @@ function BlenderViewportArea({
|
|
|
1212
886
|
cameraView={reads.cameraView}
|
|
1213
887
|
build={build}
|
|
1214
888
|
audit={false}
|
|
1215
|
-
// The authoring hierarchy is Blender’s View Layer Outliner; the default adapter supplies presentation access.
|
|
1216
|
-
{
|
|
889
|
+
// The authoring hierarchy is Blender’s View Layer Outliner; the default adapter supplies presentation access.
|
|
890
|
+
authoring={authoring}
|
|
1217
891
|
cameraDirection={[0.8187, 0.4458, 0.3617]}
|
|
1218
892
|
// FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
|
|
1219
893
|
// three fits.
|