@volter/editor-blender 0.1.150 → 0.1.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  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 +99 -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 +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  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 +145 -333
  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 +45 -31
  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 +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  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 +3 -30
  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 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -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 -48
  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/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  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,
@@ -63,52 +37,53 @@ import {
63
37
  import * as THREE from 'three';
64
38
  import {
65
39
  bindModelDocument,
40
+ blenderStarting,
41
+ subscribeBlenderStarting,
66
42
  blenderExecute,
67
- blenderRna,
68
- blenderRnaContext,
69
43
  subscribeBlenderRna,
70
44
  blenderViewShading,
71
45
  openModelDocumentBlend,
72
46
  modelDocumentMayOpen,
73
47
  modelDocumentOwnsPresentation,
74
48
  blenderActionClip,
49
+ blenderSceneMovie,
75
50
  blenderRnaVersion,
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
63
  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';
88
64
  import {
89
65
  documentSecondAreaHeader,
90
66
  setDocumentSecondArea,
91
67
  subscribeDocumentAreas,
92
- } from '@volter/sdk/kit/document-areas';
68
+ } from '@volter/sdk/document-areas';
93
69
  import { createPortal } from 'react-dom';
94
70
  import { onViewportStages, viewportStages } from '@volter/editor-threejs/viewport-door';
95
71
  import {
96
72
  object3DDocumentSession,
73
+ registerObject3DDocumentPreparation,
97
74
  subscribeObject3DDocumentSessions,
98
75
  } from '@volter/editor-threejs/kit/authoring/object3d-document-session-registry';
99
76
  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';
77
+ import { stageStore, subscribeStageStores } from '@volter/sdk/stage-store-registry';
102
78
  import { BlenderModelOpening } from './blender-model-opening';
103
79
  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';
80
+ import { Button, EditorBanner } from '@volter/sdk/widgets';
81
+ import { useProgress } from '@volter/sdk/progress';
82
+ import { modelDocumentSaveNotice, subscribeModelDocumentSaving } from '../src/model-document-saving';
83
+ import { clearStartupFailure, reportStartupFailure } from '@volter/sdk/startup-failure';
107
84
 
108
85
  /** This document's model open, as a source in the page's startup-failure registry. */
109
86
  const MODEL_OPEN_STARTUP_SOURCE = 'blender-model-open';
110
- /** How long "Opening model…" stands before the pane also says what it is waiting on. */
111
- const MODEL_OPEN_NOTE_AFTER_MS = 30_000;
112
87
  import {
113
88
  clearModelDocumentPreview,
114
89
  modelDocumentPreview,
@@ -117,52 +92,34 @@ import {
117
92
  type ModelDocumentPreview,
118
93
  } from '../src/model-document-preview';
119
94
 
120
- /** A detached copy's identity as a React key (the play area is keyed on the copy it draws). */
121
- const playCopyKeys = new WeakMap<object, number>();
122
- let nextPlayCopyKey = 0;
123
- function playCopyKey(copy: object): number {
124
- let key = playCopyKeys.get(copy);
125
- if (key === undefined) { key = ++nextPlayCopyKey; playCopyKeys.set(copy, key); }
126
- return key;
127
- }
128
-
129
95
  /** The second areas' stage stores whose overlays have been opened off (`BlenderViewportArea`). */
130
96
  const overlaysOpened = new WeakSet<object>();
131
97
 
132
98
  export const point = 'workspace.document';
133
99
  export const title = 'Blender Model';
134
100
 
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
- */
101
+ /** Object-mode menus mounted in the document toolbar. */
142
102
  export const Toolbar: ToolDocumentToolbar = ({ documentId, notify }) => (
143
103
  <BlenderObjectModeHeader documentId={documentId} notify={notify} />
144
104
  );
145
105
  /** The kind this document EDITS: every `model` table entry opens here. */
146
106
  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
- */
107
+
108
+ /** 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. */
109
+ export const workingCopy: DocumentWorkingCopy = {
110
+ dirty: (document) => document.source !== undefined && modelDocumentDirty(document.source.path),
111
+ subscribe: subscribeModelDocumentDirty,
112
+ save: (document) => saveModelDocument(blendOf(document)),
113
+ revert: (document) => revertModelDocument(blendOf(document)),
114
+ backup: (document) => snapshotModelDocument(blendOf(document)),
115
+ restore: (document, content) => restoreModelDocument(blendOf(document), content),
116
+ };
117
+
118
+ function blendOf(document: { readonly source?: { readonly path: string } }): string {
119
+ if (!document.source) throw new Error('A model document without a .blend has no working copy.');
120
+ return document.source.path;
121
+ }
122
+ /** 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
123
  export const inspectorRail = 'owned';
167
124
  export const inspectorBuiltins: readonly string[] = [];
168
125
 
@@ -244,15 +201,30 @@ function readsOf(view: AreaView): AreaReads {
244
201
  return reads;
245
202
  }
246
203
 
204
+ /** A canvas as a PNG data URL, encoded by `toBlob` (off the main thread); null when it cannot be. */
205
+ function pngDataUrl(canvas: HTMLCanvasElement): Promise<string | null> {
206
+ return new Promise((resolve) => {
207
+ canvas.toBlob((blob) => {
208
+ if (!blob) { resolve(null); return; }
209
+ const reader = new FileReader();
210
+ reader.onload = () => resolve(String(reader.result));
211
+ reader.onerror = () => resolve(null);
212
+ reader.readAsDataURL(blob);
213
+ }, 'image/png');
214
+ });
215
+ }
216
+
247
217
  export default function BlenderModelDocument(props: ToolContributionProps) {
248
218
  const { active, document, documentId, notify, publishContext } = props;
249
219
  const blend = document?.source?.path;
250
220
  const entryId = document?.id;
251
221
  const key = JSON.stringify([documentId, entryId, blend]);
252
222
  const [opened, setOpened] = useState<{ key: string; error: string | null } | null>(null);
253
- /** What a slow open is waiting on, once it has been slow (`MODEL_OPEN_NOTE_AFTER_MS`). */
254
- const [stillOpening, setStillOpening] = useState<string | null>(null);
255
223
  const [attempt, setAttempt] = useState(0);
224
+ /** Open the model again, as often as it is asked: an error's Retry, the open's wait's Retry, or a reopen. */
225
+ const retry = useCallback(() => { setOpened(null); setAttempt(value => value + 1); }, []);
226
+ const blenderStart = useSyncExternalStore(subscribeBlenderStarting, blenderStarting, () => false);
227
+ const saveNotice = useSyncExternalStore(subscribeModelDocumentSaving, () => modelDocumentSaveNotice(blend), () => null);
256
228
  const project = editorHost().projectLocalState.projectRootPath();
257
229
  const preview = useSyncExternalStore(subscribeModelDocumentPreview, () => modelDocumentPreview(project), () => null);
258
230
  const viewportVersion = useSyncExternalStore(subscribeDocumentViewports, documentViewportsVersion, documentViewportsVersion);
@@ -285,7 +257,10 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
285
257
  const context = copy.getContext('2d');
286
258
  if (context) {
287
259
  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() };
260
+ // Encode the PNG with toBlob off the main thread to keep frames responsive.
261
+ const image = await pngDataUrl(copy);
262
+ if (cancelled || !image) return;
263
+ lastFrame.current = { project, entryId, path: blend ?? 'Model', image, capturedAt: performance.now() };
289
264
  rememberModelDocumentPreview(lastFrame.current);
290
265
  }
291
266
  }
@@ -317,24 +292,22 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
317
292
  const unbind = bindModelDocument(binding);
318
293
  const publish = () => { unpublish = callbacks.current.publishContext?.(view); };
319
294
  setOpened(null);
320
- setStillOpening(null);
321
295
  // Native focus can arrive after the contributed pane mounts. A declined
322
296
  // open is not an in-flight load: retry when the host activates this model,
323
- // rather than leaving "Opening model…" latched forever. Utility focus
297
+ // rather than leaving the open's wait latched forever. Utility focus
324
298
  // after a successful open must not tear down the model's published view.
325
299
  const documents = editorHost().documents;
326
300
  let starting = false;
327
301
  let finished = false;
328
- let declined = false;
329
302
  const path = blend ?? 'the model';
330
303
  const open = async () => {
331
304
  if (cancelled || starting || finished) return;
332
- if (binding && !modelDocumentMayOpen(binding)) { declined = true; return; }
305
+ if (binding && !modelDocumentMayOpen(binding)) return;
333
306
  starting = true;
334
- declined = false;
335
307
  try {
336
308
  if (binding) {
337
- if (!await openModelDocumentBlend(binding, publish)) { declined = true; return; }
309
+ const reload = attempt > 0 && modelDocumentSaveNotice(binding.blend) !== null;
310
+ if (!await openModelDocumentBlend(binding, publish, reload)) return;
338
311
  } else {
339
312
  // The standing Model document is the explicit blender-start target.
340
313
  publish();
@@ -365,16 +338,6 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
365
338
  starting = false;
366
339
  }
367
340
  };
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.
372
- const slow = setInterval(() => {
373
- if (cancelled || finished) return;
374
- setStillOpening(declined
375
- ? `Still waiting: Blender is holding another model, and opens ${path} when it is free.`
376
- : `Still opening ${path}: Blender is working (a large file, or a cold start). ${commandLine('status')} shows what it is busy with.`);
377
- }, MODEL_OPEN_NOTE_AFTER_MS);
378
341
  // Opening publishes context to other React roots. Start outside this
379
342
  // effect (or a host activation listener's synchronous notification stack)
380
343
  // so those roots cannot synchronously commit through a pending commit here.
@@ -388,18 +351,28 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
388
351
  requestOpen();
389
352
  return () => {
390
353
  cancelled = true;
391
- clearInterval(slow);
392
354
  unsubscribe();
393
355
  unpublish?.();
394
356
  unbind();
395
357
  };
396
358
  }, [active, blend, documentId, entryId, key, attempt]);
