@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.
- package/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/contributions/animation-timeline.utility.tsx +44 -0
- package/contributions/three-integration.service.ts +13 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +113 -5
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/adapter/renderer-config.ts +3 -4
- package/src/adapter/three-contract.ts +72 -0
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/object-marks.ts +1 -1
- package/src/ecs/user-data.ts +0 -16
- package/src/host-hierarchy-objects.ts +31 -0
- package/src/kit/animation/three-clips-subject.ts +190 -0
- package/src/kit/asset-compare.ts +294 -0
- package/src/kit/asset-preview-command.ts +265 -0
- package/src/kit/asset-preview-framing.ts +357 -0
- package/src/kit/asset-preview.ts +2802 -0
- package/src/kit/asset-workflow/model-inspection.ts +830 -0
- package/src/kit/authoring/component-instance-root.ts +171 -0
- package/src/kit/authoring/design-time-settle.ts +343 -0
- package/src/kit/authoring/live-object-transform.ts +62 -0
- package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
- package/src/kit/authoring/object3d-document-session.ts +1965 -0
- package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
- package/src/kit/authoring/quarks-particle-systems.ts +19 -0
- package/src/kit/authoring/shell-viewport-policy.ts +48 -0
- package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/kit/authoring/three-projection-core.ts +226 -0
- package/src/kit/authoring/viewport-pick-context.ts +39 -0
- package/src/kit/authoring/viewport-raycast.ts +240 -0
- package/src/kit/authoring/world-hidden-viewport.ts +95 -0
- package/src/kit/camera-authoring.ts +175 -0
- package/src/kit/components/CameraInfo.tsx +56 -0
- package/src/kit/components/InspectorObjectPreview.tsx +57 -0
- package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
- package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
- package/src/kit/components/StageHost.tsx +2547 -0
- package/src/kit/components/StageOverlays.tsx +21 -0
- package/src/kit/components/StatsOverlay.tsx +78 -0
- package/src/kit/components/ToolObject3DPreview.tsx +39 -0
- package/src/kit/components/ViewportFurniture.tsx +655 -0
- package/src/kit/components/ViewportOverlay.tsx +215 -0
- package/src/kit/components/ViewportShadingMenu.tsx +340 -0
- package/src/kit/components/ViewportViewMenu.tsx +155 -0
- package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
- package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
- package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
- package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
- package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
- package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
- package/src/kit/components/stage-keyboard.tsx +40 -0
- package/src/kit/components/stage-overlay-set.tsx +105 -0
- package/src/kit/components/stage-presence-markers.ts +482 -0
- package/src/kit/components/stage-transform-chrome.ts +30 -0
- package/src/kit/components/stage-transform-tools.tsx +73 -0
- package/src/kit/components/stage-view-name.ts +30 -0
- package/src/kit/components/standard-viewport-dressing.ts +1042 -0
- package/src/kit/components/world-root-binding.ts +64 -0
- package/src/kit/constraint-helper.ts +338 -0
- package/src/kit/editor-shell-store.ts +814 -0
- package/src/kit/editor-viewport.ts +6621 -0
- package/src/kit/entity-lod.ts +31 -0
- package/src/kit/entity-object.ts +92 -0
- package/src/kit/hierarchy-mark-reader.ts +74 -0
- package/src/kit/instanced-presentation.ts +164 -0
- package/src/kit/live-module-source.ts +230 -0
- package/src/kit/model-thumbnail.ts +539 -0
- package/src/kit/play-camera-flight.ts +300 -0
- package/src/kit/projection/three.ts +898 -0
- package/src/kit/reflection-probe-helper.ts +142 -0
- package/src/kit/scene-document-viewport.ts +51 -0
- package/src/kit/scene-framing.ts +315 -0
- package/src/kit/scene-view-fog.ts +89 -0
- package/src/kit/spatial-handle-visuals.ts +332 -0
- package/src/kit/stories/three-story-model.ts +66 -0
- package/src/kit/three-canvas-render.ts +44 -0
- package/src/kit/three-hierarchy-row-media.ts +26 -0
- package/src/kit/three-inspection-media.ts +73 -0
- package/src/kit/three-integration.ts +86 -0
- package/src/kit/three-state.ts +33 -0
- package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/kit/three-viewport/camera-fit.ts +41 -0
- package/src/kit/three-viewport/interactive-renderer.ts +132 -0
- package/src/kit/three-viewport/selection-brackets.ts +355 -0
- package/src/kit/three-viewport/selection-outline.ts +333 -0
- package/src/kit/three-viewport/skeleton-helper.ts +61 -0
- package/src/kit/three-viewport/source-color.ts +197 -0
- package/src/kit/three-viewport/studio-environment.ts +96 -0
- package/src/kit/trigger-volume-helper.ts +116 -0
- package/src/kit/viewport-actions.ts +128 -0
- package/src/kit/viewport-authoring-policy.ts +154 -0
- package/src/kit/viewport-commands.ts +318 -0
- package/src/kit/viewport-hotkeys.ts +119 -0
- package/src/kit/viewport-shading-boundary.ts +12 -0
- package/src/kit/viewport-status-facet.ts +53 -0
- package/src/object3d-contributions.ts +494 -0
- package/src/render/viewport-shading.ts +6 -2
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/viewport-api.ts +92 -0
- package/src/viewport-door.ts +237 -0
- package/src/animation/animation-clock.ts +0 -479
- 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
|
+
}
|