@volter/editor-threejs 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +8 -13
  3. package/contributions/animation-mixers.service.ts +2 -4
  4. package/contributions/animation-timeline.utility.tsx +1 -2
  5. package/contributions/three.page.ts +50 -0
  6. package/dist-node/serving.mjs +84 -3
  7. package/dist-node/shared-three.mjs +145 -0
  8. package/package.json +296 -16
  9. package/serving/animation-stamp.ts +1 -1
  10. package/serving/index.ts +22 -5
  11. package/serving/model-import-conversion.ts +6 -3
  12. package/serving/shared-three.ts +176 -0
  13. package/src/adapter/constraint.ts +1 -18
  14. package/src/adapter/object3d-authoring-subject.ts +1 -12
  15. package/src/adapter/reflection-probe.ts +1 -9
  16. package/src/adapter/three-contract.ts +6 -60
  17. package/src/animation/live-mixers.ts +0 -4
  18. package/src/api.ts +7 -0
  19. package/src/asset-loaders.ts +2 -178
  20. package/src/capture/photograph-sampling.ts +333 -0
  21. package/src/capture/scene.ts +1 -1
  22. package/src/ecs/object-marks.ts +6 -14
  23. package/src/ecs/user-data.ts +3 -138
  24. package/src/kit/animation/three-clips-subject.ts +19 -42
  25. package/src/kit/asset-compare-core.ts +161 -0
  26. package/src/kit/asset-compare.ts +8 -27
  27. package/src/kit/asset-preview-command.ts +14 -19
  28. package/src/kit/asset-preview-framing.ts +8 -67
  29. package/src/kit/asset-preview.ts +109 -342
  30. package/src/kit/asset-workflow/model-inspection.ts +11 -154
  31. package/src/kit/asset-workflow/preview-resource-lifetime.ts +25 -0
  32. package/src/kit/authoring/component-instance-root.ts +3 -39
  33. package/src/kit/authoring/live-object-transform.ts +1 -30
  34. package/src/kit/authoring/object3d-document-session-registry.ts +7 -15
  35. package/src/kit/authoring/object3d-document-session.ts +471 -208
  36. package/src/kit/authoring/shell-viewport-policy.ts +18 -24
  37. package/src/kit/authoring/source-object3d-authoring-adapter.ts +6 -48
  38. package/src/kit/authoring/three-projection-core.ts +11 -40
  39. package/src/kit/authoring/viewport-pick-context.ts +5 -12
  40. package/src/kit/authoring/viewport-raycast.ts +15 -93
  41. package/src/kit/authoring/world-hidden-viewport.ts +8 -61
  42. package/src/kit/capture-camera-pose.ts +68 -0
  43. package/src/kit/collaboration-presence.ts +41 -0
  44. package/src/kit/components/CameraInfo.tsx +4 -6
  45. package/src/kit/components/InspectorObjectPreview.tsx +3 -30
  46. package/src/kit/components/Object3DDocumentToolbar.css +75 -0
  47. package/src/kit/components/Object3DDocumentToolbar.tsx +18 -84
  48. package/src/kit/components/Object3DDocumentViewport.tsx +6 -11
  49. package/src/kit/components/StageHost.css +40 -0
  50. package/src/kit/components/StageHost.tsx +192 -439
  51. package/src/kit/components/StageOverlays.tsx +1 -2
  52. package/src/kit/components/StatsOverlay.tsx +5 -17
  53. package/src/kit/components/ViewportFurniture.tsx +27 -154
  54. package/src/kit/components/ViewportOverlay.tsx +13 -17
  55. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  56. package/src/kit/components/ViewportShadingMenu.tsx +2 -42
  57. package/src/kit/components/ViewportViewMenu.tsx +7 -18
  58. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +6 -17
  59. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +9 -11
  60. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +31 -133
  61. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +10 -12
  62. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +4 -8
  63. package/src/kit/components/asset-viewers/Object3DPreview.tsx +13 -64
  64. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +6 -10
  65. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +48 -65
  66. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  67. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +2 -14
  68. package/src/kit/components/object3d-contribution-surfaces.tsx +5 -3
  69. package/src/kit/components/stage-keyboard.tsx +6 -12
  70. package/src/kit/components/stage-overlay-set.tsx +11 -20
  71. package/src/kit/components/stage-presence-markers.ts +9 -32
  72. package/src/kit/components/stage-transform-chrome.ts +4 -4
  73. package/src/kit/components/stage-transform-tools.tsx +7 -14
  74. package/src/kit/components/standard-viewport-dressing.ts +47 -167
  75. package/src/kit/components/world-root-binding.ts +2 -7
  76. package/src/kit/constraint-helper.ts +1 -1
  77. package/src/kit/document-renderer-session.ts +114 -0
  78. package/src/kit/editor-presentation-activity.ts +58 -0
  79. package/src/kit/editor-shell-store.ts +31 -403
  80. package/src/kit/editor-viewport.ts +486 -1762
  81. package/src/kit/entity-lod.ts +3 -16
  82. package/src/kit/entity-object.ts +1 -39
  83. package/src/kit/hierarchy-mark-reader.ts +13 -30
  84. package/src/kit/instanced-presentation.ts +1 -47
  85. package/src/kit/live-gesture-lock.ts +35 -0
  86. package/src/kit/live-module-source.ts +12 -52
  87. package/src/kit/model-thumbnail.ts +7 -35
  88. package/src/kit/play-camera-flight.ts +7 -16
  89. package/src/kit/projection/three.ts +13 -366
  90. package/src/kit/retained-document-states.ts +91 -0
  91. package/src/kit/scene-document-viewport.ts +3 -3
  92. package/src/kit/scene-framing.ts +11 -152
  93. package/src/kit/scene-view-fog.ts +2 -47
  94. package/src/kit/spatial-handle-visuals.ts +19 -28
  95. package/src/kit/stories/three-story-model.ts +5 -33
  96. package/src/kit/three-canvas-render.ts +3 -4
  97. package/src/kit/three-hierarchy-row-media.ts +3 -11
  98. package/src/kit/three-inspection-media.ts +6 -6
  99. package/src/kit/three-integration.ts +18 -18
  100. package/src/kit/three-state.ts +2 -19
  101. package/src/kit/three-viewport/camera-fit.ts +2 -6
  102. package/src/kit/three-viewport/selection-brackets.ts +5 -20
  103. package/src/kit/three-viewport/selection-outline.ts +4 -56
  104. package/src/kit/three-viewport/skeleton-helper.ts +1 -32
  105. package/src/kit/three-viewport/source-color.ts +1 -33
  106. package/src/kit/three-viewport/studio-environment.ts +4 -55
  107. package/src/kit/three-viewport-presentation.ts +23 -0
  108. package/src/kit/transport-strip.tsx +147 -0
  109. package/src/kit/viewport-actions.ts +8 -50
  110. package/src/kit/viewport-authoring-policy.ts +5 -52
  111. package/src/kit/viewport-commands.ts +28 -57
  112. package/src/kit/viewport-hotkeys.ts +29 -31
  113. package/src/kit/viewport-status-facet.ts +4 -5
  114. package/src/loader.ts +8 -71
  115. package/src/object3d-contributions.ts +18 -34
  116. package/src/viewport/clip-planes.ts +4 -32
  117. package/src/viewport/content-bounds.ts +13 -142
  118. package/src/viewport/editor-layers.ts +4 -26
  119. package/src/viewport/environment.ts +1 -1
  120. package/src/viewport/preview-renderer.ts +7 -45
  121. package/src/viewport/renderer-ownership.ts +14 -62
  122. package/src/viewport-api.ts +14 -33
  123. package/src/viewport-door.ts +16 -86
  124. package/contributions/three-integration.service.ts +0 -13
  125. package/src/adapter/hierarchy-marks.ts +0 -156
  126. package/src/adapter/ingest/scene-capture.ts +0 -843
  127. package/src/adapter/ingest/structural-ids.ts +0 -139
  128. package/src/adapter/ingest/visible-capture-window.ts +0 -302
  129. package/src/adapter/renderer-config.ts +0 -115
  130. package/src/asset-parse-error.ts +0 -29
  131. package/src/host-hierarchy-objects.ts +0 -31
  132. package/src/kit/authoring/design-time-settle.ts +0 -343
  133. package/src/kit/camera-authoring.ts +0 -175
@@ -1,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,23 @@ 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
+ * What the floor's overlays (its grid and its axes) are drawn with: a surface in their own plane, a level's ground at
482
+ * 0, shows them on it, as Blender's grid passes its depth test on a tie (`DRW_STATE_DEPTH_LESS_EQUAL`,
483
+ * overlay_grid.hh). three's depth test passes a tie too; the offset keeps it where the two surfaces' depths round
484
+ * apart, which would split it pixel by pixel.
485
+ */
486
+ const ON_THE_FLOOR = { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 } as const;
487
+
488
+ /**
489
+ * The floor grid as a shader over one plane (`extent` metres square, y = 0).
490
+ * Lines are `fwidth`-antialiased so they keep one on-screen width at every
491
+ * distance; the 1 m level fades out where its cells fall under a few pixels
492
+ * (Blender's adaptive grid never lets lines merge), the 10 m level stays; the
493
+ * whole floor fades to its edge. `uColor` is the palette's grid colour
494
+ * (`_applyViewportLook`). Depth-tested, never written: content standing on
495
+ * the floor occludes it, and it occludes nothing.
496
+ */
701
497
  function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial> {
702
498
  const geometry = new THREE.PlaneGeometry(extent, extent);
703
499
  geometry.rotateX(-Math.PI / 2);
@@ -715,7 +511,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
715
511
  uFadeEnd: { value: extent / 2 },
716
512
  // The grazing fade's amount, 0..1 — set by `_applyViewportLook` from
717
513
  // the one predicate that already decides whether a look paints the
718
- // viewport at all. See {@link GRAZING_FADE_GLSL} for the measurement.
514
+ // viewport at all. {@link GRAZING_FADE_GLSL} computes its angle profile.
719
515
  uGrazingFade: { value: 0 },
720
516
  // The plane the grid lies in: the floor, or an axis-aligned orthographic view's own plane
721
517
  // (`EditorViewport.alignGridToView`). A unit axis.
@@ -756,12 +552,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
756
552
  uniform float uReach;
757
553
  varying vec3 vWorld;
758
554
  ${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.
555
+ // Device-pixel line coverage with a full-strength core and a one-pixel antialiased edge.
765
556
  float gridLine(vec2 coord, float width) {
766
557
  vec2 d = max(fwidth(coord), vec2(1e-6));
767
558
  vec2 dist = abs(fract(coord - 0.5) - 0.5) / d;
@@ -791,12 +582,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
791
582
  float pixel = max(fwidth(world).x, fwidth(world).y);
792
583
  float bottomAlpha = uMinorFade * (1.0 - smoothstep(uUnit * 0.015625, uUnit * 0.25, pixel));
793
584
  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.)
585
+ // Normalize the theme grid alpha for a single aligned pass. Emphasis uses full opacity.
800
586
  float levelAlpha = (top > 0.5 || middle > 0.5 ? 1.0 : bottom * bottomAlpha) * mix(0.533, 1.0, emphasis);
801
587
  float edge = 1.0 - smoothstep(uFadeStart * uReach, uFadeEnd * uReach, length(world - uCenter));
802
588
  float alignedAlpha = levelAlpha * edge * uOpacity;
@@ -807,37 +593,17 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
807
593
  }
808
594
  vec2 pixelsPerMetre = 1.0 / max(fwidth(p), vec2(1e-6));
809
595
  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.
596
+ // Fade minor lines as their cells approach pixel size.
814
597
  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.
598
+ // Major lines use their own width and color.
821
599
  float major = gridLine(p / uMajorEvery, uMajorWidth);
822
600
  float line = max(minor, major);
823
601
  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.
602
+ // Apply the floor’s grazing-angle attenuation independently of its distance fade.
828
603
  fade *= volterGrazingFade(vWorld, cameraPosition, uPlaneNormal, uGrazingFade);
829
604
  float alpha = line * fade * uOpacity;
830
605
  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.
606
+ // Mix colors by the major line’s share of coverage, including antialiased skirts.
841
607
  float majorShare = clamp(major / max(line, 1e-6), 0.0, 1.0);
842
608
  gl_FragColor = vec4(mix(uColor, uMajorColor, majorShare), alpha);
843
609
  #include <colorspace_fragment>
@@ -846,6 +612,7 @@ function createFloorGrid(extent: number): THREE.Mesh<THREE.PlaneGeometry, THREE.
846
612
  transparent: true,
847
613
  depthWrite: false,
848
614
  side: THREE.DoubleSide,
615
+ ...ON_THE_FLOOR,
849
616
  });
850
617
  const mesh = new THREE.Mesh(geometry, material);
851
618
  mesh.name = 'editor-floor-grid';
