@volter/editor-threejs 0.5.210 → 0.5.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +8 -13
  3. package/contributions/animation-mixers.service.ts +2 -4
  4. package/contributions/animation-timeline.utility.tsx +1 -2
  5. package/contributions/three.page.ts +50 -0
  6. package/dist-node/serving.mjs +84 -3
  7. package/dist-node/shared-three.mjs +145 -0
  8. package/package.json +296 -16
  9. package/serving/animation-stamp.ts +1 -1
  10. package/serving/index.ts +22 -5
  11. package/serving/model-import-conversion.ts +6 -3
  12. package/serving/shared-three.ts +176 -0
  13. package/src/adapter/constraint.ts +1 -18
  14. package/src/adapter/object3d-authoring-subject.ts +1 -12
  15. package/src/adapter/reflection-probe.ts +1 -9
  16. package/src/adapter/three-contract.ts +6 -60
  17. package/src/animation/live-mixers.ts +0 -4
  18. package/src/api.ts +7 -0
  19. package/src/asset-loaders.ts +2 -178
  20. package/src/capture/photograph-sampling.ts +333 -0
  21. package/src/capture/scene.ts +1 -1
  22. package/src/ecs/object-marks.ts +6 -14
  23. package/src/ecs/user-data.ts +3 -138
  24. package/src/kit/animation/three-clips-subject.ts +19 -42
  25. package/src/kit/asset-compare-core.ts +161 -0
  26. package/src/kit/asset-compare.ts +8 -27
  27. package/src/kit/asset-preview-command.ts +14 -19
  28. package/src/kit/asset-preview-framing.ts +8 -67
  29. package/src/kit/asset-preview.ts +109 -342
  30. package/src/kit/asset-workflow/model-inspection.ts +11 -154
  31. package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
  32. package/src/kit/authoring/component-instance-root.ts +3 -39
  33. package/src/kit/authoring/live-object-transform.ts +1 -30
  34. package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
  35. package/src/kit/authoring/object3d-document-session.ts +471 -208
  36. package/src/kit/authoring/shell-viewport-policy.ts +18 -24
  37. package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
  38. package/src/kit/authoring/three-projection-core.ts +11 -40
  39. package/src/kit/authoring/viewport-pick-context.ts +5 -12
  40. package/src/kit/authoring/viewport-raycast.ts +15 -93
  41. package/src/kit/authoring/world-hidden-viewport.ts +8 -61
  42. package/src/kit/capture-camera-pose.ts +68 -0
  43. package/src/kit/collaboration-presence.ts +41 -0
  44. package/src/kit/components/CameraInfo.tsx +4 -6
  45. package/src/kit/components/InspectorObjectPreview.tsx +3 -30
  46. package/src/kit/components/Object3DDocumentToolbar.css +75 -0
  47. package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
  48. package/src/kit/components/Object3DDocumentViewport.tsx +5 -8
  49. package/src/kit/components/StageHost.css +40 -0
  50. package/src/kit/components/StageHost.tsx +181 -404
  51. package/src/kit/components/StageOverlays.tsx +1 -2
  52. package/src/kit/components/StatsOverlay.tsx +5 -17
  53. package/src/kit/components/ViewportFurniture.tsx +27 -154
  54. package/src/kit/components/ViewportOverlay.tsx +13 -17
  55. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  56. package/src/kit/components/ViewportShadingMenu.tsx +2 -42
  57. package/src/kit/components/ViewportViewMenu.tsx +7 -18
  58. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +4 -13
  59. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +5 -5
  60. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +24 -102
  61. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +6 -6
  62. package/src/kit/components/asset-viewers/Object3DPreview.tsx +10 -45
  63. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +2 -2
  64. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +11 -10
  65. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  66. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
  67. package/src/kit/components/object3d-contribution-surfaces.tsx +1 -1
  68. package/src/kit/components/stage-keyboard.tsx +6 -12
  69. package/src/kit/components/stage-overlay-set.tsx +11 -20
  70. package/src/kit/components/stage-presence-markers.ts +9 -32
  71. package/src/kit/components/stage-transform-chrome.ts +4 -4
  72. package/src/kit/components/stage-transform-tools.tsx +7 -14
  73. package/src/kit/components/standard-viewport-dressing.ts +47 -167
  74. package/src/kit/components/world-root-binding.ts +2 -7
  75. package/src/kit/constraint-helper.ts +1 -1
  76. package/src/kit/document-renderer-session.ts +114 -0
  77. package/src/kit/editor-presentation-activity.ts +58 -0
  78. package/src/kit/editor-shell-store.ts +31 -403
  79. package/src/kit/editor-viewport.ts +476 -1762
  80. package/src/kit/entity-lod.ts +3 -16
  81. package/src/kit/entity-object.ts +1 -39
  82. package/src/kit/hierarchy-mark-reader.ts +13 -30
  83. package/src/kit/instanced-presentation.ts +1 -47
  84. package/src/kit/live-gesture-lock.ts +35 -0
  85. package/src/kit/live-module-source.ts +12 -52
  86. package/src/kit/model-thumbnail.ts +7 -35
  87. package/src/kit/play-camera-flight.ts +7 -16
  88. package/src/kit/projection/three.ts +13 -366
  89. package/src/kit/retained-document-states.ts +91 -0
  90. package/src/kit/scene-document-viewport.ts +3 -3
  91. package/src/kit/scene-framing.ts +11 -152
  92. package/src/kit/scene-view-fog.ts +2 -47
  93. package/src/kit/spatial-handle-visuals.ts +19 -28
  94. package/src/kit/stories/three-story-model.ts +5 -33
  95. package/src/kit/three-canvas-render.ts +3 -4
  96. package/src/kit/three-hierarchy-row-media.ts +3 -11
  97. package/src/kit/three-inspection-media.ts +6 -6
  98. package/src/kit/three-integration.ts +18 -18
  99. package/src/kit/three-state.ts +2 -19
  100. package/src/kit/three-viewport/camera-fit.ts +2 -6
  101. package/src/kit/three-viewport/selection-brackets.ts +5 -20
  102. package/src/kit/three-viewport/selection-outline.ts +4 -56
  103. package/src/kit/three-viewport/skeleton-helper.ts +1 -32
  104. package/src/kit/three-viewport/source-color.ts +1 -33
  105. package/src/kit/three-viewport/studio-environment.ts +4 -55
  106. package/src/kit/three-viewport-presentation.ts +23 -0
  107. package/src/kit/transport-strip.tsx +147 -0
  108. package/src/kit/viewport-actions.ts +8 -50
  109. package/src/kit/viewport-authoring-policy.ts +5 -52
  110. package/src/kit/viewport-commands.ts +28 -57
  111. package/src/kit/viewport-hotkeys.ts +29 -31
  112. package/src/kit/viewport-status-facet.ts +4 -5
  113. package/src/kit/viewport-surface-status.tsx +44 -0
  114. package/src/loader.ts +8 -71
  115. package/src/object3d-contributions.ts +18 -34
  116. package/src/viewport/clip-planes.ts +4 -32
  117. package/src/viewport/content-bounds.ts +13 -142
  118. package/src/viewport/editor-layers.ts +4 -26
  119. package/src/viewport/environment.ts +1 -1
  120. package/src/viewport/preview-renderer.ts +7 -45
  121. package/src/viewport/renderer-ownership.ts +14 -62
  122. package/src/viewport-api.ts +14 -33
  123. package/src/viewport-door.ts +16 -86
  124. package/contributions/three-integration.service.ts +0 -13
  125. package/src/adapter/hierarchy-marks.ts +0 -156
  126. package/src/adapter/ingest/scene-capture.ts +0 -843
  127. package/src/adapter/ingest/structural-ids.ts +0 -139
  128. package/src/adapter/ingest/visible-capture-window.ts +0 -302
  129. package/src/adapter/renderer-config.ts +0 -115
  130. package/src/asset-parse-error.ts +0 -29
  131. package/src/host-hierarchy-objects.ts +0 -31
  132. package/src/kit/authoring/design-time-settle.ts +0 -343
  133. package/src/kit/camera-authoring.ts +0 -175
@@ -1,14 +1,14 @@
1
1
  import type { SparkRenderer } from '@sparkjsdev/spark';
2
- import { invalidateStages } from '@volter/sdk/kit/stage-invalidation';
2
+ import { invalidateStages } from '@volter/sdk/stage-invalidation';
3
3
  import { StagePresentationRig } from './components/standard-viewport-dressing';
4
4
  import {
5
5
  bindViewPresentation,
6
6
  subscribeViewportPresentation,
7
7
  type ViewportOverlays,
8
8
  viewPresentation,
9
- } from '@volter/sdk/kit/viewport-presentation';
9
+ } from '@volter/sdk/viewport-presentation';
10
10
  import { themeVars, zIndex } from '@volter/sdk/widgets';
11
- import type { AssetDropContext, AuthoringAdapter } from '@volter/project/adapter';
11
+ import type { AuthoringAdapter } from '@volter/project/adapter';
12
12
  import {
13
13
  EDITOR_CAMERA_FAR,
14
14
  EDITOR_CAMERA_NEAR,
@@ -33,7 +33,7 @@ import {
33
33
  import * as THREE from 'three';
34
34
  import { axisViewName, isQuarterTurnUp } from './asset-workflow/model-inspection';
35
35
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
36
- import { TransformControls } from 'three/addons/controls/TransformControls.js';
36
+ import { TransformControls, type TransformControlsGizmo } from 'three/addons/controls/TransformControls.js';
37
37
  import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
38
38
  import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
39
39
  import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
@@ -41,18 +41,16 @@ import { BatchedRenderer } from 'three.quarks';
41
41
  import {
42
42
  applyAuthoringTransform,
43
43
  beginAuthoringTransformEdit,
44
- dropAuthoringAsset,
45
44
  endAuthoringTransformEdit,
46
45
  setAuthoringSelection,
47
46
  spatialHandlesForAdapter,
48
- } from '@volter/sdk/kit/authoring/consumer-actions';
49
- import { beginLiveGesture, endLiveGesture } from '@volter/sdk/kit/live-gesture-lock';
50
- import { drillIntoSelectionScope, pickAcrossScopeExit } from '@volter/sdk/kit/authoring/selection-scope';
47
+ } from '@volter/sdk/authoring/consumer-actions';
48
+ import { beginLiveGesture, endLiveGesture } from './live-gesture-lock';
49
+ import { drillIntoSelectionScope, pickAcrossScopeExit } from '@volter/sdk/authoring/selection-scope';
51
50
  import { setViewportPickContext } from './authoring/viewport-pick-context';
52
- import { isRootHidden } from '@volter/sdk/kit/authoring/world-session-state';
53
- import type { CameraViewMode } from './camera-authoring';
51
+ import { isRootHidden } from '@volter/sdk/authoring/world-session-state';
54
52
  import { type ConstraintControl, ConstraintHelper } from './constraint-helper';
55
- import { editorConsole } from '@volter/sdk/kit/editor-console';
53
+ import { editorConsole } from '@volter/sdk/editor-console';
56
54
  import type { EditorShellStore } from './editor-shell-store';
57
55
  import { findEntityLod } from './entity-lod';
58
56
  import { entityIdOf, entityObject3D } from './entity-object';
@@ -69,8 +67,7 @@ import {
69
67
  nativeViewportSelectionBox,
70
68
  nativeViewportWire,
71
69
  subscribeNativeSelectionTheme,
72
- } from '@volter/sdk/kit/native-selection-style';
73
- import { presentationRegionBasis } from '@volter/sdk/kit/presentation-surface';
70
+ } from '@volter/sdk/native-selection-style';
74
71
  import { ReflectionProbeHelper } from './reflection-probe-helper';
75
72
  import { frameableContentBounds, seededViewShowsWorld, viewFromGameCamera } from './scene-framing';
