@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,31 +1,4 @@
1
- /**
2
- * How far a Three authoring view's perspective camera can see.
3
- *
4
- * ## The measured defect this exists for
5
- *
6
- * The editor camera was constructed once, with `far = 1000`, and never
7
- * adjusted. That is fine for the starter template (a cube on a 50-unit grid)
8
- * and wrong for anything world-sized: framing a world several hundred units
9
- * across puts the camera past the far plane, so every triangle is clipped and
10
- * the Scene viewport is a flat, empty field. The failure is graded, which is
11
- * what made it confusing — at a 454-unit framing the world draws; at 1099 only
12
- * the fragments within 1000 survive; at 1276 nothing does. A game whose own
13
- * camera declares `far = 10000` is stating the honest scale of its world, and
14
- * the editor camera has to reach it.
15
- *
16
- * Nothing about that is ingest-specific — a first-party world larger than
17
- * ~1000 units disappears the same way — so the fix is a property of the editor
18
- * camera, not of the ingest route.
19
- *
20
- * ## Why grow `far` instead of just setting it large
21
- *
22
- * The depth buffer's precision is governed by `far / near`, not by `far`. A
23
- * blanket `far = 100000` would give every small scene a 10^6 ratio and visible
24
- * z-fighting. So `far` tracks the content the camera actually has to reach,
25
- * and `near` is pushed out only as much as is needed to keep the ratio bounded
26
- * — which, for anything at the template's scale, is not at all: the planes come
27
- * back exactly `0.1 / 1000`, the values the camera has always had.
28
- */
1
+ /** Fit authoring-camera clip planes to content while bounding their ratio for depth precision. Retain base planes for ordinary small scenes. */
29
2
 
30
3
  /** The camera's minimum near plane — also the exact value for any small scene. */
31
4
  export const EDITOR_CAMERA_NEAR = 0.1;
@@ -34,7 +7,7 @@ export const EDITOR_CAMERA_NEAR = 0.1;
34
7
  export const EDITOR_CAMERA_FAR = 1000;
35
8
 
36
9
  /**
37
- * Ceiling on `far / near`. 0.1/1000 is 10^4, so this leaves two doublings of
10
+ * Ceiling on `far / near`. 1000/0.1 is 10^4, so this leaves one doubling of
38
11
  * headroom before `near` starts moving at all, and caps the depth-precision
39
12
  * loss a very large world can inflict.
40
13
  */
@@ -47,9 +20,8 @@ const REACH_MARGIN = 1.25;
47
20
  * Clip planes that reach `contentRadius` around a center `distanceToCenter`
48
21
  * away, never tighter than the base planes.
49
22
  *
50
- * Pure and total: a non-finite or negative input yields the base planes rather
51
- * than an `Infinity`/`NaN` projection matrix, because a camera whose bounds
52
- * could not be measured must still draw.
23
+ * Pure and total: a non-finite or negative input contributes zero to the reach.
24
+ * The remaining input still fits the content, and the base planes bound the result.
53
25
  */
