@volter/editor-blender 0.1.128 → 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,11 +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';
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';
33
36
  import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/editor-sdk/contributions';
34
37
  import { editorHost } from '@volter/editor-sdk/host';
35
38
  import {
@@ -72,6 +75,7 @@ import { BlenderObjectModeHeader } from './blender-header-menus';
72
75
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
73
76
  import { blenderSkin } from './blender-runtime-skin';
74
77
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
78
+ import { noteModelDocument } from '../src/play-mode';
75
79
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
76
80
  import { notifyWorkspaceDocumentSelectionChanged } from '@volter/editor-sdk/kit/workspace-document-registry';
77
81
  import {
@@ -90,6 +94,14 @@ import { stageStore, subscribeStageStores } from '@volter/editor-sdk/kit/stage-s
90
94
  import { surfaceAcceptsKey } from '@volter/editor-sdk/kit/surface-keyboard';
91
95
  import { BlenderModelOpening } from './blender-model-opening';
92
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;
93
105
  import {
94
106
  clearModelDocumentPreview,
95
107
  modelDocumentPreview,
@@ -98,6 +110,15 @@ import {
98
110
  type ModelDocumentPreview,
99
111
  } from '../src/model-document-preview';
100
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
+
101
122
  /** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
102
123
  const overlaysOpened = new WeakSet<object>();
103
124
 
@@ -222,6 +243,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
222
243
  const entryId = document?.id;
223
244
  const key = JSON.stringify([documentId, entryId, blend]);
224
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);
225
248
  const [attempt, setAttempt] = useState(0);
226
249
  const project = editorHost().projectLocalState.projectRootPath();
227
250
  const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
@@ -287,6 +310,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
287
310
  const unbind = bindModelDocument(binding);
288
311
  const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
289
312
  setOpened(null);
313
+ setStillOpening(null);
290
314
  // Native focus can arrive after the contributed pane mounts. A declined
291
315
  // open is not an in-flight load: retry when the host activates this model,
292
316
  // rather than leaving "Opening model…" latched forever. Utility focus
@@ -294,18 +318,23 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
294
318
  const documents = editorHost().documents;
295
319
  let starting = false;
296
320
  let finished = false;
321
+ let declined = false;
322
+ const path = blend ?? 'the model';
297
323
  const open = async () => {
298
- if (cancelled || starting || finished || (binding && !modelDocumentMayOpen(binding))) return;
324
+ if (cancelled || starting || finished) return;
325
+ if (binding && !modelDocumentMayOpen(binding)) { declined = true; return; }
299
326
  starting = true;
327
+ declined = false;
300
328
  try {
301
329
  if (binding) {
302
- if (!await openModelDocumentBlend(binding, publish)) return;
330
+ if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
303
331
  } else {
304
332
  // The standing Model document is the explicit blender-start target.
305
333
  publish();
306
334
  }
307
335
  if (!cancelled) {
308
336
  finished = true;
337
+ clearStartupFailure(MODEL_OPEN_STARTUP_SOURCE);
309
338
  setOpened({ key, error: null });
310
339
  }
311
340
  } catch (error) {
@@ -316,11 +345,29 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
316
345
  const detail = error instanceof Error ? error.message : String(error);
317
346
  setOpened({ key, error: detail });
318
347
  editorHost().console.error(detail, 'blender-open');
319
- 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
+ });
320
357
  } finally {
321
358
  starting = false;
322
359
  }
323
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);
324
371
  // Opening publishes context to other React roots. Start outside this
325
372
  // effect (or a host activation listener's synchronous notification stack)
326
373
  // so those roots cannot synchronously commit through a pending commit here.
@@ -334,6 +381,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
334
381
  requestOpen();
335
382
  return () => {
336
383
  cancelled = true;
384
+ clearInterval(slow);
337
385
  unsubscribe();
338
386
  unpublish?.();
339
387
  unbind();
@@ -343,6 +391,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
343
391
  if (active === false || !documentId) return null;
344
392
  if (opened?.key !== key || opened.error) return <BlenderModelOpening
345
393
  documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
394
+ note={stillOpening}
346
395
  retry={() => { setOpened(null); setAttempt(value => value + 1); }}
347
396
  returnToPreview={() => {
348
397
  if (!preview) return;
@@ -373,6 +422,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
373
422
  */
374
423
  function BlenderModelViewport(props: ToolContributionProps) {
375
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]);
376
429
  // Rendered areas and Play use the scene's display transform, just as a
377
430
  // photograph does. Solid keeps its studio transform. RNA also announces
378
431
  // colour settings that change without changing the presented geometry.
@@ -380,31 +433,62 @@ function BlenderModelViewport(props: ToolContributionProps) {
380
433
  if (!documentId) return;
381
434
  let cancelled = false;
382
435
  let revision = 0;
383
- 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
+ };
384
441
  const read = async () => {
385
442
  const mine = ++revision;
386
443
  const context = await blenderRnaContext();
387
444
  if (!context) return;
388
- 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
+ ]);
389
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
+ }
390
455
  const rows = settings.groups.flatMap(group => group.rows);