397
359
 
360
+ // THE OPEN'S ONE WAIT, from asking for the file until its viewport is drawn: Blender's start
361
+ // first when it is still under way (the first open, or one after a reload or a failure), then
362
+ // the file's own load, then its view. Retry opens it again, as often as it is chosen.
363
+ const failed = opened?.key === key && opened.error !== null;
364
+ const preparing = opened?.key === key && !failed && !!documentId && !documentViewport(documentId);
365
+ useProgress(active !== false && !!documentId && !failed && (opened?.key !== key || preparing) ? {
366
+ documentId,
367
+ title: `Opening ${blend ?? 'the model'}`,
368
+ message: preparing ? 'Preparing its view' : blenderStart ? 'Starting Blender' : undefined,
369
+ actions: [{ label: 'Retry opening', run: retry }],
370
+ } : null);
371
+
398
372
  if (active === false || !documentId) return null;
399
373
  if (opened?.key !== key || opened.error) return <BlenderModelOpening
400
374
  documentId={documentId} path={blend ?? 'Model'} error={opened?.key === key ? opened.error : null} preview={preview}
401
- note={stillOpening}
402
- retry={() => { setOpened(null); setAttempt(value => value + 1); }}
375
+ retry={retry}
403
376
  returnToPreview={() => {
404
377
  if (!preview) return;
405
378
  void editorHost().workspace.open({ kind: 'document', id: preview.entryId }).then(opened => {
@@ -409,8 +382,13 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
409
382
  />;
410
383
  return <>
411
384
  <BlenderModelViewport {...props} />
412
- {!documentViewport(documentId) && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
413
- preparingView preview={preview} retry={() => {}} returnToPreview={() => {}} />}
385
+ {saveNotice && <EditorBanner tone="warning" data-testid="blender-model-not-saving"
386
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2 }}
387
+ actions={<Button variant="secondary" onClick={retry}>Reopen saved document</Button>}>
388
+ <strong>Model is not being saved.</strong> <span style={{ userSelect: 'text' }}>{saveNotice}</span> Reopening replaces this scene with the last saved file.
389
+ </EditorBanner>}
390
+ {preparing && <BlenderModelOpening documentId={documentId} path={blend ?? 'Model'} error={null}
391
+ preparingView preview={preview} retry={retry} returnToPreview={() => {}} />}
414
392
  </>;
415
393
  }
416
394
 
@@ -421,80 +399,69 @@ export default function BlenderModelDocument(props: ToolContributionProps) {
421
399
  * Blender's standard always-on render preview is set up: Rendered shading, through the scene
422
400
  * camera. Each area has its own stage, shading, navigation and camera view; the model, its
423
401
  * selection and its history are the document's.
424
- *
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.
429
402
  */
430
403
  function BlenderModelViewport(props: ToolContributionProps) {
431
404
  const { documentId } = props;
432
405
  const blend = props.document?.source?.path;
433
- // 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.
406
+ // THE BOTTOM AREA SERVES THIS DOCUMENT while it is the model on screen (`../src/play-mode.ts`): Game or Animation,
407
+ // by whether the project has a game.
435
408
  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.
409
+ // Rendered areas use the display transform carried by the presented frame. Solid uses its studio transform. Document preparation waits for that frame’s transform before photographs.
439
410
  useEffect(() => {
440
411
  if (!documentId) return;
441
412
  let cancelled = false;
442
- let revision = 0;
443
- let applied: { key: string; transform: BlenderDisplayTransform } | null = null;
444
- const ids = [documentId, `${documentId}#area-2`, `${documentId}#play`];
413
+ let applied: BlenderDisplayTransform | null = null;
414
+ let wanted: string | null = null;
415
+ let settled: Promise<void> = Promise.resolve();
416
+ const ids = [documentId, `${documentId}#area-2`];
445
417
  const attach = () => {
446
- for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied?.transform ?? null);
418
+ for (const id of ids) object3DDocumentSession(id)?.setDisplayTransform(applied);
447
419
  };
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
- };
420
+ const follow = (): Promise<void> => {
421
+ const display = view.displaySettings();
422
+ if (!display) return settled;
473
423
  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
- }
424
+ if (key === wanted) return settled;
425
+ wanted = key;
426
+ settled = (async () => {
427
+ if (display.device !== 'sRGB')
428
+ throw new Error(`Blender display device ${display.device} has no browser display processor yet`);
429
+ const resolved = await createBlenderDisplayTransform(display);
430
+ if (cancelled || wanted !== key) { resolved.dispose(); return; }
431
+ const previous = applied;
432
+ applied = resolved;
433
+ attach();
434
+ previous?.dispose();
435
+ for (const id of ids) {
436
+ setViewPresentation(id, { modes: {
437
+ rendered: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
438
+ preview: { lighting: { tone: { mapper: 'none', exposure: 1 } } },
439
+ } });
440
+ }
441
+ })().catch((error: unknown) => editorHost().console.error(String(error), 'blender-colour'));
442
+ return settled;
487
443
  };
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();
444
+ // A photograph of each shading type is anti-aliased as Blender's viewport accumulates it.
445
+ const sample = () => {
446
+ const sampling = view.photographSampling();
447
+ for (const id of ids) object3DDocumentSession(id)?.setPhotographSampling(sampling);
448
+ };
449
+ const stopFrames = view.subscribeFrames(() => { sample(); void follow(); });
450
+ const stopSessions = subscribeObject3DDocumentSessions(() => { attach(); sample(); });
451
+ sample();
452
+ // A view presented on this document is drawn (and photographed) once the frame on screen has
453
+ // its display transform and everything it is still decoding or settling (`drawReady`).
454
+ const stopPreparation = registerObject3DDocumentPreparation(documentId, async () => {
455
+ await follow();
456
+ await view.drawReady();
457
+ });
458
+ void follow();
492
459
  return () => {
493
- cancelled = true; stopRna(); stopSessions();
460
+ cancelled = true; stopFrames(); stopSessions(); stopPreparation();
494
461
  const previous = applied;
495
462
  applied = null;
496
463
  attach();
497
- previous?.transform.dispose();
464
+ previous?.dispose();
498
465
  };
499
466
  }, [documentId]);
