@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.
@@ -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, noteModelOpening } from '../src/play-mode';
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 { commandLine } from '@volter/sdk/product-command';
84
- import { Button, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
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 Play frames responsive.
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 "Opening model…" latched forever. Utility focus
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)) { declined = true; return; }
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)) { declined = true; return; }
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
- note={stillOpening}
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 && <div role="alert" data-testid="blender-model-not-saving"
403
- style={{ position: 'absolute', top: spaceVar[3], left: spaceVar[3], right: spaceVar[3], zIndex: 2,
404
- background: themeVars.surface.chrome, color: themeVars.content.primary,
405
- border: `1px solid ${themeVars.semantic.warning}`, padding: spaceVar[3], fontSize: fontSizeVar.sm }}>
406
- <strong>Model is not being saved</strong>
407
- <div style={{ marginTop: spaceVar[2], marginBottom: spaceVar[3], userSelect: 'text' }}>{saveNotice}</div>
408
- <div style={{ marginBottom: spaceVar[3] }}>Reopening replaces this scene with the last saved file.</div>
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
- // the Game panel drives this id's run, and Game or Animation is chosen by whether this level has a game.
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 and Play use the display transform carried by the presented frame. Solid uses its studio transform. Document preparation waits for that frame’s transform before photographs.
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`, `${documentId}#play`];
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={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
685
- <div key="model" inert={hidden || tile !== null || undefined} style={game ? { position: 'absolute', ...modelBox, zIndex: 2, opacity: hidden ? 0 : 1, pointerEvents: hidden || tile !== null ? 'none' : 'auto', transition: editArea === null ? 'opacity 160ms ease-out' : 'none' } : second ? area : { display: 'contents' }}>
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={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
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
- {!game && <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />}
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
- playing = false,
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. A PLAYING copy announces nothing, and
805
- // a source that announces nothing is drawn every frame (`ToolObject3DPreviewSource.onChange`):
806
- // the play stage's frames are what start the Play tool's run once the stage stands.
807
- ...(playing
808
- ? {}
809
- : {
810
- onChange(listener: () => void) {
811
- const stopView = view.onChange(listener);
812
- const stopSkin = blenderSkin.subscribe(listener);
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, playing],
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
- // A playing copy's camera is its script's.
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, playing, documentId, view]);
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, playing ? 'render' : 'viewport');
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, playing]);
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
- !playing && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
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
- ref={surfaceRef}
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. A playing copy has no authoring adapter and uses the read-only stage.
1216
- {...(playing ? {} : { authoring })}
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.