391
456
  const transform = rows.find(row => row.identifier === 'view_transform')?.value;
392
457
  const stops = rows.find(row => row.identifier === 'exposure')?.value;
393
- 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;
394
460
  const exposure = typeof stops === 'number' ? 2 ** stops : 1;
395
- if (applied?.mapper === mapper && applied.exposure === exposure) return;
396
- applied = { mapper, exposure };
397
- 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) {
398
475
  setViewPresentation(id, { modes: {
399
- rendered: { lighting: { tone: { mapper, exposure } } },
400
- preview: { lighting: { tone: { mapper, exposure } } },
476
+ rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
477
+ preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
401
478
  } });
402
479
  }
403
480
  };
404
481
  const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
405
482
  const stopRna = subscribeBlenderRna(update);
483
+ const stopSessions = subscribeObject3DDocumentSessions(attach);
406
484
  update();
407
- 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
+ };
408
492
  }, [documentId]);
409
493
  const split = useSyncExternalStore(
410
494
  subscribeAreaSplit,
@@ -438,30 +522,121 @@ function BlenderModelViewport(props: ToolContributionProps) {
438
522
  () => null,
439
523
  );
440
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
+ );
441
579
  useEffect(() => {
442
- 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; }
443
592
  const playId = `${documentId}#play`;
444
593
  // A game is seen as the render is: Rendered shading, chosen before the stage binds.
445
594
  setViewPresentation(playId, { drawMode: 'rendered' });
446
595
  const copy = view.detach();
447
- 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);
448
600
  setPlayed(copy);
449
- // The stage's mode is PLAY for as long as the copy stands, so the keys are the player's.
450
- view.setPlaying(true);
451
- 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
+ }
452
606
  return () => {
453
- 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
+ }
454
613
  copy.dispose();
614
+ for (const old of retired.current.splice(0)) old.dispose();
615
+ awaitingDraw.current = false;
616
+ setPlayed(null);
617
+ setRestartStill(null);
455
618
  view.setPlaying(false);
456
619
  notifyWorkspaceDocumentSelectionChanged(documentId);
457
620
  };
458
- }, [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(); }, []);
459
629
  // The document closing or going inactive ends its game; a game never outlives its stage.
460
630
  useEffect(() => {
461
631
  if (!documentId) return;
462
632
  return () => documentPlayExtension('model')?.setPlaying(documentId, false);
463
633
  }, [documentId]);
464
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;
465
640
  useEffect(() => {
466
641
  if (!game || !documentId) return;
467
642
  const modelViewport = documentViewport(documentId);
@@ -481,22 +656,46 @@ function BlenderModelViewport(props: ToolContributionProps) {
481
656
  // Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
482
657
  // its pose/session and repeats the second area's initial scene-camera setup.
483
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;
484
667
  return (
485
668
  <div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
486
- <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' }}>
487
670
  <BlenderViewportArea {...props} view={view} main />
488
671
  </div>
489
672
  {game && (
490
- <div key="play" style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
491
- <div data-volter-play-frame style={playAspect === null ? { position: 'absolute', inset: 0 } : {
492
- position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
493
- width: `min(100cqw, calc(100cqh * ${playAspect}))`,
494
- height: `min(100cqh, calc(100cqw / ${playAspect}))`,
495
- }}>
496
- <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)} />
497
679
  </div>
498
680
  </div>
499
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
+ )}
500
699
  {second && (
501
700
  <div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
502
701
  <BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
@@ -717,6 +916,7 @@ function BlenderViewportArea({
717
916
  camera: () => stage.rig().drawCamera(),
718
917
  editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
719
918
  onFrame: (fn) => stage.onFrame(fn),
919
+ ownMaterial: (material) => ownedSurfaceMaterial(material as THREE.Material),
720
920
  report: (title, detail) => {
721
921
  editorHost().console.error(`${title}: ${detail}`, 'blender-play');
722
922
  notifyRef.current?.({ tone: 'error', title, detail });
@@ -724,8 +924,17 @@ function BlenderViewportArea({
724
924
  }) ?? null;
725
925
  }, error => {
726
926
  if (stopped) return;
727
- editorHost().console.error(`Rendered stage preparation failed: ${String(error)}`, 'model-play');
728
- 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
+ }
729
938
  });
730
939
  };
731
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);