500
467
  const split = useSyncExternalStore(
@@ -517,213 +484,29 @@ function BlenderModelViewport(props: ToolContributionProps) {
517
484
  second.dispose();
518
485
  };
519
486
  }, [split, documentId]);
520
- const playing = useSyncExternalStore(
521
- subscribeDocumentPlayExtensions,
522
- () => (documentId ? documentPlayExtension('model')?.playing(documentId) ?? false : false),
523
- () => false,
524
- );
525
- const [played, setPlayed] = useState<ReturnType<AreaView['detach']> | null>(null);
526
- const playAspect = useSyncExternalStore(
527
- subscribeDocumentPlayExtensions,
528
- () => (documentId ? documentPlayExtension('model')?.aspectRatio?.(documentId) ?? null : null),
529
- () => null,
530
- );
531
- const [playedReady, setPlayedReady] = useState(false);
532
- /**
533
- * RESTART IS A NEW GENERATION OF THE SAME PLAY (`DocumentPlayTransport.generation`): the copy
534
- * below is detached per generation, so a restart disposes the played copy and detaches a fresh
535
- * one from the model, which never moved, while playing stays true throughout.
536
- *
537
- * THE OLD GAME STAYS ON SCREEN UNTIL THE NEW ONE HAS DRAWN. Between the two the new copy's
538
- * stage is still preparing its rendered draw, and the model area is what lies under the play
539
- * area — so a restart would flash the editing model between two games. The generation is
540
- * therefore taken in two steps: a layout effect photographs the old play stage while it still
541
- * stands (`capture`, the frame it draws) and only then moves `shownGeneration`, which is what
542
- * 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`).
545
- */
546
- const generation = useSyncExternalStore(
547
- subscribeDocumentPlayExtensions,
548
- () => (documentId ? documentPlayExtension('model')?.transport?.generation(documentId) ?? 0 : 0),
549
- () => 0,
550
- );
551
- const [shownGeneration, setShownGeneration] = useState(generation);
552
- const [restartStill, setRestartStill] = useState<string | null>(null);
553
- const restarting = useRef(false);
554
- // A restarted game that has not drawn yet: its stage holds no game to photograph, so a second
555
- // Restart in that window keeps the cover it has (or none) rather than photographing a blank.
556
- const awaitingDraw = useRef(false);
557
- useLayoutEffect(() => {
558
- if (generation === shownGeneration) return;
559
- if (playing && documentId) {
560
- restarting.current = true;
561
- if (!awaitingDraw.current) {
562
- let still: string | null = null;
563
- try { still = documentViewport(`${documentId}#play`)?.capture?.() ?? null; }
564
- catch { /* No photograph: the new stage shows as it prepares, still never the model. */ }
565
- setRestartStill(still);
566
- }
567
- awaitingDraw.current = true;
568
- }
569
- setShownGeneration(generation);
570
- }, [generation, shownGeneration, playing, documentId]);
571
- /**
572
- * A RESTARTED GAME THAT FAILS never draws (its script did not start, or threw on its first
573
- * update), so the cover would stand over it while the panel said Playing. The runner says so
574
- * on the clock (`DocumentPlayClock.failure`); the cover drops and the stage shows what it has.
575
- */
576
- const transport = documentPlayExtension('model')?.transport;
577
- const subscribeFailure = useCallback(
578
- (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
579
- [transport],
580
- );
581
- const failure = useSyncExternalStore(
582
- subscribeFailure,
583
- () => (documentId && transport ? transport.clock(documentId).failure ?? null : null),
584
- () => null,
585
- );
586
- useEffect(() => {
587
- if (failure === null) return;
588
- awaitingDraw.current = false;
589
- setRestartStill(null);
590
- }, [failure]);
591
- /**
592
- * COPIES A RESTART RETIRED, disposed only once the play area that drew them has unmounted:
593
- * the commit that renders the new copy removes the old area first, and this runs after it.
594
- * Disposed earlier, the old stage could draw a disposed copy for a frame.
595
- */
596
- const retired = useRef<ReturnType<AreaView['detach']>[]>([]);
597
- useEffect(() => {
598
- if (!playing || !documentId) { restarting.current = false; return; }
599
- const playId = `${documentId}#play`;
600
- // A game is seen as the render is: Rendered shading, chosen before the stage binds.
601
- setViewPresentation(playId, { drawMode: 'rendered' });
602
- const copy = view.detach();
603
- const restart = restarting.current;
604
- restarting.current = false;
605
- // A restart keeps the model area hidden: the old game's photograph stands in until ready.
606
- if (!restart) setPlayedReady(false);
607
- setPlayed(copy);
608
- if (!restart) {
609
- // The stage's mode is PLAY for as long as a copy stands, so the keys are the player's.
610
- view.setPlaying(true);
611
- notifyWorkspaceDocumentSelectionChanged(documentId);
612
- }
613
- return () => {
614
- // Read at cleanup: the layout effect above has already marked a restart, whose new copy
615
- // takes over in this same commit with the play mode unchanged.
616
- if (restarting.current && documentPlayExtension('model')?.playing(documentId)) {
617
- retired.current.push(copy);
618
- return;
619
- }
620
- copy.dispose();
621
- for (const old of retired.current.splice(0)) old.dispose();
622
- awaitingDraw.current = false;
623
- setPlayed(null);
624
- setRestartStill(null);
625
- view.setPlaying(false);
626
- notifyWorkspaceDocumentSelectionChanged(documentId);
627
- };
628
- }, [playing, documentId, shownGeneration]);
629
- useEffect(() => {
630
- for (const old of retired.current.filter(one => one !== played)) old.dispose();
631
- retired.current = retired.current.filter(one => one === played);
632
- }, [played]);
633
- // And whatever is still retired when the document itself goes (declared after the copy's own
634
- // effect, so its cleanup has already handed over anything mid-restart).
635
- useEffect(() => () => { for (const old of retired.current.splice(0)) old.dispose(); }, []);
636
- // The document closing or going inactive ends its game; a game never outlives its stage.
637
- useEffect(() => {
638
- if (!documentId) return;
639
- return () => documentPlayExtension('model')?.setPlaying(documentId, false);
640
- }, [documentId]);
641
- const game = playing ? played : null;
642
- const playFrame = playAspect === null ? { position: 'absolute', inset: 0 } as const : {
643
- position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
644
- width: `min(100cqw, calc(100cqh * ${playAspect}))`,
645
- height: `min(100cqh, calc(100cqw / ${playAspect}))`,
646
- } as const;
647
- useEffect(() => {
648
- if (!game || !documentId) return;
649
- const modelViewport = documentViewport(documentId);
650
- if (!modelViewport) return;
651
- // registerObject3DDocumentSession creates this document's own viewport object; capture may follow its visible play area.
652
- const capture = modelViewport.capture;
653
- const playCapture = (size: Parameters<NonNullable<typeof capture>>[0]) => documentViewport(`${documentId}#play`)?.capture?.(size) ?? null;
654
- modelViewport.capture = playCapture;
655
- return () => {
656
- if (modelViewport.capture !== playCapture) return;
657
- if (capture) modelViewport.capture = capture;
658
- else delete modelViewport.capture;
659
- };
660
- }, [game, documentId]);
661
487
  if (!documentId) return null;
662
488
  const second = split ? follower : null;
663
- // Keep both authoring areas mounted across Play/Stop. Remounting a stage discards
664
- // its pose/session and repeats the second area's initial scene-camera setup.
665
489
  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;
674
490
  return (
675
- <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' }}>
491
+ <div style={second ? { position: 'absolute', inset: -12, display: 'flex', gap: 2 } : { display: 'contents' }}>
492
+ <div key="model" style={second ? area : { display: 'contents' }}>
677
493
  <BlenderViewportArea {...props} view={view} main />
678
494
  </div>
679
- {game && (
680
- // Keyed on the COPY, so a restart mounts a new stage and runner exactly when the new copy
681
- // is the one rendered — never over the old, disposed one.
682
- <div key={`play-${playCopyKey(game)}`} style={{ position: 'absolute', inset: 12, containerType: 'size' }} data-testid="blender-play-area">
683
- <div data-volter-play-frame style={playFrame}>
684
- <BlenderViewportArea {...props} documentId={`${documentId}#play`} view={game.view} main={false} playing
685
- onPlayReady={() => { awaitingDraw.current = false; setPlayedReady(true); setRestartStill(null); }} onPlayReturn={() => setPlayedReady(false)} />
686
- </div>
687
- </div>
688
- )}
689
- {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.
694
- <div key="play-failure" role="alert" data-testid="blender-play-failure"
695
- style={{ position: 'absolute', left: 24, right: 24, top: 24, zIndex: 4, padding: `${spaceVar[2]} ${spaceVar[3]}`,
696
- background: themeVars.surface.chrome, border: `1px solid ${themeVars.semantic.danger}`, borderRadius: themeVars.shape.small,
697
- color: themeVars.content.primary, fontSize: fontSizeVar.sm, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }}>
698
- <strong style={{ color: themeVars.semantic.danger }}>Play is not running.</strong> {failure} Restart (or save the script) to try again, or Stop.
699
- </div>
700
- )}
701
- {game && restartStill !== null && (
702
- <div key="restart-still" style={{ position: 'absolute', inset: 12, containerType: 'size', zIndex: 3, pointerEvents: 'none' }} data-testid="blender-play-restart-still">
703
- <img alt="" src={restartStill} style={{ ...playFrame, display: 'block', objectFit: 'fill' }} />
704
- </div>
705
- )}
706
495
  {second && (
707
- <div key="second" style={{ ...area, visibility: game ? 'hidden' : undefined }} aria-hidden={game ? true : undefined} data-testid="blender-second-area">
496
+ <div key="second" style={area} data-testid="blender-second-area">
708
497
  <BlenderViewportArea {...props} documentId={`${documentId}#area-2`} view={second.view} main={false} />
709
- {!game && <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />}
498
+ <SecondAreaChrome documentId={documentId} areaId={`${documentId}#area-2`} notify={props.notify} />
710
499
  </div>
711
500
  )}
712
501
  </div>
713
502
  );
