@volter/editor-threejs 0.5.65 → 0.5.67

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 (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,814 @@
1
+ /**
2
+ * EditorShellStore — the shell store's THREE half: the adopted scene and its object map, the
3
+ * renderer and camera, viewport tools (transform, snapping, pivot, gizmo), helper and shading
4
+ * state, LOD pins, capture and the periodic editor-state save. It extends `ShellStore`
5
+ * (`shell-store.ts`), the media-neutral half the kit's other modules type against, and leaves
6
+ * the kit for `@volter/editor-threejs` with the viewport (ARCHITECTURE.md §The plan, unit 3).
7
+ * It knows `THREE.Object3D`s and ids, never a document model: an adapter's document is that
8
+ * adapter's private business (`authoring-inversion-guard.test.ts` is the tripwire).
9
+ */
10
+
11
+ import type { ViewportTab } from '@volter/editor-sdk';
12
+ import { viewportCaptureOutputPass } from '@volter/editor-threejs/capture/output-pass';
13
+ import { isEditorOwnedObject } from '@volter/editor-threejs/viewport/editor-layers';
14
+ import type { WorldRendererConfig } from '@volter/editor-threejs/adapter/renderer-config';
15
+ import { getUserData, setUserData } from '@volter/editor-threejs/ecs/user-data';
16
+ import type { ViewportShadingMode } from '@volter/editor-threejs/render/viewport-shading';
17
+ import * as THREE from 'three';
18
+ import type { BatchedRenderer } from 'three.quarks';
19
+ import { findEntityLod } from './entity-lod';
20
+ import { entityIdOf } from './entity-object';
21
+ import { ShellStore } from '@volter/editor-sdk/kit/shell-store';
22
+ export type { NotifyScope, PlayEditRegime } from '@volter/editor-sdk/kit/shell-store';
23
+ import { withSceneFogNeutralized } from './scene-view-fog';
24
+
25
+ /** The store's persistence collaborator — installed by the shell, never
26
+ * imported by the store (see `attachStatePersistence`). */
27
+ export interface EditorStatePersistence {
28
+ save(state: Record<string, unknown>): unknown;
29
+ saveThumbnail?(dataUrl: string): unknown;
30
+ }
31
+
32
+ /**
33
+ * WHICH TOOL THE SHELF HAS ARMED — four that draw a transform gizmo, and one
34
+ * that draws none.
35
+ *
36
+ * `'select'` is Blender's Select Box, and it is a TOOL rather than an absence:
37
+ * its shelf opens on it (`space_toolsystem_toolbar.py`,
38
+ * `_defs_view3d_generic.select_box` first in the Object Mode `_tools_default`;
39
+ * photographed at the engine's pin in `gizmo-select-box.png`), so a selected
40
+ * object shows its outline and nothing else until Move / Rotate / Scale /
41
+ * Transform is armed. Before it there was no "no gizmo" state at all — this
42
+ * store's default was `'combined'` and every selection drew three tools'
43
+ * handles at once — which is why it is a member here rather than a flag beside
44
+ * it. `editor-viewport.ts` detaches on it, through the same branch a
45
+ * non-writable selection already took.
46
+ *
47
+ * Which member a STAGE is born with is its presentation's (`interaction.bootTool`,
48
+ * `kit/viewport-presentation`); arming one is always a click in the shelf.
49
+ */
50
+ export type { GizmoAnchor, PivotMode, TransformMode, TransformSpace } from '@volter/editor-sdk/kit/shell-store';
51
+
52
+ export type ShadingMode = ViewportShadingMode;
53
+
54
+ /**
55
+ * What a store notification says CHANGED.
56
+ *
57
+ * `'selection'` is the narrow one and it is claimed by exactly five writers
58
+ * (`select`, `selectMultiple`, `addToSelection`, `toggleSelection`,
59
+ * `applySelectionBeforePresentation`), each of which touches
60
+ * `_viewportSelections` and nothing else. `'content'` is the default and means
61
+ * "assume anything may have changed", so a mutation that forgets to classify
62
+ * itself is safe by construction — the failure mode of a wrong claim is a
63
+ * stale panel, so the burden of proof sits on the narrow value.
64
+ * See {@link EditorShellStore.contentVersion}.
65
+ */
66
+
67
+ /** Exact structural delta supplied by a live Three projection mutation. */
68
+ export interface IngestObjectMapStructureDelta {
69
+ readonly changedParentIds: ReadonlySet<string>;
70
+ readonly rootsChanged: boolean;
71
+ /** Exact projected membership changes; omission keeps legacy callers conservative. */
72
+ readonly addedIds?: ReadonlySet<string>;
73
+ readonly removedIds?: ReadonlySet<string>;
74
+ }
75
+
76
+ /** All projected parent rows changed after a previously observed gizmo epoch. */
77
+ export interface IngestObjectMapChanges {
78
+ readonly epoch: number;
79
+ readonly changedParentIds: ReadonlySet<string>;
80
+ }
81
+
82
+ /** Exact object-map identities whose membership changed after an observed gizmo epoch. */
83
+ export interface IngestObjectMapMembershipChanges {
84
+ readonly epoch: number;
85
+ readonly changedIds: ReadonlySet<string>;
86
+ }
87
+
88
+ /** Enough exact structural epochs to span deferred React consumers; older readers rebuild. */
89
+ const OBJECT_MAP_MEMBERSHIP_HISTORY = 256;
90
+
91
+
92
+
93
+ /** The two viewport tabs, Edit · Play. Defined ONCE, in the SDK's shared
94
+ * vocabulary (`@volter/editor-sdk`), because the control API speaks it too;
95
+ * re-exported here so editor modules keep one import site. */
96
+ export type { ViewportTab };
97
+
98
+ export type { HelperVisibility } from '@volter/editor-sdk/kit/shell-store';
99
+
100
+ export type { ViewportAction } from '@volter/editor-sdk/kit/shell-store';
101
+
102
+ export class EditorShellStore {
103
+ /** Made by `threeStateOf`, once per shell store. */
104
+ constructor(readonly shell: ShellStore) {}
105
+
106
+ // --- Scene graph references (set via bindScene) ---
107
+ protected _scene: THREE.Scene | null = null;
108
+ /** Adoption stack (see enterPlayScene): each frame is the scene state that
109
+ * was active when an adoption replaced it, restored in LIFO order by
110
+ * exitPlayScene. A STACK (not a single slot) because adoptions nest for
111
+ * R3F projects: the design session adopts its fiber scene at design time,
112
+ * and play adopts the live game scene OVER it while the play-entry
113
+ * transition still needs the design scene alive — the design session then
114
+ * unwinds its own frame from the middle via releaseAdoptedScene. */
115
+ protected _adoptionStack: Array<{
116
+ /** `null` when the adoption replaced no scene: a session with no Scene stage plays too. */
117
+ scene: THREE.Scene | null;
118
+ objectMap: Map<string, THREE.Object3D>;
119
+ /** Opaque payload from {@link _captureAdoptionExtras} — a document half's
120
+ * own per-adoption snapshot. The shell never inspects it. */
121
+ extras: unknown;
122
+ selection: Set<string>;
123
+ /** The image config that was active BEFORE this adoption — same LIFO
124
+ * discipline as `scene`, so a frame spliced out of the middle simply
125
+ * stops being a restore point. See {@link _applyAdoptedImageConfig}. */
126
+ imageConfig: WorldRendererConfig | undefined;
127
+ }> = [];
128
+ protected _objectMap = new Map<string, THREE.Object3D>();
129
+ protected _renderer: THREE.WebGLRenderer | null = null;
130
+ protected _camera: THREE.Camera | null = null;
131
+ protected _batchedRenderer: BatchedRenderer | null = null;
132
+ protected _lastThumbnailTime = 0;
133
+ protected _lastEditorStateSaveTime = 0;
134
+ protected _orbitTarget: THREE.Vector3 | null = null;
135
+
136
+ // --- UI state (not stored in scene graph) ---
137
+ /**
138
+ * Bumped whenever gizmos must be re-applied without a structural signature
139
+ * change (W3a B1): a single-entity rebuild (`_rebuildEntity` →
140
+ * `updateEntityPreview`) disposes the old Object3D WITH all its helper
141
+ * children, but leaves the objectMap identity/size and every toggle
142
+ * untouched — the viewport's `gizmoSig` gate would otherwise skip both
143
+ * `applyEntityGizmos` and the per-type visibility pass until an unrelated
144
+ * event.
145
+ */
146
+ protected _gizmoEpoch = 0;
147
+ /** Last structural epoch at which each projected parent's direct children changed. */
148
+ protected _objectMapParentChangedAt = new Map<string, number>();
149
+ /** A root change (or an unclassified legacy call) requires a full hierarchy read. */
150
+ protected _objectMapFullChangeEpoch = 0;
151
+ /** Last exact add/remove epoch for each projected id, independently consumed by the viewport. */
152
+ protected _objectMapMembershipChangedAt = new Map<string, number>();
153
+ /** Missing membership detail requires the viewport to reconcile its complete helper indexes. */
154
+ protected _objectMapMembershipFullChangeEpoch = 0;
155
+ /** Epochs older than this were compacted from the bounded membership history. */
156
+ protected _objectMapMembershipHistoryFloor = 0;
157
+ protected _showStats = false;
158
+ protected _shadingMode: ShadingMode = 'solid';
159
+ protected _statePersistence: EditorStatePersistence | null = null;
160
+ protected _vertexSnapActive = false;
161
+ /**
162
+ * Bumped only when post-processing-relevant state changes (environment / scene
163
+ * structure / play-stop scene swap). The viewport rebuilds its EffectComposer
164
+ * only when this changes — NOT on every notify — so a transform drag/scrub (which
165
+ * notifies per frame but never touches these) no longer recompiles shaders 60×/s.
166
+ */
167
+ protected _composerVersion = 0;
168
+ bindScene(
169
+ scene: THREE.Scene,
170
+ renderer?: THREE.WebGLRenderer,
171
+ batchedRenderer?: BatchedRenderer | null,
172
+ camera?: THREE.Camera,
173
+ ): void {
174
+ // A stage mounting while a scene is adopted binds the edit scene UNDER the adoption, where
175
+ // Stop restores it; the adopted scene stays the active one.
176
+ const base = this._adoptionStack[0];
177
+ if (base) base.scene = scene;
178
+ else this._scene = scene;
179
+ this._renderer = renderer ?? null;
180
+ this._batchedRenderer = batchedRenderer ?? null;
181
+ if (camera) this._camera = camera;
182
+ }
183
+
184
+ /** Set the orbit controls target reference for camera state persistence. */
185
+ setOrbitTarget(target: THREE.Vector3): void {
186
+ this._orbitTarget = target;
187
+ }
188
+
189
+ /** Update only the editor presentation camera (for perspective/orthographic
190
+ * switching) without rebinding or replacing an adopted game scene. */
191
+ setViewportCamera(camera: THREE.Camera): void {
192
+ this._camera = camera;
193
+ }
194
+
195
+
196
+ /**
197
+ * Where view/tool state and the autosave thumbnail go. The shell installs
198
+ * its transport here (`EditorContext` binds the server SDK); the store
199
+ * itself names no endpoint, so a document-local store (Asset Lab) or a
200
+ * fixture store simply never persists. Same collaborator shape as
201
+ * `attachHistory`, same one-owner rule.
202
+ */
203
+ attachStatePersistence(persistence: EditorStatePersistence): void {
204
+ if (this._statePersistence === persistence) return;
205
+ if (this._statePersistence) {
206
+ throw new Error('EditorStore is already attached to a state persistence.');
207
+ }
208
+ this._statePersistence = persistence;
209
+ }
210
+
211
+ /** The game camera lookup the last `focusOnScene` carried, read by the world stage while its
212
+ * auto-frame window is open. */
213
+ get sceneCameraLookup(): (() => THREE.Camera | null) | null {
214
+ return this._sceneCameraLookup;
215
+ }
216
+ protected _sceneCameraLookup: (() => THREE.Camera | null) | null = null;
217
+
218
+ /**
219
+ * Frame the whole active scene — the first thing a reader should see when a
220
+ * world they did not author is adopted (an ingest mount). The viewport keeps
221
+ * asking until the world HAS content, because a game builds itself
222
+ * asynchronously; any camera input from the reader cancels it (see
223
+ * `the world root's stage`). A no-op for a scene the reader already has a camera on.
224
+ *
225
+ * `gameCamera` is a LIVE LOOKUP, not a camera: an adopted game's own camera
226
+ * may not exist yet on the frame it is captured on, so the viewport asks
227
+ * again while its window is open. When one turns up, its view is adopted
228
+ * instead of any measurement — see `scene-framing.ts`.
229
+ */
230
+ focusOnScene(gameCamera?: () => THREE.Camera | null): void {
231
+ this._sceneCameraLookup = gameCamera ?? null;
232
+ this.shell.focusOnScene();
233
+ }
234
+
235
+
236
+ /**
237
+ * Live viewport camera pose, read straight from the bound Three.js camera +
238
+ * orbit target (`bindScene`/`setOrbitTarget` — wired by `world-root-stage.ts`).
239
+ * Null only when nothing has bound a camera yet (e.g. a headless store in a
240
+ * unit test that never mounted `the world root's stage`).
241
+ */
242
+ get cameraPose(): { position: THREE.Vector3; target: THREE.Vector3; fov: number } | null {
243
+ if (!this._camera) return null;
244
+ const fov = (this._camera as THREE.PerspectiveCamera).fov;
245
+ return {
246
+ position: this._camera.position.clone(),
247
+ target: this._orbitTarget ? this._orbitTarget.clone() : new THREE.Vector3(),
248
+ fov: typeof fov === 'number' ? fov : 0,
249
+ };
250
+ }
251
+
252
+ // --- Scene graph accessors ---
253
+
254
+ /** The object map: entity ID → Three.js Object3D. */
255
+ get objectMap(): Map<string, THREE.Object3D> {
256
+ return this._objectMap;
257
+ }
258
+
259
+ /** The bound Three.js scene. */
260
+ get scene(): THREE.Scene | null {
261
+ return this._scene;
262
+ }
263
+
264
+ /** Monotonic counter bumped when post-processing inputs change (see field doc). */
265
+ get composerVersion(): number {
266
+ return this._composerVersion;
267
+ }
268
+
269
+ /** See {@link _gizmoEpoch} — folded into the viewport's gizmo signature. */
270
+ get gizmoEpoch(): number {
271
+ return this._gizmoEpoch;
272
+ }
273
+
274
+ /**
275
+ * Return the exact projected parents changed after `epoch`, or `null` when a
276
+ * root/unclassified change crossed that boundary and callers must rebuild.
277
+ * Parent timestamps make this safe across React batching: no consumer drains
278
+ * a shared queue, and several projectile commits collapse into one union.
279
+ */
280
+ ingestObjectMapChangesSince(epoch: number): IngestObjectMapChanges | null {
281
+ if (epoch < 0 || epoch > this._gizmoEpoch || epoch < this._objectMapFullChangeEpoch) {
282
+ return null;
283
+ }
284
+ const changedParentIds = new Set<string>();
285
+ for (const [id, changedAt] of this._objectMapParentChangedAt) {
286
+ if (changedAt > epoch) changedParentIds.add(id);
287
+ }
288
+ return { epoch: this._gizmoEpoch, changedParentIds };
289
+ }
290
+
291
+ /** Return exact added/removed identities since `epoch`, or `null` across an unclassified edit. */
292
+ ingestObjectMapMembershipChangesSince(epoch: number): IngestObjectMapMembershipChanges | null {
293
+ if (
294
+ epoch < 0 ||
295
+ epoch > this._gizmoEpoch ||
296
+ epoch < this._objectMapMembershipFullChangeEpoch ||
297
+ epoch < this._objectMapMembershipHistoryFloor
298
+ ) {
299
+ return null;
300
+ }
301
+ const changedIds = new Set<string>();
302
+ for (const [id, changedAt] of this._objectMapMembershipChangedAt) {
303
+ if (changedAt > epoch) changedIds.add(id);
304
+ }
305
+ return { epoch: this._gizmoEpoch, changedIds };
306
+ }
307
+
308
+ isSkeletonVisible(id: string): boolean {
309
+ return Boolean(getUserData(this._objectMap.get(id), 'skeletonVisible'));
310
+ }
311
+
312
+ setSkeletonVisible(id: string, visible: boolean): void {
313
+ const object = this._objectMap.get(id);
314
+ if (!object) return;
315
+ setUserData(object, 'skeletonVisible', visible);
316
+ object.traverse((child) => {
317
+ if (getUserData(child, 'editorHelperType') !== 'skeletons') return;
318
+ setUserData(child, 'skeletonEnabled', visible);
319
+ child.visible = visible || this.shell.helperVisibility.skeletons;
320
+ });
321
+ this.shell.notifyChange();
322
+ }
323
+ get showStats(): boolean {
324
+ return this._showStats;
325
+ }
326
+ get shadingMode(): ShadingMode {
327
+ return this._shadingMode;
328
+ }
329
+ /** Notify subscribers after an ingest adapter mutated a live foreign object
330
+ * (material/visibility). Runtime-only — no dirty/autosave, because the edit
331
+ * lives on the live `Object3D` and nothing here owns a document. */
332
+
333
+ /** Notify after a live adapter changed `objectMap` membership.
334
+ *
335
+ * Unlike an ordinary reflected-property edit, a membership change requires
336
+ * the viewport to re-apply native helper visibility. The map is mutated in
337
+ * place, and a remove+add burst can keep its size unchanged, so neither map
338
+ * identity nor size is a complete signal. `_gizmoEpoch` is the viewport's
339
+ * existing explicit rebuild input; bump it only at this structural seam. */
340
+ notifyIngestObjectMapEdit(delta?: IngestObjectMapStructureDelta): void {
341
+ this._gizmoEpoch++;
342
+ if (delta === undefined || delta.rootsChanged) {
343
+ this._objectMapFullChangeEpoch = this._gizmoEpoch;
344
+ this._objectMapParentChangedAt.clear();
345
+ } else {
346
+ for (const id of delta.changedParentIds) {
347
+ this._objectMapParentChangedAt.set(id, this._gizmoEpoch);
348
+ }
349
+ }
350
+ if (delta?.addedIds === undefined || delta.removedIds === undefined) {
351
+ this._objectMapMembershipFullChangeEpoch = this._gizmoEpoch;
352
+ this._objectMapMembershipChangedAt.clear();
353
+ } else {
354
+ for (const id of delta.addedIds) this._objectMapMembershipChangedAt.set(id, this._gizmoEpoch);
355
+ for (const id of delta.removedIds) {
356
+ this._objectMapMembershipChangedAt.set(id, this._gizmoEpoch);
357
+ }
358
+ const historyFloor = Math.max(0, this._gizmoEpoch - OBJECT_MAP_MEMBERSHIP_HISTORY);
359
+ if (historyFloor > this._objectMapMembershipHistoryFloor) {
360
+ this._objectMapMembershipHistoryFloor = historyFloor;
361
+ for (const [id, changedAt] of this._objectMapMembershipChangedAt) {
362
+ if (changedAt <= historyFloor) this._objectMapMembershipChangedAt.delete(id);
363
+ }
364
+ }
365
+ }
366
+ const affectsShell =
367
+ delta === undefined ||
368
+ delta.rootsChanged ||
369
+ [...(delta.addedIds ?? []), ...(delta.removedIds ?? [])].some((id) =>
370
+ this.shell.selectedEntityIds.has(id),
371
+ );
372
+ this.shell.notifyChange('content', affectsShell ? 'shell' : 'object-map', false);
373
+ }
374
+
375
+ // --- Play mode scene swap ---
376
+
377
+ /** Callback to sync transform edits to game ECS. Set by play-mode, cleared on exit.
378
+ * Receives the NODE ID as well as the live object: the `PhysicsAdapter` seam
379
+ * it ultimately feeds is id-keyed since P-4, and the id is already in hand
380
+ * at the one call site — so nothing downstream has to map an object back. */
381
+ protected _ecsSyncTransform: ((id: string, obj: THREE.Object3D) => void) | null = null;
382
+
383
+ /** Set a callback that syncs editor transform changes to the game's ECS state. */
384
+ setEcsSyncTransform(fn: ((id: string, obj: THREE.Object3D) => void) | null): void {
385
+ this._ecsSyncTransform = fn;
386
+ }
387
+
388
+ /**
389
+ * Swap the store's active scene to the game scene. Editor scene is preserved.
390
+ *
391
+ * Also snapshots whatever the active document half hands back from
392
+ * {@link _captureAdoptionExtras}. A half that keeps mutating its LIVE
393
+ * metadata during play (so its inspector fields keep working) must have
394
+ * those edits discarded when play stops — otherwise a play-time edit
395
+ * silently persists with `isDirty` false. `exitPlayScene` restores the
396
+ * snapshot.
397
+ */
398
+ enterPlayScene(
399
+ gameScene: THREE.Scene,
400
+ imageConfig?: WorldRendererConfig | undefined,
401
+ projectedObjects?: ReadonlyMap<string, THREE.Object3D>,
402
+ ): void {
403
+ this._adoptionStack.push({
404
+ scene: this._scene,
405
+ objectMap: this._objectMap,
406
+ extras: this._captureAdoptionExtras(),
407
+ selection: new Set(this.shell.selectedEntityIds),
408
+ imageConfig: this._adoptedImageConfig,
409
+ });
410
+ this._scene = gameScene;
411
+ this._applyAdoptedImageConfig(imageConfig);
412
+
413
+ // The store INDEXES an adopted scene; it no longer models it. A foreign
414
+ // adapter supplies its pure projection explicitly, so editor identity is
415
+ // never written into the game's Object3Ds. Source-backed/adopted worlds
416
+ // already carry native authoring stamps and retain the fallback walk.
417
+ const gameMap = projectedObjects
418
+ ? new Map(projectedObjects)
419
+ : (() => {
420
+ const stamped = new Map<string, THREE.Object3D>();
421
+ const visit = (obj: THREE.Object3D): void => {
422
+ if (isEditorOwnedObject(obj)) return;
423
+ const eid = entityIdOf(obj);
424
+ if (eid !== undefined) stamped.set(eid, obj);
425
+ for (const child of obj.children) visit(child);
426
+ };
427
+ for (const child of gameScene.children) visit(child);
428
+ return stamped;
429
+ })();
430
+ this._objectMap = gameMap;
431
+
432
+ this.shell.applySelectionBeforePresentation(
433
+ [...this.shell.selectedEntityIds].filter((id) => gameMap.has(id)),
434
+ );
435
+ // A viewport LOD pin must not follow the entity id into the adopted game
436
+ // scene — there `WebGLRenderer.projectObject` owns level visibility (it
437
+ // calls `lod.update(camera)` for every visible LOD whose `autoUpdate` is
438
+ // still true, which is what a pin turns OFF), and per-frame enforcement
439
+ // would fight it.
440
+ this._lodForcedLevels.clear();
441
+ this._composerVersion++; // scene swapped → composer must rebuild against the game scene
442
+ this.shell.notifyChange();
443
+ }
444
+
445
+ /** Whether the active scene is one another document adopted (`enterPlayScene`). */
446
+ get hasAdoptedScene(): boolean {
447
+ return this._adoptionStack.length > 0;
448
+ }
449
+
450
+ /** Restore the previously-adopted scene (LIFO — see enterPlayScene). */
451
+ exitPlayScene(): void {
452
+ const frame = this._adoptionStack.pop();
453
+ if (!frame) return;
454
+ this._scene = frame.scene;
455
+ this._objectMap = frame.objectMap;
456
+ this._applyAdoptedImageConfig(frame.imageConfig);
457
+ // Discard any play-time env/name/ui edits — restore the pre-play snapshot.
458
+ this._restoreAdoptionExtras(frame.extras);
459
+ this.shell.applySelectionBeforePresentation([...frame.selection]);
460
+ this._composerVersion++; // scene swapped back → composer must rebuild against the editor scene
461
+ this.shell.notifyChange();
462
+ }
463
+
464
+ /**
465
+ * Release ONE adopter's adoption, wherever it sits in the stack. When
466
+ * `scene` is the ACTIVE scene this is exactly `exitPlayScene`; when a later
467
+ * adoption (play over the R3F design session) has already replaced it, the
468
+ * frame that would restore `scene` is unlinked from the middle of the stack
469
+ * instead — nothing visible changes now, and the LATER adopter's exit
470
+ * restores the state underneath as if this adoption never happened. No-op
471
+ * when `scene` was never adopted (or was already released).
472
+ */
473
+ releaseAdoptedScene(scene: THREE.Scene): void {
474
+ if (this._scene === scene) {
475
+ this.exitPlayScene();
476
+ return;
477
+ }
478
+ const index = this._adoptionStack.findIndex((frame) => frame.scene === scene);
479
+ if (index === -1) return;
480
+ this._adoptionStack.splice(index, 1);
481
+ }
482
+
483
+ /**
484
+ * THE ADOPTED CONTENT'S OWN COLOUR PIPELINE — what it is, not where it is
485
+ * applied.
486
+ *
487
+ * A mounted world may report the pipeline it was authored for
488
+ * (`MountedThreeRoot.rendererConfig`), and `applyWorldRendererConfig` puts it
489
+ * on the renderer its own host handed it. In the editor those are two
490
+ * DIFFERENT renderers: the design session mounts against a non-rasterizing
491
+ * one (`authoring/design-time-renderer.ts`) and the viewport draws the
492
+ * mounted scene with its own. So the declaration landed on a surface with no
493
+ * pixels while the surface with pixels kept the engine's defaults — measured
494
+ * on the translated Godot platformer, whose roughness-0 metal reads as chrome
495
+ * under the editor's ACES while the game renders it under its own curve.
496
+ *
497
+ * The store only REMEMBERS the declaration and bumps `_composerVersion` when
498
+ * it changes; `the world root's stage.rebuildComposer` is the one place that derives
499
+ * the viewport renderer's whole configuration, and it applies this last. That
500
+ * split is why there is no restore bookkeeping here: a rebuild always resets
501
+ * the renderer to the engine defaults first, so "unwind" is just the next
502
+ * rebuild seeing a different (or absent) config.
503
+ */
504
+ protected _adoptedImageConfig: WorldRendererConfig | undefined;
505
+
506
+ /** What the active adopted content declared about the renderer that draws it,
507
+ * or `undefined` when it declared nothing (leave the editor's own alone). */
508
+ get adoptedImageConfig(): WorldRendererConfig | undefined {
509
+ return this._adoptedImageConfig;
510
+ }
511
+
512
+ protected _applyAdoptedImageConfig(next: WorldRendererConfig | undefined): void {
513
+ if (next === this._adoptedImageConfig) return;
514
+ this._adoptedImageConfig = next;
515
+ this._composerVersion++;
516
+ }
517
+
518
+ /** True while an ADOPTED live game scene is the active scene — every
519
+ * `enterPlayScene` caller: play adoption, ingest mounts, and the R3F
520
+ * design session. Adopted scenes own their lighting/rendering completely;
521
+ * the viewport uses this to keep editor-fabricated presentation (the
522
+ * helper light rig) out of them. */
523
+ get isAdoptedSceneActive(): boolean {
524
+ return this._adoptionStack.length > 0;
525
+ }
526
+
527
+ // --- Vertex snap ---
528
+ get vertexSnapActive(): boolean {
529
+ return this._vertexSnapActive;
530
+ }
531
+ setVertexSnapActive(active: boolean): void {
532
+ this._vertexSnapActive = active;
533
+ this.shell.notifyChange();
534
+ }
535
+
536
+ protected static readonly THUMBNAIL_INTERVAL_MS = 60_000;
537
+ protected static readonly THUMBNAIL_SIZE = 256;
538
+
539
+ /**
540
+ * Render the bound camera's current view to a PNG data URL at `size`x`size`.
541
+ * Autosave thumbnails request game content only; an explicit SDK/editor
542
+ * viewport capture requests every camera layer so grid/helpers/gizmos are
543
+ * represented honestly. Both use the same offscreen render-target technique,
544
+ * so neither needs `preserveDrawingBuffer` on the live WebGL canvas.
545
+ *
546
+ * THE COLOUR PIPELINE IS THE WHOLE REASON THERE ARE TWO TARGETS. Three
547
+ * applies `renderer.toneMapping` and `renderer.outputColorSpace` ONLY when
548
+ * the destination is the canvas: rendering into an ordinary
549
+ * `WebGLRenderTarget` compiles every material with `NoToneMapping` and a
550
+ * LINEAR output transfer (`WebGLPrograms.getParameters` and
551
+ * `WebGLRenderer.setProgram` both gate on `currentRenderTarget === null`).
552
+ * So the single-target version of this function answered "what does the
553
+ * viewport look like" with a raw linear frame — measurably darker and
554
+ * flatter than the pixels on screen, and immune to every tone-mapping
555
+ * change (verified live: flipping the viewport to `NoToneMapping` moved the
556
+ * captured PNG by 0 bytes). That made this door — the only look at the EDIT
557
+ * viewport, and the source of every project thumbnail — unusable as
558
+ * evidence about the image.
559
+ *
560
+ * The fix is three's own: render the scene into a HALF-FLOAT linear buffer
561
+ * (so highlights survive to be tone-mapped) and resolve it through
562
+ * `OutputPass`, which reads `toneMapping`/`toneMappingExposure`/
563
+ * `outputColorSpace` off the renderer and applies exactly what the on-screen
564
+ * frame gets.
565
+ */
566
+ protected _renderViewportImage(
567
+ size: number | { width: number; height: number },
568
+ includeEditorLayers: boolean,
569
+ ): string | null {
570
+ if (!this._renderer || !this._scene || !this._camera) return null;
571
+ // A number is a SQUARE of that size — the default shape; `{width, height}`
572
+ // renders the buffer and the camera at that aspect so a shaped look needs
573
+ // no crop (`@volter/editor-sdk`'s `CaptureDimensions`).
574
+ const width = typeof size === 'number' ? size : size.width;
575
+ const height = typeof size === 'number' ? size : size.height;
576
+ // The same floor `authoring/object3d-document-session.ts`'s `captureImage`
577
+ // already sets, for the same reason: the doubled size is handed straight to
578
+ // `createImageData`, so a non-finite size throws
579
+ // `TypeError: Value is not of type 'long'` from the middle of the render
580
+ // path instead of failing where the value entered.
581
+ if (!Number.isFinite(width) || width < 1) return null;
582
+ if (!Number.isFinite(height) || height < 1) return null;
583
+
584
+ // render at 2x then downscale for crisp result
585
+ const renderWidth = width * 2;
586
+ const renderHeight = height * 2;
587
+ const renderer = this._renderer;
588
+ const sceneTarget = new THREE.WebGLRenderTarget(renderWidth, renderHeight, {
589
+ type: THREE.HalfFloatType,
590
+ });
591
+ const renderTarget = new THREE.WebGLRenderTarget(renderWidth, renderHeight);
592
+ const prevTarget = renderer.getRenderTarget();
593
+ const prevPixelRatio = renderer.getPixelRatio();
594
+
595
+ const camera = this._camera;
596
+ const prevLayers = camera.layers.mask;
597
+ const perspectiveCamera = camera instanceof THREE.PerspectiveCamera ? camera : null;
598
+ const previousAspect = perspectiveCamera?.aspect;
599
+ if (includeEditorLayers) {
600
+ camera.layers.enableAll();
601
+ } else {
602
+ camera.layers.disableAll();
603
+ camera.layers.enable(0); // clean project thumbnail: game content only
604
+ }
605
+
606
+ try {
607
+ if (perspectiveCamera) {
608
+ perspectiveCamera.aspect = width / height;
609
+ perspectiveCamera.updateProjectionMatrix();
610
+ }
611
+ renderer.setPixelRatio(1);
612
+ renderer.setRenderTarget(sceneTarget);
613
+ // The same view policy the on-screen draw applies (`scene-view-fog.ts`).
614
+ // This capture is the ONLY look anyone gets at the Edit viewport — the
615
+ // `vgai screenshot` evidence and every project thumbnail — so a capture
616
+ // that renders the game's fog while the viewport does not would make the
617
+ // one door onto the image disagree with the image.
618
+ withSceneFogNeutralized(this._scene, () =>
619
+ renderer.render(this._scene as THREE.Scene, camera),
620
+ );
621
+ viewportCaptureOutputPass().render(renderer, renderTarget, sceneTarget, 0, false);
622
+ } finally {
623
+ renderer.setRenderTarget(prevTarget);
624
+ renderer.setPixelRatio(prevPixelRatio);
625
+ camera.layers.mask = prevLayers;
626
+ if (perspectiveCamera && previousAspect !== undefined) {
627
+ perspectiveCamera.aspect = previousAspect;
628
+ perspectiveCamera.updateProjectionMatrix();
629
+ }
630
+ }
631
+
632
+ // Read pixels from the render target
633
+ const rowBytes = renderWidth * 4;
634
+ const pixels = new Uint8Array(rowBytes * renderHeight);
635
+ renderer.readRenderTargetPixels(renderTarget, 0, 0, renderWidth, renderHeight, pixels);
636
+ renderTarget.dispose();
637
+ sceneTarget.dispose();
638
+
639
+ // Flip vertically (WebGL reads bottom-up) and write to a full-size canvas
640
+ const fullCanvas = document.createElement('canvas');
641
+ fullCanvas.width = renderWidth;
642
+ fullCanvas.height = renderHeight;
643
+ const fullCtx = fullCanvas.getContext('2d')!;
644
+ const imageData = fullCtx.createImageData(renderWidth, renderHeight);
645
+ for (let y = 0; y < renderHeight; y++) {
646
+ const srcRow = (renderHeight - 1 - y) * rowBytes;
647
+ const dstRow = y * rowBytes;
648
+ imageData.data.set(pixels.subarray(srcRow, srcRow + rowBytes), dstRow);
649
+ }
650
+ fullCtx.putImageData(imageData, 0, 0);
651
+
652
+ // Downscale to the requested size
653
+ const canvas = document.createElement('canvas');
654
+ canvas.width = width;
655
+ canvas.height = height;
656
+ const ctx2d = canvas.getContext('2d')!;
657
+ ctx2d.drawImage(fullCanvas, 0, 0, width, height);
658
+
659
+ return canvas.toDataURL('image/png');
660
+ }
661
+
662
+ protected _captureThumbnail(): void {
663
+ const now = Date.now();
664
+ if (now - this._lastThumbnailTime < EditorShellStore.THUMBNAIL_INTERVAL_MS) return;
665
+ this._lastThumbnailTime = now;
666
+ const dataUrl = this._renderViewportImage(EditorShellStore.THUMBNAIL_SIZE, false);
667
+ if (dataUrl) void this._statePersistence?.saveThumbnail?.(dataUrl);
668
+ }
669
+
670
+ /**
671
+ * Fresh, on-demand viewport capture (unthrottled, unlike the periodic
672
+ * autosave thumbnail above) — backs the `capture-viewport` relay command.
673
+ * Returns null when no renderer/scene/camera is bound yet (e.g. before
674
+ * `the world root's stage` has mounted).
675
+ */
676
+ captureViewportImage(
677
+ size: number | { width: number; height: number } = EditorShellStore.THUMBNAIL_SIZE,
678
+ ): string | null {
679
+ return this._renderViewportImage(size, true);
680
+ }
681
+
682
+ /** The live Scene viewport's own `<canvas>`, or null before a renderer is
683
+ * bound. A photograph of the whole editor page (`editor-chrome-capture.ts`)
684
+ * needs to recognise it: its WebGL context has no `preserveDrawingBuffer`,
685
+ * so its pixels come from {@link captureViewportImage}, not a late read. */
686
+ viewportCanvas(): HTMLCanvasElement | null {
687
+ return this._renderer?.domElement ?? null;
688
+ }
689
+
690
+ // --- Editor state persistence ---
691
+
692
+ protected static readonly EDITOR_STATE_INTERVAL_MS = 60_000;
693
+
694
+ protected _saveEditorStateIfDue(): void {
695
+ if (!this._statePersistence) return;
696
+ const now = Date.now();
697
+ if (now - this._lastEditorStateSaveTime < EditorShellStore.EDITOR_STATE_INTERVAL_MS) return;
698
+ this._lastEditorStateSaveTime = now;
699
+
700
+ const state: Record<string, unknown> = {
701
+ transformMode: this.shell.transformMode,
702
+ transformSpace: this.shell.transformSpace,
703
+ snapEnabled: this.shell.snapEnabled,
704
+ snapValues: this.shell.snapValues,
705
+ preserveChildrenTransform: this.shell.preserveChildrenTransform,
706
+ pivotMode: this.shell.pivotMode,
707
+ gizmoAnchor: this.shell.gizmoAnchor,
708
+ showHelpers: this.shell.showHelpers,
709
+ helperVisibility: { ...this.shell.helperVisibility },
710
+ showStats: this._showStats,
711
+ shadingMode: this._shadingMode,
712
+ };
713
+
714
+ Object.assign(state, this._editorStateExtras());
715
+
716
+ if (this._camera) {
717
+ const cam = this._camera as THREE.PerspectiveCamera;
718
+ state['camera'] = {
719
+ position: cam.position.toArray(),
720
+ target: this._orbitTarget ? this._orbitTarget.toArray() : [0, 0, 0],
721
+ };
722
+ }
723
+
724
+ void this._statePersistence.save(state);
725
+ }
726
+
727
+ // --- Mutations ---
728
+
729
+ /**
730
+ * EDITOR-ONLY level-preview override (W2b): entity id → index into the
731
+ * live THREE.LOD's (ascending-by-distance) levels to pin in the viewport.
732
+ * Pure UI state — never written to the descriptor, so it can't leak into
733
+ * the saved scene or the undo history. Enforced per frame by
734
+ * `EditorViewport.update` (the glTF loads async and entity rebuilds replace
735
+ * the THREE.LOD instance, so a one-shot apply would silently un-pin).
736
+ */
737
+ protected _lodForcedLevels = new Map<string, number>();
738
+
739
+ get lodForcedLevels(): ReadonlyMap<string, number> {
740
+ return this._lodForcedLevels;
741
+ }
742
+
743
+ getLodForcedLevel(id: string): number | null {
744
+ return this._lodForcedLevels.get(id) ?? null;
745
+ }
746
+
747
+ /** Pin an entity's viewport LOD display to one level, or `null` for Auto
748
+ * (distance-based selection — the renderer's own `LOD.update(camera)`). */
749
+ setLodForcedLevel(id: string, level: number | null): void {
750
+ if (level === null) {
751
+ this._lodForcedLevels.delete(id);
752
+ // Hand control back to the renderer's distance-based selection.
753
+ const obj = this._objectMap.get(id);
754
+ const projectedObjects = new Set(this._objectMap.values());
755
+ const lodObj = obj ? findEntityLod(obj, (node) => projectedObjects.has(node)) : null;
756
+ if (lodObj) lodObj.autoUpdate = true;
757
+ } else {
758
+ this._lodForcedLevels.set(id, level);
759
+ }
760
+ this.shell.notifyChange();
761
+ }
762
+
763
+ toggleStats(): void {
764
+ this._showStats = !this._showStats;
765
+ this.shell.notifyChange();
766
+ }
767
+
768
+ setShadingMode(mode: ShadingMode): void {
769
+ this._shadingMode = mode;
770
+ // View-only state. The viewport applies it during its own render and
771
+ // restores native materials before returning to editor/game code.
772
+ this.shell.notifyChange();
773
+ }
774
+
775
+ // --- Subclass hooks -------------------------------------------------------
776
+ // The ONLY way the shell reaches into a document half. Each is a no-op here,
777
+ // so the shell runs standalone; a subclass that owns a document overrides the
778
+ // ones it needs (`store-notify-scope.test.ts` exercises the deferral hooks).
779
+
780
+
781
+
782
+ /** Captured into the adoption frame by `enterPlayScene`, handed back by
783
+ * `exitPlayScene`. A document half snapshots its metadata here so
784
+ * play-time edits to it are discarded on exit. */
785
+ protected _captureAdoptionExtras(): unknown {
786
+ return undefined;
787
+ }
788
+
789
+ /** Restore counterpart of {@link _captureAdoptionExtras}. */
790
+ protected _restoreAdoptionExtras(_extras: unknown): void {}
791
+
792
+
793
+ /** Extra keys folded into the periodic editor-state save. A document half
794
+ * contributes whatever it needs restored on the next boot. */
795
+ protected _editorStateExtras(): Record<string, unknown> {
796
+ return {};
797
+ }
798
+ }
799
+
800
+ const threeStates = new WeakMap<ShellStore, EditorShellStore>();
801
+
802
+ /**
803
+ * The Three half of a shell store, made the first time it is asked for. Kit modules hand out
804
+ * the media-neutral `ShellStore`; Three code asks here for its own half, so the kit constructs
805
+ * only the neutral store and a composition without Three never makes one.
806
+ */
807
+ export function threeStateOf(store: ShellStore): EditorShellStore {
808
+ let three = threeStates.get(store);
809
+ if (!three) {
810
+ three = new EditorShellStore(store);
811
+ threeStates.set(store, three);
812
+ }
813
+ return three;
814
+ }