@volter/editor-threejs 0.5.211 → 0.5.213

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 (133) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +8 -13
  3. package/contributions/animation-mixers.service.ts +2 -4
  4. package/contributions/animation-timeline.utility.tsx +1 -2
  5. package/contributions/three.page.ts +50 -0
  6. package/dist-node/serving.mjs +84 -3
  7. package/dist-node/shared-three.mjs +145 -0
  8. package/package.json +296 -16
  9. package/serving/animation-stamp.ts +1 -1
  10. package/serving/index.ts +22 -5
  11. package/serving/model-import-conversion.ts +6 -3
  12. package/serving/shared-three.ts +176 -0
  13. package/src/adapter/constraint.ts +1 -18
  14. package/src/adapter/object3d-authoring-subject.ts +1 -12
  15. package/src/adapter/reflection-probe.ts +1 -9
  16. package/src/adapter/three-contract.ts +6 -60
  17. package/src/animation/live-mixers.ts +0 -4
  18. package/src/api.ts +7 -0
  19. package/src/asset-loaders.ts +2 -178
  20. package/src/capture/photograph-sampling.ts +333 -0
  21. package/src/capture/scene.ts +1 -1
  22. package/src/ecs/object-marks.ts +6 -14
  23. package/src/ecs/user-data.ts +3 -138
  24. package/src/kit/animation/three-clips-subject.ts +19 -42
  25. package/src/kit/asset-compare-core.ts +161 -0
  26. package/src/kit/asset-compare.ts +8 -27
  27. package/src/kit/asset-preview-command.ts +14 -19
  28. package/src/kit/asset-preview-framing.ts +8 -67
  29. package/src/kit/asset-preview.ts +109 -342
  30. package/src/kit/asset-workflow/model-inspection.ts +11 -154
  31. package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
  32. package/src/kit/authoring/component-instance-root.ts +3 -39
  33. package/src/kit/authoring/live-object-transform.ts +1 -30
  34. package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
  35. package/src/kit/authoring/object3d-document-session.ts +471 -208
  36. package/src/kit/authoring/shell-viewport-policy.ts +18 -24
  37. package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
  38. package/src/kit/authoring/three-projection-core.ts +11 -40
  39. package/src/kit/authoring/viewport-pick-context.ts +5 -12
  40. package/src/kit/authoring/viewport-raycast.ts +15 -93
  41. package/src/kit/authoring/world-hidden-viewport.ts +8 -61
  42. package/src/kit/capture-camera-pose.ts +68 -0
  43. package/src/kit/collaboration-presence.ts +41 -0
  44. package/src/kit/components/CameraInfo.tsx +4 -6
  45. package/src/kit/components/InspectorObjectPreview.tsx +3 -30
  46. package/src/kit/components/Object3DDocumentToolbar.css +75 -0
  47. package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
  48. package/src/kit/components/Object3DDocumentViewport.tsx +6 -11
  49. package/src/kit/components/StageHost.css +40 -0
  50. package/src/kit/components/StageHost.tsx +192 -439
  51. package/src/kit/components/StageOverlays.tsx +1 -2
  52. package/src/kit/components/StatsOverlay.tsx +5 -17
  53. package/src/kit/components/ViewportFurniture.tsx +27 -154
  54. package/src/kit/components/ViewportOverlay.tsx +13 -17
  55. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  56. package/src/kit/components/ViewportShadingMenu.tsx +2 -42
  57. package/src/kit/components/ViewportViewMenu.tsx +7 -18
  58. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +6 -17
  59. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +9 -11
  60. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +31 -133
  61. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +10 -12
  62. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +4 -8
  63. package/src/kit/components/asset-viewers/Object3DPreview.tsx +13 -64
  64. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +6 -10
  65. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +48 -65
  66. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  67. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
  68. package/src/kit/components/object3d-contribution-surfaces.tsx +5 -3
  69. package/src/kit/components/stage-keyboard.tsx +6 -12
  70. package/src/kit/components/stage-overlay-set.tsx +11 -20
  71. package/src/kit/components/stage-presence-markers.ts +9 -32
  72. package/src/kit/components/stage-transform-chrome.ts +4 -4
  73. package/src/kit/components/stage-transform-tools.tsx +7 -14
  74. package/src/kit/components/standard-viewport-dressing.ts +47 -167
  75. package/src/kit/components/world-root-binding.ts +2 -7
  76. package/src/kit/constraint-helper.ts +1 -1
  77. package/src/kit/document-renderer-session.ts +114 -0
  78. package/src/kit/editor-presentation-activity.ts +58 -0
  79. package/src/kit/editor-shell-store.ts +31 -403
  80. package/src/kit/editor-viewport.ts +486 -1762
  81. package/src/kit/entity-lod.ts +3 -16
  82. package/src/kit/entity-object.ts +1 -39
  83. package/src/kit/hierarchy-mark-reader.ts +13 -30
  84. package/src/kit/instanced-presentation.ts +1 -47
  85. package/src/kit/live-gesture-lock.ts +35 -0
  86. package/src/kit/live-module-source.ts +12 -52
  87. package/src/kit/model-thumbnail.ts +7 -35
  88. package/src/kit/play-camera-flight.ts +7 -16
  89. package/src/kit/projection/three.ts +13 -366
  90. package/src/kit/retained-document-states.ts +91 -0
  91. package/src/kit/scene-document-viewport.ts +3 -3
  92. package/src/kit/scene-framing.ts +11 -152
  93. package/src/kit/scene-view-fog.ts +2 -47
  94. package/src/kit/spatial-handle-visuals.ts +19 -28
  95. package/src/kit/stories/three-story-model.ts +5 -33
  96. package/src/kit/three-canvas-render.ts +3 -4
  97. package/src/kit/three-hierarchy-row-media.ts +3 -11
  98. package/src/kit/three-inspection-media.ts +6 -6
  99. package/src/kit/three-integration.ts +18 -18
  100. package/src/kit/three-state.ts +2 -19
  101. package/src/kit/three-viewport/camera-fit.ts +2 -6
  102. package/src/kit/three-viewport/selection-brackets.ts +5 -20
  103. package/src/kit/three-viewport/selection-outline.ts +4 -56
  104. package/src/kit/three-viewport/skeleton-helper.ts +1 -32
  105. package/src/kit/three-viewport/source-color.ts +1 -33
  106. package/src/kit/three-viewport/studio-environment.ts +4 -55
  107. package/src/kit/three-viewport-presentation.ts +23 -0
  108. package/src/kit/transport-strip.tsx +147 -0
  109. package/src/kit/viewport-actions.ts +8 -50
  110. package/src/kit/viewport-authoring-policy.ts +5 -52
  111. package/src/kit/viewport-commands.ts +28 -57
  112. package/src/kit/viewport-hotkeys.ts +29 -31
  113. package/src/kit/viewport-status-facet.ts +4 -5
  114. package/src/loader.ts +8 -71
  115. package/src/object3d-contributions.ts +18 -34
  116. package/src/viewport/clip-planes.ts +4 -32
  117. package/src/viewport/content-bounds.ts +13 -142
  118. package/src/viewport/editor-layers.ts +4 -26
  119. package/src/viewport/environment.ts +1 -1
  120. package/src/viewport/preview-renderer.ts +7 -45
  121. package/src/viewport/renderer-ownership.ts +14 -62
  122. package/src/viewport-api.ts +14 -33
  123. package/src/viewport-door.ts +16 -86
  124. package/contributions/three-integration.service.ts +0 -13
  125. package/src/adapter/hierarchy-marks.ts +0 -156
  126. package/src/adapter/ingest/scene-capture.ts +0 -843
  127. package/src/adapter/ingest/structural-ids.ts +0 -139
  128. package/src/adapter/ingest/visible-capture-window.ts +0 -302
  129. package/src/adapter/renderer-config.ts +0 -115
  130. package/src/asset-parse-error.ts +0 -29
  131. package/src/host-hierarchy-objects.ts +0 -31
  132. package/src/kit/authoring/design-time-settle.ts +0 -343
  133. package/src/kit/camera-authoring.ts +0 -175
@@ -1,9 +1,9 @@
1
1
  import { optionalThreeStateOf, threeStateOf } from '../three-state';
2
- import { ShellStore } from '@volter/sdk/kit/shell-store';
3
- import type { ToolObject3DAuthoringProps, ToolObject3DDocumentAuthoring, ToolObject3DPreviewSource, ToolViewportDressing } from '../../object3d-contributions';
4
- import { invalidateStages, stageGeneration } from '@volter/sdk/kit/stage-invalidation';
2
+ import { ShellStore } from '@volter/sdk/shell-store';
3
+ import type { ToolObject3DAuthoringProps, ToolObject3DDocumentAuthoring, ToolObject3DPreviewSource } from '../../object3d-contributions';
4
+ import { invalidateStages, stageGeneration } from '@volter/sdk/stage-invalidation';
5
5
  import type { StageTransportSnapshot } from '@volter/sdk/host';
6
- import { EditorIcon, editorIcons, IconButton, themeVars } from '@volter/sdk/widgets';
6
+ import { EditorBanner, EditorIcon, editorIcons, IconButton, themeVars } from '@volter/sdk/widgets';
7
7
  import type { AuthoringAdapter } from '@volter/project/adapter';
8
8
  import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
9
9
  import { isInEditorOwnedSubtree } from '@volter/editor-threejs/viewport/editor-layers';
@@ -25,9 +25,9 @@ import {
25
25
  } from 'react';
26
26
  import * as THREE from 'three';
27
27
  import { DocumentScene } from '../../render/document-scene';
28
- import { registerStageTransport, StageTransport } from '@volter/sdk/kit/animation/stage-transport';
28
+ import { registerStageTransport, StageTransport } from '@volter/sdk/animation/stage-transport';
29
29
  import { scanClipSubjects } from '../animation/three-clips-subject';