714
503
  }
715
504
 
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
505
  /**
723
506
  * THE SECOND AREA'S OWN HEADER AND SHELF, as each of Blender's areas has them.
724
507
  *
725
508
  * 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
509
+ * (`@volter/sdk/document-areas`). Its HEADER stands in the document's header row over
727
510
  * this area: the host reserves this area's width at the row's trailing edge, so the first area's
728
511
  * header ends where the first area does, and this renders into the region it draws there: the
729
512
  * editor menus (their View ▸ Area ▸ Close Area closes this area), then THIS area's view, shading
@@ -783,38 +566,28 @@ function BlenderViewportArea({
783
566
  active,
784
567
  document,
785
568
  documentId,
786
- notify,
787
569
  surfaces,
788
570
  view,
789
571
  main,
790
- playing = false,
791
- onPlayReady,
792
- onPlayReturn,
793
- }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean; readonly playing?: boolean; readonly onPlayReady?: () => void; readonly onPlayReturn?: () => void }) {
572
+ }: ToolContributionProps & { readonly view: AreaView; readonly main: boolean }) {
794
573
  const reads = readsOf(view);
795
574
  const build = useCallback(
796
575
  () => ({
797
576
  root: view.root,
798
- prepareDraw: (camera: THREE.Camera, options?: {interactive: boolean; height: number; multiDraw?: boolean; renderer?: THREE.WebGLRenderer}) => view.prepareDraw(camera, options),
577
+ prepareDraw: (camera: THREE.Camera, options?: BlenderDrawOptions) => view.prepareDraw(camera, options),
799
578
  // 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`).
803
- ...(playing
804
- ? {}
805
- : {
806
- onChange(listener: () => void) {
807
- const stopView = view.onChange(listener);
808
- const stopSkin = blenderSkin.subscribe(listener);
809
- return () => {
810
- stopView();
811
- stopSkin();
812
- };
813
- },
814
- }),
579
+ // and with the skin's poses when the Timeline scrubs.
580
+ onChange(listener: () => void) {
581
+ const stopView = view.onChange(listener);
582
+ const stopSkin = blenderSkin.subscribe(listener);
583
+ return () => {
584
+ stopView();
585
+ stopSkin();
586
+ };
587
+ },
815
588
  dispose() {},
816
589
  }),
817
- [view, playing],
590
+ [view],
818
591
  );
819
592
  const blend = document?.source?.path;
820
593
  // Re-read on the engine's frames, the skin's publications and every drawn frame of this stage
@@ -854,8 +627,7 @@ function BlenderViewportArea({
854
627
  * person's.
855
628
  */