76
73
  import {
@@ -86,22 +83,21 @@ import { perspectiveDistanceToFitBox } from './three-viewport/camera-fit';
86
83
  import { SelectionBrackets } from './three-viewport/selection-brackets';
87
84
  import { collectThreeSelectionOutlineTargets } from './three-viewport/selection-outline';
88
85
  import { toneMappedSourceColor } from './three-viewport/source-color';
89
- import type { ThreeViewportProjection } from '@volter/sdk/kit/three-viewport-presentation';
90
- import { showTransientHint } from '@volter/sdk/kit/transient-hint';
91
- import { activeKeymapNavigation, subscribeEditorKeymap } from '@volter/sdk/kit/keymap-presets';
86
+ import type { ThreeViewportProjection } from './three-viewport-presentation';
87
+ import { showTransientHint } from '@volter/sdk/transient-hint';
88
+ import { editorHost } from '@volter/sdk/host';
92
89
  import { TriggerVolumeHelper } from './trigger-volume-helper';
93
90
  import { viewportAuthoringPolicy } from './viewport-authoring-policy';
94
91
  import { ensureThreeIntegration } from './three-integration';
95
92
 
96
- /** A bounds box or LineSegments wireframe tagged with its source entity object. */
97
93
  /** A selected mesh's wireframe (the view's `selection.wire` overlay, Unity's Selection Wire):
98
94
  * a stage-owned line set that copies the mesh's world matrix each frame, never a child of it. */
99
95
  interface WireHelper extends THREE.LineSegments {
100
96
  _wireOf?: THREE.Object3D;
101
97
  }
102
98
 
103
- interface TaggedHelper extends ContentBoundsHelper {
104
- isBoxHelper: boolean;
99
+ /** The source tag on a diagnostic wire cube; other helper kinds have no tag. */
100
+ interface TaggedHelper {
105
101
  _entityObj?: THREE.Object3D;
106
102
  }
107
103
 
@@ -132,30 +128,11 @@ export function topmostPublishedObjects(
132
128
  return roots;
133
129
  }
134
130
 
135
- /** Topmost live objects among an exact add/remove delta; removed ids contribute nothing. */
136
- export function topmostChangedObjects(
137
- objectMap: ReadonlyMap<string, THREE.Object3D>,
138
- changedIds: ReadonlySet<string>,
139
- ): THREE.Object3D[] {
140
- const changed = new Set<THREE.Object3D>();
141
- for (const id of changedIds) {
142
- const object = objectMap.get(id);
143
- if (object) changed.add(object);
144
- }
145
- return [...changed].filter((object) => {
146
- for (let parent = object.parent; parent !== null; parent = parent.parent) {
147
- if (changed.has(parent)) return false;
148
- }
149
- return true;
150
- });
151
- }
152
-
153
131
  /**
154
- * Everything the viewport parks in `_boxHelpers`: the Bounds diagnostic's
155
- * plain full boxes (and its degenerate-entity wire cubes), plus selection
156
- * brackets for nodes that have no renderable silhouette. They share one map
157
- * because every consumer treats them identically — hide for the camera
158
- * preview, re-add on `setScene`, refresh each frame, dispose on rebuild.
132
+ * The Bounds diagnostic's boxes and degenerate-entity wire cubes, selected
133
+ * mesh wires, and selection brackets. They share visibility, scene transfer
134
+ * and disposal; each frame updates bounds and brackets, copies mesh-wire
135
+ * matrices, or follows a tagged cube's source position.
159
136
  */
160
137
  type BoundsHelper = ContentBoundsHelper | THREE.LineSegments | SelectionBrackets;
161
138
 
@@ -174,16 +151,10 @@ const DEGENERATE_HELPER_SIZE = 0.5;
174
151
  * streaming itself in is reached within a second. */
175
152
  const CONTENT_BOUNDS_MAX_AGE_MS = 1000;
176
153
 
177
- /** The two ground-plane normals a region's declared basis selects between.
178
- * Shared constants: `Plane.set` copies the normal, so nothing here is handed a
179
- * reference it could mutate. */
180
- const UP_Y = new THREE.Vector3(0, 1, 0);
181
- const UP_Z = new THREE.Vector3(0, 0, 1);
182
-
183
154
  /** Scratch for the clip-plane fit — one per module, same reason as
184
155
  * `content-bounds.ts`'s `nodeBox`. */
185
156
  const _clipSphere = new THREE.Sphere();
186
- /** Gizmo-anchor scratch — see the two pivot branches in `_syncSelection`. */
157
+ /** Gizmo-anchor scratch — see the two pivot branches in `syncFromStore`. */
187
158
  const _medianTmp = new THREE.Vector3();
188
159
  const _pivotPos = new THREE.Vector3();
189
160
  const _pivotQuat = new THREE.Quaternion();
@@ -205,8 +176,7 @@ const VC_DIRS: THREE.Vector3[] = [
205
176
  /**
206
177
  * THE EDITOR'S OWN TRANSFORM-GIZMO AXIS COLOURS (X/Y/Z), used when the look names no
207
178
  * `color.gizmo` group. Their values are Godot's editor axis colours (`theme_modern.cpp`,
208
- * `axis_x_color`…). Kept as plain `[r,g,b]` triples: call sites construct their own
209
- * `new THREE.Color(...)`, so nothing here can be mutated in place.
179
+ * `axis_x_color`…). Plain sRGB triples become fresh Color values through `_gizmoAxisColor`.
210
180
  */
211
181
  const KIT_GIZMO_AXIS_RGB: readonly [number, number, number][] = [
212
182
  [0.96, 0.2, 0.32], // X
@@ -232,9 +202,8 @@ const KIT_GIZMO_AXIS_RGB: readonly [number, number, number][] = [
232
202
  * spellings, and the matrix is the authority — see
233
203
  * `blender-runtime-view.ts`'s constructor.
234
204
  *
235
- * Chosen by the look (`StageContribution.upAxis`), never by the
236
- * document: the stage draws one world at a time and the look is what states
237
- * which program's viewport this is.
205
+ * Chosen by the view presentation's world frame through `setStageFunction`.
206
+ * The look supplies the gizmos' paint and dimensions.
238
207
  */
239
208
  type StageAxisFrame = readonly (readonly [axis: number, sign: 1 | -1])[];
240
209
  const AXIS_FRAME_Y_UP: StageAxisFrame = [
@@ -257,46 +226,9 @@ const AXIS_FRAME_Z_UP_LEFT: StageAxisFrame = [
257
226
  /** The label a source axis index carries on the navigation gizmo. */
258
227
  const AXIS_LETTER = ['X', 'Y', 'Z'] as const;
259
228
 
260
- /**
261
- * An ONLINE asset dropped on the viewport — the asset browser's drag payload,
262
- * verbatim. An `url` means the file to fetch is already known; without one the
263
- * source has to be asked for its file list first.
264
- */
265
- export interface OnlineAssetDrop {
266
- readonly source: string;
267
- readonly id: string;
268
- readonly name: string;
269
- readonly url?: string;
270
- readonly format?: string;
271
- readonly includes?: { relativePath: string; url: string; size: number }[];
272
- }
273
-
274
- /**
275
- * THE VIEWPORT'S ONE DOOR TO THE ONLINE ASSET LIBRARY — a collaborator the
276
- * SHELL installs, never a transport the viewport imports (ARCHITECTURE-CORE
277
- * §Editor chrome, "the viewport stack is separable").
278
- *
279
- * The viewport contributes what only it knows: the world point under the
280
- * cursor, and the adapter that owns what a dropped path MEANS. Everything the
281
- * download needs — the asset cache, the source's file list, the project's
282
- * download history, and the console report when it fails — is the shell's
283
- * (`world-root-stage.ts` installs it over `editor-api.ts` +
284
- * `asset-editor-persistence.ts`, which together weigh 64 files the gizmo
285
- * viewport otherwise carries to place a chair).
286
- *
287
- * Returns the PROJECT-LOCAL path to hand the adapter's `assetDrop`, or `null`
288
- * when the asset could not be resolved — in which case the resolver has
289
- * already SAID SO on the console, so the viewport stays quiet. A rejection is
290
- * the resolver failing to report at all, and the viewport reports that itself.
291
- */
292
- export type OnlineAssetResolver = (asset: OnlineAssetDrop) => Promise<string | null>;
293
-
294
229
  export interface EditorViewportOptions {
295
230
  /** Native renderer for renderer-coupled Three extensions such as Spark. */
296
231
  readonly renderer?: THREE.WebGLRenderer;
297
- /** How a dropped ONLINE asset becomes a project path. Omit and an online
298
- * drop is REFUSED BY NAME on the console — see {@link OnlineAssetResolver}. */
299
- readonly onlineAssetResolver?: OnlineAssetResolver;
300
232
  /** Document-local adapter. Omit for the editor's active project adapter. */
301
233
  readonly authoring?: () => AuthoringAdapter;
302
234
  /** Document-local picking when this viewport is not the project viewport. */
@@ -304,48 +236,15 @@ export interface EditorViewportOptions {
304
236
  /** The camera the stage draws with, when that is not the viewport's own (a document session's
305
237
  * orthographic or camera view): screen tests such as the box select project through it. */
306
238
  readonly drawCamera?: () => THREE.Camera;
307
- /** Only the one project viewport publishes the legacy global pick context. */
239
+ /** Only the one project viewport publishes the global pick context. */
308
240
  readonly publishPickContext?: boolean;
309
241
  readonly onProjectionChange?: (projection: ThreeViewportProjection) => void;
310
- /**
311
- * How far the canvas BLEEDS past the box a person can see, in CSS px.
312
- *
313
- * A document stage inflates its canvas (`StageHost.tsx`, `inset: -12`) so
314
- * the image fills the dock panel edge to edge, and gives the DOM furniture
315
- * that bleed back (`inset: 12`) so the chrome stays inside the panel. The
316
- * ORIENTATION GIZMO is drawn on the canvas, not in the DOM, so it never got
317
- * that correction: measured on the Model stage, the canvas runs to page x
318
- * 1437.5 where the panel ends at 1419, and the gizmo — placed 5 px from the
319
- * CANVAS edge — had its −Z ball cut off by the Inspector. That is the same
320
- * defect the furniture's own docblock records fixing for the navigation
321
- * cluster. One value, set from the one expression that positions the DOM
322
- * furniture; every other host leaves it 0.
323
- */
242
+ /** Canvas bleed in CSS pixels. Inset the canvas-rendered compass by the same amount that positions the DOM furniture inside the visible panel. */
324
243
  readonly chromeInsetPx?: number;
325
- /**
326
- * The editor's own DESIGN-TIME LIGHT RIG (the ambient and the directional
327
- * this class adds to every scene it is given). Default on. `false` is a
328
- * host saying the content lights itself — which is the same thing a
329
- * document says by turning the standard dressing's key light off, and the
330
- * rig has to hear it. MEASURED: the rig's lights sit on `EDITOR_LAYER`,
331
- * but three filters lights by the CAMERA's layers
332
- * (`WebGLRenderer.projectObject`), and the camera that draws a document
333
- * stage enables that layer so it can see the grid and the gizmo — so the
334
- * layer scopes the rig away from nobody, and a document lit by its own
335
- * view-locked studio was being lit by this as well.
336
- */
244
+ /** Enable the editor light rig by default. Hosts whose content supplies its own lighting disable it explicitly. */
337
245
  readonly lightRig?: boolean;
338
246
  }
339
247
 
340
- /**
341
- * The floor grid as a shader over one plane (`extent` metres square, y = 0).
342
- * Lines are `fwidth`-antialiased so they keep one on-screen width at every
343
- * distance; the 1 m level fades out where its cells fall under a few pixels
344
- * (Blender's adaptive grid never lets lines merge), the 10 m level stays; the
345
- * whole floor fades to its edge. `uColor` is the palette's grid colour
346
- * (`_applyViewportLook`). Depth-tested, never written: content standing on
347
- * the floor occludes it, and it occludes nothing.
348
- */
349
248
  /**
350
249
  * THE STAGE'S FIELD OF VIEW, as the view states it (`ViewportCamera.fov`): an angle held on one
351
250
  * side of the region — vertical, horizontal, the larger or the smaller — so a panel that widens
@@ -404,23 +303,7 @@ function hsvToColor(h: number, s: number, v: number, out: THREE.Color): THREE.Co
404
303
  }
405
304
 
406
305
 
407
- /**
408
- * One ball of the navigation gizmo: filled with its letter (a positive axis),
409
- * or the ring Blender draws for a negative one.
410
- *
411
- * BOTH FORMS MUST REACH THE SAME EDGE, and they did not. The disc was filled
412
- * to `size/2 − 5` while the ring's 8 px stroke straddled that radius and
413
- * reached `size/2 − 1`, so a POSITIVE ball drew 0.844 of its sprite and a
414
- * negative one 0.969 — measured live on the Model stage, +X came out 11.8 CSS
415
- * beside −X's 15.5, the filled ball SMALLER than the hollow one beside it.
416
- * Blender's are one size (its variation is depth, which the caller applies).
417
- * One outer radius here, the ring's stroke laid inside it.
418
- *
419
- * THE LETTER is not black. Measured on Blender's +X ball: glyph ink
420
- * (88,19,29) over a (245,54,81) fill — 0.359 / 0.352 / 0.358 of the ball's
421
- * own colour, one ratio in all three channels, so it is the axis colour
422
- * darkened rather than a neutral. Cap height 7.5 CSS on a 17.5 CSS ball.
423
- */
306
+ /** Draw positive-axis balls filled with dark letters and negative-axis balls as rings, with equal outer extents. */
424
307
  const COMPASS_LETTER_INK = 0.356;
425
308
  const COMPASS_LETTER_CAP_FRACTION = 7.5 / 17.5;
426
309
 
@@ -502,44 +385,7 @@ function compassGlyphTexture(text: string): THREE.CanvasTexture {
502
385
  * three-quarter view (`ViewportCamera.opening`). */
503
386
  const STAGE_OPENING_DIRECTION = new THREE.Vector3(1, 0.72, 1).normalize();
504
387
 
505
- /**
506
- * THE COMPASS'S BOX, AND THE GIZMO'S OWN SIZES — re-measured 2026-09-19 from
507
- * `modeling-edit-none.png` and `modeling-object-none.png` at their native 2x,
508
- * and the numbers they replace were not small errors.
509
- *
510
- * THE METHOD, because a ball-centre distance read straight off the frame is
511
- * the wrong number: every axis is FORESHORTENED by the view, so the one thing
512
- * that can be measured is the projection. The main camera's own basis comes
513
- * out of the floor calibration (see {@link GRAZING_FADE_GLSL}); an axis `a`
514
- * lands at `R · (a·right, −a·up)` from the gizmo's centre, and the three
515
- * NEGATIVE balls (no stalk to contaminate a centroid) then give three
516
- * equations for `R` and the centre. Solved: R = 63.9 device px = **31.96 CSS**
517
- * with the centre at (2734.2, 257.3) device, cross-checked by the third ball
518
- * to within one device pixel and by the ink bbox (2·R·0.9155 + ball = 150
519
- * device against 149 measured). The previous `28` came from reading a
520
- * foreshortened distance as if it were the radius.
521
- *
522
- * BALL DIAMETER is not one number either: Blender scales it with DEPTH, which
523
- * an orthographic gizmo camera does not do for free. Six balls, against
524
- * `facing` = (axis·view + 1)/2 — the same quantity
525
- * `_syncOrientationGizmoDepth` already fades with:
526
- *
527
- * facing 0.09 0.28 0.32 0.68 0.72 0.91
528
- * d (CSS) 15.25 15.75 16.12 17.00 16.75 17.50
529
- *
530
- * — a straight line, `d = 15.0 + 2.75·facing`, residual ≤ 0.25 CSS on all six.
531
- *
532
- * PLACEMENT, measured identically in both frames: the gizmo's coloured ink
533
- * spans 74.5 × 73.5 CSS centred on that solved centre, its right edge 8.5 CSS
534
- * inside the region's right edge and its top 38 CSS below the region's top.
535
- * (The old `14` and `39.5` were the same reading taken against a smaller
536
- * drawn circle.) The navigation cluster's first glyph sits 16 CSS under the
537
- * ink, its glyphs 16 CSS on a 30 CSS pitch — which ours already draws
538
- * exactly, so only where the cluster STARTS moves.
539
- *
540
- * The box is the scissored square the gizmo draws into; the geometry reaches
541
- * `stalk + ball/2` = 40.2 px from the centre, so 90 still contains it.
542
- */
388
+ /** Navigation-gizmo dimensions in CSS pixels, shared by sprite geometry and its canvas box. */
543
389
  export const COMPASS_BOX_PX = 90;
544
390
  /** Blender's gizmo sphere: the distance from the centre to a ball's centre. */
545
391
  const COMPASS_STALK_PX = 32;
@@ -558,59 +404,11 @@ const COMPASS_MARGIN_TOP_PX = 38 + COMPASS_INK_HEIGHT_PX / 2 - COMPASS_BOX_PX /
558
404
  * `stage.chrome.viewName` `gizmo`). */
559
405
  export const COMPASS_CENTER_RIGHT_PX = COMPASS_MARGIN_RIGHT_PX + COMPASS_BOX_PX / 2;
560
406
  export const COMPASS_INK_BOTTOM_PX = COMPASS_MARGIN_TOP_PX + COMPASS_BOX_PX / 2 + COMPASS_INK_HEIGHT_PX / 2;
561
- /**
562
- * Where the navigation cluster starts. Blender's first GLYPH sits 16 CSS
563
- * under the gizmo's ink; the cluster component pads 14.5 CSS above its own
564
- * first glyph (measured on our frame), so the constant is that gap less the
565
- * padding.
566
- */
407
+ /** Navigation-cluster top offset after subtracting the component’s own padding from the desired gap. */
567
408
  export const COMPASS_CLUSTER_TOP_PX =
568
409
  COMPASS_MARGIN_TOP_PX + COMPASS_BOX_PX / 2 + COMPASS_INK_HEIGHT_PX / 2 + 16 - 14.5;
569
410
 
570
- /**
571
- * THE GRAZING FADE — the floor's depth, transcribed from Blender's own frames.
572
- *
573
- * The radial band below keeps a finite grid off a hard rim; it says nothing
574
- * about DEPTH, and at a document camera standing 11 m from a 1 m subject the
575
- * band (110–200 m) never engages inside the visible floor. Measured on our
576
- * Model stage: the 1 m line reads 84 and the X axis `203,41,63` at EVERY
577
- * depth, from the front of the frame to the horizon — a flat lattice, which
578
- * is what a fresh viewer names first.
579
- *
580
- * WHAT BLENDER DOES, measured (not read out of its source): calibrate
581
- * `modeling-edit-none.png` from its own floor — fit the two axis lines, index
582
- * the 1 m grid crossings along each (a 1D projective map, residual <0.05 of a
583
- * cell), and solve the camera from the two vanishing points with the
584
- * principal point at the region centre. That gives f = 1976 device px
585
- * (Blender's viewport lens: 50 mm on its DEFAULT_SENSOR_WIDTH of 72 mm),
586
- * camera 17.96 m out at elevation 26.46°, azimuth −23.82° — which reproduces
587
- * the elevation and azimuth Blender's opening direction was independently
588
- * solved from (`@volter/editor-blender`'s presentation), so the calibration is
589
- * checked against something already in this repo. Sampling the X axis at thirteen known floor points, its ink over
590
- * the `#3f3f3f` floor, IN LINEAR LIGHT (the blend happens before the sRGB
591
- * encode — read as sRGB levels the profile fits nothing):
592
- *
593
- * camera dist 12.3 15.6 22.2 28.6 45.9 70.5 115.2 (metres)
594
- * alpha 0.943 0.963 0.872 0.766 0.572 0.426 0.258
595
- * 1-(1-h/r)^4 0.985 0.944 0.832 0.731 0.535 0.383 0.250
596
- * ratio 0.957 1.020 1.048 1.048 1.068 1.113 1.033
597
- *
598
- * So the fade is `1 - (1 - |V·n|)^4` over the unit fragment→camera vector and
599
- * the floor normal — the GRAZING ANGLE, flat to ±6% across a 9x range of
600
- * distance. Two frames of the same scene at different view distances
601
- * (`modeling-near.png`, `modeling-far.png`) put the axis at the same PIXELS
602
- * with ink within 3–5%, which is the same statement: the profile follows the
603
- * angle, not the metres.
604
- *
605
- * WHERE THE EVIDENCE STOPS: Blender also carries a far-distance term, and
606
- * these frames cannot size it — at 115 m from an 18 m camera the residual is
607
- * still flat, so whatever its onset is, it is past everything photographed.
608
- * Not transcribed; the band below is what ends our finite floor.
609
- *
610
- * Scale-free by construction: the fade is a ratio of camera height to
611
- * distance, so a 1 m subject framed like Blender's 2 m one fades identically
612
- * at the same pixels.
613
- */
411
+ /** Fade the floor by the ratio of camera height to distance, independent of subject scale. */
614
412
  export const GRAZING_FADE_GLSL = /* glsl */ `
615
413
  float volterGrazingFade(vec3 worldPosition, vec3 eye, vec3 planeNormal, float amount) {
616
414
  vec3 toEye = eye - worldPosition;
@@ -628,26 +426,7 @@ const AXIS_SEGMENT_METRES = 2;
628
426
  /** Segments per axis (both directions from the origin). */
629
427
  const AXIS_SEGMENTS_PER_AXIS = (AXIS_HALF_LENGTH * 2) / AXIS_SEGMENT_METRES;
630
428
 
631
- /**
632
- * The X and Z axis lines as a CHAIN of short segments rather than one segment
633
- * each, so the grazing fade below can read a per-fragment world position.
634
- *
635
- * WHY, measured: a line through the origin always runs BEHIND the camera in
636
- * one direction, and `LineMaterial` trims such a segment against the camera
637
- * plane while building its screen-space quad (`trimSegment`). A varying
638
- * carried from the UNTRIMMED endpoints then interpolates across a quad whose
639
- * clip `w` went through zero, and the reconstruction is meaningless: probed
640
- * on the live Model stage with the fragment's own `|Δy|/|Δ|` written out as
641
- * grey, a 400 m segment read 0.027–0.045 down the whole visible line where
642
- * the true grazing sine runs 0.20–0.70 — i.e. pinned near the far endpoint's
643
- * value, and the axis faded flat to a tenth of its colour everywhere. Over a
644
- * 2 m segment the interpolation is exact wherever the segment is in front of
645
- * the camera, and the one segment that straddles the camera plane is 2 m of
646
- * line at the edge of the frame. (three's own answer to this is the
647
- * `worldUnits` path, which carries BOTH endpoints as constant varyings and
648
- * solves for the fragment — but that path also makes `linewidth` a world
649
- * measure, and this line's 2 device px is measured.)
650
- */
429
+ /** Build axis lines from short segments so the grazing fade interpolates positions across clipped screen-space quads. */
651
430
  function axisSegmentPositions(threeAxis: number): number[] {
652
431
  const positions: number[] = [];
653
432
  for (let i = 0; i < AXIS_SEGMENTS_PER_AXIS; i++) {
@@ -663,7 +442,7 @@ function axisSegmentPositions(threeAxis: number): number[] {
663
442
 
664
443
  /**
665
444
  * Give the floor AXES the same grazing fade the floor grid takes, through the
666
- * same 0..1 amount object. It starts at 0 — today's flat axis — and
445
+ * same 0..1 amount object. It starts at 0 (no fade), and
667
446
  * `_applyViewportLook` raises it for a look that paints the viewport.
668
447
  */
669
448
  function applyAxisGrazingFade(
@@ -698,6 +477,15 @@ const _gridQuat = new THREE.Quaternion();
698
477
  const _gridEye = new THREE.Vector3();
699
478
  const _gridUp = new THREE.Vector3(0, 1, 0);
700
479
 
480
+ /**
481
+ * The floor grid as a shader over one plane (`extent` metres square, y = 0).
482
+ * Lines are `fwidth`-antialiased so they keep one on-screen width at every
483
+ * distance; the 1 m level fades out where its cells fall under a few pixels
484
+ * (Blender's adaptive grid never lets lines merge), the 10 m level stays; the
485
+ * whole floor fades to its edge. `uColor` is the palette's grid colour
486
+ * (`_applyViewportLook`). Depth-tested, never written: content standing on
487
+ * the floor occludes it, and it occludes nothing.
488
+ */
701
489
  function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial> {
702
490
  const geometry = new THREE.PlaneGeometry(extent, extent);
703
491
  geometry.rotateX(-Math.PI / 2);
@@ -715,7 +503,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
715
503
  uFadeEnd: { value: extent / 2 },
716
504
  // The grazing fade's amount, 0..1 — set by `_applyViewportLook` from
717
505
  // the one predicate that already decides whether a look paints the
718
- // viewport at all. See {@link GRAZING_FADE_GLSL} for the measurement.
506
+ // viewport at all. {@link GRAZING_FADE_GLSL} computes its angle profile.
719
507
  uGrazingFade: { value: 0 },
720
508
  // The plane the grid lies in: the floor, or an axis-aligned orthographic view's own plane
721
509
  // (`EditorViewport.alignGridToView`). A unit axis.
@@ -756,12 +544,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
756
544
  uniform float uReach;
757
545
  varying vec3 vWorld;
758
546
  ${GRAZING_FADE_GLSL}
759
- // Coverage of a line WIDTH device pixels across, with a one-pixel
760
- // anti-aliased edge — so its core is FULL, the way Blender's is. The
761
- // earlier form (1 - distance/width) was a triangle with no plateau:
762
- // whether a line reached its own colour depended on where the nearest
763
- // fragment centre happened to fall, and the measured peak wandered
764
- // between 0.67 and 1 of it.
547
+ // Device-pixel line coverage with a full-strength core and a one-pixel antialiased edge.
765
548
  float gridLine(vec2 coord, float width) {
766
549
  vec2 d = max(fwidth(coord), vec2(1e-6));
767
550
  vec2 dist = abs(fract(coord - 0.5) - 0.5) / d;
@@ -791,12 +574,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
791
574
  float pixel = max(fwidth(world).x, fwidth(world).y);
792
575
  float bottomAlpha = uMinorFade * (1.0 - smoothstep(uUnit * 0.015625, uUnit * 0.25, pixel));
793
576
  float emphasis = top > 0.5 ? 1.0 : middle > 0.5 ? uMinorFade : 0.0;
794
- // The theme's grid colour carries alpha 0x80 and its emphasis colour none, and the
795
- // two colours here were fitted to Blender's PERSPECTIVE frames, where four additive
796
- // passes (1 + 1/2 + 1/4 + 1/8) take that alpha to about 0.94. Drawn once, as an
797
- // aligned view draws it, the grid colour keeps 0.502 / 0.941 of that. (Measured: the
798
- // 10 cm lines +5 over the ground at 198 px per metre, predicted +5.4; the 1 m lines
799
- // 89 against 88. The 0.94 is inferred from the passes, not read off a frame.)
577
+ // Normalize the theme grid alpha for a single aligned pass. Emphasis uses full opacity.
800
578
  float levelAlpha = (top > 0.5 || middle > 0.5 ? 1.0 : bottom * bottomAlpha) * mix(0.533, 1.0, emphasis);
801
579
  float edge = 1.0 - smoothstep(uFadeStart * uReach, uFadeEnd * uReach, length(world - uCenter));
802
580
  float alignedAlpha = levelAlpha * edge * uOpacity;
@@ -807,37 +585,17 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
807
585
  }
808
586
  vec2 pixelsPerMetre = 1.0 / max(fwidth(p), vec2(1e-6));
809
587
  float minorVisible = smoothstep(4.0, 14.0, min(pixelsPerMetre.x, pixelsPerMetre.y)) * uMinorFade;
810
- // Both levels reach FULL strength in their own colour, as Blender's
811
- // do: the 1 m line is measured at exactly the palette's grid colour
812
- // and the 10 m line brighter than it (see uMajorColor). The minor
813
- // level's only attenuation is its own drop-out as cells shrink.
588
+ // Fade minor lines as their cells approach pixel size.
814
589
  float minor = gridLine(p, uLineWidth) * minorVisible;
815
- // The 10 m level is WIDER as well as brighter, which is what lets it
816
- // reach full coverage — and so its full colour. Measured across one
817
- // scanline of modeling-object-none.png at device resolution: the 1 m
818
- // line is 4 px at half rise and plateaus at 83, the 10 m line is 6 px
819
- // and plateaus at 101. At one shared width ours drew a 10 m line that
820
- // never exceeded 0.75 coverage and so measured 91, not 102.
590
+ // Major lines use their own width and color.
821
591
  float major = gridLine(p / uMajorEvery, uMajorWidth);
822
592
  float line = max(minor, major);
823
593
  float fade = 1.0 - smoothstep(uFadeStart * uReach, uFadeEnd * uReach, length(world - uCenter));
824
- // The floor's DEPTH, beside the band that ends its finite extent:
825
- // Blender's own grazing-angle profile, transcribed from its frames
826
- // (see GRAZING_FADE_GLSL above). Without it this lattice reads the
827
- // same ink at the front of the frame and at the horizon.
594
+ // Apply the floor’s grazing-angle attenuation independently of its distance fade.
828
595
  fade *= volterGrazingFade(vWorld, cameraPosition, uPlaneNormal, uGrazingFade);
829
596
  float alpha = line * fade * uOpacity;
830
597
  if (alpha <= 0.002) discard;
831
- // WHICH level a fragment belongs to is the major's SHARE of the
832
- // coverage being drawn, never a comparison of the two and never the
833
- // major's raw coverage. Every 10 m line is also a 1 m line, so there
834
- // the two are equal: a step() between them compares values equal to
835
- // within rounding and flips per fragment (photographed with the major
836
- // forced white: the 10 m lines came out DOTTED), and mixing by the raw
837
- // coverage dilutes the colour by the line's own anti-aliasing (the
838
- // same lines then measured 91 where Blender's is 102). As a share it
839
- // is exactly 1 wherever the major draws — core and skirt alike, and in
840
- // the far field where the minor has dropped out — and 0 on a 1 m line.
598
+ // Mix colors by the major line’s share of coverage, including antialiased skirts.
841
599
  float majorShare = clamp(major / max(line, 1e-6), 0.0, 1.0);
842
600
  gl_FragColor = vec4(mix(uColor, uMajorColor, majorShare), alpha);
843
601
  #include <colorspace_fragment>
@@ -890,6 +648,8 @@ export class EditorViewport {
890
648
  readonly camera: THREE.PerspectiveCamera;
891
649
  readonly orthographicCamera: THREE.OrthographicCamera;
892
650
  readonly orbitControls: OrbitControls;
651
+ /** Fit clip planes only for the editor-owned camera; a live project camera retains its own projection. */
652
+ readonly projectionOwner = { owned: false };
893
653
  readonly transformControls: TransformControls;
894
654
  /** The rotate/scale halves of the 'combined' gizmo (Unity's Transform
895
655
  * tool). Constructed always, shown only in combined mode — three's
@@ -906,16 +666,14 @@ export class EditorViewport {
906
666
  })();
907
667
  /** The floor: a shader grid (`createFloorGrid`), not line geometry. */
908
668
  readonly grid: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>;
909
- /** Keyed `bounds:<id>` / `selection:<id>` — the Bounds diagnostic and a
910
- * non-renderable selection fallback can both be live for the same entity.
911
- * Nothing reads a key back; the map exists so a rebuild can dispose exactly
912
- * what it made. */
669
+ /** Keyed `bounds:<id>`, `selection:<id>` or `wire:<id>:<index>` for diagnostic boxes,
670
+ * selection brackets and mesh wires. A rebuild disposes every stored helper. */
913
671
  private _boxHelpers = new Map<string, BoundsHelper>();
914
- /** The view's selection marks (`kit/viewport-presentation` overlays.selection): the native
672
+ /** The view's selection marks (`@volter/sdk/viewport-presentation` overlays.selection): the native
915
673
  * outline is the stage's own switch; `box` brackets every selected object, not only those
916
674
  * without geometry (Godot); `wire` draws the selected meshes' wireframes (Unity). */
917
- private _selectionMarks = { outline: true, wire: false, box: false };
918
- private _unsubscribeSelectionTheme: () => void = () => {};
675
+ private _selectionMarks = { wire: false, box: false };
676
+ private _unsubscribeSelectionTheme: () => void;
919
677
  private _constraintHelpers = new Map<string, ConstraintHelper>();
920
678
  private _constraintControlRaycaster = (() => {
921
679
  const raycaster = new THREE.Raycaster();
@@ -929,7 +687,7 @@ export class EditorViewport {
929
687
  private _cameraHelpers = new Map<string, { source: THREE.Camera; helper: THREE.CameraHelper }>();
930
688
  private _lightHelpers = new Map<
931
689
  string,
932
- { source: THREE.Light; helper: THREE.Object3D & { update?(): void; dispose?(): void } }
690
+ { source: THREE.Light; helper: THREE.Object3D & { update(): void; dispose(): void } }
933
691
  >();
934
692
  private _audioHelpers = new Map<string, { source: THREE.Object3D; helper: THREE.Group }>();
935
693
  private _spatialHandleRoots: THREE.Object3D[] = [];
@@ -944,8 +702,8 @@ export class EditorViewport {
944
702
  private _spatialHandleDragPlane = new THREE.Plane();
945
703
  private _spatialHandleDragOffset = new THREE.Vector3();
946
704
  private _spatialHandleDragPoint = new THREE.Vector3();
947
- private _orbitEnabledBeforeHandleDrag = true;
948
- private _transformEnabledBeforeHandleDrag = true;
705
+ private _orbitEnabledBeforeHandleDrag!: boolean;
706
+ private _transformEnabledBeforeHandleDrag!: boolean;
949
707
  private _gizmoHelper: THREE.Object3D;
950
708
  private _editorAmbient: THREE.AmbientLight;
951
709
  private _editorDirLight: THREE.DirectionalLight;
@@ -953,53 +711,50 @@ export class EditorViewport {
953
711
  private readonly _lightRig: boolean;
954
712
 
955
713
  private _scene: THREE.Scene;
956
- /** World AABB of the active scene's non-editor content, cached because
957
- * measuring it walks the whole graph (2k+ nodes for an ingested game).
958
- * Empty until the first refresh; see {@link _contentBounds}. */
714
+ /** Cached world AABB of non-editor content; empty until the first _contentBounds refresh. */
959
715
  private readonly _contentBoundsBox = new THREE.Box3();
960
716
  /** `performance.now()` of the last content-bounds measurement; 0 invalidates
961
717
  * it (a scene swap), so the next reader re-measures immediately. */
962
718
  private _contentBoundsAt = 0;
963
- private _renderer: THREE.WebGLRenderer | undefined;
719
+ private readonly _renderer: THREE.WebGLRenderer | undefined;
964
720
  private _sparkRenderer: SparkRenderer | null = null;
965
721
  private _sparkRendererLoading = false;
966
722
  private _sparkRendererFailed = false;
967
723
  private _lastSparkDiscoveryAt = Number.NEGATIVE_INFINITY;
968
724
  private _disposed = false;
969
725
  /** Editor infrastructure objects that must move with the active scene. */
970
- private _editorObjects: THREE.Object3D[] = [];
726
+ private _editorObjects: THREE.Object3D[];
971
727
  /** The view presentation this stage is lit by, when bound (`bindPresentation`). */
972
728
  private _presentation: { readonly rig: StagePresentationRig; readonly stop: () => void } | null = null;
973
729
  /** The world's axis lines: WHICH show is the view's (`overlays.axes`, {@link setAxisLines}),
974
730
  * their colours and width the look's (`color.viewport.axisX/Y/Z`, `axisLineWidth`). */
975
- private _axisLines: LineSegments2 | null = null;
731
+ private _axisLines: LineSegments2 | null;
976
732
  /** The world's vertical axis line, a child of {@link _axisLines} (see `_rebuildAxisLines`). */
977
733
  private _verticalAxisLine: LineSegments2 | null = null;
978
734
  /** The look's axis-line width in device pixels (null: the editor's own), which an
979
735
  * axis-aligned view sets aside for one pixel. */
980
- private _axisLineWidth: number | null = null;
736
+ private _axisLineWidth!: number | null;
981
737
  /** A device-pixel width as `LineMaterial` takes it: CSS pixels, three keeping its viewport in
982
738
  * CSS units. The editor's own is 2 CSS. */
983
739
  private _axisLineCss(device: number | null): number {
984
740
  return device === null ? 2 : device / (this._renderer?.getPixelRatio() ?? 1);
985
741
  }
986
- private _axesWanted = false;
742
+ private _axesWanted!: boolean;
987
743
  private _stageAxes: ViewportOverlays['axes'] = 'floor';
988
744
  /** The look's axis colours by WORLD axis (X, Y, Z), each `null` for the gizmo's own. */
989
- private _lookAxisHexes: readonly [number | null, number | null, number | null] = [null, null, null];
745
+ private _lookAxisHexes!: readonly [number | null, number | null, number | null];
990
746
  /** The palette's grid hex, kept for the same reason the axis hexes are. */
991
- private _lookGridHex: number | null = null;
992
- /** The palette background this viewport paints (the palette's sRGB hex) and
993
- * the colour actually set for it under the renderer's tone mapping, so a
994
- * theme or pipeline change replaces OUR colour and never a host's own. */
747
+ private _lookGridHex!: number;
748
+ /** The palette's background hex, used to recognize the viewport's own background
749
+ * and preserve a host-supplied background. */
995
750
  private _lookBackgroundHex: number | null = null;
996
751
  private _lookToneMapping: THREE.ToneMapping | null = null;
997
- private _lookExposure: number | null = null;
752
+ private _lookExposure!: number;
998
753
  /** The active look's `stage.gizmoSize`, px per Blender gizmo unit
999
754
  * — null under every look that names none. Cached off the theme token
1000
755
  * rather than read per frame; {@link _applyGizmoSize} is what turns it into
1001
756
  * three's `size`. */
1002
- private _lookGizmoSizePx: number | null = null;
757
+ private _lookGizmoSizePx!: number | null;
1003
758
  /** The stage's world up axis (its presentation's `world.upAxis`, `setStageFunction`), as
1004
759
  * the table every gizmo label and colour reads (see {@link StageAxisFrame}). Defaults to
1005
760
  * three's own frame. */
@@ -1010,23 +765,8 @@ export class EditorViewport {
1010
765
  * makes a delta enough. */
1011
766
  private _appliedAxisSigns: readonly (1 | -1)[] = [1, 1, 1];
1012
767
  /** The look's gizmo colours and highlight ({@link nativeGizmoLook}); all-null keeps the
1013
- * editor's own (the kit's axis colours, three's yellow highlight, opaque handles). */
1014
- private _gizmoLook: NativeGizmoLook = {
1015
- axes: null,
1016
- navigation: null,
1017
- hover: null,
1018
- drag: null,
1019
- opacity: null,
1020
- arrowLength: null,
1021
- arrowHead: null,
1022
- ringWidth: null,
1023
- navigationSize: null,
1024
- navigationForm: 'balls',
1025
- background: null,
1026
- navigationCorner: 'top-right',
1027
- highlightSaturation: null,
1028
- highlightValue: null,
1029
- };
768
+ * editor's own axis colours, three's yellow highlight and each handle's native opacity. */
769
+ private _gizmoLook!: NativeGizmoLook;
1030
770
  /** The stage's box-select rule (its presentation's `interaction.boxSelect`) — `'touch'` is
1031
771
  * Blender's Select Box, `'contain'` (and null) the editor's own. Read by the marquee at the
1032
772
  * moment it resolves, never cached into the rectangle. */
@@ -1048,24 +788,19 @@ export class EditorViewport {
1048
788
  /** Camera input belongs to the whole viewport surface, including authoring
1049
789
  * overlays rendered above the WebGL canvas. */
1050
790
  private _interactionElement: HTMLElement;
1051
- /** The interaction-element pointerup relay (see the ctor's binding comment). */
1052
- private _onInteractionPointerUp: ((e: PointerEvent) => void) | undefined;
1053
- /** The interaction-element dblclick relay — same targeting story as the
1054
- * pointerup relay above (OrbitControls' pointer capture retargets the
1055
- * clicks a `dblclick` is synthesized from). */
1056
- private _onInteractionDblClick: ((e: MouseEvent) => void) | undefined;
791
+ /** Relay double-clicks from the element OrbitControls captures. */
792
+ private readonly _onInteractionDblClick: (e: MouseEvent) => void;
1057
793
  private readonly _authoring: () => AuthoringAdapter;
1058
794
  private readonly _pick: ((clientX: number, clientY: number) => string | null) | undefined;
1059
795
  private readonly _drawCamera: (() => THREE.Camera) | undefined;
1060
796
  /** The camera on screen: the stage's drawing camera where it states one, else the viewport's.
1061
797
  * Every screen test asks it (picks, the box select, vertex snap, handles, the ground plane);
1062
- * fly mode and the grid's own alignment keep the viewport's cameras, which they move. */
798
+ * fly mode moves the viewport's camera; grid alignment takes its caller's camera. */
1063
799
  private get _screenCamera(): THREE.Camera {
1064
800
  return this._drawCamera?.() ?? this.renderCamera;
1065
801
  }
1066
802
  private readonly _standaloneAuthoring: boolean;
1067
803
  private readonly _publishPickContext: boolean;
1068
- private readonly _onlineAssetResolver: OnlineAssetResolver | undefined;
1069
804
  private readonly _onProjectionChange: ((projection: ThreeViewportProjection) => void) | undefined;
1070
805
  private _pointerDownPos = new THREE.Vector2();
1071
806
  private _skipNextSync = false;
@@ -1075,14 +810,14 @@ export class EditorViewport {
1075
810
  /** True between a canvas pointerdown and its pointerup — see `_onPointerUp`. */
1076
811
  private _pointerSessionActive = false;
1077
812
  private _marqueeDiv: HTMLDivElement | null = null;
1078
- private _pointerDownButton = -1;
813
+ private _pointerDownButton!: number;
1079
814
  private _altDragOrbit = false;
1080
815
 
1081
816
  // -- Orientation gizmo --
1082
817
  private _vcScene = new THREE.Scene();
1083
818
  private _vcCamera: THREE.OrthographicCamera;
1084
819
  private _vcClickTargets: THREE.Mesh[] = [];
1085
- /** The gizmo's six balls and three stalks, kept for the per-frame depth
820
+ /** The gizmo's six balls and six stalks, kept for the per-frame depth
1086
821
  * cue (`_syncOrientationGizmoDepth`). */
1087
822
  private _vcBalls: Array<{
1088
823
  readonly fill: THREE.Sprite;
@@ -1100,7 +835,7 @@ export class EditorViewport {
1100
835
  readonly positive: boolean;
1101
836
  readonly color: THREE.Color;
1102
837
  }> = [];
1103
- /** The cone form's cones: drawn front to back, never dimmed (Unity's are not). */
838
+ /** Cone heads and triad lines, depth-sorted without dimming. */
1104
839
  private _vcSolids: Array<{ readonly mesh: THREE.Mesh; readonly direction: THREE.Vector3 }> = [];
1105
840
  private _vcSize = COMPASS_BOX_PX;
1106
841
  /** The view's navigation gizmo (`overlays.navigation`): clicked, only drawn, or neither. */
@@ -1112,54 +847,24 @@ export class EditorViewport {
1112
847
  private _vcMarginRight = COMPASS_MARGIN_RIGHT_PX;
1113
848
  private _vcMarginTop = COMPASS_MARGIN_TOP_PX;
1114
849
  /** See {@link EditorViewportOptions.chromeInsetPx}. */
1115
- private _chromeInsetPx = 0;
850
+ private readonly _chromeInsetPx: number;
1116
851
  /** Scratch dir vector reused each frame by renderViewCube (avoids per-frame alloc). */
1117
852
  private _vcDir = new THREE.Vector3();
1118
- /** Scratch color reused by renderCameraPreview (avoids per-frame alloc). */
1119
- private _previewClearColor = new THREE.Color();
1120
- /** Transient camera view/pilot subject. The native camera remains owned by
1121
- * the active adapter; this reference changes only viewport presentation. */
1122
- private _cameraView: THREE.Camera | null = null;
1123
853
  private _projection: ThreeViewportProjection = 'perspective';
1124
- private _pendingProjection: ThreeViewportProjection | null = null;
1125
- private _viewportAspect = 1;
854
+ private _viewportAspect: number;
1126
855
  /** The view's field of view (`ViewportCamera.fov`), held on its side as the region reshapes. */
1127
856
  private _fieldOfView: StageFieldOfView = KIT_FIELD_OF_VIEW;
1128
- private _orthographicHeight = 10;
1129
- private _cameraViewMode: CameraViewMode | null = null;
1130
- private _orbitEnabledBeforeCameraView = true;
1131
- private _orbitTargetBeforeCameraView = new THREE.Vector3();
1132
- /** Last objectMap + visibility signature applied by syncFromStore's gizmo/helper
1133
- * passes — lets those O(scene) loops skip when their inputs are unchanged (e.g.
1134
- * during a transform drag, which notifies per frame but changes none of them). */
857
+ private _orthographicHeight!: number;
858
+ /** Inputs last reconciled by syncFromStore's helper membership and visibility work. */
1135
859
  private _lastGizmoObjectMap: Map<string, THREE.Object3D> | null = null;
1136
- private _lastGizmoSig = '';
1137
- private _lastGizmoEpoch = -1;
1138
- /** Previous world-hidden eye state — edge detection for environment suppress/restore. */
1139
- /**
1140
- * Spec 28 — recomputed from surface visibility AND selection ownership on every
1141
- * `syncFromStore`; read by the per-frame render hooks (`renderViewCube`,
1142
- * `renderCameraPreview`) and the view-cube click path, which run between
1143
- * notifies and must not re-walk the composite 60×/s.
1144
- */
860
+ private _lastGizmoSig!: string;
861
+ private _lastGizmoEpoch!: number;
862
+ /** Selection ownership and surface visibility cached for helper, handle, gizmo and input consumers. */
1145
863
  private _threejsToolContextActive = false;
1146
864
  /** A three stage is painted here (selection or not) — the floor's and the compass's gate. */
1147
865
  private _threeSurfaceShowing = false;
1148
866
  private _vcRaycaster = new THREE.Raycaster();
1149
867
 
1150
- // -- Asset drop --
1151
- private _dropOverlay: HTMLDivElement | null = null;
1152
- /**
1153
- * The ground the viewport drops assets and probe points onto. NOT a constant:
1154
- * it is re-read from the ACTIVE region's declared basis
1155
- * (`presentation-surface.ts`'s `presentationRegionBasis`) on every raycast,
1156
- * because a Z-up
1157
- * game's ground is not `y = 0` and the editor may not guess which it is
1158
- * (ARCHITECTURE-CORE §The editor protocol, "Zero inference"). Mutated in
1159
- * place rather than reallocated — this sits on a drag path, not a frame path.
1160
- */
1161
- private _groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
1162
-
1163
868
  // -- Multi-drag: track primary object's transform at drag start --
1164
869
  private _dragStartPos = new THREE.Vector3();
1165
870
  private _dragStartQuat = new THREE.Quaternion();
@@ -1218,30 +923,28 @@ export class EditorViewport {
1218
923
  * debug mesh, … — scene-root objects toggled by the Helpers menu. */
1219
924
  private _helpers = new Map<string, THREE.Object3D>();
1220
925
 
1221
- // -- NavMesh path-probe visuals (W1a Navigation window) --
1222
-
1223
- // -- Snap-to-view animation --
926
+ // Snap-to-view animation.
1224
927
  private _snapAnimating = false;
1225
- private _snapStartTime = 0;
928
+ private _snapStartTime!: number;
1226
929
  private _snapDuration = 400;
1227
930
  private _snapQ1 = new THREE.Quaternion();
1228
931
  private _snapQ2 = new THREE.Quaternion();
1229
- private _snapDist = 0;
932
+ private _snapDist!: number;
1230
933
 
1231
934
  // -- Fly camera (right-click + WASD) --
1232
935
  private _flyActive = false;
1233
936
  private _snapHold = false;
1234
937
  private _flyKeys = new Set<string>();
1235
938
  private _flySpeed = 5;
1236
- private _flyEuler = new THREE.Euler(0, 0, 0, 'YXZ');
939
+ private _flyEuler = new THREE.Euler();
1237
940
  private _rightMouseDown = false;
1238
941
 
1239
942
  constructor(
1240
943
  canvas: HTMLCanvasElement,
1241
944
  scene: THREE.Scene,
1242
945
  store: EditorShellStore,
1243
- interactionElement: HTMLElement = canvas,
1244
- options: EditorViewportOptions = {},
946
+ interactionElement: HTMLElement,
947
+ options: EditorViewportOptions,
1245
948
  ) {
1246
949
  // The integration's registrations (the authoring policy this viewport asks, its verbs, the
1247
950
  // view-state persistence) exist before any viewport does; idempotent.
@@ -1258,7 +961,6 @@ export class EditorViewport {
1258
961
  this._standaloneAuthoring = options.authoring !== undefined;
1259
962
  this._publishPickContext = options.publishPickContext ?? true;
1260
963
  this._chromeInsetPx = options.chromeInsetPx ?? 0;
1261
- this._onlineAssetResolver = options.onlineAssetResolver;
1262
964
  this._onProjectionChange = options.onProjectionChange;
1263
965
 
1264
966
  // Camera — enableAll so editor sees both game content (layer 0) and editor objects (layer 31)
@@ -1283,63 +985,35 @@ export class EditorViewport {
1283
985
  this.orthographicCamera.layers.enableAll();
1284
986
  this._syncOrthographicFromPerspective();
1285
987
 
1286
- // Grid — with the standard distance fade (the dressing module owns the
1287
- // grid treatment), so it never ends on a hard rim or degenerates into
1288
- // convergence-line artifacts at the horizon.
1289
- // The floor is a SHADER grid, Blender's way: anti-aliased lines of a
1290
- // constant on-screen width (a line helper's one device pixel halved into
1291
- // the floor on every downsample — measured), a minor 1 m level that fades
1292
- // out where its cells get small so distance never merges it into a slab,
1293
- // a major 10 m level that stays, the palette's colour, and a fade to the
1294
- // floor's own edge so it never ends on a rim.
988
+ // Draw anti-aliased screen-width floor lines, adaptively fading fine levels while retaining the major grid and radial edge fade.
1295
989
  this.grid = createFloorGrid(400);
1296
990
  this.grid.layers.set(EDITOR_LAYER);
1297
991
  scene.add(this.grid);
1298
- // Axis lines — the world's, which ones the view says (`_rebuildAxisLines`) — as
1299
- // screen-space two-pixel lines: a one-pixel line halves into the floor
1300
- // when a frame is downsampled (measured), Blender's are two.
992
+ // Draw world axes as screen-space lines sized by the viewport look and projection.
1301
993
  const axisGeometry = new LineSegmentsGeometry();
1302
- axisGeometry.setPositions(axisSegmentPositions(0));
1303
- axisGeometry.setColors(new Array(AXIS_SEGMENTS_PER_AXIS * 2 * 3).fill(1));
1304
994
  this._axisLines = new LineSegments2(
1305
995
  axisGeometry,
1306
996
  new LineMaterial({
1307
997
  vertexColors: true,
1308
- linewidth: 2,
1309
- // Blended for draw order over the floor, but at FULL strength:
1310
- // Blender's axis plateaus at its flat theme colour (`#cb293f`,
1311
- // `#69aa15`, measured across the whole width of
1312
- // `modeling-object-none.png`), and an opacity here would make the
1313
- // stage's axis colour depend on whatever it happens to cross.
998
+ // Keep axis colours at full strength while blending their draw order over the floor.
1314
999
  transparent: true,
1315
1000
  opacity: 1,
1316
1001
  depthWrite: false,
1317
1002
  toneMapped: false,
1318
1003
  }),
1319
1004
  );
1320
- // The axes take the floor's own grazing fade, because in the reference
1321
- // they ARE the floor: Blender draws both out of one overlay shader and
1322
- // one `fade`, and its X axis measures 0.96 of its colour at the front of
1323
- // the frame against 0.26 near the horizon. `LineMaterial` interpolates
1324
- // per-segment endpoints with the clip `w` intact, so a world-position
1325
- // varying taken from the segment's own ends is perspective-correct along
1326
- // the line — which is all a 400 m two-segment cross needs.
1005
+ // Share the floor's grazing fade. LineMaterial interpolates the segment endpoints with
1006
+ // clip w intact, keeping the world-position varying perspective-correct along each axis.
1327
1007
  applyAxisGrazingFade(
1328
1008
  this._axisLines.material as LineMaterial,
1329
1009
  this.grid.material.uniforms['uGrazingFade'] as { value: number },
1330
1010
  this.grid.material.uniforms['uPlaneNormal'] as { value: THREE.Vector3 },
1331
1011
  );
1332
- this._axisLines.visible = false;
1333
1012
  this._axisLines.layers.set(EDITOR_LAYER);
1334
1013
  scene.add(this._axisLines);
1335
1014
 
1336
- // The palette's viewport look is applied THROUGH the grid and the axis
1337
- // lines, so this subscription cannot be made before they exist:
1338
- // `subscribeNativeSelectionTheme` calls its listener SYNCHRONOUSLY once
1339
- // (native-selection-style.ts), and from higher in this constructor that
1340
- // first call read `this.grid.geometry` off an undefined grid — the whole
1341
- // viewport construction threw, and every 3D document died with
1342
- // "Cannot read properties of undefined (reading 'geometry')".
1015
+ // Subscribe after constructing the grid and axis lines: subscribeNativeSelectionTheme
1016
+ // invokes its listener synchronously, and that listener applies their viewport look.
1343
1017
  this._unsubscribeSelectionTheme = subscribeNativeSelectionTheme(canvas, () => {
1344
1018
  invalidateStages();
1345
1019
  // The selection marks carry their look whole (colour, box form and frame, wire colour and
@@ -1361,14 +1035,13 @@ export class EditorViewport {
1361
1035
  this._lookGizmoSizePx = nativeViewportGizmoSize(canvas);
1362
1036
  this._applyGizmoSize();
1363
1037
  this._applyGizmoArrows();
1364
- // WHAT FRAME THIS STAGE IS PRESENTING, and what a box select means in
1365
- // it. Like the size above, the first synchronous call runs before the
1366
- // gizmos exist and both appliers answer that by returning.
1367
- this._readLookStage(canvas);
1038
+ // Refresh the gizmos for the current look and presentation frame.
1039
+ // The initial callback precedes transform-control construction.
1040
+ this._readLookStage();
1368
1041
  });
1369
1042
 
1370
1043
  // Editor lighting (on EDITOR_LAYER so game camera doesn't see them)
1371
- this._lightRig = options?.lightRig !== false;
1044
+ this._lightRig = options.lightRig !== false;
1372
1045
  this._editorAmbient = new THREE.AmbientLight(0xffffff, 0.5);
1373
1046
  this._editorAmbient.layers.set(EDITOR_LAYER);
1374
1047
  scene.add(this._editorAmbient);
@@ -1376,7 +1049,7 @@ export class EditorViewport {
1376
1049
  this._editorDirLight.position.set(10, 20, 10);
1377
1050
  this._editorDirLight.layers.set(EDITOR_LAYER);
1378
1051
  scene.add(this._editorDirLight);
1379
- // `_setScene` is where the rig's visibility is decided for every scene
1052
+ // `setScene` is where the rig's visibility is decided for every scene
1380
1053
  // AFTER this one; the first scene arrives here, so it is decided here too.
1381
1054
  this._editorAmbient.visible = this._lightRig;
1382
1055
  this._editorDirLight.visible = this._lightRig;
@@ -1386,15 +1059,7 @@ export class EditorViewport {
1386
1059
  this.batchedRenderer.layers.set(EDITOR_LAYER);
1387
1060
  scene.add(this.batchedRenderer);
1388
1061
 
1389
- // TRACKPAD CAMERA, before OrbitControls exists: listeners on one element
1390
- // run in registration order, so this must be first to win the wheel.
1391
- // A trackpad has no middle button (pan was unreachable) and its two-finger
1392
- // scroll arrived as plain zoom — a 20-minute human build session on one
1393
- // called moving the camera "nearly impossible" (runhuman pass 12,
1394
- // 2026-08-28). Semantics: pinch (ctrl+wheel) and pure-vertical wheel stay
1395
- // ZOOM (mouse wheels are pure-vertical, so mice are untouched); a wheel
1396
- // with a horizontal component — only trackpads produce one — or Shift
1397
- // held PANS in screen space, Figma-fashion.
1062
+ // Register trackpad navigation before OrbitControls so it owns the wheel: horizontal or Shift wheel pans in screen space.
1398
1063
  interactionElement.addEventListener('wheel', this._onTrackpadWheel, { passive: false });
1399
1064
 
1400
1065
  // OrbitControls: the editor's own mouse is right-drag to orbit, middle-drag to pan and the
@@ -1403,24 +1068,21 @@ export class EditorViewport {
1403
1068
  // (its mouse look) the browser refuses capture with an uncaught InvalidStateError on each
1404
1069
  // click. Capture is meaningless under a lock (every move already comes to the locked
1405
1070
  // element), so it is skipped then.
1406
- const capture = interactionElement.setPointerCapture.bind(interactionElement);
1071
+ // Look up capture on the element's prototype at each call so editor.document.click can
1072
+ // suppress capture while dispatching a synthetic pointer, which has no browser pointer id.
1073
+ const realm = (interactionElement.ownerDocument.defaultView ?? window) as Window & typeof globalThis;
1407
1074
  interactionElement.setPointerCapture = (pointerId: number) => {
1408
1075
  if (interactionElement.ownerDocument.pointerLockElement) return;
1409
- capture(pointerId);
1076
+ realm.Element.prototype.setPointerCapture.call(interactionElement, pointerId);
1410
1077
  };
1411
1078
  this.orbitControls = new OrbitControls(this.camera, interactionElement);
1412
1079
  this.orbitControls.enableDamping = true;
1413
1080
  this.orbitControls.dampingFactor = 0.1;
1414
1081
  this.applyKeymapNavigation();
1415
- this._unsubscribeKeymap = subscribeEditorKeymap(() => this.applyKeymapNavigation());
1082
+ this._unsubscribeKeymap = editorHost().keyboard.subscribe(() => this.applyKeymapNavigation());
1416
1083
  this._installTurntable();
1417
1084
 
1418
- // TransformControls — one instance per mode; 'combined' shows all three.
1419
- // Construction order IS pointer priority (each instance registers its own
1420
- // canvas listeners): translate, then SCALE, then rotate — the screen-space
1421
- // rotate ring's band can cross a scale cube's projected spot, and a
1422
- // pointer on that small explicit target means scale (measured: the ring
1423
- // stole the X-cube drag when rotate registered first).
1085
+ // Register translate, scale and rotate controls in pointer-priority order; combined mode shows all three.
1424
1086
  this.transformControls = new TransformControls(this.camera, canvas);
1425
1087
  this._auxScaleControls = new TransformControls(this.camera, canvas);
1426
1088
  this._auxScaleControls.setMode('scale');
@@ -1428,12 +1090,7 @@ export class EditorViewport {
1428
1090
  this._auxRotateControls.setMode('rotate');
1429
1091
  this._activeGizmo = this.transformControls;
1430
1092
  const wireGizmo = (gizmoControls: TransformControls): void => {
1431
- // THE DRAG PLANE AS THE HOVER LEFT IT. The plane a drag intersects (`TransformControlsPlane`)
1432
- // turns to the hovered axis only when the helper next updates its matrices, a render away,
1433
- // and `pointerDown` reads it as it stands; a press within a frame of its hover (a quick
1434
- // flick onto an arrow, a touch, whose hover runs inside the press) started on the plane of
1435
- // no axis, which faces the camera, and the object moved by that plane's foreshortened
1436
- // offset: measured on a fresh model, 1.24 m where the cursor asked for 2.78 m.
1093
+ // Refresh the hovered drag plane before pointerDown reads it, including a hover first established by a touch press.
1437
1094
  const pressed = gizmoControls.pointerDown.bind(gizmoControls);
1438
1095
  gizmoControls.pointerDown = (pointer) => {
1439
1096
  gizmoControls.getHelper().updateMatrixWorld(true);
@@ -1443,8 +1100,7 @@ export class EditorViewport {
1443
1100
  gizmoControls.getRaycaster().layers.enableAll();
1444
1101
  gizmoControls.addEventListener('dragging-changed', (event) => {
1445
1102
  const dragging = (event as unknown as { value: boolean }).value;
1446
- // The design session holds its remount SWAP while a drag runs
1447
- // (live-gesture-lock.ts) — a swap mid-drag snapped moved objects back.
1103
+ // Hold the design-session remount while a transform drag owns the live objects.
1448
1104
  if (dragging) beginLiveGesture();
1449
1105
  else endLiveGesture();
1450
1106
  this.orbitControls.enabled = !dragging;
@@ -1454,7 +1110,7 @@ export class EditorViewport {
1454
1110
  for (const other of this._allGizmos()) {
1455
1111
  if (other !== gizmoControls) other.enabled = false;
1456
1112
  }
1457
- const gizmoObj = gizmoControls.object;
1113
+ const gizmoObj = gizmoControls.object!;
1458
1114
  const channel =
1459
1115
  gizmoControls.mode === 'translate'
1460
1116
  ? 'position'
@@ -1464,63 +1120,61 @@ export class EditorViewport {
1464
1120
  const writable = (id: string): boolean =>
1465
1121
  this._authoring().transforms?.editability?.(id, channel).writable ?? true;
1466
1122
  const primaryDragId =
1467
- this._attachedAuthoringId ?? (gizmoObj && this._authoringIdForObject(gizmoObj)) ?? '';
1468
- if (gizmoObj) {
1469
- this._dragStartPos.copy(gizmoObj.position);
1470
- this._dragStartQuat.copy(gizmoObj.quaternion);
1471
- this._dragStartScale.copy(gizmoObj.scale);
1472
- this._otherDragObjects = [];
1473
- this._allDragObjects = [];
1474
- this._pivotPos.copy(gizmoObj.position);
1475
- const primaryId =
1476
- gizmoObj === this._pivotDummy ? null : this._authoringIdForObject(gizmoObj);
1477
- if (!primaryId) {
1478
- // Gizmo on pivot dummy (median-point mode or per-entity pivot)
1479
- for (const id of this._store.shell.selectedEntityIds) {
1480
- if (!writable(id)) continue;
1481
- const obj = this._objectForAuthoringId(id);
1482
- if (!obj) continue;
1483
- this._allDragObjects.push({
1484
- id,
1485
- obj,
1486
- startPos: obj.position.clone(),
1487
- startQuat: obj.quaternion.clone(),
1488
- startScale: obj.scale.clone(),
1489
- });
1490
- }
1491
- } else {
1492
- for (const id of this._store.shell.selectedEntityIds) {
1493
- if (id === primaryDragId) continue;
1494
- if (!writable(id)) continue;
1495
- const obj = this._objectForAuthoringId(id);
1496
- if (!obj) continue;
1497
- this._otherDragObjects.push({
1498
- id,
1499
- obj,
1500
- startPos: obj.position.clone(),
1501
- startQuat: obj.quaternion.clone(),
1502
- startScale: obj.scale.clone(),
1503
- });
1504
- }
1123
+ this._attachedAuthoringId ?? this._authoringIdForObject(gizmoObj) ?? '';
1124
+ this._dragStartPos.copy(gizmoObj.position);
1125
+ this._dragStartQuat.copy(gizmoObj.quaternion);
1126
+ this._dragStartScale.copy(gizmoObj.scale);
1127
+ this._otherDragObjects = [];
1128
+ this._allDragObjects = [];
1129
+ this._pivotPos.copy(gizmoObj.position);
1130
+ const primaryId =
1131
+ gizmoObj === this._pivotDummy ? null : this._authoringIdForObject(gizmoObj);
1132
+ if (!primaryId) {
1133
+ // Without a semantic primary ID, derive drag subjects from the writable selection.
1134
+ for (const id of this._store.shell.selectedEntityIds) {
1135
+ if (!writable(id)) continue;
1136
+ const obj = this._objectForAuthoringId(id);
1137
+ if (!obj) continue;
1138
+ this._allDragObjects.push({
1139
+ id,
1140
+ obj,
1141
+ startPos: obj.position.clone(),
1142
+ startQuat: obj.quaternion.clone(),
1143
+ startScale: obj.scale.clone(),
1144
+ });
1145
+ }
1146
+ } else {
1147
+ for (const id of this._store.shell.selectedEntityIds) {
1148
+ if (id === primaryDragId) continue;
1149
+ if (!writable(id)) continue;
1150
+ const obj = this._objectForAuthoringId(id);
1151
+ if (!obj) continue;
1152
+ this._otherDragObjects.push({
1153
+ id,
1154
+ obj,
1155
+ startPos: obj.position.clone(),
1156
+ startQuat: obj.quaternion.clone(),
1157
+ startScale: obj.scale.clone(),
1158
+ });
1505
1159
  }
1506
- const editIds =
1507
- gizmoObj === this._pivotDummy
1508
- ? this._allDragObjects.map(({ id }) => id)
1509
- : [primaryDragId, ...this._otherDragObjects.map(({ id }) => id)];
1510
- this._preservedChildObjects = this._preparePreservedChildren(editIds.filter(Boolean));
1511
- this._activeTransformEditIds = [
1512
- ...new Set([
1513
- ...editIds.filter(Boolean),
1514
- ...this._preservedChildObjects.map(({ id }) => id),
1515
- ]),
1516
- ];
1517
- // Begin every semantic source edit before TransformControls mutates
1518
- // any native object. Source-backed adapters need the pre-drag pose of
1519
- // secondary selections too; physics-backed adapters freeze each body.
1520
- const authoring = this._authoring();
1521
- for (const id of this._activeTransformEditIds)
1522
- beginAuthoringTransformEdit(authoring, id);
1523
1160
  }
1161
+ const editIds =
1162
+ gizmoObj === this._pivotDummy
1163
+ ? this._allDragObjects.map(({ id }) => id)
1164
+ : [primaryDragId, ...this._otherDragObjects.map(({ id }) => id)];
1165
+ this._preservedChildObjects = this._preparePreservedChildren(editIds.filter(Boolean));
1166
+ this._activeTransformEditIds = [
1167
+ ...new Set([
1168
+ ...editIds.filter(Boolean),
1169
+ ...this._preservedChildObjects.map(({ id }) => id),
1170
+ ]),
1171
+ ];
1172
+ // Begin every semantic source edit before TransformControls mutates
1173
+ // any native object. Source-backed adapters need the pre-drag pose of
1174
+ // secondary selections too; physics-backed adapters freeze each body.
1175
+ const authoring = this._authoring();
1176
+ for (const id of this._activeTransformEditIds)
1177
+ beginAuthoringTransformEdit(authoring, id);
1524
1178
  } else {
1525
1179
  // Hide snap indicators when drag ends
1526
1180
  this._surfaceSnapIndicator.visible = false;
@@ -1530,8 +1184,7 @@ export class EditorViewport {
1530
1184
  }
1531
1185
  });
1532
1186
  gizmoControls.addEventListener('objectChange', () => {
1533
- const gizmoObj = gizmoControls.object;
1534
- if (!gizmoObj) return;
1187
+ const gizmoObj = gizmoControls.object!;
1535
1188
  const mode = gizmoControls.mode;
1536
1189
  // A CENTRE-anchored gizmo takes the individual-origins math on purpose:
1537
1190
  // its anchor is where the handles are DRAWN, not a pivot the edit is
@@ -1599,9 +1252,8 @@ export class EditorViewport {
1599
1252
  }
1600
1253
  this._restorePreservedChildren();
1601
1254
 
1602
- // Keeps bounds visuals glued to the entity mid-drag. Both members of the
1603
- // union expose `update()`; the diagnostic's degenerate wire cube (a bare
1604
- // LineSegments) does not, hence the guard.
1255
+ // Bounds helpers and selection brackets update mid-drag; mesh wires and
1256
+ // diagnostic wire cubes are LineSegments without an update method.
1605
1257
  for (const helper of this._boxHelpers.values()) {
1606
1258
  if ('update' in helper) helper.update();
1607
1259
  }
@@ -1650,9 +1302,7 @@ export class EditorViewport {
1650
1302
  this._vertexSnapIndicator.layers.set(EDITOR_LAYER);
1651
1303
  scene.add(this._vertexSnapIndicator);
1652
1304
 
1653
- // Patch gizmo: recolour handles to the look's axis colours and fix plane geometry.
1654
- this._gizmoLook = nativeGizmoLook(canvas);
1655
- this._rebuildAxisLines();
1305
+ // Install handle highlights and align the gizmo geometry.
1656
1306
  for (const controls of this._allGizmos()) this._installGizmoHighlight(controls);
1657
1307
  this._patchGizmo(this._gizmoHelper);
1658
1308
  this._patchGizmo(this._auxRotateControls.getHelper());
@@ -1661,22 +1311,14 @@ export class EditorViewport {
1661
1311
  this._dropNegativeAxisHandles(this._auxScaleControls.getHelper(), ['scale']);
1662
1312
  this._patchCombinedScaleGizmo(this._auxScaleControls.getHelper());
1663
1313
  this._applyGizmoArrows();
1664
- // The look's gizmo size, now that there are controls to set it on: the
1665
- // theme subscription above installed before they existed and its first
1666
- // synchronous call found none (see {@link _applyGizmoSize}).
1667
- this._lookGizmoSizePx = nativeViewportGizmoSize(canvas);
1314
+ // Apply the look’s gizmo size once the controls exist; the theme subscription may run before
1315
+ // their construction.
1668
1316
  this._applyGizmoSize();
1669
- // The look's AXIS FRAME, after the three patches above have run: the
1670
- // mirror is a difference over the geometry they produced, so it is the
1671
- // last word on where a handle is drawn.
1672
- // The stage's AXIS FRAME (its world's up axis, `setStageFunction`), after the three patches
1673
- // above have run: the mirror is a difference over the geometry they produced, so it is the
1674
- // last word on where a handle is drawn.
1317
+ // Apply the stage's axis frame after constructing and patching its gizmo geometry.
1675
1318
  this._applyGizmoAxisFrame();
1676
1319
 
1677
- // Orientation gizmo (axis arrows overlay) — built in that same frame.
1320
+ // The theme subscription builds the orientation gizmo; this camera draws it.
1678
1321
  this._vcCamera = new THREE.OrthographicCamera(-1.5, 1.5, 1.5, -1.5, 0.1, 10);
1679
- this._initOrientationGizmo();
1680
1322
 
1681
1323
  // -- Fly camera input (right-click + WASD) --
1682
1324
  window.addEventListener('keydown', this._onFlyKeyDown);
@@ -1688,58 +1330,22 @@ export class EditorViewport {
1688
1330
 
1689
1331
  // Alt+Left-drag = orbit (Unity-style), via capture phase so it fires before OrbitControls
1690
1332
  canvas.addEventListener('pointerdown', this._onAltOrbitStart, { capture: true });
1691
- // The restore listens on WINDOW: an Alt+drag released outside the canvas
1692
- // never fired the element-scoped pointerup, so LEFT stayed ROTATE — every
1693
- // later left-drag orbited the camera and the marquee never drew (a human
1694
- // tester lost rectangle-select entirely: "it's like a playing view" —
1695
- // runhuman pass 27). `pointercancel` covers an interrupted drag the same
1696
- // way. Registered in _onAltOrbitRelease so dispose can remove them.
1333
+ // Listen on window so an Alt+drag released outside the canvas restores the controls.
1334
+ // pointercancel handles interrupted drags; dispose removes both listeners.
1697
1335
  window.addEventListener('pointerup', this._onAltOrbitRelease, { capture: true });
1698
1336
  window.addEventListener('pointercancel', this._onAltOrbitRelease, { capture: true });
1337
+ // A handle or constraint-control drag whose release never arrives (the pointer cancelled, the
1338
+ // window lost focus mid-drag) ends here: its flags would otherwise keep `interacting()` true
1339
+ // and the stage redrawing every frame (`StageHost`).
1340
+ window.addEventListener('pointercancel', this._onInteractionDragLost, { capture: true });
1341
+ window.addEventListener('blur', this._onInteractionDragLost);
1699
1342
  window.addEventListener('keydown', this._onSnapHoldKey);
1700
1343
  window.addEventListener('keyup', this._onSnapHoldKey);
1701
1344
 
1702
- // Click to select + marquee. The hazard this binding split defends against
1703
- // is only reproducible with REAL pointer input — synthetic `dispatchEvent`
1704
- // still works, so unit coverage cannot see it:
1705
- // OrbitControls (constructed over `interactionElement` above) calls
1706
- // `setPointerCapture` on its domElement on EVERY pointerdown — including
1707
- // the disabled LEFT button — so all subsequent pointermove/pointerup are
1708
- // RETARGETED to the interaction element and canvas-bound listeners
1709
- // silently die. Because the interaction element is NOT the canvas
1710
- // (right-drag-over-overlays orbit needs it off the canvas), canvas-bound
1711
- // move/up alone would kill click-select, marquee, and the alt-orbit
1712
- // release above.
1713
- //
1714
- // The combined mechanism:
1715
- // - `pointerdown` binds to the CANVAS: a down anywhere else is not a
1716
- // viewport gesture. It opens `_pointerSessionActive`, and the session
1717
- // guards in `_onPointerUp` AND `_onPointerMove` keep gestures that
1718
- // started on container chrome (toolbar buttons, overlay children)
1719
- // from running selection/marquee against a stale down-pos.
1720
- // - `pointermove` binds to the INTERACTION ELEMENT so marquee updates
1721
- // keep firing mid-drag: once OrbitControls owns the capture, a
1722
- // canvas-bound move never fires. (The DOM `RootSelectionOverlay` has
1723
- // its own marquee via `world-overlay-gestures`, but the viewport's
1724
- // must work bare too.)
1725
- // - `pointerup` binds to the interaction element via the stored
1726
- // `_onInteractionPointerUp` wrapper (removed in `dispose`). Its
1727
- // target guard keeps overlay-OWNED ups (the overlay stopPropagation's
1728
- // its own pointerdowns before OrbitControls can capture, so their ups
1729
- // land on overlay children) routed exclusively through the overlay's
1730
- // own selection logic. The `_pointerSessionActive` disjunct still
1731
- // admits a CANVAS-origin gesture whose down was never captured
1732
- // (handle drags stopPropagation the down too) and whose up therefore
1733
- // lands on overlay chrome — dropping those would leave the
1734
- // session/handle-drag flags stuck; `_onPointerUp`'s own guards make
1735
- // such ups end the drag, never select.
1345
+ // Bind pointer selection to the interaction element that OrbitControls captures, and handle pointerup there so real drags release their session flags.
1736
1346
  canvas.addEventListener('pointerdown', this._onPointerDown);
1737
1347
  interactionElement.addEventListener('pointermove', this._onPointerMove);
1738
- this._onInteractionPointerUp = (e: PointerEvent): void => {
1739
- if (e.target === canvas || e.target === interactionElement || this._pointerSessionActive)
1740
- this._onPointerUp(e);
1741
- };
1742
- interactionElement.addEventListener('pointerup', this._onInteractionPointerUp);
1348
+ interactionElement.addEventListener('pointerup', this._onPointerUp);
1743
1349
 
1744
1350
  // Double-click = DRILL IN (see `_onDoubleClick`). Bound on the interaction
1745
1351
  // element for the same reason `pointerup` is: OrbitControls captures the
@@ -1751,15 +1357,10 @@ export class EditorViewport {
1751
1357
  };
1752
1358
  interactionElement.addEventListener('dblclick', this._onInteractionDblClick);
1753
1359
 
1754
- // Asset drag-and-drop onto viewport
1755
- canvas.addEventListener('dragover', this._onDragOver);
1756
- canvas.addEventListener('dragleave', this._onDragLeave);
1757
- canvas.addEventListener('drop', this._onDrop);
1758
-
1759
1360
  // Collect editor infrastructure objects so they can be moved between scenes
1760
1361
  this._editorObjects = [
1761
1362
  this.grid,
1762
- ...(this._axisLines ? [this._axisLines] : []),
1363
+ this._axisLines,
1763
1364
  this._editorAmbient,
1764
1365
  this._editorDirLight,
1765
1366
  this.batchedRenderer,
@@ -1771,11 +1372,9 @@ export class EditorViewport {
1771
1372
  this._vertexSnapIndicator,
1772
1373
  ];
1773
1374
 
1774
- // D12 (B4) — publish the camera/canvas seam `VolterSceneAuthoringAdapter.pickable`
1775
- // needs (it has no other way to reach them). Cleared in `dispose()`.
1776
- if (this._publishPickContext) {
1777
- this._installPickContext(this.camera);
1778
- }
1375
+ // Publish this viewport's camera, canvas and editor controls for adapter raycasting.
1376
+ // Cleared in dispose().
1377
+ this._installPickContext(this.camera);
1779
1378
  }
1780
1379
 
1781
1380
  /** Swap the scene the viewport renders. Moves editor infrastructure objects to the new scene. */
@@ -1813,14 +1412,8 @@ export class EditorViewport {
1813
1412
  }
1814
1413
  this._scene = newScene;
1815
1414
  this._contentBoundsAt = 0; // different content → re-measure before the next draw
1816
- // The light rig is a design-time visibility aid for editor-owned scenes.
1817
- // An ADOPTED live game scene (play adoption, ingest, the R3F design
1818
- // session) owns its lighting completely — the editor camera sees
1819
- // EDITOR_LAYER lights, so the rig would add a white ambient+directional
1820
- // the game camera never sees, and the mismatch pops as a color/brightness
1821
- // shift the instant play takes over the draw. WYSIWYG for adopted scenes.
1822
- // A stage bound to a view presentation is lit by it instead (`bindPresentation`).
1823
- const rigOn = this._lightRig && !this._store.isAdoptedSceneActive && this._presentation === null;
1415
+ // Use the editor rig only when enabled and no view presentation supplies the lighting.
1416
+ const rigOn = this._lightRig && this._presentation === null;
1824
1417
  this._editorAmbient.visible = rigOn;
1825
1418
  this._editorDirLight.visible = rigOn;
1826
1419
  }
@@ -1829,11 +1422,9 @@ export class EditorViewport {
1829
1422
  return this._scene;
1830
1423
  }
1831
1424
 
1832
- /** Camera that currently paints the Scene document. Ordinarily this is the
1833
- * free editor camera; view-through/pilot temporarily returns the authored
1834
- * native camera without changing its ownership. */
1425
+ /** Camera that paints the Scene document: the free editor camera. */
1835
1426
  get renderCamera(): THREE.Camera {
1836
- return this._cameraView ?? this.freeCamera;
1427
+ return this.freeCamera;
1837
1428
  }
1838
1429
 
1839
1430
  get freeCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
@@ -1859,10 +1450,6 @@ export class EditorViewport {
1859
1450
  }
1860
1451
 
1861
1452
  setProjection(projection: ThreeViewportProjection): void {
1862
- if (this._cameraView) {
1863
- this._pendingProjection = projection;
1864
- return;
1865
- }
1866
1453
  if (this._projection === projection) return;
1867
1454
  if (projection === 'orthographic') this._syncOrthographicFromPerspective();
1868
1455
  else this._syncPerspectiveFromOrthographic();
@@ -1936,7 +1523,7 @@ export class EditorViewport {
1936
1523
  * the right button then does nothing here, as in Blender, where it is the context menu's.
1937
1524
  */
1938
1525
  private applyKeymapNavigation(): void {
1939
- const { orbit, turntable } = activeKeymapNavigation();
1526
+ const { orbit, turntable } = editorHost().keyboard.navigation();
1940
1527
  // Only a turntable keeps a roll: under any other orbit the view comes back level (a Top
1941
1528
  // view's screen up, which is no roll, stands).
1942
1529
  if (!turntable && this.camera.up.y < 0.9999 && Math.abs(this.camera.up.z) < 0.9999) this.camera.up.set(0, 1, 0);
@@ -1947,7 +1534,7 @@ export class EditorViewport {
1947
1534
  RIGHT: orbit === 'middle' ? (-1 as THREE.MOUSE) : THREE.MOUSE.ROTATE,
1948
1535
  };
1949
1536
  }
1950
- private _unsubscribeKeymap: () => void = () => {};
1537
+ private _unsubscribeKeymap: () => void;
1951
1538
 
1952
1539
  /**
1953
1540
  * A KEYMAP'S TURNTABLE (`KeymapNavigation.turntable`) in place of three's spherical orbit, on
@@ -1977,17 +1564,17 @@ export class EditorViewport {
1977
1564
  controls._handleMouseDownDolly = (event) => {
1978
1565
  dollyDown(event);
1979
1566
  dolly = null;
1980
- if (activeKeymapNavigation().zoom?.drag !== 'dolly') return;
1567
+ if (editorHost().keyboard.navigation().zoom?.drag !== 'dolly') return;
1981
1568
  const top = this._canvas.getBoundingClientRect().top;
1982
1569
  const camera = this.orbitControls.object;
1983
1570
  dolly = {
1984
1571
  len0: Math.max(5 + event.clientY - top, 1),
1985
1572
  offset: camera.position.clone().sub(this.orbitControls.target),
1986
- zoom0: (camera as THREE.OrthographicCamera).zoom ?? 1,
1573
+ zoom0: (camera as THREE.PerspectiveCamera | THREE.OrthographicCamera).zoom,
1987
1574
  };
1988
1575
  };
1989
1576
  controls._handleMouseMoveDolly = (event) => {
1990
- if (activeKeymapNavigation().zoom?.drag !== 'dolly' || !dolly) {
1577
+ if (editorHost().keyboard.navigation().zoom?.drag !== 'dolly' || !dolly) {
1991
1578
  dollyMove(event);
1992
1579
  return;
1993
1580
  }
@@ -2017,13 +1604,13 @@ export class EditorViewport {
2017
1604
  down(event);
2018
1605
  drag = null;
2019
1606
  for (const listener of [...this._rotateStartListeners]) listener();
2020
- if (!activeKeymapNavigation().turntable) return;
1607
+ if (!editorHost().keyboard.navigation().turntable) return;
2021
1608
  const camera = this.orbitControls.object;
2022
1609
  const viewUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
2023
1610
  drag = { x: event.clientX, y: event.clientY, reverse: viewUp.y < 0 ? -1 : 1 };
2024
1611
  };
2025
1612
  controls._handleMouseMoveRotate = (event) => {
2026
- const turntable = activeKeymapNavigation().turntable;
1613
+ const turntable = editorHost().keyboard.navigation().turntable;
2027
1614
  if (!turntable || !drag) {
2028
1615
  move(event);
2029
1616
  return;
@@ -2040,7 +1627,7 @@ export class EditorViewport {
2040
1627
  // Auto Perspective on a stage that draws with the viewport's own projection (a document's
2041
1628
  // session keeps its own and answers for it).
2042
1629
  () => {
2043
- if (!activeKeymapNavigation().autoPerspective || this._projection !== 'orthographic') return;
1630
+ if (!editorHost().keyboard.navigation().autoPerspective || this._projection !== 'orthographic') return;
2044
1631
  const offset = this.camera.position.clone().sub(this.orbitControls.target);
2045
1632
  const up = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
2046
1633
  if (axisViewName(offset, up) !== null) this.setProjection('perspective');
@@ -2054,7 +1641,7 @@ export class EditorViewport {
2054
1641
  */
2055
1642
  private readonly _axisViewListeners: (() => boolean)[] = [
2056
1643
  () => {
2057
- if (activeKeymapNavigation().autoPerspective) this.setProjection('orthographic');
1644
+ if (editorHost().keyboard.navigation().autoPerspective) this.setProjection('orthographic');
2058
1645
  return true;
2059
1646
  },
2060
1647
  ];
@@ -2155,7 +1742,7 @@ export class EditorViewport {
2155
1742
  * orbit's pivot, as a whole gesture (a camera lock writes it).
2156
1743
  */
2157
1744
  zoomStep(direction: 1 | -1): void {
2158
- const step = activeKeymapNavigation().zoom?.step ?? 1.2;
1745
+ const step = editorHost().keyboard.navigation().zoom?.step ?? 1.2;
2159
1746
  const factor = direction > 0 ? 1 / step : step;
2160
1747
  const camera = this.orbitControls.object;
2161
1748
  this.orbitControls.dispatchEvent({ type: 'start' });
@@ -2202,54 +1789,6 @@ export class EditorViewport {
2202
1789
  this.orbitControls.update();
2203
1790
  }
2204
1791
 
2205
- get cameraViewMode(): CameraViewMode | null {
2206
- return this._cameraViewMode;
2207
- }
2208
-
2209
- /** Enter an exact native camera view. Pilot additionally hands OrbitControls
2210
- * the authored camera; the caller owns begin/apply/end persistence. */
2211
- setCameraView(camera: THREE.Camera, mode: CameraViewMode): void {
2212
- this.clearCameraView();
2213
- this._cameraView = camera;
2214
- this._cameraViewMode = mode;
2215
- this._orbitEnabledBeforeCameraView = this.orbitControls.enabled;
2216
- this._orbitTargetBeforeCameraView.copy(this.orbitControls.target);
2217
- if (mode === 'pilot') {
2218
- const position = camera.getWorldPosition(new THREE.Vector3());
2219
- const forward = camera.getWorldDirection(new THREE.Vector3());
2220
- const distance = Math.max(1, this.freeCamera.position.distanceTo(this.orbitControls.target));
2221
- this.orbitControls.object = camera;
2222
- this.orbitControls.target.copy(position).add(forward.multiplyScalar(distance));
2223
- this.orbitControls.enabled = true;
2224
- this.orbitControls.update();
2225
- } else {
2226
- this.orbitControls.enabled = false;
2227
- }
2228
- if (this._publishPickContext) {
2229
- this._installPickContext(camera);
2230
- }
2231
- }
2232
-
2233
- /** Restore the free editor camera exactly where it was before view-through. */
2234
- clearCameraView(): void {
2235
- if (!this._cameraView) return;
2236
- if (this._flyActive) this._exitFlyMode();
2237
- this.orbitControls.object = this.freeCamera;
2238
- this.orbitControls.target.copy(this._orbitTargetBeforeCameraView);
2239
- this.orbitControls.enabled = this._orbitEnabledBeforeCameraView;
2240
- this.orbitControls.update();
2241
- this._cameraView = null;
2242
- this._cameraViewMode = null;
2243
- const pendingProjection = this._pendingProjection;
2244
- this._pendingProjection = null;
2245
- if (pendingProjection && pendingProjection !== this._projection) {
2246
- this.setProjection(pendingProjection);
2247
- }
2248
- if (this._publishPickContext) {
2249
- this._installPickContext(this.freeCamera);
2250
- }
2251
- }
2252
-
2253
1792
  get objectMap(): Map<string, THREE.Object3D> {
2254
1793
  return this._objectMap;
2255
1794
  }
@@ -2286,7 +1825,7 @@ export class EditorViewport {
2286
1825
  }
2287
1826
 
2288
1827
  private _authoringIdForObject(object: THREE.Object3D): string | null {
2289
- return this._authoring().hierarchy.idForObject3D?.(object) ?? entityIdOf(object) ?? null;
1828
+ return this._authoring().hierarchy.idForNativeObject?.(object) ?? entityIdOf(object) ?? null;
2290
1829
  }
2291
1830
 
2292
1831
  /**
@@ -2315,22 +1854,20 @@ export class EditorViewport {
2315
1854
  color: new THREE.Color(nativeSelectionColors(this._canvas).visible),
2316
1855
  toneMapped: false,
2317
1856
  });
2318
- const wire = new THREE.LineSegments(edges, mat) as unknown as TaggedHelper;
1857
+ const wire = new THREE.LineSegments(edges, mat) as THREE.LineSegments & TaggedHelper;
2319
1858
  obj.getWorldPosition(wire.position);
2320
- // Tag so we can update/dispose like BoxHelper
2321
- wire.isBoxHelper = true;
1859
+ // Retain the source so the wire cube follows its world position each frame.
2322
1860
  wire._entityObj = obj;
2323
1861
  return wire;
2324
1862
  }
2325
1863
 
2326
1864
  /**
2327
- * Rebuild the bounds visuals: the Bounds DIAGNOSTIC (plain full boxes over
2328
- * every entity, a density view) and the non-renderable SELECTION fallback
2329
- * (corner brackets — see `selection-brackets.ts`). Renderable selections are
2330
- * painted by the native postprocessing silhouette instead.
1865
+ * Rebuild diagnostic bounds and the presentation's selection wires/brackets.
1866
+ * Non-renderable selections use brackets unless their document draws its own overlay;
1867
+ * geometric selections can use wires, boxes and the native silhouette.
2331
1868
  */
2332
1869
  private _syncBoxHelpers(): void {
2333
- // Remove old + dispose geometry/materials on rebuild (LK6).
1870
+ // Dispose the previous geometry and materials before rebuilding.
2334
1871
  for (const helper of this._boxHelpers.values()) {
2335
1872
  this._scene.remove(helper);
2336
1873
  helper.geometry.dispose();
@@ -2362,7 +1899,7 @@ export class EditorViewport {
2362
1899
  let index = 0;
2363
1900
  obj.traverse((child) => {
2364
1901
  const mesh = child as THREE.Mesh;
2365
- if (!mesh.isMesh || !mesh.geometry || isInEditorOwnedSubtree(mesh)) return;
1902
+ if (!mesh.isMesh || isInEditorOwnedSubtree(mesh)) return;
2366
1903
  const look = nativeViewportWire(this._canvas);
2367
1904
  const wire = new THREE.LineSegments(
2368
1905
  new THREE.WireframeGeometry(mesh.geometry),
@@ -2399,7 +1936,7 @@ export class EditorViewport {
2399
1936
  if (this._standaloneAuthoring && (obj as THREE.Bone).isBone) continue;
2400
1937
  // Constraint owners, targets and poles already have a semantic native
2401
1938
  // viewport presentation (lines plus a target/pole handle). Drawing the
2402
- // legacy empty-transform corner brackets over that presentation creates
1939
+ // empty-transform corner brackets over that presentation creates
2403
1940
  // two competing selection languages and obscures the draggable handle.
2404
1941
  if ([...this._constraintHelpers.values()].some((helper) => helper.presents(obj))) continue;
2405
1942
  // Nor over an object whose document draws its overlay itself, selection colour included
@@ -2420,12 +1957,12 @@ export class EditorViewport {
2420
1957
  * The source component stays an empty, ordinary transform node; these
2421
1958
  * helpers live on EDITOR_LAYER and therefore never enter Game view.
2422
1959
  */
2423
- private _syncReflectionProbeHelpers(changedIds: ReadonlySet<string> | null = null): void {
2424
- if (changedIds === null) {
1960
+ private _syncReflectionProbeHelpers(reconcileMembership: boolean): void {
1961
+ if (reconcileMembership) {
2425
1962
  const current = new Map<string, THREE.Object3D>();
2426
1963
  const admitted = new Set<THREE.Object3D>();
2427
1964
  for (const [id, object] of this._objectMap) {
2428
- if (!object || admitted.has(object) || !reflectionProbeOf(object)) continue;
1965
+ if (admitted.has(object) || !reflectionProbeOf(object)) continue;
2429
1966
  admitted.add(object);
2430
1967
  current.set(id, object);
2431
1968
  }
@@ -2435,28 +1972,6 @@ export class EditorViewport {
2435
1972
  this._reflectionProbeHelpers.delete(id);
2436
1973
  }
2437
1974
  for (const [id, source] of current) this._ensureReflectionProbeHelper(id, source);
2438
- } else {
2439
- let requiresFullReconcile = false;
2440
- for (const id of changedIds) {
2441
- const source = this._objectMap.get(id);
2442
- const next = source && reflectionProbeOf(source) ? source : null;
2443
- const previous = this._reflectionProbeHelpers.get(id);
2444
- if (previous && previous.source !== next) {
2445
- if (next === null) requiresFullReconcile = true;
2446
- previous.dispose();
2447
- this._reflectionProbeHelpers.delete(id);
2448
- }
2449
- if (
2450
- next &&
2451
- ![...this._reflectionProbeHelpers.values()].some((helper) => helper.source === next)
2452
- ) {
2453
- this._ensureReflectionProbeHelper(id, next);
2454
- }
2455
- }
2456
- if (requiresFullReconcile) {
2457
- this._syncReflectionProbeHelpers();
2458
- return;
2459
- }
2460
1975
  }
2461
1976
  for (const [id, helper] of this._reflectionProbeHelpers) {
2462
1977
  helper.setSelected(this._store.shell.selectedEntityIds.has(id));
@@ -2475,21 +1990,13 @@ export class EditorViewport {
2475
1990
  this._scene.add(helper);
2476
1991
  }
2477
1992
 
2478
- /**
2479
- * WHERE A TRIGGER FIRES. A group whose `userData.triggerVolume.radius` decides
2480
- * where a hazard fires or a quest arrives has a hierarchy row and no geometry,
2481
- * so the viewport showed nothing at all where it sits. The game must not draw
2482
- * it: game source here is ecosystem-native and asks the runtime nothing about
2483
- * being authored. So the editor draws it, in the shape `_syncLightHelpers`
2484
- * already uses — editor-owned, on EDITOR_LAYER, never in the game's tree and
2485
- * never in Play.
2486
- */
2487
- private _syncTriggerVolumeHelpers(changedIds: ReadonlySet<string> | null = null): void {
2488
- if (changedIds === null) {
1993
+ /** Draw editor-owned trigger-volume guides on EDITOR_LAYER without adding geometry to the project tree. */
1994
+ private _syncTriggerVolumeHelpers(reconcileMembership: boolean): void {
1995
+ if (reconcileMembership) {
2489
1996
  const current = new Map<string, THREE.Object3D>();
2490
1997
  const admitted = new Set<THREE.Object3D>();
2491
1998
  for (const [id, object] of this._objectMap) {
2492
- if (!object || admitted.has(object) || !triggerVolumeOf(object)) continue;
1999
+ if (admitted.has(object) || !triggerVolumeOf(object)) continue;
2493
2000
  admitted.add(object);
2494
2001
  current.set(id, object);
2495
2002
  }
@@ -2498,27 +2005,6 @@ export class EditorViewport {
2498
2005
  this._disposeTriggerVolumeHelper(id, helper);
2499
2006
  }
2500
2007
  for (const [id, source] of current) this._ensureTriggerVolumeHelper(id, source);
2501
- } else {
2502
- let requiresFullReconcile = false;
2503
- for (const id of changedIds) {
2504
- const source = this._objectMap.get(id);
2505
- const next = source && triggerVolumeOf(source) ? source : null;
2506
- const previous = this._triggerVolumeHelpers.get(id);
2507
- if (previous && previous.source !== next) {
2508
- if (next === null) requiresFullReconcile = true;
2509
- this._disposeTriggerVolumeHelper(id, previous);
2510
- }
2511
- if (
2512
- next &&
2513
- ![...this._triggerVolumeHelpers.values()].some((helper) => helper.source === next)
2514
- ) {
2515
- this._ensureTriggerVolumeHelper(id, next);
2516
- }
2517
- }
2518
- if (requiresFullReconcile) {
2519
- this._syncTriggerVolumeHelpers();
2520
- return;
2521
- }
2522
2008
  }
2523
2009
  const visible =
2524
2010
  this._threeSurfaceShowing &&
@@ -2544,12 +2030,12 @@ export class EditorViewport {
2544
2030
  }
2545
2031
 
2546
2032
  /** Keep one editor-owned projection beside every source-owned constraint owner. */
2547
- private _syncConstraintHelpers(changedIds: ReadonlySet<string> | null = null): void {
2548
- if (changedIds === null) {
2033
+ private _syncConstraintHelpers(reconcileMembership: boolean): void {
2034
+ if (reconcileMembership) {
2549
2035
  const current = new Map<string, THREE.Object3D>();
2550
2036
  const admitted = new Set<THREE.Object3D>();
2551
2037
  for (const [id, object] of this._objectMap) {
2552
- if (!object || admitted.has(object) || constraintsOf(object).length === 0) continue;
2038
+ if (admitted.has(object) || constraintsOf(object).length === 0) continue;
2553
2039
  admitted.add(object);
2554
2040
  current.set(id, object);
2555
2041
  }
@@ -2559,28 +2045,6 @@ export class EditorViewport {
2559
2045
  this._constraintHelpers.delete(id);
2560
2046
  }
2561
2047
  for (const [id, source] of current) this._ensureConstraintHelper(id, source);
2562
- } else {
2563
- let requiresFullReconcile = false;
2564
- for (const id of changedIds) {
2565
- const source = this._objectMap.get(id);
2566
- const next = source && constraintsOf(source).length > 0 ? source : null;
2567
- const previous = this._constraintHelpers.get(id);
2568
- if (previous && previous.source !== next) {
2569
- if (next === null) requiresFullReconcile = true;
2570
- this._disposeConstraintHelper(previous);
2571
- this._constraintHelpers.delete(id);
2572
- }
2573
- if (
2574
- next &&
2575
- ![...this._constraintHelpers.values()].some((helper) => helper.source === next)
2576
- ) {
2577
- this._ensureConstraintHelper(id, next);
2578
- }
2579
- }
2580
- if (requiresFullReconcile) {
2581
- this._syncConstraintHelpers();
2582
- return;
2583
- }
2584
2048
  }
2585
2049
 
2586
2050
  const selectedObjects = new Set<THREE.Object3D>();
@@ -2698,45 +2162,19 @@ export class EditorViewport {
2698
2162
  }
2699
2163
 
2700
2164
  /** Native camera frustums for the ordinary Helpers → Cameras overlay. */
2701
- private _syncCameraHelpers(changedIds: ReadonlySet<string> | null = null): void {
2702
- if (changedIds === null) {
2703
- const current = new Map<string, THREE.Camera>();
2704
- for (const [id, object] of this._objectMap) {
2705
- const camera = this._nativeCamera(object);
2706
- if (camera) current.set(id, camera);
2707
- }
2708
- for (const [id, entry] of this._cameraHelpers) {
2709
- if (current.get(id) === entry.source) continue;
2710
- this._disposeCameraHelper(id, entry);
2711
- }
2712
- for (const [id, camera] of current) this._ensureCameraHelper(id, camera);
2713
- } else {
2714
- let requiresFullReconcile = false;
2715
- for (const id of changedIds) {
2716
- const object = this._objectMap.get(id);
2717
- const next = object ? this._nativeCamera(object) : null;
2718
- const previous = this._cameraHelpers.get(id);
2719
- if (previous && previous.source !== next) {
2720
- if (next === null) requiresFullReconcile = true;
2721
- this._disposeCameraHelper(id, previous);
2722
- }
2723
- if (next && ![...this._cameraHelpers.values()].some((entry) => entry.source === next)) {
2724
- this._ensureCameraHelper(id, next);
2725
- }
2726
- }
2727
- if (requiresFullReconcile) {
2728
- this._syncCameraHelpers();
2729
- return;
2730
- }
2731
- }
2732
- // PERSISTENT, like every other engine's camera icon: Unity, Unreal, Godot
2733
- // and Blender all draw a camera in the viewport whether or not it is
2734
- // selected — that drawing is HOW you find it and click it. Gating it on a
2735
- // three selection meant a scene with nothing selected showed no cameras at
2736
- // all, and the only way to see one was to already have found it in the
2737
- // hierarchy. The surface gate is the same one the grid uses ("Blender's
2738
- // floor is persistent; only the gizmos need an owner"); a camera icon is
2739
- // furniture, not a gizmo.
2165
+ private _syncCameraHelpers(): void {
2166
+ const current = new Map<string, THREE.Camera>();
2167
+ for (const [id, object] of this._objectMap) {
2168
+ const camera = this._nativeCamera(object);
2169
+ if (camera) current.set(id, camera);
2170
+ }
2171
+ for (const [id, entry] of this._cameraHelpers) {
2172
+ if (current.get(id) === entry.source) continue;
2173
+ this._disposeCameraHelper(id, entry);
2174
+ }
2175
+ for (const [id, camera] of current) this._ensureCameraHelper(id, camera);
2176
+ // Keep camera icons visible with the stage’s persistent furniture so unselected cameras can be
2177
+ // found and selected.
2740
2178
  const visible =
2741
2179
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.cameras;
2742
2180
  for (const entry of this._cameraHelpers.values()) {
@@ -2746,55 +2184,28 @@ export class EditorViewport {
2746
2184
  }
2747
2185
 
2748
2186
  /**
2749
- * WHERE A LIGHT POINTS, drawn beside it — three's own light helpers on the
2750
- * editor layer, gated by the Helpers → Lights toggle that already existed
2751
- * with nothing behind it.
2752
- *
2753
- * Two testers asked for this in the same words: with a directional light
2754
- * selected there was no way to tell which way it threw ("is it on the left
2755
- * side, the right, downwards?" — runhuman passes 68/70), and a point light
2756
- * showed only its transform. Same shape as `_syncCameraHelpers`: one
2757
- * editor-owned overlay per native light, reconciled per notify, never in the
2758
- * game's tree and never in Game view.
2187
+ * Reconcile one editor-owned three.js helper per native light when Helpers → Lights is enabled,
2188
+ * using the same overlay lifecycle as camera helpers. Helpers remain outside the authored tree
2189
+ * and hidden in Game view.
2759
2190
  */
2760
- private _syncLightHelpers(changedIds: ReadonlySet<string> | null = null): void {
2761
- if (changedIds === null) {
2762
- const current = new Map<string, THREE.Light>();
2763
- for (const [id, object] of this._objectMap) {
2764
- const light = this._nativeLight(object);
2765
- if (light) current.set(id, light);
2766
- }
2767
- for (const [id, entry] of this._lightHelpers) {
2768
- if (current.get(id) === entry.source) continue;
2769
- this._disposeLightHelper(id, entry);
2770
- }
2771
- for (const [id, light] of current) this._ensureLightHelper(id, light);
2772
- } else {
2773
- let requiresFullReconcile = false;
2774
- for (const id of changedIds) {
2775
- const object = this._objectMap.get(id);
2776
- const next = object ? this._nativeLight(object) : null;
2777
- const previous = this._lightHelpers.get(id);
2778
- if (previous && previous.source !== next) {
2779
- if (next === null) requiresFullReconcile = true;
2780
- this._disposeLightHelper(id, previous);
2781
- }
2782
- if (next && ![...this._lightHelpers.values()].some((entry) => entry.source === next)) {
2783
- this._ensureLightHelper(id, next);
2784
- }
2785
- }
2786
- if (requiresFullReconcile) {
2787
- this._syncLightHelpers();
2788
- return;
2789
- }
2191
+ private _syncLightHelpers(): void {
2192
+ const current = new Map<string, THREE.Light>();
2193
+ for (const [id, object] of this._objectMap) {
2194
+ const light = this._nativeLight(object);
2195
+ if (light) current.set(id, light);
2196
+ }
2197
+ for (const [id, entry] of this._lightHelpers) {
2198
+ if (current.get(id) === entry.source) continue;
2199
+ this._disposeLightHelper(id, entry);
2790
2200
  }
2201
+ for (const [id, light] of current) this._ensureLightHelper(id, light);
2791
2202
  // Persistent for the same reason as the camera frustum above: a light you
2792
2203
  // have not selected is exactly the light you are looking for.
2793
2204
  const visible =
2794
2205
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.lights;
2795
2206
  for (const entry of this._lightHelpers.values()) {
2796
2207
  entry.helper.visible = visible;
2797
- entry.helper.update?.();
2208
+ entry.helper.update();
2798
2209
  }
2799
2210
  }
2800
2211
 
@@ -2807,20 +2218,18 @@ export class EditorViewport {
2807
2218
 
2808
2219
  private _disposeLightHelper(
2809
2220
  id: string,
2810
- entry: { source: THREE.Light; helper: THREE.Object3D & { dispose?(): void } },
2221
+ entry: { helper: THREE.Object3D & { dispose(): void } },
2811
2222
  ): void {
2812
2223
  entry.helper.removeFromParent();
2813
- entry.helper.dispose?.();
2224
+ entry.helper.dispose();
2814
2225
  this._lightHelpers.delete(id);
2815
2226
  }
2816
2227
 
2817
2228
  private _ensureLightHelper(id: string, light: THREE.Light): void {
2818
2229
  if (this._lightHelpers.has(id)) return;
2819
- // An ambient/hemisphere light has no position or direction to draw, and a
2820
- // light class this `three` does not know gets no overlay rather than a
2821
- // wrong one — the same narrow refusal the camera overlay makes.
2230
+ // Draw helpers for supported light classes, including hemisphere lights; ambient lights have no helper.
2822
2231
  const size = 0.5;
2823
- let helper: (THREE.Object3D & { update?(): void; dispose?(): void }) | null = null;
2232
+ let helper: (THREE.Object3D & { update(): void; dispose(): void }) | null = null;
2824
2233
  const candidate = light as THREE.Light & {
2825
2234
  isDirectionalLight?: boolean;
2826
2235
  isPointLight?: boolean;
@@ -2844,26 +2253,13 @@ export class EditorViewport {
2844
2253
  helper.name = `Light helper: ${light.name || id}`;
2845
2254
  helper.userData['editorOnly'] = true;
2846
2255
  helper.traverse((child) => child.layers.set(EDITOR_LAYER));
2847
- helper.layers.set(EDITOR_LAYER);
2848
2256
  this._lightHelpers.set(id, { source: light, helper });
2849
2257
  this._scene.add(helper);
2850
2258
  }
2851
2259
 
2852
- /**
2853
- * WHERE A SOUND COMES FROM, and how far it carries — the Helpers → Audio
2854
- * toggle, which existed with nothing behind it exactly as Lights once did.
2855
- *
2856
- * A listener and a positional source have a place in the world and no
2857
- * geometry, so without an overlay they are invisible in the viewport: the
2858
- * hierarchy lists them and the scene shows nothing where they are. This
2859
- * small marker locates each source. Selected-source distance and direction
2860
- * guides belong to the adapter's spatial-handle provider, which understands
2861
- * the native audio model and owns the corresponding edits. Markers follow
2862
- * the same lifecycle as `_syncLightHelpers`: editor-owned, on the editor
2863
- * layer, never in the game's tree and never in Game view.
2864
- */
2865
- private _syncAudioHelpers(changedIds: ReadonlySet<string> | null = null): void {
2866
- if (changedIds === null) {
2260
+ /** Locate native audio listeners/sources with editor-only markers. Adapter spatial handles own distance/direction edits. */
2261
+ private _syncAudioHelpers(reconcileMembership: boolean): void {
2262
+ if (reconcileMembership) {
2867
2263
  const current = new Map<string, THREE.Object3D>();
2868
2264
  for (const [id, object] of this._objectMap) {
2869
2265
  const audio = this._nativeAudio(object);
@@ -2874,16 +2270,6 @@ export class EditorViewport {
2874
2270
  this._disposeAudioHelper(id, entry);
2875
2271
  }
2876
2272
  for (const [id, audio] of current) this._ensureAudioHelper(id, audio);
2877
- } else {
2878
- for (const id of changedIds) {
2879
- const object = this._objectMap.get(id);
2880
- const next = object ? this._nativeAudio(object) : null;
2881
- const previous = this._audioHelpers.get(id);
2882
- if (previous && previous.source !== next) this._disposeAudioHelper(id, previous);
2883
- if (next && ![...this._audioHelpers.values()].some((entry) => entry.source === next)) {
2884
- this._ensureAudioHelper(id, next);
2885
- }
2886
- }
2887
2273
  }
2888
2274
  const visible =
2889
2275
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.audio;
@@ -2912,22 +2298,20 @@ export class EditorViewport {
2912
2298
  helper.name = `Audio helper: ${source.name || id}`;
2913
2299
  helper.userData['editorOnly'] = true;
2914
2300
  helper.traverse((child) => child.layers.set(EDITOR_LAYER));
2915
- helper.layers.set(EDITOR_LAYER);
2916
- // Parented to the source so it rides the same transform: a footstep
2917
- // emitter on a walking character must not leave its ring behind.
2301
+ // Parent the marker to its source so it follows the source's transform.
2918
2302
  source.add(helper);
2919
2303
  this._audioHelpers.set(id, { source, helper });
2920
2304
  }
2921
2305
 
2922
2306
  private _disposeAudioHelper(
2923
2307
  id: string,
2924
- entry: { source: THREE.Object3D; helper: THREE.Object3D & { dispose?(): void } },
2308
+ entry: { helper: THREE.Group },
2925
2309
  ): void {
2926
2310
  entry.helper.traverse((child) => {
2927
2311
  const mesh = child as THREE.Mesh;
2928
- mesh.geometry?.dispose?.();
2312
+ mesh.geometry?.dispose();
2929
2313
  const material = mesh.material as THREE.Material | undefined;
2930
- material?.dispose?.();
2314
+ material?.dispose();
2931
2315
  });
2932
2316
  entry.helper.removeFromParent();
2933
2317
  this._audioHelpers.delete(id);
@@ -2935,7 +2319,7 @@ export class EditorViewport {
2935
2319
 
2936
2320
  private _disposeCameraHelper(
2937
2321
  id: string,
2938
- entry: { source: THREE.Camera; helper: THREE.CameraHelper },
2322
+ entry: { helper: THREE.CameraHelper },
2939
2323
  ): void {
2940
2324
  entry.helper.removeFromParent();
2941
2325
  entry.helper.dispose();
@@ -2944,9 +2328,7 @@ export class EditorViewport {
2944
2328
 
2945
2329
  private _ensureCameraHelper(id: string, camera: THREE.Camera): void {
2946
2330
  if (this._cameraHelpers.has(id)) return;
2947
- // `CameraHelper` is the HOST's class reading fields off a camera the GAME may have made with
2948
- // its own, older `three`. The failure is the discriminator; every compatible camera keeps its
2949
- // overlay, while an incompatible revision records the narrow refusal below.
2331
+ // Project camera helpers may come from a separate three instance. Keep compatible overlays and record a narrow refusal when CameraHelper cannot read the camera.
2950
2332
  let helper: THREE.CameraHelper;
2951
2333
  try {
2952
2334
  helper = new THREE.CameraHelper(camera);
@@ -2968,12 +2350,7 @@ export class EditorViewport {
2968
2350
  this._refusedCameraOverlays.add(id);
2969
2351
  editorConsole.warn(
2970
2352
  `Helpers → Cameras draws no frustum for "${camera.name || camera.type}": ` +
2971
- `${error instanceof Error ? error.message : String(error)}. This world's camera was ` +
2972
- "constructed by the GAME's own copy of three, and `THREE.CameraHelper` is the editor's " +
2973
- 'class reading fields off it that that revision does not define. Every other camera ' +
2974
- 'capability (framing, selection, inspection) is unaffected. It closes when the game ' +
2975
- "resolves `three` to the host's instance — a bare, un-rewritten `import 'three'` in its " +
2976
- 'own source.',
2353
+ `${error instanceof Error ? error.message : String(error)}. Framing, selection and inspection remain available.`,
2977
2354
  'authoring',
2978
2355
  );
2979
2356
  }
@@ -3040,13 +2417,13 @@ export class EditorViewport {
3040
2417
  }
3041
2418
 
3042
2419
  private _finishSpatialHandleDrag(commit: boolean): void {
3043
- const active = this._activeSpatialHandle;
2420
+ const active = this._activeSpatialHandle!;
3044
2421
  this._activeSpatialHandle = null;
3045
2422
  this.orbitControls.enabled = this._orbitEnabledBeforeHandleDrag;
3046
2423
  this.transformControls.enabled = this._transformEnabledBeforeHandleDrag;
3047
2424
  this.syncFromStore();
3048
2425
  this._canvas.style.cursor = '';
3049
- if (commit && active) {
2426
+ if (commit) {
3050
2427
  void spatialHandlesForAdapter(this._authoring())?.commit(active.nodeId, active.handle.id, [
3051
2428
  this._spatialHandleDragPoint.x,
3052
2429
  this._spatialHandleDragPoint.y,
@@ -3084,41 +2461,34 @@ export class EditorViewport {
3084
2461
  }
3085
2462
  setSpatialHandleHovered(hovered, true);
3086
2463
  const binding = spatialHandleBinding(hovered);
3087
- this._canvas.style.cursor = binding?.handle.writable ? 'grab' : 'not-allowed';
2464
+ this._canvas.style.cursor = binding.handle.writable ? 'grab' : 'not-allowed';
3088
2465
  }
3089
2466
 
3090
2467
  private _updateEditorHandleInteraction(e: PointerEvent): boolean {
3091
2468
  return this._updateSpatialHandleDrag(e) || this._updateConstraintControlHover(e);
3092
2469
  }
3093
2470
 
3094
- private _previewInfrastructure(): THREE.Object3D[] {
3095
- return [
3096
- this.grid,
3097
- ...(this._axisLines ? [this._axisLines] : []),
3098
- this._gizmoHelper,
3099
- this._auxRotateControls.getHelper(),
3100
- this._auxScaleControls.getHelper(),
3101
- ...this._boxHelpers.values(),
3102
- ...this._constraintHelpers.values(),
3103
- ...this._reflectionProbeHelpers.values(),
3104
- ...this._triggerVolumeHelpers.values(),
3105
- ...[...this._cameraHelpers.values()].map(({ helper }) => helper),
3106
- ...this._spatialHandleRoots,
3107
- ];
2471
+ private _allGizmos(): TransformControls[] {
2472
+ return [this.transformControls, this._auxRotateControls, this._auxScaleControls];
3108
2473
  }
3109
2474
 
3110
- private _suspendPreviewInfrastructure(): () => void {
3111
- const visibility = this._previewInfrastructure().map(
3112
- (object) => [object, object.visible] as const,
3113
- );
3114
- for (const [object] of visibility) object.visible = false;
3115
- return () => {
3116
- for (const [object, visible] of visibility) object.visible = visible;
3117
- };
2475
+ /** A drag is under way: a transform gizmo, a spatial handle or a constraint control. Every
2476
+ * frame of it is a change of the picture (`StageHost`), whatever the drag signals. */
2477
+ interacting(): boolean {
2478
+ return this._anyGizmoDragging() || this._activeSpatialHandle !== null || this._constraintControlPointerSession;
3118
2479
  }
3119
2480
 
3120
- private _allGizmos(): TransformControls[] {
3121
- return [this.transformControls, this._auxRotateControls, this._auxScaleControls];
2481
+ /**
2482
+ * WHAT INPUT CHANGES IN THE STAGE'S OWN FURNITURE: the hovered constraint control and spatial
2483
+ * handle, and each transform gizmo's shown state, highlighted axis and drag. A stage counts a
2484
+ * pointer move as a change of its picture only when this (or the camera) moves, so hovering
2485
+ * over a still view does not start its accumulation over (`StageHost`).
2486
+ */
2487
+ interactionState(): string {
2488
+ const gizmos = this._allGizmos()
2489
+ .map((controls) => `${controls.getHelper().visible ? 1 : 0}${controls.enabled ? 1 : 0}${controls.axis ?? '-'}${controls.dragging ? 1 : 0}`)
2490
+ .join(',');
2491
+ return `${this._hoveredConstraintControl?.mesh.uuid ?? '-'}|${this._hoveredSpatialHandle?.uuid ?? '-'}|${gizmos}`;
3122
2492
  }
3123
2493
 
3124
2494
  private _anyGizmoDragging(): boolean {
@@ -3137,14 +2507,9 @@ export class EditorViewport {
3137
2507
 
3138
2508
  /** Sync mode/space/snap from store. */
3139
2509
  syncFromStore(): void {
3140
- // Ensure all gizmo children stay on EDITOR_LAYER (TransformControls rebuilds internally)
3141
- this._gizmoHelper.traverse((child) => child.layers.set(EDITOR_LAYER));
3142
-
3143
2510
  const mode = this._store.shell.transformMode;
3144
2511
  const combined = mode === 'combined';
3145
- // `'select'` draws NO gizmo (Blender's Select Box — `TransformMode`'s own
3146
- // note), so there is no handle family to put the primary controls in; it
3147
- // keeps whatever it had and the attach block below detaches instead.
2512
+ // Select uses translate mode with the controls detached, so it draws no transform gizmo.
3148
2513
  const primaryMode = combined || mode === 'select' ? 'translate' : mode;
3149
2514
  if (this.transformControls.mode !== primaryMode) {
3150
2515
  this.transformControls.setMode(primaryMode);
@@ -3152,18 +2517,14 @@ export class EditorViewport {
3152
2517
  this._applySnapToGizmos();
3153
2518
  for (const controls of this._allGizmos()) controls.setSpace(this._store.shell.transformSpace);
3154
2519
 
3155
- // Spec 28 step 2 — visibility says a Three.js surface is painted;
3156
- // selection ownership says it is the native world the user is editing.
3157
- // The grid, view cube and transform gizmo require BOTH.
3158
- // A pinned camera preview remains visible without a selection.
3159
- // Empty/organizational/cross-world selections deliberately leave only
3160
- // shell chrome rather than advertising an arbitrary coordinate system.
2520
+ // Enable transform tools only for the visible three.js stage that owns this selection.
2521
+ // Camera previews and persistent furniture have their own visibility gates.
3161
2522
  const policy = viewportAuthoringPolicy();
3162
2523
  const authoring = this._authoring();
3163
2524
  const toolOwner = policy.toolOwner(authoring, this._store.shell.selectedEntityIds);
3164
2525
  this._threejsToolContextActive =
3165
2526
  (this._standaloneAuthoring || policy.toolOwnerPainted(this._store.shell, toolOwner)) &&
3166
- toolOwner?.kind === 'three';
2527
+ toolOwner?.kind === 'stage';
3167
2528
 
3168
2529
  // Grid visibility — the user's toggle, AND'd with a three stage showing at
3169
2530
  // all (selection or not: Blender's floor is persistent; the gizmos above
@@ -3174,30 +2535,12 @@ export class EditorViewport {
3174
2535
  this.grid.visible = this._presentationGrid && threeSurface;
3175
2536
  if (this._axisLines) this._axisLines.visible = this.grid.visible && this._axesWanted;
3176
2537
 
3177
- // The two passes below (apply gizmos + per-type helper/icon visibility) are
3178
- // idempotent and O(total scene nodes). Their only inputs are the objectMap
3179
- // membership and the visibility toggles — none of which change during a
3180
- // transform drag/scrub (which still fires syncFromStore every frame). Gate
3181
- // them on a cheap signature so a drag doesn't re-walk the whole graph 60×/s.
2538
+ // Reconcile helper membership, icon visibility and camera/light helpers when
2539
+ // the object map, gizmo epoch, helper settings or stage visibility changes.
3182
2540
  const vis = this._store.shell.helperVisibility;
3183
- // World-hidden eye (D9) folded into the SAME cheap signature/gate as the
3184
- // gizmo/helper passes below — see `world-hidden-viewport.ts`'s doc
3185
- // comment for why this must be recomputed from scratch on every rebuild
3186
- // (never a one-shot flip) and why folding it in here (rather than a
3187
- // separate always-on pass) is safe: the toggle itself
3188
- // (`GameHierarchy.tsx`'s `toggleRootHidden` + `store.notifyIngestEdit()`)
3189
- // changes `hiddenRootId`'s hidden state, which changes this signature,
3190
- // so it re-applies on the very next `syncFromStore()` call — exactly the
3191
- // same notify path that already re-triggers this whole method.
3192
2541
  const hiddenRootId = this._standaloneAuthoring ? null : policy.threeViewportRootId(this._store.shell);
3193
2542
  const hidden = hiddenRootId !== null && isRootHidden(hiddenRootId);
3194
- // While hidden, re-apply on EVERY notify, not just signature changes: a
3195
- // SINGLE-entity rebuild (scene-sync `updateEntityPreview`) mutates the
3196
- // existing objectMap in place — same instance, same size, so the gate
3197
- // below never re-fires — while re-stamping the rebuilt object visible
3198
- // from its descriptor. Cost is one map walk per notify only while a
3199
- // world is actually hidden; the gated call below still handles the
3200
- // hide→show transition (restoring per-entity visibility).
2543
+ // Reapply hidden state on each notification, including in-place object replacements.
3201
2544
  if (hidden) policy.applyRootHiddenVisibility(this._objectMap, hiddenRootId);
3202
2545
  // Scene-LEVEL environment (background/skybox/fog/IBL). Nothing here has to
3203
2546
  // RESTORE it on the show edge — an adapter-backed world paints its own
@@ -3205,33 +2548,17 @@ export class EditorViewport {
3205
2548
  // same rationale as the per-notify `applyRootHiddenVisibility` above.
3206
2549
  if (hidden) policy.suppressRootEnvironment(this._scene);
3207
2550
  const gizmoEpoch = this._store.gizmoEpoch;
3208
- const gizmoSig = `${this._store.shell.showHelpers}|${this._store.shell.showGizmos}|${JSON.stringify(vis)}|${hiddenRootId ?? ''}|${hidden}|${this._threejsToolContextActive}`;
2551
+ const gizmoSig = `${this._store.shell.showHelpers}|${JSON.stringify(vis)}|${threeSurface}`;
3209
2552
  const mapChanged = this._objectMap !== this._lastGizmoObjectMap;
3210
2553
  const settingsChanged = gizmoSig !== this._lastGizmoSig;
3211
- const membershipChanges = mapChanged
3212
- ? null
3213
- : this._store.ingestObjectMapMembershipChangesSince(this._lastGizmoEpoch);
3214
- const changedIds =
3215
- mapChanged || settingsChanged || membershipChanges === null
3216
- ? null
3217
- : membershipChanges.changedIds;
3218
- const reconcileMembership = changedIds === null || changedIds.size > 0;
2554
+ const reconcileMembership =
2555
+ mapChanged || settingsChanged || gizmoEpoch !== this._lastGizmoEpoch;
3219
2556
  this._lastGizmoObjectMap = this._objectMap;
3220
2557
  this._lastGizmoSig = gizmoSig;
3221
2558
  this._lastGizmoEpoch = gizmoEpoch;
3222
2559
  if (reconcileMembership) {
3223
- // Threejs roots have no DOM layer to `display:none` (unlike react/pixi
3224
- // — `design-time-layers.ts`'s `applySessionStyle`), so this is what
3225
- // actually makes a hidden three world's group-row eye hide its
3226
- // objects in THIS viewport. ABOVE the per-entity `visible` flag (ANDed
3227
- // in, never overwritten) — see `world-hidden-viewport.ts`.
3228
- policy.applyRootHiddenVisibility(this._objectMap, hiddenRootId);
3229
-
3230
2560
  // Helper + icon visibility — per-type filtering
3231
- const helperRoots =
3232
- changedIds === null
3233
- ? topmostPublishedObjects(this._objectMap)
3234
- : topmostChangedObjects(this._objectMap, changedIds);
2561
+ const helperRoots = topmostPublishedObjects(this._objectMap);
3235
2562
  for (const obj of helperRoots) {
3236
2563
  obj.traverse((child) => {
3237
2564
  if (getUserData(child, 'editorIcon')) {
@@ -3261,16 +2588,16 @@ export class EditorViewport {
3261
2588
  helper.visible =
3262
2589
  this._store.shell.showHelpers && (!(kind in vis) || (vis as Record<string, boolean>)[kind]!);
3263
2590
  }
3264
- this._syncCameraHelpers(changedIds);
3265
- this._syncLightHelpers(changedIds);
2591
+ this._syncCameraHelpers();
2592
+ this._syncLightHelpers();
3266
2593
  }
3267
2594
 
3268
- this._syncAudioHelpers(changedIds);
3269
- this._syncReflectionProbeHelpers(changedIds);
3270
- this._syncTriggerVolumeHelpers(changedIds);
3271
- this._syncConstraintHelpers(changedIds);
2595
+ this._syncAudioHelpers(reconcileMembership);
2596
+ this._syncReflectionProbeHelpers(reconcileMembership);
2597
+ this._syncTriggerVolumeHelpers(reconcileMembership);
2598
+ this._syncConstraintHelpers(reconcileMembership);
3272
2599
 
3273
- // Attach gizmo based on pivot mode. Spec 28: no gizmo without a visible
2600
+ // Attach gizmo based on pivot mode. No gizmo without a visible
3274
2601
  // three surface — this whole block re-derives per notify, so detaching
3275
2602
  // here is self-healing (the un-hide notify re-attaches to the same
3276
2603
  // selection with no extra bookkeeping).
@@ -3284,8 +2611,7 @@ export class EditorViewport {
3284
2611
  // Combined mode filters/attaches on POSITION (its median-filter channel)
3285
2612
  // and attaches when ANY channel is writable — a scale-locked prefab
3286
2613
  // instance still gets move+rotate handles instead of no gizmo at all
3287
- // (which is also exactly what single-scale mode used to show a tester:
3288
- // an empty viewport with no visible refusal).
2614
+ // (an empty viewport with no visible refusal).
3289
2615
  const activeTransformChannel =
3290
2616
  mode === 'translate' || mode === 'combined'
3291
2617
  ? 'position'
@@ -3315,12 +2641,8 @@ export class EditorViewport {
3315
2641
  }
3316
2642
  const obj = this._objectForAuthoringId(id);
3317
2643
  if (obj) {
3318
- // WORLD position, from `matrixWorld` — the pivot dummy lives at
3319
- // the scene root, so a median of LOCAL positions was already
3320
- // wrong for any nested node, and it is wrong a second way for a
3321
- // node whose driver owns its matrix (`authoring/
3322
- // live-object-transform.ts`: `.position` is frozen at spawn while
3323
- // the object renders wherever its matrix puts it).
2644
+ // Compute the scene-root pivot from world matrices. Local position fields do not
2645
+ // describe nested nodes or objects driven directly through their matrices.
3324
2646
  median.add(obj.getWorldPosition(_medianTmp));
3325
2647
  count++;
3326
2648
  }
@@ -3355,8 +2677,8 @@ export class EditorViewport {
3355
2677
  // `matrixWorld` (three's `getWorld*` decompose it), never from the
3356
2678
  // vector fields: a node whose driver owns its matrix keeps
3357
2679
  // `.position`/`.quaternion` frozen at spawn, so anchoring on those
3358
- // put the gizmo where the object USED to be while its bounds drew
3359
- // where it is (`authoring/live-object-transform.ts`).
2680
+ // would put the gizmo where the object was at spawn while its bounds
2681
+ // draw where it is (`authoring/live-object-transform.ts`).
3360
2682
  const pivotLocal = new THREE.Vector3(entityPivot[0], entityPivot[1], entityPivot[2]);
3361
2683
  pivotLocal.applyQuaternion(obj.getWorldQuaternion(_pivotQuat));
3362
2684
  this._pivotDummy.position.copy(obj.getWorldPosition(_pivotPos)).add(pivotLocal);
@@ -3381,13 +2703,6 @@ export class EditorViewport {
3381
2703
  this._syncCombinedGizmoHalves();
3382
2704
  }
3383
2705
 
3384
- /**
3385
- * The combined gizmo's halves: shown only in combined mode, each half only
3386
- * where its channel is WRITABLE for the attached entity — a prefab instance
3387
- * with a locked scale (the starter's Hero Box) shows move+rotate and
3388
- * honestly no scale handles, instead of a silently dead gizmo. Runs AFTER
3389
- * the attach block: it reads the attachment this same pass just made.
3390
- */
3391
2706
  /** Snap values reach the gizmos from the store's toggle OR a held
3392
2707
  * Ctrl/⌘ (the Unity/Blender hold-to-snap convention) — three's controls
3393
2708
  * read the snap on every pointer move, so a mid-drag hold takes effect. */
@@ -3407,7 +2722,7 @@ export class EditorViewport {
3407
2722
  private _modifiedZoom = false;
3408
2723
 
3409
2724
  private readonly _onAltOrbitStart = (e: PointerEvent): void => {
3410
- if (e.button === 1 && (e.ctrlKey || e.metaKey) && activeKeymapNavigation().orbit === 'middle') {
2725
+ if (e.button === 1 && (e.ctrlKey || e.metaKey) && editorHost().keyboard.navigation().orbit === 'middle') {
3411
2726
  this._modifiedZoom = true;
3412
2727
  this.orbitControls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
3413
2728
  }
@@ -3417,9 +2732,9 @@ export class EditorViewport {
3417
2732
  }
3418
2733
  };
3419
2734
 
3420
- private readonly _onAltOrbitRelease = (event?: PointerEvent): void => {
2735
+ private readonly _onAltOrbitRelease = (event: PointerEvent): void => {
3421
2736
  // The zoom is the middle button's gesture, and ends with that button's release.
3422
- if (this._modifiedZoom && (!event || event.type === 'pointercancel' || event.button === 1)) {
2737
+ if (this._modifiedZoom && (event.type === 'pointercancel' || event.button === 1)) {
3423
2738
  this._modifiedZoom = false;
3424
2739
  this.applyKeymapNavigation();
3425
2740
  }
@@ -3435,14 +2750,15 @@ export class EditorViewport {
3435
2750
  if (hold === this._snapHold) return;
3436
2751
  this._snapHold = hold;
3437
2752
  this._applySnapToGizmos();
3438
- // Stepping reads as "slower/laggier" to someone who doesn't know it is
3439
- // snapping (human pass 10 said exactly that) — name it while it happens.
2753
+ // Show a hint when the modifier temporarily enables snapping during a drag.
3440
2754
  if (hold && this._anyGizmoDragging() && !this._store.shell.snapEnabled) {
3441
2755
  const { translate, rotate, scale } = this._store.shell.snapValues;
3442
2756
  showTransientHint(`Snapping while held: ${translate} units · ${rotate}° · ×${scale}`);
3443
2757
  }
3444
2758
  };
3445
2759
 
2760
+ /** In combined mode, show each transform half only when its channel is
2761
+ * writable for the entity attached by the current sync pass. */
3446
2762
  private _syncCombinedGizmoHalves(): void {
3447
2763
  if (this._anyGizmoDragging()) return;
3448
2764
  const combined = this._store.shell.transformMode === 'combined';
@@ -3463,17 +2779,9 @@ export class EditorViewport {
3463
2779
  const scaleHelper = this._auxScaleControls.getHelper();
3464
2780
  rotateHelper.visible = rotateOn;
3465
2781
  scaleHelper.visible = scaleOn;
3466
- for (const helper of [rotateHelper, scaleHelper]) {
3467
- helper.traverse((child) => child.layers.set(EDITOR_LAYER));
3468
- }
3469
2782
  this.transformControls.enabled = true;
3470
2783
  }
3471
2784
 
3472
- /**
3473
- * Re-add editor infrastructure objects to the scene after play mode
3474
- * has cleared all scene children. Call this after loadDocument restores
3475
- * entities so the grid, lights, gizmos, and particle renderer are present.
3476
- */
3477
2785
  /** Move all editor gizmos from the editor scene into a target scene (e.g. the live game scene). */
3478
2786
  moveGizmosTo(target: THREE.Scene): void {
3479
2787
  const objs = this._gizmoObjects();
@@ -3505,22 +2813,14 @@ export class EditorViewport {
3505
2813
  }
3506
2814
 
3507
2815
  /**
3508
- * The active scene's content AABB, re-measured at most every
3509
- * {@link CONTENT_BOUNDS_MAX_AGE_MS}.
3510
- *
3511
- * A live game keeps building itself long after the editor adopts it (a
3512
- * level-streaming game parses its whole world in seconds after first
3513
- * capture), so this cannot be a one-shot measurement at scene swap —
3514
- * but neither can it run per frame over thousands of nodes. Cheap readers
3515
- * (the per-frame clip planes) take the cache; {@link focusOnScene} forces a
3516
- * fresh measurement, because framing on a stale box is exactly the miss it
3517
- * exists to prevent.
2816
+ * Cache the active scene’s content AABB for CONTENT_BOUNDS_MAX_AGE_MS as its live graph changes.
2817
+ * Per-frame clip planes use the cached extent; focusOnScene forces a fresh measurement.
3518
2818
  */
3519
- private _contentBounds(force = false): THREE.Box3 {
2819
+ private _contentBounds(): THREE.Box3 {
3520
2820
  const now = performance.now();
3521
2821
  const stale =
3522
2822
  this._contentBoundsAt === 0 || now - this._contentBoundsAt > CONTENT_BOUNDS_MAX_AGE_MS;
3523
- if (force || stale) {
2823
+ if (stale) {
3524
2824
  this._contentBoundsBox.makeEmpty();
3525
2825
  for (const child of this._scene.children) {
3526
2826
  if (isInEditorOwnedSubtree(child)) continue;
@@ -3551,15 +2851,9 @@ export class EditorViewport {
3551
2851
  return boxes;
3552
2852
  }
3553
2853
 
3554
- /**
3555
- * Grow the camera's clip planes to reach the content, every frame.
3556
- *
3557
- * See `viewport-clip-planes.ts` for the measured defect this closes: a world
3558
- * larger than the frozen `far = 1000` was clipped away to an empty viewport.
3559
- * Cheap — the bounds are cached, so a frame costs one distance and, only when
3560
- * the planes actually move, one `updateProjectionMatrix`.
3561
- */
2854
+ /** Grow clip planes to reach cached content bounds. Update the projection only when the required planes change. */
3562
2855
  private _updateClipPlanes(): void {
2856
+ if (this.projectionOwner.owned) return;
3563
2857
  const box = this._contentBounds();
3564
2858
  let distance = 0;
3565
2859
  let radius = 0;
@@ -3586,14 +2880,9 @@ export class EditorViewport {
3586
2880
  * Returns false — camera untouched — when that view cannot be used: the
3587
2881
  * world is behind it, on it, or PRESSED AGAINST IT (a camera placed inside
3588
2882
  * the geometry it is about to drive through is a fine gameplay camera and a
3589
- * useless first look — `seededViewShowsWorld` is the measurement, and the
3590
- * caller falls back to the framing, which always shows something).
2883
+ * useless first look — `seededViewShowsWorld` is the measurement).
3591
2884
  *
3592
- * A caller inside the auto-frame window can simply ask again: a game builds
3593
- * itself asynchronously, and Cuberun's own `<PerspectiveCamera makeDefault>`
3594
- * is not in the scene at all on the frame the editor captures it (its
3595
- * `<Suspense>` content has not resolved), so a seed attempted only at mount
3596
- * misses the flagship case.
2885
+ * A caller can retry as asynchronous scene content and its camera mount.
3597
2886
  */
3598
2887
  seedFromGameCamera(gameCamera: THREE.Camera): boolean {
3599
2888
  const box = frameableContentBounds(this._measureContentBounds());
@@ -3647,8 +2936,8 @@ export class EditorViewport {
3647
2936
  box.getSize(new THREE.Vector3()).x / this._viewportAspect,
3648
2937
  ) * 1.15;
3649
2938
  this._syncOrthographicFromPerspective(true);
3650
- // Before any draw: the framing distance for a world-sized scene is exactly
3651
- // what used to land past the far plane.
2939
+ // Before any draw: the framing distance for a world-sized scene can land
2940
+ // past a stale far plane.
3652
2941
  this._updateClipPlanes();
3653
2942
  return true;
3654
2943
  }
@@ -3752,7 +3041,7 @@ export class EditorViewport {
3752
3041
 
3753
3042
  /**
3754
3043
  * Show (or clear with null) the editor-only helper of `kind` —
3755
- * `setViewportHelper`'s implementation. The object joins the editor
3044
+ * The object joins the editor
3756
3045
  * scene as a scene-root helper (`editorHelper`, `editorHelperType = kind`)
3757
3046
  * and follows the Helpers menu's toggle for its kind.
3758
3047
  */
@@ -3885,32 +3174,20 @@ export class EditorViewport {
3885
3174
  // every draw. Nothing here should shadow that with a second source.
3886
3175
  }
3887
3176
 
3888
- /**
3889
- * The presentation rig's lighting source for what the scene now holds: a view whose lighting is
3890
- * `auto` gives way to an adopted scene's own lights. Every draw runs it; a stage whose document
3891
- * is hidden (the game world during Play, drawn by the Game view) runs it too, so the editor's
3892
- * studio lights never light the game.
3893
- */
3177
+ /** Resolve the current presentation source without applying the rig's own lights. */
3894
3178
  resolvePresentation(): void {
3895
- this._presentation?.rig.resolveSource({ light: this._store.isAdoptedSceneActive });
3179
+ this._presentation?.rig.resolveSource({ light: false });
3896
3180
  }
3897
3181
 
3898
- update(dt = 0): void {
3182
+ update(dt: number): void {
3899
3183
  this._ensureSparkRenderer();
3900
3184
  this._syncLookBackground();
3901
- // A remount can prune the attached object while the gizmos still hold it;
3902
- // three then errors EVERY FRAME ("must be a part of the scene graph" —
3903
- // human pass 10 recorded seven while authoring). Detach the moment the
3904
- // attachment goes stale; the store-driven sync re-attaches when the
3905
- // selection re-resolves onto the fresh object.
3185
+ // Detach stale gizmo objects after a remount, then resolve the current selection again.
3906
3186
  const attachedObject = this.transformControls.object;
3907
3187
  if (attachedObject && attachedObject !== this._pivotDummy && !attachedObject.parent) {
3908
3188
  this._detachGizmos();
3909
3189
  this.syncFromStore();
3910
3190
  }
3911
- // Animate custom (shader) materials in edit mode — same tick the runtime
3912
- // preRender phase runs, so authored shaders preview live in the viewport.
3913
-
3914
3191
  // Fly camera movement (before orbit so it takes priority)
3915
3192
  this._updateFlyCamera(dt);
3916
3193
 
@@ -3973,21 +3250,9 @@ export class EditorViewport {
3973
3250
  }
3974
3251
 
3975
3252
  /**
3976
- * W2b: enforce the editor-only forced-LOD-level preview every frame. The
3977
- * THREE.LOD instance is replaced by entity rebuilds and doesn't even exist
3978
- * until the glTF's async load resolves, so a one-shot apply at dropdown
3979
- * time would silently un-pin — per-frame enforcement over a map that is
3980
- * almost always empty is the robust (and cheap) form. 'Auto' removes the
3981
- * map entry and restores `autoUpdate` (see EditorShellStore.setLodForcedLevel),
3982
- * handing selection back to the renderer's own `LOD.update(camera)`.
3983
- *
3984
- * That last sentence is load-bearing and easy to disbelieve, because nothing
3985
- * in this repo calls `LOD.update`: THREE does it itself. `WebGLRenderer`'s
3986
- * `projectObject` runs `if (object.isLOD) { if (object.autoUpdate === true)
3987
- * object.update(camera); }` for every visible LOD, every render. `autoUpdate`
3988
- * is the ONE switch this whole feature turns, and pinning works by turning it
3989
- * off. Do not add an engine-side per-frame LOD tick to "fix" the missing
3990
- * call — it would duplicate the renderer's own work and fight the pin.
3253
+ * Apply pinned LOD levels each frame so asynchronous loads and rebuilt instances inherit the view
3254
+ * state. Auto removes the pin and restores autoUpdate; WebGLRenderer then calls
3255
+ * LOD.update(camera) during rendering.
3991
3256
  */
3992
3257
  private _enforceLodForcedLevels(): void {
3993
3258
  const projectedObjects = new Set(this._objectMap.values());
@@ -4011,22 +3276,19 @@ export class EditorViewport {
4011
3276
  */
4012
3277
  renderWithInfrastructure(draw: () => void): void {
4013
3278
  const spark = this._sparkRenderer;
4014
- const restoreCameraView = this._cameraView ? this._suspendPreviewInfrastructure() : null;
4015
3279
  if (spark) this._scene.add(spark);
4016
3280
  try {
4017
3281
  draw();
4018
3282
  } finally {
4019
3283
  spark?.removeFromParent();
4020
- restoreCameraView?.();
4021
3284
  }
4022
3285
  }
4023
3286
 
4024
- /** Render the standard orientation gizmo in the viewport's top-right corner. */
3287
+ /** Render the standard orientation gizmo in the corner selected by the look. */
4025
3288
  renderViewCube(renderer: THREE.WebGLRenderer): void {
4026
3289
  if (!this._threeSurfaceShowing) return; // no three stage, no compass (Blender's is persistent on one)
4027
3290
  if (this._navigation === 'hidden') return;
4028
3291
  if (!this._store.shell.showGizmos) return; // the stage's Show Gizmo is off
4029
- if (this._cameraView) return; // exact camera view owns the viewport
4030
3292
  const w = renderer.domElement.clientWidth;
4031
3293
  const h = renderer.domElement.clientHeight;
4032
3294
  const size = this._vcSize;
@@ -4068,47 +3330,7 @@ export class EditorViewport {
4068
3330
  }
4069
3331
  }
4070
3332
 
4071
- /** Paint an authored camera into the DOM preview body's exact canvas rect.
4072
- * The surrounding label/actions are React chrome; this reuses the one
4073
- * renderer and WebGL context rather than mounting a second viewport. */
4074
- renderCameraPreview(
4075
- renderer: THREE.WebGLRenderer,
4076
- entityCamera: THREE.Camera | null,
4077
- previewElement: HTMLElement | null,
4078
- ): void {
4079
- if (!entityCamera || !previewElement || this._cameraView) return;
4080
-
4081
- const w = renderer.domElement.clientWidth;
4082
- const h = renderer.domElement.clientHeight;
4083
- const canvasRect = renderer.domElement.getBoundingClientRect();
4084
- const previewRect = previewElement.getBoundingClientRect();
4085
- const px = Math.round(previewRect.left - canvasRect.left);
4086
- const py = Math.round(canvasRect.bottom - previewRect.bottom);
4087
- const pw = Math.max(1, Math.round(previewRect.width));
4088
- const ph = Math.max(1, Math.round(previewRect.height));
4089
- if (px >= w || py >= h || px + pw <= 0 || py + ph <= 0) return;
4090
-
4091
- const restoreInfrastructure = this._suspendPreviewInfrastructure();
4092
- // Save renderer state: the preview is a late scissored draw over the main
4093
- // frame and must leave the following frame's composer untouched.
4094
- const prevClearColor = renderer.getClearColor(this._previewClearColor);
4095
- const prevClearAlpha = renderer.getClearAlpha();
4096
- try {
4097
- renderer.setScissorTest(true);
4098
- renderer.setViewport(px, py, pw, ph);
4099
- renderer.setScissor(px, py, pw, ph);
4100
- renderer.setClearColor(0x111214, 1);
4101
- renderer.clear();
4102
- renderer.render(this._scene, entityCamera);
4103
- } finally {
4104
- renderer.setScissorTest(false);
4105
- renderer.setViewport(0, 0, w, h);
4106
- renderer.setClearColor(prevClearColor, prevClearAlpha);
4107
- restoreInfrastructure();
4108
- }
4109
- }
4110
-
4111
- /** True when the fly camera is active (right-click + movement key held). */
3333
+ /** True while the fly-camera session is active. */
4112
3334
  get isFlying(): boolean {
4113
3335
  return this._flyActive;
4114
3336
  }
@@ -4126,6 +3348,8 @@ export class EditorViewport {
4126
3348
  this._canvas.removeEventListener('pointerdown', this._onAltOrbitStart, { capture: true });
4127
3349
  window.removeEventListener('pointerup', this._onAltOrbitRelease, { capture: true });
4128
3350
  window.removeEventListener('pointercancel', this._onAltOrbitRelease, { capture: true });
3351
+ window.removeEventListener('pointercancel', this._onInteractionDragLost, { capture: true });
3352
+ window.removeEventListener('blur', this._onInteractionDragLost);
4129
3353
  window.removeEventListener('keydown', this._onSnapHoldKey);
4130
3354
  window.removeEventListener('keyup', this._onSnapHoldKey);
4131
3355
  this._interactionElement.removeEventListener('pointerup', this._onFlyPointerUp);
@@ -4134,15 +3358,8 @@ export class EditorViewport {
4134
3358
  this._interactionElement.removeEventListener('wheel', this._onTrackpadWheel);
4135
3359
  this._canvas.removeEventListener('pointerdown', this._onPointerDown);
4136
3360
  this._interactionElement.removeEventListener('pointermove', this._onPointerMove);
4137
- if (this._onInteractionPointerUp) {
4138
- this._interactionElement.removeEventListener('pointerup', this._onInteractionPointerUp);
4139
- }
4140
- if (this._onInteractionDblClick) {
4141
- this._interactionElement.removeEventListener('dblclick', this._onInteractionDblClick);
4142
- }
4143
- this._canvas.removeEventListener('dragover', this._onDragOver);
4144
- this._canvas.removeEventListener('dragleave', this._onDragLeave);
4145
- this._canvas.removeEventListener('drop', this._onDrop);
3361
+ this._interactionElement.removeEventListener('pointerup', this._onPointerUp);
3362
+ this._interactionElement.removeEventListener('dblclick', this._onInteractionDblClick);
4146
3363
  // Dispose controls now: deferred source cleanup must never disconnect
4147
3364
  // the reused element after the replacement controls have connected.
4148
3365
  this.orbitControls.dispose();
@@ -4150,9 +3367,9 @@ export class EditorViewport {
4150
3367
  }
4151
3368
 
4152
3369
  /**
4153
- * Paint the palette's viewport group (`EditorTheme.color.viewport`): the grid
4154
- * recoloured in place (its identity is held by the infrastructure lists), the
4155
- * axis lines shown and coloured when named, and a flat background when
3370
+ * Apply the palette's viewport group (`EditorTheme.color.viewport`): cache the grid
3371
+ * ink for `_applyGridLines`, paint axis-line colours and widths (the view owns visibility),
3372
+ * and a flat background when
4156
3373
  * named — replacing only a background this viewport painted or none at all,
4157
3374
  * never a host's own (a document's declared colour, a world radiance).
4158
3375
  */
@@ -4164,9 +3381,7 @@ export class EditorViewport {
4164
3381
  // stage's colours or keeps none of them: Classic Graphite declares no
4165
3382
  // group and keeps its floor without the grazing fade. Which axis lines
4166
3383
  // show is the view's (`overlays.axes`), whatever the look.
4167
- const grazing = this.grid.material.uniforms['uGrazingFade'];
4168
- if (grazing) grazing.value = look.background !== null ? 1 : 0;
4169
- this._paintLookGrid();
3384
+ this.grid.material.uniforms['uGrazingFade']!.value = look.background !== null ? 1 : 0;
4170
3385
  this._lookAxisHexes = [look.axisX, look.axisY, look.axisZ];
4171
3386
  if (this._axisLines) {
4172
3387
  (this._axisLines.material as LineMaterial).linewidth = this._axisLineCss(look.axisLineWidth);
@@ -4190,22 +3405,7 @@ export class EditorViewport {
4190
3405
  }
4191
3406
 
4192
3407
  /**
4193
- * The floor's TWO levels, from the palette's one grid colour. The grid is
4194
- * the ONE stage surface whose material is tone-mapped (see
4195
- * {@link toneMappedSourceColor}), so both levels are handed to it as the
4196
- * colours that MAP to the palette's.
4197
- *
4198
- * Blender draws both levels from ONE grid colour at two alphas. Measured in
4199
- * `modeling-object-none.png` over its #3f3f3f background: the 1 m line
4200
- * composites to lum 84 — exactly the palette's `#545454` — and the 10 m
4201
- * line to 102, a 1.86x contrast. The palette names the MINOR's finished
4202
- * appearance, so the major is that colour carried the rest of the way by
4203
- * the measured ratio — and the ratio is applied in the FINISHED sRGB space
4204
- * the 84 and the 102 were read in, before the inversion, because ACES is
4205
- * not linear and extrapolating on its far side lands somewhere else.
4206
- */
4207
- /**
4208
- * LIGHT AND DRESS THIS STAGE BY A VIEW'S PRESENTATION (`kit/viewport-presentation`): its studio
3408
+ * LIGHT AND DRESS THIS STAGE BY A VIEW'S PRESENTATION (`@volter/sdk/viewport-presentation`): its studio
4209
3409
  * or preview lighting through a `StagePresentationRig` in this scene (in place of the
4210
3410
  * viewport's own ambient and directional), its selection marks and its grid's major step.
4211
3411
  * The stage's render pipeline keeps its tone mapping. The scene's own lighting is what an
@@ -4240,7 +3440,7 @@ export class EditorViewport {
4240
3440
  rig.dispose();
4241
3441
  this._editorObjects = this._editorObjects.filter((object) => !roots.includes(object));
4242
3442
  if (this._presentation === binding) this._presentation = null;
4243
- const rigOn = this._lightRig && !this._store.isAdoptedSceneActive;
3443
+ const rigOn = this._lightRig;
4244
3444
  this._editorAmbient.visible = rigOn;
4245
3445
  this._editorDirLight.visible = rigOn;
4246
3446
  },
@@ -4252,11 +3452,11 @@ export class EditorViewport {
4252
3452
  return binding.stop;
4253
3453
  }
4254
3454
 
4255
- /** The view's selection marks; a change rebuilds the marks for the current selection. */
4256
- setSelectionMarks(marks: { readonly outline: boolean; readonly wire: boolean; readonly box: boolean }): void {
3455
+ /** The view's wire and box marks; a change rebuilds them for the current selection. */
3456
+ setSelectionMarks(marks: { readonly wire: boolean; readonly box: boolean }): void {
4257
3457
  const current = this._selectionMarks;
4258
- if (current.outline === marks.outline && current.wire === marks.wire && current.box === marks.box) return;
4259
- this._selectionMarks = { outline: marks.outline, wire: marks.wire, box: marks.box };
3458
+ if (current.wire === marks.wire && current.box === marks.box) return;
3459
+ this._selectionMarks = { wire: marks.wire, box: marks.box };
4260
3460
  this._syncBoxHelpers();
4261
3461
  }
4262
3462
 
@@ -4338,23 +3538,21 @@ export class EditorViewport {
4338
3538
 
4339
3539
  /** Minor cells per major line (`overlays.grid.majorEvery`; Blender 10, Godot 8). */
4340
3540
  setGridMajorEvery(every: number): void {
4341
- const uniform = this.grid.material.uniforms['uMajorEvery'];
4342
- if (uniform && Number.isFinite(every) && every >= 1) uniform.value = every;
3541
+ if (Number.isFinite(every) && every >= 1) this.grid.material.uniforms['uMajorEvery']!.value = every;
4343
3542
  }
4344
3543
 
4345
3544
  /** The look's floor lines: widths in device pixels and the major level's contrast. */
4346
- private _gridMajorContrast = 1;
3545
+ private _gridMajorContrast!: number;
4347
3546
  private _applyGridLines(lines: ReturnType<typeof nativeViewportGrid>): void {
4348
3547
  const uniforms = this.grid.material.uniforms;
4349
- if (uniforms['uLineWidth']) uniforms['uLineWidth'].value = lines.lineWidth;
4350
- if (uniforms['uMajorWidth']) uniforms['uMajorWidth'].value = lines.majorWidth;
3548
+ uniforms['uLineWidth']!.value = lines.lineWidth;
3549
+ uniforms['uMajorWidth']!.value = lines.majorWidth;
4351
3550
  this._gridMajorContrast = lines.majorContrast;
4352
3551
  this._paintLookGrid();
4353
3552
  }
4354
3553
 
4355
3554
  private _paintLookGrid(): void {
4356
3555
  const hex = this._lookGridHex;
4357
- if (hex === null) return;
4358
3556
  const backgroundHex = this._lookBackgroundHex ?? 0x3f3f3f;
4359
3557
  const channel = (shift: number) => {
4360
3558
  const line = (hex >> shift) & 0xff;
@@ -4364,18 +3562,11 @@ export class EditorViewport {
4364
3562
  const majorHex = (channel(16) << 16) | (channel(8) << 8) | channel(0);
4365
3563
  const minor = toneMappedSourceColor(hex, this._renderer);
4366
3564
  const major = toneMappedSourceColor(majorHex, this._renderer);
4367
- (this.grid.material.uniforms['uColor']?.value as THREE.Color | undefined)?.copy(minor);
4368
- (this.grid.material.uniforms['uMajorColor']?.value as THREE.Color | undefined)?.copy(major);
3565
+ (this.grid.material.uniforms['uColor']!.value as THREE.Color).copy(minor);
3566
+ (this.grid.material.uniforms['uMajorColor']!.value as THREE.Color).copy(major);
4369
3567
  }
4370
3568
 
4371
- /**
4372
- * THE WORLD'S AXIS LINES, rebuilt from the view's `overlays.axes`, the stage's axis frame and
4373
- * the look's colours. `floor` is the two world axes three's X and Z carry; each shown world
4374
- * axis is drawn along the three axis that carries it. The colours are RAW (`toneMapped: false`,
4375
- * opaque): what is set is what the screen shows — Blender's X plateaus at `#cb293f` and Y at
4376
- * `#69aa15` in `modeling-object-none.png`, which is what its palette names. An axis the look
4377
- * gives no colour takes the gizmo's.
4378
- */
3569
+ /** Build shown world axes from overlays.axes and the stage axis frame. Use raw look colours, falling back to the gizmo palette for unspecified axes. */
4379
3570
  private _rebuildAxisLines(): void {
4380
3571
  const lines = this._axisLines;
4381
3572
  if (!lines) return;
@@ -4389,16 +3580,14 @@ export class EditorViewport {
4389
3580
  this._axesWanted = shown.length > 0;
4390
3581
  lines.visible = this.grid.visible && this._axesWanted;
4391
3582
  if (!this._axesWanted) return;
4392
- // A FRESH GEOMETRY per rebuild: three caches an instanced geometry's instance count at
4393
- // its first draw (`_maxInstanceCount`), so growing one from two lines to three drew only
4394
- // the first two.
3583
+ // Replace geometry on rebuild so its cached instance count matches the current axes.
4395
3584
  const build = (threeAxes: readonly number[]): LineSegmentsGeometry => {
4396
3585
  const positions: number[] = [];
4397
3586
  const colors: number[] = [];
4398
3587
  for (const threeAxis of threeAxes) {
4399
3588
  const world = frame[threeAxis]![0];
4400
- const hex = this._lookAxisHexes[world];
4401
- const color = hex === null || hex === undefined ? this._gizmoAxisColor(world) : new THREE.Color(hex);
3589
+ const hex = this._lookAxisHexes[world]!;
3590
+ const color = hex === null ? this._gizmoAxisColor(world) : new THREE.Color(hex);
4402
3591
  positions.push(...axisSegmentPositions(threeAxis));
4403
3592
  for (let i = 0; i < AXIS_SEGMENTS_PER_AXIS * 2; i++) colors.push(color.r, color.g, color.b);
4404
3593
  }
@@ -4494,14 +3683,12 @@ export class EditorViewport {
4494
3683
  (this._axisLines.material as THREE.Material).dispose();
4495
3684
  this._axisLines = null;
4496
3685
  }
4497
- this.clearCameraView();
4498
3686
  if (this._sparkRenderer) {
4499
3687
  this._scene.remove(this._sparkRenderer);
4500
3688
  disposeSparkRendererWhenIdle(this._sparkRenderer);
4501
3689
  this._sparkRenderer = null;
4502
3690
  }
4503
3691
  this.disconnectInput();
4504
- this._hideDropOverlay();
4505
3692
  for (const helper of this._helpers.values()) this._scene.remove(helper);
4506
3693
  this._helpers.clear();
4507
3694
  for (const helper of this._boxHelpers.values()) {
@@ -4526,8 +3713,9 @@ export class EditorViewport {
4526
3713
  // --- Private ---
4527
3714
 
4528
3715
  private _ensureSparkRenderer(): void {
3716
+ const renderer = this._renderer;
4529
3717
  if (
4530
- !this._renderer ||
3718
+ !renderer ||
4531
3719
  this._sparkRenderer ||
4532
3720
  this._sparkRendererLoading ||
4533
3721
  this._sparkRendererFailed
@@ -4540,8 +3728,8 @@ export class EditorViewport {
4540
3728
  this._sparkRendererLoading = true;
4541
3729
  void import('@sparkjsdev/spark')
4542
3730
  .then(({ SparkRenderer }) => {
4543
- if (this._disposed || !this._renderer) return;
4544
- const spark = new SparkRenderer({ renderer: this._renderer, enableLod: false });
3731
+ if (this._disposed) return;
3732
+ const spark = new SparkRenderer({ renderer, enableLod: false });
4545
3733
  spark.traverse((node) => setUserData(node, 'engineInternal', true));
4546
3734
  this._sparkRenderer = spark;
4547
3735
  invalidateStages();
@@ -4557,21 +3745,7 @@ export class EditorViewport {
4557
3745
  }
4558
3746
 
4559
3747
  /**
4560
- * BLENDER'S NAVIGATION GIZMO — six balls on three axis stalks, not an arrow
4561
- * rig. Measured in `modeling-object-none.png` at matched scale: a 72 CSS px
4562
- * circle of 16 px balls on a 28 px radius; the three POSITIVE balls are
4563
- * filled in their axis colour and carry a dark letter, the three negative
4564
- * ones are a 2 px ring of the same colour over the stage; a 3 px stalk runs
4565
- * from the centre to each positive ball only; and the whole thing fades
4566
- * back-to-front with depth (`_syncOrientationGizmoDepth`, run per frame),
4567
- * which is what makes the near balls read as near.
4568
- *
4569
- * The gizmo camera's frustum is +/-1.5, and the scissored box it draws into
4570
- * is `COMPASS_BOX_PX`, so one unit is `COMPASS_BOX_PX / 3` screen px — every
4571
- * size below is a measured pixel count divided by that.
4572
- */
4573
- /**
4574
- * BUILD (or REBUILD) THE NAVIGATION GIZMO in the look's axis frame.
3748
+ * BUILD (or REBUILD) THE NAVIGATION GIZMO in the presented world's axis frame.
4575
3749
  *
4576
3750
  * Blender's gizmo draws a stalk and a FILLED, LETTERED ball on each axis's
4577
3751
  * POSITIVE side and a hollow one opposite, and the axis it calls Z is the
@@ -4586,16 +3760,15 @@ export class EditorViewport {
4586
3760
  * a fact about the scene and not about what the axis is called.
4587
3761
  */
4588
3762
  private _buildOrientationGizmo(): void {
4589
- if (this._vcScene === undefined) return;
4590
3763
  // Rebuilt on every look change, so what it held is released, textures included.
4591
3764
  for (const held of [...this._vcScene.children]) {
4592
3765
  this._vcScene.remove(held);
4593
3766
  const drawn = held as THREE.Mesh | THREE.Sprite;
4594
3767
  // A sprite's geometry is three's one shared quad: left alone.
4595
- if (!(drawn as THREE.Sprite).isSprite) drawn.geometry?.dispose();
4596
- const material = drawn.material as (THREE.Material & { map?: THREE.Texture | null }) | undefined;
4597
- material?.map?.dispose();
4598
- material?.dispose();
3768
+ if (!(drawn as THREE.Sprite).isSprite) drawn.geometry.dispose();
3769
+ const material = drawn.material as THREE.Material & { map?: THREE.Texture | null };
3770
+ material.map?.dispose();
3771
+ material.dispose();
4599
3772
  }
4600
3773
  this._vcBalls.length = 0;
4601
3774
  this._vcStalks.length = 0;
@@ -4631,7 +3804,7 @@ export class EditorViewport {
4631
3804
  // The look's navigation colours, else its axis colours, else the editor's own.
4632
3805
  const lookAxes = this._gizmoLook.navigation ?? this._gizmoLook.axes;
4633
3806
  const color =
4634
- lookAxes === null ? new THREE.Color(...KIT_GIZMO_AXIS_RGB[sourceAxis]!) : new THREE.Color(lookAxes[sourceAxis]!);
3807
+ lookAxes === null ? this._gizmoAxisColor(sourceAxis) : new THREE.Color(lookAxes[sourceAxis]!);
4635
3808
  const letter = AXIS_LETTER[sourceAxis]!;
4636
3809
  const axis = axes[i]!.clone().multiplyScalar(positive);
4637
3810
 
@@ -4701,11 +3874,11 @@ export class EditorViewport {
4701
3874
  for (let i = 0; i < 3; i++) {
4702
3875
  const [sourceAxis, positive] = this._stageAxisFrame[i]!;
4703
3876
  const color =
4704
- lookAxes === null ? new THREE.Color(...KIT_GIZMO_AXIS_RGB[sourceAxis]!) : new THREE.Color(lookAxes[sourceAxis]!);
3877
+ lookAxes === null ? this._gizmoAxisColor(sourceAxis) : new THREE.Color(lookAxes[sourceAxis]!);
4705
3878
  const letter = AXIS_LETTER[sourceAxis]!;
4706
3879
  const axis = axes[i]!.clone().multiplyScalar(positive);
4707
3880
  if (form === 'triad') {
4708
- // Unreal's triad is about 40 px, opaque and undimmed by facing (`level-editor.png`); its
3881
+ // Unreal's triad is about 40 px, opaque and undimmed by facing; its
4709
3882
  // size is the look's (`navigationSize`, a multiple of this 24 px).
4710
3883
  const scale = this._gizmoLook.navigationSize ?? 1;
4711
3884
  const length = (24 * scale) / perUnit;
@@ -4752,14 +3925,7 @@ export class EditorViewport {
4752
3925
  }
4753
3926
  }
4754
3927
 
4755
- /**
4756
- * The gizmo's depth cue, per frame: a ball pointing AWAY from the viewer is
4757
- * drawn behind, dimmer and SMALLER, exactly as Blender's is. Both laws are
4758
- * measured off the reference's own six balls and stated where they are
4759
- * applied below. Sprites carry no depth of their own here
4760
- * (`depthTest: false`, so the stage never occludes the gizmo), so the draw
4761
- * ORDER is set here too.
4762
- */
3928
+ /** Order navigation sprites by depth and dim and shrink balls that point away from the viewer. */
4763
3929
  private _syncOrientationGizmoDepth(): void {
4764
3930
  const perUnit = COMPASS_BOX_PX / 3;
4765
3931
  const view = this._vcDir.copy(this._vcCamera.position).normalize();
@@ -4767,9 +3933,7 @@ export class EditorViewport {
4767
3933
  // a ball's colour is its axis colour mixed with the viewport's background by its depth
4768
3934
  // (`fading_color`: `(depth + 1) · 0.25 + 0.5`), a negative ball a 25% tint of it ringed in
4769
3935
  // that colour, and a view looking straight down an axis (`axis_align`) hides that axis's far
4770
- // ball, fills its near negative one (ringed halfway to white) and letters it `-Y`. Checked
4771
- // against both reference frames: the default view's +X fill (245,54,81) is this mix at depth
4772
- // 0.82, the front view's (204,55,78) at depth 0.
3936
+ // ball, fills its near negative one (ringed halfway to white) and letters it `-Y`. Use the view-depth mix for the visible axis colours.
4773
3937
  if (this._gizmoLook.navigationForm === 'godot') {
4774
3938
  this._syncGodotNavigation(view);
4775
3939
  return;
@@ -4812,15 +3976,12 @@ export class EditorViewport {
4812
3976
  ringMaterial.color.copy(fading);
4813
3977
  ringMaterial.opacity = 1;
4814
3978
  }
4815
- // The letter's ink is the fill darkened, as measured on Blender's frames (0.356; Blender
4816
- // draws it black at 0.9 over a small glyph, and this is what that reads as).
3979
+ // Letter ink uses the axis fill darkened by 0.356.
4817
3980
  letterMaterial.color.copy(mix(black, fillMaterial.color, COMPASS_LETTER_INK, scratch));
4818
3981
  fill.visible = !alignedBack;
4819
3982
  ring.visible = !alignedBack;
4820
3983
  letter.visible = (positive || axis === aligned) && !alignedBack;
4821
- // The ball's SIZE, on the same quantity: an orthographic gizmo camera gives no depth scale
4822
- // for free, and Blender's six balls measure a straight line in `facing` (the fit is in the
4823
- // box's docblock).
3984
+ // Scale each ball linearly with facing; orthographic projection supplies no depth scaling.
4824
3985
  const scale = (COMPASS_BALL_BACK_PX + COMPASS_BALL_DEPTH_GAIN_PX * facing) / perUnit;
4825
3986
  const order = 10 + Math.round(facing * 100) * 3;
4826
3987
  for (const [part, rank] of [[fill, 0], [ring, 1], [letter, 2]] as const) {
@@ -4893,12 +4054,8 @@ export class EditorViewport {
4893
4054
  }
4894
4055
 
4895
4056
  /**
4896
- * Is this pointer over the orientation cube's screen-space box? The HIT
4897
- * TEST alone, with no side effect, because two gestures need the answer for
4898
- * different reasons: the single click snaps the camera with it, and the
4899
- * double click (drill-in) must simply not treat the cube as geometry —
4900
- * without this guard, double-clicking the cube drilled into whatever the
4901
- * pointer found BEHIND it.
4057
+ * Test the pointer against the orientation cube’s screen-space box without changing state. Single
4058
+ * clicks use it for axis navigation; double-click selection excludes it from scene geometry.
4902
4059
  */
4903
4060
  private _isOverViewCube(clientX: number, clientY: number): boolean {
4904
4061
  // An indicator is only drawn, and a triad has nothing to click: clicks pass to the stage.
@@ -4911,15 +4068,10 @@ export class EditorViewport {
4911
4068
  return x >= vcLeft && x <= vcLeft + this._vcSize && y >= vcTop && y <= vcTop + this._vcSize;
4912
4069
  }
4913
4070
 
4914
- /**
4915
- * WHERE THE NAVIGATION GIZMO'S BOX SITS, top-left in CSS px from the canvas's top-left: the
4916
- * look's corner (`navigationCorner`) — Blender's top right, set by its measured margins, or
4917
- * Unreal's bottom left, one small margin in from both edges — inset by the canvas's bleed.
4918
- */
4071
+ /** Place the gizmo box in the look’s navigation corner, inset by canvas bleed. */
4919
4072
  private _vcOrigin(width: number, height: number, inset: number): { left: number; top: number } {
4920
4073
  if (this._gizmoLook.navigationCorner === 'bottom-left') {
4921
- // Clear of the stage's own camera readout, two lines along the bottom edge (measured: a
4922
- // 4 px margin put the triad's lower half under it).
4074
+ // Leave room above the stage’s two-line camera readout.
4923
4075
  const left = 4;
4924
4076
  const bottom = 48;
4925
4077
  return { left: left + inset, top: height - this._vcSize - bottom - inset };
@@ -5005,8 +4157,8 @@ export class EditorViewport {
5005
4157
  });
5006
4158
  };
5007
4159
  // Primary (if it's a real entity, not pivot dummy)
5008
- const obj = this._activeGizmo.object;
5009
- if (obj && obj !== this._pivotDummy) {
4160
+ const obj = this._activeGizmo.object!;
4161
+ if (obj !== this._pivotDummy) {
5010
4162
  applyLive(this._attachedAuthoringId ?? this._authoringIdForObject(obj), obj);
5011
4163
  }
5012
4164
  // Other selected objects (active-element / individual-origins)
@@ -5195,9 +4347,6 @@ export class EditorViewport {
5195
4347
  targets: {
5196
4348
  id: string;
5197
4349
  obj: THREE.Object3D;
5198
- startPos: THREE.Vector3;
5199
- startQuat: THREE.Quaternion;
5200
- startScale: THREE.Vector3;
5201
4350
  }[],
5202
4351
  ): void {
5203
4352
  // Collect all dragged entity IDs to exclude from raycast
@@ -5214,11 +4363,6 @@ export class EditorViewport {
5214
4363
  // Also include the grid as a snap target
5215
4364
  sceneTargets.push(this.grid);
5216
4365
 
5217
- if (sceneTargets.length === 0) {
5218
- this._surfaceSnapIndicator.visible = false;
5219
- return;
5220
- }
5221
-
5222
4366
  // Raycast downward from the gizmo object position
5223
4367
  const origin = gizmoObj.position.clone();
5224
4368
  origin.y += 50; // Start high above
@@ -5269,7 +4413,7 @@ export class EditorViewport {
5269
4413
  obj.traverse((child) => {
5270
4414
  if (isInEditorOwnedSubtree(child)) return;
5271
4415
  const mesh = child as THREE.Mesh;
5272
- if (!mesh.isMesh || !mesh.geometry) return;
4416
+ if (!mesh.isMesh) return;
5273
4417
  const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
5274
4418
  if (!posAttr) return;
5275
4419
  const count = Math.min(posAttr.count, MAX_PER_MESH);
@@ -5289,7 +4433,7 @@ export class EditorViewport {
5289
4433
  */
5290
4434
  private _applyVertexSnap(
5291
4435
  gizmoObj: THREE.Object3D,
5292
- targets: { obj: THREE.Object3D; startPos: THREE.Vector3 }[],
4436
+ targets: { obj: THREE.Object3D }[],
5293
4437
  ): void {
5294
4438
  const entityPos = gizmoObj.position;
5295
4439
  const projected = entityPos.clone().project(this._screenCamera);
@@ -5323,7 +4467,7 @@ export class EditorViewport {
5323
4467
  }
5324
4468
  this._vertexSnapIndicator.position.copy(bestVertex);
5325
4469
  this._vertexSnapIndicator.visible = true;
5326
- // Constant screen size for indicator
4470
+ // Scale the indicator with its distance from the drawing camera.
5327
4471
  const dist = bestVertex.distanceTo(this._screenCamera.position);
5328
4472
  this._vertexSnapIndicator.scale.setScalar(dist * 0.008);
5329
4473
  } else {
@@ -5339,7 +4483,7 @@ export class EditorViewport {
5339
4483
  this._otherDragObjects = [];
5340
4484
  this._allDragObjects = [];
5341
4485
  this._preservedChildObjects = [];
5342
- // Persist the final transform + mark dirty (ED2). A mid-drag autosave may
4486
+ // Persist the final transform and mark dirty. A mid-drag autosave may
5343
4487
  // have already fired and cleared dirty; this re-schedules the final write.
5344
4488
  const authoring = this._authoring();
5345
4489
  for (const id of editIds) endAuthoringTransformEdit(authoring, id);
@@ -5354,61 +4498,7 @@ export class EditorViewport {
5354
4498
  return false;
5355
4499
  }
5356
4500
 
5357
- /** Recolor gizmo handles to Godot axis colors & shift plane geometry to meet at center. */
5358
- /**
5359
- * THE GIZMO IS A CONSTANT NUMBER OF PIXELS WHEN THE LOOK STATES ONE (owner,
5360
- * 2026-09-21: *"is gizmo perhaps the wrong size?"*).
5361
- *
5362
- * Our gizmo is three's `TransformControls`, whose handles scale as
5363
- * `factor · size / 4` (`TransformControls.js:1539-1551` at the pinned
5364
- * 0.180.0). Work the projection through that and the distance cancels:
5365
- *
5366
- * perspective — `factor = d · min(1.9·tan(fov/2)/zoom, 7)`, and one world
5367
- * unit at distance `d` spans `H·zoom / (2·d·tan(fov/2))` px, so one
5368
- * gizmo-LOCAL unit spans `1.9·size·H/8 = 0.2375 · size · H` px. Constant
5369
- * in `d` and in `zoom`, and a FRACTION OF THE VIEWPORT: at `size` 1 an
5370
- * 800 px viewport gave 190 px per local unit and a 1400 px one gave 332.
5371
- * orthographic — `factor = (top − bottom)/zoom` and one world unit spans
5372
- * `H·zoom / (top − bottom)` px, so one local unit spans `size · H / 4`.
5373
- *
5374
- * Blender's rule is the same SHAPE with its own unit: one gizmo unit is
5375
- * `U.gizmo_size` CSS px, flat (`wm_gizmo.cc:450-474`, and
5376
- * `StageContribution.gizmoSize` for the whole derivation). So the
5377
- * look states Blender's number and this converts it — and the conversion is
5378
- * the one thing neither side can state, because THREE'S UNIT IS HALF
5379
- * BLENDER'S. Measured off the two libraries' own handle geometry, all three
5380
- * families agreeing within 5%:
5381
- *
5382
- * translate arm, origin → arrow tip
5383
- * three 0.6 (stem `lineGeometry2` 0→0.5, `TransformControls.js:1207`;
5384
- * cone 0.1 long placed at 0.5, `:1198-1199`, `:1235`)
5385
- * Blender 1.25 (stem ends at `gizmo_line_range`'s `end` 1.0,
5386
- * `transform_gizmo_3d.cc:1173-1203`; head 0.25,
5387
- * `arrow3d_gizmo.cc:191`) ratio 2.08
5388
- * rotate ring radius
5389
- * three 0.5 (`CircleGeometry(0.5, …)`, `TransformControls.js:1315-1323`)
5390
- * Blender 1.0 (`imm_draw_circle_wire_3d(…, 1.0f, …)`,
5391
- * `dial3d_gizmo.cc:162`) ratio 2.00
5392
- * scale handle, origin → outer face
5393
- * three 0.58 (0.08 box lifted to 0.54, `:1235`-family)
5394
- * Blender 1.10 (line end 1.0 + box `size` 0.05 ×2,
5395
- * `arrow3d_gizmo.cc:175-177`) ratio 1.90
5396
- *
5397
- * so `pixels per three unit = 2 · gizmoSize`, which lands the rotate ring on
5398
- * Blender's 75 px radius exactly and the move arrow at 90 px against
5399
- * Blender's 93.75. The brief this was built from proposed
5400
- * `size = px / (0.2375·H)` — that reads Blender's number as px per THREE
5401
- * unit, which would draw every handle at half Blender's, and the three
5402
- * ratios above are why it is not what landed.
5403
- *
5404
- * A look that states no size keeps `size` 1 — three's own default and what
5405
- * every non-Blender look drew before this existed.
5406
- *
5407
- * CALLED ON EVERY RESIZE (the px↔local conversion has `H` in it), on a
5408
- * projection change (the two branches differ) and on a theme change. It is
5409
- * NOT called per frame: `getComputedStyle` is the expensive half and the
5410
- * theme subscription is what invalidates it.
5411
- */
4501
+ /** Resolve look-defined transform-handle size into TransformControls.size using camera projection and viewport height. Recompute on look changes rather than reading computed style every frame. */
5412
4502
  private _applyGizmoSize(): void {
5413
4503
  // The theme subscription fires SYNCHRONOUSLY on install, which is earlier
5414
4504
  // in the constructor than the controls — the same ordering the grid's own
@@ -5428,39 +4518,11 @@ export class EditorViewport {
5428
4518
  for (const controls of this._allGizmos()) controls.size = size;
5429
4519
  }
5430
4520
 
5431
- /**
5432
- * THE MOVE AND SCALE GIZMOS HAVE THREE ARMS, NOT SIX.
5433
- *
5434
- * three's translate gizmo draws each axis TWICE — a stem with an arrowhead
5435
- * at `+0.5` and a second bare arrowhead at `-0.5`
5436
- * (`TransformControls.js`'s `gizmoTranslate`, two `arrowGeometry` entries
5437
- * per axis) — so the handle set reads as six directions. No editor we draw
5438
- * against does that: Blender's move gizmo is three arms out of the origin
5439
- * plus the plane squares and the white view circle
5440
- * (`transform_gizmo_3d.cc`'s `gizmo_line_range`, and the reference frame
5441
- * `b6/blender-reference/gizmo-move.png`), and measured on the Model
5442
- * document at walk 5 parity row 2 the stray heads read as three extra
5443
- * "axes" pointing back through the object.
5444
- *
5445
- * The PLANE squares, the centre octahedron and every rotate ring are
5446
- * untouched; this drops only the negative-side handles and the pick volumes
5447
- * behind them, so nothing that used to be draggable stops being draggable —
5448
- * a negative handle and its positive twin drive the same axis.
5449
- *
5450
- * WHICH CHILD IS WHICH is read off the GEOMETRY, because three's
5451
- * `setupGizmo` bakes each handle's placement into a cloned geometry and
5452
- * resets the object transform to the identity every frame — the same reason
5453
- * {@link _patchCombinedScaleGizmo} measures bounding boxes rather than
5454
- * positions. A stem spans 0 → 0.5 and centres at +0.25, so only the bare
5455
- * `-0.5` head has a centre on the negative side of its own axis.
5456
- */
5457
- private _dropNegativeAxisHandles(helper: THREE.Object3D, modes: readonly string[]): void {
4521
+ /** Keep only the positive translate and scale heads; identify the negative heads by their geometry centres rather than helper names. */
4522
+ private _dropNegativeAxisHandles(helper: THREE.Object3D, modes: readonly ('translate' | 'scale')[]): void {
5458
4523
  const gizmoNode = helper.children.find(
5459
4524
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5460
- ) as unknown as
5461
- | { gizmo: Record<string, THREE.Object3D>; picker: Record<string, THREE.Object3D> }
5462
- | undefined;
5463
- if (!gizmoNode) return;
4525
+ ) as TransformControlsGizmo;
5464
4526
  const axisOf: Record<string, THREE.Vector3> = {
5465
4527
  X: new THREE.Vector3(1, 0, 0),
5466
4528
  Y: new THREE.Vector3(0, 1, 0),
@@ -5468,23 +4530,19 @@ export class EditorViewport {
5468
4530
  };
5469
4531
  for (const mode of modes)
5470
4532
  for (const group of [gizmoNode.gizmo[mode], gizmoNode.picker[mode]]) {
5471
- if (!group) continue;
5472
4533
  for (const child of [...group.children]) {
5473
4534
  const axis = axisOf[child.name];
5474
4535
  if (axis === undefined) continue;
5475
4536
  const mesh = child as THREE.Mesh;
5476
- if (!mesh.geometry) continue;
5477
4537
  mesh.geometry.computeBoundingBox();
5478
- const bounds = mesh.geometry.boundingBox;
5479
- if (!bounds) continue;
4538
+ const bounds = mesh.geometry.boundingBox!;
5480
4539
  if (bounds.getCenter(new THREE.Vector3()).dot(axis) < 0) group.remove(child);
5481
4540
  }
5482
4541
  }
5483
4542
  }
5484
4543
 
5485
4544
  /**
5486
- * THE COLOUR EACH HANDLE CARRIES, in the frame the look says this stage is
5487
- * presenting.
4545
+ * THE COLOUR EACH HANDLE CARRIES, in the world frame the view presentation supplies.
5488
4546
  *
5489
4547
  * A handle is NAMED for three's axis and COLOURED for the source axis that
5490
4548
  * three axis carries ({@link StageAxisFrame}) — under three's own frame the
@@ -5507,15 +4565,8 @@ export class EditorViewport {
5507
4565
  };
5508
4566
  }
5509
4567
 
5510
- /**
5511
- * READ WHAT THE LOOK SAYS ABOUT THIS STAGE and put the gizmos in that frame.
5512
- *
5513
- * Called once the gizmos exist and again on every theme change, and it is
5514
- * idempotent by construction: the mirror below is applied as the DIFFERENCE
5515
- * between the frame the geometry is already in ({@link _appliedAxisSigns})
5516
- * and the one the look now asks for, and a mirror is its own inverse.
5517
- */
5518
- private _readLookStage(_canvas: HTMLElement): void {
4568
+ /** Apply the current stage axis frame and rebuild the orientation gizmo after a look change. */
4569
+ private _readLookStage(): void {
5519
4570
  this._applyGizmoAxisFrame();
5520
4571
  this._buildOrientationGizmo();
5521
4572
  }
@@ -5523,7 +4574,7 @@ export class EditorViewport {
5523
4574
  /**
5524
4575
  * THE STAGE'S FUNCTION from its view's presentation (ARCHITECTURE.md rule 7): which axis of
5525
4576
  * the presented world is up — what the gizmos name and orient their axes by — and what a box
5526
- * drag selects. Never from the look: a Blender look over a Y-up game world named its axes Z-up.
4577
+ * drag selects. The look does not determine the world's coordinate system.
5527
4578
  */
5528
4579
  setStageFunction(
5529
4580
  world: { readonly upAxis: 'y' | 'z'; readonly handedness: 'right' | 'left' },
@@ -5561,8 +4612,8 @@ export class EditorViewport {
5561
4612
  * colour.
5562
4613
  *
5563
4614
  * three authors its translate arms, its scale stalks and its plane squares
5564
- * on the POSITIVE side of each of its own axes, and B6 drops the stray
5565
- * negative-side heads so the set reads as three arms rather than six. Under
4615
+ * on the positive side of each axis. Negative-side heads are hidden so
4616
+ * the set reads as three arms rather than six. Under
5566
4617
  * a Z-up world one of those three arms is pointing the wrong WAY: the
5567
4618
  * source's +Y is three's −Z, and Blender draws an arm along +Y and nothing
5568
4619
  * along −Y. So the handles of a flipped axis are MIRRORED onto the other
@@ -5598,9 +4649,8 @@ export class EditorViewport {
5598
4649
  // its geometry is not mirrored — doing either would apply the permutation
5599
4650
  // a second time.
5600
4651
  //
5601
- // MEASURED 2026-09-21: with the permutation applied to all three families,
5602
- // the scale handle that had pointed up was mirrored to point DOWN and a
5603
- // drag where it used to be moved nothing at all.
4652
+ // With the permutation applied to all three families, the scale handle
4653
+ // that points up would be mirrored to point DOWN.
5604
4654
  const identity: Record<string, THREE.Color> = {
5605
4655
  X: this._gizmoAxisColor(0),
5606
4656
  Y: this._gizmoAxisColor(1),
@@ -5609,48 +4659,43 @@ export class EditorViewport {
5609
4659
  YZ: this._gizmoAxisColor(0),
5610
4660
  XZ: this._gizmoAxisColor(1),
5611
4661
  };
5612
- // A HANDLE'S MATERIAL IS SHARED ACROSS THE WHOLE INSTANCE — three builds
5613
- // ONE `materialLib` per `TransformControlsGizmo` and hands the same
5614
- // `matGreen` to the translate arm, the rotate ring and the scale stalk of
5615
- // that axis (`TransformControls.js`'s `materialLib`, and the public
5616
- // `setColors` that writes it). So a family that needs its OWN ink must
5617
- // take its own material first; without that the last paint wins for every
5618
- // family at once (measured 2026-09-21: painting scale identity turned the
5619
- // translate arms back to three's colours in the same call).
4662
+ // Clone shared handle materials when a gizmo family needs separate colours, so changing scale colours cannot recolour translate or rotate.
5620
4663
  const paint = (
5621
- group: THREE.Object3D | undefined,
4664
+ group: THREE.Object3D,
5622
4665
  map: Record<string, THREE.Color>,
5623
4666
  flip: boolean,
5624
4667
  ownMaterial: boolean,
5625
4668
  handles: boolean,
5626
4669
  ) => {
5627
- if (!group) return;
5628
4670
  group.traverse((child) => {
5629
- const mesh = child as THREE.Mesh;
4671
+ const mesh = child as THREE.Object3D & {
4672
+ geometry?: THREE.BufferGeometry;
4673
+ material?: (THREE.MeshBasicMaterial | THREE.LineBasicMaterial) & {
4674
+ _color?: THREE.Color | undefined;
4675
+ _opacity?: number | undefined;
4676
+ };
4677
+ };
5630
4678
  if (flip && mesh.geometry) mesh.geometry.scale(delta[0]!, delta[1]!, delta[2]!);
5631
4679
  if (
5632
4680
  ownMaterial &&
5633
4681
  mesh.material &&
5634
- !Array.isArray(mesh.material) &&
5635
4682
  !mesh.userData['volterOwnMaterial']
5636
4683
  ) {
5637
- mesh.material = (mesh.material as THREE.Material).clone();
4684
+ mesh.material = mesh.material.clone();
5638
4685
  mesh.userData['volterOwnMaterial'] = true;
5639
4686
  }
5640
- const material = mesh.material as
5641
- | (THREE.MeshBasicMaterial & { _color?: THREE.Color | undefined })
5642
- | undefined;
5643
- if (!material || Array.isArray(material)) return;
4687
+ const material = mesh.material;
4688
+ if (!material) return;
5644
4689
  if (flip) material.side = THREE.DoubleSide;
5645
4690
  const ink = map[child.name];
5646
- if (ink && material.color) {
4691
+ if (ink) {
5647
4692
  // THE LOOK'S RESTING OPACITY over the handle's own (three draws its plane squares
5648
4693
  // translucent); three caches it like the colour, so the cache is dropped too.
5649
4694
  // Handles only: the drag's axis lines (the `helper` family) keep three's own.
5650
4695
  if (handles) {
5651
4696
  const base = (material.userData['volterBaseOpacity'] ??= material.opacity) as number;
5652
4697
  material.opacity = base * (this._gizmoLook.opacity ?? 1);
5653
- (material as { _opacity?: number | undefined })._opacity = undefined;
4698
+ material._opacity = undefined;
5654
4699
  }
5655
4700
  material.color.copy(ink);
5656
4701
  // THREE CACHES A HANDLE'S RESTING COLOUR ON ITS FIRST UPDATE
@@ -5668,22 +4713,15 @@ export class EditorViewport {
5668
4713
  .getHelper()
5669
4714
  .children.find(
5670
4715
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5671
- ) as unknown as
5672
- | {
5673
- gizmo: Record<string, THREE.Object3D>;
5674
- picker: Record<string, THREE.Object3D>;
5675
- helper: Record<string, THREE.Object3D>;
5676
- }
5677
- | undefined;
5678
- if (!node) continue;
4716
+ ) as TransformControlsGizmo;
5679
4717
  for (const family of ['gizmo', 'picker', 'helper'] as const) {
5680
4718
  // ONLY THE TRANSLATE ARMS ARE MIRRORED. A rotate RING is symmetric
5681
4719
  // about every axis it spans, so the permutation is visible in its
5682
4720
  // colour alone; the scale family is in the object's own frame (above).
5683
4721
  const handles = family === 'gizmo';
5684
- paint(node[family]?.['translate'], permuted, mirrored, false, handles);
5685
- paint(node[family]?.['rotate'], permuted, false, false, handles);
5686
- paint(node[family]?.['scale'], identity, false, true, handles);
4722
+ paint(node[family].translate, permuted, mirrored, false, handles);
4723
+ paint(node[family].rotate, permuted, false, false, handles);
4724
+ paint(node[family].scale, identity, false, true, handles);
5687
4725
  }
5688
4726
  }
5689
4727
  if (mirrored) this._appliedAxisSigns = want as readonly (1 | -1)[];
@@ -5713,17 +4751,7 @@ export class EditorViewport {
5713
4751
  .getHelper()
5714
4752
  .children.find(
5715
4753
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5716
- ) as
5717
- | (THREE.Object3D & {
5718
- gizmo: Record<string, THREE.Object3D>;
5719
- picker: Record<string, THREE.Object3D>;
5720
- axis: string | null;
5721
- mode: string;
5722
- enabled: boolean;
5723
- dragging: boolean;
5724
- })
5725
- | undefined;
5726
- if (!node) return;
4754
+ ) as TransformControlsGizmo & Pick<TransformControls, 'axis' | 'mode' | 'enabled' | 'dragging'>;
5727
4755
  const threeUpdate = node.updateMatrixWorld.bind(node);
5728
4756
  node.updateMatrixWorld = (force?: boolean) => {
5729
4757
  threeUpdate(force);
@@ -5741,7 +4769,7 @@ export class EditorViewport {
5741
4769
  : null;
5742
4770
  if (hidden !== null) {
5743
4771
  for (const family of [node.gizmo, node.picker]) {
5744
- for (const handle of family[node.mode]?.children ?? []) {
4772
+ for (const handle of family[node.mode].children) {
5745
4773
  if (handle.name === hidden) handle.visible = false;
5746
4774
  }
5747
4775
  }
@@ -5757,12 +4785,10 @@ export class EditorViewport {
5757
4785
  )
5758
4786
  return;
5759
4787
  const fixed = node.dragging ? (look.drag ?? look.hover) : look.hover;
5760
- for (const handle of node.gizmo[node.mode]?.children ?? []) {
4788
+ for (const handle of node.gizmo[node.mode].children) {
5761
4789
  if (handle.name !== axis && !axis.split('').some((letter) => handle.name === letter)) continue;
5762
- const material = (handle as THREE.Mesh).material as
5763
- | (THREE.MeshBasicMaterial & { _color?: THREE.Color })
5764
- | undefined;
5765
- if (!material || Array.isArray(material) || !material.color) continue;
4790
+ const material = (handle as THREE.Mesh | THREE.Line).material as
4791
+ (THREE.MeshBasicMaterial | THREE.LineBasicMaterial) & { _color?: THREE.Color };
5766
4792
  if (fixed !== null) {
5767
4793
  material.color.setHex(fixed);
5768
4794
  continue;
@@ -5808,19 +4834,15 @@ export class EditorViewport {
5808
4834
  .getHelper()
5809
4835
  .children.find(
5810
4836
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5811
- ) as unknown as
5812
- | { gizmo: Record<string, THREE.Object3D>; picker: Record<string, THREE.Object3D> }
5813
- | undefined;
5814
- if (!node) return;
4837
+ ) as TransformControlsGizmo;
5815
4838
  const axisIndex: Record<string, number> = { X: 0, Y: 1, Z: 2 };
5816
4839
  const along = (index: number, factor: number): [number, number, number] =>
5817
4840
  [0, 1, 2].map((i) => (i === index ? factor : 1)) as [number, number, number];
5818
- for (const handle of node.gizmo['translate']?.children ?? []) {
4841
+ for (const handle of node.gizmo.translate.children) {
5819
4842
  const index = axisIndex[handle.name];
5820
- const geometry = (handle as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
5821
- if (index === undefined || !geometry) continue;
5822
- const cylinder = geometry as THREE.CylinderGeometry;
5823
- const isHead = cylinder.parameters?.radiusTop === 0;
4843
+ if (index === undefined) continue;
4844
+ const geometry = (handle as THREE.Mesh<THREE.CylinderGeometry>).geometry;
4845
+ const isHead = geometry.parameters.radiusTop === 0;
5824
4846
  if (!isHead) {
5825
4847
  geometry.scale(...along(index, stretch));
5826
4848
  continue;
@@ -5838,10 +4860,10 @@ export class EditorViewport {
5838
4860
  offset[index] = base * stretch;
5839
4861
  geometry.translate(...offset);
5840
4862
  }
5841
- for (const handle of node.picker['translate']?.children ?? []) {
4863
+ for (const handle of node.picker.translate.children) {
5842
4864
  const index = axisIndex[handle.name];
5843
- const geometry = (handle as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
5844
- if (index === undefined || !geometry) continue;
4865
+ if (index === undefined) continue;
4866
+ const geometry = (handle as THREE.Mesh<THREE.CylinderGeometry>).geometry;
5845
4867
  geometry.scale(...along(index, tipScale));
5846
4868
  }
5847
4869
  this._appliedArrow = want;
@@ -5866,14 +4888,13 @@ export class EditorViewport {
5866
4888
  .getHelper()
5867
4889
  .children.find(
5868
4890
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5869
- ) as unknown as { gizmo: Record<string, THREE.Object3D> } | undefined;
5870
- for (const handle of node?.gizmo['rotate']?.children ?? []) {
5871
- const geometry = (handle as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
4891
+ ) as TransformControlsGizmo;
4892
+ for (const handle of node.gizmo.rotate.children) {
5872
4893
  // The axis rings only: the trackball circle and the view ring stay hairlines (Godot's
5873
4894
  // grey circle is one).
5874
4895
  if (!['X', 'Y', 'Z'].includes(handle.name)) continue;
5875
- if (!geometry || (geometry as THREE.TorusGeometry).type !== 'TorusGeometry') continue;
5876
- const radius = (geometry as THREE.TorusGeometry).parameters.radius;
4896
+ const geometry = (handle as THREE.Mesh<THREE.TorusGeometry>).geometry;
4897
+ const radius = geometry.parameters.radius;
5877
4898
  geometry.computeBoundingBox();
5878
4899
  geometry.boundingBox!.getSize(size);
5879
4900
  const normal = size.x <= size.y && size.x <= size.z ? 0 : size.y <= size.z ? 1 : 2;
@@ -5894,8 +4915,6 @@ export class EditorViewport {
5894
4915
  }
5895
4916
 
5896
4917
  private _patchGizmo(gizmoHelper: THREE.Object3D): void {
5897
- const colorMap = this._axisColorMap();
5898
-
5899
4918
  // Plane geometry offsets to bring inner corners to object center
5900
4919
  const planeOffsets: Record<string, [number, number, number]> = {
5901
4920
  XY: [-0.075, -0.075, 0],
@@ -5904,44 +4923,20 @@ export class EditorViewport {
5904
4923
  };
5905
4924
 
5906
4925
  gizmoHelper.traverse((child) => {
5907
- const mesh = child as THREE.Mesh;
5908
-
5909
- // Recolor handle materials
5910
- const newColor = colorMap[child.name];
5911
- if (newColor && mesh.material) {
5912
- const mat = mesh.material as THREE.MeshBasicMaterial | THREE.LineBasicMaterial;
5913
- if (mat.color) mat.color.copy(newColor);
5914
- }
5915
-
5916
4926
  // Shift plane handle geometry
5917
4927
  const offset = planeOffsets[child.name];
5918
- if (offset && mesh.geometry) {
5919
- mesh.geometry.translate(offset[0], offset[1], offset[2]);
4928
+ if (offset) {
4929
+ (child as THREE.Mesh).geometry.translate(offset[0], offset[1], offset[2]);
5920
4930
  }
5921
4931
  });
5922
4932
  }
5923
4933
 
5924
- /**
5925
- * Combined-mode surgery on the aux SCALE gizmo. The stock scale handles
5926
- * share their axis span with the translate arrows (cubes at ±0.5, picker
5927
- * cones 0–0.6 — identical to translate's pickers), so with three
5928
- * synchronized gizmos the translate picker always wins the pointer and
5929
- * scale is undraggable (measured: dragging the X cube moved position).
5930
- * Slide the axis cubes and their pickers outward past the arrow tips, and
5931
- * drop the plane/uniform handles and axis lines whose footprint translate
5932
- * already owns. Placement is baked into geometry by three's setupGizmo, so
5933
- * geometry.translate is frame-safe where object transforms are not
5934
- * (TransformControlsGizmo.updateMatrixWorld resets handle transforms every
5935
- * frame). Single-mode scale (R) uses the primary gizmo and is untouched.
5936
- */
4934
+ /** Shift auxiliary scale cubes and picker geometry clear of the combined tool's translate controls. */
5937
4935
  private _patchCombinedScaleGizmo(helper: THREE.Object3D): void {
5938
4936
  const AXIS_OFFSET = 0.55;
5939
4937
  const gizmoNode = helper.children.find(
5940
4938
  (child) => (child as { isTransformControlsGizmo?: boolean }).isTransformControlsGizmo,
5941
- ) as unknown as
5942
- | { gizmo: Record<string, THREE.Object3D>; picker: Record<string, THREE.Object3D> }
5943
- | undefined;
5944
- if (!gizmoNode) return;
4939
+ ) as TransformControlsGizmo;
5945
4940
  const axisOf: Record<string, THREE.Vector3> = {
5946
4941
  X: new THREE.Vector3(1, 0, 0),
5947
4942
  Y: new THREE.Vector3(0, 1, 0),
@@ -5953,24 +4948,22 @@ export class EditorViewport {
5953
4948
  const CUBE_CENTER = 0.54 + AXIS_OFFSET;
5954
4949
  const PICKER_SIZE = 0.3;
5955
4950
  for (const [group, isPicker] of [
5956
- [gizmoNode.gizmo['scale'], false],
5957
- [gizmoNode.picker['scale'], true],
4951
+ [gizmoNode.gizmo.scale, false],
4952
+ [gizmoNode.picker.scale, true],
5958
4953
  ] as const) {
5959
- if (!group) continue;
5960
4954
  for (const child of [...group.children]) {
5961
- const mesh = child as THREE.Mesh;
4955
+ const mesh = child as THREE.Mesh<THREE.BoxGeometry | THREE.CylinderGeometry>;
5962
4956
  const axis = axisOf[child.name];
5963
4957
  const isAxisLine =
5964
4958
  axis !== undefined &&
5965
- mesh.geometry?.type === 'CylinderGeometry' &&
4959
+ mesh.geometry.type === 'CylinderGeometry' &&
5966
4960
  (mesh.geometry as THREE.CylinderGeometry).parameters.radiusTop < 0.05;
5967
4961
  if (axis === undefined || isAxisLine) {
5968
4962
  group.remove(child);
5969
4963
  continue;
5970
4964
  }
5971
4965
  mesh.geometry.computeBoundingBox();
5972
- const bounds = mesh.geometry.boundingBox;
5973
- if (!bounds) continue;
4966
+ const bounds = mesh.geometry.boundingBox!;
5974
4967
  const sign = Math.sign(bounds.getCenter(new THREE.Vector3()).dot(axis)) || 1;
5975
4968
  if (isPicker) {
5976
4969
  const at = axis.clone().multiplyScalar(sign * CUBE_CENTER);
@@ -6005,8 +4998,6 @@ export class EditorViewport {
6005
4998
  private static _FLY_KEYS = new Set(['w', 'a', 's', 'd', 'q', 'e']);
6006
4999
 
6007
5000
  private _enterFlyMode(): void {
6008
- if (this._flyActive) return;
6009
- if (this._cameraViewMode === 'view') return;
6010
5001
  this._flyActive = true;
6011
5002
  this._snapAnimating = false; // cancel any view snap
6012
5003
  this.orbitControls.enabled = false;
@@ -6017,10 +5008,9 @@ export class EditorViewport {
6017
5008
  }
6018
5009
 
6019
5010
  private _exitFlyMode(): void {
6020
- if (!this._flyActive) return;
6021
5011
  this._flyActive = false;
6022
5012
  this._flyKeys.clear();
6023
- this.orbitControls.enabled = this._cameraViewMode !== 'view';
5013
+ this.orbitControls.enabled = true;
6024
5014
  document.exitPointerLock();
6025
5015
  // Sync orbit target to a point in front of the camera
6026
5016
  const camera = this.renderCamera;
@@ -6078,10 +5068,6 @@ export class EditorViewport {
6078
5068
 
6079
5069
  private _onFlyKeyUp = (e: KeyboardEvent): void => {
6080
5070
  const key = e.key.toLowerCase();
6081
- if (key === 'shift') {
6082
- this._flyKeys.delete('shift');
6083
- return;
6084
- }
6085
5071
  this._flyKeys.delete(key);
6086
5072
  };
6087
5073
 
@@ -6120,11 +5106,8 @@ export class EditorViewport {
6120
5106
  const distance = this.camera.position.distanceTo(this.orbitControls.target);
6121
5107
  const worldPerPixel =
6122
5108
  (2 * Math.tan(((this.camera.fov / 2) * Math.PI) / 180) * distance) / height;
6123
- // Trackpads report fine per-frame pixel deltas; a mouse wheel NOTCH is
6124
- // ±100+ px (or line-mode units), so unclamped Shift+wheel panned a third
6125
- // of the viewport per click and flung a human tester off the map ("went
6126
- // to the bottom, can't get out" — runhuman pass 17). Clamp each event to
6127
- // a modest step; trackpad deltas sit far below the clamp and keep 1:1.
5109
+ // Clamp mouse-wheel pan events to a modest step. Fine trackpad deltas stay below the clamp and
5110
+ // preserve their scale.
6128
5111
  const unit = e.deltaMode === 1 ? 16 : 1;
6129
5112
  const clampStep = (value: number): number =>
6130
5113
  Math.sign(value) * Math.min(Math.abs(value * unit), 30);
@@ -6156,8 +5139,9 @@ export class EditorViewport {
6156
5139
  if (e.button === 0 && !e.altKey) {
6157
5140
  if (this._selectConstraintControlAt(e)) return;
6158
5141
  const mesh = this._raycastSpatialHandle(e);
6159
- const binding = mesh ? spatialHandleBinding(mesh) : null;
6160
- if (mesh && binding?.handle.writable) {
5142
+ if (!mesh) return;
5143
+ const binding = spatialHandleBinding(mesh);
5144
+ if (binding.handle.writable) {
6161
5145
  e.stopPropagation();
6162
5146
  this._activeSpatialHandle = binding;
6163
5147
  this._spatialHandleDragPoint.copy(mesh.position);
@@ -6203,21 +5187,14 @@ export class EditorViewport {
6203
5187
 
6204
5188
  if (!this._marqueeDiv) {
6205
5189
  this._marqueeDiv = document.createElement('div');
6206
- // V-12 — was the string `'999'` (a stray string literal in an
6207
- // otherwise-numeric z-index scale); now the canonical `zIndex.sticky`
6208
- // token. Sticky chrome stays above the editor root while dropdowns
6209
- // retain their distinct higher tier.
5190
+ // Use the sticky chrome tier above the editor root and below dropdowns.
6210
5191
  Object.assign(this._marqueeDiv.style, {
6211
5192
  position: 'fixed',
6212
5193
  pointerEvents: 'none',
6213
5194
  zIndex: zIndex.sticky,
6214
5195
  });
6215
- // INSIDE the theme scope, never document.body: the `--volter-*` tokens
6216
- // live on `#editor-chrome-root` (deliberately not `:root`), so a
6217
- // body-parented div resolves both colors to nothing and the rubber
6218
- // band is INVISIBLE — three human passes marqueed blind, reading the
6219
- // selection count jump 4/0 and calling the tool broken (runhuman
6220
- // 27–29). `position: fixed` keeps the client-coordinate math.
5196
+ // Mount inside the theme scope so --volter-* colors resolve. Fixed positioning keeps the
5197
+ // client-coordinate math.
6221
5198
  (document.getElementById('editor-chrome-root') ?? document.body).appendChild(
6222
5199
  this._marqueeDiv,
6223
5200
  );
@@ -6228,12 +5205,12 @@ export class EditorViewport {
6228
5205
  Object.assign(this._marqueeDiv.style, marqueeStyle(nativeViewportMarqueeDash(this._canvas)));
6229
5206
  }
6230
5207
 
6231
- if (this._marqueeActive && this._marqueeDiv) {
5208
+ if (this._marqueeActive) {
6232
5209
  const left = Math.min(this._pointerDownPos.x, e.clientX);
6233
5210
  const top = Math.min(this._pointerDownPos.y, e.clientY);
6234
5211
  const w = Math.abs(e.clientX - this._pointerDownPos.x);
6235
5212
  const h = Math.abs(e.clientY - this._pointerDownPos.y);
6236
- Object.assign(this._marqueeDiv.style, {
5213
+ Object.assign(this._marqueeDiv!.style, {
6237
5214
  left: `${left}px`,
6238
5215
  top: `${top}px`,
6239
5216
  width: `${w}px`,
@@ -6243,6 +5220,14 @@ export class EditorViewport {
6243
5220
  }
6244
5221
  };
6245
5222
 
5223
+ /** A drag lost its release: a spatial handle goes back uncommitted, a constraint-control
5224
+ * session closes, and the pointer session with them. */
5225
+ private _onInteractionDragLost = (): void => {
5226
+ if (this._activeSpatialHandle) this._finishSpatialHandleDrag(false);
5227
+ this._constraintControlPointerSession = false;
5228
+ this._pointerSessionActive = false;
5229
+ };
5230
+
6246
5231
  private _onPointerUp = (e: PointerEvent): void => {
6247
5232
  // Only complete gestures that STARTED on the canvas (`_onPointerDown`) —
6248
5233
  // this listener lives on the interaction element (see the binding comment
@@ -6260,18 +5245,12 @@ export class EditorViewport {
6260
5245
  return;
6261
5246
  }
6262
5247
 
6263
- // Handle marquee select. D12 (B4) scope note: marquee stays THREEJS-ONLY —
6264
- // it only ever scans `this._objectMap` (the Three world's live
6265
- // objects), never the composite's other (react/pixi) layered children. A
6266
- // layered/rect-based marquee across every visible layer is out of scope
6267
- // for this task (D12) — single-click picking is layered (below), marquee
6268
- // is not.
5248
+ // Marquee scans this viewport's native object map. Other composite roots are outside
5249
+ // its screen-projection selection; single-click picking below can cross those roots.
6269
5250
  if (this._marqueeActive) {
6270
5251
  this._marqueeActive = false;
6271
5252
  this.orbitControls.enabled = true;
6272
- if (this._marqueeDiv) {
6273
- this._marqueeDiv.style.display = 'none';
6274
- }
5253
+ this._marqueeDiv!.style.display = 'none';
6275
5254
 
6276
5255
  // Find all entities whose screen projection falls within the marquee
6277
5256
  const left = Math.min(this._pointerDownPos.x, e.clientX);
@@ -6283,33 +5262,12 @@ export class EditorViewport {
6283
5262
  const corner = new THREE.Vector3();
6284
5263
  const bounds = new THREE.Box3();
6285
5264
  for (const [id, obj] of this._objectMap) {
6286
- // Skip invisible entities — mirrors `raycastPick`'s single-click
6287
- // behavior for free (THREE.Raycaster's own `intersectObject` already
6288
- // stops descending into an `obj.visible === false` subtree, so a
6289
- // single click can never land on one). Marquee did its own screen-
6290
- // space rect test with no such check, so a world hidden via the
6291
- // group-row eye (`world-hidden-viewport.ts` — forces `obj.visible =
6292
- // false` for the whole world while hidden) stayed marquee-selectable
6293
- // even though it was invisible and unclickable. Also correctly skips
6294
- // an entity hidden via its OWN eye, for the same reason.
5265
+ // Skip objects whose own visibility is off.
6295
5266
  if (!obj.visible) continue;
6296
- // A CAMERA never joins a marquee. Its empty bounds fall to the
6297
- // origin test, so a rectangle around scene content kept catching the
6298
- // template's authored camera — and camera selection pops the live
6299
- // preview overlay, which two consecutive human passes read as "it
6300
- // instantly plays" and which then sat over the viewport blocking
6301
- // their next drag (runhuman passes 27/28; play state was measured
6302
- // stopped throughout). Click and hierarchy selection still reach it.
5267
+ // Exclude cameras from marquee selection; click and hierarchy selection still reach them.
6303
5268
  if ((obj as THREE.Camera).isCamera === true) continue;
6304
- // CONTAINMENT of the projected bounds, not origin-in-rect: every
6305
- // environment fixture's ORIGIN (the ground plane, the sky dome, the
6306
- // Environment group — all at scene center) landed inside any central
6307
- // rectangle, so circling three boxes selected seven things and a
6308
- // human tester gave up on marquee ("I still select more than what is
6309
- // needed" — runhuman pass 25). A rectangle dragged AROUND something
6310
- // selects what fits inside it; a fixture larger than the rectangle
6311
- // cannot. Entities with no renderable bounds (lights, cameras,
6312
- // empty groups) keep the origin test — circling a light selects it.
5269
+ // Test projected geometry bounds against the rectangle using the presentation’s selection rule.
5270
+ // Objects with no renderable bounds use their projected origin.
6313
5271
  bounds.setFromObject(obj);
6314
5272
  if (bounds.isEmpty()) {
6315
5273
  const screen = this._projectToScreen(obj);
@@ -6318,21 +5276,7 @@ export class EditorViewport {
6318
5276
  }
6319
5277
  continue;
6320
5278
  }
6321
- // WHAT THE RECTANGLE MEANS IS THE LOOK'S TO SAY (`boxSelect`): the
6322
- // editor's own answer is CONTAINMENT, and Blender's Select Box is
6323
- // TOUCH — its object-mode box select reads the object-id buffer under
6324
- // the rectangle, so any drawn part inside it selects the object. Both
6325
- // are real answers and the field's own note carries why neither can be
6326
- // the other's default. Measured 2026-09-21 on the Model document: a
6327
- // rectangle holding the whole cube and cutting the torus selected the
6328
- // cube alone, where Blender takes both.
6329
- //
6330
- // TOUCH is the projected bounding box INTERSECTING the rectangle,
6331
- // which is a superset of Blender's drawn-pixel test — an honest
6332
- // approximation, and the closest one available without a picking
6333
- // buffer. It is stated rather than hidden: a long thin diagonal object
6334
- // whose box crosses the rectangle while none of its pixels do is the
6335
- // case where the two differ.
5279
+ // Use the presentation’s boxSelect rule: containment or overlap. Geometry bounds approximate Blender’s pixel-touch selection and can include a thin diagonal that draws outside the rectangle.
6336
5280
  const touch = this._stageBoxSelect === 'touch';
6337
5281
  const rect = this._canvas.getBoundingClientRect();
6338
5282
  // Through the camera on screen: an orthographic document view or a camera view draws with
@@ -6369,8 +5313,7 @@ export class EditorViewport {
6369
5313
  }
6370
5314
 
6371
5315
  if (e.shiftKey) {
6372
- // Add to existing selection (additive modifier — SelectionProvider
6373
- // extension is Phase-F; first-party store op for now).
5316
+ // Extend the store selection for the additive modifier.
6374
5317
  for (const id of hitIds) this._store.shell.addToSelection(id);
6375
5318
  } else {
6376
5319
  setAuthoringSelection(this._authoring(), hitIds);
@@ -6389,63 +5332,26 @@ export class EditorViewport {
6389
5332
  // Don't interfere with gizmo clicks
6390
5333
  if (this._anyGizmoDragging()) return;
6391
5334
 
6392
- // Check orientation gizmo click first (spec 28 — the cube isn't rendered
6393
- // without a visible three surface, so it can't be clicked either)
5335
+ // Check the orientation gizmo first; it is clickable only on a visible three surface.
6394
5336
  if (this._threejsToolContextActive && this._handleViewCubeClick(e)) return;
6395
5337
 
6396
- // D12 (B4) — topmost-first layered pick: the composite's non-threejs
6397
- // children (react/pixi design-time layers) are tried topmost-first before
6398
- // falling through to the three raycast (now `VolterSceneAuthoringAdapter
6399
- // .pickable`) — see `pickTopmost`'s doc comment. Note: an INTERACTIVE
6400
- // layer (session `interactive` toggle, B1) is mutually exclusive with this
6401
- // walk running at all, by construction — its `pointer-events:auto` DOM
6402
- // intercepts the event before it ever reaches this canvas listener, so
6403
- // there is no special-case here for it.
5338
+ // Try the active adapter’s pickable layers from topmost to bottommost. Interactive DOM layers
5339
+ // intercept their own pointer events before they reach this canvas.
6404
5340
  const deepSelection = e.shiftKey && (e.metaKey || e.ctrlKey);
6405
- // Figma's outside-click rule: a click the open scope does not contain
6406
- // LEAVES that scope, and empty space closes every level. The pick resolves
6407
- // against the scope that WAS open, so `pickAcrossScopeExit` lets the stack
6408
- // follow the hit and re-resolves once when it moved — without that, a
6409
- // three-deep stack answered one level too shallow and took a second click
6410
- // to land (`authoring/selection-scope.ts`).
5341
+ // A click outside the open scope leaves it; empty space closes every level. pickAcrossScopeExit
5342
+ // updates the scope stack and resolves the hit again when the stack changes.
6411
5343
  const entityId = pickAcrossScopeExit(this._scopeAuthoring(), () =>
6412
5344
  this._pickAt(e.clientX, e.clientY, deepSelection ? 'deep' : 'normal'),
6413
5345
  );
6414
5346
 
6415
- if (deepSelection) {
6416
- setAuthoringSelection(this._authoring(), entityId ? [entityId] : []);
6417
- } else if (e.shiftKey && entityId) {
6418
- // TOGGLE, not add. On a canvas this modifier is the ONE gesture for
6419
- // "and also this / no, not that one" — Figma, Blender and Unity all
6420
- // remove an already-selected object on a second shift-click, and adding
6421
- // only meant a mis-click was unrecoverable: "if it's like 20 items and
6422
- // you mess up one, you're gonna have to start all over" (runhuman pass
6423
- // 94, their top finding). The Hierarchy keeps the LIST convention
6424
- // instead — shift extends a RANGE there — because a row list has an
6425
- // order for a range to mean something and a viewport does not.
6426
- this._store.shell.toggleSelection(entityId);
6427
- } else if ((e.metaKey || e.ctrlKey) && entityId) {
5347
+ if (!deepSelection && entityId && (e.shiftKey || e.metaKey || e.ctrlKey)) {
6428
5348
  this._store.shell.toggleSelection(entityId);
6429
5349
  } else {
6430
5350
  setAuthoringSelection(this._authoring(), entityId ? [entityId] : []);
6431
5351
  }
6432
5352
  };
6433
5353
 
6434
- /**
6435
- * The adapter a selection SCOPE is keyed on.
6436
- *
6437
- * `authoring/selection-scope.ts` keys its stack by adapter IDENTITY, and the
6438
- * Hierarchy — its breadcrumb, and its own double-click scope entry — resolves
6439
- * the PANEL adapter (`authoring/panel-authoring.ts`), which for a Scene
6440
- * document is the composite's Three child, not the composite itself.
6441
- * A scope opened against a different object of the same tree is invisible
6442
- * there (measured: a viewport drill-in selected the deeper member correctly
6443
- * and the breadcrumb never appeared). One resolution, so the viewport gesture
6444
- * and the panel are talking about the same scope.
6445
- *
6446
- * A host that INJECTED its own adapter (the Object3D document's own viewport)
6447
- * is not driving the shared panels at all and keeps its own.
6448
- */
5354
+ /** Key selection scopes to the same panel adapter used by the Hierarchy. An injected document adapter keeps its own scope. */
6449
5355
  private _scopeAuthoring(): AuthoringAdapter {
6450
5356
  return this._standaloneAuthoring
6451
5357
  ? this._authoring()
@@ -6461,25 +5367,10 @@ export class EditorViewport {
6461
5367
  }
6462
5368
 
6463
5369
  /**
6464
- * Double-click = DRILL IN — the viewport's way INTO a component instance.
6465
- *
6466
- * A single click resolves to the OUTERMOST component boundary under the
6467
- * pointer (`SelectionProvider.resolve`), which is why a whole arena selects
6468
- * as one instance no matter where you click it. That is Figma's instance
6469
- * semantics and it is correct; what was missing was the way in. Double-click
6470
- * opens that instance's scope (`enterSelectionScope`) and selects the next
6471
- * member down the same owner chain — repeat to keep descending, Escape
6472
- * (`editor-hotkeys.ts`) walks back out, and a click outside the open scope
6473
- * leaves it (`_onPointerUp`).
6474
- *
6475
- * The deeper member is not computed here: `layered-pick.ts` already hands
6476
- * `currentSelectionScopeId()` to the adapter's own resolve, so re-running the
6477
- * ORDINARY pick with the scope open is what answers. No second chain rule,
6478
- * nothing per-surface, no new state.
6479
- *
6480
- * The two single-clicks that precede this both select that same outermost
6481
- * boundary, so they cannot conflict with the drill — they are the step it
6482
- * starts from.
5370
+ * Double-click opens the selected component’s scope and selects the next member down its owner
5371
+ * chain. Escape walks out and an outside click leaves the scope. The ordinary layered pick
5372
+ * resolves through the adapter with currentSelectionScopeId, including the two single-clicks
5373
+ * preceding this gesture.
6483
5374
  */
6484
5375
  private _onDoubleClick = (e: MouseEvent): void => {
6485
5376
  if (e.button !== 0) return;
@@ -6498,181 +5389,4 @@ export class EditorViewport {
6498
5389
  this._pickAt(e.clientX, e.clientY, 'normal'),
6499
5390
  );
6500
5391
  };
6501
-
6502
- // --- Asset drag-and-drop onto viewport ---
6503
-
6504
- /**
6505
- * Screen → world point for the path-probe tool (W1a): prefer a hit on the
6506
- * baked navmesh overlay (guarantees a point on the mesh), fall back to the
6507
- * ground plane so probing still lands a marker pre-bake.
6508
- */
6509
- /**
6510
- * Raycast from screen coordinates to the DECLARED ground plane. Returns world
6511
- * position or null.
6512
- */
6513
- private _raycastGroundPlane(e: { clientX: number; clientY: number }): THREE.Vector3 | null {
6514
- const rect = this._canvas.getBoundingClientRect();
6515
- const mouse = new THREE.Vector2(
6516
- ((e.clientX - rect.left) / rect.width) * 2 - 1,
6517
- -((e.clientY - rect.top) / rect.height) * 2 + 1,
6518
- );
6519
- this.raycaster.setFromCamera(mouse, this._screenCamera);
6520
- const basis = presentationRegionBasis('three');
6521
- this._groundPlane.set(
6522
- basis.up === 'z' ? UP_Z : UP_Y,
6523
- // `Plane.constant` is the SIGNED distance from the origin along -normal,
6524
- // so a ground at `h` is `-h` here.
6525
- -basis.groundHeight,
6526
- );
6527
- const target = new THREE.Vector3();
6528
- const hit = this.raycaster.ray.intersectPlane(this._groundPlane, target);
6529
- return hit;
6530
- }
6531
-
6532
- private _showDropOverlay(): void {
6533
- if (this._dropOverlay) return;
6534
- const overlay = document.createElement('div');
6535
- // V-12 — the stray string `'5'` (below every named tier in the z-index
6536
- // scale) becomes the numeric `zIndex.base`, the lowest defined tier,
6537
- // preserving its relative order (still below the marquee's `zIndex.
6538
- // sticky` and every dropdown/menu). The semantic accent tokens reconcile this
6539
- // block's former one-off blue onto the canonical palette accent tint per
6540
- // the audit's own §3 absorption list.
6541
- Object.assign(overlay.style, {
6542
- position: 'fixed',
6543
- inset: '0',
6544
- pointerEvents: 'none',
6545
- border: `2px dashed ${themeVars.accent.default}`,
6546
- background: themeVars.accent.muted,
6547
- zIndex: zIndex.base,
6548
- });
6549
- // Same class as the marquee band: `--volter-*` resolves only under
6550
- // `#editor-chrome-root`, so a body-parented overlay draws with NO border
6551
- // or fill — the drag-to-viewport highlight has been invisible.
6552
- (document.getElementById('editor-chrome-root') ?? document.body).appendChild(overlay);
6553
- this._dropOverlay = overlay;
6554
- }
6555
-
6556
- private _hideDropOverlay(): void {
6557
- if (this._dropOverlay) {
6558
- this._dropOverlay.remove();
6559
- this._dropOverlay = null;
6560
- }
6561
- }
6562
-
6563
- private _onDragOver = (e: DragEvent): void => {
6564
- if (!e.dataTransfer?.types.includes('application/x-editor-asset')) return;
6565
- e.preventDefault();
6566
- e.dataTransfer.dropEffect = 'copy';
6567
- this._showDropOverlay();
6568
- };
6569
-
6570
- private _onDragLeave = (_e: DragEvent): void => {
6571
- this._hideDropOverlay();
6572
- };
6573
-
6574
- private _onDrop = (e: DragEvent): void => {
6575
- this._hideDropOverlay();
6576
- const raw = e.dataTransfer?.getData('application/x-editor-asset');
6577
- if (!raw) return;
6578
- e.preventDefault();
6579
-
6580
- let data: {
6581
- path: string;
6582
- kind: string;
6583
- name: string;
6584
- component?: AssetDropContext['item'];
6585
- online?: OnlineAssetDrop;
6586
- };
6587
- try {
6588
- data = JSON.parse(raw);
6589
- } catch {
6590
- return;
6591
- }
6592
-
6593
- // Find drop position via ground-plane raycast, fallback to the ground's own
6594
- // origin. The two in-plane components are rounded to centimetres; the UP
6595
- // component is pinned to the DECLARED ground height rather than to a
6596
- // hardcoded 0 — which axis that is comes from the region's basis, not from
6597
- // an assumption that every game is Y-up.
6598
- const basis = presentationRegionBasis('three');
6599
- const worldPos = this._raycastGroundPlane(e);
6600
- const cm = (value: number): number => Math.round(value * 100) / 100;
6601
- const pos: [number, number, number] =
6602
- basis.up === 'z'
6603
- ? [cm(worldPos?.x ?? 0), cm(worldPos?.y ?? 0), basis.groundHeight]
6604
- : [cm(worldPos?.x ?? 0), basis.groundHeight, cm(worldPos?.z ?? 0)];
6605
-
6606
- // A viewport drop is an AUTHORING op, so it goes through the active
6607
- // adapter's `assetDrop` provider — not straight into the first-party
6608
- // document. An adapter that declares no `assetDrop` (for example a LIVE
6609
- // adopted play scene) accepts nothing: dropping into it used to mutate the
6610
- // retired descriptor document of a world that was not even on screen.
6611
- // Absent ⇒ nothing happens, which is the honest degrade.
6612
- const authoring = this._authoring();
6613
- const assetDrop = authoring.assetDrop;
6614
- const item: AssetDropContext['item'] = data.component ?? {
6615
- kind: 'file',
6616
- name: data.name,
6617
- };
6618
- // A refused drop SAYS so: the silent degrade read as "drag and drop is
6619
- // not working" to a human who tried it both ways (runhuman pass 45).
6620
- if (!assetDrop) {
6621
- editorConsole.warn(
6622
- `Dropped “${data.name}” was not placed: this document does not accept dropped assets.`,
6623
- 'editor',
6624
- );
6625
- return;
6626
- }
6627
-
6628
- // Online asset: the SHELL's resolver turns it into a project-local path
6629
- // (cache, file list, download, history — see `OnlineAssetResolver`), then
6630
- // it takes the same adapter drop path a local asset takes.
6631
- //
6632
- // The shell owns no document, so there is nothing to place a progress
6633
- // placeholder INTO: the resolve settles silently and a failure is reported
6634
- // to the console.
6635
- if (data.online) {
6636
- const online = data.online;
6637
- const resolveOnline = this._onlineAssetResolver;
6638
- // Same rule as a missing `assetDrop` above: a refused drop SAYS so,
6639
- // naming the missing collaborator rather than swallowing the gesture.
6640
- if (!resolveOnline) {
6641
- editorConsole.warn(
6642
- `Dropped “${data.name}” was not placed: this viewport has no online-asset resolver installed.`,
6643
- 'editor',
6644
- );
6645
- return;
6646
- }
6647
- // The download takes seconds and used to be silent: "it does not
6648
- // seem to be adding… oh, that took some time, but it did happen"
6649
- // (runhuman pass 136). Say it where the hand is — a cached asset
6650
- // answers at once and the two hints simply follow each other.
6651
- showTransientHint(`Downloading ${online.name} from the library…`);
6652
- resolveOnline(online)
6653
- .then((path) => {
6654
- // `null` = the resolver already reported why on the console.
6655
- if (path) {
6656
- showTransientHint(`${online.name} downloaded — placing it.`);
6657
- void dropAuthoringAsset(authoring, '', path, { position: pos });
6658
- } else {
6659
- showTransientHint(`Could not download ${online.name} — see the Console.`);
6660
- }
6661
- })
6662
- // Browser mode (no Node dev server) throws instead of resolving —
6663
- // the one failure nobody else reported, and no unhandled rejection.
6664
- .catch(() => {
6665
- showTransientHint(`Could not download ${online.name} — see the Console.`);
6666
- editorConsole.error(`Could not download ${online.name}.`, 'asset');
6667
- });
6668
- return;
6669
- }
6670
-
6671
- // Model / image / audio / component: the adapter owns what an asset path
6672
- // MEANS in its world, and its `drop` plans the same answer `accepts` would
6673
- // — but when it refuses it SAYS WHY (transient hint + console), which a
6674
- // boolean pre-check never could. The viewport contributes only the world
6675
- // point under the cursor.
6676
- void dropAuthoringAsset(authoring, '', data.path, { position: pos, item });
6677
- };
6678
5392
  }