30
- import { liveGestureActive, whenLiveGestureIdle } from '@volter/sdk/kit/live-gesture-lock';
30
+ import { liveGestureActive, whenLiveGestureIdle } from '../live-gesture-lock';
31
31
  import {
32
32
  type Object3DDocumentPresentationState,
33
33
  Object3DDocumentSession,
@@ -37,39 +37,36 @@ import {
37
37
  } from '../authoring/object3d-document-session-registry';
38
38
  import { Object3DGestureController } from '../authoring/object3d-gesture-controller';
39
39
  import { SourceObject3DAuthoringAdapter } from '../authoring/source-object3d-authoring-adapter';
40
- import { registerDesignTimeSurface } from '@volter/sdk/kit/coverage/design-time-surfaces';
41
- import { DocumentRendererSession } from '@volter/sdk/kit/document-renderer-session';
42
- import { useOptionalEditorStats, useOptionalEditorStore } from '@volter/sdk/kit/editor-runtime';
40
+ import { registerDesignTimeSurface } from '@volter/sdk/coverage/design-time-surfaces';
41
+ import { DocumentRendererSession } from '../document-renderer-session';
42
+ import { useOptionalEditorStats, useOptionalEditorStore } from '@volter/sdk/editor-runtime';
43
43
  import type { EditorShellStore } from '../editor-shell-store';
44
44
  import { EditorViewport } from '../editor-viewport';
45
45
  import {
46
46
  lookDeclaresViewportColors,
47
47
  nativeViewportLook,
48
48
  subscribeNativeSelectionTheme,
49
- } from '@volter/sdk/kit/native-selection-style';
50
- import {
51
- type Object3DDocumentPersistenceSession,
52
- object3DDocumentWritePolicy,
53
- } from '@volter/sdk/kit/object3d-document-write-policy';
54
- import { registerPerformanceSource } from '@volter/sdk/kit/performance-sources';
49
+ } from '@volter/sdk/native-selection-style';
50
+ import type { AssetDocumentPersistenceSession } from '@volter/sdk/authoring/document-persistence-types';
51
+ import { assetDocumentWritePolicy } from '@volter/sdk/asset-document-write-policy';
55
52
  import {
56
53
  assetSubjectApplies,
57
54
  documentStageContext,
58
55
  focusedStageStore,
59
56
  identityRowApplies,
60
57
  studioStageApplies,
61
- } from '@volter/sdk/kit/stage-context';
62
- import { registerStageStore } from '@volter/sdk/kit/stage-store-registry';
63
- import { announceDocumentStage, registerDocumentViewport } from '@volter/sdk/kit/document-viewports';
58
+ } from '@volter/sdk/stage-context';
59
+ import { registerStageStore } from '@volter/sdk/stage-store-registry';
60
+ import { announceDocumentStage, registerDocumentViewport } from '@volter/sdk/document-viewports';
64
61
  import { sceneDocumentViewport } from '../scene-document-viewport';
65
62
  import { threeStageTransformChrome } from './stage-transform-chrome';
66
- import { RetainedDocumentStates } from '@volter/sdk/kit/retained-document-states';
63
+ import { RetainedDocumentStates } from '../retained-document-states';
67
64
  import { perspectiveDistanceToFitBox } from '../three-viewport/camera-fit';
68
65
  import {
69
66
  acquireInteractiveViewportRenderer,
70
67
  type InteractiveViewportRendererLease,
71
68
  } from '../three-viewport/interactive-renderer';
72
- import type { ThreeViewportProjection } from '@volter/sdk/kit/three-viewport-presentation';
69
+ import type { ThreeViewportProjection } from '../three-viewport-presentation';
73
70
  import {
74
71
  activeViewportBreakdownDocumentId,
75
72
  markViewportConstructReady,
@@ -77,9 +74,9 @@ import {
77
74
  markViewportFirstRenderStart,
78
75
  markViewportRafResume,
79
76
  markViewportReactActive,
80
- markViewportSegment,
77
+ markViewportFirstRender,
81
78
  recordViewportFirstFrame,
82
- } from '@volter/sdk/kit/viewport-activation-timings';
79
+ } from '@volter/sdk/viewport-activation-timings';
83
80
  import { bindViewportRig, runViewportFrame } from '../../viewport-door';
84
81
  import type { StageFrameCost } from '../../viewport-api';
85
82
  import {
@@ -87,11 +84,11 @@ import {
87
84
  openWorkspaceDocuments,
88
85
  registerWorkspaceDocumentSelection,
89
86
  subscribeWorkspaceDocuments,
90
- } from '@volter/sdk/kit/workspace-document-registry';
91
- import { AssetEditorShell } from '@volter/sdk/kit/components/AssetEditorShell';
87
+ } from '@volter/sdk/workspace-document-registry';
88
+ import { AssetEditorShell } from '@volter/sdk/components/AssetEditorShell';
92
89
  import { StageOverlays } from './StageOverlays';
93
90
  import type { WorldRootStageBinding } from './world-root-binding';
94
- import { TransportStrip } from '@volter/sdk/kit/transport-strip';
91
+ import { TransportStrip } from '../transport-strip';
95
92
 
96
93
  // The stage's own keyboard actions, behind the same lazy boundary as the
97
94
  // overlays and for the same reason — a bounded host pays for neither
@@ -108,8 +105,8 @@ import {
108
105
  watchPaletteBackdrop,
109
106
  } from './standard-viewport-dressing';
110
107
  import { ViewportFurniture } from './ViewportFurniture';
111
- import { OBJECT3D_SURFACE_BUILDING, ViewportSurfaceStatus } from '@volter/sdk/kit/viewport-surface-status';
112
- import { workspaceHistoryService } from '@volter/sdk/kit/components/workspace-history';
108
+ import { Progress } from '@volter/sdk/progress';
109
+ import { workspaceHistoryService } from '@volter/sdk/components/workspace-history';
113
110
  import {
114
111
  bindViewPresentation,
115
112
  DOCUMENT_STUDIO_PRESET,
@@ -123,11 +120,12 @@ import {
123
120
  viewPresentation,
124
121
  viewPresentationSnapshot,
125
122
  type ViewportDrawMode,
126
- } from '@volter/sdk/kit/viewport-presentation';
127
- import { subscribeEnvironmentImages } from '@volter/sdk/kit/environment-images';
123
+ } from '@volter/sdk/viewport-presentation';
124
+ import { subscribeEnvironmentImages } from '@volter/sdk/environment-images';
128
125
  import { StagePresentationRig } from './standard-viewport-dressing';
129
126
  import { threeStoreForHost } from '../three-state';
130
127
  import { threeObject } from '../../adapter/three-contract';
128
+ import './StageHost.css';
131
129
 
132
130
  /** The kind of stage a document's view is, for its starting presentation: the document's own
133
131
  * kind, its id's prefix inside the workspace's `document:` wrapper
@@ -138,13 +136,12 @@ const stageKindOf = (documentId: string): string => {
138
136
  };
139
137
 
140
138
  /** WHERE THIS DOCUMENT'S BYTES GO — the one collaborator the shell installs
141
- * (`object3d-document-write-policy.ts`). The tier's source recorder, the
139
+ * (`asset-document-write-policy.ts`). The tier's source recorder, the
142
140
  * project file history and the thumbnail manifest all live behind it, so the
143
- * Asset Lab 3D document carries no editor transport in its own closure
144
- * (ARCHITECTURE-CORE §Editor chrome, "the viewport stack is separable").
141
+ * Asset Lab 3D document carries no editor transport in its own closure.
145
142
  * With no shell above it every member refuses by name, which is the same
146
143
  * refusal a read-only tier already produced. */
147
- const projectWrites = () => object3DDocumentWritePolicy();
144
+ const projectWrites = () => assetDocumentWritePolicy();
148
145
 
149
146
  /**
150
147
  * How far a BARE dock-panel stage bleeds past the box a person sees, so the
@@ -157,34 +154,6 @@ const projectWrites = () => object3DDocumentWritePolicy();
157
154
  */
158
155
  const STAGE_BLEED_PX = 12;
159
156
 
160
- function boxFromFrameBounds(
161
- bounds:
162
- | {
163
- readonly min: readonly [number, number, number];
164
- readonly max: readonly [number, number, number];
165
- }
166
- | undefined,
167
- ): THREE.Box3 | null {
168
- if (!bounds) return null;
169
- const box = new THREE.Box3(
170
- new THREE.Vector3(bounds.min[0], bounds.min[1], bounds.min[2]),
171
- new THREE.Vector3(bounds.max[0], bounds.max[1], bounds.max[2]),
172
- );
173
- return box.isEmpty() ? null : box;
174
- }
175
-
176
- /**
177
- * Per-document overrides of the standard viewport dressing
178
- * (`standard-viewport-dressing.ts`) — explicit opt-outs/opt-ins, never
179
- * re-implementations. Omitted means the standard look.
180
- *
181
- * THE SHAPE IS THE SDK'S (`ToolViewportDressing`), because a contributed
182
- * document is what asks for it: `@volter/editor-blender`'s Model document hands over
183
- * its view-locked studio through this door. One declaration, so the public
184
- * surface and the host cannot drift.
185
- */
186
- export type StandardDressingProps = ToolViewportDressing;
187
-
188
157
  /** What an editor-side source lane gets to build its authoring adapter over
189
158
  * the document's own store and scene. */