856
629
  useEffect(() => {
857
- // A playing copy's camera is its script's.
858
- if (main || playing || !documentId) return;
630
+ if (main || !documentId) return;
859
631
  let entered = false;
860
632
  const enter = (): void => {
861
633
  if (entered) return;
@@ -870,194 +642,7 @@ function BlenderViewportArea({
870
642
  stopSessions();
871
643
  stopFrames();
872
644
  };
873
- }, [main, playing, documentId, view]);
874
- /**
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.
878
- */
879
- const notifyRef = useRef(notify);
880
- const surfaceRef = useRef<HTMLDivElement>(null);
881
- const [playReady, setPlayReady] = useState(false);
882
- notifyRef.current = notify;
883
- const playReadyRef = useRef(onPlayReady);
884
- playReadyRef.current = onPlayReady;
885
- const playReturnRef = useRef(onPlayReturn);
886
- playReturnRef.current = onPlayReturn;
887
- useEffect(() => {
888
- if (!playing || !documentId) return;
889
- if (blend === undefined) {
890
- notifyRef.current?.({
891
- 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.',
894
- });
895
- return;
896
- }
897
- const modelId = documentId.replace(/#play$/, '');
898
- const layers = window.document.createElement('div');
899
- Object.assign(layers.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
900
- layers.dataset['testid'] = 'model-play-roots';
901
- surfaceRef.current?.appendChild(layers);
902
- setPlayReady(false);
903
- let stopped = false;
904
- let preparing = false;
905
- let stopScript: (() => void) | null = null;
906
- let orbit: { enabled: boolean } | null = null;
907
- let animation: PlayAnimation | null = null;
908
- const start = (): void => {
909
- if (stopScript || preparing || stopped) return;
910
- const stage = viewportStages().find((one) => one.documentId === documentId);
911
- 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
- });
1014
- };
1015
- start();
1016
- let waitingStage: ReturnType<typeof viewportStages>[number] | undefined;
1017
- let stopPrepareFrames: (() => void) | undefined;
1018
- const waitForDraw = () => {
1019
- const stage = viewportStages().find(one => one.documentId === documentId);
1020
- if (stage === waitingStage) return;
1021
- stopPrepareFrames?.();
1022
- waitingStage = stage;
1023
- stopPrepareFrames = stage?.onFrame(start);
1024
- start();
1025
- };
1026
- waitForDraw();
1027
- const stopStages = onViewportStages(waitForDraw);
1028
- const onEscape = (event: KeyboardEvent): void => {
1029
- if (event.key !== 'Escape' || !surfaceAcceptsKey(event)) return;
1030
- // Decided once the event has finished dispatching: a play script that claims Escape (a
1031
- // 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).
1034
- if (globalThis.document.pointerLockElement || performance.now() - lastPointerUnlockAt < 500) return;
1035
- setTimeout(() => {
1036
- if (event.defaultPrevented) return;
1037
- const extension = documentPlayExtension('model');
1038
- if (extension?.escape) extension.escape(modelId);
1039
- else extension?.setPlaying(modelId, false);
1040
- }, 0);
1041
- };
1042
- let lastPointerUnlockAt = -Infinity;
1043
- const onLockChange = (): void => { if (!globalThis.document.pointerLockElement) lastPointerUnlockAt = performance.now(); };
1044
- globalThis.document.addEventListener('pointerlockchange', onLockChange);
1045
- window.addEventListener('keydown', onEscape, true);
1046
- return () => {
1047
- globalThis.document.removeEventListener('pointerlockchange', onLockChange);
1048
- stopped = true;
1049
- stopStages();
1050
- stopPrepareFrames?.();
1051
- window.removeEventListener('keydown', onEscape, true);
1052
- 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;
1057
- layers.remove();
1058
- if (orbit) orbit.enabled = true;
1059
- };
1060
- }, [playing, documentId, blend, view]);
645
+ }, [main, documentId, view]);
1061
646
  /**
1062
647
  * A SHADING PICK IS KEPT WHERE BLENDER KEEPS IT: in the file's own 3D View, the one it reopens
1063
648
  * on (`blenderViewShading`), so the next save carries it and a reopen starts from it. Noted on
@@ -1095,20 +680,7 @@ function BlenderViewportArea({
1095
680
  opened();
1096
681
  return subscribeStageStores(opened);
1097
682
  }, [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
- */
683
+ /** 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
684
  useEffect(() => {
1113
685
  if (!documentId) return;
1114
686
  const groups = view.overlayGroups();
@@ -1135,8 +707,9 @@ function BlenderViewportArea({
1135
707
  if (!documentId || !main) return;
1136
708
  let live = true;
1137
709
  void blenderSkin.sync(view, {
1138
- clip: (armature) => blenderActionClip({ object: armature }),
710
+ clip: (armature) => blenderActionClip(armature === null ? {} : { object: armature }),
1139
711
  bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
712
+ movie: () => blenderSceneMovie(),
1140
713
  }).catch((thrown: unknown) => {
1141
714
  if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1142
715
  });
@@ -1218,7 +791,7 @@ function BlenderViewportArea({
1218
791
  const needsScene = lighting.source === 'scene' || (preview && lighting.preview.sceneLights);
1219
792
  // Shading changes lighting, not which objects an editing viewport shows.
1220
793
  // Only the detached game uses render visibility, as a clean capture does.
1221
- view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, playing ? 'render' : 'viewport');
794
+ view.holdRendered(needsScene && stage ? drawCamera : null, sceneLighting, 'viewport');
1222
795
  // BLENDER'S SOLID IS BLENDER'S OWN FUNCTION: while the stage lights by Blender's studio, the
1223
796
  // presenter draws every surface by it (`blender-workbench-material.ts`).
1224
797
  view.setWorkbench(lighting.source === 'studio' && lighting.studioPreset === DOCUMENT_STUDIO_PRESET.id);
@@ -1233,7 +806,7 @@ function BlenderViewportArea({
1233
806
  view.holdRendered(null);
1234
807
  view.setWorkbench(false);
1235
808
  };
1236
- }, [documentId, view, playing]);
809
+ }, [documentId, view]);
1237
810
  /**
1238
811
  * SHIFT+RIGHT-CLICK PLACES THE 3D CURSOR, Blender's own chord for
1239
812
  * `view3d.cursor3d` (`blender_default.py`, `params.cursor_set_event`). The
@@ -1246,7 +819,7 @@ function BlenderViewportArea({
1246
819
  */
