@volter/editor-blender 0.1.150 → 0.1.151

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.
Files changed (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +100 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +142 -332
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +43 -23
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +12 -32
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -43
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -1,53 +1,27 @@
1
- /**
2
- * THE MODEL DOCUMENT — Blender's, and Blender's alone (ARCHITECTURE-CORE §The
3
- * project model, "A model is Blender data, and a prefab is not a model";
4
- * WORK.md §Blender in the tab is Blender, "The mesh kit retires", M1).
5
- *
6
- * This module declares `documentKind = 'model'`, so the host opens every
7
- * `model` entry the project's table lists as its OWN document — titled by the
8
- * entry's label, one per entry — and mounts this with the entry as
9
- * `props.document`. A `model` entry names a `.blend`
10
- * (`models.finder.ts`), and opening the document OPENS THAT FILE IN THE
11
- * ENGINE: `session.py` opens the named file at start and saves back to it, so
12
- * this is the WS-F save path run backwards.
13
- *
14
- * bpy owns every mutation; this document displays them. A gesture is a bpy
15
- * call the engine records — there is no cage, no append-per-gesture and no
16
- * TypeScript geometry module. The bpy scripts that authored a `.blend` are
17
- * ordinary project files beside it (`src/models/<name>.py`); this document
18
- * does not run them, a person or an agent does, through the session's Blender
19
- * (`volter blender-mcp`).
20
- *
21
- * WITHOUT AN ENTRY it is still the document `blender-start` presents into: a
22
- * project that lists no `.blend` of its own gets one Model document at the
23
- * standing `blender:runtime` address (`models.finder.ts` lists it), and
24
- * the session opens its default `models/model.blend`.
25
- */
26
1
 
27
- // How the `model` stage this document builds behaves (its starting presentation).
2
+
28
3
  import './blender-properties-context';
29
- import { liveAnimation, setLiveAnimation } from '../src/play-live';
30
4
  import { blenderViewFieldOfView } from '../src/presentation';
31
5
  import {
6
+ type BlenderDrawOptions,
32
7
  type BlenderRuntimeView,
33
8
  blenderModelView,
34
9
  } from '@volter/blender-engine/browser/three/blender-runtime-view';
35
- import { ownedSurfaceMaterial } from '@volter/blender-engine/browser/three/blender-physical-material';
36
10
  import { createBlenderDisplayTransform, type BlenderDisplayTransform } from '@volter/blender-engine/browser/three/blender-display-transform';
37
- import type { ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
11
+ import type { DocumentWorkingCopy, ToolContributionProps, ToolDocumentToolbar } from '@volter/sdk/contributions';
38
12
  import { editorHost } from '@volter/sdk/host';
39
13
  import {
40
14
  documentViewport,
41
15
  documentViewportsVersion,
42
16
  subscribeDocumentViewports,
43
- } from '@volter/sdk/kit/document-viewports';
17
+ } from '@volter/sdk/document-viewports';
44
18
  import {
45
19
  DOCUMENT_STUDIO_PRESET,
46
20
  setViewPresentation,
47
21
  subscribeViewportPresentation,
48
22
  viewPresentation,
49
23
  viewPresentationSnapshot,
50
- } from '@volter/sdk/kit/viewport-presentation';
24
+ } from '@volter/sdk/viewport-presentation';
51
25
  import {
52
26
  type MouseEvent as ReactMouseEvent,
53
27
  type PointerEvent as ReactPointerEvent,
@@ -57,6 +31,7 @@ import {
57
31
  useLayoutEffect,
58
32
  useMemo,
59
33
  useRef,
34
+ type RefObject,
60
35
  useState,
61
36
  useSyncExternalStore,
62
37
  } from 'react';
@@ -64,46 +39,51 @@ import * as THREE from 'three';
64
39
  import {
65
40
  bindModelDocument,
66
41
  blenderExecute,
67
- blenderRna,
68
- blenderRnaContext,
69
42
  subscribeBlenderRna,
70
43
  blenderViewShading,
71
44
  openModelDocumentBlend,
72
45
  modelDocumentMayOpen,
73
46
  modelDocumentOwnsPresentation,
74
47
  blenderActionClip,
48
+ blenderSceneMovie,
75
49
  blenderRnaVersion,
50
+ blenderPresentDocument,
51
+ modelDocumentDirty,
52
+ restoreModelDocument,
53
+ revertModelDocument,
54
+ saveModelDocument,
55
+ snapshotModelDocument,
56
+ subscribeModelDocumentDirty,
76
57
  } from '../host/blender-runtime-host';
77
58
  import { BlenderObjectModeHeader } from './blender-header-menus';
78
59
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
79
60
  import { blenderSkin } from './blender-runtime-skin';
80
- import { playAnimation, type PlayAnimation, type PlayClipCache } from './blender-play-skin';
81
61
 
82
- /** Clip reads per model document, kept across Plays (`PlayClipCache`). */
83
- const playClipCaches = new Map<string, PlayClipCache>();
84
62
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
85
- import { noteModelDocument } from '../src/play-mode';
86
- import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
87
- import { notifyWorkspaceDocumentSelectionChanged } from '@volter/sdk/kit/workspace-document-registry';
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';
88
66
  import {
89
67
  documentSecondAreaHeader,
90
68
  setDocumentSecondArea,
91
69
  subscribeDocumentAreas,
92
- } from '@volter/sdk/kit/document-areas';
70
+ } from '@volter/sdk/document-areas';
93
71
  import { createPortal } from 'react-dom';
94
72
  import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
95
73
  import {
96
74
  object3DDocumentSession,
75
+ registerObject3DDocumentPreparation,
97
76
  subscribeObject3DDocumentSessions,
98
77
  } from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