190
159
  export interface SourceDocumentAuthoringContext {
@@ -246,63 +215,23 @@ export interface Object3DDocumentViewportProps
246
215
  * SOURCE (a mounted R3F composition's stamped JSX, today). The caller that
247
216
  * knows the source lane supplies the adapter; this document constructs
248
217
  * none of them, so it stays mountable without any source lane in its
249
- * closure (ARCHITECTURE-CORE §Editor chrome, "the viewport stack is
250
- * separable"). Raw model/artifact documents omit this and remain honestly
218
+ * closure. Raw model/artifact documents omit this and remain honestly
251
219
  * read-only through the native source adapter. Consulted only when the
252
220
  * project `authoring` factory declined. MUST be referentially stable for
253
221
  * the document's lifetime, like `build`: it is an activation dependency.
254
222
  */
255
223
  readonly sourceAuthoring?: SourceDocumentAuthoringFactory;
256
- /**
257
- * Where this viewport's `WebGLRenderer` comes from.
258
- *
259
- * `own` (the default) constructs one for this mount — the right answer for a
260
- * document that lives as long as its panel does. `inspector-preview` draws
261
- * through the shared inspector-preview renderer
262
- * (`inspector-preview-renderer.ts`): that lane REMOUNTS PER SELECTION, and a
263
- * renderer construction there is ~1.9 s of frozen main thread plus a cold
264
- * shader cache and a fresh PMREM bake, every time you click something.
265
- */
224
+ /** Use the shared inspector-preview renderer for mounts replaced per selection; other
225
+ * document mounts own their renderer and shader cache for their lifetime. */
266
226
  readonly rendererLane?: 'own' | 'inspector-preview';
267
- /**
268
- * Double-click on a picked node — the document's "open THIS" gesture,
269
- * reported with the node's own `Object3D` so the owner can resolve whatever
270
- * it means by it. Single click stays plain selection; a document that
271
- * declares no handler has no open gesture at all.
272
- *
273
- * Sole caller: the `3D` board, where an exhibit opens that
274
- * story's own turntable document (`three-board/ThreeBoardDocument.tsx`).
275
- */
276
- readonly onOpenNode?: (object: THREE.Object3D) => void;
277
- /**
278
- * When set, Frame with no selection and the view presets fit THIS box
279
- * instead of `contentWorldBounds(root)`. The 3D board passes the union of
280
- * each exhibit's presence as its true-scale overview.
281
- */
282
- readonly frameBounds?: {
283
- readonly min: readonly [number, number, number];
284
- readonly max: readonly [number, number, number];
285
- };
286
- /** Optional first-paint frame distinct from the document's Frame fallback.
287
- * The 3D component board opens on one readable exhibit, while clearing the
288
- * selection and pressing Frame still restores its true-scale overview. */
289
- readonly openingFrameBounds?: {
290
- readonly min: readonly [number, number, number];
291
- readonly max: readonly [number, number, number];
292
- };
293
227
  /** Multiplies the opening fit distance; `1` fills the view. */
294
228
  readonly openingFit?: number;
295
229
  /** The kind of stage this view is, for its starting presentation
296
- * (`@volter/sdk/kit/viewport-presentation`): the document's own kind (`'model'`).
230
+ * (`@volter/sdk/viewport-presentation`): the document's own kind (`'model'`).
297
231
  * Without it the kind is read off the document id's prefix. */
298
232
  readonly stageKind?: string;
299
233
  }
300
234
 
301
- /**
302
- * Distinguishes the chromeless mounts of ONE document from each other and from
303
- * that document's viewport, so each publishes its own design-time surface
304
- * instead of overwriting a sibling's (see the registration below).
305
- */
306
235
  /** The draw modes a view's presentation carries (`ViewportDrawMode`). */
307
236
  const VIEW_DRAW_MODES: readonly ViewportDrawMode[] = [
308
237
  'solid',
@@ -316,6 +245,11 @@ const VIEW_DRAW_MODES: readonly ViewportDrawMode[] = [
316
245
  'overdraw',
317
246
  ];
318
247
 
248
+ /**
249
+ * Distinguishes the chromeless mounts of ONE document from each other and from
250
+ * that document's viewport, so each publishes its own design-time surface
251
+ * instead of overwriting a sibling's (see the registration below).
252
+ */
319
253
  let chromelessSurfaceSequence = 0;
320
254
 
321
255
  interface RetainedObject3DStageState {
@@ -375,11 +309,11 @@ function mountViewportSurface(
375
309
  lane: 'own' | 'inspector-preview',
376
310
  displayName: string,
377
311
  initialSize: { readonly width: number; readonly height: number },
378
- onContextLost?: () => void,
312
+ onContextLost: () => void,
379
313
  ): {
380
314
  canvas: HTMLCanvasElement;
381
315
  renderer: THREE.WebGLRenderer;
382
- lease: InspectorPreviewLease | InteractiveViewportRendererLease | null;
316
+ lease: InspectorPreviewLease | InteractiveViewportRendererLease;
383
317
  } {
384
318
  if (lane === 'inspector-preview') {
385
319
  const lease = acquireInspectorPreviewRenderer();
@@ -406,7 +340,6 @@ function mountViewportSurface(
406
340
  * Source lifetimes borrow it; closing waits for any asynchronous gesture
407
341
  * cleanup to return its borrow before releasing the context. */
408
342
 
409
-
410
343
  interface DocumentContentBinding {
411
344
  readonly scene: THREE.Scene;
412
345
  settle(): Promise<void>;
@@ -416,7 +349,7 @@ interface DocumentContentBinding {
416
349
  /** Persistent editor state. Source revisions own only their content binding. */
417
350
  class Object3DDocumentHost {
418
351
  readonly scene = new DocumentScene();
419
- /** This document's store, optionally supplied by its owner. */
352
+ /** This document's store, supplied by its owner. */
420
353
  readonly store: EditorShellStore;
421
354
  readonly cleanups: Array<() => void> = [];
422
355
  readonly rendererSession = new DocumentRendererSession(
@@ -427,7 +360,7 @@ class Object3DDocumentHost {
427
360
  session: Object3DDocumentSession | null = null;
428
361
  dressing: StandardViewportDressing | null = null;
429
362
  /** The stage's lighting, tone and exposure, from its view's presentation
430
- * (`standard-viewport-dressing.ts`, `kit/viewport-presentation`). */
363
+ * (`standard-viewport-dressing.ts`, `@volter/sdk/viewport-presentation`). */
431
364
  presentationRig: StagePresentationRig | null = null;
432
365
  /** Re-apply the view's presentation (after the viewport and session exist). */
433
366
  applyPresentation: (() => void) | null = null;
@@ -487,18 +420,15 @@ class Object3DDocumentHost {
487
420
  return this.closed;
488
421
  }
489
422
 
490
- /** THIS STAGE's transport — the one holder of the content-time door below.
491
- * Built from this stage's own store, because `driver` is that store's
492
- * `playState` and nothing else (`animation/stage-transport.ts`). */
493
- readonly transport: StageTransport;
423
+ /** THIS STAGE's transport — the one holder of the content-time door below. */
424
+ readonly transport = new StageTransport();
494
425
 
495
426
  constructor(
496
427
  readonly lane: 'own' | 'inspector-preview',
497
- readonly surface: ReturnType<typeof mountViewportSurface>,
498
- store?: EditorShellStore,
428
+ readonly surface: ReturnType<typeof mountViewportSurface> | ReturnType<WorldRootStageBinding['mountWorldRootSurface']>,
429
+ store: EditorShellStore,
499
430
  ) {
500
- this.store = store ?? threeStateOf(new ShellStore());
501
- this.transport = new StageTransport(this.store.shell);
431
+ this.store = store;
502
432
  }
503
433
 
504
434
  borrow(): () => void {
@@ -529,7 +459,7 @@ class Object3DDocumentHost {
529
459
  private releaseIfClosed(): void {
530
460
  if (!this.closed || this.users !== 0 || this.released) return;
531
461
  this.released = true;
532
- const { lease, renderer, canvas } = this.surface;
462
+ const { lease, canvas } = this.surface;
533
463
  for (const cleanup of [
534
464
  ...this.cleanups.splice(0),
535
465
  () => this.session?.dispose(),
@@ -537,9 +467,7 @@ class Object3DDocumentHost {
537
467
  () => this.rendererSession.dispose(),
538
468
  () => this.dressing?.dispose(),
539
469
  () => canvas.removeAttribute('data-volter-root-surface'),
540
- ...(lease
541
- ? [() => lease.release()]
542
- : [() => renderer.dispose(), () => renderer.forceContextLoss(), () => canvas.remove()]),
470
+ () => lease?.release(),
543
471
  ]) {
544
472
  try {
545
473
  cleanup();
@@ -551,33 +479,7 @@ class Object3DDocumentHost {
551
479
  }
552
480
  }
553
481
 
554
- /**
555
- * Editor-owned authoring host for a project-supplied native Object3D factory.
556
- * Project code supplies only source construction; this component owns the
557
- * real EditorViewport, document-scoped AuthoringAdapter, selection, framing,
558
- * animation preview, resize, and teardown.
559
- *
560
- * ## TIME ON THIS SURFACE — the one policy, inherited by everything mounted here
561
- *
562
- * This host is the shared stack: Asset Lab documents, story turntables, 3D board
563
- * exhibits and the inspector's preview card all render through it. So the
564
- * Edit-static rule is enforced HERE, once, rather than at each of them:
565
- *
566
- * - PRESENTATION time always advances — orbit damping, the view cube, the
567
- * standard dressing, the editor's own helpers and overlays. It is
568
- * editor-owned and it moves while the editor is in Edit.
569
- * - CONTENT time does NOT. The source's own `update(dt)` and any clip a
570
- * subject shows move only through the stage TRANSPORT, and only while a
571
- * human is holding it (`animation/stage-transport.ts`, WORK.md §The stage
572
- * transport and the animation door). A mounted
573
- * document plays nothing on its own: that is the defect this closes (a GLB
574
- * train animating in the Asset Lab while every instrument called the editor
575
- * still, owner find 2026-08-15).
576
- *
577
- * Whatever content time this surface has reached is published as its content
578
- * clock (`coverage/design-time-surfaces.ts`), so the Edit-static vitals row
579
- * measures THIS surface by name instead of speaking only for the Scene view.
580
- */
482
+ /** Shared Object3D authoring host: owns viewport, adapter, selection, framing, resize and disposal. Presentation time advances independently; source update and clips advance only through the stage transport. Publish content time for this surface. */
581
483
  export function Object3DDocumentViewport({
582
484
  documentId,
583
485
  sourcePath,
@@ -601,9 +503,6 @@ export function Object3DDocumentViewport({
601
503
  sourceAuthoring,
602
504
  dressing,
603
505
  rendererLane = 'own',
604
- onOpenNode,
605
- frameBounds,
606
- openingFrameBounds,
607
506
  openingFit,
608
507
  openingView,
609
508
  presentation: documentPresentation,
@@ -624,40 +523,12 @@ export function Object3DDocumentViewport({
624
523
  if (chromeless) return;
625
524
  return announceDocumentStage(documentId);
626
525
  }, [documentId, chromeless]);
627
- // The SHELL store, not this host's own: what a stage is showing is a fact
628
- // about the workspace's document, and `stage-context.ts` is where every
629
- // stage capability asks it (ARCHITECTURE-CORE §One stage). OPTIONAL because
630
- // this host is a bounded-host surface — `@volter/editor-blender`'s Model document
631
- // mounts it with no `EditorProvider` above it, and the throwing hook took
632
- // that document off the screen with "useEditorStore must be used within
633
- // <EditorProvider>" (measured live, 2026-09-18). No shell, no shell frame.
526
+ // Shell React context is optional for a bounded stage. Shell overlays mount only when that provider is present.
634
527
  const shellStore = optionalThreeStateOf(useOptionalEditorStore());
635
- /**
636
- * THE STORE THE STAGE CONTEXT IS ASKED AGAINST — the session's own, reached
637
- * without a React provider.
638
- *
639
- * `optionalThreeStateOf(useOptionalEditorStore())` above answers a NARROWER question than the
640
- * context needs: it is whether the shell's own React tree is above this
641
- * host, and for every package-contributed document it is not.
642
- * `@volter/editor-blender`'s Model document mounts this host through `ToolHost`, so the
643
- * context store is null there — and reading only it left `stageCtx` null,
644
- * which made EVERY condition in `stage-context.ts` unanswerable for that
645
- * document. That is a capability decided by where a document's React tree
646
- * happens to mount, which is the origin-deciding reading §One stage
647
- * retired. Without the context, the studio arm of `studioStageApplies`
648
- * could never fire for a model no matter what the look declared.
649
- *
650
- * `shellStoreForHost()` supplies the session store that
651
- * `focusedStageStore()` falls back to. It is deliberately NOT used for the
652
- * overlay set or the shell readout below: those are the SHELL's estate and
653
- * their condition is the shell's React presence, exactly as §One stage
654
- * unit 3 measured it — a stage that is not inside the shell has no shell
655
- * overlays. That gap is real and named, not closed here: unit 1's bar is
656
- * that the Model document's Blender frame does not move.
657
- */
528
+ /** Use the host’s session store for stage conditions even when ToolHost mounts outside the shell’s React tree. Shell overlays still require the shell provider. */
658
529
  const contextStore = shellStore ?? threeStoreForHost();
659
530
  /**
660
- * WHAT THIS STAGE IS SHOWING, asked once (ARCHITECTURE-CORE §One stage).
531
+ * WHAT THIS STAGE IS SHOWING, asked once.
661
532
  * `chrome` is the host's own answer and nothing infers it: an EMBEDDED
662
533
  * preview has no workspace document at all, which is exactly why the
663
534
  * subject would come back `unknown` for one.
@@ -668,10 +539,8 @@ export function Object3DDocumentViewport({
668
539
  /**
669
540
  * THE STUDIO PRESENTATION — the alpha clear, the suppressed palette
670
541
  * backdrop, the identity-row frame and the asset flavour of this stage's
671
- * provenance. It replaced a `documentKind` prop the CALLER chose, which is
672
- * exactly the origin-of-the-document reading §One stage retired: every
673
- * mount that passed `documentKind="asset"` is a story, an artifact or an
674
- * embedded preview, and each still answers true.
542
+ * provenance. It follows the stage context and look; stories, artifacts
543
+ * and embedded previews each use studio presentation.
675
544
  */
676
545
  // The LOOK is one of `studioStageApplies`'s live inputs (a data subject takes
677
546
  // the studio only where the look leaves the backdrop to the editor), and the
@@ -695,11 +564,8 @@ export function Object3DDocumentViewport({
695
564
  const hasShell = stageCtx !== null && identityRowApplies(stageCtx);
696
565
  const hasShellRef = useRef(hasShell);
697
566
  hasShellRef.current = hasShell;
698
- // The ASSET FLAVOUR of this stage's provenance: the source adapter's
699
- // `documentKind` ("derived asset", `model-asset`) and the performance
700
- // source's kind. It is the studio presentation MINUS the data arm — a mesh
701
- // module opens in an isolation scene but is not a derived asset — and was
702
- // read off `studioStage` while the two were the same answer.
567
+ // Use the asset-stage flavor for the source adapter’s document kind and performance source.
568
+ // Studio presentation also applies to source modules, which are not derived assets.
703
569
  const assetSubject = stageCtx !== null && assetSubjectApplies(stageCtx);
704
570
  const assetSubjectRef = useRef(assetSubject);
705
571
  assetSubjectRef.current = assetSubject;
@@ -775,15 +641,6 @@ export function Object3DDocumentViewport({
775
641
  documentHostRef.current = null;
776
642
  };
777
643
  }, [documentId, rendererLane, retainedState, surfaceAttached, worldRoot]);
778
- // Read through a ref: the one big lifetime effect below owns the canvas
779
- // listener, and re-running it on a new handler identity would tear the whole
780
- // renderer down and rebuild it.
781
- const onOpenNodeRef = useRef(onOpenNode);
782
- onOpenNodeRef.current = onOpenNode;
783
- const frameBoundsRef = useRef(frameBounds);
784
- frameBoundsRef.current = frameBounds;
785
- const openingFrameBoundsRef = useRef(openingFrameBounds);
786
- openingFrameBoundsRef.current = openingFrameBounds;
787
644
  const openingViewRef = useRef(openingView);
788
645
  openingViewRef.current = openingView;
789
646
  const documentPresentationRef = useRef(documentPresentation);
@@ -817,14 +674,13 @@ export function Object3DDocumentViewport({
817
674
  root: THREE.Object3D;
818
675
  animations: readonly THREE.AnimationClip[];
819
676
  } | null>(null);
820
- const persistenceSessionRef = useRef<Promise<Object3DDocumentPersistenceSession> | null>(null);
677
+ const persistenceSessionRef = useRef<Promise<AssetDocumentPersistenceSession> | null>(null);
821
678
  const [error, setError] = useState<string | null>(null);
822
679
  const [surfaceStatus, setSurfaceStatus] = useState<'building' | 'ready'>('building');
823
680
  // A world root's own mount state and the binding that draws its overlays.
824
681
  const [worldRootBinding, setWorldRootBinding] = useState<WorldRootStageBinding | null>(null);
825
682
  const [designMountStatus, setDesignMountStatus] = useState<'mounting' | 'ready'>('mounting');
826
683
  const [designRootIds, setDesignRootIds] = useState<readonly string[]>([]);
827
- const cameraPreviewRef = useRef<HTMLDivElement | null>(null);
828
684
  const [projection, setProjection] = useState<ThreeViewportProjection>('perspective');
829
685
  // The live authoring adapter this document's inspection composes from. It
830
686
  // only exists once the graph is built, so it is state rather than a ref:
@@ -864,7 +720,8 @@ export function Object3DDocumentViewport({
864
720
  const canvasHost = canvasHostRef.current;
865
721
  if (!container || !canvasHost || documentHostRef.current) return;
866
722
  const stats = shellStatsRef.current;
867
- if (!stats) {
723
+ const store = threeStoreForHost();
724
+ if (!stats || !store) {
868
725
  // The world root IS the shell's own subject — its readout, its
869
726
  // adoption stack, its transport. A bounded host has none of that.
870
727
  setError('The world root needs the editor shell above it; this host has none.');
@@ -880,7 +737,7 @@ export function Object3DDocumentViewport({
880
737
  // The world is the session's own subject: Play adoption, ingest, the
881
738
  // project history and the host door's hierarchy facet all address the
882
739
  // SESSION store, so this stage runs on it rather than a private one.
883
- threeStoreForHost() ?? undefined,
740
+ store,
884
741
  );
885
742
  documentHostRef.current = host;
886
743
  host.cleanups.push(registerStageStore(documentId, host.store.shell));
@@ -898,7 +755,6 @@ export function Object3DDocumentViewport({
898
755
  canvas: host.surface.canvas,
899
756
  renderer: host.surface.renderer,
900
757
  stats,
901
- cameraPreview: () => cameraPreviewRef.current,
902
758
  onMountStatus: setDesignMountStatus,
903
759
  onRootIds: setDesignRootIds,
904
760
  runFrame: (delta) => runViewportFrame(documentId, delta),
@@ -910,21 +766,16 @@ export function Object3DDocumentViewport({
910
766
  settle: () => Promise.resolve(),
911
767
  dispose: async () => stage.dispose(),
912
768
  };
913
- // The world's tree arrives asynchronously and keeps changing, so its
914
- // clip subjects are rescanned on the store's change notification,
915
- // throttled, and not while Play drives time.
916
- // The scene the stage DRAWS: an R3F world renders the session store's
917
- // scene, not the stage's own (`world-root-stage.ts`), and scanning the
918
- // latter found no subject on a world whose enemies each had a mixer.
769
+ // Rescan clip subjects on throttled store changes as the world tree mounts and updates.
770
+ // Inspect the scene the stage draws, including a session store’s scene.
919
771
  const clipScan = scanClipSubjects(() => host.store.scene ?? stage.scene, host.transport, {
920
772
  // A game makes its mixers as its world runs, not when the store changes.
921
- rescanOnLiveMixers: () => host.store.shell.playState === 'stopped',
773
+ rescanOnLiveMixers: true,
922
774
  });
923
775
  let lastClipScan = 0;
924
776
  host.cleanups.push(() => clipScan.dispose());
925
777
  host.cleanups.push(
926
778
  host.store.shell.subscribe(() => {
927
- if (host.store.shell.playState !== 'stopped') return;
928
779
  const now = performance.now();
929
780
  if (now - lastClipScan < CLIP_RESCAN_INTERVAL_MS) return;
930
781
  lastClipScan = now;
@@ -939,9 +790,10 @@ export function Object3DDocumentViewport({
939
790
  camera: stage.viewport.camera,
940
791
  drawCamera: () => stage.viewport.renderCamera,
941
792
  orbit: stage.viewport.orbitControls,
793
+ projection: stage.viewport.projectionOwner,
942
794
  scene: stage.scene,
795
+ renderer: host.surface.renderer,
943
796
  },
944
- stage.present,
945
797
  stage.setHelper,
946
798
  { documentId },
947
799
  ),
@@ -1015,7 +867,7 @@ export function Object3DDocumentViewport({
1015
867
  const { renderer, canvas, lease } = host.surface;
1016
868
  const store = host.store;
1017
869
  // This stage's store, reachable by the shared panels through
1018
- // `focusedStageStore()` (ARCHITECTURE-CORE §One stage unit 4). A
870
+ // `focusedStageStore()`. A
1019
871
  // CHROMELESS preview is not a stage anything can focus — it has no
1020
872
  // workspace document — so it registers nothing.
1021
873
  if (!chromeless) {
@@ -1038,7 +890,7 @@ export function Object3DDocumentViewport({
1038
890
  const documentState = { root: source.root, animations: source.root.animations };
1039
891
  let defaultAdapter: SourceObject3DAuthoringAdapter | null = null;
1040
892
  let projectAuthoring: ToolObject3DDocumentAuthoring | null = null;
1041
- let activePersistenceSession: Promise<Object3DDocumentPersistenceSession> | null = null;
893
+ let activePersistenceSession: Promise<AssetDocumentPersistenceSession> | null = null;
1042
894
  let interactionExtension: ReturnType<NonNullable<typeof interaction>['setup']> | null = null;
1043
895
  let gestureController: Object3DGestureController | null = null;
1044
896
  let pointerDownListener: ((event: PointerEvent) => void) | null = null;
@@ -1109,8 +961,6 @@ export function Object3DDocumentViewport({
1109
961
  // animation binding writes through — checksum-guarded whole-file
1110
962
  // replace, recorded in canonical history — never the resource
1111
963
  // route, which refuses executable source by rule.
1112
- if (!document)
1113
- throw new Error('Project history is unavailable; the edit was not saved.');
1114
964
  const history = workspaceHistoryService();
1115
965
  if (!history) {
1116
966
  throw new Error('Project history is unavailable; the edit was not saved.');
@@ -1133,7 +983,7 @@ export function Object3DDocumentViewport({
1133
983
  if (!persistence) {
1134
984
  throw new Error('This Asset Lab document has no persistence binding.');
1135
985
  }
1136
- if (!activePersistenceSession || !document) {
986
+ if (!activePersistenceSession) {
1137
987
  throw new Error('Project history is unavailable; the edit was not saved.');
1138
988
  }
1139
989
  // The pipe's ack for THIS commit: `persisted` is whether a byte moved
@@ -1181,11 +1031,8 @@ export function Object3DDocumentViewport({
1181
1031
  });
1182
1032
  defaultAdapter.dispose();
1183
1033
  }
1184
- // Project authoring may establish the module-local artifact that its
1185
- // serializers read (a rig document is the concrete example). Open the
1186
- // persistence baseline only after that factory has adopted the root;
1187
- // opening it before authoring made every clean rig mount reject with
1188
- // "Object3D is not a project rig artifact."
1034
+ // Open the persistence baseline after the authoring factory adopts the root and
1035
+ // establishes any module-local artifact its serializers read.
1189
1036
  activePersistenceSession =
1190
1037
  persistence && history
1191
1038
  ? projectWrites().openPersistence({
@@ -1214,7 +1061,8 @@ export function Object3DDocumentViewport({
1214
1061
  // operator into every `toneMapped` program.
1215
1062
  renderer.toneMapping = dressingToneMapping ?? THREE.ACESFilmicToneMapping;
1216
1063
  renderer.shadowMap.enabled = true;
1217
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
1064
+ // PCFShadowMap supplies the soft-shadow filter supported by this three.js release.
1065
+ renderer.shadowMap.type = THREE.PCFShadowMap;
1218
1066
  renderer.info.autoReset = false;
1219
1067
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
1220
1068
  if (studioStage) renderer.setClearColor(0x000000, 0);
@@ -1235,8 +1083,6 @@ export function Object3DDocumentViewport({
1235
1083
  !studioStage &&
1236
1084
  background === undefined &&
1237
1085
  dressingBackground !== false,
1238
- // The stage's lights are the presentation rig's, below.
1239
- keyLight: false,
1240
1086
  grid: dressingGrid === true,
1241
1087
  content: source.root,
1242
1088
  });
@@ -1334,7 +1180,7 @@ export function Object3DDocumentViewport({
1334
1180
  const idOf = (start: THREE.Object3D | null): string | null => {
1335
1181
  for (let object = start; object; object = object.parent) {
1336
1182
  const id =
1337
- presentation?.hierarchy.idForObject3D?.(object) ?? current.hierarchy.idForObject3D?.(object);
1183
+ presentation?.hierarchy.idForNativeObject?.(object) ?? current.hierarchy.idForNativeObject?.(object);
1338
1184
  if (id) return id;
1339
1185
  }
1340
1186
  return null;
@@ -1382,25 +1228,10 @@ export function Object3DDocumentViewport({
1382
1228
  // The same expression that places the DOM furniture, for the one
1383
1229
  // piece of furniture that is drawn on the canvas instead.
1384
1230
  chromeInsetPx: chromeless || hasShell || fillContainer ? 0 : STAGE_BLEED_PX,
1385
- // A document that turns the dressing's key light off has said it
1386
- // lights itself, and the editor's design-time rig answers exactly
1387
- // the same question. MEASURED on the Model stage before this
1388
- // existed: the rig's ambient 0.5 + directional 1.0 sit on
1389
- // `EDITOR_LAYER`, but three filters lights by the CAMERA's layers
1390
- // (`WebGLRenderer.projectObject`) and the one camera that draws
1391
- // this stage enables that layer to see the grid and the gizmo —
1392
- // so the layer scopes them away from nothing at all and they lit
1393
- // the content.
1231
+ // View presentation supplies the lighting; disable the viewport's built-in rig.
1394
1232
  lightRig: false,
1395
1233
  });
1396
1234
  if (dressingGrid === true) host.viewport.grid.removeFromParent();
1397
- const open = (event: MouseEvent) => {
1398
- const id = pick(event.clientX, event.clientY);
1399
- const object = id && host.adapter ? threeObject(host.adapter.hierarchy, id) : null;
1400
- if (object) onOpenNodeRef.current?.(object);
1401
- };
1402
- container.addEventListener('dblclick', open);
1403
- host.cleanups.push(() => container.removeEventListener('dblclick', open));
1404
1235
  }
1405
1236
  const viewport = host.viewport;
1406
1237
  if (dressingViewLocked) {
@@ -1492,19 +1323,7 @@ export function Object3DDocumentViewport({
1492
1323
  event.stopPropagation();
1493
1324
  };
1494
1325
  pointerDownListener = (event) => {
1495
- // A control painted OVER the stage owns its own press. These
1496
- // listeners sit on the CONTAINER in the capture phase, so they run
1497
- // before that control's own handler and a `stopPropagation` there
1498
- // would be too late: pressing the rolled-back-operation notice's
1499
- // Dismiss also started a box-select on the mesh (measured — the
1500
- // notice's text changed from the failed op to "Box select").
1501
- // The viewport's navigation cluster (`ViewportFurniture`) is the
1502
- // same case and was missing here: its Pan button drives the camera
1503
- // from `onPointerDown`, which React dispatches at the root, so
1504
- // claiming the press here meant `startPan` never ran and the
1505
- // control was inert (measured — the camera floats were identical
1506
- // before and after a full pointer gesture, while the cluster's
1507
- // `onClick` buttons beside it moved the camera every time).
1326
+ // Let controls painted over the stage own their pointer presses before the container’s capture listeners begin selection.
1508
1327
  if (
1509
1328
  event.target instanceof Element &&
1510
1329
  event.target.closest('[role="alert"], [role="toolbar"]') !== null
@@ -1566,7 +1385,6 @@ export function Object3DDocumentViewport({
1566
1385
  const previousPersistence = persistenceSessionRef.current;
1567
1386
  const previousInteraction = interactionExtensionRef.current;
1568
1387
  const previousBackground = host.session?.neutralBackgroundTexture();
1569
- const previousLights = host.dressing.lights.map((light) => light.visible);
1570
1388
  const cleanupCount = host.cleanups.length;
1571
1389
  const wasInitialized = host.initialized;
1572
1390
  rollback = () => {
@@ -1587,7 +1405,7 @@ export function Object3DDocumentViewport({
1587
1405
  store.objectMap.clear();
1588
1406
  for (const [id, object] of previousObjects) store.objectMap.set(id, object);
1589
1407
  if (previous)
1590
- store.bindScene(previous.scene, renderer, viewport.batchedRenderer, viewport.camera);
1408
+ store.bindScene(previous.scene, renderer, viewport.camera);
1591
1409
  if (previousRoot && previousAdapter)
1592
1410
  host.session?.replaceContent(previousRoot, previousAdapter);
1593
1411
  documentStateRef.current = previousDocument;
@@ -1597,9 +1415,6 @@ export function Object3DDocumentViewport({
1597
1415
  setDocumentAdapter(previousAdapter);
1598
1416
  if (previousBackground !== undefined)
1599
1417
  host.session?.setNeutralBackground(previousBackground);
1600
- host.dressing?.lights.forEach((light, index) => {
1601
- light.visible = previousLights[index] ?? true;
1602
- });
1603
1418
  if (previousRoot) {
1604
1419
  host.dressing?.frameContent(previousRoot);
1605
1420
  host.presentationRig?.placeFloor(previousRoot);
@@ -1612,7 +1427,7 @@ export function Object3DDocumentViewport({
1612
1427
  host.adapter = adapter;
1613
1428
  host.presentationAdapter = presentationAdapter;
1614
1429
  host.content = binding;
1615
- store.bindScene(scene, renderer, viewport.batchedRenderer, viewport.camera);
1430
+ store.bindScene(scene, renderer, viewport.camera);
1616
1431
  store.setOrbitTarget(viewport.orbitControls.target);
1617
1432
  store.objectMap.clear();
1618
1433
  scene.traverse((object) => {
@@ -1622,12 +1437,10 @@ export function Object3DDocumentViewport({
1622
1437
  // too. A Blender document names its objects only once the engine's outliner rows
1623
1438
  // arrive, often after this bind; the presentation ids exist from the first frame.
1624
1439
  const id =
1625
- presentationAdapter?.hierarchy.idForObject3D?.(object) ?? adapter.hierarchy.idForObject3D?.(object);
1440
+ presentationAdapter?.hierarchy.idForNativeObject?.(object) ?? adapter.hierarchy.idForNativeObject?.(object);
1626
1441
  if (id) store.objectMap.set(id, object);
1627
1442
  });
1628
1443
  if (!host.session) {
1629
- const overviewFrame = boxFromFrameBounds(frameBoundsRef.current);
1630
- const openingFrame = boxFromFrameBounds(openingFrameBoundsRef.current) ?? overviewFrame;
1631
1444
  // The view's camera (`ViewportCamera`): its field of view, and the direction it opens
1632
1445
  // from when the document states none.
1633
1446
  const viewCamera = viewPresentation(documentId).camera;
@@ -1642,7 +1455,7 @@ export function Object3DDocumentViewport({
1642
1455
  .copy(target)
1643
1456
  .addScaledVector(new THREE.Vector3(...viewCamera.opening).normalize(), distance);
1644
1457
  }
1645
- if (!openingFrame) viewport.focusOn(source.root);
1458
+ viewport.focusOn(source.root);
1646
1459
  if (stated) {
1647
1460
  const target = new THREE.Vector3(...stated.target);
1648
1461
  const direction = new THREE.Vector3(...stated.direction).normalize();
@@ -1653,7 +1466,7 @@ export function Object3DDocumentViewport({
1653
1466
  if (stated.up) viewport.camera.up.set(...stated.up);
1654
1467
  viewport.orbitControls.target.copy(target);
1655
1468
  } else if (statedDirection) {
1656
- const box = openingFrame ?? contentWorldBounds(source.root);
1469
+ const box = contentWorldBounds(source.root);
1657
1470
  const center = box.getCenter(new THREE.Vector3());
1658
1471
  const direction = new THREE.Vector3(cameraX, cameraY, cameraZ).normalize();
1659
1472
  const distance =
@@ -1674,7 +1487,6 @@ export function Object3DDocumentViewport({
1674
1487
  : background === undefined
1675
1488
  ? host.dressing.backgroundTexture
1676
1489
  : new THREE.Color(background),
1677
- overviewFrame,
1678
1490
  );
1679
1491
  host.session.selectionOutlineEnabled = !shared && selectionOutlineRef.current;
1680
1492
  host.session.setCameraViewSource(cameraViewRef.current ?? null);
@@ -1686,13 +1498,8 @@ export function Object3DDocumentViewport({
1686
1498
  const session = host.session;
1687
1499
  host.cleanups.push(
1688
1500
  watchPaletteBackdrop(() => {
1689
- // THE LOOK'S BACKDROP, in both directions and through the
1690
- // session's neutral backdrop, which repaints what is showing: a
1691
- // palette's flat viewport background when it names one, the
1692
- // dressing's gradient re-derived for the palette otherwise.
1693
- // Painting the flat colour straight onto the scene left it there
1694
- // when the next palette named none (Plotter's paper stayed under
1695
- // Classic).
1501
+ // Refresh the session's neutral backdrop from the palette's flat background,
1502
+ // or a newly derived gradient when the palette names no flat background.
1696
1503
  const flat = nativeViewportLook(canvas).background;
1697
1504
  const previous = host.defaultBackground;
1698
1505
  host.defaultBackground =
@@ -1771,12 +1578,7 @@ export function Object3DDocumentViewport({
1771
1578
  if (viewPresentation(documentId).drawMode !== mode)
1772
1579
  setViewPresentation(documentId, { drawMode: mode as ViewportDrawMode });
1773
1580
  };
1774
- // A document whose FILE states its draw mode keeps a person's pick there (a .blend's 3D
1775
- // View), so the file's statement is where the session starts. Otherwise a mode the person
1776
- // chose and the view restored is theirs. Only a view with neither is given the session's.
1777
- // Then the view takes the session's mode either way: a stored choice older than the file's
1778
- // statement would otherwise keep lighting the view for the mode it no longer draws (the
1779
- // Blender presenter dressed in Solid's materials under a Material Preview cell).
1581
+ // Initialize draw mode from the document’s file, then the restored view, then the session default. Synchronize the view to the resulting session mode.
1780
1582
  const restoredMode = ownLayer?.drawMode ?? viewPresentationSnapshot(documentId).drawMode;
1781
1583
  if (restoredMode !== undefined && viewModes.has(restoredMode) && host.session)
1782
1584
  host.session.setMode(restoredMode);
@@ -1900,7 +1702,8 @@ export function Object3DDocumentViewport({
1900
1702
  ? host.darkenContentLights()
1901
1703
  : 0;
1902
1704
  rig.update(documentSession.camera());
1903
- reportViewDraw(documentId, {
1705
+ // A sampled photograph draws many times; it is one draw to report.
1706
+ if (documentSession.photographSampleIndex() === 0) reportViewDraw(documentId, {
1904
1707
  source: drawSource,
1905
1708
  presetId: rig.presetId(),
1906
1709
  presetLights: rig.lightsVisible(),
@@ -1919,24 +1722,21 @@ export function Object3DDocumentViewport({
1919
1722
  host.syncHostScene?.();
1920
1723
  const mode = documentSession.presentation().mode;
1921
1724
  const navigation = interactive && ['solid', 'preview', 'clay', 'unlit', 'matcap'].includes(mode);
1922
- return source.prepareDraw?.(documentSession.camera(), {interactive: navigation, height: renderer.domElement.height, multiDraw: renderer.extensions.has('WEBGL_multi_draw'), renderer});
1725
+ return source.prepareDraw?.(documentSession.camera(), {interactive: navigation, height: renderer.domElement.height, multiDraw: renderer.extensions.has('WEBGL_multi_draw'), renderer, drawMode: mode, pixelScale: documentSession.drawPixelScale(), photograph: documentSession.photographing()});
1923
1726
  });
1924
1727
  // This loop draws the compass and every other overlay pass over the
1925
1728
  // document's own render, so it — and only it — can serve the chrome
1926
1729
  // door a frame that matches the screen.
1927
1730
  documentSession.setPresentsFrames(true);
1928
- // THE CONTENT-TIME DOOR, re-created in Step 3 with exactly one caller
1929
- // (the transport's `tick` below). The mixer line the deleted estate
1930
- // had here is gone for good: subjects seek THEMSELVES, so content time
1931
- // is only ever the bookkeeping plus the source's own update.
1731
+ // Advance content-time bookkeeping and the source’s update. Animation subjects seek
1732
+ // themselves through their own transport.
1932
1733
  const advanceContent = (deltaSeconds: number): void => {
1933
1734
  if (disposed || !(deltaSeconds > 0) || !Number.isFinite(deltaSeconds)) return;
1934
1735
  host.contentSeconds += deltaSeconds;
1935
1736
  host.transportAdvances++;
1936
1737
  source.update?.(deltaSeconds);
1937
1738
  };
1938
- // What this stage can show at a time. Scanned once here; the world
1939
- // root's stage rescans (below) because its tree arrives later.
1739
+ // Scan this source's animation subjects once. World-root stages rescan as their trees update.
1940
1740
  const clipScan = scanClipSubjects(source.root, host.transport);
1941
1741
  host.cleanups.push(() => clipScan.dispose());
1942
1742
  const retainedTransport = retainedState.transport;
@@ -1951,37 +1751,41 @@ export function Object3DDocumentViewport({
1951
1751
  retainedState.transport = null;
1952
1752
  }
1953
1753
  let previousTime = performance.now();
1954
- // DRAWING ON CHANGE. A source that announces its changes
1955
- // (`ToolObject3DPreviewSource.onChange`) is drawn only when something
1956
- // changed; one that does not is drawn every frame, as before. The loop
1957
- // itself still runs every frame -- transport, controls, flights and
1958
- // presence advance there -- and what is skipped is the render: the
1959
- // scene, the composer's passes and the compass. MEASURED before: an
1960
- // idle Model document spent 43% of the main thread drawing the same
1961
- // picture (4 s at load ~30: 372 frames, 1.7 s in the loop, 0.18 s of
1962
- // it WebGL calls, the rest three's render and the outline passes).
1963
- //
1964
- // What draws a frame: the content's own announcement; the stage
1965
- // store; pointer, wheel and key input on the stage (hover and gizmo
1966
- // highlights follow the pointer); the editor acting on a stage from
1967
- // outside those (`stage-invalidation.ts`); a moved camera or a resized
1968
- // surface; playback; a flight or a pending photograph
1969
- // (`needsFrame`); another participant's markers; particle systems;
1970
- // and an interaction extension that refines every frame.
1754
+ // Draw on announced content changes, stage state or interaction, camera/size changes, playback, flights, captures, presence, particles and active interaction extensions. Sources without onChange draw each frame; the presentation loop always advances.
1971
1755
  const drawsOnChange = typeof source.onChange === 'function';
1972
1756
  let dirty = true;
1757
+ // Every signal, counted: a signal that arrives while a frame draws (a source answering its
1758
+ // own draw) is not cleared by that frame, and the count is the content's REVISION, which a
1759
+ // live accumulation's sum belongs to.
1760
+ let changeCount = 0;
1973
1761
  const markDirty = (): void => {
1974
1762
  dirty = true;
1763
+ changeCount++;
1764
+ };
1765
+ // A pointer moving over the stage draws a frame (hover highlights follow it), but it is a
1766
+ // change of the picture only when it changes what the stage draws: the furniture's hover
1767
+ // and drag state (`EditorViewport.interactionState`) or the camera, both compared below.
1768
+ // A press, a release, the wheel and keys still count as changes, and so does a move with
1769
+ // a button held: a drag moves things whether or not what it moves signals (a gizmo drag in
1770
+ // a Blender view writes the object's matrix in place). An active drag or gesture is a
1771
+ // change on every frame too (`changedSinceDrawn`).
1772
+ let inputPending = false;
1773
+ const markInput = (event: Event): void => {
1774
+ if ((event as PointerEvent).buttons) markDirty();
1775
+ else inputPending = true;
1975
1776
  };
1976
1777
  if (drawsOnChange) {
1977
1778
  const stopSource = source.onChange!(markDirty);
1978
1779
  const stopStore = store.shell.subscribe(markDirty);
1979
- const inputs = ['pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'wheel', 'keydown', 'keyup'] as const;
1980
- for (const type of inputs) container.addEventListener(type, markDirty, { capture: true, passive: true });
1780
+ const changes = ['pointerdown', 'pointerup', 'wheel', 'keydown', 'keyup'] as const;
1781
+ const moves = ['pointermove', 'pointerleave'] as const;
1782
+ for (const type of changes) container.addEventListener(type, markDirty, { capture: true, passive: true });
1783
+ for (const type of moves) container.addEventListener(type, markInput, { capture: true, passive: true });
1981
1784
  stopDrawSignals = () => {
1982
1785
  stopSource();
1983
1786
  stopStore();
1984
- for (const type of inputs) container.removeEventListener(type, markDirty, { capture: true });
1787
+ for (const type of changes) container.removeEventListener(type, markDirty, { capture: true });
1788
+ for (const type of moves) container.removeEventListener(type, markInput, { capture: true });
1985
1789
  };
1986
1790
  }
1987
1791
  let drawnGeneration = -1;
@@ -1992,13 +1796,36 @@ export function Object3DDocumentViewport({
1992
1796
  let drawnBackground: unknown = undefined;
1993
1797
  let drawnToneMapping: THREE.ToneMapping | null = null;
1994
1798
  let drawnExposure = Number.NaN;
1995
- const mustDraw = (advanced: number): boolean => {
1799
+ let drawnInteraction = '';
1800
+ // The signals already taken when the frame being drawn began.
1801
+ let changeCountAtDraw = 0;
1802
+ let generationAtDraw = -1;
1803
+ // What a draw mirrors into the stage's scene (the host's world, its tone), BY VALUE: a
1804
+ // backdrop colour is handed over as a fresh instance on every draw, so identity would read
1805
+ // every draw as a change. A still frame that changes it is followed by a changed one.
1806
+ const valueOf = (value: THREE.Color | THREE.Texture | null): string =>
1807
+ !value ? '-' : (value as THREE.Color).isColor ? `#${(value as THREE.Color).getHexString()}` : (value as THREE.Texture).uuid;
1808
+ const mirroredState = (): readonly unknown[] => {
1809
+ const fog = host.scene.fog as (THREE.Fog & THREE.FogExp2) | null;
1810
+ return [
1811
+ valueOf(host.scene.background as THREE.Color | THREE.Texture | null),
1812
+ valueOf(host.scene.environment),
1813
+ fog ? `${fog.color.getHexString()}|${fog.near}|${fog.far}|${fog.density}` : '-',
1814
+ host.scene.backgroundIntensity, host.scene.backgroundBlurriness, host.scene.environmentIntensity,
1815
+ renderer.toneMapping, renderer.toneMappingExposure,
1816
+ ];
1817
+ };
1818
+ // What changed since the last drawn frame. A frame drawn with nothing changed (a
1819
+ // presented-frame request, the live view's accumulation going on) is a still one.
1820
+ const changedSinceDrawn = (advanced: number): boolean => {
1996
1821
  if (!drawsOnChange || dirty || advanced > 0) return true;
1997
1822
  if (stageGeneration() !== drawnGeneration) return true;
1998
- if (documentSession.needsFrame() || host.presence?.live()) return true;
1823
+ if (host.presence?.live()) return true;
1999
1824
  if (interactionExtension?.prepareFrame || interactionExtension?.update) return true;
2000
1825
  if (((viewport.batchedRenderer as unknown as { batches?: readonly unknown[] }).batches?.length ?? 0) > 0)
2001
1826
  return true;
1827
+ if (viewport.interacting() || gestureController?.hasActiveGesture()) return true;
1828
+ if (viewport.interactionState() !== drawnInteraction) return true;
2002
1829
  const camera = documentSession.camera();
2003
1830
  camera.updateMatrixWorld();
2004
1831
  renderer.getDrawingBufferSize(currentSize);
@@ -2010,8 +1837,10 @@ export function Object3DDocumentViewport({
2010
1837
  renderer.toneMappingExposure !== drawnExposure;
2011
1838
  };
2012
1839
  const noteDrawn = (): void => {
2013
- dirty = false;
2014
- drawnGeneration = stageGeneration();
1840
+ dirty = changeCount !== changeCountAtDraw;
1841
+ inputPending = false;
1842
+ drawnGeneration = generationAtDraw;
1843
+ drawnInteraction = viewport.interactionState();
2015
1844
  const camera = documentSession.camera();
2016
1845
  drawnCamera.copy(camera.matrixWorld);
2017
1846
  drawnProjection.copy(camera.projectionMatrix);
@@ -2022,15 +1851,12 @@ export function Object3DDocumentViewport({
2022
1851
  };
2023
1852
  let navigationMeasurement: THREE.Quaternion | null = null;
2024
1853
  const animate = (time: number, resumed: boolean) => {
2025
- if (disposed || !renderer || !viewport) return;
1854
+ if (disposed) return;
2026
1855
  const activeRenderer = renderer;
2027
1856
  const activeViewport = viewport;
2028
- const profiler = documentSession?.profiler;
2029
- profiler?.beginFrame();
2030
1857
  if (resumed) previousTime = time;
2031
1858
  const delta = Math.min((time - previousTime) / 1000, 0.1);
2032
1859
  previousTime = time;
2033
- profiler?.beginPhase();
2034
1860
  // CONTENT TIME moves ONLY through the stage transport, and only
2035
1861
  // while a human is holding it: `tick` is inert unless playing and
2036
1862
  // the editor drives, and it returns exactly the seconds it advanced.
@@ -2038,8 +1864,6 @@ export function Object3DDocumentViewport({
2038
1864
  if (advanced > 0) advanceContent(advanced);
2039
1865
  interactionExtension?.prepareFrame?.(delta);
2040
1866
  interactionExtension?.update?.(delta);
2041
- profiler?.endPhase('animation');
2042
- profiler?.beginPhase();
2043
1867
  host.presence?.syncMarkers(delta);
2044
1868
  viewport.update(delta);
2045
1869
  // This stage's frame hook. After viewport.update, the same order the
@@ -2052,12 +1876,15 @@ export function Object3DDocumentViewport({
2052
1876
  camera.updateMatrixWorld(true);
2053
1877
  }
2054
1878
  viewport.batchedRenderer.update(delta);
2055
- profiler?.endPhase('editor');
2056
- if (!resumed && !mustDraw(advanced)) {
2057
- profiler?.endFrame();
1879
+ // THE LIVE VIEW ACCUMULATES while nothing changes, one sample a frame, as Blender's
1880
+ // viewport does (`Object3DDocumentSession.renderViewport`'s `still`).
1881
+ const changed = resumed || navigationMeasurement !== null || changedSinceDrawn(advanced);
1882
+ if (!changed && !inputPending && !documentSession.needsFrame() && !documentSession.accumulating()) {
2058
1883
  return;
2059
1884
  }
2060
- profiler?.beginPhase();
1885
+ changeCountAtDraw = changeCount;
1886
+ generationAtDraw = stageGeneration();
1887
+ const mirroredBefore = changed ? null : mirroredState();
2061
1888
  activeRenderer.info.reset();
2062
1889
  const timeFirstRender =
2063
1890
  !firstFrameGateRef.current && activeViewportBreakdownDocumentId() === documentId;
@@ -2072,33 +1899,30 @@ export function Object3DDocumentViewport({
2072
1899
  // Photographs and the default frame-cost reading draw the
2073
1900
  // full resident scene. Only navigation may use source-owned
2074
1901
  // temporary geometry; the source restores it after the draw.
2075
- documentSession.renderViewport(delta, navigationMeasurement !== null || (!resumed && !documentSession.needsFrame()));
1902
+ documentSession.renderViewport(
1903
+ delta,
1904
+ navigationMeasurement !== null || (!resumed && !documentSession.needsFrame()),
1905
+ !changed,
1906
+ `${changeCountAtDraw}/${generationAtDraw}`,
1907
+ );
2076
1908
  activeViewport.renderViewCube(activeRenderer);
2077
1909
  });
2078
1910
  } finally {
2079
1911
  host.scene.overrideMaterial = presentationOverride;
2080
1912
  }
1913
+ // A still frame whose draw changed what the stage mirrors starts the sum over next frame.
1914
+ if (mirroredBefore && mirroredState().some((value, index) => value !== mirroredBefore[index])) markDirty();
2081
1915
  // Inside the frame that drew them — the canvas has no
2082
1916
  // preserveDrawingBuffer, so this is the only moment its pixels exist.
2083
1917
  documentSession.servePresentedFrame();
2084
1918
  noteDrawn();
2085
1919
  if (!firstFrameGateRef.current) {
2086
1920
  firstFrameGateRef.current = true;
2087
- if (timeFirstRender) markViewportSegment('first-render', Date.now() - tFirstRender);
1921
+ if (timeFirstRender) markViewportFirstRender(Date.now() - tFirstRender);
2088
1922
  recordViewportFirstFrame(documentId);
2089
1923
  setSurfaceStatus('ready');
2090
1924
  }
2091
- profiler?.reportRender({
2092
- gpuMs: null,
2093
- drawCalls: activeRenderer.info.render.calls,
2094
- triangles: activeRenderer.info.render.triangles,
2095
- geometries: activeRenderer.info.memory.geometries,
2096
- textures: activeRenderer.info.memory.textures,
2097
- });
2098
- profiler?.endPhase('render');
2099
- profiler?.endFrame();
2100
- // THE SHELL'S READOUT IS THE FOCUSED STAGE'S (ARCHITECTURE-CORE
2101
- // §One stage unit 4). `CameraInfo` and `StatsOverlay` read one
1925
+ // THE SHELL'S READOUT IS THE FOCUSED STAGE'S. `CameraInfo` and `StatsOverlay` read one
2102
1926
  // struct the shell owns; the stage the panels are following fills
2103
1927
  // it, so a model or prefab document reports ITS frame and ITS
2104
1928
  // camera. Inactive documents leave the shared readout alone.
@@ -2124,8 +1948,7 @@ export function Object3DDocumentViewport({
2124
1948
  * resumed so it draws, run back to back and each waited out on the GPU with a one-pixel
2125
1949
  * read, which a real frame never does; the first frame warms and is not counted.
2126
1950
  */
2127
- const measureFrameCost = (frames: number, quality: 'full' | 'navigation' = 'full'): StageFrameCost => {
2128
- if (!renderer) throw new Error('This stage has no renderer yet.');
1951
+ const measureFrameCost = (frames: number, quality: 'full' | 'navigation'): StageFrameCost => {
2129
1952
  const activeRenderer = renderer;
2130
1953
  const gl = activeRenderer.getContext();
2131
1954
  const pixel = new Uint8Array(4);
@@ -2170,17 +1993,17 @@ export function Object3DDocumentViewport({
2170
1993
  sceneTriangles += Math.floor(count / 3) * ((mesh as THREE.InstancedMesh).count ?? 1);
2171
1994
  });
2172
1995
  const sorted = [...times].sort((a, b) => a - b);
2173
- const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
1996
+ const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
2174
1997
  const size = activeRenderer.getDrawingBufferSize(new THREE.Vector2());
2175
1998
  const ratio = activeRenderer.getPixelRatio();
2176
1999
  const round = (value: number) => Math.round(value * 100) / 100;
2177
- const median = (values: number[]) => round(values.sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0);
2000
+ const median = (values: number[]) => round(values.sort((a, b) => a - b)[Math.floor(values.length / 2)]!);
2178
2001
  return {
2179
2002
  frames,
2180
2003
  quality,
2181
2004
  medianMs: round(at(0.5)),
2182
2005
  p95Ms: round(at(0.95)),
2183
- minMs: round(sorted[0] ?? 0),
2006
+ minMs: round(sorted[0]!),
2184
2007
  submitMedianMs: median(submitTimes),
2185
2008
  completionWaitMedianMs: median(completionTimes),
2186
2009
  width: size.x,
@@ -2197,13 +2020,7 @@ export function Object3DDocumentViewport({
2197
2020
  if (!host.initialized) {
2198
2021
  if (!chromeless) {
2199
2022
  // --- The viewport door (viewport-door.ts) ---
2200
- // This host is ONE STAGE among the mounted 3D documents
2201
- // (ARCHITECTURE-CORE §One stage), and it binds under the id of the
2202
- // document it draws, so an SDK reader reaches THIS stage's rig,
2203
- // helper sink and frame loop through the viewport door's `viewportStages()`.
2204
- // It presents no live roots yet — Play's adoption is the world
2205
- // root's, and moves onto this host in unit 3 — so the presenter
2206
- // declines every root rather than pretending to a subject.
2023
+ // Bind this document's rig, helper sink and frame loop. Its presenter declines live roots.
2207
2024
  // A CHROMELESS mount (the inspector's object preview) binds
2208
2025
  // nothing: it is a thumbnail of a document, not a stage of its own,
2209
2026
  // and several can be alive for one document id at once.
@@ -2213,23 +2030,17 @@ export function Object3DDocumentViewport({
2213
2030
  camera: viewport.camera,
2214
2031
  drawCamera: () => host.session?.camera() ?? viewport.camera,
2215
2032
  orbit: viewport.orbitControls,
2033
+ projection: viewport.projectionOwner,
2216
2034
  scene: host.scene,
2035
+ renderer: host.surface.renderer,
2217
2036
  frameCost: (frames, quality) => measureFrameCost(frames, quality),
2218
2037
  },
2219
- () => null,
2220
2038
  (kind, object) => viewport.setHelper(kind, object),
2221
2039
  { documentId },
2222
2040
  ),
2223
2041
  );
2224
- // --- The other participants, on THIS stage ---
2225
- // Presence was the scene panel's, so only the world root had it.
2226
- // It is a capability with a condition — a stage painting a three
2227
- // surface — and a `build` stage always paints one, so every
2228
- // document with chrome mounts it and a prefab story shows the
2229
- // same camera frusta, selection boxes and pointer rays the Scene
2230
- // does (ARCHITECTURE-CORE §One stage; WORK.md §Presence and the
2231
- // substrate, presence unit 4). Dynamically imported: it reaches
2232
- // the collaboration client, which a bounded host must not pay for.
2042
+ // Mount participant camera frusta, selection boxes and pointer rays on chromed three.js
2043
+ // stages. Load the collaboration client dynamically so bounded hosts do not import it.
2233
2044
  void import('./stage-presence-markers').then((markers) => {
2234
2045
  if (host.isClosed || documentHostRef.current !== host || host.presence) return;
2235
2046
  const binding = markers.bindStagePresenceMarkers({
@@ -2259,24 +2070,13 @@ export function Object3DDocumentViewport({
2259
2070
  host.cleanups.push(
2260
2071
  registerObject3DDocumentSession(documentSession, threeStageTransformChrome(documentId)),
2261
2072
  );
2262
- host.cleanups.push(
2263
- registerPerformanceSource({
2264
- id: documentId,
2265
- label: displayName,
2266
- kind: assetSubject ? 'asset' : 'source',
2267
- profiler: documentSession.profiler,
2268
- }),
2269
- );
2270
2073
  }
2271
2074
  // EXACTLY ONE registrar of this document's workspace selection: the
2272
2075
  // identity-row shell does it through its own `selection` prop, so
2273
- // the host does it wherever that shell is absent. The condition was
2274
- // `!studioStage` while the shell and the studio were the same
2275
- // answer; they parted when a DATA subject gained the studio
2276
- // (§A model is data), and reading the old one left the Model
2277
- // document with NEITHER registrar — the Hierarchy said "No
2278
- // authoring adapter" and the mesh inspector emptied (measured live
2279
- // on the models scaffold, 2026-09-18).
2076
+ // the host does it wherever that shell is absent. The shell and the
2077
+ // studio are different answers (a data subject has the studio),
2078
+ // so `!studioStage` would leave the Model document
2079
+ // with NEITHER registrar.
2280
2080
  if (!chromeless && !hasShell) {
2281
2081
  host.cleanups.push(
2282
2082
  registerWorkspaceDocumentSelection(documentId, () => ({
@@ -2296,12 +2096,8 @@ export function Object3DDocumentViewport({
2296
2096
  transportAdvances: () => host.transportAdvances,
2297
2097
  }),
2298
2098
  );
2299
- // THE VIEWPORT ACTION BUS, on a document stage. The world root has
2300
- // answered these since the scene panel existed; a document stage
2301
- // never did, which is why Frame (`F`) was inert on a prefab or a
2302
- // model — the hotkey pushes `focus-selection` onto the stage's own
2303
- // store and nothing was listening (ARCHITECTURE-CORE §One stage:
2304
- // the capability is present wherever its condition holds).
2099
+ // Listen to the document stage’s viewport action bus so commands such as focus-selection
2100
+ // reach the stage’s own store.
2305
2101
  host.cleanups.push(
2306
2102
  store.shell.onViewportAction((action) => {
2307
2103
  switch (action.type) {
@@ -2469,6 +2265,8 @@ export function Object3DDocumentViewport({
2469
2265
  }
2470
2266
  // A world root keeps its clock while another document holds focus.
2471
2267
  session.setActive(active || worldRoot);
2268
+ // A hidden stage keeps no live accumulation (its targets are the drawing buffer's size).
2269
+ if (!active) documentHostRef.current?.session?.releaseLive();
2472
2270
  }, [active, documentId, worldRoot]);
2473
2271
 
2474
2272
  // The stage's own handle for the capabilities that drive it directly (the
@@ -2518,12 +2316,7 @@ export function Object3DDocumentViewport({
2518
2316
  style={{ position: 'absolute', inset: 0, minHeight: 0, overflow: 'hidden' }}
2519
2317
  >
2520
2318
  <ViewportSurface canvasHostRef={canvasHostRef} />
2521
- {/* WHICH KEYS THIS STAGE ANSWERS — the transform modes, the view
2522
- presets, Frame, pivot, snap. Beside the stage rather than inside
2523
- the overlay set, because a package-contributed document mounts
2524
- with no shell above it and reached none of them
2525
- (`stage-keyboard.tsx`). The registry holds ONE stage's set, so the
2526
- condition is the ACTIVE document's stage. */}
2319
+ {/* The active document's stage owns transform, view, Frame, pivot and snap keys. */}
2527
2320
  {active && stageHandle && !chromeless ? (
2528
2321
  <Suspense fallback={null}>
2529
2322
  <LazyStageKeyboard stage={stageHandle} />
@@ -2547,47 +2340,16 @@ export function Object3DDocumentViewport({
2547
2340
  <worldRootBinding.Overlays
2548
2341
  store={shellStore}
2549
2342
  documentId={documentId}
2550
- cameraPreviewRef={cameraPreviewRef}
2551
2343
  mountStatus={designMountStatus}
2552
2344
  rootIds={designRootIds}
2553
2345
  />
2554
2346
  ) : null}
2555
- {/* A ROLLED-BACK operation (an animation write, a capture, a source
2556
- write the history guarded) — every writer of `error` restores the
2557
- prior state, so the surface below is live and must stay visible.
2558
- It used to mount a full-bleed opaque curtain with no dismiss: the
2559
- stage read as dead until the session restarted (measured). A
2560
- notice over the stage, dismissible, and cleared by the next
2561
- gesture that succeeds. */}
2347
+ {/* Show a dismissible error notice over any stage that remains available. */}
2562
2348
  {error && (
2563
- <div
2564
- role="alert"
2565
- style={{
2566
- position: 'absolute',
2567
- insetInline: 0,
2568
- top: 0,
2569
- display: 'flex',
2570
- justifyContent: 'center',
2571
- padding: 'var(--volter-space-3)',
2572
- pointerEvents: 'none',
2573
- }}
2574
- >
2575
- <div
2576
- className="volter-chrome-island"
2577
- style={{
2578
- display: 'flex',
2579
- alignItems: 'center',
2580
- gap: 'var(--volter-space-3)',
2581
- maxWidth: '80%',
2582
- padding: 'var(--volter-space-2) var(--volter-space-3)',
2583
- borderRadius: 'var(--volter-radius-md)',
2584
- border: `var(--volter-stroke-resting) solid ${themeVars.semantic.danger}`,
2585
- background: themeVars.surface.raised,
2586
- color: themeVars.semantic.danger,
2587
- pointerEvents: 'auto',
2588
- }}
2589
- >
2590
- <span>{error}</span>
2349
+ <EditorBanner
2350
+ tone="error"
2351
+ style={{ position: 'absolute', insetInline: 0, top: 0 }}
2352
+ actions={
2591
2353
  <IconButton
2592
2354
  size="compact"
2593
2355
  aria-label="Dismiss"
@@ -2596,21 +2358,15 @@ export function Object3DDocumentViewport({
2596
2358
  >
2597
2359
  <EditorIcon icon={editorIcons.action.close} />
2598
2360
  </IconButton>
2599
- </div>
2600
- </div>
2361
+ }
2362
+ >
2363
+ {error}
2364
+ </EditorBanner>
2601
2365
  )}
2602
- {!error && surfaceStatus === 'building' && (
2603
- <ViewportSurfaceStatus testId="object3d-document-status">
2604
- {OBJECT3D_SURFACE_BUILDING}
2605
- </ViewportSurfaceStatus>
2366
+ {!error && !chromeless && surfaceStatus === 'building' && (
2367
+ <Progress documentId={documentId} title="Preparing the viewport" />
2606
2368
  )}
2607
- {/* The stage BLEEDS 12px under the dock panel's padding (`inset: -12`
2608
- above) so the rendered image fills it edge to edge. The furniture is
2609
- CHROME, not image: it has to sit inside the panel the person can
2610
- actually see, so it gets that bleed back. Without this the view text
2611
- started at page x=-6 — the "U" of "User Perspective" was clipped off
2612
- the window — and the navigation cluster's right edge landed 4px past
2613
- the panel, under the Inspector's section rail (both measured). */}
2369
+ {/* Inset furniture by the canvas bleed so chrome stays inside the visible panel. */}
2614
2370
  <div
2615
2371
  style={{
2616
2372
  position: 'absolute',
@@ -2654,9 +2410,6 @@ export function Object3DDocumentViewport({
2654
2410
  >
2655
2411
  <div className="volter-object3d-asset-workspace">
2656
2412
  {viewport}
2657
- {/* Where the deleted preview strip was. It draws nothing until this
2658
- stage's transport has a subject, so a document with nothing to show
2659
- at a time is unchanged. */}
2660
2413
  <TransportStrip transport={documentHostRef.current?.transport ?? null} />
2661
2414
  </div>
2662
2415
  </AssetEditorShell>