@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,15 @@
1
- import { createPerformanceProfiler } from '@volter/sdk/kit/performance-profiler';
2
- import { resetViewPresentation, setViewPresentation, type ViewportXray } from '@volter/sdk/kit/viewport-presentation';
3
- import { invalidateStages } from '@volter/sdk/kit/stage-invalidation';
1
+ import { resetViewPresentation, setViewPresentation, type ViewportXray } from '@volter/sdk/viewport-presentation';
2
+ import { invalidateStages } from '@volter/sdk/stage-invalidation';
4
3
  import type { AuthoringAdapter } from '@volter/project/adapter';
5
4
  import { viewportCaptureOutputPass } from '@volter/editor-threejs/capture/output-pass';
6
5
  import { resolveSceneLinearSize } from '@volter/editor-threejs/capture/linear-resolve';
6
+ import {
7
+ jitterProjection,
8
+ PHOTOGRAPH_SAMPLE_CAP,
9
+ PhotographAccumulation,
10
+ type PhotographSampling,
11
+ } from '@volter/editor-threejs/capture/photograph-sampling';
12
+ import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js';
7
13
  import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
8
14
  import { setUserData } from '@volter/editor-threejs/ecs/user-data';
9
15
  import {
@@ -17,10 +23,10 @@ import { DepthDrawMaterials } from '../../render/depth-draw-materials';
17
23
  import type { DocumentDisplayTransform } from '../../render/document-display-transform';
18
24
  import { threeObject } from '../../adapter/three-contract';
19
25
  import { axisViewName, cameraPresetDirection, type ModelCameraPreset } from '../asset-workflow/model-inspection';
20
- import { activeKeymapNavigation } from '@volter/sdk/kit/keymap-presets';
21
- import { editorConsole } from '@volter/sdk/kit/editor-console';
26
+ import { editorHost } from '@volter/sdk/host';
27
+ import { editorConsole } from '@volter/sdk/editor-console';
22
28
  import { type EditorViewport } from '../editor-viewport';
23
- import { nativeSelectionColors, subscribeNativeSelectionTheme } from '@volter/sdk/kit/native-selection-style';
29
+ import { nativeSelectionColors, subscribeNativeSelectionTheme } from '@volter/sdk/native-selection-style';
24
30
  import { BoneSelectionHighlight } from '../three-viewport/bone-selection-highlight';
25
31
  import { perspectiveDistanceToFitBox } from '../three-viewport/camera-fit';
26
32
  import {
@@ -31,7 +37,7 @@ import {
31
37
  } from '../three-viewport/selection-outline';
32
38
  import { styleEditorSkeletonHelper } from '../three-viewport/skeleton-helper';
33
39
  import { isEditorViewportShadingTarget } from '../viewport-shading-boundary';
34
- import { setAuthoringSelection } from '@volter/sdk/kit/authoring/consumer-actions';
40
+ import { setAuthoringSelection } from '@volter/sdk/authoring/consumer-actions';
35
41
  import type { ToolCameraView, ToolCameraViewSource } from '../../object3d-contributions';
36
42
 
37
43
  export type Object3DDocumentViewMode = ViewportShadingMode | 'uv' | 'vertex-colors';
@@ -68,7 +74,7 @@ interface CameraFlight {
68
74
  elapsed: number;
69
75
  theta: number;
70
76
  phi: number;
71
- settle: ((outcome: DocumentLookOutcome) => void) | null;
77
+ settle: (outcome: DocumentLookOutcome) => void;
72
78
  }
73
79
 
74
80
  /**
@@ -80,12 +86,10 @@ interface CameraFlight {
80
86
  */
81
87
  const TOPOLOGY_EDGE_THRESHOLD_DEGREES = 1;
82
88
 
83
- /** OrbitControls' own poles-excluded range; orbiting past them flips the view. */
84
- /** How long a presented-frame request waits on the host's draw loop before it
85
- * gives the chrome door back `null` (see `requestPresentedFrame`). Two frames
86
- * at 30fps is a loop that is running; longer means it is not. */
89
+ /** Maximum wait for the host draw loop before requestPresentedFrame returns null. */
87
90
  const PRESENTED_FRAME_WAIT_MS = 700;
88
91
 
92
+ /** OrbitControls' own poles-excluded range; orbiting past them flips the view. */
89
93
  const MIN_POLAR = 1e-3;
90
94
  const MAX_POLAR = Math.PI - 1e-3;
91
95
 
@@ -112,15 +116,14 @@ const INITIAL_PRESENTATION: Object3DDocumentPresentationState = {
112
116
  bounds: false,
113
117
  };
114
118
 
119
+ /** The wire of an unselected object in the wireframe overlay: dark on the light clay body. */
120
+ const TOPOLOGY_WIRE_COLOR = 0x11161d;
121
+
115
122
  /**
116
123
  * Live presentation controls for one native Object3D document. The model
117
124
  * graph remains truth; this session owns only editor chrome and diagnostics.
118
125
  */
119
- /** The wire of an unselected object in the wireframe overlay: dark on the light clay body. */
120
- const TOPOLOGY_WIRE_COLOR = 0x11161d;
121
-
122
126
  export class Object3DDocumentSession {
123
- readonly profiler = createPerformanceProfiler();
124
127
  private readonly shading = new ViewportShadingRenderer();
125
128
  private readonly depthDrawMaterials = new DepthDrawMaterials();
126
129
  private readonly helpers: THREE.Object3D[] = [];
@@ -166,12 +169,75 @@ export class Object3DDocumentSession {
166
169
  * shape (square by default, `{width, height}` when a caller asks for one)
167
170
  * while the live camera's aspect is the panel's. */
168
171
  private captureAspect: number | null = null;
169
- /** The camera a capture door was handed, for the duration of that capture.
170
- * A BLENDER RENDER IS NOT THE MODELING VIEWPORT: it photographs the scene
171
- * through the scene's OWN camera, which has nothing to do with where the
172
- * person is looking — see `blender-runtime-host.ts`. Before this existed the
173
- * only way to photograph from another camera was to move the viewport onto
174
- * it and move it back, which is why that code had a restore dance at all. */
172
+ /** Target pixels per output pixel, non-null ONLY inside {@link captureImage}, which draws at
173
+ * twice the photograph's size and reduces. */
174
+ private capturePixelScale: number | null = null;
175
+
176
+ /** Target pixels per output pixel of the draw being made: the photograph's supersampling
177
+ * inside a capture, the device pixel ratio on screen. What a pixel-wide effect (Blender's
178
+ * object outline) spans. */
179
+ drawPixelScale(): number {
180
+ return this.capturePixelScale ?? this.renderer.getPixelRatio();
181
+ }
182
+
183
+ /** The draw being made is {@link captureImage}'s photograph. */
184
+ photographing(): boolean {
185
+ return this.capturePixelScale !== null;
186
+ }
187
+
188
+ /** How a photograph of each draw mode is anti-aliased, when the document states it
189
+ * (`PhotographSampling`): Blender's Solid by Workbench's own accumulation, its Rendered by
190
+ * EEVEE's film. A mode with none is drawn at twice the photograph's size and reduced. */
191
+ private photographSampling: Partial<Record<Object3DDocumentViewMode, PhotographSampling>> | null = null;
192
+ setPhotographSampling(sampling: Partial<Record<Object3DDocumentViewMode, PhotographSampling>> | null): void {
193
+ if (this.photographSampling === sampling) return;
194
+ this.photographSampling = sampling;
195
+ // The live view accumulates by the same sampling; what it gathered under the old is dropped.
196
+ this.live?.dispose();
197
+ this.live = null;
198
+ }
199
+
200
+ /** Accumulate the document’s viewport samples across unchanged redraws. Navigation, edits and resize reset the sum; release live targets while hidden and retain the finished display-encoded image after completion. */
201
+ private live: LiveAccumulation | null = null;
202
+ /** A failure inside the live accumulation is reported once per session. */
203
+ private liveFailureReported = false;
204
+
205
+ /** The sampling the live view accumulates by in the current mode, or null (drawn plain). */
206
+ private liveSampling(): PhotographSampling | null {
207
+ const mode = this.state.mode;
208
+ if (mode === 'uv' || mode === 'vertex-colors') return null;
209
+ return this.photographSampling?.[mode] ?? null;
210
+ }
211
+
212
+ /** The live view has samples still to draw while nothing changes: the host keeps drawing. */
213
+ accumulating(): boolean {
214
+ const sampling = this.liveSampling();
215
+ if (!sampling) return false;
216
+ const live = this.live;
217
+ if (!live || live.sampling !== sampling) return true;
218
+ return !live.failed && live.next < live.count;
219
+ }
220
+
221
+ /** The stage is hidden: the live accumulation's targets go (it starts over when next shown). */
222
+ releaseLive(): void {
223
+ this.live?.dispose();
224
+ this.live = null;
225
+ }
226
+
227
+ /** The sub-pixel move of the sample being drawn, in a sampled photograph or the live view's accumulation. */
228
+ private captureJitter: { readonly offset: readonly [number, number]; readonly width: number; readonly height: number } | null = null;
229
+ /** Which sample of a sampled photograph is being drawn (0 for any other draw). */
230
+ private captureSample = 0;
231
+ /** The backdrop a sampled photograph leaves out of its samples, to lay its drawing over. */
232
+ private captureBackdrop: THREE.Color | null = null;
233
+
234
+ /** The sample being drawn: 0 on screen and in an unsampled photograph. A host reports a
235
+ * photograph's draw once, on its first sample. */
236
+ photographSampleIndex(): number {
237
+ return this.captureSample;
238
+ }
239
+ /** The camera used for the current capture. Blender renders use the scene camera while
240
+ * the modeling viewport keeps its own view. */
175
241
  private cameraOverride: THREE.Camera | null = null;
176
242
  /** The document's cameras a camera view can look through (`ToolObject3DAuthoringProps.cameraView`). */
177
243
  private cameraViewSource: ToolCameraViewSource | null = null;
@@ -221,12 +287,10 @@ export class Object3DDocumentSession {
221
287
  readonly scene: THREE.Scene,
222
288
  readonly renderer: THREE.WebGLRenderer,
223
289
  readonly viewport: EditorViewport,
224
- private authoring?: AuthoringAdapter,
290
+ private authoring: AuthoringAdapter,
225
291
  /** Asset Lab renders its neutral stage in editor chrome behind an alpha
226
292
  * canvas; other document hosts may retain a real Three background. */
227
- private neutralBackground: THREE.Color | THREE.Texture | null = new THREE.Color(0x20242a),
228
- /** When set, Frame and view presets fit this box instead of the root AABB. */
229
- private readonly frameBox: THREE.Box3 | null = null,
293
+ private neutralBackground: THREE.Color | THREE.Texture | null,
230
294
  ) {
231
295
  this.unsubscribeSelectionTheme = subscribeNativeSelectionTheme(
232
296
  renderer.domElement,
@@ -250,14 +314,14 @@ export class Object3DDocumentSession {
250
314
  private readonly axisView = (): boolean => {
251
315
  this.leaveCameraView(false);
252
316
  this.settleFlight('superseded');
253
- if (activeKeymapNavigation().autoPerspective) this.setProjection('orthographic');
317
+ if (editorHost().keyboard.navigation().autoPerspective) this.setProjection('orthographic');
254
318
  return true;
255
319
  };
256
320
 
257
321
  /** Auto Perspective, where the keymap states it (`KeymapNavigation.autoPerspective`): a rotate
258
322
  * that starts from an orthographic view still down an axis draws in perspective. */
259
323
  private readonly autoPerspective = (): void => {
260
- if (!activeKeymapNavigation().autoPerspective || this.cameraView() !== null) return;
324
+ if (!editorHost().keyboard.navigation().autoPerspective || this.cameraView() !== null) return;
261
325
  if (this.state.projection !== 'orthographic') return;
262
326
  const camera = this.viewport.camera;
263
327
  const offset = camera.position.clone().sub(this.viewport.orbitControls.target);
@@ -319,12 +383,12 @@ export class Object3DDocumentSession {
319
383
  }
320
384
 
321
385
  private resolveFrameBounds(subject: 'selection' | 'all' = 'selection'): THREE.Box3 {
322
- const selection = subject === 'all' ? [] : (this.authoring?.selection?.get() ?? []);
386
+ const selection = subject === 'all' ? [] : (this.authoring.selection?.get() ?? []);
323
387
  if (selection.length > 0) {
324
388
  const selected = new THREE.Box3();
325
389
  let found = false;
326
390
  for (const id of selection) {
327
- const object = this.authoring ? threeObject(this.authoring.hierarchy, id) : null;
391
+ const object = threeObject(this.authoring.hierarchy, id);
328
392
  if (!object) continue;
329
393
  const bounds = contentWorldBounds(object);
330
394
  if (bounds.isEmpty()) continue;
@@ -333,15 +397,14 @@ export class Object3DDocumentSession {
333
397
  }
334
398
  if (found) return selected;
335
399
  }
336
- if (this.frameBox && !this.frameBox.isEmpty()) return this.frameBox;
337
400
  return contentWorldBounds(this.root);
338
401
  }
339
402
 
340
403
  /**
341
- * Frame the subject: the selection if there is one, else this document's
342
- * frame box, else the whole root; `subject: 'all'` passes over the
343
- * selection (Blender's View All). `fit` scales the fitted distance — 1 is
344
- * the tight fit the toolbar's Frame button has always used, >1 pulls back.
404
+ * Frame the subject: the selection if there is one, else the whole root.
405
+ * `subject: 'all'` passes over selection (Blender's View All).
406
+ * `fit` scales the fitted distance — 1 is
407
+ * the toolbar's tight fit, >1 pulls back.
345
408
  */
346
409
  frame(fit = 1, subject: 'selection' | 'all' = 'selection'): boolean {
347
410
  this.leaveCameraView(false);
@@ -358,7 +421,7 @@ export class Object3DDocumentSession {
358
421
  positions += geometry.getAttribute('position')?.count ?? 0;
359
422
  });
360
423
  editorConsole.warn(
361
- `frame: nothing to frame — root "${this.root.name}" (${this.root.children.length} children, ${meshes} meshes, ${positions} positions), selection ${JSON.stringify(this.authoring?.selection?.get() ?? [])}, frameBox ${this.frameBox ? (this.frameBox.isEmpty() ? 'empty' : 'set') : 'none'}`,
424
+ `frame: nothing to frame — root "${this.root.name}" (${this.root.children.length} children, ${meshes} meshes, ${positions} positions), selection ${JSON.stringify(this.authoring.selection?.get() ?? [])}`,
362
425
  'document',
363
426
  );
364
427
  return false;
@@ -446,6 +509,8 @@ export class Object3DDocumentSession {
446
509
  spherical.radius > 1e-4
447
510
  ? spherical.radius
448
511
  : Math.max(this.resolveFrameBounds().getSize(new THREE.Vector3()).length(), 1);
512
+ let settle!: CameraFlight['settle'];
513
+ const settled = new Promise<DocumentLookOutcome>((resolve) => { settle = resolve; });
449
514
  const flight: CameraFlight = {
450
515
  pivot,
451
516
  radius,
@@ -460,12 +525,9 @@ export class Object3DDocumentSession {
460
525
  elapsed: 0,
461
526
  theta: spherical.theta,
462
527
  phi: spherical.phi,
463
- settle: null,
528
+ settle,
464
529
  };
465
530
  this.flight = flight;
466
- const settled = new Promise<DocumentLookOutcome>((resolve) => {
467
- flight.settle = resolve;
468
- });
469
531
  // A zero-length move is still a move: apply it now so the pose is correct
470
532
  // even on a surface whose next frame never comes.
471
533
  this.applyFlightPose(flight, 0);
@@ -529,27 +591,27 @@ export class Object3DDocumentSession {
529
591
  elevation: Math.PI / 2 - flight.phi,
530
592
  seconds: Math.min(flight.elapsed, flight.duration),
531
593
  };
532
- flight.settle?.(cancelledBy === null ? ended : { ...ended, cancelledBy });
594
+ flight.settle(cancelledBy === null ? ended : { ...ended, cancelledBy });
533
595
  }
534
596
 
535
597
  select(ids: readonly string[]): void {
536
598
  invalidateStages();
537
- if (this.authoring) setAuthoringSelection(this.authoring, [...ids]);
599
+ setAuthoringSelection(this.authoring, [...ids]);
538
600
  }
539
601
 
540
602
  selection(): string[] {
541
- return [...(this.authoring?.selection?.get() ?? [])];
603
+ return [...(this.authoring.selection?.get() ?? [])];
542
604
  }
543
605
 
544
606
  idForObject(object: THREE.Object3D): string | null {
545
- return this.authoring?.hierarchy.idForObject3D?.(object) ?? null;
607
+ return this.authoring.hierarchy.idForNativeObject?.(object) ?? null;
546
608
  }
547
609
 
548
610
  /** Keep native hierarchy selection legible in this document's one viewport. */
549
611
  syncSelectionPresentation(): void {
550
612
  invalidateStages();
551
- const selected = (this.authoring?.selection?.get() ?? [])
552
- .map((id) => (this.authoring ? threeObject(this.authoring.hierarchy, id) : null))
613
+ const selected = (this.authoring.selection?.get() ?? [])
614
+ .map((id) => threeObject(this.authoring.hierarchy, id))
553
615
  .filter((object): object is THREE.Object3D => object !== null);
554
616
  this.selectedObjects = selected;
555
617
  if (this.selectionOutline) {
@@ -581,14 +643,14 @@ export class Object3DDocumentSession {
581
643
  }
582
644
 
583
645
  frameSelection(): boolean {
584
- return this.frameIds(this.authoring?.selection?.get() ?? []);
646
+ return this.frameIds(this.authoring.selection?.get() ?? []);
585
647
  }
586
648
 
587
649
  frameIds(ids: readonly string[]): boolean {
588
650
  this.leaveCameraView(false);
589
651
  this.settleFlight('superseded');
590
652
  const objects = ids
591
- .map((id) => (this.authoring ? threeObject(this.authoring.hierarchy, id) : null))
653
+ .map((id) => threeObject(this.authoring.hierarchy, id))
592
654
  .filter((object): object is THREE.Object3D => object !== null);
593
655
  if (objects.length === 0) return false;
594
656
  if (objects.every((object) => (object as THREE.Bone).isBone)) {
@@ -599,18 +661,7 @@ export class Object3DDocumentSession {
599
661
  return true;
600
662
  }
601
663
 
602
- /**
603
- * An AXIS view is ORTHOGRAPHIC, the way Blender's numpad 1/3/7 are: the
604
- * reference frame for numpad 1 (`modeling-front-ortho.png`) says so in its
605
- * own view text, "Front Orthographic". Ours left the stage in perspective,
606
- * so a front view still showed the cube's side faces converging.
607
- *
608
- * `isometric` is the perspective preset (it is the stage's 3/4 opening
609
- * direction, not an axis) and stays perspective. Both callers of this door
610
- * already separate the two: the relay's `view-preset` maps its own
611
- * `perspective` onto `isometric`, and the document toolbar's view menu
612
- * carries the projection pair as its own second group — so nothing here
613
- * reads an axis preset expecting perspective.
664
+ /** Axis presets use orthographic projection; isometric uses the stage's perspective opening.
614
665
  *
615
666
  * The pose is still solved against the PERSPECTIVE camera, because the
616
667
  * session's orthographic camera is derived from that pose every frame
@@ -734,9 +785,12 @@ export class Object3DDocumentSession {
734
785
  const pose = through.locked
735
786
  ? `${free.position.toArray().join(',')}|${free.quaternion.toArray().join(',')}`
736
787
  : '';
737
- const key = `${through.camera}|${region.width}|${region.height}|${through.zoom}|${through.offset.join(',')}|${pose}`;
788
+ // THE SCENE'S CAMERA AS IT IS NOW, as Blender's camera view looks through `scene.camera`, which
789
+ // the Timeline's markers switch as the film plays; a LOCKED view keeps the camera it moves
790
+ const camera = through.locked ? through.camera : (source.camera() ?? through.camera);
791
+ const key = `${camera}|${region.width}|${region.height}|${through.zoom}|${through.offset.join(',')}|${pose}`;
738
792
  if (this.viewMemo?.key === key) return this.viewMemo.view;
739
- const seen = source.view(through.camera, region, through.zoom, through.offset);
793
+ const seen = source.view(camera, region, through.zoom, through.offset);
740
794
  const view =
741
795
  seen && through.locked
742
796
  ? {
@@ -956,7 +1010,7 @@ export class Object3DDocumentSession {
956
1010
  const leftAxis =
957
1011
  axisViewName(through.left.position.clone().sub(through.left.target), through.left.up) !== null;
958
1012
  const projection =
959
- activeKeymapNavigation().autoPerspective && leftAxis ? 'perspective' : through.left.projection;
1013
+ editorHost().keyboard.navigation().autoPerspective && leftAxis ? 'perspective' : through.left.projection;
960
1014
  this.state = { ...this.state, projection };
961
1015
  }
962
1016
  viewport.camera.lookAt(viewport.orbitControls.target);
@@ -1116,8 +1170,7 @@ export class Object3DDocumentSession {
1116
1170
  * `wireframe` flag, which draws each RENDERABLE triangle — so a quad shows
1117
1171
  * its triangulation diagonal and a modeller reads noise where they expect
1118
1172
  * face flow. On the Asset Lab document viewport, where the mesh IS the
1119
- * subject, the mode instead draws neutral clay bodies (so wires behind the
1120
- * form are correctly occluded) under an edge overlay built with
1173
+ * subject, the mode draws neutral clay bodies under a depth-test-free edge overlay built with
1121
1174
  * `EdgesGeometry`: coplanar diagonals fall out, quads read as quads.
1122
1175
  *
1123
1176
  * The overlay is editor chrome — tagged `editorHelper`, so picking, bakes
@@ -1135,11 +1188,8 @@ export class Object3DDocumentSession {
1135
1188
  if (!mesh.isMesh || !mesh.geometry) return;
1136
1189
  const line = new THREE.LineSegments(
1137
1190
  new THREE.EdgesGeometry(mesh.geometry, TOPOLOGY_EDGE_THRESHOLD_DEGREES),
1138
- // Dark on the light clay body, and DEPTH-TEST OFF. An edge sits
1139
- // exactly on the surface it bounds, so a depth-tested line stipples
1140
- // itself away in z-fighting — photographed and read before this was
1141
- // written. Off is also the truer read: Blender's wireframe shading
1142
- // shows the far side too, and the far side is half the topology.
1191
+ // Draw dark wire edges without depth testing: coplanar lines would z-fight, and wireframe
1192
+ // shading includes the far-side topology.
1143
1193
  new THREE.LineBasicMaterial({
1144
1194
  color: TOPOLOGY_WIRE_COLOR,
1145
1195
  toneMapped: false,
@@ -1258,7 +1308,7 @@ export class Object3DDocumentSession {
1258
1308
 
1259
1309
  resetPresentation(): void {
1260
1310
  invalidateStages();
1261
- // Lighting, backdrop and tone are the view's presentation (`kit/viewport-presentation`).
1311
+ // Lighting, backdrop and tone are the view's presentation (`@volter/sdk/viewport-presentation`).
1262
1312
  resetViewPresentation(this.documentId);
1263
1313
  this.clearDiagnosticPresentation();
1264
1314
  this.state = INITIAL_PRESENTATION;
@@ -1275,11 +1325,9 @@ export class Object3DDocumentSession {
1275
1325
  *
1276
1326
  * The host keeps the document's own `THREE.Scene` nested inside the session's
1277
1327
  * rendered scene and mirrors the world dressing (environment, fog, their
1278
- * intensities/rotations, background) outward. That mirror used to live in the
1279
- * host's per-frame `animate`, so an offscreen photograph taken between two
1280
- * viewport ticks drew whatever the LAST tick happened to mirror — a sky set
1281
- * synchronously before the capture was simply missing from it. Registering it
1282
- * here puts `renderViewport` and `captureImage` on one path.
1328
+ * intensities/rotations, background) outward. In a per-frame `animate` an
1329
+ * offscreen photograph taken between two viewport ticks would miss a sky set
1330
+ * just before the capture. Registering it here puts `renderViewport` and `captureImage` on one path.
1283
1331
  *
1284
1332
  * The host owns the step and clears it in its own teardown; the session only
1285
1333
  * holds the reference.
@@ -1288,26 +1336,7 @@ export class Object3DDocumentSession {
1288
1336
  this.beforeRender = step;
1289
1337
  }
1290
1338
 
1291
- /**
1292
- * THE PRESENTED FRAME — the stage exactly as the person is seeing it,
1293
- * including everything drawn OVER the document's own render: the
1294
- * orientation compass (`EditorViewport.renderViewCube`) above all.
1295
- *
1296
- * Why this is not {@link captureImage}: that door is an offscreen
1297
- * re-render of the document's content, for thumbnails and asset previews,
1298
- * and it deliberately draws the subject alone. The chrome door's promise is
1299
- * the opposite one — "photograph the editor as the person sees it" — and
1300
- * routing it through `captureImage` made the door LIE about this stage: the
1301
- * compass renders every frame into the default framebuffer (measured: the
1302
- * call is reached, `_threeSurfaceShowing` true, 24 objects, target=screen)
1303
- * and never appeared in a single chrome frame.
1304
- *
1305
- * The canvas has no `preserveDrawingBuffer`, so the only place its pixels
1306
- * can be read is inside the frame that drew them — which is why the host
1307
- * serves the request at the end of its own `animate`
1308
- * ({@link servePresentedFrame}), the same shape the ingest lane's
1309
- * `LiveSession.snapshotFrame` uses.
1310
- */
1339
+ /** Capture the presented stage, including its orientation compass, at the end of animate through servePresentedFrame. captureImage separately renders document content offscreen for previews. */
1311
1340
  private presentedFrameWaiters: Array<(frame: HTMLCanvasElement | null) => void> = [];
1312
1341
  /** True while a host render loop is servicing {@link servePresentedFrame}. */
1313
1342
  private presentsFrames = false;
@@ -1334,8 +1363,8 @@ export class Object3DDocumentSession {
1334
1363
  resolve(frame);
1335
1364
  };
1336
1365
  this.presentedFrameWaiters.push(once);
1337
- // A stage that draws on change draws for this request.
1338
- invalidateStages();
1366
+ // A stage that draws on change draws for this request (`needsFrame`); it is not a change
1367
+ // to the picture, so the stages are not invalidated (a live accumulation goes on).
1339
1368
  setTimeout(() => once(null), PRESENTED_FRAME_WAIT_MS);
1340
1369
  });
1341
1370
  }
@@ -1372,6 +1401,16 @@ export class Object3DDocumentSession {
1372
1401
  render(renderSolid: (camera: THREE.Camera) => void, interactive = false): void {
1373
1402
  const finishDraw = this.beforeRender?.(interactive);
1374
1403
  const restoreDepthDraw = this.depthDrawMaterials.bind(this.renderer);
1404
+ // A sampled photograph's jitter moves the projection of the camera the scene is drawn with,
1405
+ // AFTER the source prepared its draw (shadow fits stay put), and stays on through the source's
1406
+ // own finishing passes (Blender's outline is drawn per sample).
1407
+ const jitter = this.captureJitter;
1408
+ const jittered: { restore: (() => void) | null } = { restore: null };
1409
+ const unjittered = renderSolid;
1410
+ renderSolid = (camera) => {
1411
+ if (jitter && !jittered.restore) jittered.restore = jitterProjection(camera, jitter.offset, jitter.width, jitter.height);
1412
+ unjittered(camera);
1413
+ };
1375
1414
  try {
1376
1415
  this.boneSelectionHighlight?.update();
1377
1416
  const camera = this.camera();
@@ -1406,8 +1445,7 @@ export class Object3DDocumentSession {
1406
1445
  }
1407
1446
  return;
1408
1447
  }
1409
- // Clay bodies, not the triangle-wireframe swap: the overlay IS the wire,
1410
- // and the solid form behind it is what occludes the far side.
1448
+ // Draw clay bodies beneath the edge overlay, including far-side wires.
1411
1449
  this.shading.render(
1412
1450
  this.scene,
1413
1451
  'clay',
@@ -1419,14 +1457,30 @@ export class Object3DDocumentSession {
1419
1457
  this.shading.render(this.scene, mode, () => renderSolid(camera), isEditorViewportShadingTarget);
1420
1458
  } finally {
1421
1459
  restoreDepthDraw();
1422
- finishDraw?.();
1460
+ try {
1461
+ finishDraw?.();
1462
+ } finally {
1463
+ jittered.restore?.();
1464
+ }
1423
1465
  }
1424
1466
  }
1425
1467
 
1426
- /** Visible authoring draw with the editor-only native selection silhouette. */
1427
- renderViewport(deltaSeconds = 0, interactive = false): void {
1468
+ /** Visible authoring draw with the editor-only native selection silhouette. `still`: nothing
1469
+ * has changed since the host's last drawn frame, so the live accumulation goes on; `revision`
1470
+ * names the state of the content the host drew it at. */
1471
+ renderViewport(deltaSeconds = 0, interactive = false, still = false, revision = ''): void {
1428
1472
  this.advanceLook(deltaSeconds);
1429
1473
  this.ensureComposer();
1474
+ const sampling = this.liveSampling();
1475
+ if (sampling && still && this.flight === null) {
1476
+ if (this.renderLiveFrame(sampling, revision)) return;
1477
+ // The sum is not yet better than the ordinary frame (or failed): the ordinary frame, at full
1478
+ // detail, without starting the sum over.
1479
+ interactive = false;
1480
+ } else {
1481
+ // Something changed: this frame is drawn plain, and the next still one starts over.
1482
+ this.live?.restart();
1483
+ }
1430
1484
  const transform = this.materialDisplayTransform();
1431
1485
  if (transform) {
1432
1486
  const size = this.renderer.getDrawingBufferSize(new THREE.Vector2());
@@ -1461,6 +1515,104 @@ export class Object3DDocumentSession {
1461
1515
  }, interactive);
1462
1516
  }
1463
1517
 
1518
+ /**
1519
+ * One more sample of the live accumulation, and the picture so far on screen once it is at least
1520
+ * as good as the ordinary frame; once every sample is in, the finished picture again (a redraw
1521
+ * with nothing changed: a presented-frame request). A sample is drawn as a photograph's is
1522
+ * (`captureImage`): the projection moved by its offset after the source prepared its draw, a flat
1523
+ * backdrop left out and merged under the sum. Answers false when the caller is to draw the
1524
+ * ordinary frame instead.
1525
+ */
1526
+ private renderLiveFrame(sampling: PhotographSampling, revision: string): boolean {
1527
+ const size = this.renderer.getDrawingBufferSize(new THREE.Vector2());
1528
+ let live = this.live;
1529
+ if (!live || live.sampling !== sampling || live.width !== size.x || live.height !== size.y) {
1530
+ live?.dispose();
1531
+ live = this.live = new LiveAccumulation(sampling, size.x, size.y);
1532
+ }
1533
+ if (live.failed) return false;
1534
+ // A sum drawn under another state of the content is neither added to nor shown.
1535
+ if (live.next > 0 && live.revision !== revision) live.restart();
1536
+ const transform = this.materialDisplayTransform();
1537
+ const previous = this.renderer.getRenderTarget();
1538
+ try {
1539
+ if (live.next < live.count) {
1540
+ const index = live.next;
1541
+ const { accumulation, sampleTarget } = live.working(this.renderer);
1542
+ if (index === 0) {
1543
+ accumulation.clear(this.renderer);
1544
+ live.backdrop = null;
1545
+ live.revision = revision;
1546
+ }
1547
+ const offset = accumulation.offsets[index]!;
1548
+ const mergeBackdrop = sampling.backdropMerge && !transform && this.renderer.toneMapping === THREE.NoToneMapping;
1549
+ // The first still frame draws the shadow maps for what it shows; the rest reuse them.
1550
+ const shadowsUpdate = this.renderer.shadowMap.autoUpdate;
1551
+ this.renderer.shadowMap.autoUpdate = shadowsUpdate && index === 0;
1552
+ this.captureBackdrop = null;
1553
+ this.captureJitter = {
1554
+ offset: [offset[0] * sampling.projectionScale, offset[1] * sampling.projectionScale],
1555
+ width: size.x, height: size.y,
1556
+ };
1557
+ try {
1558
+ this.renderer.setRenderTarget(sampleTarget);
1559
+ this.renderCapture(
1560
+ (camera) => this.renderLinearScene(camera, sampleTarget),
1561
+ false,
1562
+ mergeBackdrop && (index === 0 || live.backdrop !== null),
1563
+ );
1564
+ } finally {
1565
+ this.captureJitter = null;
1566
+ this.renderer.shadowMap.autoUpdate = shadowsUpdate;
1567
+ }
1568
+ if (index === 0) live.backdrop = this.captureBackdrop;
1569
+ this.captureBackdrop = null;
1570
+ accumulation.add(this.renderer, sampleTarget.texture, offset);
1571
+ live.next = index + 1;
1572
+ const drawing = accumulation.resolve(this.renderer);
1573
+ if (live.next < live.count) {
1574
+ if (live.next < live.showFrom) return false;
1575
+ this.presentLiveSum(live, drawing, transform, previous);
1576
+ return true;
1577
+ }
1578
+ // Complete: the picture as shown is kept and the working targets go.
1579
+ this.presentLiveSum(live, drawing, transform, live.finishedTarget());
1580
+ live.releaseWorking();
1581
+ }
1582
+ if (!live.finished) return false;
1583
+ live.blitFinished(this.renderer, previous);
1584
+ return true;
1585
+ } catch (error) {
1586
+ // An accumulation that fails ends: the ordinary frame from here on, until something changes.
1587
+ live.failed = true;
1588
+ live.releaseWorking();
1589
+ if (!this.liveFailureReported) {
1590
+ this.liveFailureReported = true;
1591
+ editorConsole.warn(
1592
+ `live view: the accumulation failed and the view draws without it until it changes — ${
1593
+ error instanceof Error ? error.message : String(error)}`,
1594
+ 'document',
1595
+ );
1596
+ }
1597
+ return false;
1598
+ } finally {
1599
+ this.renderer.setRenderTarget(previous);
1600
+ }
1601
+ }
1602
+
1603
+ /** The resolved sum through the view (merged over its backdrop, or the display transform, or
1604
+ * the output pass) into `target` (null: the canvas). */
1605
+ private presentLiveSum(
1606
+ live: LiveAccumulation,
1607
+ drawing: THREE.WebGLRenderTarget,
1608
+ transform: DocumentDisplayTransform | null,
1609
+ target: THREE.WebGLRenderTarget | null,
1610
+ ): void {
1611
+ if (live.backdrop) live.working(this.renderer).accumulation.merge(this.renderer, drawing, live.backdrop, target);
1612
+ else if (transform) transform.render(this.renderer, drawing, target, false);
1613
+ else viewportCaptureOutputPass(false).render(this.renderer, target as THREE.WebGLRenderTarget, drawing, 0, false);
1614
+ }
1615
+
1464
1616
  resize(width: number, height: number): void {
1465
1617
  invalidateStages();
1466
1618
  this.renderWidth = Math.max(1, width);
@@ -1468,15 +1620,25 @@ export class Object3DDocumentSession {
1468
1620
  this.composer?.setSize(this.renderWidth, this.renderHeight);
1469
1621
  }
1470
1622
 
1471
- private renderCapture(renderSolid: (camera: THREE.Camera) => void, transparent: boolean): void {
1623
+ private renderCapture(
1624
+ renderSolid: (camera: THREE.Camera) => void,
1625
+ transparent: boolean,
1626
+ /**
1627
+ * Retain a flat-color backdrop separately for composition beneath accumulated samples. Read it
1628
+ * after the host mirrors the current dressing into the scene.
1629
+ */
1630
+ leaveColorBackdrop = false,
1631
+ ): void {
1472
1632
  this.render((camera) => {
1473
- if (!transparent) {
1474
- renderSolid(camera);
1475
- return;
1476
- }
1477
1633
  // Apply after the host mirrors its world into this scene. Environment
1478
1634
  // lighting stays intact; only the camera background is omitted.
1479
1635
  const background = this.scene.background;
1636
+ const leaveBackdrop = !transparent && leaveColorBackdrop && (background as THREE.Color | null)?.isColor === true;
1637
+ if (!transparent && !leaveBackdrop) {
1638
+ renderSolid(camera);
1639
+ return;
1640
+ }
1641
+ if (leaveBackdrop) this.captureBackdrop = (background as THREE.Color).clone();
1480
1642
  const clearColor = this.renderer.getClearColor(new THREE.Color());
1481
1643
  const clearAlpha = this.renderer.getClearAlpha();
1482
1644
  try {
@@ -1491,18 +1653,9 @@ export class Object3DDocumentSession {
1491
1653
  }
1492
1654
 
1493
1655
  /**
1494
- * Fresh offscreen capture through this document's own renderer and
1495
- * diagnostic pipeline. This does not depend on preserveDrawingBuffer and
1496
- * therefore remains truthful after the visible canvas has presented.
1497
- *
1498
- * A NUMBER is a square of that size — the default shape, and the right one
1499
- * for an unstaged look at a model. `{width, height}` renders the buffer AND
1500
- * the camera at that aspect, so a video-shaped look comes back already
1501
- * shaped instead of square-and-cropped. Whatever the shape, the camera is
1502
- * matched to the BUFFER for the duration: rendering the panel's aspect
1503
- * through a square buffer is what photographed a cube as a tall thin prism
1504
- * on a 3.05:1 document panel, through the one look instrument an agent has.
1505
- * The visible viewport is never touched.
1656
+ * Capture offscreen through this document’s renderer and diagnostic pipeline without
1657
+ * preserveDrawingBuffer. A number requests a square; width and height request a matching camera
1658
+ * aspect and buffer for the capture duration. Keep the visible viewport unchanged.
1506
1659
  */
1507
1660
  captureImage(
1508
1661
  size:
@@ -1521,8 +1674,12 @@ export class Object3DDocumentSession {
1521
1674
  if (!Number.isFinite(requestedHeight) || requestedHeight < 1) return null;
1522
1675
  const outputWidth = Math.min(2048, Math.round(requestedWidth));
1523
1676
  const outputHeight = Math.min(2048, Math.round(requestedHeight));
1524
- const renderWidth = outputWidth * 2;
1525
- const renderHeight = outputHeight * 2;
1677
+ const mode = this.state.mode;
1678
+ const sampling = (mode !== 'uv' && mode !== 'vertex-colors' ? this.photographSampling?.[mode] : undefined) ?? null;
1679
+ // A sampled photograph is drawn at its own size, once per sample; otherwise at twice it.
1680
+ const scale = sampling ? 1 : 2;
1681
+ const renderWidth = outputWidth * scale;
1682
+ const renderHeight = outputHeight * scale;
1526
1683
  // Three disables material tone mapping on ordinary render targets. A
1527
1684
  // byte target clips lit surfaces to white before display conversion can
1528
1685
  // recover them. Keep HDR values, then use the same output resolve as the
@@ -1533,28 +1690,84 @@ export class Object3DDocumentSession {
1533
1690
  const transform = this.materialDisplayTransform();
1534
1691
  const target = new THREE.WebGLRenderTarget(transform ? outputWidth : renderWidth, transform ? outputHeight : renderHeight);
1535
1692
  let resolved: THREE.WebGLRenderTarget | null = null;
1693
+ const accumulation = sampling ? new PhotographAccumulation(sampling, outputWidth, outputHeight, this.renderer) : null;
1536
1694
  const previousTarget = this.renderer.getRenderTarget();
1537
1695
  const perspective = this.viewport.camera;
1538
1696
  const previousAspect = perspective.aspect;
1539
1697
  try {
1540
1698
  this.captureAspect = outputWidth / outputHeight;
1699
+ this.capturePixelScale = scale;
1541
1700
  const override = typeof size === 'number' ? undefined : size.camera;
1542
1701
  if (override) this.cameraOverride = override;
1543
1702
  else {
1544
1703
  perspective.aspect = outputWidth / outputHeight;
1545
1704
  perspective.updateProjectionMatrix();
1546
1705
  }
1547
- this.renderer.setRenderTarget(sceneTarget);
1548
- this.renderCapture(
1549
- (camera) => this.renderSolidForCapture(camera, sceneTarget, renderWidth, renderHeight),
1550
- typeof size !== 'number' && size.transparent === true,
1551
- );
1552
- const straightAlpha = typeof size !== 'number' && size.transparent === true;
1553
- if (transform) {
1554
- resolved = resolveSceneLinearSize(this.renderer, sceneTarget, outputWidth, outputHeight);
1555
- transform.render(this.renderer, resolved, target, straightAlpha);
1706
+ const transparentCapture = typeof size !== 'number' && size.transparent === true;
1707
+ let merged = false;
1708
+ if (accumulation && sampling) {
1709
+ // The composer's buffers take the photograph's size once, not once per sample.
1710
+ const sizedComposer = this.sizeComposerForCapture(renderWidth, renderHeight);
1711
+ // The shadow maps the first sample drew serve them all: the jitter moves only the
1712
+ // projection, after the source fitted its shadows to the unmoved camera.
1713
+ const shadowsUpdate = this.renderer.shadowMap.autoUpdate;
1714
+ try {
1715
+ // Merged over its backdrop after the samples, as Blender's viewport does: each sample is
1716
+ // drawn with none when the backdrop is a flat colour (an opaque photograph of a
1717
+ // transparent drawing is laid over it below). The first sample decides; the rest follow.
1718
+ // The merge writes the display encoding itself; under a tone-mapping renderer the
1719
+ // drawing goes through the output pass as any other.
1720
+ const mergeBackdrop = sampling.backdropMerge && !transparentCapture && !transform &&
1721
+ this.renderer.toneMapping === THREE.NoToneMapping;
1722
+ this.captureBackdrop = null;
1723
+ accumulation.clear(this.renderer);
1724
+ accumulation.offsets.forEach((offset, index) => {
1725
+ this.captureSample = index;
1726
+ this.renderer.shadowMap.autoUpdate = shadowsUpdate && index === 0;
1727
+ this.captureJitter = {
1728
+ offset: [offset[0] * sampling.projectionScale, offset[1] * sampling.projectionScale],
1729
+ width: renderWidth, height: renderHeight,
1730
+ };
1731
+ this.renderer.setRenderTarget(sceneTarget);
1732
+ this.renderCapture(
1733
+ (camera) => this.renderSolidForCapture(camera, sceneTarget, renderWidth, renderHeight, sizedComposer),
1734
+ transparentCapture,
1735
+ mergeBackdrop && (index === 0 || this.captureBackdrop !== null),
1736
+ );
1737
+ this.captureJitter = null;
1738
+ accumulation.add(this.renderer, sceneTarget.texture, offset);
1739
+ });
1740
+ const drawing = accumulation.resolve(this.renderer);
1741
+ if (mergeBackdrop && this.captureBackdrop) {
1742
+ accumulation.merge(this.renderer, drawing, this.captureBackdrop, target);
1743
+ merged = true;
1744
+ }
1745
+ resolved = null;
1746
+ if (!merged) {
1747
+ if (transform) transform.render(this.renderer, drawing, target, transparentCapture);
1748
+ else viewportCaptureOutputPass(transparentCapture).render(this.renderer, target, drawing, 0, false);
1749
+ merged = true;
1750
+ }
1751
+ } finally {
1752
+ this.captureSample = 0;
1753
+ this.renderer.shadowMap.autoUpdate = shadowsUpdate;
1754
+ if (sizedComposer) this.restoreComposerAfterCapture(sizedComposer);
1755
+ }
1756
+ } else {
1757
+ this.renderer.setRenderTarget(sceneTarget);
1758
+ this.renderCapture(
1759
+ (camera) => this.renderSolidForCapture(camera, sceneTarget, renderWidth, renderHeight),
1760
+ transparentCapture,
1761
+ );
1762
+ }
1763
+ const straightAlpha = transparentCapture;
1764
+ if (!merged) {
1765
+ if (transform) {
1766
+ resolved = resolveSceneLinearSize(this.renderer, sceneTarget, outputWidth, outputHeight);
1767
+ transform.render(this.renderer, resolved, target, straightAlpha);
1768
+ }
1769
+ else viewportCaptureOutputPass(straightAlpha).render(this.renderer, target, sceneTarget, 0, false);
1556
1770
  }
1557
- else viewportCaptureOutputPass(straightAlpha).render(this.renderer, target, sceneTarget, 0, false);
1558
1771
 
1559
1772
  const rowBytes = target.width * 4;
1560
1773
  const pixels = new Uint8Array(rowBytes * target.height);
@@ -1580,6 +1793,10 @@ export class Object3DDocumentSession {
1580
1793
  return output.toDataURL('image/png');
1581
1794
  } finally {
1582
1795
  this.captureAspect = null;
1796
+ this.capturePixelScale = null;
1797
+ this.captureJitter = null;
1798
+ this.captureBackdrop = null;
1799
+ accumulation?.dispose();
1583
1800
  this.cameraOverride = null;
1584
1801
  perspective.aspect = previousAspect;
1585
1802
  perspective.updateProjectionMatrix();
@@ -1612,6 +1829,8 @@ export class Object3DDocumentSession {
1612
1829
  if (this.selectionOutline)
1613
1830
  releaseThreeSelectionOutline(this.renderer, this.selectionOutline, this.selectionOutlinePass);
1614
1831
  this.composer?.dispose();
1832
+ this.live?.dispose();
1833
+ this.live = null;
1615
1834
  this.displayTarget?.dispose();
1616
1835
  this.displayTarget = null;
1617
1836
  this.displayTransform = null;
@@ -1631,34 +1850,8 @@ export class Object3DDocumentSession {
1631
1850
  this.helpers.push(helper);
1632
1851
  }
1633
1852
 
1634
- /**
1635
- * THE LAZY DOOR for `postprocessing` (~200 kB of passes and shaders).
1636
- *
1637
- * The composer exists for ONE feature — the editor-owned selection
1638
- * silhouette; `syncComposerOutput` hands the screen back to the plain scene
1639
- * pass whenever nothing is outlined — so the library belongs with the first
1640
- * frame of this document, not with the module that draws it. Everything an
1641
- * Asset Lab document does before that first frame (mount, adopt, frame the
1642
- * content) is untouched by it.
1643
- *
1644
- * Called from every `renderViewport`, so it must be cheap and re-entrant:
1645
- * `composerLoading` keeps exactly one import in flight, and the continuation
1646
- * re-checks `disposed` because a document can close mid-load.
1647
- */
1648
- /**
1649
- * WHETHER THIS DOCUMENT DRAWS THE SELECTION SILHOUETTE AT ALL — a LIVE
1650
- * switch, not just a construction-time one.
1651
- *
1652
- * Two callers turn it off, for different reasons. The inspector's preview
1653
- * lane shows a subject with nothing selectable inside it, so it never needs
1654
- * the pass — or the effect it would build per selection — and says so once.
1655
- * A modeling document says so whenever it is in EDIT mode: Blender draws no
1656
- * object outline there (`modeling-edit-all.png`), and the silhouette
1657
- * otherwise follows the hierarchy selection straight through the mode
1658
- * change. That second caller flips it while the composer is already built,
1659
- * so turning it off must drop the outline the composer is currently drawing
1660
- * — gating construction alone leaves the frame exactly as it was.
1661
- */
1853
+ /** Enable the selection silhouette. Preview-only subjects and Edit mode
1854
+ * disable it; changing this also clears any already-built outline. */
1662
1855
  get selectionOutlineEnabled(): boolean {
1663
1856
  return this.selectionOutlineWanted;
1664
1857
  }
@@ -1673,7 +1866,6 @@ export class Object3DDocumentSession {
1673
1866
  }
1674
1867
  private selectionOutlineWanted = true;
1675
1868
 
1676
- /** Whether the view draws its selected objects' origins (`overlays.selection.origins`). */
1677
1869
  /** The view's X-ray for its current draw mode (`ViewportModePresentation.xray`). */
1678
1870
  private xray: ViewportXray = { enabled: false, alpha: 1 };
1679
1871
 
@@ -1683,6 +1875,7 @@ export class Object3DDocumentSession {
1683
1875
  invalidateStages();
1684
1876
  }
1685
1877
 
1878
+ /** Whether the view draws selected-object origins (`overlays.selection.origins`). */
1686
1879
  set selectionOriginsEnabled(value: boolean) {
1687
1880
  if (value === (this.selectionOrigins !== null)) return;
1688
1881
  if (value) {
@@ -1696,6 +1889,8 @@ export class Object3DDocumentSession {
1696
1889
  }
1697
1890
  }
1698
1891
 
1892
+ /** Lazily import and construct the selection-outline composer. Keep one
1893
+ * import in flight and discard its result if the document closes. */
1699
1894
  private ensureComposer(): void {
1700
1895
  if (!this.selectionOutlineWanted) return;
1701
1896
  if (this.composer || this.composerLoading || this.disposed) return;
@@ -1735,35 +1930,15 @@ export class Object3DDocumentSession {
1735
1930
  });
1736
1931
  }
1737
1932
 
1738
- /**
1739
- * EffectComposer marks the last added pass as the screen output. Disabling
1740
- * that pass without transferring screen ownership to the scene pass leaves
1741
- * the unoutlined frame stranded in the composer's offscreen buffer. Keep
1742
- * selection responsible only for the outline, never for whether the asset
1743
- * itself reaches the canvas.
1744
- */
1745
- /**
1746
- * Draw the scene FOR A PHOTOGRAPH into `target` the way the screen sees it:
1747
- * through the outline pass when a selection silhouette is on (the outline is
1748
- * a pass, absent from a plain draw — a capture without it lied about the
1749
- * screen), the plain draw otherwise.
1750
- *
1751
- * The two passes are driven DIRECTLY rather than through `composer.render()`,
1752
- * because neither of the composer's own doors says where a frame landed:
1753
- * `EffectComposer.render` ping-pongs LOCAL variables and never reassigns
1754
- * `inputBuffer`/`outputBuffer`, and `CopyPass.render` ignores its
1755
- * `outputBuffer` argument entirely (it always writes to its own render
1756
- * target, 1x1 unless the composer sized it). Going through them wrote the
1757
- * photograph nowhere — every capture of a selected object came back empty.
1758
- * A pass's own contract is exact: `RenderPass` draws the scene into the
1759
- * `inputBuffer` it is handed, `EffectPass` reads that and writes the
1760
- * `outputBuffer` it is handed — so hand the outline pass the capture target.
1761
- */
1933
+ /** Capture through the current scene and outline passes. Resize only the
1934
+ * composer's offscreen buffers, then restore their live dimensions. */
1762
1935
  private renderSolidForCapture(
1763
1936
  camera: THREE.Camera,
1764
1937
  target: THREE.WebGLRenderTarget,
1765
1938
  width: number,
1766
1939
  height: number,
1940
+ /** The composer is already at the photograph's size (a sampled photograph sized it once). */
1941
+ sized: { readonly width: number; readonly height: number } | null = null,
1767
1942
  ): void {
1768
1943
  const composer = this.composer;
1769
1944
  const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
@@ -1771,6 +1946,14 @@ export class Object3DDocumentSession {
1771
1946
  this.renderLinearScene(camera, target);
1772
1947
  return;
1773
1948
  }
1949
+ if (sized) {
1950
+ try {
1951
+ this.renderLinearScene(camera, target);
1952
+ } finally {
1953
+ this.renderer.setRenderTarget(target);
1954
+ }
1955
+ return;
1956
+ }
1774
1957
  const previousSize = this.renderer.getDrawingBufferSize(new THREE.Vector2());
1775
1958
  try {
1776
1959
  this.resizeComposerBuffers(width, height);
@@ -1782,6 +1965,21 @@ export class Object3DDocumentSession {
1782
1965
  }
1783
1966
  }
1784
1967
 
1968
+ /** A sampled photograph's composer sizing, once for all its samples; null when the capture
1969
+ * does not draw through the composer (nothing is outlined). */
1970
+ private sizeComposerForCapture(width: number, height: number): { readonly width: number; readonly height: number } | null {
1971
+ const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
1972
+ if (!this.composer || !outlined || !this.sceneRenderPass || !this.selectionOutlinePass) return null;
1973
+ const previous = this.renderer.getDrawingBufferSize(new THREE.Vector2());
1974
+ this.resizeComposerBuffers(width, height);
1975
+ return { width: previous.x, height: previous.y };
1976
+ }
1977
+
1978
+ private restoreComposerAfterCapture(previous: { readonly width: number; readonly height: number }): void {
1979
+ this.resizeComposerBuffers(previous.width, previous.height);
1980
+ this.syncComposerOutput();
1981
+ }
1982
+
1785
1983
  /** Offscreen sizing must not call EffectComposer.setSize: it also resizes
1786
1984
  * the renderer's visible canvas, clearing the frame a human is watching. */
1787
1985
  private resizeComposerBuffers(width: number, height: number): void {
@@ -1823,6 +2021,7 @@ export class Object3DDocumentSession {
1823
2021
  }
1824
2022
  }
1825
2023
 
2024
+ /** Route screen output through the outline pass only while it has a selection. */
1826
2025
  private syncComposerOutput(): void {
1827
2026
  const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
1828
2027
  if (this.sceneRenderPass) this.sceneRenderPass.renderToScreen = !outlined;
@@ -1945,30 +2144,7 @@ export class Object3DDocumentSession {
1945
2144
  }
1946
2145
  }
1947
2146
 
1948
- // Compatibility for existing internal callers/tests; lookup-only consumers
1949
- // import the registry directly so they do not pull this implementation graph.
1950
- export {
1951
- __resetObject3DDocumentSessionsForTest,
1952
- allObject3DDocumentSessions,
1953
- object3DDocumentSession,
1954
- object3DDocumentSessionsVersion,
1955
- registerObject3DDocumentSession,
1956
- subscribeObject3DDocumentSessions,
1957
- } from './object3d-document-session-registry';
1958
2147
 
1959
- /**
1960
- * OBJECT ORIGINS: a dot at the origin of each selected object, drawn over everything at a fixed
1961
- * pixel size — Blender's "Origins" overlay (`overlays.selection.origins` in
1962
- * `@volter/sdk/kit/viewport-presentation`). Blender draws it one pixel wider than its
1963
- * Object Origin Size (default 6; `overlay_instance.cc`: `obcenter_dia + 1`) with a one-pixel
1964
- * dark rim, and in the active object's
1965
- * colour for the active object; the kit's rule for the active colour stands in for that: a lone
1966
- * selected object is shown in the look's active colour, several in its selection colour, the
1967
- * same rule the outline follows.
1968
- *
1969
- * The positions are read from each object's world matrix as the frame is drawn, so a dot follows
1970
- * a drag without the selection having to change.
1971
- */
1972
2148
  /** Blender's drawn origin diameter at the default Object Origin Size, in CSS pixels. three
1973
2149
  * scales a point's size by the pixel ratio itself (`WebGLMaterials.refreshUniformsPoints`). */
1974
2150
  const ORIGIN_SIZE_PX = 7;
@@ -1995,6 +2171,8 @@ function originDotTexture(): THREE.Texture {
1995
2171
  return dotTexture;
1996
2172
  }
1997
2173
 
2174
+ /** Draw selected-object origins at a fixed pixel size, using each object's
2175
+ * current world matrix and the active/selection colors. */
1998
2176
  class SelectionOrigins extends THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> {
1999
2177
  private objects: readonly THREE.Object3D[] = [];
2000
2178
  private readonly origin = new THREE.Vector3();
@@ -2056,3 +2234,88 @@ class SelectionOrigins extends THREE.Points<THREE.BufferGeometry, THREE.PointsMa
2056
2234
  this.material.dispose();
2057
2235
  }
2058
2236
  }
2237
+
2238
+ /**
2239
+ * The live view's accumulation (`Object3DDocumentSession.renderLiveFrame`). Its working targets
2240
+ * (the sample, the sum) are half precision, made on the first sample and freed when the sum is
2241
+ * complete; the finished picture is kept display-encoded, 4 bytes a pixel.
2242
+ */
2243
+ class LiveAccumulation {
2244
+ /** The next sample to draw: 0 starts over, `count` is done. */
2245
+ next = 0;
2246
+ /** The content revision the sum was begun at. */
2247
+ revision: string | null = null;
2248
+ /** The flat backdrop the samples leave out, to merge the sum over; null when drawn in. */
2249
+ backdrop: THREE.Color | null = null;
2250
+ /** The finished picture as shown, once every sample is in. */
2251
+ finished: THREE.WebGLRenderTarget | null = null;
2252
+ /** A failure ended the accumulation; cleared by the next change. */
2253
+ failed = false;
2254
+ readonly count: number;
2255
+ /** The sum is shown from this many samples on; the ordinary frame before. */
2256
+ readonly showFrom: number;
2257
+ private accumulation: PhotographAccumulation | null = null;
2258
+ private sampleTarget: THREE.WebGLRenderTarget | null = null;
2259
+ private readonly copy = new FullScreenQuad(new THREE.RawShaderMaterial({
2260
+ name: 'Live view finished picture', glslVersion: THREE.GLSL3, depthTest: false, depthWrite: false,
2261
+ blending: THREE.NoBlending, uniforms: { picture: { value: null } },
2262
+ vertexShader: `precision highp float; in vec3 position; in vec2 uv; out vec2 vUv;
2263
+ void main(){vUv=uv;gl_Position=vec4(position,1.0);}`,
2264
+ fragmentShader: `precision highp float; uniform sampler2D picture; in vec2 vUv; out vec4 outputColor;
2265
+ void main(){outputColor=texelFetch(picture, ivec2(vUv * vec2(textureSize(picture, 0))), 0);}`,
2266
+ }));
2267
+
2268
+ constructor(readonly sampling: PhotographSampling, readonly width: number, readonly height: number) {
2269
+ this.count = Math.min(sampling.offsets.length, PHOTOGRAPH_SAMPLE_CAP);
2270
+ this.showFrom = Math.min(this.count, Math.max(1, sampling.liveFrom ?? this.count));
2271
+ }
2272
+
2273
+ /** The working targets, made on demand. */
2274
+ working(renderer: THREE.WebGLRenderer): { accumulation: PhotographAccumulation; sampleTarget: THREE.WebGLRenderTarget } {
2275
+ this.accumulation ??= new PhotographAccumulation(this.sampling, this.width, this.height, renderer, 'half');
2276
+ this.sampleTarget ??= new THREE.WebGLRenderTarget(this.width, this.height, { type: THREE.HalfFloatType });
2277
+ return { accumulation: this.accumulation, sampleTarget: this.sampleTarget };
2278
+ }
2279
+
2280
+ finishedTarget(): THREE.WebGLRenderTarget {
2281
+ this.finished ??= new THREE.WebGLRenderTarget(this.width, this.height, { depthBuffer: false });
2282
+ return this.finished;
2283
+ }
2284
+
2285
+ blitFinished(renderer: THREE.WebGLRenderer, target: THREE.WebGLRenderTarget | null): void {
2286
+ if (!this.finished) return;
2287
+ (this.copy.material as THREE.RawShaderMaterial).uniforms['picture']!.value = this.finished.texture;
2288
+ const autoClear = renderer.autoClear;
2289
+ renderer.setRenderTarget(target);
2290
+ renderer.autoClear = false;
2291
+ try {
2292
+ this.copy.render(renderer);
2293
+ } finally {
2294
+ renderer.autoClear = autoClear;
2295
+ }
2296
+ }
2297
+
2298
+ /** A change: the next still frame begins a new sum. */
2299
+ restart(): void {
2300
+ this.next = 0;
2301
+ this.revision = null;
2302
+ this.failed = false;
2303
+ this.finished?.dispose();
2304
+ this.finished = null;
2305
+ }
2306
+
2307
+ releaseWorking(): void {
2308
+ this.accumulation?.dispose();
2309
+ this.accumulation = null;
2310
+ this.sampleTarget?.dispose();
2311
+ this.sampleTarget = null;
2312
+ }
2313
+
2314
+ dispose(): void {
2315
+ this.releaseWorking();
2316
+ this.finished?.dispose();
2317
+ this.finished = null;
2318
+ this.copy.material.dispose();
2319
+ this.copy.dispose();
2320
+ }
2321
+ }