99
78
  import type { ToolObject3DAuthoringProps } from '@volter/editor-threejs/object3d-contributions';
100
- import { stageStore, subscribeStageStores } from '@volter/sdk/kit/stage-store-registry';
101
- import { surfaceAcceptsKey } from '@volter/sdk/kit/surface-keyboard';
79
+ import { stageStore, subscribeStageStores } from '@volter/sdk/stage-store-registry';
80
+ import { surfaceAcceptsKey } from '@volter/sdk/surface-keyboard';
102
81
  import { BlenderModelOpening } from './blender-model-opening';
103
82
  import { modelOpeningErrorMessage } from '../src/model-opening-error';
104
- import { commandLine } from '@volter/sdk/kit/product-command';
105
- import { fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
106
- import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/kit/startup-failure';
83
+ import { commandLine } from '@volter/sdk/product-command';
84
+ import { Button, fontSizeVar, spaceVar, themeVars } from '@volter/sdk/widgets';
85
+ import { modelDocumentSaveNotice, subscribeModelDocumentSaving } from '../src/model-document-saving';
86
+ import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/startup-failure';
107
87
 
108
88
  /** This document's model open, as a source in the page's startup-failure registry. */
109
89
  const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
@@ -132,37 +112,28 @@ const overlaysOpened = new WeakSet<object>();
132
112
  export const point = 'workspace.document';
133
113
  export const title = 'Blender Model';
134
114
 
135
- /**
136
- * THE DOCUMENT'S HEADER IS BLENDER'S 3D VIEWPORT HEADER — Select, Add and
137
- * Object, the menu words `VIEW3D_MT_editor_menus` draws in Object Mode. The
138
- * rows, their order and what View's absence means are
139
- * `blender-header-menus.tsx`'s own header; this is the one line the host's
140
- * strip mounts (`ToolDocumentToolbar`, `DocumentHeaderStrip`).
141
- */
115
+ /** Object-mode menus mounted in the document toolbar. */
142
116
  export const Toolbar: ToolDocumentToolbar = ({ documentId, notify }) => (
143
117
  <BlenderObjectModeHeader documentId={documentId} notify={notify} />
144
118
  );
145
119
  /** The kind this document EDITS: every `model` table entry opens here. */
146
120
  export const documentKind = 'model';
147
- /**
148
- * ON A MODEL DOCUMENT THE PROPERTIES RAIL IS BLENDER'S, AND BLENDER'S ALONE
149
- * (owner ruling; WORK.md §Blender in the tab is Blender, "Inspection parity",
150
- * I2 decision 1).
151
- *
152
- * Blender's Properties editor draws the tabs `ED_buttons_tabs_list` returns
153
- * (`space_buttons/space_buttons.cc:201-255`) and nothing else. A rail that
154
- * carried the host's own Preview, Transform, generic Object, Geometry and
155
- * Materials blocks beside Blender's sixteen would not be Blender's rail — it
156
- * would be Blender's rail plus another editor's, and the second editor would
157
- * be showing three.js's reading of data Blender owns.
158
- *
159
- * Declaring this stands the host's sections down for THIS document only: the
160
- * composer keeps the sections contributed by this contribution's own package
161
- * (`@volter/editor-blender`) and nothing else (`inspection/compose.ts`,
162
- * `OwnedInspectorRail`). `inspectorBuiltins` is empty — there is no host block
163
- * a Blender tab does not already answer for, and the Preview in particular is
164
- * the document's own viewport, which is the whole centre of the screen.
165
- */
121
+
122
+ /** The Model is a workbench working copy: the workbench owns dirty state, Save, Revert, autosave and backup. Blender writes the file; the session’s delta route carries only changed chunks. */
123
+ export const workingCopy: DocumentWorkingCopy = {
124
+ dirty: (document) => document.source !== undefined && modelDocumentDirty(document.source.path),
125
+ subscribe: subscribeModelDocumentDirty,
126
+ save: (document) => saveModelDocument(blendOf(document)),
127
+ revert: (document) => revertModelDocument(blendOf(document)),
128
+ backup: (document) => snapshotModelDocument(blendOf(document)),
129
+ restore: (document, content) => restoreModelDocument(blendOf(document), content),
130
+ };
131
+
132
+ function blendOf(document: { readonly source?: { readonly path: string } }): string {
133
+ if (!document.source) throw new Error('A model document without a .blend has no working copy.');
134
+ return document.source.path;
135
+ }
136
+ /** The Model document owns its Properties rail. Compose only this package’s sections and no host inspector builtins; Blender’s tabs inspect the data while the central viewport supplies the preview. */
166
137
  export const inspectorRail = 'owned';
167
138
  export const inspectorBuiltins: readonly string[] = [];
168
139
 
@@ -244,6 +215,19 @@ function readsOf(view: AreaView): AreaReads {
244
215
  return reads;
245
216
  }
246
217
 
218
+ /** A canvas as a PNG data URL, encoded by `toBlob` (off the main thread); null when it cannot be. */
219
+ function pngDataUrl(canvas: HTMLCanvasElement): Promise<string | null> {
220
+ return new Promise((resolve) => {
221
+ canvas.toBlob((blob) => {
222
+ if (!blob) { resolve(null); return; }
223
+ const reader = new FileReader();
224
+ reader.onload = () => resolve(String(reader.result));
225
+ reader.onerror = () => resolve(null);
226
+ reader.readAsDataURL(blob);
227
+ }, 'image/png');
228
+ });
229
+ }
230
+
247
231
  export default function BlenderModelDocument(props: ToolContributionProps) {
248
232
  const { active, document, documentId, notify, publishContext } = props;
249
233
  const blend = document?.source?.path;
@@ -253,6 +237,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
253
237
  /** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
254
238
  const [stillOpening, setStillOpening] = useState<string | null>(null);
255
239
  const [attempt, setAttempt] = useState(0);
240
+ const saveNotice = useSyncExternalStore(subscribeModelDocumentSaving, () => modelDocumentSaveNotice(blend), () => null);
256
241
  const project = editorHost().projectLocalState.projectRootPath();
257
242
  const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
258
243
  const viewportVersion = useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
@@ -261,6 +246,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
261
246
  callbacks.current = { notify, publishContext };
262
247
 
263
248
  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]);
264
253
  // Retain a bounded photograph while the stage is healthy. Teardown can
265
254
  // already have changed its scene/camera, so it is too late to re-render then.
266
255
  useEffect(() => {
@@ -285,7 +274,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
285
274
  const context = copy.getContext('2d');
286
275
  if (context) {
287
276
  context.drawImage(frame, 0, 0, copy.width, copy.height);
288
- lastFrame.current = { project, entryId, path: blend ?? 'Model', image: copy.toDataURL('image/png'), capturedAt: performance.now() };
277
+ // Encode the PNG with toBlob off the main thread to keep Play frames responsive.
278
+ const image = await pngDataUrl(copy);
279
+ if (cancelled || !image) return;
280
+ lastFrame.current = { project, entryId, path: blend ?? 'Model', image, capturedAt: performance.now() };
289
281
  rememberModelDocumentPreview(lastFrame.current);
290
282
  }
291
283
  }
@@ -334,7 +326,8 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
334
326
  declined = false;
335
327
  try {
336
328
  if (binding) {
337
- if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
329
+ const reload = attempt > 0 && modelDocumentSaveNotice(binding.blend) !== null;
330
+ if (!await openModelDocumentBlend(binding, publish, reload)) { declined = true; return; }
338
331
  } else {
339
332
  // The standing Model document is the explicit blender-start target.
340
333
  publish();
@@ -365,10 +358,7 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
365
358
  starting = false;
366
359
  }
367
360
  };
368
- // A SLOW OPEN SAYS WHAT IT IS WAITING ON (2026-10-06 audit: "Opening model…" had no word
369
- // past its spinner, and a declined open sat there indefinitely). It is progress, never an
370
- // error, and it offers no Retry (#147 review): the open is still running, and a second one
371
- // would queue behind it. A real failure arrives through the catch above.
361
+ // A long open reports progress while its request runs. Retry is offered only for an actual failure, avoiding a second queued open.
372
362
  const slow = setInterval(() => {
373
363
  if (cancelled || finished) return;
374
364
  setStillOpening(declined
@@ -409,6 +399,17 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
409
399
  />;
410
400
  return <>
411
401
  <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>}
412
413
  {!documentViewport(documentId) && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
413
414
  preparingView preview={preview} retry={() => {}} returnToPreview={() => {}} />}
414
415
  </>;
@@ -422,79 +423,73 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
422
423
  * camera. Each area has its own stage, shading, navigation and camera view; the model, its
423
424
  * selection and its history are the document's.
424
425
  *
425
- * PLAYING (the composed Play tool), the area shows a DETACHED copy of the model
426
- * (`BlenderRuntimeView.detach`) on a stage of its own, which the project's play script moves
427
- * (the document Play extension). The model's own stage stays mounted underneath, hidden, so Stop
428
- * returns to the same view of the same model: nothing a game does reaches Blender.
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.
429
430
  */
430
431
  function BlenderModelViewport(props: ToolContributionProps) {
431
432
  const { documentId } = props;
432
433
  const blend = props.document?.source?.path;
433
434
  // THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`):
434
- // the Game panel drives this id's run, and Game or Movie is chosen by this file's play script.
435
+ // the Game panel drives this id's run, and Game or Animation is chosen by whether this level has a game.
435
436
  useEffect(() => (documentId ? noteModelDocument(documentId, blend) : undefined), [documentId, blend]);
436
- // Rendered areas and Play use the scene's display transform, just as a
437
- // photograph does. Solid keeps its studio transform. RNA also announces
438
- // colour settings that change without changing the presented geometry.
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.
439
438
  useEffect(() => {
440
439
  if (!documentId) return;
441
440
  let cancelled = false;
442
- let revision = 0;
443
- let applied: { key: string; transform: BlenderDisplayTransform } | null = null;
441
+ let applied: BlenderDisplayTransform | null = null;
442
+ let wanted: string | null = null;
443
+ let settled: Promise<void> = Promise.resolve();
444
444
  const ids = [documentId, `${documentId}#area-2`, `${documentId}#play`];
445
445
  const attach = () => {
446
- for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied?.transform ?? null);
446
+ for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied);
447
447
  };
448
- const read = async () => {
449
- const mine = ++revision;
450
- const context = await blenderRnaContext();
451
- if (!context) return;
452
- const [settings, displaySettings] = await Promise.all([
453
- blenderRna(`${context.scene}.view_settings`),
454
- blenderRna(`${context.scene}.display_settings`),
455
- ]);
456
- if (cancelled || mine !== revision || settings?.kind !== 'struct' || settings.type !== 'ColorManagedViewSettings') return;
457
- if (displaySettings?.kind === 'struct') {
458
- const device = displaySettings.groups.flatMap(group => group.rows).find(row => row.identifier === 'display_device')?.value;
459
- if (typeof device === 'string' && device !== 'sRGB')
460
- throw new Error(`Blender display device ${device} has no browser display processor yet`);
461
- }
462
- const rows = settings.groups.flatMap(group => group.rows);
463
- const transform = rows.find(row => row.identifier === 'view_transform')?.value;
464
- const stops = rows.find(row => row.identifier === 'exposure')?.value;
465
- const look = rows.find(row => row.identifier === 'look')?.value;
466
- const gamma = rows.find(row => row.identifier === 'gamma')?.value;
467
- const exposure = typeof stops === 'number' ? 2 ** stops : 1;
468
- const display = {
469
- transform: typeof transform === 'string' ? transform : 'AgX',
470
- look: typeof look === 'string' ? look : 'None',
471
- exposure, gamma: typeof gamma === 'number' ? gamma : 1,
472
- };
448
+ const follow = (): Promise<void> => {
449
+ const display = view.displaySettings();
450
+ if (!display) return settled;
473
451
  const key = JSON.stringify(display);
474
- if (applied?.key === key) return;
475
- const resolved = await createBlenderDisplayTransform(display);
476
- if (cancelled || mine !== revision) { resolved.dispose(); return; }
477
- const previous = applied;
478
- applied = { key, transform: resolved };
479
- attach();
480
- previous?.transform.dispose();
481
- for (const id of ids) {
482
- setViewPresentation(id, { modes: {
483
- rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
484
- preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
485
- } });
486
- }
452
+ if (key === wanted) return settled;
453
+ wanted = key;
454
+ settled = (async () => {
455
+ if (display.device !== 'sRGB')
456
+ throw new Error(`Blender display device ${display.device} has no browser display processor yet`);
457
+ const resolved = await createBlenderDisplayTransform(display);
458
+ if (cancelled || wanted !== key) { resolved.dispose(); return; }
459
+ const previous = applied;
460
+ applied = resolved;
461
+ attach();
462
+ previous?.dispose();
463
+ for (const id of ids) {
464
+ setViewPresentation(id, { modes: {
465
+ rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
466
+ preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
467
+ } });
468
+ }
469
+ })().catch((error: unknown) => editorHost().console.error(String(error), 'blender-colour'));
470
+ return settled;
487
471
  };
488
- const update = () => { void read().catch(error => editorHost().console.error(String(error), 'blender-colour')); };
489
- const stopRna = subscribeBlenderRna(update);
490
- const stopSessions = subscribeObject3DDocumentSessions(attach);
491
- update();
472
+ // A photograph of each shading type is anti-aliased as Blender's viewport accumulates it.
473
+ const sample = () => {
474
+ const sampling = view.photographSampling();
475
+ for (const id of ids) object3DDocumentSession(id)?.setPhotographSampling(sampling);
476
+ };
477
+ const stopFrames = view.subscribeFrames(() => { sample(); void follow(); });
478
+ const stopSessions = subscribeObject3DDocumentSessions(() => { attach(); sample(); });
479
+ sample();
480
+ // A view presented on this document is drawn (and photographed) once the frame on screen has
481
+ // its display transform and everything it is still decoding or settling (`drawReady`).
482
+ const stopPreparation = registerObject3DDocumentPreparation(documentId, async () => {
483
+ await follow();
484
+ await view.drawReady();
485
+ });
486
+ void follow();
492
487
  return () => {
493
- cancelled = true; stopRna(); stopSessions();
488
+ cancelled = true; stopFrames(); stopSessions(); stopPreparation();
494
489
  const previous = applied;
495
490
  applied = null;
496
491
  attach();
497
- previous?.transform.dispose();
492
+ previous?.dispose();
498
493
  };
499
494
  }, [documentId]);
500
495
  const split = useSyncExternalStore(
@@ -518,17 +513,26 @@ function BlenderModelViewport(props: ToolContributionProps) {
518
513
  };
519
514
  }, [split, documentId]);
520
515
  const playing = useSyncExternalStore(
521
- subscribeDocumentPlayExtensions,
522
- () => (documentId ? documentPlayExtension('model')?.playing(documentId) ?? false : false),
516
+ subscribeModelPlayExtension,
517
+ () => (documentId ? modelPlayExtension()?.playing(documentId) ?? false : false),
523
518
  () => false,
524
519
  );
525
520
  const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
526
521
  const playAspect = useSyncExternalStore(
527
- subscribeDocumentPlayExtensions,
528
- () => (documentId ? documentPlayExtension('model')?.aspectRatio?.(documentId) ?? null : null),
522
+ subscribeModelPlayExtension,
523
+ () => (documentId ? modelPlayExtension()?.aspectRatio?.(documentId) ?? null : null),
529
524
  () => null,
530
525
  );
531
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);
532
536
  /**
533
537
  * RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
534
538
  * below is detached per generation, so a restart disposes the played copy and detaches a fresh
@@ -540,12 +544,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
540
544
  * therefore taken in two steps: a layout effect photographs the old play stage while it still
541
545
  * stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
542
546
  * detaches the new copy. The photograph covers the play area until the new runner's first
543
- * frame (`onPlayReady`), the model area stays hidden throughout, and the old runner, no longer
544
- * the current generation, stands still meanwhile (`play-script.ts`).
547
+ * frame (`onPlayReady`), and the model area stays hidden throughout.
545
548
  */