@@ -890,6 +657,8 @@ export class EditorViewport {
890
657
  readonly camera: THREE.PerspectiveCamera;
891
658
  readonly orthographicCamera: THREE.OrthographicCamera;
892
659
  readonly orbitControls: OrbitControls;
660
+ /** Fit clip planes only for the editor-owned camera; a live project camera retains its own projection. */
661
+ readonly projectionOwner = { owned: false };
893
662
  readonly transformControls: TransformControls;
894
663
  /** The rotate/scale halves of the 'combined' gizmo (Unity's Transform
895
664
  * tool). Constructed always, shown only in combined mode — three's
@@ -906,16 +675,14 @@ export class EditorViewport {
906
675
  })();
907
676
  /** The floor: a shader grid (`createFloorGrid`), not line geometry. */
908
677
  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. */
678
+ /** Keyed `bounds:<id>`, `selection:<id>` or `wire:<id>:<index>` for diagnostic boxes,
679
+ * selection brackets and mesh wires. A rebuild disposes every stored helper. */
913
680
  private _boxHelpers = new Map<string, BoundsHelper>();
914
- /** The view's selection marks (`kit/viewport-presentation` overlays.selection): the native
681
+ /** The view's selection marks (`@volter/sdk/viewport-presentation` overlays.selection): the native
915
682
  * outline is the stage's own switch; `box` brackets every selected object, not only those
916
683
  * without geometry (Godot); `wire` draws the selected meshes' wireframes (Unity). */
917
- private _selectionMarks = { outline: true, wire: false, box: false };
918
- private _unsubscribeSelectionTheme: () => void = () => {};
684
+ private _selectionMarks = { wire: false, box: false };
685
+ private _unsubscribeSelectionTheme: () => void;
919
686
  private _constraintHelpers = new Map<string, ConstraintHelper>();
920
687
  private _constraintControlRaycaster = (() => {
921
688
  const raycaster = new THREE.Raycaster();
@@ -929,7 +696,7 @@ export class EditorViewport {
929
696
  private _cameraHelpers = new Map<string, { source: THREE.Camera; helper: THREE.CameraHelper }>();
930
697
  private _lightHelpers = new Map<
931
698
  string,
932
- { source: THREE.Light; helper: THREE.Object3D & { update?(): void; dispose?(): void } }
699
+ { source: THREE.Light; helper: THREE.Object3D & { update(): void; dispose(): void } }
933
700
  >();
934
701
  private _audioHelpers = new Map<string, { source: THREE.Object3D; helper: THREE.Group }>();
935
702
  private _spatialHandleRoots: THREE.Object3D[] = [];
@@ -944,8 +711,8 @@ export class EditorViewport {
944
711
  private _spatialHandleDragPlane = new THREE.Plane();
945
712
  private _spatialHandleDragOffset = new THREE.Vector3();
946
713
  private _spatialHandleDragPoint = new THREE.Vector3();
947
- private _orbitEnabledBeforeHandleDrag = true;
948
- private _transformEnabledBeforeHandleDrag = true;
714
+ private _orbitEnabledBeforeHandleDrag!: boolean;
715
+ private _transformEnabledBeforeHandleDrag!: boolean;
949
716
  private _gizmoHelper: THREE.Object3D;
950
717
  private _editorAmbient: THREE.AmbientLight;
951
718
  private _editorDirLight: THREE.DirectionalLight;
@@ -953,53 +720,50 @@ export class EditorViewport {
953
720
  private readonly _lightRig: boolean;
954
721
 
955
722
  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}. */
723
+ /** Cached world AABB of non-editor content; empty until the first _contentBounds refresh. */
959
724
  private readonly _contentBoundsBox = new THREE.Box3();
960
725
  /** `performance.now()` of the last content-bounds measurement; 0 invalidates
961
726
  * it (a scene swap), so the next reader re-measures immediately. */
962
727
  private _contentBoundsAt = 0;
963
- private _renderer: THREE.WebGLRenderer | undefined;
728
+ private readonly _renderer: THREE.WebGLRenderer | undefined;
964
729
  private _sparkRenderer: SparkRenderer | null = null;
965
730
  private _sparkRendererLoading = false;
966
731
  private _sparkRendererFailed = false;
967
732
  private _lastSparkDiscoveryAt = Number.NEGATIVE_INFINITY;
968
733
  private _disposed = false;
969
734
  /** Editor infrastructure objects that must move with the active scene. */
970
- private _editorObjects: THREE.Object3D[] = [];
735
+ private _editorObjects: THREE.Object3D[];
971
736
  /** The view presentation this stage is lit by, when bound (`bindPresentation`). */
972
737
  private _presentation: { readonly rig: StagePresentationRig; readonly stop: () => void } | null = null;
973
738
  /** The world's axis lines: WHICH show is the view's (`overlays.axes`, {@link setAxisLines}),
974
739
  * their colours and width the look's (`color.viewport.axisX/Y/Z`, `axisLineWidth`). */
975
- private _axisLines: LineSegments2 | null = null;
740
+ private _axisLines: LineSegments2 | null;
976
741
  /** The world's vertical axis line, a child of {@link _axisLines} (see `_rebuildAxisLines`). */
977
742
  private _verticalAxisLine: LineSegments2 | null = null;
978
743
  /** The look's axis-line width in device pixels (null: the editor's own), which an
979
744
  * axis-aligned view sets aside for one pixel. */
980
- private _axisLineWidth: number | null = null;
745
+ private _axisLineWidth!: number | null;
981
746
  /** A device-pixel width as `LineMaterial` takes it: CSS pixels, three keeping its viewport in
982
747
  * CSS units. The editor's own is 2 CSS. */
983
748
  private _axisLineCss(device: number | null): number {
984
749
  return device === null ? 2 : device / (this._renderer?.getPixelRatio() ?? 1);
985
750
  }
986
- private _axesWanted = false;
751
+ private _axesWanted!: boolean;
987
752
  private _stageAxes: ViewportOverlays['axes'] = 'floor';
988
753
  /** 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];
754
+ private _lookAxisHexes!: readonly [number | null, number | null, number | null];
990
755
  /** 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. */
756
+ private _lookGridHex!: number;
757
+ /** The palette's background hex, used to recognize the viewport's own background
758
+ * and preserve a host-supplied background. */
995
759
  private _lookBackgroundHex: number | null = null;
996
760
  private _lookToneMapping: THREE.ToneMapping | null = null;
997
- private _lookExposure: number | null = null;
761
+ private _lookExposure!: number;
998
762
  /** The active look's `stage.gizmoSize`, px per Blender gizmo unit
999
763
  * — null under every look that names none. Cached off the theme token
1000
764
  * rather than read per frame; {@link _applyGizmoSize} is what turns it into
1001
765
  * three's `size`. */
1002
- private _lookGizmoSizePx: number | null = null;
766
+ private _lookGizmoSizePx!: number | null;
1003
767
  /** The stage's world up axis (its presentation's `world.upAxis`, `setStageFunction`), as
1004
768
  * the table every gizmo label and colour reads (see {@link StageAxisFrame}). Defaults to
1005
769
  * three's own frame. */
@@ -1010,23 +774,8 @@ export class EditorViewport {
1010
774
  * makes a delta enough. */
1011
775
  private _appliedAxisSigns: readonly (1 | -1)[] = [1, 1, 1];
1012
776
  /** 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
- };
777
+ * editor's own axis colours, three's yellow highlight and each handle's native opacity. */
778
+ private _gizmoLook!: NativeGizmoLook;
1030
779
  /** The stage's box-select rule (its presentation's `interaction.boxSelect`) — `'touch'` is
1031
780
  * Blender's Select Box, `'contain'` (and null) the editor's own. Read by the marquee at the
1032
781
  * moment it resolves, never cached into the rectangle. */
@@ -1048,24 +797,19 @@ export class EditorViewport {
1048
797
  /** Camera input belongs to the whole viewport surface, including authoring
1049
798
  * overlays rendered above the WebGL canvas. */
1050
799
  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;
800
+ /** Relay double-clicks from the element OrbitControls captures. */
801
+ private readonly _onInteractionDblClick: (e: MouseEvent) => void;
1057
802
  private readonly _authoring: () => AuthoringAdapter;
1058
803
  private readonly _pick: ((clientX: number, clientY: number) => string | null) | undefined;
1059
804
  private readonly _drawCamera: (() => THREE.Camera) | undefined;
1060
805
  /** The camera on screen: the stage's drawing camera where it states one, else the viewport's.
1061
806
  * 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. */
807
+ * fly mode moves the viewport's camera; grid alignment takes its caller's camera. */
1063
808
  private get _screenCamera(): THREE.Camera {
1064
809
  return this._drawCamera?.() ?? this.renderCamera;
1065
810
  }
1066
811
  private readonly _standaloneAuthoring: boolean;
1067
812
  private readonly _publishPickContext: boolean;
1068
- private readonly _onlineAssetResolver: OnlineAssetResolver | undefined;
1069
813
  private readonly _onProjectionChange: ((projection: ThreeViewportProjection) => void) | undefined;
1070
814
  private _pointerDownPos = new THREE.Vector2();
1071
815
  private _skipNextSync = false;
@@ -1075,14 +819,14 @@ export class EditorViewport {
1075
819
  /** True between a canvas pointerdown and its pointerup — see `_onPointerUp`. */
1076
820
  private _pointerSessionActive = false;
1077
821
  private _marqueeDiv: HTMLDivElement | null = null;
1078
- private _pointerDownButton = -1;
822
+ private _pointerDownButton!: number;
1079
823
  private _altDragOrbit = false;
1080
824
 
1081
825
  // -- Orientation gizmo --
1082
826
  private _vcScene = new THREE.Scene();
1083
827
  private _vcCamera: THREE.OrthographicCamera;
1084
828
  private _vcClickTargets: THREE.Mesh[] = [];
1085
- /** The gizmo's six balls and three stalks, kept for the per-frame depth
829
+ /** The gizmo's six balls and six stalks, kept for the per-frame depth
1086
830
  * cue (`_syncOrientationGizmoDepth`). */
1087
831
  private _vcBalls: Array<{
1088
832
  readonly fill: THREE.Sprite;
@@ -1100,7 +844,7 @@ export class EditorViewport {
1100
844
  readonly positive: boolean;
1101
845
  readonly color: THREE.Color;
1102
846
  }> = [];
1103
- /** The cone form's cones: drawn front to back, never dimmed (Unity's are not). */
847
+ /** Cone heads and triad lines, depth-sorted without dimming. */
1104
848
  private _vcSolids: Array<{ readonly mesh: THREE.Mesh; readonly direction: THREE.Vector3 }> = [];
1105
849
  private _vcSize = COMPASS_BOX_PX;
1106
850
  /** The view's navigation gizmo (`overlays.navigation`): clicked, only drawn, or neither. */
@@ -1112,54 +856,24 @@ export class EditorViewport {
1112
856
  private _vcMarginRight = COMPASS_MARGIN_RIGHT_PX;
1113
857
  private _vcMarginTop = COMPASS_MARGIN_TOP_PX;
1114
858
  /** See {@link EditorViewportOptions.chromeInsetPx}. */
1115
- private _chromeInsetPx = 0;
859
+ private readonly _chromeInsetPx: number;
1116
860
  /** Scratch dir vector reused each frame by renderViewCube (avoids per-frame alloc). */
1117
861
  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
862
  private _projection: ThreeViewportProjection = 'perspective';
1124
- private _pendingProjection: ThreeViewportProjection | null = null;
1125
- private _viewportAspect = 1;
863
+ private _viewportAspect: number;
1126
864
  /** The view's field of view (`ViewportCamera.fov`), held on its side as the region reshapes. */
1127
865
  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). */
866
+ private _orthographicHeight!: number;
867
+ /** Inputs last reconciled by syncFromStore's helper membership and visibility work. */
1135
868
  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
- */
869
+ private _lastGizmoSig!: string;
870
+ private _lastGizmoEpoch!: number;
871
+ /** Selection ownership and surface visibility cached for helper, handle, gizmo and input consumers. */
1145
872
  private _threejsToolContextActive = false;
1146
873
  /** A three stage is painted here (selection or not) — the floor's and the compass's gate. */
1147
874
  private _threeSurfaceShowing = false;
1148
875
  private _vcRaycaster = new THREE.Raycaster();
1149
876
 
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
877
  // -- Multi-drag: track primary object's transform at drag start --
1164
878
  private _dragStartPos = new THREE.Vector3();
1165
879
  private _dragStartQuat = new THREE.Quaternion();
@@ -1218,30 +932,28 @@ export class EditorViewport {
1218
932
  * debug mesh, … — scene-root objects toggled by the Helpers menu. */
1219
933
  private _helpers = new Map<string, THREE.Object3D>();
1220
934
 
1221
- // -- NavMesh path-probe visuals (W1a Navigation window) --
1222
-
1223
- // -- Snap-to-view animation --
935
+ // Snap-to-view animation.
1224
936
  private _snapAnimating = false;
1225
- private _snapStartTime = 0;
937
+ private _snapStartTime!: number;
1226
938
  private _snapDuration = 400;
1227
939
  private _snapQ1 = new THREE.Quaternion();
1228
940
  private _snapQ2 = new THREE.Quaternion();
1229
- private _snapDist = 0;
941
+ private _snapDist!: number;
1230
942
 
1231
943
  // -- Fly camera (right-click + WASD) --
1232
944
  private _flyActive = false;
1233
945
  private _snapHold = false;
1234
946
  private _flyKeys = new Set<string>();
1235
947
  private _flySpeed = 5;
1236
- private _flyEuler = new THREE.Euler(0, 0, 0, 'YXZ');
948
+ private _flyEuler = new THREE.Euler();
1237
949
  private _rightMouseDown = false;
1238
950
 
1239
951
  constructor(
1240
952
  canvas: HTMLCanvasElement,
1241
953
  scene: THREE.Scene,
1242
954
  store: EditorShellStore,
1243
- interactionElement: HTMLElement = canvas,
1244
- options: EditorViewportOptions = {},
955
+ interactionElement: HTMLElement,
956
+ options: EditorViewportOptions,
1245
957
  ) {
1246
958
  // The integration's registrations (the authoring policy this viewport asks, its verbs, the
1247
959
  // view-state persistence) exist before any viewport does; idempotent.
@@ -1258,7 +970,6 @@ export class EditorViewport {
1258
970
  this._standaloneAuthoring = options.authoring !== undefined;
1259
971
  this._publishPickContext = options.publishPickContext ?? true;
1260
972
  this._chromeInsetPx = options.chromeInsetPx ?? 0;
1261
- this._onlineAssetResolver = options.onlineAssetResolver;
1262
973
  this._onProjectionChange = options.onProjectionChange;
1263
974
 
1264
975
  // Camera — enableAll so editor sees both game content (layer 0) and editor objects (layer 31)
@@ -1283,63 +994,36 @@ export class EditorViewport {
1283
994
  this.orthographicCamera.layers.enableAll();
1284
995
  this._syncOrthographicFromPerspective();
1285
996
 
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.
997
+ // Draw anti-aliased screen-width floor lines, adaptively fading fine levels while retaining the major grid and radial edge fade.
1295
998
  this.grid = createFloorGrid(400);
1296
999
  this.grid.layers.set(EDITOR_LAYER);
1297
1000
  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.
1001
+ // Draw world axes as screen-space lines sized by the viewport look and projection.
1301
1002
  const axisGeometry = new LineSegmentsGeometry();
1302
- axisGeometry.setPositions(axisSegmentPositions(0));
1303
- axisGeometry.setColors(new Array(AXIS_SEGMENTS_PER_AXIS * 2 * 3).fill(1));
1304
1003
  this._axisLines = new LineSegments2(
1305
1004
  axisGeometry,
1306
1005
  new LineMaterial({
1307
1006
  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.
1007
+ // Keep axis colours at full strength while blending their draw order over the floor.
1314
1008
  transparent: true,
1315
1009
  opacity: 1,
1316
1010
  depthWrite: false,
1317
1011
  toneMapped: false,
1012
+ ...ON_THE_FLOOR,
1318
1013
  }),
1319
1014
  );
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.
1015
+ // Share the floor's grazing fade. LineMaterial interpolates the segment endpoints with
1016
+ // clip w intact, keeping the world-position varying perspective-correct along each axis.
1327
1017
  applyAxisGrazingFade(
1328
1018
  this._axisLines.material as LineMaterial,
1329
1019
  this.grid.material.uniforms['uGrazingFade'] as { value: number },
1330
1020
  this.grid.material.uniforms['uPlaneNormal'] as { value: THREE.Vector3 },
1331
1021
  );
1332
- this._axisLines.visible = false;
1333
1022
  this._axisLines.layers.set(EDITOR_LAYER);
1334
1023
  scene.add(this._axisLines);
1335
1024
 
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')".
1025
+ // Subscribe after constructing the grid and axis lines: subscribeNativeSelectionTheme
1026
+ // invokes its listener synchronously, and that listener applies their viewport look.
1343
1027
  this._unsubscribeSelectionTheme = subscribeNativeSelectionTheme(canvas, () => {
1344
1028
  invalidateStages();
1345
1029
  // The selection marks carry their look whole (colour, box form and frame, wire colour and
@@ -1361,14 +1045,13 @@ export class EditorViewport {
1361
1045
  this._lookGizmoSizePx = nativeViewportGizmoSize(canvas);
1362
1046
  this._applyGizmoSize();
1363
1047
  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);
1048
+ // Refresh the gizmos for the current look and presentation frame.
1049
+ // The initial callback precedes transform-control construction.
1050
+ this._readLookStage();
1368
1051
  });
1369
1052
 
1370
1053
  // Editor lighting (on EDITOR_LAYER so game camera doesn't see them)
1371
- this._lightRig = options?.lightRig !== false;
1054
+ this._lightRig = options.lightRig !== false;
1372
1055
  this._editorAmbient = new THREE.AmbientLight(0xffffff, 0.5);
1373
1056
  this._editorAmbient.layers.set(EDITOR_LAYER);
1374
1057
  scene.add(this._editorAmbient);
@@ -1376,7 +1059,7 @@ export class EditorViewport {
1376
1059
  this._editorDirLight.position.set(10, 20, 10);
1377
1060
  this._editorDirLight.layers.set(EDITOR_LAYER);
1378
1061
  scene.add(this._editorDirLight);
1379
- // `_setScene` is where the rig's visibility is decided for every scene
1062
+ // `setScene` is where the rig's visibility is decided for every scene
1380
1063
  // AFTER this one; the first scene arrives here, so it is decided here too.
1381
1064
  this._editorAmbient.visible = this._lightRig;
1382
1065
  this._editorDirLight.visible = this._lightRig;
@@ -1386,15 +1069,7 @@ export class EditorViewport {
1386
1069
  this.batchedRenderer.layers.set(EDITOR_LAYER);
1387
1070
  scene.add(this.batchedRenderer);
1388
1071
 
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.
1072
+ // Register trackpad navigation before OrbitControls so it owns the wheel: horizontal or Shift wheel pans in screen space.
1398
1073
  interactionElement.addEventListener('wheel', this._onTrackpadWheel, { passive: false });
1399
1074
 
1400
1075
  // OrbitControls: the editor's own mouse is right-drag to orbit, middle-drag to pan and the
@@ -1403,24 +1078,21 @@ export class EditorViewport {
1403
1078
  // (its mouse look) the browser refuses capture with an uncaught InvalidStateError on each
1404
1079
  // click. Capture is meaningless under a lock (every move already comes to the locked
1405
1080
  // element), so it is skipped then.
1406
- const capture = interactionElement.setPointerCapture.bind(interactionElement);
1081
+ // Look up capture on the element's prototype at each call so editor.document.click can
1082
+ // suppress capture while dispatching a synthetic pointer, which has no browser pointer id.
1083
+ const realm = (interactionElement.ownerDocument.defaultView ?? window) as Window & typeof globalThis;
1407
1084
  interactionElement.setPointerCapture = (pointerId: number) => {
1408
1085
  if (interactionElement.ownerDocument.pointerLockElement) return;
1409
- capture(pointerId);
1086
+ realm.Element.prototype.setPointerCapture.call(interactionElement, pointerId);
1410
1087
  };
1411
1088
  this.orbitControls = new OrbitControls(this.camera, interactionElement);
1412
1089
  this.orbitControls.enableDamping = true;
1413
1090
  this.orbitControls.dampingFactor = 0.1;
1414
1091
  this.applyKeymapNavigation();
1415
- this._unsubscribeKeymap = subscribeEditorKeymap(() => this.applyKeymapNavigation());
1092
+ this._unsubscribeKeymap = editorHost().keyboard.subscribe(() => this.applyKeymapNavigation());
1416
1093
  this._installTurntable();
1417
1094
 
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).
1095
+ // Register translate, scale and rotate controls in pointer-priority order; combined mode shows all three.
1424
1096
  this.transformControls = new TransformControls(this.camera, canvas);
1425
1097
  this._auxScaleControls = new TransformControls(this.camera, canvas);
1426
1098
  this._auxScaleControls.setMode('scale');
@@ -1428,12 +1100,7 @@ export class EditorViewport {
1428
1100
  this._auxRotateControls.setMode('rotate');
1429
1101
  this._activeGizmo = this.transformControls;
1430
1102
  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.
1103
+ // Refresh the hovered drag plane before pointerDown reads it, including a hover first established by a touch press.
1437
1104
  const pressed = gizmoControls.pointerDown.bind(gizmoControls);
1438
1105
  gizmoControls.pointerDown = (pointer) => {
1439
1106
  gizmoControls.getHelper().updateMatrixWorld(true);
@@ -1443,8 +1110,7 @@ export class EditorViewport {
1443
1110
  gizmoControls.getRaycaster().layers.enableAll();
1444
1111
  gizmoControls.addEventListener('dragging-changed', (event) => {
1445
1112
  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.
1113
+ // Hold the design-session remount while a transform drag owns the live objects.
1448
1114
  if (dragging) beginLiveGesture();
1449
1115
  else endLiveGesture();
1450
1116
  this.orbitControls.enabled = !dragging;
@@ -1454,7 +1120,7 @@ export class EditorViewport {
1454
1120
  for (const other of this._allGizmos()) {
1455
1121
  if (other !== gizmoControls) other.enabled = false;
1456
1122
  }
1457
- const gizmoObj = gizmoControls.object;
1123
+ const gizmoObj = gizmoControls.object!;
1458
1124
  const channel =
1459
1125
  gizmoControls.mode === 'translate'
1460
1126
  ? 'position'
@@ -1464,63 +1130,61 @@ export class EditorViewport {
1464
1130
  const writable = (id: string): boolean =>
1465
1131
  this._authoring().transforms?.editability?.(id, channel).writable ?? true;
1466
1132
  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
- }
1133
+ this._attachedAuthoringId ?? this._authoringIdForObject(gizmoObj) ?? '';
1134
+ this._dragStartPos.copy(gizmoObj.position);
1135
+ this._dragStartQuat.copy(gizmoObj.quaternion);
1136
+ this._dragStartScale.copy(gizmoObj.scale);
1137
+ this._otherDragObjects = [];
1138
+ this._allDragObjects = [];
1139
+ this._pivotPos.copy(gizmoObj.position);
1140
+ const primaryId =
1141
+ gizmoObj === this._pivotDummy ? null : this._authoringIdForObject(gizmoObj);
1142
+ if (!primaryId) {
1143
+ // Without a semantic primary ID, derive drag subjects from the writable selection.
1144
+ for (const id of this._store.shell.selectedEntityIds) {
1145
+ if (!writable(id)) continue;
1146
+ const obj = this._objectForAuthoringId(id);
1147
+ if (!obj) continue;
1148
+ this._allDragObjects.push({
1149
+ id,
1150
+ obj,
1151
+ startPos: obj.position.clone(),
1152
+ startQuat: obj.quaternion.clone(),
1153
+ startScale: obj.scale.clone(),
1154
+ });
1155
+ }
1156
+ } else {
1157
+ for (const id of this._store.shell.selectedEntityIds) {
1158
+ if (id === primaryDragId) continue;
1159
+ if (!writable(id)) continue;
1160
+ const obj = this._objectForAuthoringId(id);
1161
+ if (!obj) continue;
1162
+ this._otherDragObjects.push({
1163
+ id,
1164
+ obj,
1165
+ startPos: obj.position.clone(),
1166
+ startQuat: obj.quaternion.clone(),
1167
+ startScale: obj.scale.clone(),
1168
+ });
1505
1169
  }
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
1170
  }
1171
+ const editIds =
1172
+ gizmoObj === this._pivotDummy
1173
+ ? this._allDragObjects.map(({ id }) => id)
1174
+ : [primaryDragId, ...this._otherDragObjects.map(({ id }) => id)];
1175
+ this._preservedChildObjects = this._preparePreservedChildren(editIds.filter(Boolean));
1176
+ this._activeTransformEditIds = [
1177
+ ...new Set([
1178
+ ...editIds.filter(Boolean),
1179
+ ...this._preservedChildObjects.map(({ id }) => id),
1180
+ ]),
1181
+ ];
1182
+ // Begin every semantic source edit before TransformControls mutates
1183
+ // any native object. Source-backed adapters need the pre-drag pose of
1184
+ // secondary selections too; physics-backed adapters freeze each body.
1185
+ const authoring = this._authoring();
1186
+ for (const id of this._activeTransformEditIds)
1187
+ beginAuthoringTransformEdit(authoring, id);
1524
1188
  } else {
1525
1189
  // Hide snap indicators when drag ends
1526
1190
  this._surfaceSnapIndicator.visible = false;
@@ -1530,8 +1194,7 @@ export class EditorViewport {
1530
1194
  }
1531
1195
  });
1532
1196
  gizmoControls.addEventListener('objectChange', () => {
1533
- const gizmoObj = gizmoControls.object;
1534
- if (!gizmoObj) return;
1197
+ const gizmoObj = gizmoControls.object!;
1535
1198
  const mode = gizmoControls.mode;
1536
1199
  // A CENTRE-anchored gizmo takes the individual-origins math on purpose:
1537
1200
  // its anchor is where the handles are DRAWN, not a pivot the edit is
@@ -1599,9 +1262,8 @@ export class EditorViewport {
1599
1262
  }
1600
1263
  this._restorePreservedChildren();
1601
1264
 
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.
1265
+ // Bounds helpers and selection brackets update mid-drag; mesh wires and
1266
+ // diagnostic wire cubes are LineSegments without an update method.
1605
1267
  for (const helper of this._boxHelpers.values()) {
1606
1268
  if ('update' in helper) helper.update();
1607
1269
  }
@@ -1650,9 +1312,7 @@ export class EditorViewport {
1650
1312
  this._vertexSnapIndicator.layers.set(EDITOR_LAYER);
1651
1313
  scene.add(this._vertexSnapIndicator);
1652
1314
 
1653
- // Patch gizmo: recolour handles to the look's axis colours and fix plane geometry.
1654
- this._gizmoLook = nativeGizmoLook(canvas);
1655
- this._rebuildAxisLines();
1315
+ // Install handle highlights and align the gizmo geometry.
1656
1316
  for (const controls of this._allGizmos()) this._installGizmoHighlight(controls);
1657
1317
  this._patchGizmo(this._gizmoHelper);
1658
1318
  this._patchGizmo(this._auxRotateControls.getHelper());
@@ -1661,22 +1321,14 @@ export class EditorViewport {
1661
1321
  this._dropNegativeAxisHandles(this._auxScaleControls.getHelper(), ['scale']);
1662
1322
  this._patchCombinedScaleGizmo(this._auxScaleControls.getHelper());
1663
1323
  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);
1324
+ // Apply the look’s gizmo size once the controls exist; the theme subscription may run before
1325
+ // their construction.
1668
1326
  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.
1327
+ // Apply the stage's axis frame after constructing and patching its gizmo geometry.
1675
1328
  this._applyGizmoAxisFrame();
1676
1329
 
1677
- // Orientation gizmo (axis arrows overlay) — built in that same frame.
1330
+ // The theme subscription builds the orientation gizmo; this camera draws it.
1678
1331
  this._vcCamera = new THREE.OrthographicCamera(-1.5, 1.5, 1.5, -1.5, 0.1, 10);
1679
- this._initOrientationGizmo();
1680
1332
 
1681
1333
  // -- Fly camera input (right-click + WASD) --
1682
1334
  window.addEventListener('keydown', this._onFlyKeyDown);
@@ -1688,58 +1340,22 @@ export class EditorViewport {
1688
1340
 
1689
1341
  // Alt+Left-drag = orbit (Unity-style), via capture phase so it fires before OrbitControls
1690
1342
  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.
1343
+ // Listen on window so an Alt+drag released outside the canvas restores the controls.
1344
+ // pointercancel handles interrupted drags; dispose removes both listeners.
1697
1345
  window.addEventListener('pointerup', this._onAltOrbitRelease, { capture: true });
1698
1346
  window.addEventListener('pointercancel', this._onAltOrbitRelease, { capture: true });
1347
+ // A handle or constraint-control drag whose release never arrives (the pointer cancelled, the
1348
+ // window lost focus mid-drag) ends here: its flags would otherwise keep `interacting()` true
1349
+ // and the stage redrawing every frame (`StageHost`).
1350
+ window.addEventListener('pointercancel', this._onInteractionDragLost, { capture: true });
1351
+ window.addEventListener('blur', this._onInteractionDragLost);
1699
1352
  window.addEventListener('keydown', this._onSnapHoldKey);
1700
1353
  window.addEventListener('keyup', this._onSnapHoldKey);
1701
1354
 
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.
1355
+ // Bind pointer selection to the interaction element that OrbitControls captures, and handle pointerup there so real drags release their session flags.
1736
1356
  canvas.addEventListener('pointerdown', this._onPointerDown);
1737
1357
  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);
1358
+ interactionElement.addEventListener('pointerup', this._onPointerUp);
1743
1359
 
1744
1360
  // Double-click = DRILL IN (see `_onDoubleClick`). Bound on the interaction
1745
1361
  // element for the same reason `pointerup` is: OrbitControls captures the
@@ -1751,15 +1367,10 @@ export class EditorViewport {
1751
1367
  };
1752
1368
  interactionElement.addEventListener('dblclick', this._onInteractionDblClick);
1753
1369
 
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
1370
  // Collect editor infrastructure objects so they can be moved between scenes
1760
1371
  this._editorObjects = [
1761
1372
  this.grid,
1762
- ...(this._axisLines ? [this._axisLines] : []),
1373
+ this._axisLines,
1763
1374
  this._editorAmbient,
1764
1375
  this._editorDirLight,
1765
1376
  this.batchedRenderer,
@@ -1771,11 +1382,9 @@ export class EditorViewport {
1771
1382
  this._vertexSnapIndicator,
1772
1383
  ];
1773
1384
 
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
- }
1385
+ // Publish this viewport's camera, canvas and editor controls for adapter raycasting.
1386
+ // Cleared in dispose().
1387
+ this._installPickContext(this.camera);
1779
1388
  }
1780
1389
 
1781
1390
  /** Swap the scene the viewport renders. Moves editor infrastructure objects to the new scene. */
@@ -1813,14 +1422,8 @@ export class EditorViewport {
1813
1422
  }
1814
1423
  this._scene = newScene;
1815
1424
  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;
1425
+ // Use the editor rig only when enabled and no view presentation supplies the lighting.
1426
+ const rigOn = this._lightRig && this._presentation === null;
1824
1427
  this._editorAmbient.visible = rigOn;
1825
1428
  this._editorDirLight.visible = rigOn;
1826
1429
  }
@@ -1829,11 +1432,9 @@ export class EditorViewport {
1829
1432
  return this._scene;
1830
1433
  }
1831
1434
 
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. */
1435
+ /** Camera that paints the Scene document: the free editor camera. */
1835
1436
  get renderCamera(): THREE.Camera {
1836
- return this._cameraView ?? this.freeCamera;
1437
+ return this.freeCamera;
1837
1438
  }
1838
1439
 
1839
1440
  get freeCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
@@ -1859,10 +1460,6 @@ export class EditorViewport {
1859
1460
  }
1860
1461
 
1861
1462
  setProjection(projection: ThreeViewportProjection): void {
1862
- if (this._cameraView) {
1863
- this._pendingProjection = projection;
1864
- return;
1865
- }
1866
1463
  if (this._projection === projection) return;
1867
1464
  if (projection === 'orthographic') this._syncOrthographicFromPerspective();
1868
1465
  else this._syncPerspectiveFromOrthographic();
@@ -1936,7 +1533,7 @@ export class EditorViewport {
1936
1533
  * the right button then does nothing here, as in Blender, where it is the context menu's.
1937
1534
  */
1938
1535
  private applyKeymapNavigation(): void {
1939
- const { orbit, turntable } = activeKeymapNavigation();
1536
+ const { orbit, turntable } = editorHost().keyboard.navigation();
1940
1537
  // Only a turntable keeps a roll: under any other orbit the view comes back level (a Top
1941
1538
  // view's screen up, which is no roll, stands).
1942
1539
  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 +1544,7 @@ export class EditorViewport {
1947
1544
  RIGHT: orbit === 'middle' ? (-1 as THREE.MOUSE) : THREE.MOUSE.ROTATE,
1948
1545
  };
1949
1546
  }
1950
- private _unsubscribeKeymap: () => void = () => {};
1547
+ private _unsubscribeKeymap: () => void;
1951
1548
 
1952
1549
  /**
1953
1550
  * A KEYMAP'S TURNTABLE (`KeymapNavigation.turntable`) in place of three's spherical orbit, on
@@ -1977,17 +1574,17 @@ export class EditorViewport {
1977
1574
  controls._handleMouseDownDolly = (event) => {
1978
1575
  dollyDown(event);
1979
1576
  dolly = null;
1980
- if (activeKeymapNavigation().zoom?.drag !== 'dolly') return;
1577
+ if (editorHost().keyboard.navigation().zoom?.drag !== 'dolly') return;
1981
1578
  const top = this._canvas.getBoundingClientRect().top;
1982
1579
  const camera = this.orbitControls.object;
1983
1580
  dolly = {
1984
1581
  len0: Math.max(5 + event.clientY - top, 1),
1985
1582
  offset: camera.position.clone().sub(this.orbitControls.target),
1986
- zoom0: (camera as THREE.OrthographicCamera).zoom ?? 1,
1583
+ zoom0: (camera as THREE.PerspectiveCamera | THREE.OrthographicCamera).zoom,
1987
1584
  };
1988
1585
  };
1989
1586
  controls._handleMouseMoveDolly = (event) => {
1990
- if (activeKeymapNavigation().zoom?.drag !== 'dolly' || !dolly) {
1587
+ if (editorHost().keyboard.navigation().zoom?.drag !== 'dolly' || !dolly) {
1991
1588
  dollyMove(event);
1992
1589
  return;
1993
1590
  }
@@ -2017,13 +1614,13 @@ export class EditorViewport {
2017
1614
  down(event);
2018
1615
  drag = null;
2019
1616
  for (const listener of [...this._rotateStartListeners]) listener();
2020
- if (!activeKeymapNavigation().turntable) return;
1617
+ if (!editorHost().keyboard.navigation().turntable) return;
2021
1618
  const camera = this.orbitControls.object;
2022
1619
  const viewUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
2023
1620
  drag = { x: event.clientX, y: event.clientY, reverse: viewUp.y < 0 ? -1 : 1 };
2024
1621
  };
2025
1622
  controls._handleMouseMoveRotate = (event) => {
2026
- const turntable = activeKeymapNavigation().turntable;
1623
+ const turntable = editorHost().keyboard.navigation().turntable;
2027
1624
  if (!turntable || !drag) {
2028
1625
  move(event);
2029
1626
  return;
@@ -2040,7 +1637,7 @@ export class EditorViewport {
2040
1637
  // Auto Perspective on a stage that draws with the viewport's own projection (a document's
2041
1638
  // session keeps its own and answers for it).
2042
1639
  () => {
2043
- if (!activeKeymapNavigation().autoPerspective || this._projection !== 'orthographic') return;
1640
+ if (!editorHost().keyboard.navigation().autoPerspective || this._projection !== 'orthographic') return;
2044
1641
  const offset = this.camera.position.clone().sub(this.orbitControls.target);
2045
1642
  const up = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
2046
1643
  if (axisViewName(offset, up) !== null) this.setProjection('perspective');
@@ -2054,7 +1651,7 @@ export class EditorViewport {
2054
1651
  */
2055
1652
  private readonly _axisViewListeners: (() => boolean)[] = [
2056
1653
  () => {
2057
- if (activeKeymapNavigation().autoPerspective) this.setProjection('orthographic');
1654
+ if (editorHost().keyboard.navigation().autoPerspective) this.setProjection('orthographic');
2058
1655
  return true;
2059
1656
  },
2060
1657
  ];
@@ -2155,7 +1752,7 @@ export class EditorViewport {
2155
1752
  * orbit's pivot, as a whole gesture (a camera lock writes it).
2156
1753
  */
2157
1754
  zoomStep(direction: 1 | -1): void {
2158
- const step = activeKeymapNavigation().zoom?.step ?? 1.2;
1755
+ const step = editorHost().keyboard.navigation().zoom?.step ?? 1.2;
2159
1756
  const factor = direction > 0 ? 1 / step : step;
2160
1757
  const camera = this.orbitControls.object;
2161
1758
  this.orbitControls.dispatchEvent({ type: 'start' });
@@ -2202,54 +1799,6 @@ export class EditorViewport {
2202
1799
  this.orbitControls.update();
2203
1800
  }
2204
1801
 
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
1802
  get objectMap(): Map<string, THREE.Object3D> {
2254
1803
  return this._objectMap;
2255
1804
  }
@@ -2286,7 +1835,7 @@ export class EditorViewport {
2286
1835
  }
2287
1836
 
2288
1837
  private _authoringIdForObject(object: THREE.Object3D): string | null {
2289
- return this._authoring().hierarchy.idForObject3D?.(object) ?? entityIdOf(object) ?? null;
1838
+ return this._authoring().hierarchy.idForNativeObject?.(object) ?? entityIdOf(object) ?? null;
2290
1839
  }
2291
1840
 
2292
1841
  /**
@@ -2315,22 +1864,20 @@ export class EditorViewport {
2315
1864
  color: new THREE.Color(nativeSelectionColors(this._canvas).visible),
2316
1865
  toneMapped: false,
2317
1866
  });
2318
- const wire = new THREE.LineSegments(edges, mat) as unknown as TaggedHelper;
1867
+ const wire = new THREE.LineSegments(edges, mat) as THREE.LineSegments & TaggedHelper;
2319
1868
  obj.getWorldPosition(wire.position);
2320
- // Tag so we can update/dispose like BoxHelper
2321
- wire.isBoxHelper = true;
1869
+ // Retain the source so the wire cube follows its world position each frame.
2322
1870
  wire._entityObj = obj;
2323
1871
  return wire;
2324
1872
  }
2325
1873
 
2326
1874
  /**
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.
1875
+ * Rebuild diagnostic bounds and the presentation's selection wires/brackets.
1876
+ * Non-renderable selections use brackets unless their document draws its own overlay;
1877
+ * geometric selections can use wires, boxes and the native silhouette.
2331
1878
  */
2332
1879
  private _syncBoxHelpers(): void {
2333
- // Remove old + dispose geometry/materials on rebuild (LK6).
1880
+ // Dispose the previous geometry and materials before rebuilding.
2334
1881
  for (const helper of this._boxHelpers.values()) {
2335
1882
  this._scene.remove(helper);
2336
1883
  helper.geometry.dispose();
@@ -2362,7 +1909,7 @@ export class EditorViewport {
2362
1909
  let index = 0;
2363
1910
  obj.traverse((child) => {
2364
1911
  const mesh = child as THREE.Mesh;
2365
- if (!mesh.isMesh || !mesh.geometry || isInEditorOwnedSubtree(mesh)) return;
1912
+ if (!mesh.isMesh || isInEditorOwnedSubtree(mesh)) return;
2366
1913
  const look = nativeViewportWire(this._canvas);
2367
1914
  const wire = new THREE.LineSegments(
2368
1915
  new THREE.WireframeGeometry(mesh.geometry),
@@ -2399,7 +1946,7 @@ export class EditorViewport {
2399
1946
  if (this._standaloneAuthoring && (obj as THREE.Bone).isBone) continue;
2400
1947
  // Constraint owners, targets and poles already have a semantic native
2401
1948
  // viewport presentation (lines plus a target/pole handle). Drawing the
2402
- // legacy empty-transform corner brackets over that presentation creates
1949
+ // empty-transform corner brackets over that presentation creates
2403
1950
  // two competing selection languages and obscures the draggable handle.
2404
1951
  if ([...this._constraintHelpers.values()].some((helper) => helper.presents(obj))) continue;
2405
1952
  // Nor over an object whose document draws its overlay itself, selection colour included
@@ -2420,12 +1967,12 @@ export class EditorViewport {
2420
1967
  * The source component stays an empty, ordinary transform node; these
2421
1968
  * helpers live on EDITOR_LAYER and therefore never enter Game view.
2422
1969
  */
2423
- private _syncReflectionProbeHelpers(changedIds: ReadonlySet<string> | null = null): void {
2424
- if (changedIds === null) {
1970
+ private _syncReflectionProbeHelpers(reconcileMembership: boolean): void {
1971
+ if (reconcileMembership) {
2425
1972
  const current = new Map<string, THREE.Object3D>();
2426
1973
  const admitted = new Set<THREE.Object3D>();
2427
1974
  for (const [id, object] of this._objectMap) {
2428
- if (!object || admitted.has(object) || !reflectionProbeOf(object)) continue;
1975
+ if (admitted.has(object) || !reflectionProbeOf(object)) continue;
2429
1976
  admitted.add(object);
2430
1977
  current.set(id, object);
2431
1978
  }
@@ -2435,28 +1982,6 @@ export class EditorViewport {
2435
1982
  this._reflectionProbeHelpers.delete(id);
2436
1983
  }
2437
1984
  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
1985
  }
2461
1986
  for (const [id, helper] of this._reflectionProbeHelpers) {
2462
1987
  helper.setSelected(this._store.shell.selectedEntityIds.has(id));
@@ -2475,21 +2000,13 @@ export class EditorViewport {
2475
2000
  this._scene.add(helper);
2476
2001
  }
2477
2002
 
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) {
2003
+ /** Draw editor-owned trigger-volume guides on EDITOR_LAYER without adding geometry to the project tree. */
2004
+ private _syncTriggerVolumeHelpers(reconcileMembership: boolean): void {
2005
+ if (reconcileMembership) {
2489
2006
  const current = new Map<string, THREE.Object3D>();
2490
2007
  const admitted = new Set<THREE.Object3D>();
2491
2008
  for (const [id, object] of this._objectMap) {
2492
- if (!object || admitted.has(object) || !triggerVolumeOf(object)) continue;
2009
+ if (admitted.has(object) || !triggerVolumeOf(object)) continue;
2493
2010
  admitted.add(object);
2494
2011
  current.set(id, object);
2495
2012
  }
@@ -2498,27 +2015,6 @@ export class EditorViewport {
2498
2015
  this._disposeTriggerVolumeHelper(id, helper);
2499
2016
  }
2500
2017
  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
2018
  }
2523
2019
  const visible =
2524
2020
  this._threeSurfaceShowing &&
@@ -2544,12 +2040,12 @@ export class EditorViewport {
2544
2040
  }
2545
2041
 
2546
2042
  /** Keep one editor-owned projection beside every source-owned constraint owner. */
2547
- private _syncConstraintHelpers(changedIds: ReadonlySet<string> | null = null): void {
2548
- if (changedIds === null) {
2043
+ private _syncConstraintHelpers(reconcileMembership: boolean): void {
2044
+ if (reconcileMembership) {
2549
2045
  const current = new Map<string, THREE.Object3D>();
2550
2046
  const admitted = new Set<THREE.Object3D>();
2551
2047
  for (const [id, object] of this._objectMap) {
2552
- if (!object || admitted.has(object) || constraintsOf(object).length === 0) continue;
2048
+ if (admitted.has(object) || constraintsOf(object).length === 0) continue;
2553
2049
  admitted.add(object);
2554
2050
  current.set(id, object);
2555
2051
  }
@@ -2559,28 +2055,6 @@ export class EditorViewport {
2559
2055
  this._constraintHelpers.delete(id);
2560
2056
  }
2561
2057
  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
2058
  }
2585
2059
 
2586
2060
  const selectedObjects = new Set<THREE.Object3D>();
@@ -2698,45 +2172,19 @@ export class EditorViewport {
2698
2172
  }
2699
2173
 
2700
2174
  /** 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.
2175
+ private _syncCameraHelpers(): void {
2176
+ const current = new Map<string, THREE.Camera>();
2177
+ for (const [id, object] of this._objectMap) {
2178
+ const camera = this._nativeCamera(object);
2179
+ if (camera) current.set(id, camera);
2180
+ }
2181
+ for (const [id, entry] of this._cameraHelpers) {
2182
+ if (current.get(id) === entry.source) continue;
2183
+ this._disposeCameraHelper(id, entry);
2184
+ }
2185
+ for (const [id, camera] of current) this._ensureCameraHelper(id, camera);
2186
+ // Keep camera icons visible with the stage’s persistent furniture so unselected cameras can be
2187
+ // found and selected.
2740
2188
  const visible =
2741
2189
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.cameras;
2742
2190
  for (const entry of this._cameraHelpers.values()) {
@@ -2746,55 +2194,28 @@ export class EditorViewport {
2746
2194
  }
2747
2195
 
2748
2196
  /**
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.
2197
+ * Reconcile one editor-owned three.js helper per native light when Helpers → Lights is enabled,
2198
+ * using the same overlay lifecycle as camera helpers. Helpers remain outside the authored tree
2199
+ * and hidden in Game view.
2759
2200
  */
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
- }
2201
+ private _syncLightHelpers(): void {
2202
+ const current = new Map<string, THREE.Light>();
2203
+ for (const [id, object] of this._objectMap) {
2204
+ const light = this._nativeLight(object);
2205
+ if (light) current.set(id, light);
2206
+ }
2207
+ for (const [id, entry] of this._lightHelpers) {
2208
+ if (current.get(id) === entry.source) continue;
2209
+ this._disposeLightHelper(id, entry);
2790
2210
  }
2211
+ for (const [id, light] of current) this._ensureLightHelper(id, light);
2791
2212
  // Persistent for the same reason as the camera frustum above: a light you
2792
2213
  // have not selected is exactly the light you are looking for.
2793
2214
  const visible =
2794
2215
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.lights;
2795
2216
  for (const entry of this._lightHelpers.values()) {
2796
2217
  entry.helper.visible = visible;
2797
- entry.helper.update?.();
2218
+ entry.helper.update();
2798
2219
  }
2799
2220
  }
2800
2221
 
@@ -2807,20 +2228,18 @@ export class EditorViewport {
2807
2228
 
2808
2229
  private _disposeLightHelper(
2809
2230
  id: string,
2810
- entry: { source: THREE.Light; helper: THREE.Object3D & { dispose?(): void } },
2231
+ entry: { helper: THREE.Object3D & { dispose(): void } },
2811
2232
  ): void {
2812
2233
  entry.helper.removeFromParent();
2813
- entry.helper.dispose?.();
2234
+ entry.helper.dispose();
2814
2235
  this._lightHelpers.delete(id);
2815
2236
  }
2816
2237
 
2817
2238
  private _ensureLightHelper(id: string, light: THREE.Light): void {
2818
2239
  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.
2240
+ // Draw helpers for supported light classes, including hemisphere lights; ambient lights have no helper.
2822
2241
  const size = 0.5;
2823
- let helper: (THREE.Object3D & { update?(): void; dispose?(): void }) | null = null;
2242
+ let helper: (THREE.Object3D & { update(): void; dispose(): void }) | null = null;
2824
2243
  const candidate = light as THREE.Light & {
2825
2244
  isDirectionalLight?: boolean;
2826
2245
  isPointLight?: boolean;
@@ -2844,26 +2263,13 @@ export class EditorViewport {
2844
2263
  helper.name = `Light helper: ${light.name || id}`;
2845
2264
  helper.userData['editorOnly'] = true;
2846
2265
  helper.traverse((child) => child.layers.set(EDITOR_LAYER));
2847
- helper.layers.set(EDITOR_LAYER);
2848
2266
  this._lightHelpers.set(id, { source: light, helper });
2849
2267
  this._scene.add(helper);
2850
2268
  }
2851
2269
 
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) {
2270
+ /** Locate native audio listeners/sources with editor-only markers. Adapter spatial handles own distance/direction edits. */
2271
+ private _syncAudioHelpers(reconcileMembership: boolean): void {
2272
+ if (reconcileMembership) {
2867
2273
  const current = new Map<string, THREE.Object3D>();
2868
2274
  for (const [id, object] of this._objectMap) {
2869
2275
  const audio = this._nativeAudio(object);
@@ -2874,16 +2280,6 @@ export class EditorViewport {
2874
2280
  this._disposeAudioHelper(id, entry);
2875
2281
  }
2876
2282
  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
2283
  }
2888
2284
  const visible =
2889
2285
  this._threeSurfaceShowing && this._store.shell.showHelpers && this._store.shell.helperVisibility.audio;
@@ -2912,22 +2308,20 @@ export class EditorViewport {
2912
2308
  helper.name = `Audio helper: ${source.name || id}`;
2913
2309
  helper.userData['editorOnly'] = true;
2914
2310
  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.
2311
+ // Parent the marker to its source so it follows the source's transform.
2918
2312
  source.add(helper);
2919
2313
  this._audioHelpers.set(id, { source, helper });
2920
2314
  }
2921
2315
 
2922
2316
  private _disposeAudioHelper(
2923
2317
  id: string,
2924
- entry: { source: THREE.Object3D; helper: THREE.Object3D & { dispose?(): void } },
2318
+ entry: { helper: THREE.Group },
2925
2319
  ): void {
2926
2320
  entry.helper.traverse((child) => {
2927
2321
  const mesh = child as THREE.Mesh;
2928
- mesh.geometry?.dispose?.();
2322
+ mesh.geometry?.dispose();
2929
2323
  const material = mesh.material as THREE.Material | undefined;
2930
- material?.dispose?.();
2324
+ material?.dispose();
2931
2325
  });
2932
2326
  entry.helper.removeFromParent();
2933
2327
  this._audioHelpers.delete(id);
@@ -2935,7 +2329,7 @@ export class EditorViewport {
2935
2329
 
2936
2330
  private _disposeCameraHelper(
2937
2331
  id: string,
2938
- entry: { source: THREE.Camera; helper: THREE.CameraHelper },
2332
+ entry: { helper: THREE.CameraHelper },
2939
2333
  ): void {
2940
2334
  entry.helper.removeFromParent();
2941
2335
  entry.helper.dispose();
@@ -2944,9 +2338,7 @@ export class EditorViewport {
2944
2338
 
2945
2339
  private _ensureCameraHelper(id: string, camera: THREE.Camera): void {
2946
2340
  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.
2341
+ // 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
2342
  let helper: THREE.CameraHelper;
2951
2343
  try {
2952
2344
  helper = new THREE.CameraHelper(camera);
@@ -2968,12 +2360,7 @@ export class EditorViewport {
2968
2360
  this._refusedCameraOverlays.add(id);
2969
2361
  editorConsole.warn(
2970
2362
  `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.',
2363
+ `${error instanceof Error ? error.message : String(error)}. Framing, selection and inspection remain available.`,
2977
2364
  'authoring',
2978
2365
  );
2979
2366
  }
@@ -3040,13 +2427,13 @@ export class EditorViewport {
3040
2427
  }
3041
2428
 
3042
2429
  private _finishSpatialHandleDrag(commit: boolean): void {
3043
- const active = this._activeSpatialHandle;
2430
+ const active = this._activeSpatialHandle!;
3044
2431
  this._activeSpatialHandle = null;
3045
2432
  this.orbitControls.enabled = this._orbitEnabledBeforeHandleDrag;
3046
2433
  this.transformControls.enabled = this._transformEnabledBeforeHandleDrag;
3047
2434
  this.syncFromStore();
3048
2435
  this._canvas.style.cursor = '';
3049
- if (commit && active) {
2436
+ if (commit) {
3050
2437
  void spatialHandlesForAdapter(this._authoring())?.commit(active.nodeId, active.handle.id, [
3051
2438
  this._spatialHandleDragPoint.x,
3052
2439
  this._spatialHandleDragPoint.y,
@@ -3084,41 +2471,34 @@ export class EditorViewport {
3084
2471
  }
3085
2472
  setSpatialHandleHovered(hovered, true);
3086
2473
  const binding = spatialHandleBinding(hovered);
3087
- this._canvas.style.cursor = binding?.handle.writable ? 'grab' : 'not-allowed';
2474
+ this._canvas.style.cursor = binding.handle.writable ? 'grab' : 'not-allowed';
3088
2475
  }
3089
2476
 
3090
2477
  private _updateEditorHandleInteraction(e: PointerEvent): boolean {
3091
2478
  return this._updateSpatialHandleDrag(e) || this._updateConstraintControlHover(e);
3092
2479
  }
3093
2480
 
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
- ];
2481
+ private _allGizmos(): TransformControls[] {
2482
+ return [this.transformControls, this._auxRotateControls, this._auxScaleControls];
3108
2483
  }
3109
2484
 
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
- };
2485
+ /** A drag is under way: a transform gizmo, a spatial handle or a constraint control. Every
2486
+ * frame of it is a change of the picture (`StageHost`), whatever the drag signals. */
2487
+ interacting(): boolean {
2488
+ return this._anyGizmoDragging() || this._activeSpatialHandle !== null || this._constraintControlPointerSession;
3118
2489
  }
3119
2490
 
3120
- private _allGizmos(): TransformControls[] {
3121
- return [this.transformControls, this._auxRotateControls, this._auxScaleControls];
2491
+ /**
2492
+ * WHAT INPUT CHANGES IN THE STAGE'S OWN FURNITURE: the hovered constraint control and spatial
2493
+ * handle, and each transform gizmo's shown state, highlighted axis and drag. A stage counts a
2494
+ * pointer move as a change of its picture only when this (or the camera) moves, so hovering
2495
+ * over a still view does not start its accumulation over (`StageHost`).
2496
+ */
2497
+ interactionState(): string {
2498
+ const gizmos = this._allGizmos()
2499
+ .map((controls) => `${controls.getHelper().visible ? 1 : 0}${controls.enabled ? 1 : 0}${controls.axis ?? '-'}${controls.dragging ? 1 : 0}`)
2500
+ .join(',');
2501
+ return `${this._hoveredConstraintControl?.mesh.uuid ?? '-'}|${this._hoveredSpatialHandle?.uuid ?? '-'}|${gizmos}`;
3122
2502
  }
3123
2503
 
3124
2504
  private _anyGizmoDragging(): boolean {
@@ -3137,14 +2517,9 @@ export class EditorViewport {
3137
2517
 
3138
2518
  /** Sync mode/space/snap from store. */
3139
2519
  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
2520
  const mode = this._store.shell.transformMode;
3144
2521
  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.
2522
+ // Select uses translate mode with the controls detached, so it draws no transform gizmo.
3148
2523
  const primaryMode = combined || mode === 'select' ? 'translate' : mode;
3149
2524
  if (this.transformControls.mode !== primaryMode) {
3150
2525
  this.transformControls.setMode(primaryMode);
@@ -3152,18 +2527,14 @@ export class EditorViewport {
3152
2527
  this._applySnapToGizmos();
3153
2528
  for (const controls of this._allGizmos()) controls.setSpace(this._store.shell.transformSpace);
3154
2529
 
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.
2530
+ // Enable transform tools only for the visible three.js stage that owns this selection.
2531
+ // Camera previews and persistent furniture have their own visibility gates.
3161
2532
  const policy = viewportAuthoringPolicy();
3162
2533
  const authoring = this._authoring();
3163
2534
  const toolOwner = policy.toolOwner(authoring, this._store.shell.selectedEntityIds);
3164
2535
  this._threejsToolContextActive =
3165
2536
  (this._standaloneAuthoring || policy.toolOwnerPainted(this._store.shell, toolOwner)) &&
3166
- toolOwner?.kind === 'three';
2537
+ toolOwner?.kind === 'stage';
3167
2538
 
3168
2539
  // Grid visibility — the user's toggle, AND'd with a three stage showing at
3169
2540
  // all (selection or not: Blender's floor is persistent; the gizmos above
@@ -3174,30 +2545,12 @@ export class EditorViewport {
3174
2545
  this.grid.visible = this._presentationGrid && threeSurface;
3175
2546
  if (this._axisLines) this._axisLines.visible = this.grid.visible && this._axesWanted;
3176
2547
 
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.
2548
+ // Reconcile helper membership, icon visibility and camera/light helpers when
2549
+ // the object map, gizmo epoch, helper settings or stage visibility changes.
3182
2550
  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
2551
  const hiddenRootId = this._standaloneAuthoring ? null : policy.threeViewportRootId(this._store.shell);
3193
2552
  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).
2553
+ // Reapply hidden state on each notification, including in-place object replacements.
3201
2554
  if (hidden) policy.applyRootHiddenVisibility(this._objectMap, hiddenRootId);
3202
2555
  // Scene-LEVEL environment (background/skybox/fog/IBL). Nothing here has to
3203
2556
  // RESTORE it on the show edge — an adapter-backed world paints its own
@@ -3205,33 +2558,17 @@ export class EditorViewport {
3205
2558
  // same rationale as the per-notify `applyRootHiddenVisibility` above.
3206
2559
  if (hidden) policy.suppressRootEnvironment(this._scene);
3207
2560
  const gizmoEpoch = this._store.gizmoEpoch;
3208
- const gizmoSig = `${this._store.shell.showHelpers}|${this._store.shell.showGizmos}|${JSON.stringify(vis)}|${hiddenRootId ?? ''}|${hidden}|${this._threejsToolContextActive}`;
2561
+ const gizmoSig = `${this._store.shell.showHelpers}|${JSON.stringify(vis)}|${threeSurface}`;
3209
2562
  const mapChanged = this._objectMap !== this._lastGizmoObjectMap;
3210
2563
  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;
2564
+ const reconcileMembership =
2565
+ mapChanged || settingsChanged || gizmoEpoch !== this._lastGizmoEpoch;
3219
2566
  this._lastGizmoObjectMap = this._objectMap;
3220
2567
  this._lastGizmoSig = gizmoSig;
3221
2568
  this._lastGizmoEpoch = gizmoEpoch;
3222
2569
  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
2570
  // Helper + icon visibility — per-type filtering
3231
- const helperRoots =
3232
- changedIds === null
3233
- ? topmostPublishedObjects(this._objectMap)
3234
- : topmostChangedObjects(this._objectMap, changedIds);
2571
+ const helperRoots = topmostPublishedObjects(this._objectMap);
3235
2572
  for (const obj of helperRoots) {
3236
2573
  obj.traverse((child) => {
3237
2574
  if (getUserData(child, 'editorIcon')) {
@@ -3261,16 +2598,16 @@ export class EditorViewport {
3261
2598
  helper.visible =
3262
2599
  this._store.shell.showHelpers && (!(kind in vis) || (vis as Record<string, boolean>)[kind]!);
3263
2600
  }
3264
- this._syncCameraHelpers(changedIds);
3265
- this._syncLightHelpers(changedIds);
2601
+ this._syncCameraHelpers();
2602
+ this._syncLightHelpers();
3266
2603
  }
3267
2604
 
3268
- this._syncAudioHelpers(changedIds);
3269
- this._syncReflectionProbeHelpers(changedIds);
3270
- this._syncTriggerVolumeHelpers(changedIds);
3271
- this._syncConstraintHelpers(changedIds);
2605
+ this._syncAudioHelpers(reconcileMembership);
2606
+ this._syncReflectionProbeHelpers(reconcileMembership);
2607
+ this._syncTriggerVolumeHelpers(reconcileMembership);
2608
+ this._syncConstraintHelpers(reconcileMembership);
3272
2609
 
3273
- // Attach gizmo based on pivot mode. Spec 28: no gizmo without a visible
2610
+ // Attach gizmo based on pivot mode. No gizmo without a visible
3274
2611
  // three surface — this whole block re-derives per notify, so detaching
3275
2612
  // here is self-healing (the un-hide notify re-attaches to the same
3276
2613
  // selection with no extra bookkeeping).
@@ -3284,8 +2621,7 @@ export class EditorViewport {
3284
2621
  // Combined mode filters/attaches on POSITION (its median-filter channel)
3285
2622
  // and attaches when ANY channel is writable — a scale-locked prefab
3286
2623
  // 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).
2624
+ // (an empty viewport with no visible refusal).
3289
2625
  const activeTransformChannel =
3290
2626
  mode === 'translate' || mode === 'combined'
3291
2627
  ? 'position'
@@ -3315,12 +2651,8 @@ export class EditorViewport {
3315
2651
  }
3316
2652
  const obj = this._objectForAuthoringId(id);
3317
2653
  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).
2654
+ // Compute the scene-root pivot from world matrices. Local position fields do not
2655
+ // describe nested nodes or objects driven directly through their matrices.
3324
2656
  median.add(obj.getWorldPosition(_medianTmp));
3325
2657
  count++;
3326
2658
  }
@@ -3355,8 +2687,8 @@ export class EditorViewport {
3355
2687
  // `matrixWorld` (three's `getWorld*` decompose it), never from the
3356
2688
  // vector fields: a node whose driver owns its matrix keeps
3357
2689
  // `.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`).
2690
+ // would put the gizmo where the object was at spawn while its bounds
2691
+ // draw where it is (`authoring/live-object-transform.ts`).
3360
2692
  const pivotLocal = new THREE.Vector3(entityPivot[0], entityPivot[1], entityPivot[2]);
3361
2693
  pivotLocal.applyQuaternion(obj.getWorldQuaternion(_pivotQuat));
3362
2694
  this._pivotDummy.position.copy(obj.getWorldPosition(_pivotPos)).add(pivotLocal);
@@ -3381,13 +2713,6 @@ export class EditorViewport {
3381
2713
  this._syncCombinedGizmoHalves();
3382
2714
  }
3383
2715
 
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
2716
  /** Snap values reach the gizmos from the store's toggle OR a held
3392
2717
  * Ctrl/⌘ (the Unity/Blender hold-to-snap convention) — three's controls
3393
2718
  * read the snap on every pointer move, so a mid-drag hold takes effect. */
@@ -3407,7 +2732,7 @@ export class EditorViewport {
3407
2732
  private _modifiedZoom = false;
3408
2733
 
3409
2734
  private readonly _onAltOrbitStart = (e: PointerEvent): void => {
3410
- if (e.button === 1 && (e.ctrlKey || e.metaKey) && activeKeymapNavigation().orbit === 'middle') {
2735
+ if (e.button === 1 && (e.ctrlKey || e.metaKey) && editorHost().keyboard.navigation().orbit === 'middle') {
3411
2736
  this._modifiedZoom = true;
3412
2737
  this.orbitControls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
3413
2738
  }
@@ -3417,9 +2742,9 @@ export class EditorViewport {
3417
2742
  }
3418
2743
  };
3419
2744
 
3420
- private readonly _onAltOrbitRelease = (event?: PointerEvent): void => {
2745
+ private readonly _onAltOrbitRelease = (event: PointerEvent): void => {
3421
2746
  // 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)) {
2747
+ if (this._modifiedZoom && (event.type === 'pointercancel' || event.button === 1)) {
3423
2748
  this._modifiedZoom = false;
3424
2749
  this.applyKeymapNavigation();
3425
2750
  }
@@ -3435,14 +2760,15 @@ export class EditorViewport {
3435
2760
  if (hold === this._snapHold) return;
3436
2761
  this._snapHold = hold;
3437
2762
  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.
2763
+ // Show a hint when the modifier temporarily enables snapping during a drag.
3440
2764
  if (hold && this._anyGizmoDragging() && !this._store.shell.snapEnabled) {
3441
2765
  const { translate, rotate, scale } = this._store.shell.snapValues;
3442
2766
  showTransientHint(`Snapping while held: ${translate} units · ${rotate}° · ×${scale}`);
3443
2767
  }
3444
2768
  };
3445
2769
 
2770
+ /** In combined mode, show each transform half only when its channel is
2771
+ * writable for the entity attached by the current sync pass. */
3446
2772
  private _syncCombinedGizmoHalves(): void {
3447
2773
  if (this._anyGizmoDragging()) return;
3448
2774
  const combined = this._store.shell.transformMode === 'combined';
@@ -3463,17 +2789,9 @@ export class EditorViewport {
3463
2789
  const scaleHelper = this._auxScaleControls.getHelper();
3464
2790
  rotateHelper.visible = rotateOn;
3465
2791
  scaleHelper.visible = scaleOn;
3466
- for (const helper of [rotateHelper, scaleHelper]) {
3467
- helper.traverse((child) => child.layers.set(EDITOR_LAYER));
3468
- }
3469
2792
  this.transformControls.enabled = true;
3470
2793
  }
3471
2794
 
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
2795
  /** Move all editor gizmos from the editor scene into a target scene (e.g. the live game scene). */
3478
2796
  moveGizmosTo(target: THREE.Scene): void {
3479
2797
  const objs = this._gizmoObjects();
@@ -3505,22 +2823,14 @@ export class EditorViewport {
3505
2823
  }
3506
2824
 
3507
2825
  /**
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.
2826
+ * Cache the active scene’s content AABB for CONTENT_BOUNDS_MAX_AGE_MS as its live graph changes.
2827
+ * Per-frame clip planes use the cached extent; focusOnScene forces a fresh measurement.
3518
2828
  */
3519
- private _contentBounds(force = false): THREE.Box3 {
2829
+ private _contentBounds(): THREE.Box3 {
3520
2830
  const now = performance.now();
3521
2831
  const stale =
3522
2832
  this._contentBoundsAt === 0 || now - this._contentBoundsAt > CONTENT_BOUNDS_MAX_AGE_MS;
3523
- if (force || stale) {
2833
+ if (stale) {
3524
2834
  this._contentBoundsBox.makeEmpty();
3525
2835
  for (const child of this._scene.children) {
3526
2836
  if (isInEditorOwnedSubtree(child)) continue;
@@ -3551,15 +2861,9 @@ export class EditorViewport {
3551
2861
  return boxes;
3552
2862
  }
3553
2863
 
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
- */
2864
+ /** Grow clip planes to reach cached content bounds. Update the projection only when the required planes change. */
3562
2865
  private _updateClipPlanes(): void {
2866
+ if (this.projectionOwner.owned) return;
3563
2867
  const box = this._contentBounds();
3564
2868
  let distance = 0;
3565
2869
  let radius = 0;
@@ -3586,14 +2890,9 @@ export class EditorViewport {
3586
2890
  * Returns false — camera untouched — when that view cannot be used: the
3587
2891
  * world is behind it, on it, or PRESSED AGAINST IT (a camera placed inside
3588
2892
  * 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).
2893
+ * useless first look — `seededViewShowsWorld` is the measurement).
3591
2894
  *
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.
2895
+ * A caller can retry as asynchronous scene content and its camera mount.
3597
2896
  */
3598
2897
  seedFromGameCamera(gameCamera: THREE.Camera): boolean {
3599
2898
  const box = frameableContentBounds(this._measureContentBounds());
@@ -3647,8 +2946,8 @@ export class EditorViewport {
3647
2946
  box.getSize(new THREE.Vector3()).x / this._viewportAspect,
3648
2947
  ) * 1.15;
3649
2948
  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.
2949
+ // Before any draw: the framing distance for a world-sized scene can land
2950
+ // past a stale far plane.
3652
2951
  this._updateClipPlanes();
3653
2952
  return true;
3654
2953
  }
@@ -3752,7 +3051,7 @@ export class EditorViewport {
3752
3051
 
3753
3052
  /**
3754
3053
  * Show (or clear with null) the editor-only helper of `kind` —
3755
- * `setViewportHelper`'s implementation. The object joins the editor
3054
+ * The object joins the editor
3756
3055
  * scene as a scene-root helper (`editorHelper`, `editorHelperType = kind`)
3757
3056
  * and follows the Helpers menu's toggle for its kind.
3758
3057
  */
@@ -3885,32 +3184,20 @@ export class EditorViewport {
3885
3184
  // every draw. Nothing here should shadow that with a second source.
3886
3185
  }
3887
3186
 
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
- */
3187
+ /** Resolve the current presentation source without applying the rig's own lights. */
3894
3188
  resolvePresentation(): void {
3895
- this._presentation?.rig.resolveSource({ light: this._store.isAdoptedSceneActive });
3189
+ this._presentation?.rig.resolveSource({ light: false });
3896
3190
  }
3897
3191
 
3898
- update(dt = 0): void {
3192
+ update(dt: number): void {
3899
3193
  this._ensureSparkRenderer();
3900
3194
  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.
3195
+ // Detach stale gizmo objects after a remount, then resolve the current selection again.
3906
3196
  const attachedObject = this.transformControls.object;
3907
3197
  if (attachedObject && attachedObject !== this._pivotDummy && !attachedObject.parent) {
3908
3198
  this._detachGizmos();
3909
3199
  this.syncFromStore();
3910
3200
  }
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
3201
  // Fly camera movement (before orbit so it takes priority)
3915
3202
  this._updateFlyCamera(dt);
3916
3203
 
@@ -3973,21 +3260,9 @@ export class EditorViewport {
3973
3260
  }
3974
3261
 
3975
3262
  /**
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.
3263
+ * Apply pinned LOD levels each frame so asynchronous loads and rebuilt instances inherit the view
3264
+ * state. Auto removes the pin and restores autoUpdate; WebGLRenderer then calls
3265
+ * LOD.update(camera) during rendering.
3991
3266
  */
3992
3267
  private _enforceLodForcedLevels(): void {
3993
3268
  const projectedObjects = new Set(this._objectMap.values());
@@ -4011,22 +3286,19 @@ export class EditorViewport {
4011
3286
  */
4012
3287
  renderWithInfrastructure(draw: () => void): void {
4013
3288
  const spark = this._sparkRenderer;
4014
- const restoreCameraView = this._cameraView ? this._suspendPreviewInfrastructure() : null;
4015
3289
  if (spark) this._scene.add(spark);
4016
3290
  try {
4017
3291
  draw();
4018
3292
  } finally {
4019
3293
  spark?.removeFromParent();
4020
- restoreCameraView?.();
4021
3294
  }
4022
3295
  }
4023
3296
 
4024
- /** Render the standard orientation gizmo in the viewport's top-right corner. */
3297
+ /** Render the standard orientation gizmo in the corner selected by the look. */
4025
3298
  renderViewCube(renderer: THREE.WebGLRenderer): void {
4026
3299
  if (!this._threeSurfaceShowing) return; // no three stage, no compass (Blender's is persistent on one)
4027
3300
  if (this._navigation === 'hidden') return;
4028
3301
  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
3302
  const w = renderer.domElement.clientWidth;
4031
3303
  const h = renderer.domElement.clientHeight;
4032
3304
  const size = this._vcSize;
@@ -4068,47 +3340,7 @@ export class EditorViewport {
4068
3340
  }
4069
3341
  }
4070
3342
 
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). */
3343
+ /** True while the fly-camera session is active. */
4112
3344
  get isFlying(): boolean {
4113
3345
  return this._flyActive;
4114
3346
  }
@@ -4126,6 +3358,8 @@ export class EditorViewport {
4126
3358
  this._canvas.removeEventListener('pointerdown', this._onAltOrbitStart, { capture: true });
4127
3359
  window.removeEventListener('pointerup', this._onAltOrbitRelease, { capture: true });
4128
3360
  window.removeEventListener('pointercancel', this._onAltOrbitRelease, { capture: true });
3361
+ window.removeEventListener('pointercancel', this._onInteractionDragLost, { capture: true });
3362
+ window.removeEventListener('blur', this._onInteractionDragLost);
4129
3363
  window.removeEventListener('keydown', this._onSnapHoldKey);
4130
3364
  window.removeEventListener('keyup', this._onSnapHoldKey);
4131
3365
  this._interactionElement.removeEventListener('pointerup', this._onFlyPointerUp);
@@ -4134,15 +3368,8 @@ export class EditorViewport {
4134
3368
  this._interactionElement.removeEventListener('wheel', this._onTrackpadWheel);
4135
3369
  this._canvas.removeEventListener('pointerdown', this._onPointerDown);
4136
3370
  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);
3371
+ this._interactionElement.removeEventListener('pointerup', this._onPointerUp);
3372
+ this._interactionElement.removeEventListener('dblclick', this._onInteractionDblClick);
4146
3373
  // Dispose controls now: deferred source cleanup must never disconnect
4147
3374
  // the reused element after the replacement controls have connected.
4148
3375
  this.orbitControls.dispose();
@@ -4150,9 +3377,9 @@ export class EditorViewport {
4150
3377
  }
4151
3378
 
4152
3379
  /**
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
3380
+ * Apply the palette's viewport group (`EditorTheme.color.viewport`): cache the grid
3381
+ * ink for `_applyGridLines`, paint axis-line colours and widths (the view owns visibility),
3382
+ * and a flat background when
4156
3383
  * named — replacing only a background this viewport painted or none at all,
4157
3384
  * never a host's own (a document's declared colour, a world radiance).
4158
3385
  */
@@ -4164,9 +3391,7 @@ export class EditorViewport {
4164
3391
  // stage's colours or keeps none of them: Classic Graphite declares no
4165
3392
  // group and keeps its floor without the grazing fade. Which axis lines
4166
3393
  // 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();
3394
+ this.grid.material.uniforms['uGrazingFade']!.value = look.background !== null ? 1 : 0;
4170
3395
  this._lookAxisHexes = [look.axisX, look.axisY, look.axisZ];
4171
3396
  if (this._axisLines) {
4172
3397
  (this._axisLines.material as LineMaterial).linewidth = this._axisLineCss(look.axisLineWidth);
@@ -4190,22 +3415,7 @@ export class EditorViewport {
4190
3415
  }
4191
3416
 
4192
3417
  /**
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
3418
+ * LIGHT AND DRESS THIS STAGE BY A VIEW'S PRESENTATION (`@volter/sdk/viewport-presentation`): its studio
4209
3419
  * or preview lighting through a `StagePresentationRig` in this scene (in place of the
4210
3420
  * viewport's own ambient and directional), its selection marks and its grid's major step.
4211
3421
  * The stage's render pipeline keeps its tone mapping. The scene's own lighting is what an
@@ -4240,7 +3450,7 @@ export class EditorViewport {
4240
3450
  rig.dispose();
4241
3451
  this._editorObjects = this._editorObjects.filter((object) => !roots.includes(object));
4242
3452
  if (this._presentation === binding) this._presentation = null;
4243
- const rigOn = this._lightRig && !this._store.isAdoptedSceneActive;
3453
+ const rigOn = this._lightRig;
4244
3454
  this._editorAmbient.visible = rigOn;
4245
3455
  this._editorDirLight.visible = rigOn;
4246
3456
  },
@@ -4252,11 +3462,11 @@ export class EditorViewport {
4252
3462
  return binding.stop;
4253
3463
  }
4254
3464
 
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 {
3465
+ /** The view's wire and box marks; a change rebuilds them for the current selection. */
3466
+ setSelectionMarks(marks: { readonly wire: boolean; readonly box: boolean }): void {
4257
3467
  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 };
3468
+ if (current.wire === marks.wire && current.box === marks.box) return;
3469
+ this._selectionMarks = { wire: marks.wire, box: marks.box };
4260
3470
  this._syncBoxHelpers();
4261
3471
  }
4262
3472
 
@@ -4338,23 +3548,21 @@ export class EditorViewport {
4338
3548
 
4339
3549
  /** Minor cells per major line (`overlays.grid.majorEvery`; Blender 10, Godot 8). */
4340
3550
  setGridMajorEvery(every: number): void {
4341
- const uniform = this.grid.material.uniforms['uMajorEvery'];
4342
- if (uniform && Number.isFinite(every) && every >= 1) uniform.value = every;
3551
+ if (Number.isFinite(every) && every >= 1) this.grid.material.uniforms['uMajorEvery']!.value = every;
4343
3552
  }
4344
3553
 
4345
3554
  /** The look's floor lines: widths in device pixels and the major level's contrast. */
4346
- private _gridMajorContrast = 1;
3555
+ private _gridMajorContrast!: number;
4347
3556
  private _applyGridLines(lines: ReturnType<typeof nativeViewportGrid>): void {
4348
3557
  const uniforms = this.grid.material.uniforms;
4349
- if (uniforms['uLineWidth']) uniforms['uLineWidth'].value = lines.lineWidth;
4350
- if (uniforms['uMajorWidth']) uniforms['uMajorWidth'].value = lines.majorWidth;
3558
+ uniforms['uLineWidth']!.value = lines.lineWidth;
3559
+ uniforms['uMajorWidth']!.value = lines.majorWidth;
4351
3560
  this._gridMajorContrast = lines.majorContrast;
4352
3561
  this._paintLookGrid();
4353
3562
  }
4354
3563
 
4355
3564
  private _paintLookGrid(): void {
4356
3565
  const hex = this._lookGridHex;
4357
- if (hex === null) return;
4358
3566
  const backgroundHex = this._lookBackgroundHex ?? 0x3f3f3f;
4359
3567
  const channel = (shift: number) => {
4360
3568
  const line = (hex >> shift) & 0xff;
@@ -4364,18 +3572,11 @@ export class EditorViewport {
4364
3572
  const majorHex = (channel(16) << 16) | (channel(8) << 8) | channel(0);
4365
3573
  const minor = toneMappedSourceColor(hex, this._renderer);
4366
3574
  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);
3575
+ (this.grid.material.uniforms['uColor']!.value as THREE.Color).copy(minor);
3576
+ (this.grid.material.uniforms['uMajorColor']!.value as THREE.Color).copy(major);
4369
3577
  }
4370
3578
 
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
- */
3579
+ /** 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
3580
  private _rebuildAxisLines(): void {
4380
3581
  const lines = this._axisLines;
4381
3582
  if (!lines) return;
@@ -4389,16 +3590,14 @@ export class EditorViewport {
4389
3590
  this._axesWanted = shown.length > 0;
4390
3591
  lines.visible = this.grid.visible && this._axesWanted;
4391
3592
  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.
3593
+ // Replace geometry on rebuild so its cached instance count matches the current axes.
4395
3594
  const build = (threeAxes: readonly number[]): LineSegmentsGeometry => {
4396
3595
  const positions: number[] = [];
4397
3596
  const colors: number[] = [];
4398
3597
  for (const threeAxis of threeAxes) {
4399
3598
  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);
3599
+ const hex = this._lookAxisHexes[world]!;
3600
+ const color = hex === null ? this._gizmoAxisColor(world) : new THREE.Color(hex);
4402
3601
  positions.push(...axisSegmentPositions(threeAxis));
4403
3602
  for (let i = 0; i < AXIS_SEGMENTS_PER_AXIS * 2; i++) colors.push(color.r, color.g, color.b);
4404
3603
  }
@@ -4494,14 +3693,12 @@ export class EditorViewport {
4494
3693
  (this._axisLines.material as THREE.Material).dispose();
4495
3694
  this._axisLines = null;
4496
3695
  }
4497
- this.clearCameraView();
4498
3696
  if (this._sparkRenderer) {
4499
3697
  this._scene.remove(this._sparkRenderer);
4500
3698
  disposeSparkRendererWhenIdle(this._sparkRenderer);
4501
3699
  this._sparkRenderer = null;
4502
3700
  }
4503
3701
  this.disconnectInput();
4504
- this._hideDropOverlay();
4505
3702
  for (const helper of this._helpers.values()) this._scene.remove(helper);
4506
3703
  this._helpers.clear();
4507
3704
  for (const helper of this._boxHelpers.values()) {
@@ -4526,8 +3723,9 @@ export class EditorViewport {
4526
3723
  // --- Private ---
4527
3724
 
4528
3725
  private _ensureSparkRenderer(): void {
3726
+ const renderer = this._renderer;
4529
3727
  if (
4530
- !this._renderer ||
3728
+ !renderer ||
4531
3729
  this._sparkRenderer ||
4532
3730
  this._sparkRendererLoading ||
4533
3731
  this._sparkRendererFailed
@@ -4540,8 +3738,8 @@ export class EditorViewport {
4540
3738
  this._sparkRendererLoading = true;
4541
3739
  void import('@sparkjsdev/spark')
4542
3740
  .then(({ SparkRenderer }) => {
4543
- if (this._disposed || !this._renderer) return;
4544
- const spark = new SparkRenderer({ renderer: this._renderer, enableLod: false });
3741
+ if (this._disposed) return;
3742
+ const spark = new SparkRenderer({ renderer, enableLod: false });
4545
3743
  spark.traverse((node) => setUserData(node, 'engineInternal', true));
4546
3744
  this._sparkRenderer = spark;
4547
3745
  invalidateStages();
@@ -4557,21 +3755,7 @@ export class EditorViewport {
4557
3755
  }
4558
3756
 
4559
3757
  /**
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.
3758
+ * BUILD (or REBUILD) THE NAVIGATION GIZMO in the presented world's axis frame.
4575
3759
  *
4576
3760
  * Blender's gizmo draws a stalk and a FILLED, LETTERED ball on each axis's
4577
3761
  * POSITIVE side and a hollow one opposite, and the axis it calls Z is the
@@ -4586,16 +3770,15 @@ export class EditorViewport {
4586
3770
  * a fact about the scene and not about what the axis is called.
4587
3771
  */
4588
3772
  private _buildOrientationGizmo(): void {
4589
- if (this._vcScene === undefined) return;
4590
3773
  // Rebuilt on every look change, so what it held is released, textures included.
4591
3774
  for (const held of [...this._vcScene.children]) {
4592
3775
  this._vcScene.remove(held);
4593
3776
  const drawn = held as THREE.Mesh | THREE.Sprite;
4594
3777
  // 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();
3778
+ if (!(drawn as THREE.Sprite).isSprite) drawn.geometry.dispose();
3779
+ const material = drawn.material as THREE.Material & { map?: THREE.Texture | null };
3780
+ material.map?.dispose();
3781
+ material.dispose();
4599
3782
  }
4600
3783
  this._vcBalls.length = 0;
4601
3784
  this._vcStalks.length = 0;
@@ -4631,7 +3814,7 @@ export class EditorViewport {
4631
3814
  // The look's navigation colours, else its axis colours, else the editor's own.
4632
3815
  const lookAxes = this._gizmoLook.navigation ?? this._gizmoLook.axes;
4633
3816
  const color =
4634
- lookAxes === null ? new THREE.Color(...KIT_GIZMO_AXIS_RGB[sourceAxis]!) : new THREE.Color(lookAxes[sourceAxis]!);
3817
+ lookAxes === null ? this._gizmoAxisColor(sourceAxis) : new THREE.Color(lookAxes[sourceAxis]!);
4635
3818
  const letter = AXIS_LETTER[sourceAxis]!;
4636
3819
  const axis = axes[i]!.clone().multiplyScalar(positive);
4637
3820
 
@@ -4701,11 +3884,11 @@ export class EditorViewport {
4701
3884
  for (let i = 0; i < 3; i++) {
4702
3885
  const [sourceAxis, positive] = this._stageAxisFrame[i]!;
4703
3886
  const color =
4704
- lookAxes === null ? new THREE.Color(...KIT_GIZMO_AXIS_RGB[sourceAxis]!) : new THREE.Color(lookAxes[sourceAxis]!);
3887
+ lookAxes === null ? this._gizmoAxisColor(sourceAxis) : new THREE.Color(lookAxes[sourceAxis]!);
4705
3888
  const letter = AXIS_LETTER[sourceAxis]!;
4706
3889
  const axis = axes[i]!.clone().multiplyScalar(positive);
4707
3890
  if (form === 'triad') {
4708
- // Unreal's triad is about 40 px, opaque and undimmed by facing (`level-editor.png`); its
3891
+ // Unreal's triad is about 40 px, opaque and undimmed by facing; its
4709
3892
  // size is the look's (`navigationSize`, a multiple of this 24 px).
4710
3893
  const scale = this._gizmoLook.navigationSize ?? 1;
4711
3894
  const length = (24 * scale) / perUnit;
@@ -4752,14 +3935,7 @@ export class EditorViewport {
4752
3935
  }
4753
3936
  }
4754
3937
 
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
- */
3938
+ /** Order navigation sprites by depth and dim and shrink balls that point away from the viewer. */
4763
3939
  private _syncOrientationGizmoDepth(): void {
4764
3940
  const perUnit = COMPASS_BOX_PX / 3;
4765
3941
  const view = this._vcDir.copy(this._vcCamera.position).normalize();
@@ -4767,9 +3943,7 @@ export class EditorViewport {
4767
3943
  // a ball's colour is its axis colour mixed with the viewport's background by its depth
4768
3944
  // (`fading_color`: `(depth + 1) · 0.25 + 0.5`), a negative ball a 25% tint of it ringed in
4769
3945
  // 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.
3946
+ // 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
3947
  if (this._gizmoLook.navigationForm === 'godot') {
4774
3948
  this._syncGodotNavigation(view);
4775
3949
  return;
@@ -4812,15 +3986,12 @@ export class EditorViewport {
4812
3986
  ringMaterial.color.copy(fading);
4813
3987
  ringMaterial.opacity = 1;
4814
3988
  }
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).
3989
+ // Letter ink uses the axis fill darkened by 0.356.
4817
3990
  letterMaterial.color.copy(mix(black, fillMaterial.color, COMPASS_LETTER_INK, scratch));
4818
3991
  fill.visible = !alignedBack;
4819
3992
  ring.visible = !alignedBack;
4820
3993
  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).
3994
+ // Scale each ball linearly with facing; orthographic projection supplies no depth scaling.
4824
3995
  const scale = (COMPASS_BALL_BACK_PX + COMPASS_BALL_DEPTH_GAIN_PX * facing) / perUnit;
4825
3996
  const order = 10 + Math.round(facing * 100) * 3;
4826
3997
  for (const [part, rank] of [[fill, 0], [ring, 1], [letter, 2]] as const) {
@@ -4893,12 +4064,8 @@ export class EditorViewport {
4893
4064
  }
4894
4065
 
4895
4066
  /**
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.
4067
+ * Test the pointer against the orientation cube’s screen-space box without changing state. Single
4068
+ * clicks use it for axis navigation; double-click selection excludes it from scene geometry.
4902
4069
  */
4903
4070
  private _isOverViewCube(clientX: number, clientY: number): boolean {
4904
4071
  // An indicator is only drawn, and a triad has nothing to click: clicks pass to the stage.
@@ -4911,15 +4078,10 @@ export class EditorViewport {
4911
4078
  return x >= vcLeft && x <= vcLeft + this._vcSize && y >= vcTop && y <= vcTop + this._vcSize;
4912
4079
  }
4913
4080
 
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
- */
4081
+ /** Place the gizmo box in the look’s navigation corner, inset by canvas bleed. */
4919
4082
  private _vcOrigin(width: number, height: number, inset: number): { left: number; top: number } {
4920
4083
  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).
4084
+ // Leave room above the stage’s two-line camera readout.
4923
4085
  const left = 4;
4924
4086
  const bottom = 48;
4925
4087
  return { left: left + inset, top: height - this._vcSize - bottom - inset };
@@ -5005,8 +4167,8 @@ export class EditorViewport {
5005
4167
  });
5006
4168
  };
5007
4169
  // Primary (if it's a real entity, not pivot dummy)
5008
- const obj = this._activeGizmo.object;
5009
- if (obj && obj !== this._pivotDummy) {
4170
+ const obj = this._activeGizmo.object!;
4171
+ if (obj !== this._pivotDummy) {
5010
4172
  applyLive(this._attachedAuthoringId ?? this._authoringIdForObject(obj), obj);
5011
4173
  }
5012
4174
  // Other selected objects (active-element / individual-origins)
@@ -5195,9 +4357,6 @@ export class EditorViewport {
5195
4357
  targets: {
5196
4358
  id: string;
5197
4359
  obj: THREE.Object3D;
5198
- startPos: THREE.Vector3;
5199
- startQuat: THREE.Quaternion;
5200
- startScale: THREE.Vector3;
5201
4360
  }[],
5202
4361
  ): void {
5203
4362
  // Collect all dragged entity IDs to exclude from raycast
@@ -5214,11 +4373,6 @@ export class EditorViewport {
5214
4373
  // Also include the grid as a snap target
5215
4374
  sceneTargets.push(this.grid);
5216
4375
 
5217
- if (sceneTargets.length === 0) {
5218
- this._surfaceSnapIndicator.visible = false;
5219
- return;
5220
- }
5221
-
5222
4376
  // Raycast downward from the gizmo object position
5223
4377
  const origin = gizmoObj.position.clone();
5224
4378
  origin.y += 50; // Start high above
@@ -5269,7 +4423,7 @@ export class EditorViewport {
5269
4423
  obj.traverse((child) => {
5270
4424
  if (isInEditorOwnedSubtree(child)) return;
5271
4425
  const mesh = child as THREE.Mesh;
5272
- if (!mesh.isMesh || !mesh.geometry) return;
4426
+ if (!mesh.isMesh) return;
5273
4427
  const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
5274
4428
  if (!posAttr) return;
5275
4429
  const count = Math.min(posAttr.count, MAX_PER_MESH);
@@ -5289,7 +4443,7 @@ export class EditorViewport {
5289
4443
  */
5290
4444
  private _applyVertexSnap(
5291
4445
  gizmoObj: THREE.Object3D,
5292
- targets: { obj: THREE.Object3D; startPos: THREE.Vector3 }[],
4446
+ targets: { obj: THREE.Object3D }[],
5293
4447
  ): void {
5294
4448
  const entityPos = gizmoObj.position;
5295
4449
  const projected = entityPos.clone().project(this._screenCamera);
@@ -5323,7 +4477,7 @@ export class EditorViewport {
5323
4477
  }
5324
4478
  this._vertexSnapIndicator.position.copy(bestVertex);
5325
4479
  this._vertexSnapIndicator.visible = true;
5326
- // Constant screen size for indicator
4480
+ // Scale the indicator with its distance from the drawing camera.
5327
4481
  const dist = bestVertex.distanceTo(this._screenCamera.position);
5328
4482
  this._vertexSnapIndicator.scale.setScalar(dist * 0.008);
5329
4483
  } else {
@@ -5339,7 +4493,7 @@ export class EditorViewport {
5339
4493
  this._otherDragObjects = [];
5340
4494
  this._allDragObjects = [];
5341
4495
  this._preservedChildObjects = [];
5342
- // Persist the final transform + mark dirty (ED2). A mid-drag autosave may
4496
+ // Persist the final transform and mark dirty. A mid-drag autosave may
5343
4497
  // have already fired and cleared dirty; this re-schedules the final write.
5344
4498
  const authoring = this._authoring();
5345
4499
  for (const id of editIds) endAuthoringTransformEdit(authoring, id);
@@ -5354,61 +4508,7 @@ export class EditorViewport {
5354
4508
  return false;
5355
4509
  }
5356
4510
 
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
- */
4511
+ /** 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
4512
  private _applyGizmoSize(): void {
5413
4513
  // The theme subscription fires SYNCHRONOUSLY on install, which is earlier
5414
4514
  // in the constructor than the controls — the same ordering the grid's own
@@ -5428,39 +4528,11 @@ export class EditorViewport {
5428
4528
  for (const controls of this._allGizmos()) controls.size = size;
5429
4529
  }
5430
4530
 
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 {
4531
+ /** Keep only the positive translate and scale heads; identify the negative heads by their geometry centres rather than helper names. */
4532
+ private _dropNegativeAxisHandles(helper: THREE.Object3D, modes: readonly ('translate' | 'scale')[]): void {
5458
4533
  const gizmoNode = helper.children.find(
5459
4534
  (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;
4535
+ ) as TransformControlsGizmo;
5464
4536
  const axisOf: Record<string, THREE.Vector3> = {
5465
4537
  X: new THREE.Vector3(1, 0, 0),
5466
4538
  Y: new THREE.Vector3(0, 1, 0),
@@ -5468,23 +4540,19 @@ export class EditorViewport {
5468
4540
  };
5469
4541
  for (const mode of modes)
5470
4542
  for (const group of [gizmoNode.gizmo[mode], gizmoNode.picker[mode]]) {
5471
- if (!group) continue;
5472
4543
  for (const child of [...group.children]) {
5473
4544
  const axis = axisOf[child.name];
5474
4545
  if (axis === undefined) continue;
5475
4546
  const mesh = child as THREE.Mesh;
5476
- if (!mesh.geometry) continue;
5477
4547
  mesh.geometry.computeBoundingBox();
5478
- const bounds = mesh.geometry.boundingBox;
5479
- if (!bounds) continue;
4548
+ const bounds = mesh.geometry.boundingBox!;
5480
4549
  if (bounds.getCenter(new THREE.Vector3()).dot(axis) < 0) group.remove(child);
5481
4550
  }
5482
4551
  }
5483
4552
  }
5484
4553
 
5485
4554
  /**
5486
- * THE COLOUR EACH HANDLE CARRIES, in the frame the look says this stage is
5487
- * presenting.
4555
+ * THE COLOUR EACH HANDLE CARRIES, in the world frame the view presentation supplies.
5488
4556
  *
5489
4557
  * A handle is NAMED for three's axis and COLOURED for the source axis that
5490
4558
  * three axis carries ({@link StageAxisFrame}) — under three's own frame the
@@ -5507,15 +4575,8 @@ export class EditorViewport {
5507
4575
  };
5508
4576
  }
5509
4577
 
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 {
4578
+ /** Apply the current stage axis frame and rebuild the orientation gizmo after a look change. */
4579
+ private _readLookStage(): void {
5519
4580
  this._applyGizmoAxisFrame();
5520
4581
  this._buildOrientationGizmo();
5521
4582
  }
@@ -5523,7 +4584,7 @@ export class EditorViewport {
5523
4584
  /**
5524
4585
  * THE STAGE'S FUNCTION from its view's presentation (ARCHITECTURE.md rule 7): which axis of
5525
4586
  * 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.
4587
+ * drag selects. The look does not determine the world's coordinate system.
5527
4588
  */
5528
4589
  setStageFunction(
5529
4590
  world: { readonly upAxis: 'y' | 'z'; readonly handedness: 'right' | 'left' },
@@ -5561,8 +4622,8 @@ export class EditorViewport {
5561
4622
  * colour.
5562
4623
  *
5563
4624
  * 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
4625
+ * on the positive side of each axis. Negative-side heads are hidden so
4626
+ * the set reads as three arms rather than six. Under
5566
4627
  * a Z-up world one of those three arms is pointing the wrong WAY: the
5567
4628
  * source's +Y is three's −Z, and Blender draws an arm along +Y and nothing
5568
4629
  * along −Y. So the handles of a flipped axis are MIRRORED onto the other
@@ -5598,9 +4659,8 @@ export class EditorViewport {
5598
4659
  // its geometry is not mirrored — doing either would apply the permutation
5599
4660
  // a second time.
5600
4661
  //
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.
4662
+ // With the permutation applied to all three families, the scale handle
4663
+ // that points up would be mirrored to point DOWN.
5604
4664
  const identity: Record<string, THREE.Color> = {
5605
4665
  X: this._gizmoAxisColor(0),
5606
4666
  Y: this._gizmoAxisColor(1),
@@ -5609,48 +4669,43 @@ export class EditorViewport {
5609
4669
  YZ: this._gizmoAxisColor(0),
5610
4670
  XZ: this._gizmoAxisColor(1),
5611
4671
  };
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).
4672
+ // Clone shared handle materials when a gizmo family needs separate colours, so changing scale colours cannot recolour translate or rotate.
5620
4673
  const paint = (
5621
- group: THREE.Object3D | undefined,
4674
+ group: THREE.Object3D,
5622
4675
  map: Record<string, THREE.Color>,
5623
4676
  flip: boolean,
5624
4677
  ownMaterial: boolean,
5625
4678
  handles: boolean,
5626
4679
  ) => {
5627
- if (!group) return;
5628
4680
  group.traverse((child) => {
5629
- const mesh = child as THREE.Mesh;
4681
+ const mesh = child as THREE.Object3D & {
4682
+ geometry?: THREE.BufferGeometry;
4683
+ material?: (THREE.MeshBasicMaterial | THREE.LineBasicMaterial) & {
4684
+ _color?: THREE.Color | undefined;
4685
+ _opacity?: number | undefined;
4686
+ };
4687
+ };
5630
4688
  if (flip && mesh.geometry) mesh.geometry.scale(delta[0]!, delta[1]!, delta[2]!);
5631
4689
  if (
5632
4690
  ownMaterial &&
5633
4691
  mesh.material &&
5634
- !Array.isArray(mesh.material) &&
5635
4692
  !mesh.userData['volterOwnMaterial']
5636
4693
  ) {
5637
- mesh.material = (mesh.material as THREE.Material).clone();
4694
+ mesh.material = mesh.material.clone();
5638
4695
  mesh.userData['volterOwnMaterial'] = true;
5639
4696
  }
5640
- const material = mesh.material as
5641
- | (THREE.MeshBasicMaterial & { _color?: THREE.Color | undefined })
5642
- | undefined;
5643
- if (!material || Array.isArray(material)) return;
4697
+ const material = mesh.material;
4698
+ if (!material) return;
5644
4699
  if (flip) material.side = THREE.DoubleSide;
5645
4700
  const ink = map[child.name];
5646
- if (ink && material.color) {
4701
+ if (ink) {
5647
4702
  // THE LOOK'S RESTING OPACITY over the handle's own (three draws its plane squares
5648
4703
  // translucent); three caches it like the colour, so the cache is dropped too.
5649
4704
  // Handles only: the drag's axis lines (the `helper` family) keep three's own.
5650
4705
  if (handles) {
5651
4706
  const base = (material.userData['volterBaseOpacity'] ??= material.opacity) as number;
5652
4707
  material.opacity = base * (this._gizmoLook.opacity ?? 1);
5653
- (material as { _opacity?: number | undefined })._opacity = undefined;
4708
+ material._opacity = undefined;
5654
4709
  }
5655
4710
  material.color.copy(ink);
5656
4711
  // THREE CACHES A HANDLE'S RESTING COLOUR ON ITS FIRST UPDATE
@@ -5668,22 +4723,15 @@ export class EditorViewport {
5668
4723
  .getHelper()
5669
4724
  .children.find(
5670
4725
  (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;
4726
+ ) as TransformControlsGizmo;
5679
4727
  for (const family of ['gizmo', 'picker', 'helper'] as const) {
5680
4728
  // ONLY THE TRANSLATE ARMS ARE MIRRORED. A rotate RING is symmetric
5681
4729
  // about every axis it spans, so the permutation is visible in its
5682
4730
  // colour alone; the scale family is in the object's own frame (above).
5683
4731
  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);
4732
+ paint(node[family].translate, permuted, mirrored, false, handles);
4733
+ paint(node[family].rotate, permuted, false, false, handles);
4734
+ paint(node[family].scale, identity, false, true, handles);
5687
4735
  }
5688
4736
  }
5689
4737
  if (mirrored) this._appliedAxisSigns = want as readonly (1 | -1)[];
@@ -5713,17 +4761,7 @@ export class EditorViewport {
5713
4761
  .getHelper()
5714
4762
  .children.find(
5715
4763
  (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;
4764
+ ) as TransformControlsGizmo & Pick<TransformControls, 'axis' | 'mode' | 'enabled' | 'dragging'>;
5727
4765
  const threeUpdate = node.updateMatrixWorld.bind(node);
5728
4766
  node.updateMatrixWorld = (force?: boolean) => {
5729
4767
  threeUpdate(force);
@@ -5741,7 +4779,7 @@ export class EditorViewport {
5741
4779
  : null;
5742
4780
  if (hidden !== null) {
5743
4781
  for (const family of [node.gizmo, node.picker]) {
5744
- for (const handle of family[node.mode]?.children ?? []) {
4782
+ for (const handle of family[node.mode].children) {
5745
4783
  if (handle.name === hidden) handle.visible = false;
5746
4784
  }
5747
4785
  }
@@ -5757,12 +4795,10 @@ export class EditorViewport {
5757
4795
  )
5758
4796
  return;
5759
4797
  const fixed = node.dragging ? (look.drag ?? look.hover) : look.hover;
5760
- for (const handle of node.gizmo[node.mode]?.children ?? []) {
4798
+ for (const handle of node.gizmo[node.mode].children) {
5761
4799
  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;
4800
+ const material = (handle as THREE.Mesh | THREE.Line).material as
4801
+ (THREE.MeshBasicMaterial | THREE.LineBasicMaterial) & { _color?: THREE.Color };
5766
4802
  if (fixed !== null) {
5767
4803
  material.color.setHex(fixed);
5768
4804
  continue;
@@ -5808,19 +4844,15 @@ export class EditorViewport {
5808
4844
  .getHelper()
5809
4845
  .children.find(
5810
4846
  (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;
4847
+ ) as TransformControlsGizmo;
5815
4848
  const axisIndex: Record<string, number> = { X: 0, Y: 1, Z: 2 };
5816
4849
  const along = (index: number, factor: number): [number, number, number] =>
5817
4850
  [0, 1, 2].map((i) => (i === index ? factor : 1)) as [number, number, number];
5818
- for (const handle of node.gizmo['translate']?.children ?? []) {
4851
+ for (const handle of node.gizmo.translate.children) {
5819
4852
  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;
4853
+ if (index === undefined) continue;
4854
+ const geometry = (handle as THREE.Mesh<THREE.CylinderGeometry>).geometry;
4855
+ const isHead = geometry.parameters.radiusTop === 0;
5824
4856
  if (!isHead) {
5825
4857
  geometry.scale(...along(index, stretch));
5826
4858
  continue;
@@ -5838,10 +4870,10 @@ export class EditorViewport {
5838
4870
  offset[index] = base * stretch;
5839
4871
  geometry.translate(...offset);
5840
4872
  }
5841
- for (const handle of node.picker['translate']?.children ?? []) {
4873
+ for (const handle of node.picker.translate.children) {
5842
4874
  const index = axisIndex[handle.name];
5843
- const geometry = (handle as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
5844
- if (index === undefined || !geometry) continue;
4875
+ if (index === undefined) continue;
4876
+ const geometry = (handle as THREE.Mesh<THREE.CylinderGeometry>).geometry;
5845
4877
  geometry.scale(...along(index, tipScale));
5846
4878
  }
5847
4879
  this._appliedArrow = want;
@@ -5866,14 +4898,13 @@ export class EditorViewport {
5866
4898
  .getHelper()
5867
4899
  .children.find(
5868
4900
  (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;
4901
+ ) as TransformControlsGizmo;
4902
+ for (const handle of node.gizmo.rotate.children) {
5872
4903
  // The axis rings only: the trackball circle and the view ring stay hairlines (Godot's
5873
4904
  // grey circle is one).
5874
4905
  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;
4906
+ const geometry = (handle as THREE.Mesh<THREE.TorusGeometry>).geometry;
4907
+ const radius = geometry.parameters.radius;
5877
4908
  geometry.computeBoundingBox();
5878
4909
  geometry.boundingBox!.getSize(size);
5879
4910
  const normal = size.x <= size.y && size.x <= size.z ? 0 : size.y <= size.z ? 1 : 2;
@@ -5894,8 +4925,6 @@ export class EditorViewport {
5894
4925
  }
5895
4926
 
5896
4927
  private _patchGizmo(gizmoHelper: THREE.Object3D): void {
5897
- const colorMap = this._axisColorMap();
5898
-
5899
4928
  // Plane geometry offsets to bring inner corners to object center
5900
4929
  const planeOffsets: Record<string, [number, number, number]> = {
5901
4930
  XY: [-0.075, -0.075, 0],
@@ -5904,44 +4933,20 @@ export class EditorViewport {
5904
4933
  };
5905
4934
 
5906
4935
  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
4936
  // Shift plane handle geometry
5917
4937
  const offset = planeOffsets[child.name];
5918
- if (offset && mesh.geometry) {
5919
- mesh.geometry.translate(offset[0], offset[1], offset[2]);
4938
+ if (offset) {
4939
+ (child as THREE.Mesh).geometry.translate(offset[0], offset[1], offset[2]);
5920
4940
  }
5921
4941
  });
5922
4942
  }
5923
4943
 
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
- */
4944
+ /** Shift auxiliary scale cubes and picker geometry clear of the combined tool's translate controls. */
5937
4945
  private _patchCombinedScaleGizmo(helper: THREE.Object3D): void {
5938
4946
  const AXIS_OFFSET = 0.55;
5939
4947
  const gizmoNode = helper.children.find(
5940
4948
  (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;
4949
+ ) as TransformControlsGizmo;
5945
4950
  const axisOf: Record<string, THREE.Vector3> = {
5946
4951
  X: new THREE.Vector3(1, 0, 0),
5947
4952
  Y: new THREE.Vector3(0, 1, 0),
@@ -5953,24 +4958,22 @@ export class EditorViewport {
5953
4958
  const CUBE_CENTER = 0.54 + AXIS_OFFSET;
5954
4959
  const PICKER_SIZE = 0.3;
5955
4960
  for (const [group, isPicker] of [
5956
- [gizmoNode.gizmo['scale'], false],
5957
- [gizmoNode.picker['scale'], true],
4961
+ [gizmoNode.gizmo.scale, false],
4962
+ [gizmoNode.picker.scale, true],
5958
4963
  ] as const) {
5959
- if (!group) continue;
5960
4964
  for (const child of [...group.children]) {
5961
- const mesh = child as THREE.Mesh;
4965
+ const mesh = child as THREE.Mesh<THREE.BoxGeometry | THREE.CylinderGeometry>;
5962
4966
  const axis = axisOf[child.name];
5963
4967
  const isAxisLine =
5964
4968
  axis !== undefined &&
5965
- mesh.geometry?.type === 'CylinderGeometry' &&
4969
+ mesh.geometry.type === 'CylinderGeometry' &&
5966
4970
  (mesh.geometry as THREE.CylinderGeometry).parameters.radiusTop < 0.05;
5967
4971
  if (axis === undefined || isAxisLine) {
5968
4972
  group.remove(child);
5969
4973
  continue;
5970
4974
  }
5971
4975
  mesh.geometry.computeBoundingBox();
5972
- const bounds = mesh.geometry.boundingBox;
5973
- if (!bounds) continue;
4976
+ const bounds = mesh.geometry.boundingBox!;
5974
4977
  const sign = Math.sign(bounds.getCenter(new THREE.Vector3()).dot(axis)) || 1;
5975
4978
  if (isPicker) {
5976
4979
  const at = axis.clone().multiplyScalar(sign * CUBE_CENTER);
@@ -6005,8 +5008,6 @@ export class EditorViewport {
6005
5008
  private static _FLY_KEYS = new Set(['w', 'a', 's', 'd', 'q', 'e']);
6006
5009
 
6007
5010
  private _enterFlyMode(): void {
6008
- if (this._flyActive) return;
6009
- if (this._cameraViewMode === 'view') return;
6010
5011
  this._flyActive = true;
6011
5012
  this._snapAnimating = false; // cancel any view snap
6012
5013
  this.orbitControls.enabled = false;
@@ -6017,10 +5018,9 @@ export class EditorViewport {
6017
5018
  }
6018
5019
 
6019
5020
  private _exitFlyMode(): void {
6020
- if (!this._flyActive) return;
6021
5021
  this._flyActive = false;
6022
5022
  this._flyKeys.clear();
6023
- this.orbitControls.enabled = this._cameraViewMode !== 'view';
5023
+ this.orbitControls.enabled = true;
6024
5024
  document.exitPointerLock();
6025
5025
  // Sync orbit target to a point in front of the camera
6026
5026
  const camera = this.renderCamera;
@@ -6078,10 +5078,6 @@ export class EditorViewport {
6078
5078
 
6079
5079
  private _onFlyKeyUp = (e: KeyboardEvent): void => {
6080
5080
  const key = e.key.toLowerCase();
6081
- if (key === 'shift') {
6082
- this._flyKeys.delete('shift');
6083
- return;
6084
- }
6085
5081
  this._flyKeys.delete(key);
6086
5082
  };
6087
5083
 
@@ -6120,11 +5116,8 @@ export class EditorViewport {
6120
5116
  const distance = this.camera.position.distanceTo(this.orbitControls.target);
6121
5117
  const worldPerPixel =
6122
5118
  (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.
5119
+ // Clamp mouse-wheel pan events to a modest step. Fine trackpad deltas stay below the clamp and
5120
+ // preserve their scale.
6128
5121
  const unit = e.deltaMode === 1 ? 16 : 1;
6129
5122
  const clampStep = (value: number): number =>
6130
5123
  Math.sign(value) * Math.min(Math.abs(value * unit), 30);
@@ -6156,8 +5149,9 @@ export class EditorViewport {
6156
5149
  if (e.button === 0 && !e.altKey) {
6157
5150
  if (this._selectConstraintControlAt(e)) return;
6158
5151
  const mesh = this._raycastSpatialHandle(e);
6159
- const binding = mesh ? spatialHandleBinding(mesh) : null;
6160
- if (mesh && binding?.handle.writable) {
5152
+ if (!mesh) return;
5153
+ const binding = spatialHandleBinding(mesh);
5154
+ if (binding.handle.writable) {
6161
5155
  e.stopPropagation();
6162
5156
  this._activeSpatialHandle = binding;
6163
5157
  this._spatialHandleDragPoint.copy(mesh.position);
@@ -6203,21 +5197,14 @@ export class EditorViewport {
6203
5197
 
6204
5198
  if (!this._marqueeDiv) {
6205
5199
  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.
5200
+ // Use the sticky chrome tier above the editor root and below dropdowns.
6210
5201
  Object.assign(this._marqueeDiv.style, {
6211
5202
  position: 'fixed',
6212
5203
  pointerEvents: 'none',
6213
5204
  zIndex: zIndex.sticky,
6214
5205
  });
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.
5206
+ // Mount inside the theme scope so --volter-* colors resolve. Fixed positioning keeps the
5207
+ // client-coordinate math.
6221
5208
  (document.getElementById('editor-chrome-root') ?? document.body).appendChild(
6222
5209
  this._marqueeDiv,
6223
5210
  );
@@ -6228,12 +5215,12 @@ export class EditorViewport {
6228
5215
  Object.assign(this._marqueeDiv.style, marqueeStyle(nativeViewportMarqueeDash(this._canvas)));
6229
5216
  }
6230
5217
 
6231
- if (this._marqueeActive && this._marqueeDiv) {
5218
+ if (this._marqueeActive) {
6232
5219
  const left = Math.min(this._pointerDownPos.x, e.clientX);
6233
5220
  const top = Math.min(this._pointerDownPos.y, e.clientY);
6234
5221
  const w = Math.abs(e.clientX - this._pointerDownPos.x);
6235
5222
  const h = Math.abs(e.clientY - this._pointerDownPos.y);
6236
- Object.assign(this._marqueeDiv.style, {
5223
+ Object.assign(this._marqueeDiv!.style, {
6237
5224
  left: `${left}px`,
6238
5225
  top: `${top}px`,
6239
5226
  width: `${w}px`,
@@ -6243,6 +5230,14 @@ export class EditorViewport {
6243
5230
  }
6244
5231
  };
6245
5232
 
5233
+ /** A drag lost its release: a spatial handle goes back uncommitted, a constraint-control
5234
+ * session closes, and the pointer session with them. */
5235
+ private _onInteractionDragLost = (): void => {
5236
+ if (this._activeSpatialHandle) this._finishSpatialHandleDrag(false);
5237
+ this._constraintControlPointerSession = false;
5238
+ this._pointerSessionActive = false;
5239
+ };
5240
+
6246
5241
  private _onPointerUp = (e: PointerEvent): void => {
6247
5242
  // Only complete gestures that STARTED on the canvas (`_onPointerDown`) —
6248
5243
  // this listener lives on the interaction element (see the binding comment
@@ -6260,18 +5255,12 @@ export class EditorViewport {
6260
5255
  return;
6261
5256
  }
6262
5257
 
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.
5258
+ // Marquee scans this viewport's native object map. Other composite roots are outside
5259
+ // its screen-projection selection; single-click picking below can cross those roots.
6269
5260
  if (this._marqueeActive) {
6270
5261
  this._marqueeActive = false;
6271
5262
  this.orbitControls.enabled = true;
6272
- if (this._marqueeDiv) {
6273
- this._marqueeDiv.style.display = 'none';
6274
- }
5263
+ this._marqueeDiv!.style.display = 'none';
6275
5264
 
6276
5265
  // Find all entities whose screen projection falls within the marquee
6277
5266
  const left = Math.min(this._pointerDownPos.x, e.clientX);
@@ -6283,33 +5272,12 @@ export class EditorViewport {
6283
5272
  const corner = new THREE.Vector3();
6284
5273
  const bounds = new THREE.Box3();
6285
5274
  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.
5275
+ // Skip objects whose own visibility is off.
6295
5276
  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.
5277
+ // Exclude cameras from marquee selection; click and hierarchy selection still reach them.
6303
5278
  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.
5279
+ // Test projected geometry bounds against the rectangle using the presentation’s selection rule.
5280
+ // Objects with no renderable bounds use their projected origin.
6313
5281
  bounds.setFromObject(obj);
6314
5282
  if (bounds.isEmpty()) {
6315
5283
  const screen = this._projectToScreen(obj);
@@ -6318,21 +5286,7 @@ export class EditorViewport {
6318
5286
  }
6319
5287
  continue;
6320
5288
  }
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.
5289
+ // 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
5290
  const touch = this._stageBoxSelect === 'touch';
6337
5291
  const rect = this._canvas.getBoundingClientRect();
6338
5292
  // Through the camera on screen: an orthographic document view or a camera view draws with
@@ -6369,8 +5323,7 @@ export class EditorViewport {
6369
5323
  }
6370
5324
 
6371
5325
  if (e.shiftKey) {
6372
- // Add to existing selection (additive modifier — SelectionProvider
6373
- // extension is Phase-F; first-party store op for now).
5326
+ // Extend the store selection for the additive modifier.
6374
5327
  for (const id of hitIds) this._store.shell.addToSelection(id);
6375
5328
  } else {
6376
5329
  setAuthoringSelection(this._authoring(), hitIds);
@@ -6389,63 +5342,26 @@ export class EditorViewport {
6389
5342
  // Don't interfere with gizmo clicks
6390
5343
  if (this._anyGizmoDragging()) return;
6391
5344
 
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)
5345
+ // Check the orientation gizmo first; it is clickable only on a visible three surface.
6394
5346
  if (this._threejsToolContextActive && this._handleViewCubeClick(e)) return;
6395
5347
 
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.
5348
+ // Try the active adapter’s pickable layers from topmost to bottommost. Interactive DOM layers
5349
+ // intercept their own pointer events before they reach this canvas.
6404
5350
  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`).
5351
+ // A click outside the open scope leaves it; empty space closes every level. pickAcrossScopeExit
5352
+ // updates the scope stack and resolves the hit again when the stack changes.
6411
5353
  const entityId = pickAcrossScopeExit(this._scopeAuthoring(), () =>
6412
5354
  this._pickAt(e.clientX, e.clientY, deepSelection ? 'deep' : 'normal'),
6413
5355
  );
6414
5356
 
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) {
5357
+ if (!deepSelection && entityId && (e.shiftKey || e.metaKey || e.ctrlKey)) {
6428
5358
  this._store.shell.toggleSelection(entityId);
6429
5359
  } else {
6430
5360
  setAuthoringSelection(this._authoring(), entityId ? [entityId] : []);
6431
5361
  }
6432
5362
  };
6433
5363
 
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
- */
5364
+ /** Key selection scopes to the same panel adapter used by the Hierarchy. An injected document adapter keeps its own scope. */
6449
5365
  private _scopeAuthoring(): AuthoringAdapter {
6450
5366
  return this._standaloneAuthoring
6451
5367
  ? this._authoring()
@@ -6461,25 +5377,10 @@ export class EditorViewport {
6461
5377
  }
6462
5378
 
6463
5379
  /**
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.
5380
+ * Double-click opens the selected component’s scope and selects the next member down its owner
5381
+ * chain. Escape walks out and an outside click leaves the scope. The ordinary layered pick
5382
+ * resolves through the adapter with currentSelectionScopeId, including the two single-clicks
5383
+ * preceding this gesture.
6483
5384
  */
6484
5385
  private _onDoubleClick = (e: MouseEvent): void => {
6485
5386
  if (e.button !== 0) return;
@@ -6498,181 +5399,4 @@ export class EditorViewport {
6498
5399
  this._pickAt(e.clientX, e.clientY, 'normal'),
6499
5400
  );
6500
5401
  };
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
5402
  }