@volter/editor-blender 0.1.129 → 0.1.130

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.
@@ -25,12 +25,14 @@
25
25
  */
26
26
 
27
27
  // How the `model` stage this document builds behaves (its starting presentation).
28
+ import './blender-properties-context';
28
29
  import { blenderViewFieldOfView } from '../src/presentation';
29
30
  import {
30
31
  type BlenderRuntimeView,
31
32
  blenderModelView,
32
33
  } from '@volter/blender-engine/browser/three/blender-runtime-view';
33
34
  import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
35
+ import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
34
36
  import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
35
37
  import { editorHost } from '@volter/editor-sdk/host';
36
38
  import {
@@ -73,6 +75,7 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
73
75
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
74
76
  import { blenderSkin } from './blender-runtime-skin';
75
77
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
78
+ import { noteModelDocument } from '../src/play-mode';
76
79
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
77
80
  import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
78
81
  import {
@@ -91,6 +94,14 @@ import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-s
91
94
  import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
92
95
  import { BlenderModelOpening } from './blender-model-opening';
93
96
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
97
+ import { commandLine } from '@volter/editor-sdk/kit/product-command';
98
+ import { fontSizeVar, spaceVar, themeVars } from '@volter/editor-sdk/widgets';
99
+ import { clearStartupFailure, reportStartupFailure } from '@volter/editor-sdk/kit/startup-failure';
100
+
101
+ /** This document's model open, as a source in the page's startup-failure registry. */
102
+ const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
103
+ /** How long "Opening model…" stands before the pane also says what it is waiting on. */
104
+ const MODEL_OPEN_NOTE_AFTER_MS = 30_000;
94
105
  import {
95
106
  clearModelDocumentPreview,
96
107
  modelDocumentPreview,
@@ -99,6 +110,15 @@ import {
99
110
  type ModelDocumentPreview,
100
111
  } from '../src/model-document-preview';
101
112
 
113
+ /** A detached copy's identity as a React key (the play area is keyed on the copy it draws). */
114
+ const playCopyKeys = new WeakMap<object, number>();
115
+ let nextPlayCopyKey = 0;
116
+ function playCopyKey(copy: object): number {
117
+ let key = playCopyKeys.get(copy);
118
+ if (key === undefined) { key = ++nextPlayCopyKey; playCopyKeys.set(copy, key); }
119
+ return key;
120
+ }
121
+
102
122
  /** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
103
123
  const overlaysOpened = new WeakSet<object>();
104
124
 
@@ -223,6 +243,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
223
243
  const entryId = document?.id;
224
244
  const key = JSON.stringify([documentId, entryId, blend]);
225
245
  const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
246
+ /** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
247
+ const [stillOpening, setStillOpening] = useState<string | null>(null);
226
248
  const [attempt, setAttempt] = useState(0);
227
249
  const project = editorHost().projectLocalState.projectRootPath();
228
250
  const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
@@ -288,6 +310,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
288
310
  const unbind = bindModelDocument(binding);
289
311
  const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
290
312
  setOpened(null);
313
+ setStillOpening(null);
291
314
  // Native focus can arrive after the contributed pane mounts. A declined
292
315
  // open is not an in-flight load: retry when the host activates this model,
293
316
  // rather than leaving "Opening model…" latched forever. Utility focus
@@ -295,18 +318,23 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
295
318
  const documents = editorHost().documents;
296
319
  let starting = false;
297
320
  let finished = false;
321
+ let declined = false;
322
+ const path = blend ?? 'the model';
298
323
  const open = async () => {
299
- if (cancelled || starting || finished || (binding && !modelDocumentMayOpen(binding))) return;
324
+ if (cancelled || starting || finished) return;
325
+ if (binding && !modelDocumentMayOpen(binding)) { declined = true; return; }
300
326
  starting = true;
327
+ declined = false;
301
328
  try {
302
329
  if (binding) {
303
- if (!await openModelDocumentBlend(binding, publish)) return;
330
+ if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
304
331
  } else {
305
332
  // The standing Model document is the explicit blender-start target.
306
333
  publish();
307
334
  }
308
335
  if (!cancelled) {
309
336
  finished = true;
337
+ clearStartupFailure(MODEL_OPEN_STARTUP_SOURCE);
310
338
  setOpened({ key, error: null });
311
339
  }
312
340
  } catch (error) {
@@ -317,11 +345,29 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
317
345
  const detail = error instanceof Error ? error.message : String(error);
318
346
  setOpened({ key, error: detail });
319
347
  editorHost().console.error(detail, 'blender-open');
320
- callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${blend ?? 'the model'}`, detail: modelOpeningErrorMessage(detail) });
348
+ callbacks.current.notify?.({ tone: 'error', title: `Blender could not open ${path}`, detail: modelOpeningErrorMessage(detail) });
349
+ // AND TO THE PRODUCT'S COVER, which is still up while the first model opens and
350
+ // would otherwise go on saying "Opening the first model…" over this pane's error
351
+ // until its own budget ran out. After the cover has lifted, this changes nothing.
352
+ reportStartupFailure(MODEL_OPEN_STARTUP_SOURCE, {
353
+ message: `Blender could not open ${path}: ${detail}`,
354
+ guidance: `${modelOpeningErrorMessage(detail)} Dismiss this to reach the model's Retry.`,
355
+ command: null,
356
+ });
321
357
  } finally {
322
358
  starting = false;
323
359
  }
324
360
  };
361
+ // A SLOW OPEN SAYS WHAT IT IS WAITING ON (2026-10-06 audit: "Opening model…" had no word
362
+ // past its spinner, and a declined open sat there indefinitely). It is progress, never an
363
+ // error, and it offers no Retry (#147 review): the open is still running, and a second one
364
+ // would queue behind it. A real failure arrives through the catch above.
365
+ const slow = setInterval(() => {
366
+ if (cancelled || finished) return;
367
+ setStillOpening(declined
368
+ ? `Still waiting: Blender is holding another model, and opens ${path} when it is free.`
369
+ : `Still opening ${path}: Blender is working (a large file, or a cold start). ${commandLine('status')} shows what it is busy with.`);
370
+ }, MODEL_OPEN_NOTE_AFTER_MS);
325
371
  // Opening publishes context to other React roots. Start outside this
326
372
  // effect (or a host activation listener's synchronous notification stack)
327
373
  // so those roots cannot synchronously commit through a pending commit here.
@@ -335,6 +381,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
335
381
  requestOpen();
336
382
  return () => {
337
383
  cancelled = true;
384
+ clearInterval(slow);
338
385
  unsubscribe();
339
386
  unpublish?.();
340
387
  unbind();
@@ -344,6 +391,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
344
391
  if (active === false || !documentId) return null;
345
392
  if (opened?.key !== key || opened.error) return <BlenderModelOpening
346
393
  documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
394
+ note={stillOpening}
347
395
  retry={() => { setOpened(null); setAttempt(value => value + 1); }}
348
396
  returnToPreview={() => {
349
397
  if (!preview) return;
@@ -374,6 +422,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
374
422
  */
375
423
  function BlenderModelViewport(props: ToolContributionProps) {
376
424
  const { documentId } = props;
425
+ const blend = props.document?.source?.path;
426
+ // THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
427
+ // the Game panel drives this id's run, and Game or Movie is chosen by this file's play script.
428
+ useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
377
429
  // Rendered areas and Play use the scene's display transform, just as a
378
430
  // photograph does. Solid keeps its studio transform. RNA also announces
379
431
  // colour settings that change without changing the presented geometry.
@@ -381,31 +433,62 @@ function BlenderModelViewport(props: ToolContributionProps) {
381
433
  if (!documentId) return;
382
434
  let cancelled = false;
383
435
  let revision = 0;
384
- let applied: { mapper: string; exposure: number } | null = null;
436
+ let applied: { key: string; transform: BlenderDisplayTransform } | null = null;
437
+ const ids = [documentId, `${documentId}#area-2`, `${documentId}#play`];
438
+ const attach = () => {
439
+ for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied?.transform ?? null);
440
+ };
385
441
  const read = async () => {
386
442
  const mine = ++revision;
387
443
  const context = await blenderRnaContext();
388
444
  if (!context) return;
389
- const settings = await blenderRna(`${context.scene}.view_settings`);
445
+ const [settings, displaySettings] = await Promise.all([
446
+ blenderRna(`${context.scene}.view_settings`),
447
+ blenderRna(`${context.scene}.display_settings`),
448
+ ]);
390
449
  if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
450
+ if (displaySettings?.kind === 'struct') {
451
+ const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
452
+ if (typeof device === 'string' && device !== 'sRGB')
453
+ throw new Error(`Blender display device ${device} has no browser display processor yet`);
454
+ }
391
455
  const rows = settings.groups.flatMap(group => group.rows);
392
456
  const transform = rows.find(row => row.identifier === 'view_transform')?.value;
393
457
  const stops = rows.find(row => row.identifier === 'exposure')?.value;
394
- const mapper = transform === 'Standard' ? 'none' : transform === 'Filmic' ? 'filmic' : 'agx';
458
+ const look = rows.find(row => row.identifier === 'look')?.value;
459
+ const gamma = rows.find(row => row.identifier === 'gamma')?.value;
395
460
  const exposure = typeof stops === 'number' ? 2 ** stops : 1;
396
- if (applied?.mapper === mapper && applied.exposure === exposure) return;
397
- applied = { mapper, exposure };
398
- for (const id of [documentId, `${documentId}#area-2`, `${documentId}#play`]) {
461
+ const display = {
462
+ transform: typeof transform === 'string' ? transform : 'AgX',
463
+ look: typeof look === 'string' ? look : 'None',
464
+ exposure, gamma: typeof gamma === 'number' ? gamma : 1,
465
+ };
466
+ const key = JSON.stringify(display);
467
+ if (applied?.key === key) return;
468
+ const resolved = await createBlenderDisplayTransform(display);
469
+ if (cancelled || mine !== revision) { resolved.dispose(); return; }
470
+ const previous = applied;
471
+ applied = { key, transform: resolved };
472
+ attach();
473
+ previous?.transform.dispose();
474
+ for (const id of ids) {
399
475
  setViewPresentation(id, { modes: {
400
- rendered: { lighting: { tone: { mapper, exposure } } },
401
- preview: { lighting: { tone: { mapper, exposure } } },
476
+ rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
477
+ preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
402
478
  } });
403
479
  }
404
480
  };
405
481
  const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
406
482
  const stopRna = subscribeBlenderRna(update);
483
+ const stopSessions = subscribeObject3DDocumentSessions(attach);
407
484
  update();
408
- return () => { cancelled = true; stopRna(); };
485
+ return () => {
486
+ cancelled = true; stopRna(); stopSessions();
487
+ const previous = applied;
488
+ applied = null;
489
+ attach();
490
+ previous?.transform.dispose();
491
+ };
409
492
  }, [documentId]);
410
493
  const split = useSyncExternalStore(
411
494
  subscribeAreaSplit,
@@ -439,30 +522,121 @@ function BlenderModelViewport(props: ToolContributionProps) {
439
522
  () => null,
440
523
  );
441
524
  const [playedReady, setPlayedReady] = useState(false);
525
+ /**
526
+ * RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
527
+ * below is detached per generation, so a restart disposes the played copy and detaches a fresh
528
+ * one from the model, which never moved, while playing stays true throughout.
529
+ *
530
+ * THE OLD GAME STAYS ON SCREEN UNTIL THE NEW ONE HAS DRAWN. Between the two the new copy's
531
+ * stage is still preparing its rendered draw, and the model area is what lies under the play
532
+ * area — so a restart would flash the editing model between two games. The generation is
533
+ * therefore taken in two steps: a layout effect photographs the old play stage while it still
534
+ * stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
535
+ * detaches the new copy. The photograph covers the play area until the new runner's first
536
+ * frame (`onPlayReady`), the model area stays hidden throughout, and the old runner, no longer
537
+ * the current generation, stands still meanwhile (`play-script.ts`).
538
+ */
539
+ const generation = useSyncExternalStore(
540
+ subscribeDocumentPlayExtensions,
541
+ () => (documentId ? documentPlayExtension('model')?.transport?.generation(documentId) ?? 0 : 0),
542
+ () => 0,
543
+ );
544
+ const [shownGeneration, setShownGeneration] = useState(generation);
545
+ const [restartStill, setRestartStill] = useState<string | null>(null);
546
+ const restarting = useRef(false);
547
+ // A restarted game that has not drawn yet: its stage holds no game to photograph, so a second
548
+ // Restart in that window keeps the cover it has (or none) rather than photographing a blank.
549
+ const awaitingDraw = useRef(false);
550
+ useLayoutEffect(() => {
551
+ if (generation === shownGeneration) return;
552
+ if (playing && documentId) {
553
+ restarting.current = true;
554
+ if (!awaitingDraw.current) {
555
+ let still: string | null = null;
556
+ try { still = documentViewport(`${documentId}#play`)?.capture?.() ?? null; }
557
+ catch { /* No photograph: the new stage shows as it prepares, still never the model. */ }
558
+ setRestartStill(still);
559
+ }
560
+ awaitingDraw.current = true;
561
+ }
562
+ setShownGeneration(generation);
563
+ }, [generation, shownGeneration, playing, documentId]);
564
+ /**
565
+ * A RESTARTED GAME THAT FAILS never draws (its script did not start, or threw on its first
566
+ * update), so the cover would stand over it while the panel said Playing. The runner says so
567
+ * on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
568
+ */
569
+ const transport = documentPlayExtension('model')?.transport;
570
+ const subscribeFailure = useCallback(
571
+ (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
572
+ [transport],
573
+ );
574
+ const failure = useSyncExternalStore(
575
+ subscribeFailure,
576
+ () => (documentId && transport ? transport.clock(documentId).failure ?? null : null),
577
+ () => null,
578
+ );
442
579
  useEffect(() => {
443
- if (!playing || !documentId) return;
580
+ if (failure === null) return;
581
+ awaitingDraw.current = false;
582
+ setRestartStill(null);
583
+ }, [failure]);
584
+ /**
585
+ * COPIES A RESTART RETIRED, disposed only once the play area that drew them has unmounted:
586
+ * the commit that renders the new copy removes the old area first, and this runs after it.
587
+ * Disposed earlier, the old stage could draw a disposed copy for a frame.
588
+ */
589
+ const retired = useRef<ReturnType<AreaView['detach']>[]>([]);
590
+ useEffect(() => {
591
+ if (!playing || !documentId) { restarting.current = false; return; }
444
592
  const playId = `${documentId}#play`;
445
593
  // A game is seen as the render is: Rendered shading, chosen before the stage binds.
446
594
  setViewPresentation(playId, { drawMode: 'rendered' });
447
595
  const copy = view.detach();
448
- setPlayedReady(false);
596
+ const restart = restarting.current;
597
+ restarting.current = false;
598
+ // A restart keeps the model area hidden: the old game's photograph stands in until ready.
599
+ if (!restart) setPlayedReady(false);
449
600
  setPlayed(copy);
450
- // The stage's mode is PLAY for as long as the copy stands, so the keys are the player's.
451
- view.setPlaying(true);
452
- notifyWorkspaceDocumentSelectionChanged(documentId);
601
+ if (!restart) {
602
+ // The stage's mode is PLAY for as long as a copy stands, so the keys are the player's.
603
+ view.setPlaying(true);
604
+ notifyWorkspaceDocumentSelectionChanged(documentId);
605
+ }
453
606
  return () => {
454
- setPlayed(null);
607
+ // Read at cleanup: the layout effect above has already marked a restart, whose new copy
608
+ // takes over in this same commit with the play mode unchanged.
609
+ if (restarting.current && documentPlayExtension('model')?.playing(documentId)) {
610
+ retired.current.push(copy);
611
+ return;
612
+ }
455
613
  copy.dispose();
614
+ for (const old of retired.current.splice(0)) old.dispose();
615
+ awaitingDraw.current = false;
616
+ setPlayed(null);
617
+ setRestartStill(null);
456
618
  view.setPlaying(false);
457
619
  notifyWorkspaceDocumentSelectionChanged(documentId);
458
620
  };
459
- }, [playing, documentId]);
621
+ }, [playing, documentId, shownGeneration]);
622
+ useEffect(() => {
623
+ for (const old of retired.current.filter(one => one !== played)) old.dispose();
624
+ retired.current = retired.current.filter(one => one === played);
625
+ }, [played]);
626
+ // And whatever is still retired when the document itself goes (declared after the copy's own
627
+ // effect, so its cleanup has already handed over anything mid-restart).
628
+ useEffect(() => () => { for (const old of retired.current.splice(0)) old.dispose(); }, []);
460
629
  // The document closing or going inactive ends its game; a game never outlives its stage.
461
630
  useEffect(() => {
462
631
  if (!documentId) return;
463
632
  return () => documentPlayExtension('model')?.setPlaying(documentId, false);
464
633
  }, [documentId]);
465
634
  const game = playing ? played : null;
635
+ const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
636
+ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
637
+ width: `min(100cqw, calc(100cqh * ${playAspect}))`,
638
+ height: `min(100cqh, calc(100cqw / ${playAspect}))`,
639
+ } as const;
466
640
  useEffect(() => {
467
641
  if (!game || !documentId) return;
468
642
  const modelViewport = documentViewport(documentId);
@@ -482,22 +656,46 @@ function BlenderModelViewport(props: ToolContributionProps) {
482
656
  // Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
483
657
  // its pose/session and repeats the second area's initial scene-camera setup.
484
658
  const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
659
+ // THE HIDDEN MODEL AREA IS INERT, not merely `pointer-events: none`. It lies OVER the play
660
+ // area (z-index 2, faded out once the game has drawn), and the stage inside it sets its own
661
+ // `pointer-events: auto` (`StageHost`'s viewport box), which a parent's `none` does not reach:
662
+ // a real click on the game hit the invisible editing canvas, never the play surface, so the
663
+ // runner's takeover (`play-script.ts`, which asks whether the press landed in the game) never
664
+ // saw it and the game never got it — while keys, read off the window, took over fine. `inert`
665
+ // takes the whole subtree out of hit testing and focus.
666
+ const hidden = game !== null && playedReady;
485
667
  return (
486
668
  <div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
487
- <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' }}>
669
+ <div key="model" inert={hidden || undefined} 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' }}>
488
670
  <BlenderViewportArea {...props} view={view} main />
489
671
  </div>
490
672
  {game && (
491
- <div key="play" style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
492
- <div data-volter-play-frame style={playAspect === null ? { position: 'absolute', inset: 0 } : {
493
- position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
494
- width: `min(100cqw, calc(100cqh * ${playAspect}))`,
495
- height: `min(100cqh, calc(100cqw / ${playAspect}))`,
496
- }}>
497
- <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing onPlayReady={() => setPlayedReady(true)} onPlayReturn={() => setPlayedReady(false)} />
673
+ // Keyed on the COPY, so a restart mounts a new stage and runner exactly when the new copy
674
+ // is the one rendered — never over the old, disposed one.
675
+ <div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
676
+ <div data-volter-play-frame style={playFrame}>
677
+ <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
678
+ onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }} onPlayReturn={() => setPlayedReady(false)} />
498
679
  </div>
499
680
  </div>
500
681
  )}
682
+ {game && failure !== null && (
683
+ // A PLAY WITH NO GAME, said over the area itself. When the script did not start (or the
684
+ // stage could not be prepared) the game never draws and the model area stays in front,
685
+ // so the area looked like plain editing while Play was on and the reason was a toast
686
+ // that had already gone (2026-10-06 audit). The Game panel says the same in its line.
687
+ <div key="play-failure" role="alert" data-testid="blender-play-failure"
688
+ style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
689
+ background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
690
+ color: themeVars.content.primary, fontSize: fontSizeVar.sm, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }}>
691
+ <strong style={{ color: themeVars.semantic.danger }}>Play is not running.</strong> {failure} Restart (or save the script) to try again, or Stop.
692
+ </div>
693
+ )}
694
+ {game && restartStill !== null && (
695
+ <div key="restart-still" style={{ position: 'absolute', inset: 12, containerType: 'size', zIndex: 3, pointerEvents: 'none' }} data-testid="blender-play-restart-still">
696
+ <img alt="" src={restartStill} style={{ ...playFrame, display: 'block', objectFit: 'fill' }} />
697
+ </div>
698
+ )}
501
699
  {second && (
502
700
  <div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
503
701
  <BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
@@ -726,8 +924,17 @@ function BlenderViewportArea({
726
924
  }) ?? null;
727
925
  }, error => {
728
926
  if (stopped) return;
729
- editorHost().console.error(`Rendered stage preparation failed: ${String(error)}`, 'model-play');
730
- documentPlayExtension('model')?.setPlaying(modelId, false);
927
+ const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
928
+ editorHost().console.error(failure, 'model-play');
929
+ // SAID WHERE THE PERSON IS LOOKING: on the run's clock, which the Game panel and the
930
+ // play area draw, and in the play log. Turning Play off (what this did until
931
+ // 2026-10-06) erased the only trace a person could have seen.
932
+ const extension = documentPlayExtension('model');
933
+ if (extension?.transport?.fail) extension.transport.fail(modelId, blend, failure);
934
+ else {
935
+ notifyRef.current?.({ tone: 'error', title: 'Play could not start', detail: failure });
936
+ extension?.setPlaying(modelId, false);
937
+ }
731
938
  });
732
939
  };
733
940
  start();
@@ -67,6 +67,9 @@ import {
67
67
  timelineViewVersion,
68
68
  } from '../src/timeline-view-state';
69
69
  import { blenderSkin, blenderSkinVersion, subscribeBlenderSkin } from './blender-runtime-skin';
70
+ import { BlenderGamePanel, GAME_PANEL_RATIO, PlayModeSwitch } from './blender-game-panel';
71
+ import { setWorkspaceAreaRatio } from '@volter/editor-sdk/kit/workspace-areas';
72
+ import { modelPlayMode, playModeVersion, servedModelDocument, subscribePlayMode } from '../src/play-mode';
70
73
  import {
71
74
  CHANNEL_HEIGHT,
72
75
  DIAMOND_RADIUS,
@@ -228,7 +231,7 @@ function report(): unknown {
228
231
  * no mixer (`blender-runtime-skin.ts`), so the seek was a no-op and
229
232
  * `report()` then answered `frame: 1` — the playhead's honest position and a
230
233
  * complete lie about the gesture. Measured on walk 5 over a fresh
231
- * `model-editor create` scaffold: `volter.timeline.frame {frame:120}` and
234
+ * `cyclotron create` scaffold: `volter.timeline.frame {frame:120}` and
232
235
  * `volter.timeline.jump-end` (frame 250) both answered `frame: 1`,
233
236
  * `refusal: null`. This is the half of walk 4's W5 (#7740) that the header
234
237
  * got and the VERBS did not — there the buttons were disabled wearing this
@@ -360,7 +363,32 @@ registerViewVerbs({
360
363
  ],
361
364
  });
362
365
 
363
- export default function BlenderTimeline() {
366
+ /** This area's id in `model.layout.ts`, the one workspace that places this document. */
367
+ const BOTTOM_AREA = 'timeline';
368
+
369
+ /**
370
+ * THE BOTTOM AREA IS THE TIMELINE IN MOVIE MODE AND THE GAME PANEL IN GAME MODE
371
+ * (`../src/play-mode.ts`, for the model document on screen). Same area: the
372
+ * layout's `areas` entry names this document either way. NOT THE SAME SIZE:
373
+ * Blender's measured Timeline strip (`model.layout.ts`) left the Game panel's
374
+ * play log one clipped line, so Game mode asks the area for the panel's own
375
+ * share (`GAME_PANEL_RATIO`) and Movie mode hands Blender's back. Each switch
376
+ * stands the area up at its mode's size; a person's resize holds until the
377
+ * next switch. The Timeline unmounts in Game mode; a document that opens as a
378
+ * game therefore never pays for the rig bind (the cost note in
379
+ * `BlenderTimeline`), and one bound earlier keeps its bind, which the Timeline
380
+ * never undoes.
381
+ */
382
+ export default function BlenderBottomArea() {
383
+ useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
384
+ const documentId = servedModelDocument();
385
+ const game = documentId !== null && modelPlayMode(documentId) === 'game';
386
+ useEffect(() => setWorkspaceAreaRatio(BOTTOM_AREA, game ? GAME_PANEL_RATIO : null), [game]);
387
+ useEffect(() => () => setWorkspaceAreaRatio(BOTTOM_AREA, null), []);
388
+ return game ? <BlenderGamePanel /> : <BlenderTimeline />;
389
+ }
390
+
391
+ function BlenderTimeline() {
364
392
  // THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
365
393
  // every presented frame bumps it, which is exactly when a rig or an action
366
394
  // can have moved.
@@ -919,7 +947,9 @@ function TimelineHeader() {
919
947
  borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
920
948
  }}
921
949
  >
922
- <span style={{ color: TIMELINE_CHROME.text }}>Timeline</span>
950
+ {/* GAME | MOVIE in place of the title, as in the Game panel, so the person switches back
951
+ from either (`blender-game-panel.tsx`); without a Play tool or a model, the title. */}
952
+ <PlayModeSwitch title="Timeline" />
923
953
  <TimelineViewMenu />
924
954
  {/* TWO SPACERS, which is what CENTRES the transport. Blender's Timeline
925
955
  header is three clusters: the menus at the leading edge, the
@@ -94,7 +94,7 @@ const REFUSALS = {
94
94
 
95
95
  /** THE VIEW'S VERBS, published once, here — U8's ruling 1 (2026-09-19). Under
96
96
  * the Code-OSS frame each becomes a `volter.blender-uv-view.<verb>` command the
97
- * bridge dispatches into the view, and standalone `volter-model-editor edit` reaches the
97
+ * bridge dispatches into the view, and standalone `cyclotron edit` reaches the
98
98
  * SAME table through the session. **This view adds no `blender-*` session
99
99
  * verb at all**, which is exactly what the ruling asked the remaining I5
100
100
  * views to stop paying for. */
@@ -49,5 +49,9 @@ export const layout: WorkspaceLayoutContribution = {
49
49
  // It is stated to four places because it is a measurement, not a preference;
50
50
  // the dock uses it once, to size the group the first time this workspace
51
51
  // stands up, and a capture of the settled grid wins afterwards.
52
+ //
53
+ // IN GAME MODE THE SAME AREA HOLDS THE GAME PANEL (`../src/play-mode.ts`):
54
+ // the document below draws the Timeline or the panel by the model's mode,
55
+ // so the area, its proportion and a person's resize all carry over.
52
56
  areas: [{ id: 'timeline', document: 'blender-timeline.document', place: 'below', ratio: 0.0719 }],
53
57
  };
@@ -25,13 +25,17 @@
25
25
  * This module is a plain object the HOST registers; it mounts no UI. It reads
26
26
  * the project only through `input.files` — the PATHS matching those globs.
27
27
  * Deliberately not `input.sources`: a `.blend` is binary, there is nothing in
28
- * it a text walk could honestly read, and the entry is the file itself.
28
+ * it a text walk could honestly read, and the entry is the file itself. It says
29
+ * so (`reads: 'files'`), and the host then lists the files without fetching them.
29
30
  */
30
31
 
31
32
  import { z } from 'zod';
32
33
 
33
34
  export const finder = {
34
35
  name: 'modelsFromBlendFiles',
36
+ // PATHS ONLY, so the host never fetches a `.blend` through its text-source route (a
37
+ // multi-megabyte binary there is a `413`, and nothing here would read the text).
38
+ reads: 'files',
35
39
  schema: z
36
40
  .object({
37
41
  finder: z.literal('modelsFromBlendFiles'),
@@ -13,9 +13,10 @@
13
13
  * this tab shows, and our widgets draw them from the door's rows.
14
14
  *
15
15
  * WHAT THE TRANSCRIPTION DELIBERATELY DROPS:
16
- * - the `poll`/`if` branches. `show_all_edges` is mesh-only in Python; here
16
+ * - type-only `poll`/`if` branches. `show_all_edges` is mesh-only in Python; here
17
17
  * it is simply not on a Camera's RNA, so it does not draw. Same outcome,
18
18
  * from the datablock rather than from a copy of Blender's conditions.
19
+ * Rotation mode is a value on every object, so its draw branch is retained.
19
20
  * - `OBJECT_PT_context_object` (`bl_label = ""`) — the breadcrumb row, which
20
21
  * is the host's identity row here.
21
22
  * - the four panels whose datablock is NOT the object: Motion Paths reads
@@ -35,35 +36,33 @@ import type { BlenderCuratedPanel } from './blender-properties-view';
35
36
  /** `properties_object.py`, panel by panel. Line numbers are that file's. */
36
37
  const CURATED: readonly BlenderCuratedPanel[] = [
37
38
  {
38
- // OBJECT_PT_transform, :36. Blender draws the rotation channel that
39
- // `rotation_mode` selects; all three are listed, and the two the object is
40
- // not in are still real RNA, so all three draw. That is MORE than Blender
41
- // shows and it is the honest reading: the door reports what the object
42
- // holds, and a quaternion object's euler is not a fiction.
39
+ // OBJECT_PT_transform, :36-83: only the current rotation channel, with
40
+ // lock toggles aligned beside its components and the Mode label.
43
41
  title: 'Transform',
44
42
  properties: [
45
- 'location',
46
- 'lock_location',
47
- 'rotation_quaternion',
48
- 'rotation_axis_angle',
49
- 'rotation_euler',
50
- 'lock_rotation_w',
51
- 'lock_rotation',
52
- 'rotation_mode',
53
- 'scale',
54
- 'lock_scale',
55
- ],
56
- },
57
- {
58
- // OBJECT_PT_delta_transform, :86.
59
- title: 'Delta Transform',
60
- closed: true,
61
- properties: [
62
- 'delta_location',
63
- 'delta_rotation_quaternion',
64
- 'delta_rotation_euler',
65
- 'delta_scale',
43
+ { property: 'location', lock: 'lock_location' },
44
+ { property: 'rotation_quaternion', label: 'Rotation', lock: 'lock_rotation', firstLock: 'lock_rotation_w',
45
+ when: { property: 'rotation_mode', is: ['QUATERNION'] } },
46
+ { property: 'rotation_axis_angle', label: 'Rotation', lock: 'lock_rotation', firstLock: 'lock_rotation_w',
47
+ when: { property: 'rotation_mode', is: ['AXIS_ANGLE'] } },
48
+ { property: 'rotation_euler', label: 'Rotation', lock: 'lock_rotation',
49
+ when: { property: 'rotation_mode', isNot: ['QUATERNION', 'AXIS_ANGLE'] } },
50
+ { property: 'rotation_mode', label: 'Mode' },
51
+ { property: 'scale', lock: 'lock_scale' },
66
52
  ],
53
+ sub: [{
54
+ // OBJECT_PT_delta_transform, :86-107: a child of Transform, with no
55
+ // rotation field for AXIS_ANGLE, exactly as its draw branch specifies.
56
+ title: 'Delta Transform', closed: true,
57
+ properties: [
58
+ { property: 'delta_location', label: 'Location' },
59
+ { property: 'delta_rotation_quaternion', label: 'Rotation',
60
+ when: { property: 'rotation_mode', is: ['QUATERNION'] } },
61
+ { property: 'delta_rotation_euler', label: 'Rotation',
62
+ when: { property: 'rotation_mode', isNot: ['QUATERNION', 'AXIS_ANGLE'] } },
63
+ { property: 'delta_scale', label: 'Scale' },
64
+ ],
65
+ }],
67
66
  },
68
67
  {
69
68
  // OBJECT_PT_relations, :132.
@@ -35,13 +35,15 @@
35
35
  */
36
36
  import { blenderPropertiesTabMatch, blenderPropertiesTabSection } from './blender-properties-tab';
37
37
  import type { BlenderCuratedPanel } from './blender-properties-view';
38
+ import { resolveBlenderSubject } from './blender-properties-model';
39
+ import type { ToolContributionNode } from '@volter/editor-sdk/contributions';
38
40
 
39
41
  /** `properties_scene.py`, panel by panel. Line numbers are that file's. */
40
42
  const CURATED: readonly BlenderCuratedPanel[] = [
41
43
  {
42
44
  // SCENE_PT_scene, :49.
43
45
  title: 'Scene',
44
- properties: ['camera', 'background_set', 'active_clip'],
46
+ properties: ['camera', 'background_set', { property: 'active_clip', label: 'Active Clip' }],
45
47
  },
46
48
  {
47
49
  // SCENE_PT_unit, :64 — `unit = scene.unit_settings` (:74).
@@ -64,7 +66,8 @@ const CURATED: readonly BlenderCuratedPanel[] = [
64
66
  // (`draw_header`, :307) and `gravity` its one row.
65
67
  title: 'Gravity',
66
68
  closed: true,
67
- properties: ['use_gravity', 'gravity'],
69
+ headerProperty: 'use_gravity',
70
+ properties: ['gravity'],
68
71
  },
69
72
  {
70
73
  // SCENE_PT_simulation, :320.
@@ -170,5 +173,9 @@ export const order = 40;
170
173
  * object group (Object … Material), then Texture. The presentation draws a
171
174
  * separator wherever this changes. */
172
175
  export const railGroup = 'scene';
176
+ /** buttons_context_compute falls back to SCENE when no object context exists.
177
+ * Keep the remembered tab when valid; otherwise use Blender's scene fallback. */
178
+ export const railDefault = (node: ToolContributionNode | null, adapter: unknown): boolean =>
179
+ resolveBlenderSubject(node, adapter)?.kind === 'scene';
173
180
  export const match = blenderPropertiesTabMatch(TAB);
174
181
  export default blenderPropertiesTabSection(TAB);