546
549
  const generation = useSyncExternalStore(
547
- subscribeDocumentPlayExtensions,
548
- () => (documentId ? documentPlayExtension('model')?.transport?.generation(documentId) ?? 0 : 0),
550
+ subscribeModelPlayExtension,
551
+ () => (documentId ? modelPlayExtension()?.transport?.generation(documentId) ?? 0 : 0),
549
552
  () => 0,
550
553
  );
551
554
  const [shownGeneration, setShownGeneration] = useState(generation);
@@ -573,7 +576,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
573
576
  * update), so the cover would stand over it while the panel said Playing. The runner says so
574
577
  * on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
575
578
  */
576
- const transport = documentPlayExtension('model')?.transport;
579
+ const transport = modelPlayExtension()?.transport;
577
580
  const subscribeFailure = useCallback(
578
581
  (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
579
582
  [transport],
@@ -599,6 +602,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
599
602
  const playId = `${documentId}#play`;
600
603
  // A game is seen as the render is: Rendered shading, chosen before the stage binds.
601
604
  setViewPresentation(playId, { drawMode: 'rendered' });
605
+ // The game brings its own objects: the copy is the World alone.
602
606
  const copy = view.detach();
603
607
  const restart = restarting.current;
604
608
  restarting.current = false;
@@ -613,17 +617,20 @@ function BlenderModelViewport(props: ToolContributionProps) {
613
617
  return () => {
614
618
  // Read at cleanup: the layout effect above has already marked a restart, whose new copy
615
619
  // takes over in this same commit with the play mode unchanged.
616
- if (restarting.current && documentPlayExtension('model')?.playing(documentId)) {
620
+ if (restarting.current && modelPlayExtension()?.playing(documentId)) {
617
621
  retired.current.push(copy);
618
622
  return;
619
623
  }
620
624
  copy.dispose();
621
625
  for (const old of retired.current.splice(0)) old.dispose();
622
626
  awaitingDraw.current = false;
627
+ setEditArea(null);
623
628
  setPlayed(null);
624
629
  setRestartStill(null);
625
630
  view.setPlaying(false);
626
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(() => {});
627
634
  };
628
635
  }, [playing, documentId, shownGeneration]);
629
636
  useEffect(() => {
@@ -636,7 +643,7 @@ function BlenderModelViewport(props: ToolContributionProps) {
636
643
  // The document closing or going inactive ends its game; a game never outlives its stage.
637
644
  useEffect(() => {
638
645
  if (!documentId) return;
639
- return () => documentPlayExtension('model')?.setPlaying(documentId, false);
646
+ return () => modelPlayExtension()?.setPlaying(documentId, false);
640
647
  }, [documentId]);
641
648
  const game = playing ? played : null;
642
649
  const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
@@ -650,7 +657,8 @@ function BlenderModelViewport(props: ToolContributionProps) {
650
657
  if (!modelViewport) return;
651
658
  // registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
652
659
  const capture = modelViewport.capture;
653
- const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
660
+ const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) =>
661
+ documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
654
662
  modelViewport.capture = playCapture;
655
663
  return () => {
656
664
  if (modelViewport.capture !== playCapture) return;
@@ -658,22 +666,23 @@ function BlenderModelViewport(props: ToolContributionProps) {
658
666
  else delete modelViewport.capture;
659
667
  };
660
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]);
661
671
  if (!documentId) return null;
662
672
  const second = split ? follower : null;
663
673
  // Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
664
674
  // its pose/session and repeats the second area's initial scene-camera setup.
665
675
  const area = { position: 'relative', flex: '1 1 0', minWidth: 0, margin: 12 } as const;
666
- // THE HIDDEN MODEL AREA IS INERT, not merely `pointer-events: none`. It lies OVER the play
667
- // area (z-index 2, faded out once the game has drawn), and the stage inside it sets its own
668
- // `pointer-events: auto` (`StageHost`'s viewport box), which a parent's `none` does not reach:
669
- // a real click on the game hit the invisible editing canvas, never the play surface, so the
670
- // runner's takeover (`play-script.ts`, which asks whether the press landed in the game) never
671
- // saw it and the game never got it — while keys, read off the window, took over fine. `inert`
672
- // takes the whole subtree out of hit testing and focus.
673
- const hidden = game !== null && playedReady;
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
+ };
674
683
  return (
675
684
  <div style={game || second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
676
- <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' }}>
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' }}>
677
686
  <BlenderViewportArea {...props} view={view} main />
678
687
  </div>
679
688
  {game && (
@@ -682,15 +691,14 @@ function BlenderModelViewport(props: ToolContributionProps) {
682
691
  <div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
683
692
  <div data-volter-play-frame style={playFrame}>
684
693
  <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
685
- onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }} onPlayReturn={() => setPlayedReady(false)} />
694
+ onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }}
695
+ onPlayEdit={setEditArea} playOverlay={playOverlay} />
686
696
  </div>
687
697
  </div>
688
698
  )}
699
+ {game && <div key="play-overlay" ref={playOverlay} data-testid="blender-play-overlay" style={{ position: 'absolute', inset: 12, zIndex: 5, pointerEvents: 'none' }} />}
689
700
  {game && failure !== null && (
690
- // A PLAY WITH NO GAME, said over the area itself. When the script did not start (or the
691
- // stage could not be prepared) the game never draws and the model area stays in front,
692
- // so the area looked like plain editing while Play was on and the reason was a toast
693
- // that had already gone (2026-10-06 audit). The Game panel says the same in its line.
701
+ // Display a failed Play start over the area until it draws; the Game panel repeats the same refusal.
694
702
  <div key="play-failure" role="alert" data-testid="blender-play-failure"
695
703
  style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
696
704
  background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
@@ -713,17 +721,11 @@ function BlenderModelViewport(props: ToolContributionProps) {
713
721
  );
714
722
  }
715
723
 
716
- /**
717
- * THE SECOND AREA'S OWN HEADER, as each of Blender's areas has one: the editor menus (their
718
- * View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading and helpers, the
719
- * header controls its own stage registered, so its shading cells change this area and not the
720
- * other.
721
- */
722
724
  /**
723
725
  * THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
724
726
  *
725
727
  * The area is declared to the host as the document's second area
726
- * (`@volter/sdk/kit/document-areas`). Its HEADER stands in the document's header row over
728
+ * (`@volter/sdk/document-areas`). Its HEADER stands in the document's header row over
727
729
  * this area: the host reserves this area's width at the row's trailing edge, so the first area's
728
730
  * header ends where the first area does, and this renders into the region it draws there: the
729
731
  * editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
@@ -789,17 +791,19 @@ function BlenderViewportArea({
789
791
  main,
790
792
  playing = false,
791
793
  onPlayReady,
792
- onPlayReturn,
793
- }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
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> }) {
794
798
  const reads = readsOf(view);
795
799
  const build = useCallback(
796
800
  () => ({
797
801
  root: view.root,
798
- prepareDraw: (camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}) => view.prepareDraw(camera, options),
802
+ prepareDraw: (camera: THREE.Camera, options?: BlenderDrawOptions) => view.prepareDraw(camera, options),
799
803
  // What the view draws changes with each frame and the work after it,
800
- // and with the skin's poses when the Timeline scrubs. A PLAYING copy announces nothing:
801
- // its script moves it every frame, and a source that announces nothing is drawn every
802
- // frame (`ToolObject3DPreviewSource.onChange`).
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.
803
807
  ...(playing
804
808
  ? {}
805
809
  : {
@@ -872,9 +876,8 @@ function BlenderViewportArea({
872
876
  };
873
877
  }, [main, playing, documentId, view]);
874
878
  /**
875
- * THE PLAYING COPY IS MOVED BY THE PROJECT'S PLAY SCRIPT (the document Play extension), on this
876
- * stage's own frame hook, once the stage stands. The stage's orbit stands down while it runs,
877
- * so the script's camera is not argued with, and Escape stops the game.
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.
878
881
  */
879
882
  const notifyRef = useRef(notify);
880
883
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -882,15 +885,15 @@ function BlenderViewportArea({
882
885
  notifyRef.current = notify;
883
886
  const playReadyRef = useRef(onPlayReady);
884
887
  playReadyRef.current = onPlayReady;
885
- const playReturnRef = useRef(onPlayReturn);
886
- playReturnRef.current = onPlayReturn;
888
+ const playEditRef = useRef(onPlayEdit);
889
+ playEditRef.current = onPlayEdit;
887
890
  useEffect(() => {
888
891
  if (!playing || !documentId) return;
889
892
  if (blend === undefined) {
890
893
  notifyRef.current?.({
891
894
  tone: 'error',
892
- title: 'This model has no play script',
893
- detail: 'A play script stands beside a model\'s .blend file, and this model has no file of its own.',
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.',
894
897
  });
895
898
  return;
896
899
  }
@@ -901,116 +904,26 @@ function BlenderViewportArea({
901
904
  surfaceRef.current?.appendChild(layers);
902
905
  setPlayReady(false);
903
906
  let stopped = false;
904
- let preparing = false;
905
907
  let stopScript: (() => void) | null = null;
906
- let orbit: { enabled: boolean } | null = null;
907
- let animation: PlayAnimation | null = null;
908
+ let hiddenCopy: boolean | null = null;
908
909
  const start = (): void => {
909
- if (stopScript || preparing || stopped) return;
910
+ if (stopScript || stopped) return;
910
911
  const stage = viewportStages().find((one) => one.documentId === documentId);
911
912
  if (!stage || !view.root.parent) return;
912
- preparing = true;
913
- orbit = stage.rig().orbit;
914
- orbit.enabled = false;
915
- // THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
916
- // armature plays the action the file assigns it until the game sets another
917
- // (`blender-play-skin.ts`). Nothing is read before the game starts.
918
- // A LOADING PHASE, said on screen: the game starts only once its stage is drawn and every
919
- // character's starting clips are baked (`PlayAnimation.prepare`). Started before them, a
920
- // character whose clip Blender had not answered yet stood frozen in its rest pose, and a
921
- // clip whose read failed stayed frozen for the whole run. That is the suspected cause of
922
- // "sometimes none of the animations work" (a busy Blender was measured to delay the clips).
923
- // THE WAIT IS BOUNDED AND SAID: after LOADING_SAY the overlay names the clips still
924
- // awaited; at LOADING_LIMIT the game starts without them and the console names them (each
925
- // still plays once Blender answers). A thrown `prepare` is named too; the game always starts.
926
- const loading = window.document.createElement('div');
927
- loading.dataset['testid'] = 'model-play-loading';
928
- loading.textContent = 'Loading…';
929
- Object.assign(loading.style, {
930
- position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center',
931
- background: 'rgba(0, 0, 0, 0.35)', color: '#e6e6e6', font: '600 14px system-ui, sans-serif', pointerEvents: 'none',
932
- });
933
- layers.appendChild(loading);
934
- void view.prepareRendered(stage.rig().drawCamera(), 'render').then(async () => {
935
- if (stopped) return;
936
- // No session answers null: a read that failed, asked again, not a clip that animates nothing.
937
- // A SETUP THAT THROWS still starts the game, without animation and said so; left
938
- // outside any catch it left the loading overlay up and the script never ran.
939
- let cache = playClipCaches.get(modelId);
940
- if (!cache) playClipCaches.set(modelId, cache = new Map());
941
- let loaded: PlayAnimation | null = null;
942
- try {
943
- loaded = playAnimation(view, async (armature, action) => {
944
- const clip = await blenderActionClip({ object: armature, action, summary: false });
945
- if (clip === null) throw new Error("Blender's session is not started");
946
- return clip;
947
- }, cache);
948
- } catch (error) {
949
- editorHost().console.error(`The game's animation could not be set up, so the game starts without it: ${error instanceof Error ? error.message : String(error)}`, 'blender-animation');
950
- }
951
- animation = loaded;
952
- if (!loaded) {
953
- loading.remove();
954
- if (stopped) return;
955
- }
956
- if (loaded) {
957
- const anim = loaded;
958
- const LOADING_SAY = 3_000, LOADING_LIMIT = 20_000;
959
- const clips = (names: readonly string[]): string => `${names.length} clip${names.length === 1 ? '' : 's'}`;
960
- const say = window.setInterval(() => {
961
- const pending = anim.pending();
962
- if (pending.length > 0) loading.textContent = `Loading… waiting on Blender for ${clips(pending)}: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ` and ${pending.length - 6} more` : ''}`;
963
- }, LOADING_SAY);
964
- let limit = 0;
965
- const outcome = await Promise.race([
966
- anim.prepare().then(({ failed }) => ({ failed, waiting: [] as readonly string[] }), (error: unknown) => ({ failed: [`the loading phase failed: ${error instanceof Error ? error.message : String(error)}`], waiting: [] as readonly string[] })),
967
- new Promise<{ failed: readonly string[]; waiting: readonly string[] }>((resolve) => {
968
- limit = window.setTimeout(() => resolve({ failed: [], waiting: anim.pending() }), LOADING_LIMIT);
969
- }),
970
- ]);
971
- window.clearInterval(say);
972
- window.clearTimeout(limit);
973
- loading.remove();
974
- if (stopped) return;
975
- if (outcome.failed.length > 0)
976
- editorHost().console.warn(`The game started without ${clips(outcome.failed)} it starts on: ${outcome.failed.join('; ')}`, 'blender-animation');
977
- if (outcome.waiting.length > 0)
978
- editorHost().console.warn(`The game started after ${LOADING_LIMIT / 1000} s still waiting on Blender for ${clips(outcome.waiting)}; each plays once Blender answers: ${outcome.waiting.join(', ')}`, 'blender-animation');
979
- anim.prefetch();
980
- }
981
- setLiveAnimation(animation);
982
- stopScript = documentPlayExtension('model')?.run({
983
- ...(animation ? { animation } : {}),
984
- documentId: modelId,
985
- container: layers,
986
- ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
987
- returning: () => { if (!stopped) playReturnRef.current?.(); },
988
- sourcePath: blend,
989
- root: view.root,
990
- camera: () => stage.rig().drawCamera(),
991
- editingCamera: () => viewportStages().find(one => one.documentId === modelId)?.rig().drawCamera() ?? stage.rig().drawCamera(),
992
- onFrame: (fn) => stage.onFrame(fn),
993
- ownMaterial: (material) => ownedSurfaceMaterial(material as THREE.Material),
994
- report: (title, detail) => {
995
- editorHost().console.error(`${title}: ${detail}`, 'blender-play');
996
- notifyRef.current?.({ tone: 'error', title, detail });
997
- },
998
- }) ?? null;
999
- }, error => {
1000
- loading.remove();
1001
- if (stopped) return;
1002
- const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
1003
- editorHost().console.error(failure, 'model-play');
1004
- // SAID WHERE THE PERSON IS LOOKING: on the run's clock, which the Game panel and the
1005
- // play area draw, and in the play log. Turning Play off (what this did until
1006
- // 2026-10-06) erased the only trace a person could have seen.
1007
- const extension = documentPlayExtension('model');
1008
- if (extension?.transport?.fail) extension.transport.fail(modelId, blend, failure);
1009
- else {
1010
- notifyRef.current?.({ tone: 'error', title: 'Play could not start', detail: failure });
1011
- extension?.setPlaying(modelId, false);
1012
- }
1013
- });
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;
1014
927
  };
1015
928
  start();
1016
929
  let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
@@ -1027,14 +940,13 @@ function BlenderViewportArea({
1027
940
  const stopStages = onViewportStages(waitForDraw);
1028
941
  const onEscape = (event: KeyboardEvent): void => {
1029
942
  if (event.key !== 'Escape' || !surfaceAcceptsKey(event)) return;
1030
- // Decided once the event has finished dispatching: a play script that claims Escape (a
943
+ // Decided once the event has finished dispatching: a game that claims Escape (a
1031
944
  // game's pause, registered after this listener) prevents its default, and the Escape that
1032
- // frees a game's mouse look is the person taking their mouse back. Both used to stop Play
1033
- // (three playtests of a Cyclotron game lost a mission each to it).
945
+ // frees a game's mouse look is the person taking their mouse back. Neither stops Play.
1034
946
  if (globalThis.document.pointerLockElement || performance.now() - lastPointerUnlockAt < 500) return;
1035
947
  setTimeout(() => {
1036
948
  if (event.defaultPrevented) return;
1037
- const extension = documentPlayExtension('model');
949
+ const extension = modelPlayExtension();
1038
950
  if (extension?.escape) extension.escape(modelId);
1039
951
  else extension?.setPlaying(modelId, false);
1040
952
  }, 0);
@@ -1050,12 +962,8 @@ function BlenderViewportArea({
1050
962
  stopPrepareFrames?.();
1051
963
  window.removeEventListener('keydown', onEscape, true);
1052
964
  stopScript?.();
1053
- // Only this run's animation is cleared: a restart's new run may already hold the slot.
1054
- if (liveAnimation() === animation) setLiveAnimation(null);
1055
- animation?.dispose();
1056
- animation = null;
965
+ if (hiddenCopy !== null) { view.root.visible = hiddenCopy; hiddenCopy = null; }
1057
966
  layers.remove();
1058
- if (orbit) orbit.enabled = true;
1059
967
  };
1060
968
  }, [playing, documentId, blend, view]);
1061
969
  /**
@@ -1095,20 +1003,7 @@ function BlenderViewportArea({
1095
1003
  opened();
1096
1004
  return subscribeStageStores(opened);
1097
1005
  }, [main, documentId]);
1098
- /**
1099
- * THE INSPECTION OVERLAYS ARE HELPERS, and the Helpers menu owns them
1100
- * (WORK.md §Blender in the tab is Blender, "Inspection parity", I4).
1101
- *
1102
- * Blender's viewport overlay has a Bones checkbox (`View3DOverlay.
1103
- * show_bones`, `rna_space.cc:5125-5129`, drawn by `space_view3d.py:7161`)
1104
- * and this editor's Helpers menu already has a Skeletons row; a weight
1105
- * display had no member and gained one (`weights`). Handing each group to
1106
- * the stage through `setHelper` is the whole wiring: the host marks it
1107
- * editor-owned — out of the hierarchy, out of the raycast — sets its
1108
- * visibility from that kind's checkbox on the spot, keeps it across the
1109
- * stage's remounts, and toggles it afterwards. Nothing here reads or
1110
- * mirrors the toggle's state, which is why there is no second copy of it.
1111
- */
1006
+ /** Register bone and weight overlays as helpers. The host owns their visibility, excludes them from hierarchy and raycast, and restores them across stage remounts. */
1112
1007
  useEffect(() => {
1113
1008
  if (!documentId) return;
1114
1009
  const groups = view.overlayGroups();
@@ -1135,8 +1030,9 @@ function BlenderViewportArea({
1135
1030
  if (!documentId || !main) return;
1136
1031
  let live = true;
1137
1032
  void blenderSkin.sync(view, {
1138
- clip: (armature) => blenderActionClip({ object: armature }),
1033
+ clip: (armature) => blenderActionClip(armature === null ? {} : { object: armature }),
1139
1034
  bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
1035
+ movie: () => blenderSceneMovie(),
1140
1036
  }).catch((thrown: unknown) => {
1141
1037
  if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1142
1038
  });
@@ -1316,16 +1212,7 @@ function BlenderViewportArea({
1316
1212
  cameraView={reads.cameraView}
1317
1213
  build={build}
1318
1214
  audit={false}
1319
- // ON A MODEL DOCUMENT THE HIERARCHY IS BLENDER'S OUTLINER, for the same
1320
- // reason the Properties rail is Blender's rail: the rows this panel drew
1321
- // were the PRESENTER's three.js graph — a reading of Blender's data by
1322
- // the thing that photographs it — where Blender's own Outliner shows the
1323
- // VIEW LAYER's datablock tree. `blender-outliner-authoring.ts` is the
1324
- // provider; the default adapter it delegates to still answers everything
1325
- // about the presentation (WORK.md §Blender in the tab is Blender,
1326
- // "Inspection parity", I3).
1327
- // A PLAYING COPY HAS NO AUTHORING: it is read-only to the editor, and what moves it is its
1328
- // script. With none given the stage is the native read-only one.
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.
1329
1216
  {...(playing ? {} : { authoring })}
1330
1217
  cameraDirection={[0.8187, 0.4458, 0.3617]}
1331
1218
  // FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
@@ -1342,33 +1229,24 @@ function BlenderViewportArea({
1342
1229
  const saved = view.savedView();
1343
1230
  if (!saved) return null;
1344
1231
  const camera = { fov: blenderViewFieldOfView(saved.lens) };
1232
+ // Solid and Wireframe use the saved view's resolved theme, World or viewport colour.
1233
+ const behind = { backdrop: { source: 'color' as const, color: saved.background } };
1345
1234
  return main && saved.drawMode ? {
1346
1235
  camera, drawMode: saved.drawMode,
1347
- modes: { preview: {
1348
- lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
1349
- backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
1350
- } },
1351
- } : { camera };
1236
+ modes: {
1237
+ solid: behind,
1238
+ wireframe: behind,
1239
+ preview: {
1240
+ lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
1241
+ backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
1242
+ },
1243
+ },
1244
+ } : { camera, modes: { solid: behind, wireframe: behind } };
1352
1245
  })()}
1353
1246
  // Every entry this document opens is a `model` stage, the standing `blender:runtime`
1354
1247
  // address included (its id carries no `model:` prefix).
1355
1248
  stageKind={documentKind}
1356
- // SOLID SHADING IS BLENDER'S, and Blender's Solid mode has NO world
1357
- // light: `light_ambient` is (0,0,0), there is no IBL, and the model is
1358
- // lit by four studio lights stated in VIEW space. So this document
1359
- // turns the editor's studio dressing off — its RoomEnvironment IBL and
1360
- // its one warm key, which between them were the blow-out row 1
1361
- // measured (the factory cube read (240,240,239)/(231,231,231)/
1362
- // (227,226,225) against Blender's (141,143,145)/(129,131,131)/
1363
- // (111,112,113), a spread of 13 against 30) — and hands the stage
1364
- // Blender's own four instead. `keyLight: false` also stands down the
1365
- // editor's design-time light rig, which answers the same question.
1366
- // The BACKGROUND stays the dressing's, because the Blender palette
1367
- // already paints the viewport its own flat grey.
1368
- //
1369
- // THE VIEW TRANSFORM IS EACH SHADING MODE'S OWN (`src/presentation.ts`: Standard for Solid,
1370
- // AgX for Material Preview and Rendered), so the dressing states none: a document mapper
1371
- // would outrank every mode's.
1249
+ // Solid shading uses Blender’s four view-space studio lights without IBL or an editor key light. Keep the palette background; each shading mode owns its own view transform.
1372
1250
  dressing={{
1373
1251
  environment: false,
1374
1252
  keyLight: false,