1247
820
  const cursorPress = useRef<{ id: number; x: number; y: number } | null>(null);
1248
821
  const cursorChord = (event: ReactPointerEvent | ReactMouseEvent): boolean =>
1249
- !playing && event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
822
+ event.button === 2 && event.shiftKey && event.target instanceof HTMLCanvasElement;
1250
823
  const onPointerDownCapture = (event: ReactPointerEvent): void => {
1251
824
  if (!cursorChord(event)) {
1252
825
  cursorPress.current = null;
@@ -1294,9 +867,7 @@ function BlenderViewportArea({
1294
867
  const Surface = surfaces.Object3DAuthoring;
1295
868
  return (
1296
869
  <div
1297
- ref={surfaceRef}
1298
- data-play-ready={playing ? playReady : undefined}
1299
- style={playing ? { position: 'absolute', inset: 0 } : { display: 'contents' }}
870
+ style={{ display: 'contents' }}
1300
871
  onPointerDownCapture={onPointerDownCapture}
1301
872
  onPointerUpCapture={onPointerUpCapture}
1302
873
  onPointerCancelCapture={dropCursorPress}
@@ -1305,7 +876,6 @@ function BlenderViewportArea({
1305
876
  >
1306
877
  <Surface
1307
878
  active={active ?? true}
1308
- fillContainer={playing}
1309
879
  documentId={documentId}
1310
880
  sourcePath={blend ?? 'blender:runtime'}
1311
881
  displayName={document?.label ?? 'Model'}
@@ -1316,17 +886,8 @@ function BlenderViewportArea({
1316
886
  cameraView={reads.cameraView}
1317
887
  build={build}
1318
888
  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.
1329
- {...(playing ? {} : { authoring })}
889
+ // The authoring hierarchy is Blender’s View Layer Outliner; the default adapter supplies presentation access.
890
+ authoring={authoring}
1330
891
  cameraDirection={[0.8187, 0.4458, 0.3617]}
1331
892
  // FOR A FILE THAT SAVED NO 3D VIEW: Blender's factory Modeling direction, standing back
1332
893
  // three fits.
@@ -1342,33 +903,24 @@ function BlenderViewportArea({
1342
903
  const saved = view.savedView();
1343
904
  if (!saved) return null;
1344
905
  const camera = { fov: blenderViewFieldOfView(saved.lens) };
906
+ // Solid and Wireframe use the saved view's resolved theme, World or viewport colour.
907
+ const behind = { backdrop: { source: 'color' as const, color: saved.background } };
1345
908
  return main && saved.drawMode ? {
1346
909
  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 };
910
+ modes: {
911
+ solid: behind,
912
+ wireframe: behind,
913
+ preview: {
914
+ lighting: { source: saved.sceneWorld ? 'scene' as const : 'preview' as const, preview: { sceneLights: saved.sceneLights } },
915
+ backdrop: { source: saved.sceneWorld ? 'scene' as const : 'fill' as const },
916
+ },
917
+ },
918
+ } : { camera, modes: { solid: behind, wireframe: behind } };
1352
919
  })()}
1353
920
  // Every entry this document opens is a `model` stage, the standing `blender:runtime`
1354
921
  // address included (its id carries no `model:` prefix).
1355
922
  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.
923
+ // 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
924
  dressing={{
1373
925
  environment: false,
1374
926
  keyLight: false,