54
26
  export function fitClipPlanes(
55
27
  distanceToCenter: number,
@@ -1,71 +1,16 @@
1
- /**
2
- * World-space bounds of the GAME OBJECT a reader selected — the one walk every
3
- * bounds consumer in the editor uses instead of `Box3.setFromObject`.
4
- *
5
- * ## Why `Box3.setFromObject` is the wrong instrument here
6
- *
7
- * `setFromObject` answers "what is the extent of this whole subtree", and a
8
- * live game's subtree contains machinery as well as content. The marks in
9
- * `@volter/editor-threejs/adapter/hierarchy-marks` already say which is which — a component
10
- * WRAPS its functionality and MARKS the implementation it attaches — and the
11
- * hierarchy panel and the drag rules already read them. Bounds is the third
12
- * consumer, and it is the one where getting it wrong is visible on screen.
13
- *
14
- * The measurement that forced this (2026-08-14, the translated platformer's
15
- * coins): a component root at `(12, 3, -5)` owning a 0.5 m coin mesh and, as a
16
- * real child, its three.quarks `BatchedRenderer` with
17
- * `matrixWorldAutoUpdate = false`. That renderer draws in WORLD space, so its
18
- * identity `matrixWorld` is deliberate and correct for rendering — but its
19
- * `VFXBatch` child carries the billboard template geometry, whose bounding box
20
- * is `(-0.5, -0.5, 0)…(0.5, 0.5, 0)` and, at identity, sits AT THE WORLD
21
- * ORIGIN. `setFromObject` unioned the two and returned
22
- * `(-0.5, -0.5, -5.05)…(12.25, 3.25, 0)`: a selection cage stretching from the
23
- * coin all the way back to the origin, on a coin that is 0.5 m across. The
24
- * batch contributes that box whether or not a single particle is alive, so the
25
- * cage was wrong from the first frame.
26
- *
27
- * That is the general shape, not a quarks quirk: ANY built-internal child whose
28
- * world transform its owner writes by hand (`matrixWorldAutoUpdate = false` is
29
- * the mechanism the marks' own header recommends) sits somewhere the subtree
30
- * walk has no reason to expect, and pooled machinery routinely carries stale or
31
- * template-sized geometry. Excluding implementation is the only rule that
32
- * survives all of them.
33
- *
34
- * ## What is NOT excluded
35
- *
36
- * Editor furniture is deliberately still measured. `isEditorOwnedObject`
37
- * matches `userData.engineInternal`, and the splat bounds proxy
38
- * (`@volter/editor-threejs-runtime/asset-loaders`, `__volter_splat_bounds`) is engine-internal
39
- * geometry that exists PRECISELY so generic focus/selection bounds can frame a
40
- * Gaussian splat — a splat renders no `BufferGeometry` of its own. Excluding
41
- * editor-owned nodes here would silently un-frame every splat. Consumers that
42
- * want helpers out of their measurement (`_hasBoundableGeometry`, the asset
43
- * preview's staging) filter for that themselves, on top of this walk.
44
- *
45
- * The selected object ITSELF is always measured, even when it is marked. A
46
- * revealed internals row is selectable, and answering "where is it" with an
47
- * empty box would be a refusal dressed as a measurement.
48
- */
1
+ /** Measure world-space content bounds for shared focus and selection consumers. Include editor-owned bound proxies such as Gaussian-splat geometry; callers may filter helpers separately. */
49
2
 
50
- import { isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
51
3
  import * as THREE from 'three';
52
4
 
53
5
  /**
54
- * Visit `object` and every descendant that is not implementation, top-down.
55
- *
56
- * Subtree-scoped, matching the mark's own contract: a marked node is skipped
57
- * WITH everything under it, so a rig or a pool costs one check rather than one
58
- * per bone. `object` itself is always visited — see the header.
6
+ * Visit `object` and every descendant, top-down.
59
7
  */
60
8
  export function traverseContent(
61
9
  object: THREE.Object3D,
62
10
  visit: (node: THREE.Object3D) => void,
63
11
  ): void {
64
12
  visit(object);
65
- for (const child of object.children) {
66
- if (isBuiltInternal(child)) continue;
67
- traverseContent(child, visit);
68
- }
13
+ for (const child of object.children) traverseContent(child, visit);
69
14
  }
70
15
 
71
16
  /** Scratch for one node's transformed geometry box — this module is synchronous
@@ -73,31 +18,7 @@ export function traverseContent(
73
18
  const nodeBox = new THREE.Box3();
74
19
  const frameMatrix = new THREE.Matrix4();
75
20
 
76
- /**
77
- * Is this box a measurement at all?
78
- *
79
- * `Box3.isEmpty()` compares `max < min`, and EVERY comparison against `NaN` is
80
- * false — so a box with a non-finite corner reports itself as a real,
81
- * non-empty box, unions into every consumer, and turns the whole world's
82
- * extent into `NaN`.
83
- *
84
- * That is not hypothetical and it is not the game's fault: a live world holds
85
- * transforms that are not numbers yet. Measured on the racing-game mount
86
- * (2026-08-15) — two of its meshes sit at `quaternion = (NaN, NaN, NaN, NaN)`
87
- * while the game is PAUSED at frame zero, because a physics binding reads its
88
- * worker's buffers before the worker has ever answered. The editor mounts an
89
- * ingested game paused, so that is the exact state the first look measures.
90
- * One such node poisoned all 71 boxes: the framing refused (`isEmpty()` false,
91
- * fit distance `NaN`), the game-camera seed refused (`radius` `NaN`), and the
92
- * Scene tab opened on the editor's boot pose — inside canyon geometry, a
93
- * full-bleed rectangle of rock.
94
- *
95
- * The clip planes already made this decision for themselves
96
- * (`viewport-clip-planes.ts`: "a camera whose bounds could not be measured
97
- * must still draw"). Dropping the node HERE is the same decision made once, at
98
- * the measurement, so framing, clipping and the selection cage all get the
99
- * finite answer instead of each guarding separately.
100
- */
21
+ /** Reject non-finite corners before unioning a bounds box. Box3.isEmpty alone does not reject NaN, which would contaminate every derived extent. */
101
22
  function isFiniteBox(box: THREE.Box3): boolean {
102
23
  return (
103
24
  Number.isFinite(box.min.x) &&
@@ -113,29 +34,7 @@ function isFiniteBox(box: THREE.Box3): boolean {
113
34
  const instanceMatrix = new THREE.Matrix4();
114
35
  const instanceBox = new THREE.Box3();
115
36
 
116
- /**
117
- * Union an `InstancedMesh`'s OWN drawn units into `target`, in world space.
118
- *
119
- * **Why not the object-level `boundingBox` three's own rule prefers here:
120
- * `InstancedMesh.computeBoundingBox()` CACHES, and instanced content MOVES.**
121
- * `boundingBox` starts `null`, is filled on the first request, and is never
122
- * recomputed — three's docs say so outright ("You may need to recompute the
123
- * bounding box if an instance is transformed"). A trail system that rewrites
124
- * every instance matrix each frame (the racing-game's `Dust`/`Skid`) is
125
- * therefore measured forever at whatever pose the editor first happened to ask
126
- * about, and the per-frame selection cage — which exists to be glued to the
127
- * thing — is glued to where the content USED to be. Nothing invalidates that
128
- * cache, because nothing in the game's code knows the editor is looking.
129
- *
130
- * So the union is taken from the instance matrices as they stand, per call. The
131
- * per-instance finite filter is the same decision {@link isFiniteBox} makes one
132
- * level up and for the same reason: a live world holds transforms that are not
133
- * numbers yet, and one such unit would otherwise turn the node's whole box into
134
- * `NaN` — which `isEmpty()` cannot see.
135
- *
136
- * Cost is O(count) per call, which is exactly what `computeBoundingBox` costs;
137
- * the callers that pay it per frame are scoped to the selection.
138
- */
37
+ /** Union each InstancedMesh unit from its current instance matrix and geometry bounds. Skip non-finite instances instead of relying on the mesh’s cached box. */
139
38
  function expandByInstances(
140
39
  target: THREE.Box3,
141
40
  node: THREE.InstancedMesh,
@@ -157,16 +56,9 @@ function expandByInstances(
157
56
  /**
158
57
  * Union `node`'s OWN renderable extent into `target`, ignoring its children.
159
58
  *
160
- * The object-level `boundingBox` (SkinnedMesh / BatchedMesh, which account for
161
- * skinning and draw ranges) takes precedence over the shared geometry's, exactly
162
- * as three's own `Box3.expandByObject` decides it. We cannot call that method —
163
- * it walks children, which is the whole thing this module exists to control — so
164
- * the per-node half is spelled here, over three's public `boundingBox` /
165
- * `computeBoundingBox` API. Same shape as `model-thumbnail.ts`'s
166
- * `modelPreviewBounds`, which already needed a filtered walk for its own reason.
167
- *
168
- * `InstancedMesh` is the one node three's own rule gets wrong for a LIVE world;
169
- * see {@link expandByInstances}.
59
+ * SkinnedMesh uses its geometry's bind-pose box; instanced meshes expand each unit.
60
+ * Other objects use their own boundingBox when available, then the geometry's box.
61
+ * The caller controls descendant traversal and editor-helper filtering.
170
62
  */
171
63
  function expandByNodeGeometry(
172
64
  target: THREE.Box3,
@@ -202,21 +94,7 @@ function localRenderableBox(
202
94
  boundingBox?: THREE.Box3 | null;
203
95
  computeBoundingBox?: () => void;
204
96
  };
205
- // An InstancedMesh's own `boundingBox` already has the instance matrices
206
- // baked in, so it is the GEOMETRY box that `expandByInstances` needs.
207
- //
208
- // A SkinnedMesh is measured by its GEOMETRY (bind-pose) box, and its
209
- // mesh-level cache is deliberately never touched. `computeBoundingBox()` on
210
- // a SkinnedMesh runs the CPU skinning path, and on the humanoid rigs it
211
- // yields bone-WORLD-contaminated "local" bounds (measured live on the
212
- // hosted editor: local box z≈7.8..8.2 for a body whose matrixWorld already
213
- // carries z=8). Three CACHES that box on the mesh, and `SkinnedMesh.raycast`
214
- // then uses it as an early-out gate against the LOCALIZED ray — so the one
215
- // framing/thumbnail pass that touched a character made it click-through
216
- // FOREVER: rays hit the floor behind while the body rendered right under
217
- // the cursor (the enemy-torso click that selected the jump pad ten units
218
- // behind it). The bind-pose box is the honest edit-mode answer here anyway
219
- // — edit mode shows the authored pose.
97
+ // Use geometry bounds for instanced units and the bind-pose box for SkinnedMesh. Leave skinned mesh-level caches untouched so raycasting does not inherit bone-world-space bounds.
220
98
  if ((node as THREE.SkinnedMesh).isSkinnedMesh) {
221
99
  if (geometry.boundingBox === null) geometry.computeBoundingBox();
222
100
  return geometry.boundingBox;
@@ -256,12 +134,7 @@ const collectBox = new THREE.Box3();
256
134
  */
257
135
  export interface ContentNodeRecord {
258
136
  readonly box: THREE.Box3;
259
- /**
260
- * Every material on this mesh has `depthWrite === false`. That is the
261
- * authored "I am a volume, not a body" flag — measured on the lighthouse
262
- * beam (`transparent`, `opacity: 0.16`, `depthWrite: false`) and absent
263
- * from every body mesh of that prefab and of an opaque hull.
264
- */
137
+ /** Whether every material disables depthWrite, identifying a volume rather than an opaque body for this bounds walk. */
265
138
  readonly overlay: boolean;
266
139
  /**
267
140
  * Every material on this mesh is `BackSide`. That is the authored
@@ -296,14 +169,12 @@ export function nodeIsEnclosure(node: THREE.Object3D): boolean {
296
169
  * The union answers "how big is this world"; framing needs "where is this
297
170
  * world's content", and those are different questions the moment a world has a
298
171
  * backdrop — one skybox sphere or one far prop moves the union and moves
299
- * nothing else (`scene-framing.ts` has the measurement). Only nodes that
172
+ * nothing else. Only nodes that
300
173
  * actually draw contribute a box, so groups, lights and cameras add nothing to
301
174
  * the distribution.
302
175
  */
303
- export function collectContentNodeRecords(
304
- object: THREE.Object3D,
305
- out: ContentNodeRecord[] = [],
306
- ): ContentNodeRecord[] {
176
+ export function collectContentNodeRecords(object: THREE.Object3D): ContentNodeRecord[] {
177
+ const out: ContentNodeRecord[] = [];
307
178
  traverseContent(object, (node) => {
308
179
  node.updateWorldMatrix(false, false);
309
180
  collectBox.makeEmpty();
@@ -14,35 +14,13 @@ export const EDITOR_LAYER = 31;
14
14
  export const EDITOR_SELECTION_LAYER = 30;
15
15
 
16
16
  /**
17
- * True when this object is the EDITOR'S OWN furniture rather than game content.
18
- *
19
- * There are two marking conventions in this codebase and neither one covers
20
- * everything: the viewport's constructor-time furniture (`GridHelper`, the
21
- * editor ambient/directional lights, the three.quarks `BatchedRenderer`, the
22
- * TransformControls helper, the pivot dummy, the snap indicators) is marked
23
- * ONLY with `layers.set(EDITOR_LAYER)`, while helpers added later
24
- * (skeleton/physics/crowd/component gizmos) are marked ONLY with
25
- * `userData.editorHelper`. A walk that checks one convention silently presents
26
- * the other half as game content.
27
- *
28
- * That is the SimCity ledger's S-1 hierarchy defect: `projectThreeScene`
29
- * checked only the userData flags, so when an ingest mount FAILED — leaving
30
- * `store.scene` pointed at the editor viewport scene instead of a captured game
31
- * scene — the hierarchy listed the editor's own grid, lights, BatchedRenderer,
32
- * gizmo helper, pivot dummy and two snap indicators under the game world's id.
33
- * A dead game was indistinguishable from a live one.
34
- *
35
- * This is the SAME predicate the engine's ingest walk already applies
36
- * (`@volter/editor-threejs-runtime/adapter/ingest/structural-ids`'s `isEditorOnly`, layer half) plus
37
- * the userData half `viewport-shading-boundary.ts` applies — stated once here
38
- * so the two halves can never drift apart again.
17
+ * Identify editor furniture through either EDITOR_LAYER or the editorHelper userData mark.
18
+ * Constructor-time viewport objects and dynamically added helpers use these complementary marks;
19
+ * hierarchy, capture and export filters must recognize both.
39
20
  */
40
21
  export function isEditorOwnedObject(object: THREE.Object3D): boolean {
41
22
  return (
42
- // Read the public mask, which has existed for the full three.js range the
43
- // ingest devtools hook supports. `Layers.isEnabled()` is newer than r129:
44
- // calling it rejected an otherwise healthy Cuberun capture before the
45
- // hierarchy could mount.
23
+ // The public layer mask identifies editor-only infrastructure.
46
24
  (object.layers.mask & (1 << EDITOR_LAYER)) !== 0 ||
47
25
  !!getObjectMark(object, 'editorHelper') ||
48
26
  !!getObjectMark(object, 'engineInternal')
@@ -28,7 +28,7 @@ export function createStandardEnvironment(renderer: THREE.WebGLRenderer): Standa
28
28
  }
29
29
 
30
30
  /**
31
- * An environment image (`@volter/sdk/kit/environment-images`) as an equirectangular
31
+ * An environment image (`@volter/sdk/environment-images`) as an equirectangular
32
32
  * texture in scene-referred light. Read as half float: a sun past white stays bright, and half
33
33
  * float filters linearly where full float cannot (phones).
34
34
  */
@@ -1,40 +1,9 @@
1
1
  /**
2
- * The WebGL renderer the INSPECTOR-PREVIEW lane draws through — one per editor
3
- * session, not one per selection.
4
- *
5
- * Why this module exists (measured, 2026-08-07): every chromeless preview
6
- * (`components/InspectorObjectPreview.tsx` → `Object3DSnapshotDocument` →
7
- * `Object3DDocumentViewport`) used to construct its own
8
- * `THREE.WebGLRenderer` on mount, so SELECTING A THING built a whole GL stack.
9
- * A renderer's constructor is a long chain of synchronous driver round trips
10
- * (`WebGLCapabilities`/`getExtension`/`getParameter`), and its program cache
11
- * starts empty, so every selection also recompiled the subject's shaders and
12
- * re-baked the PMREM environment. A click profiled at ~1.9 s of frozen main
13
- * thread, 3.5 s of self time in `gl.getParameter` alone. Sharing one renderer
14
- * removes the construction entirely and lets the program cache and the IBL
15
- * bake survive across selections.
16
- *
17
- * OWNERSHIP — the one place it is stated:
18
- * - This module OWNS every canvas, `WebGLRenderer` and baked
19
- * {@link StandardEnvironment} it creates. Nothing else may dispose them.
20
- * - SHARERS are lease holders: {@link acquireInspectorPreviewRenderer} hands
21
- * out a lease, the holder mounts `lease.canvas` in its own container and
22
- * draws through `lease.renderer` for as long as it is mounted.
23
- * - The ONE teardown path a holder has is {@link InspectorPreviewLease.release}
24
- * — it returns the renderer to the pool (or disposes it, if it was an
25
- * overflow lease that the pool does not keep). A holder never calls
26
- * `renderer.dispose()`, and never disposes `lease.environment()`, whose
27
- * handle is deliberately non-owning.
28
- * - Per-selection SCENE resources (the snapshot's skeletons, the dressing's
29
- * lights/grid/backdrop) stay the holder's, exactly as before: three's
30
- * program cache lives on the renderer and is not touched by disposing the
31
- * materials that populated it.
32
- *
33
- * Capacity is deliberately small. The inspector shows ONE live preview at a
34
- * time by construction (`InspectionProjection.tsx` drops the disc to its glyph
35
- * while the preview panel is open), so a second entry is slack for a handover
36
- * frame, and anything beyond that is a transient renderer disposed on release
37
- * rather than a growing pool.
2
+ * Lease pooled WebGL renderers for Inspector previews so program caches and environment bakes
3
+ * survive selection changes. This module owns each canvas, renderer and StandardEnvironment;
4
+ * holders mount and draw through the lease, then release it without disposing those shared
5
+ * resources. Per-selection scene resources belong to the holder. Keep a small idle pool and dispose
6
+ * overflow renderers on release.
38
7
  */
39
8
 
40
9
  import * as THREE from 'three';
@@ -108,15 +77,8 @@ function disposeEntry(entry: PreviewRendererEntry): void {
108
77
  entry.environment?.dispose();
109
78
  entry.environment = null;
110
79
  entry.renderer.dispose();
111
- // `dispose()` frees GPU RESOURCES; it does not give the CONTEXT back. The
112
- // canvas holds it until garbage collection decides otherwise, and WebGL
113
- // contexts are a hard ~16-per-page budget the browser enforces by killing
114
- // the OLDEST — which is the main viewport, so the symptom is a white
115
- // viewport and "too many active WebGL contexts" far from the cause
116
- // (runhuman pass 104). Every other renderer owner in this editor already
117
- // pairs the two (`asset-preview.ts`, `components/world-root-stage.ts`); this
118
- // shared pool did not, and it backs both the Inspector preview and the
119
- // Object3D tool's viewport, so its transients leaked a context each.
80
+ // Release the WebGL context as well as GPU resources so discarded previews do not consume the
81
+ // browser’s context budget.
120
82
  entry.renderer.forceContextLoss();
121
83
  entry.canvas.remove();
122
84
  }
@@ -1,67 +1,23 @@
1
1
  /**
2
- * The set of `WebGLRenderer`s the editor-side Three integration owns — its viewport, and every
3
- * offscreen renderer it spins up for thumbnails, asset previews and asset
4
- * compare.
5
- *
6
- * Why this exists: ingest's scene-capture trap
7
- * (`@volter/editor-threejs-runtime/adapter/ingest/scene-capture`) is installed on the SHARED
8
- * `three.WebGLRenderer.prototype` — that sharing is the whole mechanism, it is
9
- * how an unmodified game's renderer gets instrumented without touching the
10
- * game's source. But it means the editor's own renders arrive at the trap too,
11
- * and the trap captures the first scene it sees. Any host renderer constructed
12
- * after the trap installs (a thumbnail bake, an asset preview) could therefore
13
- * be captured AS THE INGESTED GAME.
14
- *
15
- * That is not hypothetical: it is what made the "a game bundling its own three
16
- * is DETECTED, never silently mistaken for a capture" spec pass or fail on
17
- * timing alone. When it failed, the editor
18
- * had adopted its own thumbnail scene as the game: a hierarchy of the editor's
19
- * own lights, presented as the game's content.
20
- *
21
- * Marking is explicit at each construction site rather than inferred, because
22
- * the honest question ("did the HOST make this renderer?") has no reliable
23
- * signal at the trap: a host renderer and a game renderer are the same class,
24
- * both constructed after install, and both render real scenes.
25
- */
26
-
27
- const _hostRenderers = new WeakSet<object>();
28
-
29
- /** Record `renderer` as the editor's own. Returns it, so it can wrap a `new`. */
30
- /**
31
- * WebGL contexts are a HARD, SMALL browser budget (order of sixteen per page),
32
- * and this is the one chokepoint every editor-constructed renderer passes
33
- * through — so it is where an editor that is quietly accumulating them can be
34
- * caught naming its own culprit.
35
- *
36
- * A tester adding and deleting prefabs hit "too many active WebGL contexts"
37
- * after five rounds and the whole viewport turned white, because the browser
38
- * evicts the OLDEST context when the limit is reached and the oldest is the
39
- * main viewport (runhuman pass 104). Three's warning comes from inside
40
- * `three.module.js` and names nothing about who asked, which is what made it
41
- * undiagnosable from the report.
42
- *
43
- * The count is LIVE: `dispose()` is wrapped so a renderer that is properly
44
- * torn down stops counting. Past the budget the warning carries the creating
45
- * stack, so the next sighting names the leak instead of the victim.
2
+ * Count live WebGLRenderers owned by the editor’s three.js integration, including offscreen
3
+ * previews. Wrapped dispose calls decrement the count. Warn with the creating stack near the
4
+ * browser’s context budget so the renderer owner can be located.
46
5
  */
47
6
  const LIVE_RENDERER_WARN_AT = 8;
48
7
  let liveHostRenderers = 0;
49
8
 
50
- export function markHostRenderer<T extends object>(renderer: T): T {
51
- _hostRenderers.add(renderer);
9
+ /** Count `renderer` as the editor's own. Returns it, so it can wrap a `new`. */
10
+ export function markHostRenderer<T extends { dispose(): void }>(renderer: T): T {
52
11
  liveHostRenderers += 1;
53
- const disposable = renderer as { dispose?: () => void };
54
- const dispose = disposable.dispose;
55
- if (typeof dispose === 'function') {
56
- let counted = true;
57
- disposable.dispose = function wrappedDispose(this: unknown, ...args: unknown[]) {
58
- if (counted) {
59
- counted = false;
60
- liveHostRenderers -= 1;
61
- }
62
- return (dispose as (...a: unknown[]) => unknown).apply(this, args);
63
- } as () => void;
64
- }
12
+ const dispose = renderer.dispose;
13
+ let counted = true;
14
+ renderer.dispose = function wrappedDispose() {
15
+ if (counted) {
16
+ counted = false;
17
+ liveHostRenderers -= 1;
18
+ }
19
+ dispose.call(this);
20
+ };
65
21
  if (liveHostRenderers >= LIVE_RENDERER_WARN_AT) {
66
22
  // biome-ignore lint/suspicious/noConsole: the standing warning channel for a budget the browser enforces by destroying the viewport
67
23
  console.warn(
@@ -80,7 +36,3 @@ export function liveHostRendererCount(): number {
80
36
  return liveHostRenderers;
81
37
  }
82
38
 
83
- /** True when `renderer` is one the editor constructed for its own rendering. */
84
- export function isHostRenderer(renderer: unknown): boolean {
85
- return typeof renderer === 'object' && renderer !== null && _hostRenderers.has(renderer);
86
- }
@@ -1,21 +1,11 @@
1
1
  /**
2
2
  * THE AUTHORED THREE VIEWPORT'S PUBLIC SHAPE — what a lane that mounts something reaches through
3
3
  * `viewport-door.ts`: the rig a camera flight drives, the frame loop it rides, the helpers it
4
- * shows, every mounted stage, and the one verb that swaps the viewport's subject for a live
5
- * instance's roots (Play's adoption of the running scene).
4
+ * shows and every mounted stage.
6
5
  */
7
- import type { MountedRoot } from '@volter/project/adapter/root-adapter';
8
6
  import type * as THREE from 'three';
9
7
  import type { StageFrameCostReading } from '@volter/sdk';
10
8
 
11
- /** A live instance's roots, presented as the authored viewport's subject. */
12
- export interface ViewportPresentation {
13
- /** The root whose native subject the viewport shows. */
14
- readonly worldId: string;
15
- /** Restores the viewport's prior subject; idempotent. */
16
- dispose(): void;
17
- }
18
-
19
9
  /** What a frame of a stage costs, uncapped ({@link ViewportRig.frameCost}): the wire's reading
20
10
  * without the stage's id, which the door adds. */
21
11
  export type StageFrameCost = Omit<StageFrameCostReading, 'stage'>;
@@ -32,15 +22,20 @@ export interface ViewportRig {
32
22
  * `requestAnimationFrame`, each waited out on the GPU, so the number is the frame's cost and
33
23
  * not the display's refresh. Absent on a stage with no frame of its own.
34
24
  */
35
- frameCost?(frames: number, quality?: 'full' | 'navigation'): StageFrameCost;
25
+ frameCost?(frames: number, quality: 'full' | 'navigation'): StageFrameCost;
36
26
  readonly orbit: { readonly target: THREE.Vector3; enabled: boolean; update(): void };
27
+ /** While `owned`, the stage leaves the camera's projection (its clip planes) to whoever drives it:
28
+ * a running game. Absent on a stage that never fits its planes. */
29
+ readonly projection?: { owned: boolean };
37
30
  /** The editor's own scene (helpers live on its editor layer). */
38
31
  readonly scene: THREE.Scene;
32
+ /** The stage's one renderer, for a binding that draws a document's content into this stage's scene through it. */
33
+ readonly renderer: THREE.WebGLRenderer;
39
34
  }
40
35
 
41
36
  /**
42
37
  * ONE MOUNTED 3D STAGE, named by the document it draws. Every 3D document
43
- * mounts a stage of its own (ARCHITECTURE-CORE §One stage); this is the door
38
+ * mounts a stage of its own ; this is the door
44
39
  * to a particular one, where the viewport door's singular members
45
40
  * are the door to whichever is primary.
46
41
  */
@@ -56,16 +51,13 @@ export interface ViewportStage {
56
51
 
57
52
  /**
58
53
  * THE AUTHORED VIEWPORT, as a lane that mounts something reaches it: the rig
59
- * a camera flight drives, the frame loop it rides, and the one verb that
60
- * swaps the viewport's subject for a live instance's roots (Play's adoption
61
- * of the running scene). Which native surface the viewport renders is the
62
- * host's; a lane that finds no rig (a headless tab, a project with no Three
63
- * root) mounts without one.
54
+ * a camera flight drives and the frame loop it rides. Which native surface the
55
+ * viewport renders is the host's; a lane that finds no rig (a headless tab, a
56
+ * project with no Three root) mounts without one.
64
57
  *
65
- * THE SINGULAR MEMBERS ARE THE PRIMARY STAGE — the stage presenting live
66
- * roots if one is, else the FOCUSED stage, else the first bound. A camera
67
- * flight and Play's adoption want exactly that one, which is why they read
68
- * here and never enumerate.
58
+ * THE SINGULAR MEMBERS ARE THE PRIMARY STAGE — the FOCUSED stage, else the
59
+ * first bound. A camera flight wants exactly that one, which is why it reads
60
+ * here and never enumerates.
69
61
  *
70
62
  * {@link stages} is EVERY mounted 3D stage. A helper is an `Object3D` and an
71
63
  * `Object3D` has one parent, so there is no "set it once for all stages": a
@@ -73,19 +65,8 @@ export interface ViewportStage {
73
65
  * `onStages` is how a lane keeps up with stages that mount and unmount after
74
66
  * it made that pass.
75
67
  */
76
- /** A mounted adapter surface offered to the viewport. Execution, input and
77
- * ticking stay with the caller; presentation requires only identity and mount.
78
- * A game's richer root instance can satisfy this interface directly.
79
- */
80
- export interface ViewportRoot {
81
- readonly id: string;
82
- readonly mounted: MountedRoot;
83
- }
84
-
85
68
  export interface ViewportDoor {
86
69
  rig(): ViewportRig | null;
87
- /** Null when the viewport has no subject among `roots` (no Three root). */
88
- presentRoots(roots: readonly ViewportRoot[]): ViewportPresentation | null;
89
70
  /** Runs after the viewport's own per-frame update; the return unsubscribes. */
90
71
  onFrame(fn: (dtSeconds: number) => void): () => void;
91
72
  /**