@volter/editor-threejs 0.5.66 → 0.5.68

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 (99) hide show
  1. package/contributions/animation-timeline.utility.tsx +44 -0
  2. package/contributions/three-integration.service.ts +13 -0
  3. package/package.json +96 -6
  4. package/src/adapter/renderer-config.ts +3 -4
  5. package/src/adapter/three-contract.ts +72 -0
  6. package/src/ecs/object-marks.ts +1 -1
  7. package/src/ecs/user-data.ts +0 -9
  8. package/src/host-hierarchy-objects.ts +31 -0
  9. package/src/kit/animation/three-clips-subject.ts +190 -0
  10. package/src/kit/asset-compare.ts +294 -0
  11. package/src/kit/asset-preview-command.ts +265 -0
  12. package/src/kit/asset-preview-framing.ts +357 -0
  13. package/src/kit/asset-preview.ts +2802 -0
  14. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  15. package/src/kit/authoring/component-instance-root.ts +171 -0
  16. package/src/kit/authoring/design-time-settle.ts +343 -0
  17. package/src/kit/authoring/live-object-transform.ts +62 -0
  18. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  19. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  20. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  21. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  22. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  23. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  24. package/src/kit/authoring/three-projection-core.ts +226 -0
  25. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  26. package/src/kit/authoring/viewport-raycast.ts +240 -0
  27. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  28. package/src/kit/camera-authoring.ts +175 -0
  29. package/src/kit/components/CameraInfo.tsx +56 -0
  30. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  31. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  32. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  33. package/src/kit/components/StageHost.tsx +2547 -0
  34. package/src/kit/components/StageOverlays.tsx +21 -0
  35. package/src/kit/components/StatsOverlay.tsx +78 -0
  36. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  37. package/src/kit/components/ViewportFurniture.tsx +655 -0
  38. package/src/kit/components/ViewportOverlay.tsx +215 -0
  39. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  40. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  41. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  42. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  43. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  44. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  45. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  46. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  47. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  48. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  49. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  50. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  51. package/src/kit/components/stage-keyboard.tsx +40 -0
  52. package/src/kit/components/stage-overlay-set.tsx +105 -0
  53. package/src/kit/components/stage-presence-markers.ts +482 -0
  54. package/src/kit/components/stage-transform-chrome.ts +30 -0
  55. package/src/kit/components/stage-transform-tools.tsx +73 -0
  56. package/src/kit/components/stage-view-name.ts +30 -0
  57. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  58. package/src/kit/components/world-root-binding.ts +64 -0
  59. package/src/kit/constraint-helper.ts +338 -0
  60. package/src/kit/editor-shell-store.ts +814 -0
  61. package/src/kit/editor-viewport.ts +6621 -0
  62. package/src/kit/entity-lod.ts +31 -0
  63. package/src/kit/entity-object.ts +92 -0
  64. package/src/kit/hierarchy-mark-reader.ts +74 -0
  65. package/src/kit/instanced-presentation.ts +164 -0
  66. package/src/kit/live-module-source.ts +230 -0
  67. package/src/kit/model-thumbnail.ts +539 -0
  68. package/src/kit/play-camera-flight.ts +300 -0
  69. package/src/kit/projection/three.ts +898 -0
  70. package/src/kit/reflection-probe-helper.ts +142 -0
  71. package/src/kit/scene-document-viewport.ts +51 -0
  72. package/src/kit/scene-framing.ts +315 -0
  73. package/src/kit/scene-view-fog.ts +89 -0
  74. package/src/kit/spatial-handle-visuals.ts +332 -0
  75. package/src/kit/stories/three-story-model.ts +66 -0
  76. package/src/kit/three-canvas-render.ts +44 -0
  77. package/src/kit/three-hierarchy-row-media.ts +26 -0
  78. package/src/kit/three-inspection-media.ts +73 -0
  79. package/src/kit/three-integration.ts +86 -0
  80. package/src/kit/three-state.ts +33 -0
  81. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  82. package/src/kit/three-viewport/camera-fit.ts +41 -0
  83. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  84. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  85. package/src/kit/three-viewport/selection-outline.ts +333 -0
  86. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  87. package/src/kit/three-viewport/source-color.ts +197 -0
  88. package/src/kit/three-viewport/studio-environment.ts +96 -0
  89. package/src/kit/trigger-volume-helper.ts +116 -0
  90. package/src/kit/viewport-actions.ts +128 -0
  91. package/src/kit/viewport-authoring-policy.ts +154 -0
  92. package/src/kit/viewport-commands.ts +318 -0
  93. package/src/kit/viewport-hotkeys.ts +119 -0
  94. package/src/kit/viewport-shading-boundary.ts +12 -0
  95. package/src/kit/viewport-status-facet.ts +53 -0
  96. package/src/object3d-contributions.ts +494 -0
  97. package/src/render/viewport-shading.ts +6 -2
  98. package/src/viewport-api.ts +92 -0
  99. package/src/viewport-door.ts +237 -0
@@ -0,0 +1,1965 @@
1
+ import { createPerformanceProfiler } from '@volter/editor-sdk/kit/performance-profiler';
2
+ import { resetViewPresentation, type ViewportXray } from '@volter/editor-sdk/kit/viewport-presentation';
3
+ import { invalidateStages } from '@volter/editor-sdk/kit/stage-invalidation';
4
+ import type { AuthoringAdapter } from '@volter/editor-project/adapter';
5
+ import { viewportCaptureOutputPass } from '@volter/editor-threejs/capture/output-pass';
6
+ import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
7
+ import { setUserData } from '@volter/editor-threejs/ecs/user-data';
8
+ import {
9
+ type ViewportShadingMode,
10
+ ViewportShadingRenderer,
11
+ } from '@volter/editor-threejs/render/viewport-shading';
12
+ import type { EffectComposer, EffectPass, RenderPass } from 'postprocessing';
13
+ import * as THREE from 'three';
14
+ import { threeObject } from '../../adapter/three-contract';
15
+ import { axisViewName, cameraPresetDirection, type ModelCameraPreset } from '../asset-workflow/model-inspection';
16
+ import { activeKeymapNavigation } from '@volter/editor-sdk/kit/keymap-presets';
17
+ import { editorConsole } from '@volter/editor-sdk/kit/editor-console';
18
+ import { type EditorViewport } from '../editor-viewport';
19
+ import { nativeSelectionColors, subscribeNativeSelectionTheme } from '@volter/editor-sdk/kit/native-selection-style';
20
+ import { BoneSelectionHighlight } from '../three-viewport/bone-selection-highlight';
21
+ import { perspectiveDistanceToFitBox } from '../three-viewport/camera-fit';
22
+ import {
23
+ acquireThreeSelectionOutline,
24
+ releaseThreeSelectionOutline,
25
+ setThreeSelectionOutlineColors,
26
+ syncThreeSelectionOutline,
27
+ } from '../three-viewport/selection-outline';
28
+ import { styleEditorSkeletonHelper } from '../three-viewport/skeleton-helper';
29
+ import { isEditorViewportShadingTarget } from '../viewport-shading-boundary';
30
+ import { setAuthoringSelection } from '@volter/editor-sdk/kit/authoring/consumer-actions';
31
+ import type { ToolCameraView, ToolCameraViewSource } from '../../object3d-contributions';
32
+
33
+ export type Object3DDocumentViewMode = ViewportShadingMode | 'uv' | 'vertex-colors';
34
+
35
+ /**
36
+ * How a look flight ended. Never a rejection: "the human grabbed the view
37
+ * mid-orbit" is an ordinary outcome of a shared camera, not an error, and the
38
+ * caller needs to be able to tell it apart from "it ran to the end".
39
+ */
40
+ export interface DocumentLookOutcome {
41
+ readonly completed: boolean;
42
+ /** Only when `completed` is false. */
43
+ readonly cancelledBy?: 'human' | 'superseded' | 'closed';
44
+ /** Where the camera actually ended up, in the pivot's spherical frame. */
45
+ readonly azimuth: number;
46
+ readonly elevation: number;
47
+ readonly seconds: number;
48
+ }
49
+
50
+ /**
51
+ * One in-flight camera move. The pivot and radius are captured at LAUNCH from
52
+ * wherever the human left the view, so a flight always lerps from the current
53
+ * pose rather than snapping to a canonical one.
54
+ */
55
+ interface CameraFlight {
56
+ readonly pivot: THREE.Vector3;
57
+ readonly radius: number;
58
+ readonly startTheta: number;
59
+ readonly startPhi: number;
60
+ readonly deltaTheta: number;
61
+ readonly deltaPhi: number;
62
+ readonly duration: number;
63
+ readonly ease: (t: number) => number;
64
+ elapsed: number;
65
+ theta: number;
66
+ phi: number;
67
+ settle: ((outcome: DocumentLookOutcome) => void) | null;
68
+ }
69
+
70
+ /**
71
+ * Dihedral angle below which an interior edge is dropped from the topology
72
+ * overlay. At 1° a PLANAR quad's triangulation diagonal disappears while every
73
+ * real face boundary survives — which is the entire reason the overlay exists
74
+ * (`toBufferGeometry` fan-triangulates, so nothing downstream of the mesh kit
75
+ * still knows which pairs of triangles were one quad).
76
+ */
77
+ const TOPOLOGY_EDGE_THRESHOLD_DEGREES = 1;
78
+
79
+ /** OrbitControls' own poles-excluded range; orbiting past them flips the view. */
80
+ /** How long a presented-frame request waits on the host's draw loop before it
81
+ * gives the chrome door back `null` (see `requestPresentedFrame`). Two frames
82
+ * at 30fps is a loop that is running; longer means it is not. */
83
+ const PRESENTED_FRAME_WAIT_MS = 700;
84
+
85
+ const MIN_POLAR = 1e-3;
86
+ const MAX_POLAR = Math.PI - 1e-3;
87
+
88
+ const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
89
+ const linear = (t: number): number => t;
90
+
91
+ function finiteOr(value: unknown, fallback: number): number {
92
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
93
+ }
94
+
95
+ export interface Object3DDocumentPresentationState {
96
+ readonly mode: Object3DDocumentViewMode;
97
+ readonly background: 'neutral' | 'transparent';
98
+ readonly projection: 'perspective' | 'orthographic';
99
+ readonly skeleton: boolean;
100
+ readonly bounds: boolean;
101
+ }
102
+
103
+ const INITIAL_PRESENTATION: Object3DDocumentPresentationState = {
104
+ mode: 'solid',
105
+ background: 'neutral',
106
+ projection: 'perspective',
107
+ skeleton: false,
108
+ bounds: false,
109
+ };
110
+
111
+ /**
112
+ * Live presentation controls for one native Object3D document. The model
113
+ * graph remains truth; this session owns only editor chrome and diagnostics.
114
+ */
115
+ /** The wire of an unselected object in the wireframe overlay: dark on the light clay body. */
116
+ const TOPOLOGY_WIRE_COLOR = 0x11161d;
117
+
118
+ export class Object3DDocumentSession {
119
+ readonly profiler = createPerformanceProfiler();
120
+ private readonly shading = new ViewportShadingRenderer();
121
+ private readonly helpers: THREE.Object3D[] = [];
122
+ private skeletonHelper: THREE.SkeletonHelper | null = null;
123
+ private boundsHelper: THREE.BoxHelper | null = null;
124
+ private boneSelectionHighlight: BoneSelectionHighlight | null = null;
125
+ private boneSelectionSignature = '';
126
+ private composer: EffectComposer | null = null;
127
+ /** One in-flight `import('postprocessing')` at a time — {@link ensureComposer}
128
+ * is called from every frame. */
129
+ private composerLoading = false;
130
+ private disposed = false;
131
+ private sceneRenderPass: RenderPass | null = null;
132
+ private selectionOutline: ReturnType<typeof acquireThreeSelectionOutline> | null = null;
133
+ private selectionOutlinePass: EffectPass | null = null;
134
+ private selectedObjects: THREE.Object3D[] = [];
135
+ /** The view's origin dots (`overlays.selection.origins`), when it shows them. */
136
+ private selectionOrigins: SelectionOrigins | null = null;
137
+ private renderWidth = 1;
138
+ private renderHeight = 1;
139
+ private readonly orthographicCamera = new THREE.OrthographicCamera();
140
+ /** The offscreen render target's aspect, non-null ONLY inside
141
+ * {@link Object3DDocumentSession.captureImage} — whose buffer has its own
142
+ * shape (square by default, `{width, height}` when a caller asks for one)
143
+ * while the live camera's aspect is the panel's. */
144
+ private captureAspect: number | null = null;
145
+ /** The camera a capture door was handed, for the duration of that capture.
146
+ * A BLENDER RENDER IS NOT THE MODELING VIEWPORT: it photographs the scene
147
+ * through the scene's OWN camera, which has nothing to do with where the
148
+ * person is looking — see `blender-runtime-host.ts`. Before this existed the
149
+ * only way to photograph from another camera was to move the viewport onto
150
+ * it and move it back, which is why that code had a restore dance at all. */
151
+ private cameraOverride: THREE.Camera | null = null;
152
+ /** The document's cameras a camera view can look through (`ToolObject3DAuthoringProps.cameraView`). */
153
+ private cameraViewSource: ToolCameraViewSource | null = null;
154
+ /** A camera view in progress: the camera, the frame's zoom and offset, the view it left, and
155
+ * how to put its input back. */
156
+ private through: {
157
+ readonly camera: string;
158
+ zoom: number;
159
+ offset: [number, number];
160
+ readonly left: {
161
+ readonly position: THREE.Vector3;
162
+ readonly target: THREE.Vector3;
163
+ readonly up: THREE.Vector3;
164
+ readonly projection: 'perspective' | 'orthographic';
165
+ };
166
+ /** Undo the input the view holds: its own capture, or the lock's hand-over. */
167
+ release: () => void;
168
+ locked: boolean;
169
+ } | null = null;
170
+ private leaving = false;
171
+ /** The view computed for one synchronous burst of readers (a frame asks a dozen times). */
172
+ private viewMemo: { key: string; view: ToolCameraView | null } | null = null;
173
+ private readonly throughPerspective = new THREE.PerspectiveCamera();
174
+ private readonly throughOrthographic = new THREE.OrthographicCamera();
175
+ /** The host's mirror of the document scene onto the rendered scene — see
176
+ * {@link Object3DDocumentSession.setBeforeRender}. */
177
+ private beforeRender: (() => void) | null = null;
178
+ private state = INITIAL_PRESENTATION;
179
+ private version = 0;
180
+ private readonly listeners = new Set<() => void>();
181
+ private unsubscribeSelectionTheme: () => void = () => {};
182
+ /** The one in-flight look move. See {@link Object3DDocumentSession.orbit}. */
183
+ private flight: CameraFlight | null = null;
184
+ /** The camera position the live flight wrote LAST frame — the drift check
185
+ * in {@link Object3DDocumentSession.advanceLook} compares against it. */
186
+ private readonly flightPose = new THREE.Vector3();
187
+ /** Wireframe's edge overlay and the meshes each line set shadows. */
188
+ private topologyOverlay: THREE.Group | null = null;
189
+ private topologyFollowers: Array<{
190
+ readonly line: THREE.LineSegments;
191
+ readonly source: THREE.Object3D;
192
+ }> = [];
193
+
194
+ constructor(
195
+ readonly documentId: string,
196
+ public root: THREE.Object3D,
197
+ readonly scene: THREE.Scene,
198
+ readonly renderer: THREE.WebGLRenderer,
199
+ readonly viewport: EditorViewport,
200
+ private authoring?: AuthoringAdapter,
201
+ /** Asset Lab renders its neutral stage in editor chrome behind an alpha
202
+ * canvas; other document hosts may retain a real Three background. */
203
+ private neutralBackground: THREE.Color | THREE.Texture | null = new THREE.Color(0x20242a),
204
+ /** When set, Frame and view presets fit this box instead of the root AABB. */
205
+ private readonly frameBox: THREE.Box3 | null = null,
206
+ ) {
207
+ this.unsubscribeSelectionTheme = subscribeNativeSelectionTheme(
208
+ renderer.domElement,
209
+ this.syncSelectionTheme,
210
+ );
211
+ // THE HUMAN ALWAYS WINS THE CAMERA. OrbitControls fires `start` on the
212
+ // pointer-down that begins a real drag (its `update()` only ever fires
213
+ // `change`), so this is the one event that means "a person just grabbed
214
+ // this view". An agent flight yields at that instant, leaving the camera
215
+ // exactly where it is: no snap-back, no two writers fighting for the pose.
216
+ viewport.orbitControls.addEventListener('start', this.cancelLookForHuman);
217
+ this.unsubscribeRotateStart = viewport.onRotateStart(this.autoPerspective);
218
+ this.unsubscribeAxisView = viewport.onAxisView(this.axisView);
219
+ }
220
+
221
+ private readonly unsubscribeRotateStart: () => void;
222
+ private readonly unsubscribeAxisView: () => void;
223
+
224
+ /** The navigation gizmo turning the view to an axis: out of a camera view at the camera's pose
225
+ * (Blender's `view_axis` leaves it), and orthographic under Auto Perspective. */
226
+ private readonly axisView = (): boolean => {
227
+ this.leaveCameraView(false);
228
+ this.settleFlight('superseded');
229
+ if (activeKeymapNavigation().autoPerspective) this.setProjection('orthographic');
230
+ return true;
231
+ };
232
+
233
+ /** Auto Perspective, where the keymap states it (`KeymapNavigation.autoPerspective`): a rotate
234
+ * that starts from an orthographic view still down an axis draws in perspective. */
235
+ private readonly autoPerspective = (): void => {
236
+ if (!activeKeymapNavigation().autoPerspective || this.cameraView() !== null) return;
237
+ if (this.state.projection !== 'orthographic') return;
238
+ const camera = this.viewport.camera;
239
+ const offset = camera.position.clone().sub(this.viewport.orbitControls.target);
240
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
241
+ if (axisViewName(offset, up) !== null) this.setProjection('perspective');
242
+ };
243
+
244
+ /** Replace authored content without replacing the editor session or camera. */
245
+ replaceContent(root: THREE.Object3D, authoring: AuthoringAdapter): void {
246
+ invalidateStages();
247
+ this.selectionOutline?.selection.clear();
248
+ this.selectedObjects = [];
249
+ this.boneSelectionSignature = '';
250
+ this.clearBoneSelectionHighlight();
251
+ this.clearSkeletonHelper();
252
+ this.clearBoundsHelper();
253
+ this.clearTopologyOverlay();
254
+ this.root = root;
255
+ this.authoring = authoring;
256
+ if (this.state.mode === 'wireframe') this.buildTopologyOverlay();
257
+ if (this.state.bounds) {
258
+ this.boundsHelper = new THREE.BoxHelper(root, 0xffb454);
259
+ this.addHelper(this.boundsHelper, 'bounds');
260
+ }
261
+ this.syncSelectionPresentation();
262
+ this.refreshSkeletonHelper();
263
+ this.notify();
264
+ }
265
+
266
+ private readonly cancelLookForHuman = (): void => {
267
+ this.settleFlight('human');
268
+ };
269
+
270
+ /** The look's selection colours, read when the theme changes (not per frame: the read resolves
271
+ * computed styles). */
272
+ private selectionColors: ReturnType<typeof nativeSelectionColors> | null = null;
273
+
274
+ private readonly syncSelectionTheme = (): void => {
275
+ this.selectionColors = nativeSelectionColors(this.renderer.domElement);
276
+ const color = this.selectionColors.visible;
277
+ if (this.selectionOutline) {
278
+ setThreeSelectionOutlineColors(
279
+ this.selectionOutline,
280
+ nativeSelectionColors(this.renderer.domElement),
281
+ );
282
+ }
283
+ this.boneSelectionHighlight?.setColor(color);
284
+ };
285
+
286
+ subscribe = (listener: () => void): (() => void) => {
287
+ this.listeners.add(listener);
288
+ return () => this.listeners.delete(listener);
289
+ };
290
+
291
+ getSnapshot = (): number => this.version;
292
+
293
+ presentation(): Object3DDocumentPresentationState {
294
+ return this.state;
295
+ }
296
+
297
+ private resolveFrameBounds(subject: 'selection' | 'all' = 'selection'): THREE.Box3 {
298
+ const selection = subject === 'all' ? [] : (this.authoring?.selection?.get() ?? []);
299
+ if (selection.length > 0) {
300
+ const selected = new THREE.Box3();
301
+ let found = false;
302
+ for (const id of selection) {
303
+ const object = this.authoring ? threeObject(this.authoring.hierarchy, id) : null;
304
+ if (!object) continue;
305
+ const bounds = contentWorldBounds(object);
306
+ if (bounds.isEmpty()) continue;
307
+ selected.union(bounds);
308
+ found = true;
309
+ }
310
+ if (found) return selected;
311
+ }
312
+ if (this.frameBox && !this.frameBox.isEmpty()) return this.frameBox;
313
+ return contentWorldBounds(this.root);
314
+ }
315
+
316
+ /**
317
+ * Frame the subject: the selection if there is one, else this document's
318
+ * frame box, else the whole root; `subject: 'all'` passes over the
319
+ * selection (Blender's View All). `fit` scales the fitted distance — 1 is
320
+ * the tight fit the toolbar's Frame button has always used, >1 pulls back.
321
+ */
322
+ frame(fit = 1, subject: 'selection' | 'all' = 'selection'): boolean {
323
+ this.leaveCameraView(false);
324
+ const bounds = this.resolveFrameBounds(subject);
325
+ if (bounds.isEmpty()) {
326
+ // A Frame that does nothing must say why — a model document whose
327
+ // mesh measures as nothing is a defect, never a quiet no-op.
328
+ let meshes = 0;
329
+ let positions = 0;
330
+ this.root.traverse((node) => {
331
+ const geometry = (node as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
332
+ if (!geometry) return;
333
+ meshes += 1;
334
+ positions += geometry.getAttribute('position')?.count ?? 0;
335
+ });
336
+ editorConsole.warn(
337
+ `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'}`,
338
+ 'document',
339
+ );
340
+ return false;
341
+ }
342
+ this.settleFlight('superseded');
343
+ const center = bounds.getCenter(new THREE.Vector3());
344
+ const direction = this.viewport.camera.position
345
+ .clone()
346
+ .sub(this.viewport.orbitControls.target)
347
+ .normalize();
348
+ if (direction.lengthSq() === 0) direction.set(0.8, 0.5, -1).normalize();
349
+ const scale = Math.min(10, Math.max(0.1, finiteOr(fit, 1)));
350
+ const distance = perspectiveDistanceToFitBox(bounds, this.viewport.camera, direction) * scale;
351
+ this.viewport.setPose(center.clone().addScaledVector(direction, distance), center);
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * THE AGENT LOOKING AT THE MODEL, as an act a person can watch.
357
+ *
358
+ * Swing this document's ONE camera — the camera the human is looking
359
+ * through — around the framed subject by `azimuth`/`elevation` radians,
360
+ * animated over `duration` seconds (advanced in {@link renderViewport}, so
361
+ * every intermediate pose is a frame that actually got drawn). The returned
362
+ * promise settles when the move ends, either because it finished or because
363
+ * a human grabbed the view.
364
+ *
365
+ * The pivot and radius are read from the CURRENT pose at launch, so this
366
+ * composes with wherever the view already is instead of homing to a canon.
367
+ * A second flight supersedes the first rather than blending with it.
368
+ *
369
+ * The move is drawn by the document's rAF loop, so a document that is not
370
+ * rendering (a background tab, an inactive dock panel) does not orbit —
371
+ * which is the honest behavior for a verb whose whole point is being seen.
372
+ */
373
+ orbit(options: {
374
+ readonly azimuth?: number;
375
+ readonly elevation?: number;
376
+ readonly duration?: number;
377
+ }): Promise<DocumentLookOutcome> {
378
+ return this.launchFlight(
379
+ finiteOr(options.azimuth, 0),
380
+ finiteOr(options.elevation, 0),
381
+ Math.min(60, Math.max(0, finiteOr(options.duration, 0.6))),
382
+ easeInOutCubic,
383
+ );
384
+ }
385
+
386
+ /**
387
+ * A slow full orbit — {@link orbit} with the revolutions spelled in turns
388
+ * and a CONSTANT angular rate, because a turntable that eases in and out
389
+ * reads as a nervous camera rather than a rotating subject.
390
+ */
391
+ turntable(options: {
392
+ readonly seconds?: number;
393
+ readonly revolutions?: number;
394
+ }): Promise<DocumentLookOutcome> {
395
+ const revolutions = finiteOr(options.revolutions, 1);
396
+ return this.launchFlight(
397
+ revolutions * Math.PI * 2,
398
+ 0,
399
+ Math.min(60, Math.max(0, finiteOr(options.seconds, 6))),
400
+ linear,
401
+ );
402
+ }
403
+
404
+ /** Is a look flight drawing right now? */
405
+ looking(): boolean {
406
+ return this.flight !== null;
407
+ }
408
+
409
+ private launchFlight(
410
+ deltaAzimuth: number,
411
+ deltaElevation: number,
412
+ duration: number,
413
+ ease: (t: number) => number,
414
+ ): Promise<DocumentLookOutcome> {
415
+ this.settleFlight('superseded');
416
+ const camera = this.viewport.camera;
417
+ const pivot = this.viewport.orbitControls.target.clone();
418
+ const spherical = new THREE.Spherical().setFromVector3(camera.position.clone().sub(pivot));
419
+ // A degenerate radius (camera sitting on its own target) has no orbit to
420
+ // run; back off to something the subject's own scale justifies.
421
+ const radius =
422
+ spherical.radius > 1e-4
423
+ ? spherical.radius
424
+ : Math.max(this.resolveFrameBounds().getSize(new THREE.Vector3()).length(), 1);
425
+ const flight: CameraFlight = {
426
+ pivot,
427
+ radius,
428
+ startTheta: spherical.theta,
429
+ startPhi: spherical.phi,
430
+ deltaTheta: deltaAzimuth,
431
+ // Elevation is measured UP from the horizon; the polar angle is measured
432
+ // DOWN from +Y. Raising the camera therefore shrinks phi.
433
+ deltaPhi: -deltaElevation,
434
+ duration,
435
+ ease,
436
+ elapsed: 0,
437
+ theta: spherical.theta,
438
+ phi: spherical.phi,
439
+ settle: null,
440
+ };
441
+ this.flight = flight;
442
+ const settled = new Promise<DocumentLookOutcome>((resolve) => {
443
+ flight.settle = resolve;
444
+ });
445
+ // A zero-length move is still a move: apply it now so the pose is correct
446
+ // even on a surface whose next frame never comes.
447
+ this.applyFlightPose(flight, 0);
448
+ if (duration <= 0) this.settleFlight(null);
449
+ return settled;
450
+ }
451
+
452
+ private applyFlightPose(flight: CameraFlight, progress: number): void {
453
+ const k = flight.ease(progress);
454
+ flight.theta = flight.startTheta + flight.deltaTheta * k;
455
+ flight.phi = Math.min(MAX_POLAR, Math.max(MIN_POLAR, flight.startPhi + flight.deltaPhi * k));
456
+ const camera = this.viewport.camera;
457
+ camera.position
458
+ .setFromSphericalCoords(flight.radius, flight.phi, flight.theta)
459
+ .add(flight.pivot);
460
+ this.viewport.orbitControls.target.copy(flight.pivot);
461
+ camera.lookAt(flight.pivot);
462
+ camera.updateMatrixWorld();
463
+ this.flightPose.copy(camera.position);
464
+ }
465
+
466
+ /**
467
+ * Advance the live flight. Called from {@link renderViewport} — i.e. AFTER
468
+ * `EditorViewport.update()` has run OrbitControls for this frame — so the
469
+ * flight has the final say on the pose that is about to be drawn. (Same
470
+ * ordering rule the viewport's own snap-to-view animation follows.)
471
+ */
472
+ private advanceLook(deltaSeconds: number): void {
473
+ const flight = this.flight;
474
+ if (!flight) return;
475
+ // DID THE POSE I WROTE LAST FRAME SURVIVE? If not, something else owns the
476
+ // camera now, and it wins — yielding here rather than yanking the view
477
+ // back onto the arc is the difference between "the agent stopped" and two
478
+ // writers fighting over one camera.
479
+ //
480
+ // This is the door-independent half of cancellation, and it is not
481
+ // redundant with the OrbitControls `start` listener: three dispatches
482
+ // `start` for mouse, wheel and touch, but NOT for its keyboard pan, and a
483
+ // pointer gesture that cannot take pointer capture (every synthetic one)
484
+ // throws inside `onPointerDown` before reaching the dispatch. The listener
485
+ // names the human case early; this catches every writer that moves the
486
+ // camera without announcing itself.
487
+ const tolerance = Math.max(1e-4, flight.radius * 1e-3);
488
+ if (this.viewport.camera.position.distanceToSquared(this.flightPose) > tolerance * tolerance) {
489
+ this.settleFlight('human');
490
+ return;
491
+ }
492
+ if (deltaSeconds > 0 && Number.isFinite(deltaSeconds)) flight.elapsed += deltaSeconds;
493
+ const progress = flight.duration <= 0 ? 1 : Math.min(1, flight.elapsed / flight.duration);
494
+ this.applyFlightPose(flight, progress);
495
+ if (progress >= 1) this.settleFlight(null);
496
+ }
497
+
498
+ private settleFlight(cancelledBy: 'human' | 'superseded' | 'closed' | null): void {
499
+ const flight = this.flight;
500
+ if (!flight) return;
501
+ this.flight = null;
502
+ const ended = {
503
+ completed: cancelledBy === null,
504
+ azimuth: flight.theta,
505
+ elevation: Math.PI / 2 - flight.phi,
506
+ seconds: Math.min(flight.elapsed, flight.duration),
507
+ };
508
+ flight.settle?.(cancelledBy === null ? ended : { ...ended, cancelledBy });
509
+ }
510
+
511
+ select(ids: readonly string[]): void {
512
+ invalidateStages();
513
+ if (this.authoring) setAuthoringSelection(this.authoring, [...ids]);
514
+ }
515
+
516
+ selection(): string[] {
517
+ return [...(this.authoring?.selection?.get() ?? [])];
518
+ }
519
+
520
+ idForObject(object: THREE.Object3D): string | null {
521
+ return this.authoring?.hierarchy.idForObject3D?.(object) ?? null;
522
+ }
523
+
524
+ /** Keep native hierarchy selection legible in this document's one viewport. */
525
+ syncSelectionPresentation(): void {
526
+ invalidateStages();
527
+ const selected = (this.authoring?.selection?.get() ?? [])
528
+ .map((id) => (this.authoring ? threeObject(this.authoring.hierarchy, id) : null))
529
+ .filter((object): object is THREE.Object3D => object !== null);
530
+ this.selectedObjects = selected;
531
+ if (this.selectionOutline) {
532
+ syncThreeSelectionOutline(this.selectionOutline, this.selectionOutlineWanted ? selected : []);
533
+ this.syncComposerOutput();
534
+ }
535
+ if (this.selectionOrigins) {
536
+ const colors = nativeSelectionColors(this.renderer.domElement);
537
+ this.selectionOrigins.update(selected, {
538
+ visible: colors.visible,
539
+ ...(colors.active ? { active: colors.active.visible } : {}),
540
+ });
541
+ }
542
+ const bones = selected.filter((object): object is THREE.Bone =>
543
+ Boolean((object as THREE.Bone).isBone),
544
+ );
545
+ const signature = bones.map((bone) => bone.uuid).join('\u0000');
546
+ if (signature === this.boneSelectionSignature) return;
547
+ this.boneSelectionSignature = signature;
548
+ this.clearBoneSelectionHighlight();
549
+ if (bones.length > 0) {
550
+ this.boneSelectionHighlight = new BoneSelectionHighlight(
551
+ bones,
552
+ nativeSelectionColors(this.renderer.domElement).visible,
553
+ );
554
+ this.scene.add(this.boneSelectionHighlight);
555
+ }
556
+ this.refreshSkeletonHelper();
557
+ }
558
+
559
+ frameSelection(): boolean {
560
+ return this.frameIds(this.authoring?.selection?.get() ?? []);
561
+ }
562
+
563
+ frameIds(ids: readonly string[]): boolean {
564
+ this.leaveCameraView(false);
565
+ this.settleFlight('superseded');
566
+ const objects = ids
567
+ .map((id) => (this.authoring ? threeObject(this.authoring.hierarchy, id) : null))
568
+ .filter((object): object is THREE.Object3D => object !== null);
569
+ if (objects.length === 0) return false;
570
+ if (objects.every((object) => (object as THREE.Bone).isBone)) {
571
+ this.frameBones(objects as THREE.Bone[]);
572
+ return true;
573
+ }
574
+ this.viewport.focusOnMultiple(objects);
575
+ return true;
576
+ }
577
+
578
+ /**
579
+ * An AXIS view is ORTHOGRAPHIC, the way Blender's numpad 1/3/7 are: the
580
+ * reference frame for numpad 1 (`modeling-front-ortho.png`) says so in its
581
+ * own view text, "Front Orthographic". Ours left the stage in perspective,
582
+ * so a front view still showed the cube's side faces converging.
583
+ *
584
+ * `isometric` is the perspective preset (it is the stage's 3/4 opening
585
+ * direction, not an axis) and stays perspective. Both callers of this door
586
+ * already separate the two: the relay's `view-preset` maps its own
587
+ * `perspective` onto `isometric`, and the document toolbar's view menu
588
+ * carries the projection pair as its own second group — so nothing here
589
+ * reads an axis preset expecting perspective.
590
+ *
591
+ * The pose is still solved against the PERSPECTIVE camera, because the
592
+ * session's orthographic camera is derived from that pose every frame
593
+ * (`syncOrthographicCamera`) rather than posed independently.
594
+ *
595
+ * `around` says what the view turns about. `bounds`, the document's opening camera, frames the
596
+ * content from that side. `view` is a person's numpad: Blender's `view3d.view_axis` turns about
597
+ * the view's own pivot at its own distance, so the zoom holds.
598
+ */
599
+ setViewPreset(preset: ModelCameraPreset, around: 'bounds' | 'view' = 'bounds'): void {
600
+ invalidateStages();
601
+ this.leaveCameraView(false);
602
+ this.settleFlight('superseded');
603
+ const direction = cameraPresetDirection(preset);
604
+ let center: THREE.Vector3;
605
+ let distance: number;
606
+ if (around === 'view') {
607
+ center = this.viewport.orbitControls.target.clone();
608
+ distance = this.viewport.camera.position.distanceTo(center);
609
+ } else {
610
+ const bounds = this.resolveFrameBounds();
611
+ if (bounds.isEmpty()) return;
612
+ center = bounds.getCenter(new THREE.Vector3());
613
+ distance = perspectiveDistanceToFitBox(bounds, this.viewport.camera, direction);
614
+ }
615
+ const position = center.clone().addScaledVector(direction, distance);
616
+ // A preset has no roll. Top's screen up is the world's -Z and Bottom's +Z (Blender's +Y and
617
+ // -Y); the others' is the world's up.
618
+ const up = preset === 'top' ? { x: 0, y: 0, z: -1 } : preset === 'bottom' ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 };
619
+ this.viewport.setPose(position, center, undefined, up);
620
+ this.setProjection(preset === 'isometric' ? 'perspective' : 'orthographic');
621
+ }
622
+
623
+ setCameraPose(
624
+ position: { x: number; y: number; z: number },
625
+ target: { x: number; y: number; z: number },
626
+ fov?: number,
627
+ ): void {
628
+ invalidateStages();
629
+ this.leaveCameraView(false);
630
+ this.settleFlight('superseded');
631
+ this.viewport.setPose(position, target, fov);
632
+ }
633
+
634
+ cameraPose(): {
635
+ readonly position: [number, number, number];
636
+ readonly target: [number, number, number];
637
+ readonly fov: number;
638
+ } {
639
+ return {
640
+ position: this.viewport.camera.position.toArray(),
641
+ target: this.viewport.orbitControls.target.toArray(),
642
+ fov: this.viewport.camera.fov,
643
+ };
644
+ }
645
+
646
+ camera(): THREE.Camera {
647
+ if (this.cameraOverride) return this.cameraOverride;
648
+ const drawn = this.drawnCamera();
649
+ // The gizmos size, face and pick against the camera on screen: this session's orthographic
650
+ // camera or its camera view, else the viewport's own.
651
+ this.viewport.setGizmoCamera(drawn === this.viewport.camera ? null : drawn);
652
+ return drawn;
653
+ }
654
+
655
+ private drawnCamera(): THREE.Camera {
656
+ const through = this.through ? this.cameraView() : null;
657
+ if (through) return this.throughCamera(through);
658
+ // The camera went (deleted, renamed): the view leaves rather than hold the input on a frame
659
+ // no one can see. After this call, never inside it — a read must not notify.
660
+ if (this.through && !this.leaving) {
661
+ this.leaving = true;
662
+ queueMicrotask(() => {
663
+ this.leaving = false;
664
+ if (this.through && !this.cameraView()) this.leaveCameraView(false);
665
+ });
666
+ }
667
+ if (this.state.projection === 'perspective') return this.viewport.camera;
668
+ this.syncOrthographicCamera();
669
+ return this.orthographicCamera;
670
+ }
671
+
672
+ /**
673
+ * THE CAMERA VIEW (Blender's `view3d.view_camera`): the stage draws through one of the
674
+ * document's own cameras, with that camera's frame on the region. Entering remembers the view
675
+ * it leaves; the toggle puts that view back. Any other navigation leaves it where it is —
676
+ * a preset, a Frame, a set pose — and ROTATING leaves it at the camera's own pose, as Blender
677
+ * switches a rotated camera view to a user view from the camera (`ED_view3d_persp_switch_
678
+ * from_camera`). While it lasts the wheel zooms the frame, a pan moves it and a dolly drag
679
+ * zooms it, each by Blender's rule (`view_zoomstep_apply_ex`'s 1.2 a step, `view_move`,
680
+ * `viewzoom_scale_value`), and the orbit controls stand down.
681
+ */
682
+ setCameraViewSource(source: ToolCameraViewSource | null): void {
683
+ if (this.cameraViewSource === source) return;
684
+ this.cameraViewSource = source;
685
+ if (!source) this.leaveCameraView(false);
686
+ this.notify();
687
+ }
688
+
689
+ /** Whether the document has cameras to look through at all. */
690
+ hasCameraView(): boolean {
691
+ return this.cameraViewSource !== null;
692
+ }
693
+
694
+ /** The camera view in progress on the current region, or null. */
695
+ cameraView(): ToolCameraView | null {
696
+ const through = this.through;
697
+ const source = this.cameraViewSource;
698
+ if (!through || !source) return null;
699
+ // A photograph renders into its own buffer shape (`captureImage`), as the other cameras do.
700
+ const canvas = this.renderer.domElement;
701
+ const region = this.captureAspect
702
+ ? { width: this.captureAspect, height: 1 }
703
+ : { width: canvas.clientWidth, height: canvas.clientHeight };
704
+ // A LOCKED view is drawn from the free camera the navigation moves, ahead of the camera's
705
+ // own pose, which the engine confirms at the end of each gesture.
706
+ const free = this.viewport.camera;
707
+ const pose = through.locked
708
+ ? `${free.position.toArray().join(',')}|${free.quaternion.toArray().join(',')}`
709
+ : '';
710
+ const key = `${through.camera}|${region.width}|${region.height}|${through.zoom}|${through.offset.join(',')}|${pose}`;
711
+ if (this.viewMemo?.key === key) return this.viewMemo.view;
712
+ const seen = source.view(through.camera, region, through.zoom, through.offset);
713
+ const view =
714
+ seen && through.locked
715
+ ? {
716
+ ...seen,
717
+ position: free.position.toArray() as [number, number, number],
718
+ quaternion: free.quaternion.toArray() as [number, number, number, number],
719
+ }
720
+ : seen;
721
+ this.viewMemo = { key, view };
722
+ queueMicrotask(() => {
723
+ this.viewMemo = null;
724
+ });
725
+ return view;
726
+ }
727
+
728
+ /** Enter or leave the camera view; false when there is no camera to look through. */
729
+ toggleCameraView(): boolean {
730
+ if (this.through) {
731
+ this.leaveCameraView(true);
732
+ return true;
733
+ }
734
+ const source = this.cameraViewSource;
735
+ const camera = source?.camera() ?? null;
736
+ if (!source) return false;
737
+ if (camera === null) {
738
+ // A view that does not change says why, as Blender's `view_camera_exec` reports.
739
+ editorConsole.warn('camera view: No active camera — the scene has no camera to look through', 'document');
740
+ return false;
741
+ }
742
+ invalidateStages();
743
+ this.settleFlight('superseded');
744
+ const viewport = this.viewport;
745
+ // The orbit's damping would go on moving the free camera behind the view: spend it now, so
746
+ // the view it leaves is the one on screen.
747
+ const controls = viewport.orbitControls;
748
+ const damping = controls.enableDamping;
749
+ controls.enableDamping = false;
750
+ controls.update();
751
+ controls.enableDamping = damping;
752
+ this.through = {
753
+ camera,
754
+ zoom: source.zoom.opening,
755
+ offset: [0, 0],
756
+ left: {
757
+ position: viewport.camera.position.clone(),
758
+ target: viewport.orbitControls.target.clone(),
759
+ up: viewport.camera.up.clone(),
760
+ projection: this.state.projection,
761
+ },
762
+ release: this.captureCameraViewInput(),
763
+ locked: false,
764
+ };
765
+ source.showing?.(camera);
766
+ this.notify();
767
+ return true;
768
+ }
769
+
770
+ /** Whether the camera view is locked to its camera; null outside one, or where the document
771
+ * cannot move its camera. */
772
+ cameraViewLocked(): boolean | null {
773
+ if (!this.through || !this.cameraViewSource?.setPose) return null;
774
+ return this.through.locked;
775
+ }
776
+
777
+ /**
778
+ * LOCK THE CAMERA TO THE VIEW (Blender's `View3D.lock_camera`, the navigation cluster's lock):
779
+ * navigating a locked camera view moves the camera. The orbit controls take the view from the
780
+ * camera's own pose about a pivot as far ahead as the view it left stood from its pivot; the
781
+ * view draws from where they put it, and the camera follows as they move — one write in
782
+ * flight at a time, the latest pose after it (`ED_view3d_camera_lock_sync`, which keeps the
783
+ * camera's scale). Unlocked, the view's own input comes back.
784
+ */
785
+ toggleCameraViewLock(): void {
786
+ const through = this.through;
787
+ const source = this.cameraViewSource;
788
+ if (!through || !source?.setPose) return;
789
+ through.release();
790
+ through.locked = !through.locked;
791
+ if (!through.locked) {
792
+ through.release = this.captureCameraViewInput();
793
+ } else {
794
+ const view = this.cameraView();
795
+ const controls = this.viewport.orbitControls;
796
+ const camera = this.viewport.camera;
797
+ if (view) {
798
+ const eye = new THREE.Vector3(...view.position);
799
+ const rotation = new THREE.Quaternion(...view.quaternion);
800
+ const distance = through.left.position.distanceTo(through.left.target);
801
+ // The camera's own up, so a rolled camera keeps its roll (the controls re-aim by it).
802
+ camera.up.set(0, 1, 0).applyQuaternion(rotation);
803
+ camera.position.copy(eye);
804
+ camera.quaternion.copy(rotation);
805
+ controls.target.copy(eye).addScaledVector(new THREE.Vector3(0, 0, -1).applyQuaternion(rotation), distance);
806
+ controls.update();
807
+ }
808
+ const enabled = controls.enabled;
809
+ controls.enabled = true;
810
+ // No inertia: Blender's navigation stops where the gesture does, and so does the camera
811
+ // it moves — the gesture's end is the pose the history records.
812
+ const damping = controls.enableDamping;
813
+ controls.enableDamping = false;
814
+ const name = through.camera;
815
+ // One write in flight; the latest pose after it, and the gesture's end as its own final
816
+ // write. A failed write is the source's to report (the session only keeps the chain going).
817
+ type Pose = { position: [number, number, number]; quaternion: [number, number, number, number]; final: boolean };
818
+ const snapshot = (final: boolean): Pose => ({
819
+ position: camera.position.toArray(),
820
+ quaternion: camera.quaternion.toArray() as [number, number, number, number],
821
+ final,
822
+ });
823
+ let writing = false;
824
+ let pending: Pose | null = null;
825
+ let moving = false;
826
+ const send = (pose: Pose): void => {
827
+ if (writing) {
828
+ // The latest pose waits, and a final one stays final.
829
+ pending = { ...pose, final: pose.final || (pending?.final ?? false) };
830
+ return;
831
+ }
832
+ writing = true;
833
+ pending = null;
834
+ void Promise.resolve(source.setPose!(name, pose.position, pose.quaternion, pose.final))
835
+ .catch(() => undefined)
836
+ .finally(() => {
837
+ writing = false;
838
+ if (pending) send(pending);
839
+ });
840
+ };
841
+ const moved = (): void => {
842
+ moving = true;
843
+ invalidateStages();
844
+ this.notify();
845
+ send(snapshot(false));
846
+ };
847
+ const ended = (): void => {
848
+ if (!moving) return;
849
+ moving = false;
850
+ send(snapshot(true));
851
+ };
852
+ controls.addEventListener('change', moved);
853
+ controls.addEventListener('end', ended);
854
+ through.release = () => {
855
+ // A gesture cut short by leaving or unlocking still lands, as its last pose.
856
+ ended();
857
+ controls.removeEventListener('change', moved);
858
+ controls.removeEventListener('end', ended);
859
+ controls.enabled = enabled;
860
+ controls.enableDamping = damping;
861
+ };
862
+ }
863
+ this.viewMemo = null;
864
+ invalidateStages();
865
+ this.notify();
866
+ }
867
+
868
+ /** The camera view's frame zoom, or null outside one. */
869
+ cameraViewZoom(): number | null {
870
+ return this.through?.zoom ?? null;
871
+ }
872
+
873
+ /** Set the camera view's frame zoom, within the source's range. */
874
+ setCameraViewZoom(zoom: number): void {
875
+ const through = this.through;
876
+ const source = this.cameraViewSource;
877
+ if (!through || !source || !Number.isFinite(zoom) || zoom <= 0) return;
878
+ through.zoom = Math.min(source.zoom.max, Math.max(source.zoom.min, zoom));
879
+ invalidateStages();
880
+ this.notify();
881
+ }
882
+
883
+ /** Zoom the camera view's frame by `factor` (the wheel's step). */
884
+ zoomCameraView(factor: number): void {
885
+ if (this.through) this.setCameraViewZoom(this.through.zoom * factor);
886
+ }
887
+
888
+ /** Pan the camera view by a pointer move, in fractions of the region (the source's rule). */
889
+ panCameraView(dx: number, dy: number): void {
890
+ const through = this.through;
891
+ const source = this.cameraViewSource;
892
+ if (!through || !source) return;
893
+ const [x, y] = source.pan(through.offset, through.zoom, dx, dy);
894
+ through.offset = [x, y];
895
+ invalidateStages();
896
+ this.notify();
897
+ }
898
+
899
+ /**
900
+ * Leave the camera view: back to the view it left (`restore`), or, when navigation moves on
901
+ * from it, with the view standing at the camera's pose at the distance it had.
902
+ */
903
+ private leaveCameraView(restore: boolean): void {
904
+ const through = this.through;
905
+ if (!through) return;
906
+ const view = this.cameraView();
907
+ through.release();
908
+ this.through = null;
909
+ this.viewMemo = null;
910
+ this.cameraViewSource?.showing?.(null);
911
+ this.viewport.setGizmoCamera(null);
912
+ const viewport = this.viewport;
913
+ if (restore) {
914
+ viewport.camera.position.copy(through.left.position);
915
+ viewport.camera.up.copy(through.left.up);
916
+ viewport.orbitControls.target.copy(through.left.target);
917
+ this.state = { ...this.state, projection: through.left.projection };
918
+ } else if (view) {
919
+ const distance = through.left.position.distanceTo(through.left.target);
920
+ const eye = new THREE.Vector3(...view.position);
921
+ const rotation = new THREE.Quaternion(...view.quaternion);
922
+ const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(rotation);
923
+ // The camera's rotation, roll included (`ED_view3d_persp_switch_from_camera`), in the
924
+ // projection the view had before it went through (`lpersp`), or perspective where Auto
925
+ // Perspective holds and that view was down an axis (`ED_view3d_persp_ensure`).
926
+ viewport.camera.up.set(0, 1, 0).applyQuaternion(rotation);
927
+ viewport.camera.position.copy(eye);
928
+ viewport.orbitControls.target.copy(eye).addScaledVector(forward, distance);
929
+ const leftAxis =
930
+ axisViewName(through.left.position.clone().sub(through.left.target), through.left.up) !== null;
931
+ const projection =
932
+ activeKeymapNavigation().autoPerspective && leftAxis ? 'perspective' : through.left.projection;
933
+ this.state = { ...this.state, projection };
934
+ }
935
+ viewport.camera.lookAt(viewport.orbitControls.target);
936
+ viewport.orbitControls.update();
937
+ invalidateStages();
938
+ this.notify();
939
+ }
940
+
941
+ /** The stage's drawing camera for `view`: its pose, and its window as the projection. */
942
+ private throughCamera(view: ToolCameraView): THREE.Camera {
943
+ const { left, right, top, bottom } = view.window;
944
+ const layers = this.viewport.camera.layers.mask;
945
+ if (view.projection === 'orthographic') {
946
+ const camera = this.throughOrthographic;
947
+ Object.assign(camera, { left, right, top, bottom, near: view.near, far: view.far, zoom: 1 });
948
+ camera.position.set(...view.position);
949
+ camera.quaternion.set(...view.quaternion);
950
+ camera.layers.mask = layers;
951
+ camera.updateProjectionMatrix();
952
+ camera.updateMatrixWorld(true);
953
+ return camera;
954
+ }
955
+ const camera = this.throughPerspective;
956
+ camera.near = view.near;
957
+ camera.far = view.far;
958
+ // Readers of the angle (the 3D cursor's size) see the window's; the matrix is the window.
959
+ camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan((top - bottom) / 2));
960
+ camera.aspect = (right - left) / (top - bottom);
961
+ camera.zoom = 1;
962
+ camera.position.set(...view.position);
963
+ camera.quaternion.set(...view.quaternion);
964
+ camera.layers.mask = layers;
965
+ camera.projectionMatrix.makePerspective(
966
+ left * view.near,
967
+ right * view.near,
968
+ top * view.near,
969
+ bottom * view.near,
970
+ view.near,
971
+ view.far,
972
+ );
973
+ camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
974
+ camera.updateMatrixWorld(true);
975
+ return camera;
976
+ }
977
+
978
+ /**
979
+ * The pointer while a camera view lasts, read ahead of the orbit controls (a capturing
980
+ * listener on the canvas's parent), which stand down. A gesture the controls would read as
981
+ * ROTATE leaves the view at the camera and hands the same press back to them; PAN moves the
982
+ * frame and DOLLY zooms it. Returns the undo.
983
+ */
984
+ private captureCameraViewInput(): () => void {
985
+ const controls = this.viewport.orbitControls;
986
+ const canvas = this.renderer.domElement;
987
+ const host = canvas.parentElement ?? canvas;
988
+ const enabled = controls.enabled;
989
+ controls.enabled = false;
990
+ const onWheel = (event: WheelEvent): void => {
991
+ if (event.target !== canvas || event.deltaY === 0) return;
992
+ event.preventDefault();
993
+ this.zoomCameraView(event.deltaY < 0 ? 1.2 : 1 / 1.2);
994
+ };
995
+ const onPointerDown = (event: PointerEvent): void => {
996
+ if (event.target !== canvas) return;
997
+ const buttons = controls.mouseButtons as Record<string, THREE.MOUSE | null | undefined>;
998
+ let action = [buttons['LEFT'], buttons['MIDDLE'], buttons['RIGHT']][event.button] ?? null;
999
+ const modified = event.ctrlKey || event.metaKey || event.shiftKey;
1000
+ // Ctrl/Cmd on an orbiting middle button zooms (the viewport's own rule, Blender's
1001
+ // Ctrl+MIDDLEMOUSE); Shift and the rest swap orbit and pan, as OrbitControls does.
1002
+ if (event.button === 1 && (event.ctrlKey || event.metaKey) && action === THREE.MOUSE.ROTATE)
1003
+ action = THREE.MOUSE.DOLLY;
1004
+ else if (modified && action === THREE.MOUSE.ROTATE) action = THREE.MOUSE.PAN;
1005
+ else if (modified && action === THREE.MOUSE.PAN) action = THREE.MOUSE.ROTATE;
1006
+ if (action === THREE.MOUSE.ROTATE) {
1007
+ this.leaveCameraView(false);
1008
+ return;
1009
+ }
1010
+ if (action !== THREE.MOUSE.PAN && action !== THREE.MOUSE.DOLLY) return;
1011
+ event.preventDefault();
1012
+ const rect = canvas.getBoundingClientRect();
1013
+ let lastX = event.clientX;
1014
+ let lastY = event.clientY;
1015
+ const zoom0 = this.through?.zoom ?? 1;
1016
+ const lenOld = Math.max(5 + event.clientY - rect.top, 1);
1017
+ const move = (moveEvent: PointerEvent): void => {
1018
+ if (action === THREE.MOUSE.PAN) {
1019
+ this.panCameraView(
1020
+ (moveEvent.clientX - lastX) / Math.max(rect.width, 1),
1021
+ (moveEvent.clientY - lastY) / Math.max(rect.height, 1),
1022
+ );
1023
+ lastX = moveEvent.clientX;
1024
+ lastY = moveEvent.clientY;
1025
+ return;
1026
+ }
1027
+ const factor = Math.max(0.01, 2 * ((5 + moveEvent.clientY - rect.top) / lenOld - 1) + 1);
1028
+ this.setCameraViewZoom(zoom0 / factor);
1029
+ };
1030
+ const end = (): void => {
1031
+ window.removeEventListener('pointermove', move);
1032
+ window.removeEventListener('pointerup', end);
1033
+ window.removeEventListener('pointercancel', end);
1034
+ };
1035
+ window.addEventListener('pointermove', move);
1036
+ window.addEventListener('pointerup', end);
1037
+ window.addEventListener('pointercancel', end);
1038
+ };
1039
+ host.addEventListener('wheel', onWheel, { capture: true, passive: false });
1040
+ host.addEventListener('pointerdown', onPointerDown, { capture: true });
1041
+ return () => {
1042
+ host.removeEventListener('wheel', onWheel, { capture: true });
1043
+ host.removeEventListener('pointerdown', onPointerDown, { capture: true });
1044
+ controls.enabled = enabled;
1045
+ };
1046
+ }
1047
+
1048
+ /** Which projection the session is drawing with. Public because a caller
1049
+ * that switches it for ONE frame — the Blender render capture — has to put
1050
+ * it back, and cannot read it otherwise. */
1051
+ projection(): 'perspective' | 'orthographic' {
1052
+ return this.state.projection;
1053
+ }
1054
+
1055
+ setProjection(projection: 'perspective' | 'orthographic'): void {
1056
+ invalidateStages();
1057
+ if (this.state.projection === projection) return;
1058
+ this.state = { ...this.state, projection };
1059
+ this.notify();
1060
+ }
1061
+
1062
+ setMode(mode: Object3DDocumentViewMode): void {
1063
+ invalidateStages();
1064
+ if (this.state.mode === mode) return;
1065
+ this.clearDiagnosticPresentation();
1066
+ this.state = { ...this.state, mode };
1067
+ if (mode === 'uv') {
1068
+ this.scene.overrideMaterial = new THREE.ShaderMaterial({
1069
+ vertexShader:
1070
+ 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }',
1071
+ fragmentShader: 'varying vec2 vUv; void main(){ gl_FragColor=vec4(vUv,0.0,1.0); }',
1072
+ });
1073
+ } else if (mode === 'vertex-colors') {
1074
+ this.scene.overrideMaterial = new THREE.MeshBasicMaterial({ vertexColors: true });
1075
+ } else if (mode === 'wireframe') {
1076
+ this.buildTopologyOverlay();
1077
+ }
1078
+ this.notify();
1079
+ }
1080
+
1081
+ /**
1082
+ * WIREFRAME ON THE MODELING SURFACE IS TOPOLOGY, NOT TRIANGLES.
1083
+ *
1084
+ * The shared `wireframe` shading mode flips every material's
1085
+ * `wireframe` flag, which draws each RENDERABLE triangle — so a quad shows
1086
+ * its triangulation diagonal and a modeller reads noise where they expect
1087
+ * face flow. On the Asset Lab document viewport, where the mesh IS the
1088
+ * subject, the mode instead draws neutral clay bodies (so wires behind the
1089
+ * form are correctly occluded) under an edge overlay built with
1090
+ * `EdgesGeometry`: coplanar diagonals fall out, quads read as quads.
1091
+ *
1092
+ * The overlay is editor chrome — tagged `editorHelper`, so picking, bakes
1093
+ * and the shading swap itself all skip it, and gizmos/outlines are
1094
+ * untouched. One honest limit: a line set follows its mesh's NODE transform,
1095
+ * not its skin, so a skinned mesh shows rest-pose edges while a clip plays.
1096
+ * This surface holds content time still by policy, so that is the rare case.
1097
+ */
1098
+ private buildTopologyOverlay(): void {
1099
+ const group = new THREE.Group();
1100
+ const followers: Array<{ line: THREE.LineSegments; source: THREE.Object3D }> = [];
1101
+ this.root.updateMatrixWorld(true);
1102
+ this.root.traverse((object) => {
1103
+ const mesh = object as THREE.Mesh;
1104
+ if (!mesh.isMesh || !mesh.geometry) return;
1105
+ const line = new THREE.LineSegments(
1106
+ new THREE.EdgesGeometry(mesh.geometry, TOPOLOGY_EDGE_THRESHOLD_DEGREES),
1107
+ // Dark on the light clay body, and DEPTH-TEST OFF. An edge sits
1108
+ // exactly on the surface it bounds, so a depth-tested line stipples
1109
+ // itself away in z-fighting — photographed and read before this was
1110
+ // written. Off is also the truer read: Blender's wireframe shading
1111
+ // shows the far side too, and the far side is half the topology.
1112
+ new THREE.LineBasicMaterial({
1113
+ color: TOPOLOGY_WIRE_COLOR,
1114
+ toneMapped: false,
1115
+ transparent: true,
1116
+ opacity: 0.85,
1117
+ depthTest: false,
1118
+ depthWrite: false,
1119
+ }),
1120
+ );
1121
+ line.matrixAutoUpdate = false;
1122
+ line.matrixWorldAutoUpdate = false;
1123
+ line.matrixWorld.copy(mesh.matrixWorld);
1124
+ line.frustumCulled = false;
1125
+ line.renderOrder = 2;
1126
+ group.add(line);
1127
+ followers.push({ line, source: mesh });
1128
+ });
1129
+ if (followers.length === 0) return;
1130
+ this.topologyOverlay = group;
1131
+ this.topologyFollowers = followers;
1132
+ this.addHelper(group, 'topology');
1133
+ }
1134
+
1135
+ private syncTopologyOverlay(): void {
1136
+ // A live module document keeps this session and SWAPS the built child on
1137
+ // every save, which detaches the meshes these lines shadow. Rebuild from
1138
+ // the current graph rather than drawing the previous revision's edges.
1139
+ if (this.topologyFollowers.some(({ source }) => source.parent === null)) {
1140
+ this.clearTopologyOverlay();
1141
+ this.buildTopologyOverlay();
1142
+ }
1143
+ // A selected object's wires wear the selection's colour, the active one the active colour, as
1144
+ // Blender's wireframe overlay does; the rest the wire's own. The active object is the
1145
+ // selection's last, the shell's own rule (`EditorShellStore.selectedEntityId`), which a
1146
+ // Blender document keeps by publishing its active object last.
1147
+ this.selectionColors ??= nativeSelectionColors(this.renderer.domElement);
1148
+ const colors = this.selectionColors;
1149
+ const active = this.selectedObjects.at(-1) ?? null;
1150
+ const within = (object: THREE.Object3D, owner: THREE.Object3D): boolean => {
1151
+ for (let at: THREE.Object3D | null = object; at; at = at.parent) if (at === owner) return true;
1152
+ return false;
1153
+ };
1154
+ for (const { line, source } of this.topologyFollowers) {
1155
+ line.matrixWorld.copy(source.matrixWorld);
1156
+ line.visible = source.visible;
1157
+ const material = line.material as THREE.LineBasicMaterial;
1158
+ const isActive = active !== null && within(source, active);
1159
+ const isSelected = isActive || this.selectedObjects.some((owner) => within(source, owner));
1160
+ const color = isActive ? (colors.active?.visible ?? colors.visible) : isSelected ? colors.visible : null;
1161
+ material.color.setHex(color ?? TOPOLOGY_WIRE_COLOR);
1162
+ }
1163
+ }
1164
+
1165
+ private clearTopologyOverlay(): void {
1166
+ for (const { line } of this.topologyFollowers.splice(0)) {
1167
+ line.geometry.dispose();
1168
+ (line.material as THREE.Material).dispose();
1169
+ }
1170
+ const group = this.topologyOverlay;
1171
+ this.topologyOverlay = null;
1172
+ if (!group) return;
1173
+ group.removeFromParent();
1174
+ const index = this.helpers.indexOf(group);
1175
+ if (index >= 0) this.helpers.splice(index, 1);
1176
+ }
1177
+
1178
+ setBounds(bounds: boolean): void {
1179
+ invalidateStages();
1180
+ if (this.state.bounds === bounds) return;
1181
+ this.state = { ...this.state, bounds };
1182
+ if (bounds) {
1183
+ this.boundsHelper = new THREE.BoxHelper(this.root, 0xffb454);
1184
+ this.addHelper(this.boundsHelper, 'bounds');
1185
+ } else {
1186
+ this.clearBoundsHelper();
1187
+ }
1188
+ this.notify();
1189
+ }
1190
+
1191
+ setSkeleton(skeleton: boolean): void {
1192
+ invalidateStages();
1193
+ if (this.state.skeleton === skeleton) return;
1194
+ this.state = { ...this.state, skeleton };
1195
+ this.refreshSkeletonHelper();
1196
+ this.notify();
1197
+ }
1198
+
1199
+ /** The neutral backdrop as installed — what a re-tint replaces. */
1200
+ neutralBackgroundTexture(): THREE.Color | THREE.Texture | null {
1201
+ return this.neutralBackground;
1202
+ }
1203
+
1204
+ /** Re-tint the neutral backdrop (a palette switch re-derives the dressing's
1205
+ * gradient, `standard-viewport-dressing.ts`). Repaints when it is showing. */
1206
+ setNeutralBackground(background: THREE.Color | THREE.Texture | null): void {
1207
+ invalidateStages();
1208
+ // A flat colour is showing when the scene wears that colour, whichever
1209
+ // `THREE.Color` instance carries it (the viewport repaints a palette's
1210
+ // flat background as its own instance).
1211
+ const current = this.scene.background;
1212
+ const neutral = this.neutralBackground;
1213
+ const showing =
1214
+ current === neutral ||
1215
+ (current instanceof THREE.Color && neutral instanceof THREE.Color && current.equals(neutral));
1216
+ this.neutralBackground = background;
1217
+ if (showing) this.scene.background = background;
1218
+ }
1219
+
1220
+ setBackground(background: 'neutral' | 'transparent'): void {
1221
+ invalidateStages();
1222
+ if (this.state.background === background) return;
1223
+ this.scene.background = background === 'transparent' ? null : this.neutralBackground;
1224
+ this.state = { ...this.state, background };
1225
+ this.notify();
1226
+ }
1227
+
1228
+ resetPresentation(): void {
1229
+ invalidateStages();
1230
+ // Lighting, backdrop and tone are the view's presentation (`kit/viewport-presentation`).
1231
+ resetViewPresentation(this.documentId);
1232
+ this.clearDiagnosticPresentation();
1233
+ this.state = INITIAL_PRESENTATION;
1234
+ this.refreshSkeletonHelper();
1235
+ this.clearBoundsHelper();
1236
+ this.scene.background = this.neutralBackground;
1237
+ this.viewport.camera.up.set(0, 1, 0);
1238
+ this.frame();
1239
+ this.notify();
1240
+ }
1241
+
1242
+ /**
1243
+ * The step the document host runs before EVERY draw of the rendered scene.
1244
+ *
1245
+ * The host keeps the document's own `THREE.Scene` nested inside the session's
1246
+ * rendered scene and mirrors the world dressing (environment, fog, their
1247
+ * intensities/rotations, background) outward. That mirror used to live in the
1248
+ * host's per-frame `animate`, so an offscreen photograph taken between two
1249
+ * viewport ticks drew whatever the LAST tick happened to mirror — a sky set
1250
+ * synchronously before the capture was simply missing from it. Registering it
1251
+ * here puts `renderViewport` and `captureImage` on one path.
1252
+ *
1253
+ * The host owns the step and clears it in its own teardown; the session only
1254
+ * holds the reference.
1255
+ */
1256
+ setBeforeRender(step: (() => void) | null): void {
1257
+ this.beforeRender = step;
1258
+ }
1259
+
1260
+ /**
1261
+ * THE PRESENTED FRAME — the stage exactly as the person is seeing it,
1262
+ * including everything drawn OVER the document's own render: the
1263
+ * orientation compass (`EditorViewport.renderViewCube`) above all.
1264
+ *
1265
+ * Why this is not {@link captureImage}: that door is an offscreen
1266
+ * re-render of the document's content, for thumbnails and asset previews,
1267
+ * and it deliberately draws the subject alone. The chrome door's promise is
1268
+ * the opposite one — "photograph the editor as the person sees it" — and
1269
+ * routing it through `captureImage` made the door LIE about this stage: the
1270
+ * compass renders every frame into the default framebuffer (measured: the
1271
+ * call is reached, `_threeSurfaceShowing` true, 24 objects, target=screen)
1272
+ * and never appeared in a single chrome frame.
1273
+ *
1274
+ * The canvas has no `preserveDrawingBuffer`, so the only place its pixels
1275
+ * can be read is inside the frame that drew them — which is why the host
1276
+ * serves the request at the end of its own `animate`
1277
+ * ({@link servePresentedFrame}), the same shape the ingest lane's
1278
+ * `LiveSession.snapshotFrame` uses.
1279
+ */
1280
+ private presentedFrameWaiters: Array<(frame: HTMLCanvasElement | null) => void> = [];
1281
+ /** True while a host render loop is servicing {@link servePresentedFrame}. */
1282
+ private presentsFrames = false;
1283
+
1284
+ /** The host declares that its loop serves presented-frame requests. Without
1285
+ * it a request answers `null` at once rather than waiting for a frame that
1286
+ * is never coming (a document whose loop has stopped). */
1287
+ setPresentsFrames(on: boolean): void {
1288
+ this.presentsFrames = on;
1289
+ if (!on) this.resolvePresentedFrame(null);
1290
+ }
1291
+
1292
+ /** Ask for the next drawn frame. `null` when no loop is serving them, and
1293
+ * `null` again when a loop that says it serves them does not draw within
1294
+ * {@link PRESENTED_FRAME_WAIT_MS} — a hidden tab's rAF is throttled to a
1295
+ * stop, and a photograph door may never hang on another loop's liveness. */
1296
+ requestPresentedFrame(): Promise<HTMLCanvasElement | null> {
1297
+ if (!this.presentsFrames) return Promise.resolve(null);
1298
+ return new Promise((resolve) => {
1299
+ let settled = false;
1300
+ const once = (frame: HTMLCanvasElement | null): void => {
1301
+ if (settled) return;
1302
+ settled = true;
1303
+ resolve(frame);
1304
+ };
1305
+ this.presentedFrameWaiters.push(once);
1306
+ // A stage that draws on change draws for this request.
1307
+ invalidateStages();
1308
+ setTimeout(() => once(null), PRESENTED_FRAME_WAIT_MS);
1309
+ });
1310
+ }
1311
+
1312
+ /** Whether the next frame must be drawn whatever else changed: a camera
1313
+ * flight is advanced by the draw itself, and a presented-frame request is
1314
+ * served only by a frame that draws. */
1315
+ needsFrame(): boolean {
1316
+ return this.flight !== null || this.presentedFrameWaiters.length > 0;
1317
+ }
1318
+
1319
+ /** Called by the host at the END of a frame, after every overlay pass. */
1320
+ servePresentedFrame(): void {
1321
+ if (this.presentedFrameWaiters.length === 0) return;
1322
+ const source = this.renderer.domElement;
1323
+ const copy = document.createElement('canvas');
1324
+ copy.width = Math.max(1, source.width);
1325
+ copy.height = Math.max(1, source.height);
1326
+ const context = copy.getContext('2d');
1327
+ if (!context) {
1328
+ this.resolvePresentedFrame(null);
1329
+ return;
1330
+ }
1331
+ context.drawImage(source, 0, 0);
1332
+ this.resolvePresentedFrame(copy);
1333
+ }
1334
+
1335
+ private resolvePresentedFrame(frame: HTMLCanvasElement | null): void {
1336
+ const waiters = this.presentedFrameWaiters;
1337
+ this.presentedFrameWaiters = [];
1338
+ for (const resolve of waiters) resolve(frame);
1339
+ }
1340
+
1341
+ render(renderSolid: (camera: THREE.Camera) => void): void {
1342
+ this.beforeRender?.();
1343
+ this.boneSelectionHighlight?.update();
1344
+ const camera = this.camera();
1345
+ const mode = this.state.mode;
1346
+ this.boundsHelper?.update();
1347
+ this.selectionOrigins?.place();
1348
+ if (mode === 'uv' || mode === 'vertex-colors') {
1349
+ renderSolid(camera);
1350
+ return;
1351
+ }
1352
+ if (mode === 'wireframe' && this.topologyOverlay) {
1353
+ this.syncTopologyOverlay();
1354
+ if (this.xray.enabled && this.xray.alpha <= 0) {
1355
+ // X-RAY AT NO ALPHA: the wires alone, every surface left out of the draw. The surfaces'
1356
+ // MATERIALS stand down, not the meshes, whose children (lines, points, other objects)
1357
+ // still draw, as every wire does in Blender's.
1358
+ const hidden = new Set<THREE.Material>();
1359
+ this.root.traverse((object) => {
1360
+ const mesh = object as THREE.Mesh;
1361
+ if (!mesh.isMesh) return;
1362
+ for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
1363
+ if (material?.visible) {
1364
+ material.visible = false;
1365
+ hidden.add(material);
1366
+ }
1367
+ }
1368
+ });
1369
+ try {
1370
+ renderSolid(camera);
1371
+ } finally {
1372
+ for (const material of hidden) material.visible = true;
1373
+ }
1374
+ return;
1375
+ }
1376
+ // Clay bodies, not the triangle-wireframe swap: the overlay IS the wire,
1377
+ // and the solid form behind it is what occludes the far side.
1378
+ this.shading.render(
1379
+ this.scene,
1380
+ 'clay',
1381
+ () => renderSolid(camera),
1382
+ isEditorViewportShadingTarget,
1383
+ );
1384
+ return;
1385
+ }
1386
+ this.shading.render(this.scene, mode, () => renderSolid(camera), isEditorViewportShadingTarget);
1387
+ }
1388
+
1389
+ /** Visible authoring draw with the editor-only native selection silhouette. */
1390
+ renderViewport(deltaSeconds = 0): void {
1391
+ this.advanceLook(deltaSeconds);
1392
+ this.ensureComposer();
1393
+ this.render((camera) => {
1394
+ const composer = this.composer;
1395
+ if (!composer) {
1396
+ // The composer's library is still in flight (see `ensureComposer`).
1397
+ // Draw straight through — the same call the offscreen capture makes —
1398
+ // so the opening frames show the asset without its selection
1399
+ // silhouette rather than nothing at all.
1400
+ this.renderer.render(this.scene, camera);
1401
+ return;
1402
+ }
1403
+ composer.setMainScene(this.scene);
1404
+ composer.setMainCamera(camera);
1405
+ composer.render(deltaSeconds);
1406
+ });
1407
+ }
1408
+
1409
+ resize(width: number, height: number): void {
1410
+ invalidateStages();
1411
+ this.renderWidth = Math.max(1, width);
1412
+ this.renderHeight = Math.max(1, height);
1413
+ this.composer?.setSize(this.renderWidth, this.renderHeight);
1414
+ }
1415
+
1416
+ private renderCapture(renderSolid: (camera: THREE.Camera) => void, transparent: boolean): void {
1417
+ this.render((camera) => {
1418
+ if (!transparent) {
1419
+ renderSolid(camera);
1420
+ return;
1421
+ }
1422
+ // Apply after the host mirrors its world into this scene. Environment
1423
+ // lighting stays intact; only the camera background is omitted.
1424
+ const background = this.scene.background;
1425
+ const clearColor = this.renderer.getClearColor(new THREE.Color());
1426
+ const clearAlpha = this.renderer.getClearAlpha();
1427
+ try {
1428
+ this.scene.background = null;
1429
+ this.renderer.setClearColor(0, 0);
1430
+ renderSolid(camera);
1431
+ } finally {
1432
+ this.scene.background = background;
1433
+ this.renderer.setClearColor(clearColor, clearAlpha);
1434
+ }
1435
+ });
1436
+ }
1437
+
1438
+ /**
1439
+ * Fresh offscreen capture through this document's own renderer and
1440
+ * diagnostic pipeline. This does not depend on preserveDrawingBuffer and
1441
+ * therefore remains truthful after the visible canvas has presented.
1442
+ *
1443
+ * A NUMBER is a square of that size — the default shape, and the right one
1444
+ * for an unstaged look at a model. `{width, height}` renders the buffer AND
1445
+ * the camera at that aspect, so a video-shaped look comes back already
1446
+ * shaped instead of square-and-cropped. Whatever the shape, the camera is
1447
+ * matched to the BUFFER for the duration: rendering the panel's aspect
1448
+ * through a square buffer is what photographed a cube as a tall thin prism
1449
+ * on a 3.05:1 document panel, through the one look instrument an agent has.
1450
+ * The visible viewport is never touched.
1451
+ */
1452
+ captureImage(
1453
+ size:
1454
+ | number
1455
+ | {
1456
+ width: number;
1457
+ height: number;
1458
+ transparent?: boolean;
1459
+ /** Photograph through this caller-owned camera without changing its projection. */
1460
+ camera?: THREE.Camera;
1461
+ } = 512,
1462
+ ): string | null {
1463
+ const requestedWidth = typeof size === 'number' ? size : size.width;
1464
+ const requestedHeight = typeof size === 'number' ? size : size.height;
1465
+ if (!Number.isFinite(requestedWidth) || requestedWidth < 1) return null;
1466
+ if (!Number.isFinite(requestedHeight) || requestedHeight < 1) return null;
1467
+ const outputWidth = Math.min(2048, Math.round(requestedWidth));
1468
+ const outputHeight = Math.min(2048, Math.round(requestedHeight));
1469
+ const renderWidth = outputWidth * 2;
1470
+ const renderHeight = outputHeight * 2;
1471
+ // Three disables material tone mapping on ordinary render targets. A
1472
+ // byte target clips lit surfaces to white before display conversion can
1473
+ // recover them. Keep HDR values, then use the same output resolve as the
1474
+ // scene viewport so exposure, tone mapping and color space affect captures.
1475
+ const sceneTarget = new THREE.WebGLRenderTarget(renderWidth, renderHeight, {
1476
+ type: THREE.HalfFloatType,
1477
+ });
1478
+ const target = new THREE.WebGLRenderTarget(renderWidth, renderHeight);
1479
+ const previousTarget = this.renderer.getRenderTarget();
1480
+ const previousPixelRatio = this.renderer.getPixelRatio();
1481
+ const perspective = this.viewport.camera;
1482
+ const previousAspect = perspective.aspect;
1483
+ try {
1484
+ this.captureAspect = outputWidth / outputHeight;
1485
+ const override = typeof size === 'number' ? undefined : size.camera;
1486
+ if (override) this.cameraOverride = override;
1487
+ else {
1488
+ perspective.aspect = outputWidth / outputHeight;
1489
+ perspective.updateProjectionMatrix();
1490
+ }
1491
+ this.renderer.setPixelRatio(1);
1492
+ this.renderer.setRenderTarget(sceneTarget);
1493
+ this.renderCapture(
1494
+ (camera) => this.renderSolidForCapture(camera, sceneTarget, renderWidth, renderHeight),
1495
+ typeof size !== 'number' && size.transparent === true,
1496
+ );
1497
+ viewportCaptureOutputPass(typeof size !== 'number' && size.transparent === true).render(
1498
+ this.renderer,
1499
+ target,
1500
+ sceneTarget,
1501
+ 0,
1502
+ false,
1503
+ );
1504
+
1505
+ const rowBytes = renderWidth * 4;
1506
+ const pixels = new Uint8Array(rowBytes * renderHeight);
1507
+ this.renderer.readRenderTargetPixels(target, 0, 0, renderWidth, renderHeight, pixels);
1508
+ const fullCanvas = document.createElement('canvas');
1509
+ fullCanvas.width = renderWidth;
1510
+ fullCanvas.height = renderHeight;
1511
+ const fullContext = fullCanvas.getContext('2d');
1512
+ if (!fullContext) return null;
1513
+ const imageData = fullContext.createImageData(renderWidth, renderHeight);
1514
+ for (let y = 0; y < renderHeight; y++) {
1515
+ const sourceRow = (renderHeight - 1 - y) * rowBytes;
1516
+ const destinationRow = y * rowBytes;
1517
+ imageData.data.set(pixels.subarray(sourceRow, sourceRow + rowBytes), destinationRow);
1518
+ }
1519
+ fullContext.putImageData(imageData, 0, 0);
1520
+ const output = document.createElement('canvas');
1521
+ output.width = outputWidth;
1522
+ output.height = outputHeight;
1523
+ const outputContext = output.getContext('2d');
1524
+ if (!outputContext) return null;
1525
+ outputContext.drawImage(fullCanvas, 0, 0, outputWidth, outputHeight);
1526
+ return output.toDataURL('image/png');
1527
+ } finally {
1528
+ this.captureAspect = null;
1529
+ this.cameraOverride = null;
1530
+ perspective.aspect = previousAspect;
1531
+ perspective.updateProjectionMatrix();
1532
+ this.renderer.setRenderTarget(previousTarget);
1533
+ this.renderer.setPixelRatio(previousPixelRatio);
1534
+ // THE COMPOSER IS SIZED AGAIN AT THE DISPLAY'S RATIO. The capture's own draw restores the
1535
+ // composer's size while the ratio is still 1 (`renderSolidForCapture`), and the composer
1536
+ // sizes its buffers from the renderer's drawing buffer: every capture (a selection's
1537
+ // preview takes one) left the view and its outline mask at CSS resolution until the next
1538
+ // resize. Measured on the stage: a crisp outline stating 4 device px drew 10 right after a
1539
+ // selection and 4 after a repaint that resized.
1540
+ this.composer?.setSize(this.renderWidth, this.renderHeight, false);
1541
+ target.dispose();
1542
+ sceneTarget.dispose();
1543
+ }
1544
+ }
1545
+
1546
+ dispose(): void {
1547
+ this.disposed = true;
1548
+ this.leaveCameraView(false);
1549
+ this.selectionOrigins?.dispose();
1550
+ this.selectionOrigins = null;
1551
+ this.settleFlight('closed');
1552
+ this.viewport.orbitControls.removeEventListener('start', this.cancelLookForHuman);
1553
+ this.unsubscribeRotateStart();
1554
+ this.unsubscribeAxisView();
1555
+ this.unsubscribeSelectionTheme();
1556
+ this.clearDiagnosticPresentation();
1557
+ this.clearBoneSelectionHighlight();
1558
+ this.clearBoundsHelper();
1559
+ this.clearSkeletonHelper();
1560
+ // The outline goes back to this renderer's pool before the composer
1561
+ // disposes its pass (`releaseThreeSelectionOutline` clears its selection).
1562
+ if (this.selectionOutline)
1563
+ releaseThreeSelectionOutline(this.renderer, this.selectionOutline, this.selectionOutlinePass);
1564
+ this.composer?.dispose();
1565
+ this.sceneRenderPass = null;
1566
+ this.selectionOutline = null;
1567
+ this.selectionOutlinePass = null;
1568
+ this.composer = null;
1569
+ this.shading.dispose();
1570
+ this.listeners.clear();
1571
+ }
1572
+
1573
+ private addHelper(helper: THREE.Object3D, kind: string): void {
1574
+ helper.name = `__object3d_document_${kind}`;
1575
+ setUserData(helper, 'editorHelper', true);
1576
+ this.scene.add(helper);
1577
+ this.helpers.push(helper);
1578
+ }
1579
+
1580
+ /**
1581
+ * THE LAZY DOOR for `postprocessing` (~200 kB of passes and shaders).
1582
+ *
1583
+ * The composer exists for ONE feature — the editor-owned selection
1584
+ * silhouette; `syncComposerOutput` hands the screen back to the plain scene
1585
+ * pass whenever nothing is outlined — so the library belongs with the first
1586
+ * frame of this document, not with the module that draws it. Everything an
1587
+ * Asset Lab document does before that first frame (mount, adopt, frame the
1588
+ * content) is untouched by it.
1589
+ *
1590
+ * Called from every `renderViewport`, so it must be cheap and re-entrant:
1591
+ * `composerLoading` keeps exactly one import in flight, and the continuation
1592
+ * re-checks `disposed` because a document can close mid-load.
1593
+ */
1594
+ /**
1595
+ * WHETHER THIS DOCUMENT DRAWS THE SELECTION SILHOUETTE AT ALL — a LIVE
1596
+ * switch, not just a construction-time one.
1597
+ *
1598
+ * Two callers turn it off, for different reasons. The inspector's preview
1599
+ * lane shows a subject with nothing selectable inside it, so it never needs
1600
+ * the pass — or the effect it would build per selection — and says so once.
1601
+ * A modeling document says so whenever it is in EDIT mode: Blender draws no
1602
+ * object outline there (`modeling-edit-all.png`), and the silhouette
1603
+ * otherwise follows the hierarchy selection straight through the mode
1604
+ * change. That second caller flips it while the composer is already built,
1605
+ * so turning it off must drop the outline the composer is currently drawing
1606
+ * — gating construction alone leaves the frame exactly as it was.
1607
+ */
1608
+ get selectionOutlineEnabled(): boolean {
1609
+ return this.selectionOutlineWanted;
1610
+ }
1611
+ set selectionOutlineEnabled(value: boolean) {
1612
+ if (value === this.selectionOutlineWanted) return;
1613
+ this.selectionOutlineWanted = value;
1614
+ if (value) this.syncSelectionPresentation();
1615
+ else {
1616
+ this.selectionOutline?.selection.clear();
1617
+ this.syncComposerOutput();
1618
+ }
1619
+ }
1620
+ private selectionOutlineWanted = true;
1621
+
1622
+ /** Whether the view draws its selected objects' origins (`overlays.selection.origins`). */
1623
+ /** The view's X-ray for its current draw mode (`ViewportModePresentation.xray`). */
1624
+ private xray: ViewportXray = { enabled: false, alpha: 1 };
1625
+
1626
+ setXray(xray: ViewportXray): void {
1627
+ if (this.xray.enabled === xray.enabled && this.xray.alpha === xray.alpha) return;
1628
+ this.xray = xray;
1629
+ invalidateStages();
1630
+ }
1631
+
1632
+ set selectionOriginsEnabled(value: boolean) {
1633
+ if (value === (this.selectionOrigins !== null)) return;
1634
+ if (value) {
1635
+ this.selectionOrigins = new SelectionOrigins();
1636
+ this.scene.add(this.selectionOrigins);
1637
+ this.syncSelectionPresentation();
1638
+ } else {
1639
+ this.selectionOrigins?.dispose();
1640
+ this.selectionOrigins = null;
1641
+ invalidateStages();
1642
+ }
1643
+ }
1644
+
1645
+ private ensureComposer(): void {
1646
+ if (!this.selectionOutlineWanted) return;
1647
+ if (this.composer || this.composerLoading || this.disposed) return;
1648
+ this.composerLoading = true;
1649
+ void import('postprocessing')
1650
+ .then((postprocessing) => {
1651
+ this.composerLoading = false;
1652
+ if (this.disposed || this.composer) return;
1653
+ const camera = this.camera();
1654
+ const composer = new postprocessing.EffectComposer(this.renderer);
1655
+ this.sceneRenderPass = new postprocessing.RenderPass(this.scene, camera);
1656
+ composer.addPass(this.sceneRenderPass);
1657
+ this.selectionOutline = acquireThreeSelectionOutline(
1658
+ postprocessing,
1659
+ this.renderer,
1660
+ this.scene,
1661
+ camera,
1662
+ nativeSelectionColors(this.renderer.domElement),
1663
+ );
1664
+ this.syncSelectionTheme();
1665
+ syncThreeSelectionOutline(this.selectionOutline, this.selectedObjects);
1666
+ this.selectionOutlinePass = new postprocessing.EffectPass(camera, this.selectionOutline);
1667
+ composer.addPass(this.selectionOutlinePass);
1668
+ this.composer = composer;
1669
+ this.syncComposerOutput();
1670
+ composer.setSize(this.renderWidth, this.renderHeight);
1671
+ // The outline draws from the next frame on; ask for it.
1672
+ invalidateStages();
1673
+ })
1674
+ .catch(() => {
1675
+ // A failed chunk load must not wedge the surface: the plain draw path
1676
+ // in `renderViewport` keeps the document visible, and the next frame
1677
+ // retries the import.
1678
+ this.composerLoading = false;
1679
+ });
1680
+ }
1681
+
1682
+ /**
1683
+ * EffectComposer marks the last added pass as the screen output. Disabling
1684
+ * that pass without transferring screen ownership to the scene pass leaves
1685
+ * the unoutlined frame stranded in the composer's offscreen buffer. Keep
1686
+ * selection responsible only for the outline, never for whether the asset
1687
+ * itself reaches the canvas.
1688
+ */
1689
+ /**
1690
+ * Draw the scene FOR A PHOTOGRAPH into `target` the way the screen sees it:
1691
+ * through the outline pass when a selection silhouette is on (the outline is
1692
+ * a pass, absent from a plain draw — a capture without it lied about the
1693
+ * screen), the plain draw otherwise.
1694
+ *
1695
+ * The two passes are driven DIRECTLY rather than through `composer.render()`,
1696
+ * because neither of the composer's own doors says where a frame landed:
1697
+ * `EffectComposer.render` ping-pongs LOCAL variables and never reassigns
1698
+ * `inputBuffer`/`outputBuffer`, and `CopyPass.render` ignores its
1699
+ * `outputBuffer` argument entirely (it always writes to its own render
1700
+ * target, 1x1 unless the composer sized it). Going through them wrote the
1701
+ * photograph nowhere — every capture of a selected object came back empty.
1702
+ * A pass's own contract is exact: `RenderPass` draws the scene into the
1703
+ * `inputBuffer` it is handed, `EffectPass` reads that and writes the
1704
+ * `outputBuffer` it is handed — so hand the outline pass the capture target.
1705
+ */
1706
+ private renderSolidForCapture(
1707
+ camera: THREE.Camera,
1708
+ target: THREE.WebGLRenderTarget,
1709
+ width: number,
1710
+ height: number,
1711
+ ): void {
1712
+ const composer = this.composer;
1713
+ const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
1714
+ if (!composer || !outlined || !this.sceneRenderPass || !this.selectionOutlinePass) {
1715
+ this.renderer.render(this.scene, camera);
1716
+ return;
1717
+ }
1718
+ const previousWidth = this.renderWidth;
1719
+ const previousHeight = this.renderHeight;
1720
+ try {
1721
+ this.sceneRenderPass.renderToScreen = false;
1722
+ this.selectionOutlinePass.renderToScreen = false;
1723
+ composer.setSize(width, height, false);
1724
+ this.sceneRenderPass.render(this.renderer, composer.inputBuffer, composer.outputBuffer, 0);
1725
+ this.selectionOutlinePass.render(this.renderer, composer.inputBuffer, target, 0);
1726
+ } finally {
1727
+ composer.setSize(previousWidth, previousHeight, false);
1728
+ this.syncComposerOutput();
1729
+ this.renderer.setRenderTarget(target);
1730
+ }
1731
+ }
1732
+
1733
+ private syncComposerOutput(): void {
1734
+ const outlined = (this.selectionOutline?.selection.size ?? 0) > 0;
1735
+ if (this.sceneRenderPass) this.sceneRenderPass.renderToScreen = !outlined;
1736
+ if (this.selectionOutlinePass) {
1737
+ this.selectionOutlinePass.enabled = outlined;
1738
+ this.selectionOutlinePass.renderToScreen = outlined;
1739
+ }
1740
+ }
1741
+
1742
+ private refreshSkeletonHelper(): void {
1743
+ const visible = this.state.skeleton || this.boneSelectionHighlight !== null;
1744
+ if (visible && !this.skeletonHelper) {
1745
+ this.skeletonHelper = new THREE.SkeletonHelper(this.root);
1746
+ styleEditorSkeletonHelper(this.skeletonHelper);
1747
+ this.scene.add(this.skeletonHelper);
1748
+ } else if (!visible) {
1749
+ this.clearSkeletonHelper();
1750
+ }
1751
+ }
1752
+
1753
+ private clearSkeletonHelper(): void {
1754
+ if (!this.skeletonHelper) return;
1755
+ this.skeletonHelper.removeFromParent();
1756
+ this.skeletonHelper.dispose();
1757
+ this.skeletonHelper = null;
1758
+ }
1759
+
1760
+ private clearBoneSelectionHighlight(): void {
1761
+ if (!this.boneSelectionHighlight) return;
1762
+ this.boneSelectionHighlight.removeFromParent();
1763
+ this.boneSelectionHighlight.dispose();
1764
+ this.boneSelectionHighlight = null;
1765
+ }
1766
+
1767
+ private clearBoundsHelper(): void {
1768
+ if (!this.boundsHelper) return;
1769
+ const helper = this.boundsHelper;
1770
+ this.boundsHelper = null;
1771
+ const index = this.helpers.indexOf(helper);
1772
+ if (index >= 0) this.helpers.splice(index, 1);
1773
+ helper.removeFromParent();
1774
+ helper.geometry.dispose();
1775
+ if (Array.isArray(helper.material))
1776
+ helper.material.forEach((material) => {
1777
+ material.dispose();
1778
+ });
1779
+ else helper.material.dispose();
1780
+ }
1781
+
1782
+ private frameBones(bones: readonly THREE.Bone[]): void {
1783
+ const bounds = new THREE.Box3();
1784
+ const point = new THREE.Vector3();
1785
+ for (const bone of bones) {
1786
+ bounds.expandByPoint(bone.getWorldPosition(point));
1787
+ if ((bone.parent as THREE.Bone | null)?.isBone) {
1788
+ bounds.expandByPoint(bone.parent!.getWorldPosition(point));
1789
+ }
1790
+ for (const child of bone.children) {
1791
+ if ((child as THREE.Bone).isBone) bounds.expandByPoint(child.getWorldPosition(point));
1792
+ }
1793
+ }
1794
+ const center = bounds.getCenter(new THREE.Vector3());
1795
+ const minimumSpan = Math.max(bounds.getSize(new THREE.Vector3()).length(), 0.2);
1796
+ bounds.expandByScalar(minimumSpan * 0.35);
1797
+ const direction = this.viewport.camera.position
1798
+ .clone()
1799
+ .sub(this.viewport.orbitControls.target)
1800
+ .normalize();
1801
+ const distance = perspectiveDistanceToFitBox(bounds, this.viewport.camera, direction);
1802
+ this.viewport.setPose(center.clone().addScaledVector(direction, distance), center);
1803
+ }
1804
+
1805
+ private syncOrthographicCamera(): void {
1806
+ const source = this.viewport.camera;
1807
+ const target = this.viewport.orbitControls.target;
1808
+ const distance = Math.max(source.position.distanceTo(target), 0.001);
1809
+ const halfHeight = Math.max(
1810
+ distance * Math.tan(THREE.MathUtils.degToRad(source.fov) / 2),
1811
+ 0.001,
1812
+ );
1813
+ const width = Math.max(this.renderer.domElement.clientWidth, 1);
1814
+ const height = Math.max(this.renderer.domElement.clientHeight, 1);
1815
+ // The offscreen capture renders into its OWN buffer shape — see
1816
+ // `captureImage`; outside one, the panel's box is the aspect.
1817
+ const aspect = this.captureAspect ?? width / height;
1818
+ this.orthographicCamera.left = -halfHeight * aspect;
1819
+ this.orthographicCamera.right = halfHeight * aspect;
1820
+ this.orthographicCamera.top = halfHeight;
1821
+ this.orthographicCamera.bottom = -halfHeight;
1822
+ this.orthographicCamera.near = source.near;
1823
+ this.orthographicCamera.far = source.far;
1824
+ this.orthographicCamera.position.copy(source.position);
1825
+ this.orthographicCamera.quaternion.copy(source.quaternion);
1826
+ this.orthographicCamera.up.copy(source.up);
1827
+ this.orthographicCamera.layers.mask = source.layers.mask;
1828
+ this.orthographicCamera.updateProjectionMatrix();
1829
+ this.orthographicCamera.updateMatrixWorld(true);
1830
+ }
1831
+
1832
+ private clearDiagnosticPresentation(): void {
1833
+ this.clearTopologyOverlay();
1834
+ this.scene.overrideMaterial?.dispose();
1835
+ this.scene.overrideMaterial = null;
1836
+ for (const helper of this.helpers.splice(0)) {
1837
+ helper.removeFromParent();
1838
+ const line = helper as THREE.Line;
1839
+ line.geometry?.dispose();
1840
+ const material = line.material;
1841
+ if (Array.isArray(material))
1842
+ material.forEach((entry) => {
1843
+ entry.dispose();
1844
+ });
1845
+ else material?.dispose();
1846
+ }
1847
+ }
1848
+
1849
+ private notify(): void {
1850
+ this.version++;
1851
+ for (const listener of this.listeners) listener();
1852
+ }
1853
+ }
1854
+
1855
+ // Compatibility for existing internal callers/tests; lookup-only consumers
1856
+ // import the registry directly so they do not pull this implementation graph.
1857
+ export {
1858
+ __resetObject3DDocumentSessionsForTest,
1859
+ allObject3DDocumentSessions,
1860
+ object3DDocumentSession,
1861
+ object3DDocumentSessionsVersion,
1862
+ registerObject3DDocumentSession,
1863
+ subscribeObject3DDocumentSessions,
1864
+ } from './object3d-document-session-registry';
1865
+
1866
+ /**
1867
+ * OBJECT ORIGINS: a dot at the origin of each selected object, drawn over everything at a fixed
1868
+ * pixel size — Blender's "Origins" overlay (`overlays.selection.origins` in
1869
+ * `@volter/editor-sdk/kit/viewport-presentation`). Blender draws it one pixel wider than its
1870
+ * Object Origin Size (default 6; `overlay_instance.cc`: `obcenter_dia + 1`) with a one-pixel
1871
+ * dark rim, and in the active object's
1872
+ * colour for the active object; the kit's rule for the active colour stands in for that: a lone
1873
+ * selected object is shown in the look's active colour, several in its selection colour, the
1874
+ * same rule the outline follows.
1875
+ *
1876
+ * The positions are read from each object's world matrix as the frame is drawn, so a dot follows
1877
+ * a drag without the selection having to change.
1878
+ */
1879
+ /** Blender's drawn origin diameter at the default Object Origin Size, in CSS pixels. three
1880
+ * scales a point's size by the pixel ratio itself (`WebGLMaterials.refreshUniformsPoints`). */
1881
+ const ORIGIN_SIZE_PX = 7;
1882
+
1883
+ let dotTexture: THREE.Texture | null = null;
1884
+ /** A filled disc with a thin dark rim, white where the colour goes. */
1885
+ function originDotTexture(): THREE.Texture {
1886
+ if (dotTexture) return dotTexture;
1887
+ const size = 64;
1888
+ const canvas = document.createElement('canvas');
1889
+ canvas.width = size;
1890
+ canvas.height = size;
1891
+ const context = canvas.getContext('2d')!;
1892
+ context.beginPath();
1893
+ context.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
1894
+ context.fillStyle = 'rgba(0, 0, 0, 0.75)';
1895
+ context.fill();
1896
+ context.beginPath();
1897
+ context.arc(size / 2, size / 2, size / 2 - 8, 0, Math.PI * 2);
1898
+ context.fillStyle = '#ffffff';
1899
+ context.fill();
1900
+ dotTexture = new THREE.CanvasTexture(canvas);
1901
+ dotTexture.colorSpace = THREE.SRGBColorSpace;
1902
+ return dotTexture;
1903
+ }
1904
+
1905
+ class SelectionOrigins extends THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> {
1906
+ private objects: readonly THREE.Object3D[] = [];
1907
+ private readonly origin = new THREE.Vector3();
1908
+
1909
+ constructor() {
1910
+ super(
1911
+ new THREE.BufferGeometry(),
1912
+ new THREE.PointsMaterial({
1913
+ map: originDotTexture(),
1914
+ size: ORIGIN_SIZE_PX,
1915
+ sizeAttenuation: false,
1916
+ transparent: true,
1917
+ alphaTest: 0.05,
1918
+ depthTest: false,
1919
+ depthWrite: false,
1920
+ toneMapped: false,
1921
+ }),
1922
+ );
1923
+ this.name = 'vgai:selection-origins';
1924
+ this.userData['editorHelper'] = true;
1925
+ this.renderOrder = 1000;
1926
+ this.frustumCulled = false;
1927
+ this.raycast = () => {};
1928
+ }
1929
+
1930
+ /** The selected objects and the colours their dots take (`active` for a lone selection). */
1931
+ update(objects: readonly THREE.Object3D[], colors: { readonly visible: number; readonly active?: number }): void {
1932
+ this.objects = objects;
1933
+ this.material.color.setHex(objects.length === 1 && colors.active !== undefined ? colors.active : colors.visible);
1934
+ this.visible = objects.length > 0;
1935
+ }
1936
+
1937
+ /**
1938
+ * Before each draw: the dots where their objects are now. Called by the session's `render`
1939
+ * BEFORE the renderer starts, never from `onBeforeRender`: three uploads a geometry's
1940
+ * attributes while it builds the render list, so an attribute replaced in `onBeforeRender` is
1941
+ * drawn un-uploaded, and every dot falls to the Points object's own origin.
1942
+ */
1943
+ place(): void {
1944
+ const count = this.objects.length;
1945
+ let attribute = this.geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
1946
+ // Grown, never shrunk, and drawn to `count`: a replaced attribute's GL buffer is not freed.
1947
+ if (attribute === undefined || attribute.count < count) {
1948
+ this.geometry.dispose();
1949
+ attribute = new THREE.BufferAttribute(new Float32Array(Math.max(count, 8) * 3), 3);
1950
+ this.geometry.setAttribute('position', attribute);
1951
+ }
1952
+ this.geometry.setDrawRange(0, count);
1953
+ this.objects.forEach((object, index) => {
1954
+ object.getWorldPosition(this.origin);
1955
+ attribute.setXYZ(index, this.origin.x, this.origin.y, this.origin.z);
1956
+ });
1957
+ attribute.needsUpdate = true;
1958
+ }
1959
+
1960
+ dispose(): void {
1961
+ this.removeFromParent();
1962
+ this.geometry.dispose();
1963
+ this.material.dispose();
1964
+ }
1965
+ }