@volter/editor-threejs 0.5.213 → 0.5.214
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/dist-node/serving.mjs +4 -20
- package/package.json +3 -5
- package/serving/animation-live-module.ts +4 -20
- package/src/animation/live-mixers.ts +1 -9
- package/src/kit/animation/three-clips-subject.ts +10 -50
- package/src/kit/authoring/component-instance-root.ts +2 -3
- package/src/kit/components/Object3DDocumentToolbar.tsx +1 -2
- package/src/kit/components/StageHost.tsx +66 -172
- package/src/kit/components/ViewportViewMenu.tsx +1 -1
- package/src/kit/components/stage-transform-tools.tsx +1 -2
- package/src/kit/editor-shell-store.ts +2 -26
- package/src/kit/editor-viewport.ts +1 -1
- package/src/kit/three-integration.ts +0 -2
- package/src/viewport-api.ts +4 -16
- package/src/viewport-door.ts +2 -38
- package/src/kit/components/world-root-binding.ts +0 -59
- package/src/kit/play-camera-flight.ts +0 -291
- package/src/kit/scene-document-viewport.ts +0 -51
package/dist-node/serving.mjs
CHANGED
|
@@ -5,17 +5,7 @@ import ts from "typescript";
|
|
|
5
5
|
// serving/animation-live-module.ts
|
|
6
6
|
var ANIMATION_LIVE_MODULE_ID = "virtual:volter-three-animation-live";
|
|
7
7
|
var animationLiveModuleSource = `
|
|
8
|
-
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= {
|
|
9
|
-
mixers: new Map(),
|
|
10
|
-
listeners: new Set(),
|
|
11
|
-
version: 0,
|
|
12
|
-
});
|
|
13
|
-
function notify() {
|
|
14
|
-
live.version++;
|
|
15
|
-
for (const listener of live.listeners) {
|
|
16
|
-
try { listener(); } catch (error) { console.error(error); }
|
|
17
|
-
}
|
|
18
|
-
}
|
|
8
|
+
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= { mixers: new Map() });
|
|
19
9
|
export function __volterMixer(mixer, key) {
|
|
20
10
|
if (!mixer || typeof mixer.clipAction !== 'function') return mixer;
|
|
21
11
|
if (mixer.__volterMixerKey) return mixer;
|
|
@@ -28,10 +18,8 @@ export function __volterMixer(mixer, key) {
|
|
|
28
18
|
if (action) {
|
|
29
19
|
const played = action.getClip();
|
|
30
20
|
const target = action.getRoot();
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
if (target && !entry.roots.has(target)) { entry.roots.add(target); changed = true; }
|
|
34
|
-
if (changed) notify();
|
|
21
|
+
if (played && !entry.clips.has(played.name)) entry.clips.set(played.name, played);
|
|
22
|
+
if (target) entry.roots.add(target);
|
|
35
23
|
}
|
|
36
24
|
return action;
|
|
37
25
|
};
|
|
@@ -40,10 +28,8 @@ export function __volterMixer(mixer, key) {
|
|
|
40
28
|
const result = uncacheRoot.call(this, root);
|
|
41
29
|
entry.roots.delete(root);
|
|
42
30
|
if (root === this.getRoot()) live.mixers.delete(this);
|
|
43
|
-
notify();
|
|
44
31
|
return result;
|
|
45
32
|
};
|
|
46
|
-
notify();
|
|
47
33
|
return mixer;
|
|
48
34
|
}
|
|
49
35
|
/** drei's \`useAnimations\` result: its mixer, and the clips it was handed. */
|
|
@@ -52,11 +38,9 @@ export function __volterAnimations(result, key) {
|
|
|
52
38
|
__volterMixer(result.mixer, key);
|
|
53
39
|
const entry = live.mixers.get(result.mixer);
|
|
54
40
|
if (entry && Array.isArray(result.clips)) {
|
|
55
|
-
let changed = false;
|
|
56
41
|
for (const clip of result.clips) {
|
|
57
|
-
if (clip && clip.name && !entry.clips.has(clip.name))
|
|
42
|
+
if (clip && clip.name && !entry.clips.has(clip.name)) entry.clips.set(clip.name, clip);
|
|
58
43
|
}
|
|
59
|
-
if (changed) notify();
|
|
60
44
|
}
|
|
61
45
|
return result;
|
|
62
46
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@volter/editor-threejs",
|
|
3
3
|
"publisher": "volter",
|
|
4
4
|
"author": "Volter AI, Inc.",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.214",
|
|
6
6
|
"engines": {
|
|
7
7
|
"vscode": "*"
|
|
8
8
|
},
|
|
@@ -83,7 +83,6 @@
|
|
|
83
83
|
"./kit/components/stage-transform-chrome": "./src/kit/components/stage-transform-chrome.ts",
|
|
84
84
|
"./kit/components/stage-transform-tools": "./src/kit/components/stage-transform-tools.tsx",
|
|
85
85
|
"./kit/components/standard-viewport-dressing": "./src/kit/components/standard-viewport-dressing.ts",
|
|
86
|
-
"./kit/components/world-root-binding": "./src/kit/components/world-root-binding.ts",
|
|
87
86
|
"./kit/constraint-helper": "./src/kit/constraint-helper.ts",
|
|
88
87
|
"./kit/editor-shell-store": "./src/kit/editor-shell-store.ts",
|
|
89
88
|
"./kit/editor-viewport": "./src/kit/editor-viewport.ts",
|
|
@@ -95,7 +94,6 @@
|
|
|
95
94
|
"./kit/model-thumbnail": "./src/kit/model-thumbnail.ts",
|
|
96
95
|
"./kit/projection/three": "./src/kit/projection/three.ts",
|
|
97
96
|
"./kit/reflection-probe-helper": "./src/kit/reflection-probe-helper.ts",
|
|
98
|
-
"./kit/scene-document-viewport": "./src/kit/scene-document-viewport.ts",
|
|
99
97
|
"./kit/scene-framing": "./src/kit/scene-framing.ts",
|
|
100
98
|
"./kit/scene-view-fog": "./src/kit/scene-view-fog.ts",
|
|
101
99
|
"./kit/spatial-handle-visuals": "./src/kit/spatial-handle-visuals.ts",
|
|
@@ -416,11 +414,11 @@
|
|
|
416
414
|
}
|
|
417
415
|
},
|
|
418
416
|
"dependencies": {
|
|
419
|
-
"@volter/project": "0.5.
|
|
417
|
+
"@volter/project": "0.5.214",
|
|
420
418
|
"@sparkjsdev/spark": "^2.1.0",
|
|
421
419
|
"three.quarks": "^0.16.0",
|
|
422
420
|
"typescript": "5.9.3",
|
|
423
|
-
"@volter/sdk": "0.5.
|
|
421
|
+
"@volter/sdk": "0.5.214",
|
|
424
422
|
"postprocessing": "6.39.5",
|
|
425
423
|
"quarks.core": "0.16.0",
|
|
426
424
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
@@ -12,17 +12,7 @@
|
|
|
12
12
|
export const ANIMATION_LIVE_MODULE_ID = 'virtual:volter-three-animation-live';
|
|
13
13
|
|
|
14
14
|
export const animationLiveModuleSource = `
|
|
15
|
-
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= {
|
|
16
|
-
mixers: new Map(),
|
|
17
|
-
listeners: new Set(),
|
|
18
|
-
version: 0,
|
|
19
|
-
});
|
|
20
|
-
function notify() {
|
|
21
|
-
live.version++;
|
|
22
|
-
for (const listener of live.listeners) {
|
|
23
|
-
try { listener(); } catch (error) { console.error(error); }
|
|
24
|
-
}
|
|
25
|
-
}
|
|
15
|
+
const live = (globalThis[Symbol.for('volter.three.animation.live')] ??= { mixers: new Map() });
|
|
26
16
|
export function __volterMixer(mixer, key) {
|
|
27
17
|
if (!mixer || typeof mixer.clipAction !== 'function') return mixer;
|
|
28
18
|
if (mixer.__volterMixerKey) return mixer;
|
|
@@ -35,10 +25,8 @@ export function __volterMixer(mixer, key) {
|
|
|
35
25
|
if (action) {
|
|
36
26
|
const played = action.getClip();
|
|
37
27
|
const target = action.getRoot();
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
if (target && !entry.roots.has(target)) { entry.roots.add(target); changed = true; }
|
|
41
|
-
if (changed) notify();
|
|
28
|
+
if (played && !entry.clips.has(played.name)) entry.clips.set(played.name, played);
|
|
29
|
+
if (target) entry.roots.add(target);
|
|
42
30
|
}
|
|
43
31
|
return action;
|
|
44
32
|
};
|
|
@@ -47,10 +35,8 @@ export function __volterMixer(mixer, key) {
|
|
|
47
35
|
const result = uncacheRoot.call(this, root);
|
|
48
36
|
entry.roots.delete(root);
|
|
49
37
|
if (root === this.getRoot()) live.mixers.delete(this);
|
|
50
|
-
notify();
|
|
51
38
|
return result;
|
|
52
39
|
};
|
|
53
|
-
notify();
|
|
54
40
|
return mixer;
|
|
55
41
|
}
|
|
56
42
|
/** drei's \`useAnimations\` result: its mixer, and the clips it was handed. */
|
|
@@ -59,11 +45,9 @@ export function __volterAnimations(result, key) {
|
|
|
59
45
|
__volterMixer(result.mixer, key);
|
|
60
46
|
const entry = live.mixers.get(result.mixer);
|
|
61
47
|
if (entry && Array.isArray(result.clips)) {
|
|
62
|
-
let changed = false;
|
|
63
48
|
for (const clip of result.clips) {
|
|
64
|
-
if (clip && clip.name && !entry.clips.has(clip.name))
|
|
49
|
+
if (clip && clip.name && !entry.clips.has(clip.name)) entry.clips.set(clip.name, clip);
|
|
65
50
|
}
|
|
66
|
-
if (changed) notify();
|
|
67
51
|
}
|
|
68
52
|
return result;
|
|
69
53
|
}
|
|
@@ -19,15 +19,13 @@ export interface LiveMixer {
|
|
|
19
19
|
|
|
20
20
|
interface Registry {
|
|
21
21
|
readonly mixers: Map<unknown, LiveMixer>;
|
|
22
|
-
readonly listeners: Set<() => void>;
|
|
23
|
-
version: number;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
const REGISTRY = Symbol.for('volter.three.animation.live');
|
|
27
25
|
|
|
28
26
|
function registry(): Registry {
|
|
29
27
|
const holder = globalThis as unknown as Record<symbol, Registry | undefined>;
|
|
30
|
-
return (holder[REGISTRY] ??= { mixers: new Map()
|
|
28
|
+
return (holder[REGISTRY] ??= { mixers: new Map() });
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
/** The live mixer that animates `object`, or null when the game animates it with none. */
|
|
@@ -43,9 +41,3 @@ export function liveMixerFor(object: THREE.Object3D): LiveMixer | null {
|
|
|
43
41
|
export function liveMixers(): LiveMixer[] {
|
|
44
42
|
return [...registry().mixers.values()];
|
|
45
43
|
}
|
|
46
|
-
|
|
47
|
-
export function subscribeLiveMixers(listener: () => void): () => void {
|
|
48
|
-
const listeners = registry().listeners;
|
|
49
|
-
listeners.add(listener);
|
|
50
|
-
return () => listeners.delete(listener);
|
|
51
|
-
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* reaches clips loaded through useGLTF and useAnimations. Subjects seek through
|
|
6
6
|
* AnimationMixer.setTime; the transport owns playback position.
|
|
7
7
|
*/
|
|
8
|
-
import { liveMixerFor
|
|
8
|
+
import { liveMixerFor } from '@volter/editor-threejs/animation/live-mixers';
|
|
9
9
|
import * as THREE from 'three';
|
|
10
10
|
import type { StageTransport } from '@volter/sdk/animation/stage-transport';
|
|
11
11
|
|
|
@@ -104,62 +104,22 @@ function attachInspected(object: THREE.Object3D, transport: StageTransport): (()
|
|
|
104
104
|
return detach;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
* One scan of a mounted root, and the handle that keeps it current.
|
|
109
|
-
*
|
|
110
|
-
* `refresh()` DIFFS by object uuid rather than re-attaching: a rescan of an
|
|
111
|
-
* unchanged tree must not detach and re-attach every subject, because that
|
|
112
|
-
* would reset which clip each one is showing under a person's hands. The
|
|
113
|
-
* caller decides when to rescan (the stage host throttles it and skips it
|
|
114
|
-
* while the world is driving time).
|
|
115
|
-
*/
|
|
107
|
+
/** One scan of a mounted root, and the handle that detaches what it attached. */
|
|
116
108
|
export interface ClipSubjectScan {
|
|
117
|
-
refresh(): void;
|
|
118
109
|
dispose(): void;
|
|
119
110
|
}
|
|
120
111
|
|
|
121
|
-
export function scanClipSubjects(
|
|
122
|
-
root: THREE.Object3D | (() => THREE.Object3D | null),
|
|
123
|
-
transport: StageTransport,
|
|
124
|
-
options: {
|
|
125
|
-
/** Rescan when the game registers or drops a mixer. */
|
|
126
|
-
readonly rescanOnLiveMixers?: boolean;
|
|
127
|
-
} = {},
|
|
128
|
-
): ClipSubjectScan {
|
|
112
|
+
export function scanClipSubjects(root: THREE.Object3D, transport: StageTransport): ClipSubjectScan {
|
|
129
113
|
const attached = new Map<string, () => void>();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (attached.has(object.uuid)) {
|
|
138
|
-
seen.add(object.uuid);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
// The inspection wins when an object has both: it means a world binding
|
|
142
|
-
// is already driving that skeleton, and a second mixer over it would
|
|
143
|
-
// give one object two writers.
|
|
144
|
-
const detach = attachInspected(object, transport) ?? attachOwnClips(object, transport);
|
|
145
|
-
if (!detach) return;
|
|
146
|
-
attached.set(object.uuid, detach);
|
|
147
|
-
seen.add(object.uuid);
|
|
148
|
-
});
|
|
149
|
-
for (const [uuid, detach] of [...attached]) {
|
|
150
|
-
if (seen.has(uuid)) continue;
|
|
151
|
-
detach();
|
|
152
|
-
attached.delete(uuid);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
refresh();
|
|
157
|
-
const stopMixers = options.rescanOnLiveMixers ? subscribeLiveMixers(refresh) : () => {};
|
|
158
|
-
|
|
114
|
+
root.traverse((object) => {
|
|
115
|
+
// The project's own mixer wins when an object has both: the project's
|
|
116
|
+
// code is already driving that skeleton, and a second mixer over it would
|
|
117
|
+
// give one object two writers.
|
|
118
|
+
const detach = attachInspected(object, transport) ?? attachOwnClips(object, transport);
|
|
119
|
+
if (detach) attached.set(object.uuid, detach);
|
|
120
|
+
});
|
|
159
121
|
return {
|
|
160
|
-
refresh,
|
|
161
122
|
dispose: () => {
|
|
162
|
-
stopMixers();
|
|
163
123
|
for (const detach of attached.values()) detach();
|
|
164
124
|
attached.clear();
|
|
165
125
|
},
|
|
@@ -105,9 +105,8 @@ function forwardedCallsiteOid(object: THREE.Object3D | null | undefined): string
|
|
|
105
105
|
* same oid twice, and both mint the same shape of id: the first one seen in
|
|
106
106
|
* depth-first order keeps the bare address, later ones get `#1`, `#2`, ….
|
|
107
107
|
*
|
|
108
|
-
* The suffix is spelled HERE and nowhere else
|
|
109
|
-
* (`projection/three.ts
|
|
110
|
-
* check and the presence report all use this one.
|
|
108
|
+
* The suffix is spelled HERE and nowhere else; the minting walk
|
|
109
|
+
* (`projection/three.ts`) uses this one.
|
|
111
110
|
*/
|
|
112
111
|
export function occurrenceId(baseId: string, occurrence: number): string {
|
|
113
112
|
return occurrence === 0 ? baseId : `${baseId}#${occurrence}`;
|
|
@@ -112,8 +112,7 @@ export function Object3DDocumentToolbar({
|
|
|
112
112
|
session?.getSnapshot ?? noSessionVersion,
|
|
113
113
|
);
|
|
114
114
|
// THIS STAGE's own store and its own helpers: the shared panels read
|
|
115
|
-
// whichever store the FOCUSED document's stage runs on
|
|
116
|
-
// world root's.
|
|
115
|
+
// whichever store the FOCUSED document's stage runs on, and a toolbar is a control over ITS document.
|
|
117
116
|
useSyncExternalStore(subscribeStageStores, stageStoresVersion, stageStoresVersion);
|
|
118
117
|
const stage = stageStore(documentId);
|
|
119
118
|
useSyncExternalStore(
|
|
@@ -57,8 +57,7 @@ import {
|
|
|
57
57
|
studioStageApplies,
|
|
58
58
|
} from '@volter/sdk/stage-context';
|
|
59
59
|
import { registerStageStore } from '@volter/sdk/stage-store-registry';
|
|
60
|
-
import { announceDocumentStage
|
|
61
|
-
import { sceneDocumentViewport } from '../scene-document-viewport';
|
|
60
|
+
import { announceDocumentStage } from '@volter/sdk/document-viewports';
|
|
62
61
|
import { threeStageTransformChrome } from './stage-transform-chrome';
|
|
63
62
|
import { RetainedDocumentStates } from '../retained-document-states';
|
|
64
63
|
import { perspectiveDistanceToFitBox } from '../three-viewport/camera-fit';
|
|
@@ -87,7 +86,6 @@ import {
|
|
|
87
86
|
} from '@volter/sdk/workspace-document-registry';
|
|
88
87
|
import { AssetEditorShell } from '@volter/sdk/components/AssetEditorShell';
|
|
89
88
|
import { StageOverlays } from './StageOverlays';
|
|
90
|
-
import type { WorldRootStageBinding } from './world-root-binding';
|
|
91
89
|
import { TransportStrip } from '../transport-strip';
|
|
92
90
|
|
|
93
91
|
// The stage's own keyboard actions, behind the same lazy boundary as the
|
|
@@ -113,10 +111,12 @@ import {
|
|
|
113
111
|
mergeLayers,
|
|
114
112
|
type PresentationLayer,
|
|
115
113
|
reportViewDraw,
|
|
114
|
+
setViewFraming,
|
|
116
115
|
setViewPresentation,
|
|
117
116
|
stageLightsPerMode,
|
|
118
117
|
startingPresentation,
|
|
119
118
|
subscribeViewportPresentation,
|
|
119
|
+
viewFraming,
|
|
120
120
|
viewPresentation,
|
|
121
121
|
viewPresentationSnapshot,
|
|
122
122
|
type ViewportDrawMode,
|
|
@@ -166,23 +166,6 @@ export type SourceDocumentAuthoringFactory = (
|
|
|
166
166
|
context: SourceDocumentAuthoringContext,
|
|
167
167
|
) => ToolObject3DDocumentAuthoring;
|
|
168
168
|
|
|
169
|
-
/** WHAT THE STAGE IS SHOWING. `build` is one Object3D the caller's `build()`
|
|
170
|
-
* constructs, in isolation; its kind supplies authoring behavior and chrome,
|
|
171
|
-
* and this host owns renderer lifetime, capture and disposal. `world-root` is
|
|
172
|
-
* the project's world: the contributing package's binding mounts the
|
|
173
|
-
* manifest's roots and presents the live roots Play adopts, and this host
|
|
174
|
-
* gives it the same surface place, frame session, per-stage door and
|
|
175
|
-
* overlays. A modeling host never loads a world binding. */
|
|
176
|
-
export type Object3DDocumentContent =
|
|
177
|
-
| { readonly kind: 'build' }
|
|
178
|
-
| { readonly kind: 'world-root'; load(): Promise<WorldRootStageBinding> };
|
|
179
|
-
|
|
180
|
-
/** Throttle for the world-root stage's clip rescan. A traverse per store
|
|
181
|
-
* notification would run on every selection change; 500 ms is fast enough
|
|
182
|
-
* that a streamed-in character becomes scrubbable while a person is still
|
|
183
|
-
* looking at it. */
|
|
184
|
-
const CLIP_RESCAN_INTERVAL_MS = 500;
|
|
185
|
-
|
|
186
169
|
/** Module-level so its identity is stable across renders, which
|
|
187
170
|
* `useSyncExternalStore` requires; the theme root is global, so no stage's
|
|
188
171
|
* own element narrows it. */
|
|
@@ -190,15 +173,7 @@ function subscribeThemeViewportGroup(onChange: () => void): () => void {
|
|
|
190
173
|
return subscribeNativeSelectionTheme(null, onChange);
|
|
191
174
|
}
|
|
192
175
|
|
|
193
|
-
export interface Object3DDocumentViewportProps
|
|
194
|
-
extends Omit<ToolObject3DAuthoringProps, 'build' | 'sourcePath'> {
|
|
195
|
-
/** Required by `build` content (and by the SDK door, where both stay
|
|
196
|
-
* mandatory); a `world-root` stage constructs nothing of its own and has no
|
|
197
|
-
* single source file — its content is the manifest's roots. */
|
|
198
|
-
readonly build?: ToolObject3DAuthoringProps['build'];
|
|
199
|
-
readonly sourcePath?: string;
|
|
200
|
-
/** See {@link Object3DDocumentContent}. Default `{ kind: 'build' }`. */
|
|
201
|
-
readonly content?: Object3DDocumentContent;
|
|
176
|
+
export interface Object3DDocumentViewportProps extends ToolObject3DAuthoringProps {
|
|
202
177
|
/** See `@volter/sdk`'s `ToolObject3DAuthoringProps.audit`. */
|
|
203
178
|
readonly audit?: boolean;
|
|
204
179
|
/** Reuse the canonical Asset Lab viewport with NONE of the document chrome —
|
|
@@ -425,7 +400,7 @@ class Object3DDocumentHost {
|
|
|
425
400
|
|
|
426
401
|
constructor(
|
|
427
402
|
readonly lane: 'own' | 'inspector-preview',
|
|
428
|
-
readonly surface: ReturnType<typeof mountViewportSurface
|
|
403
|
+
readonly surface: ReturnType<typeof mountViewportSurface>,
|
|
429
404
|
store: EditorShellStore,
|
|
430
405
|
) {
|
|
431
406
|
this.store = store;
|
|
@@ -511,7 +486,6 @@ export function Object3DDocumentViewport({
|
|
|
511
486
|
statistics,
|
|
512
487
|
subject,
|
|
513
488
|
gridScale,
|
|
514
|
-
content,
|
|
515
489
|
}: Object3DDocumentViewportProps) {
|
|
516
490
|
const viewStageKind = stageKind ?? stageKindOf(documentId);
|
|
517
491
|
// ANNOUNCE THE STAGE (`authoring/object3d-document-session-registry.ts`).
|
|
@@ -588,16 +562,10 @@ export function Object3DDocumentViewport({
|
|
|
588
562
|
return () => retainedObject3DStages.release(retained, generation);
|
|
589
563
|
}, [documentId]);
|
|
590
564
|
const retainedState = retainedRef.current;
|
|
591
|
-
// WHAT THIS STAGE SHOWS. A world root stays attached while hidden: Play
|
|
592
|
-
// adopts into it, and the play-entry flight rides its frame clock.
|
|
593
|
-
const worldRoot = content?.kind === 'world-root';
|
|
594
565
|
// Only a visible pane owns an interactive attachment. Hidden documents
|
|
595
566
|
// retain their state and return their renderer to the bounded pool.
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
worldRootLoadRef.current = content?.kind === 'world-root' ? content.load : null;
|
|
599
|
-
const [surfaceAttached, setSurfaceAttached] = useState(wantsSurface);
|
|
600
|
-
useEffect(() => setSurfaceAttached(wantsSurface), [wantsSurface]);
|
|
567
|
+
const [surfaceAttached, setSurfaceAttached] = useState(active);
|
|
568
|
+
useEffect(() => setSurfaceAttached(active), [active]);
|
|
601
569
|
// The selection silhouette is a LIVE prop: a modeling document turns it off
|
|
602
570
|
// the moment it enters its own sub-object mode and back on when it leaves,
|
|
603
571
|
// and the session applies that to the composer it has already built. The ref
|
|
@@ -620,7 +588,7 @@ export function Object3DDocumentViewport({
|
|
|
620
588
|
setSurfaceStatus('building');
|
|
621
589
|
return () => {
|
|
622
590
|
const host = documentHostRef.current;
|
|
623
|
-
if (host
|
|
591
|
+
if (host) {
|
|
624
592
|
// Inactive tabs stay open in the workspace. Their source remains
|
|
625
593
|
// mounted in retained CPU state while the expensive attachment goes
|
|
626
594
|
// back to the pool. A source revision or final close still disposes it.
|
|
@@ -640,7 +608,7 @@ export function Object3DDocumentViewport({
|
|
|
640
608
|
host?.close();
|
|
641
609
|
documentHostRef.current = null;
|
|
642
610
|
};
|
|
643
|
-
}, [documentId, rendererLane, retainedState, surfaceAttached
|
|
611
|
+
}, [documentId, rendererLane, retainedState, surfaceAttached]);
|
|
644
612
|
const openingViewRef = useRef(openingView);
|
|
645
613
|
openingViewRef.current = openingView;
|
|
646
614
|
const documentPresentationRef = useRef(documentPresentation);
|
|
@@ -677,10 +645,6 @@ export function Object3DDocumentViewport({
|
|
|
677
645
|
const persistenceSessionRef = useRef<Promise<AssetDocumentPersistenceSession> | null>(null);
|
|
678
646
|
const [error, setError] = useState<string | null>(null);
|
|
679
647
|
const [surfaceStatus, setSurfaceStatus] = useState<'building' | 'ready'>('building');
|
|
680
|
-
// A world root's own mount state and the binding that draws its overlays.
|
|
681
|
-
const [worldRootBinding, setWorldRootBinding] = useState<WorldRootStageBinding | null>(null);
|
|
682
|
-
const [designMountStatus, setDesignMountStatus] = useState<'mounting' | 'ready'>('mounting');
|
|
683
|
-
const [designRootIds, setDesignRootIds] = useState<readonly string[]>([]);
|
|
684
648
|
const [projection, setProjection] = useState<ThreeViewportProjection>('perspective');
|
|
685
649
|
// The live authoring adapter this document's inspection composes from. It
|
|
686
650
|
// only exists once the graph is built, so it is state rather than a ref:
|
|
@@ -707,122 +671,14 @@ export function Object3DDocumentViewport({
|
|
|
707
671
|
useEffect(() => {
|
|
708
672
|
let cancelled = false;
|
|
709
673
|
if (!surfaceAttached) return;
|
|
710
|
-
/**
|
|
711
|
-
* THE WORLD ROOT'S CONTENT BINDING. It shows the project's world rather
|
|
712
|
-
* than one constructed Object3D, so it brings its own surface, scene,
|
|
713
|
-
* composer and the presenter Play adopts through; everything else about
|
|
714
|
-
* the stage is the host's, exactly as it is for a prefab or a model.
|
|
715
|
-
*/
|
|
716
|
-
const installWorldRoot = async (
|
|
717
|
-
load: () => Promise<WorldRootStageBinding>,
|
|
718
|
-
): Promise<void> => {
|
|
719
|
-
const container = containerRef.current;
|
|
720
|
-
const canvasHost = canvasHostRef.current;
|
|
721
|
-
if (!container || !canvasHost || documentHostRef.current) return;
|
|
722
|
-
const stats = shellStatsRef.current;
|
|
723
|
-
const store = threeStoreForHost();
|
|
724
|
-
if (!stats || !store) {
|
|
725
|
-
// The world root IS the shell's own subject — its readout, its
|
|
726
|
-
// adoption stack, its transport. A bounded host has none of that.
|
|
727
|
-
setError('The world root needs the editor shell above it; this host has none.');
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
const binding = await load();
|
|
731
|
-
if (cancelled || documentHostRef.current) return;
|
|
732
|
-
setWorldRootBinding(() => binding);
|
|
733
|
-
markViewportConstructStarted();
|
|
734
|
-
const host = new Object3DDocumentHost(
|
|
735
|
-
'own',
|
|
736
|
-
binding.mountWorldRootSurface(canvasHost, container, displayName),
|
|
737
|
-
// The world is the session's own subject: Play adoption, ingest, the
|
|
738
|
-
// project history and the host door's hierarchy facet all address the
|
|
739
|
-
// SESSION store, so this stage runs on it rather than a private one.
|
|
740
|
-
store,
|
|
741
|
-
);
|
|
742
|
-
documentHostRef.current = host;
|
|
743
|
-
host.cleanups.push(registerStageStore(documentId, host.store.shell));
|
|
744
|
-
host.cleanups.push(registerStageTransport(documentId, host.transport));
|
|
745
|
-
host.cleanups.push(
|
|
746
|
-
registerDocumentViewport(documentId, {
|
|
747
|
-
...sceneDocumentViewport(host.store, documentId),
|
|
748
|
-
...threeStageTransformChrome(documentId),
|
|
749
|
-
}),
|
|
750
|
-
);
|
|
751
|
-
const stage = binding.installWorldRootStage({
|
|
752
|
-
store: host.store,
|
|
753
|
-
documentId,
|
|
754
|
-
container,
|
|
755
|
-
canvas: host.surface.canvas,
|
|
756
|
-
renderer: host.surface.renderer,
|
|
757
|
-
stats,
|
|
758
|
-
onMountStatus: setDesignMountStatus,
|
|
759
|
-
onRootIds: setDesignRootIds,
|
|
760
|
-
runFrame: (delta) => runViewportFrame(documentId, delta),
|
|
761
|
-
});
|
|
762
|
-
host.viewport = stage.viewport;
|
|
763
|
-
host.frame = stage.frame;
|
|
764
|
-
host.content = {
|
|
765
|
-
scene: stage.scene,
|
|
766
|
-
settle: () => Promise.resolve(),
|
|
767
|
-
dispose: async () => stage.dispose(),
|
|
768
|
-
};
|
|
769
|
-
// Rescan clip subjects on throttled store changes as the world tree mounts and updates.
|
|
770
|
-
// Inspect the scene the stage draws, including a session store’s scene.
|
|
771
|
-
const clipScan = scanClipSubjects(() => host.store.scene ?? stage.scene, host.transport, {
|
|
772
|
-
// A game makes its mixers as its world runs, not when the store changes.
|
|
773
|
-
rescanOnLiveMixers: true,
|
|
774
|
-
});
|
|
775
|
-
let lastClipScan = 0;
|
|
776
|
-
host.cleanups.push(() => clipScan.dispose());
|
|
777
|
-
host.cleanups.push(
|
|
778
|
-
host.store.shell.subscribe(() => {
|
|
779
|
-
const now = performance.now();
|
|
780
|
-
if (now - lastClipScan < CLIP_RESCAN_INTERVAL_MS) return;
|
|
781
|
-
lastClipScan = now;
|
|
782
|
-
clipScan.refresh();
|
|
783
|
-
}),
|
|
784
|
-
);
|
|
785
|
-
// This stage's presenter is the real one: Play adoption is a condition
|
|
786
|
-
// on the world-root binding, and a `build` stage declines every root.
|
|
787
|
-
host.cleanups.push(
|
|
788
|
-
bindViewportRig(
|
|
789
|
-
{
|
|
790
|
-
camera: stage.viewport.camera,
|
|
791
|
-
drawCamera: () => stage.viewport.renderCamera,
|
|
792
|
-
orbit: stage.viewport.orbitControls,
|
|
793
|
-
projection: stage.viewport.projectionOwner,
|
|
794
|
-
scene: stage.scene,
|
|
795
|
-
renderer: host.surface.renderer,
|
|
796
|
-
},
|
|
797
|
-
stage.setHelper,
|
|
798
|
-
{ documentId },
|
|
799
|
-
),
|
|
800
|
-
);
|
|
801
|
-
rendererSessionRef.current = host.rendererSession;
|
|
802
|
-
host.initialized = true;
|
|
803
|
-
markViewportConstructReady();
|
|
804
|
-
// The binding's own overlay reports mount progress.
|
|
805
|
-
setSurfaceStatus('ready');
|
|
806
|
-
// Always active: the play-entry flight rides this clock while the Game
|
|
807
|
-
// document holds focus; the stage skips frames by its own visibility.
|
|
808
|
-
host.rendererSession.setActive(true);
|
|
809
|
-
};
|
|
810
674
|
const install = async () => {
|
|
811
675
|
await documentHostRef.current?.content?.settle();
|
|
812
676
|
while (liveGestureActive()) await whenLiveGestureIdle();
|
|
813
677
|
if (cancelled) return;
|
|
814
|
-
const loadWorldRoot = worldRootLoadRef.current;
|
|
815
|
-
if (loadWorldRoot) return installWorldRoot(loadWorldRoot);
|
|
816
678
|
const container = containerRef.current;
|
|
817
679
|
const canvasHost = canvasHostRef.current;
|
|
818
680
|
if (!container || !canvasHost) return;
|
|
819
|
-
|
|
820
|
-
// `build` content is one Object3D the caller constructs from one file.
|
|
821
|
-
// Both are required by the SDK; validate callers at the runtime boundary.
|
|
822
|
-
setError('This stage shows `build` content and was given no `build()`/`sourcePath`.');
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
let source: ReturnType<NonNullable<typeof build>>;
|
|
681
|
+
let source: ReturnType<typeof build>;
|
|
826
682
|
const retainedGeneration = retainedObject3DStages.generation(retainedState);
|
|
827
683
|
try {
|
|
828
684
|
source = retainedState.source ?? build();
|
|
@@ -877,6 +733,8 @@ export function Object3DDocumentViewport({
|
|
|
877
733
|
const history = workspaceHistoryService();
|
|
878
734
|
if (history) store.shell.attachHistory(history);
|
|
879
735
|
const shared = rendererLane === 'inspector-preview';
|
|
736
|
+
// Only the document's own view keeps its framing: a preview of it stands where it frames.
|
|
737
|
+
const recordsFraming = !shared && !chromeless;
|
|
880
738
|
const studioStage = studioStageRef.current;
|
|
881
739
|
const hasShell = hasShellRef.current;
|
|
882
740
|
const assetSubject = assetSubjectRef.current;
|
|
@@ -1440,6 +1298,7 @@ export function Object3DDocumentViewport({
|
|
|
1440
1298
|
presentationAdapter?.hierarchy.idForNativeObject?.(object) ?? adapter.hierarchy.idForNativeObject?.(object);
|
|
1441
1299
|
if (id) store.objectMap.set(id, object);
|
|
1442
1300
|
});
|
|
1301
|
+
const opening = !host.session;
|
|
1443
1302
|
if (!host.session) {
|
|
1444
1303
|
// The view's camera (`ViewportCamera`): its field of view, and the direction it opens
|
|
1445
1304
|
// from when the document states none.
|
|
@@ -1528,6 +1387,18 @@ export function Object3DDocumentViewport({
|
|
|
1528
1387
|
// After the pose, which draws in perspective: an orthographic view comes back as one.
|
|
1529
1388
|
viewport.setProjection(projection);
|
|
1530
1389
|
retainedState.camera = null;
|
|
1390
|
+
} else if (opening && recordsFraming) {
|
|
1391
|
+
// THE VIEW'S STATE OVER THE FILE'S: a document that was seen before opens where it was
|
|
1392
|
+
// last seen (`viewFraming`, restored from workspace storage, as Code-OSS restores an
|
|
1393
|
+
// editor's view state), in place of the opening view its file states.
|
|
1394
|
+
const framing = viewFraming(documentId);
|
|
1395
|
+
if (framing) {
|
|
1396
|
+
const [x, y, z] = framing.position;
|
|
1397
|
+
const [tx, ty, tz] = framing.target;
|
|
1398
|
+
const [ux, uy, uz] = framing.up;
|
|
1399
|
+
viewport.setPose({ x, y, z }, { x: tx, y: ty, z: tz }, undefined, { x: ux, y: uy, z: uz });
|
|
1400
|
+
host.session.setProjection(framing.projection);
|
|
1401
|
+
}
|
|
1531
1402
|
}
|
|
1532
1403
|
const documentSession = host.session;
|
|
1533
1404
|
documentStateRef.current = documentState;
|
|
@@ -1578,8 +1449,11 @@ export function Object3DDocumentViewport({
|
|
|
1578
1449
|
if (viewPresentation(documentId).drawMode !== mode)
|
|
1579
1450
|
setViewPresentation(documentId, { drawMode: mode as ViewportDrawMode });
|
|
1580
1451
|
};
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1452
|
+
// The draw mode the view opens in: the view state (the person's last view of this document,
|
|
1453
|
+
// `document-view-states.ts`), else what the document's file states, else the session's default,
|
|
1454
|
+
// as Code-OSS restores an editor's view state over its input's defaults. The view then follows
|
|
1455
|
+
// the session.
|
|
1456
|
+
const restoredMode = viewPresentationSnapshot(documentId).drawMode ?? ownLayer?.drawMode;
|
|
1583
1457
|
if (restoredMode !== undefined && viewModes.has(restoredMode) && host.session)
|
|
1584
1458
|
host.session.setMode(restoredMode);
|
|
1585
1459
|
sessionToView();
|
|
@@ -1718,8 +1592,39 @@ export function Object3DDocumentViewport({
|
|
|
1718
1592
|
}
|
|
1719
1593
|
};
|
|
1720
1594
|
host.syncHostScene = syncHostScene;
|
|
1595
|
+
// WHERE THE VIEW STANDS, recorded as it moves (`setViewFraming`): when the orbit controls
|
|
1596
|
+
// report a move (`OrbitControls.update` dispatches `change` for any move, `setPose`'s
|
|
1597
|
+
// included), when the session changes (its projection), and before every draw. A door can
|
|
1598
|
+
// move the camera with no ordinary draw after it: measured, a pose set through
|
|
1599
|
+
// `editor.present` was not recorded by draws alone; that the page's tab was not on screen
|
|
1600
|
+
// is the likely cause, not measured. A photograph's draw (`captureImage`) leaves the view's
|
|
1601
|
+
// camera where it stands. During a photograph the projection recorded last stands, as a
|
|
1602
|
+
// guard: no caller switches the projection for a photograph today.
|
|
1603
|
+
let recordedFraming = '';
|
|
1604
|
+
let recordedProjection: 'perspective' | 'orthographic' | null = null;
|
|
1605
|
+
const recordFraming = (): void => {
|
|
1606
|
+
if (!recordsFraming) return;
|
|
1607
|
+
const position = viewport.camera.position.toArray();
|
|
1608
|
+
const target = viewport.orbitControls.target.toArray();
|
|
1609
|
+
const up = viewport.camera.up.toArray();
|
|
1610
|
+
const projection =
|
|
1611
|
+
documentSession.photographing() && recordedProjection !== null ? recordedProjection : documentSession.projection();
|
|
1612
|
+
const key = `${position}|${target}|${up}|${projection}`;
|
|
1613
|
+
if (key === recordedFraming) return;
|
|
1614
|
+
recordedFraming = key;
|
|
1615
|
+
recordedProjection = projection;
|
|
1616
|
+
setViewFraming(documentId, { position, target, up, projection });
|
|
1617
|
+
};
|
|
1618
|
+
const controls = viewport.orbitControls;
|
|
1619
|
+
controls.addEventListener('change', recordFraming);
|
|
1620
|
+
const stopSessionFraming = documentSession.subscribe(recordFraming);
|
|
1621
|
+
host.cleanups.push(() => {
|
|
1622
|
+
controls.removeEventListener('change', recordFraming);
|
|
1623
|
+
stopSessionFraming();
|
|
1624
|
+
});
|
|
1721
1625
|
documentSession.setBeforeRender((interactive) => {
|
|
1722
1626
|
host.syncHostScene?.();
|
|
1627
|
+
recordFraming();
|
|
1723
1628
|
const mode = documentSession.presentation().mode;
|
|
1724
1629
|
const navigation = interactive && ['solid', 'preview', 'clay', 'unlit', 'matcap'].includes(mode);
|
|
1725
1630
|
return source.prepareDraw?.(documentSession.camera(), {interactive: navigation, height: renderer.domElement.height, multiDraw: renderer.extensions.has('WEBGL_multi_draw'), renderer, drawMode: mode, pixelScale: documentSession.drawPixelScale(), photograph: documentSession.photographing()});
|
|
@@ -1736,7 +1641,7 @@ export function Object3DDocumentViewport({
|
|
|
1736
1641
|
host.transportAdvances++;
|
|
1737
1642
|
source.update?.(deltaSeconds);
|
|
1738
1643
|
};
|
|
1739
|
-
// Scan this source's animation subjects once.
|
|
1644
|
+
// Scan this source's animation subjects once.
|
|
1740
1645
|
const clipScan = scanClipSubjects(source.root, host.transport);
|
|
1741
1646
|
host.cleanups.push(() => clipScan.dispose());
|
|
1742
1647
|
const retainedTransport = retainedState.transport;
|
|
@@ -1867,8 +1772,8 @@ export function Object3DDocumentViewport({
|
|
|
1867
1772
|
host.presence?.syncMarkers(delta);
|
|
1868
1773
|
viewport.update(delta);
|
|
1869
1774
|
// This stage's frame hook. After viewport.update, the same order the
|
|
1870
|
-
// scene panel uses, so anything riding the loop
|
|
1871
|
-
//
|
|
1775
|
+
// scene panel uses, so anything riding the loop has final say over
|
|
1776
|
+
// the pose and orbit damping never fights it.
|
|
1872
1777
|
if (!chromeless) runViewportFrame(documentId, delta);
|
|
1873
1778
|
if (navigationMeasurement) {
|
|
1874
1779
|
const camera = documentSession.camera();
|
|
@@ -2102,7 +2007,6 @@ export function Object3DDocumentViewport({
|
|
|
2102
2007
|
store.shell.onViewportAction((action) => {
|
|
2103
2008
|
switch (action.type) {
|
|
2104
2009
|
case 'focus-selection':
|
|
2105
|
-
case 'focus-scene':
|
|
2106
2010
|
// The session frames the selection, else the whole subject,
|
|
2107
2011
|
// and says so on the console when there is nothing to frame.
|
|
2108
2012
|
if (!host.session?.frame() && host.session?.root)
|
|
@@ -2251,7 +2155,6 @@ export function Object3DDocumentViewport({
|
|
|
2251
2155
|
surfaceAttached,
|
|
2252
2156
|
sourceAuthoring,
|
|
2253
2157
|
sourcePath,
|
|
2254
|
-
worldRoot,
|
|
2255
2158
|
]);
|
|
2256
2159
|
|
|
2257
2160
|
useEffect(() => {
|
|
@@ -2263,11 +2166,10 @@ export function Object3DDocumentViewport({
|
|
|
2263
2166
|
firstFrameGateRef.current = false;
|
|
2264
2167
|
markViewportRafResume(documentId);
|
|
2265
2168
|
}
|
|
2266
|
-
|
|
2267
|
-
session.setActive(active || worldRoot);
|
|
2169
|
+
session.setActive(active);
|
|
2268
2170
|
// A hidden stage keeps no live accumulation (its targets are the drawing buffer's size).
|
|
2269
2171
|
if (!active) documentHostRef.current?.session?.releaseLive();
|
|
2270
|
-
}, [active, documentId
|
|
2172
|
+
}, [active, documentId]);
|
|
2271
2173
|
|
|
2272
2174
|
// The stage's own handle for the capabilities that drive it directly (the
|
|
2273
2175
|
// viewport hotkeys). `surfaceStatus` is what re-renders once the stage has
|
|
@@ -2336,14 +2238,6 @@ export function Object3DDocumentViewport({
|
|
|
2336
2238
|
chrome="document"
|
|
2337
2239
|
/>
|
|
2338
2240
|
) : null}
|
|
2339
|
-
{shellStore && worldRootBinding ? (
|
|
2340
|
-
<worldRootBinding.Overlays
|
|
2341
|
-
store={shellStore}
|
|
2342
|
-
documentId={documentId}
|
|
2343
|
-
mountStatus={designMountStatus}
|
|
2344
|
-
rootIds={designRootIds}
|
|
2345
|
-
/>
|
|
2346
|
-
) : null}
|
|
2347
2241
|
{/* Show a dismissible error notice over any stage that remains available. */}
|
|
2348
2242
|
{error && (
|
|
2349
2243
|
<EditorBanner
|
|
@@ -27,7 +27,7 @@ const ZERO = () => 0;
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Editor-camera projection and axis presets for the stage’s own shell. A documentId targets its
|
|
30
|
-
* Object3DDocumentSession;
|
|
30
|
+
* Object3DDocumentSession; without one, the editor-wide projection. label shows the view
|
|
31
31
|
* name instead of a camera icon, and kebab prefixes the label with three dots.
|
|
32
32
|
*/
|
|
33
33
|
export function ViewportViewMenu({
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* document's chrome (`@volter/sdk/document-viewports`): what the
|
|
4
4
|
* host's transform tools drive on it (`stage-context.ts`'s one answer), the tool
|
|
5
5
|
* strip on its shelf and the gizmo's header wells. Every tool writes THIS
|
|
6
|
-
* stage's own store (`stage-store-registry.ts`)
|
|
7
|
-
* the session store, so the Scene document is unchanged either way.
|
|
6
|
+
* stage's own store (`stage-store-registry.ts`).
|
|
8
7
|
*/
|
|
9
8
|
import { type ViewportInteraction, viewPresentation } from '@volter/sdk/viewport-presentation';
|
|
10
9
|
import { useEffect, useSyncExternalStore } from 'react';
|
|
@@ -99,35 +99,11 @@ export class EditorShellStore {
|
|
|
99
99
|
this._statePersistence = persistence;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** The game camera lookup the last `focusOnScene` carried, read by the world stage while its
|
|
103
|
-
* auto-frame window is open. */
|
|
104
|
-
get sceneCameraLookup(): (() => THREE.Camera | null) | null {
|
|
105
|
-
return this._sceneCameraLookup;
|
|
106
|
-
}
|
|
107
|
-
protected _sceneCameraLookup: (() => THREE.Camera | null) | null = null;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Frame the whole active scene — the first thing a reader should see when a
|
|
111
|
-
* world they did not author opens. The viewport keeps
|
|
112
|
-
* asking until the world HAS content, because a game builds itself
|
|
113
|
-
* asynchronously; any camera input from the reader cancels it (see
|
|
114
|
-
* `the world root's stage`). A no-op for a scene the reader already has a camera on.
|
|
115
|
-
*
|
|
116
|
-
* `gameCamera` is a LIVE LOOKUP, not a camera: a game's own camera
|
|
117
|
-
* may not exist yet on the frame it is captured on, so the viewport asks
|
|
118
|
-
* again while its window is open. When one turns up, its view is adopted
|
|
119
|
-
* instead of any measurement — see `scene-framing.ts`.
|
|
120
|
-
*/
|
|
121
|
-
focusOnScene(gameCamera?: () => THREE.Camera | null): void {
|
|
122
|
-
this._sceneCameraLookup = gameCamera ?? null;
|
|
123
|
-
this.shell.focusOnScene();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
102
|
/**
|
|
127
103
|
* Live viewport camera pose, read straight from the bound Three.js camera +
|
|
128
104
|
* orbit target (`bindScene`/`setOrbitTarget`).
|
|
129
105
|
* Null only when nothing has bound a camera yet (e.g. a headless store in a
|
|
130
|
-
* unit test that never mounted
|
|
106
|
+
* unit test that never mounted a stage).
|
|
131
107
|
*/
|
|
132
108
|
get cameraPose(): { position: THREE.Vector3; target: THREE.Vector3; fov: number } | null {
|
|
133
109
|
if (!this._camera) return null;
|
|
@@ -326,7 +302,7 @@ export class EditorShellStore {
|
|
|
326
302
|
* Fresh, on-demand viewport capture (unthrottled, unlike the periodic
|
|
327
303
|
* autosave thumbnail above) — backs the `capture-viewport` relay command.
|
|
328
304
|
* Returns null when no renderer/scene/camera is bound yet (e.g. before
|
|
329
|
-
*
|
|
305
|
+
* a stage has mounted).
|
|
330
306
|
*/
|
|
331
307
|
captureViewportImage(
|
|
332
308
|
size: number | { width: number; height: number } = EditorShellStore.THUMBNAIL_SIZE,
|
|
@@ -1432,7 +1432,7 @@ export class EditorViewport {
|
|
|
1432
1432
|
return this._scene;
|
|
1433
1433
|
}
|
|
1434
1434
|
|
|
1435
|
-
/** Camera that paints the
|
|
1435
|
+
/** Camera that paints the stage: the free editor camera. */
|
|
1436
1436
|
get renderCamera(): THREE.Camera {
|
|
1437
1437
|
return this.freeCamera;
|
|
1438
1438
|
}
|
|
@@ -22,7 +22,6 @@ import { editorHost } from '@volter/sdk/host';
|
|
|
22
22
|
import { onShellStore } from '@volter/sdk/shell-store-door';
|
|
23
23
|
import { registerThreeCanvasRender } from './three-canvas-render';
|
|
24
24
|
import { registerThreeHierarchyRowMedia } from './three-hierarchy-row-media';
|
|
25
|
-
import { registerThreePlayCameraFlight } from './play-camera-flight';
|
|
26
25
|
import { registerThreeInspectionMedia } from './three-inspection-media';
|
|
27
26
|
import { threeStateOf } from './three-state';
|
|
28
27
|
import { installViewportAuthoringPolicy } from './viewport-authoring-policy';
|
|
@@ -68,7 +67,6 @@ export function ensureThreeIntegration(): () => void {
|
|
|
68
67
|
reportViewportStatus(),
|
|
69
68
|
registerThreeCanvasRender(),
|
|
70
69
|
registerThreeHierarchyRowMedia(),
|
|
71
|
-
registerThreePlayCameraFlight(),
|
|
72
70
|
registerRendererResourceCounts('hostLive', liveHostRendererCount),
|
|
73
71
|
registerRendererResourceCounts('interactive', interactiveViewportRendererCounts),
|
|
74
72
|
registerRendererResourceCounts('inspectorPreview', inspectorPreviewRendererCounts),
|
package/src/viewport-api.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE AUTHORED THREE VIEWPORT'S PUBLIC SHAPE — what a lane that mounts something reaches through
|
|
3
|
-
* `viewport-door.ts`:
|
|
4
|
-
* shows and every mounted stage.
|
|
3
|
+
* `viewport-door.ts`: every mounted stage, its rig, its frame loop and the helpers it shows.
|
|
5
4
|
*/
|
|
6
5
|
import type * as THREE from 'three';
|
|
7
6
|
import type { StageFrameCostReading } from '@volter/sdk';
|
|
@@ -35,9 +34,7 @@ export interface ViewportRig {
|
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
36
|
* ONE MOUNTED 3D STAGE, named by the document it draws. Every 3D document
|
|
38
|
-
* mounts a stage of its own
|
|
39
|
-
* to a particular one, where the viewport door's singular members
|
|
40
|
-
* are the door to whichever is primary.
|
|
37
|
+
* mounts a stage of its own; this is the door to a particular one.
|
|
41
38
|
*/
|
|
42
39
|
export interface ViewportStage {
|
|
43
40
|
/** The workspace document this stage draws. */
|
|
@@ -50,14 +47,8 @@ export interface ViewportStage {
|
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
/**
|
|
53
|
-
* THE AUTHORED VIEWPORT, as a lane that mounts something reaches it
|
|
54
|
-
*
|
|
55
|
-
* viewport renders is the host's; a lane that finds no rig (a headless tab, a
|
|
56
|
-
* project with no Three root) mounts without one.
|
|
57
|
-
*
|
|
58
|
-
* THE SINGULAR MEMBERS ARE THE PRIMARY STAGE — the FOCUSED stage, else the
|
|
59
|
-
* first bound. A camera flight wants exactly that one, which is why it reads
|
|
60
|
-
* here and never enumerates.
|
|
50
|
+
* THE AUTHORED VIEWPORT, as a lane that mounts something reaches it. Which
|
|
51
|
+
* native surface the viewport renders is the host's.
|
|
61
52
|
*
|
|
62
53
|
* {@link stages} is EVERY mounted 3D stage. A helper is an `Object3D` and an
|
|
63
54
|
* `Object3D` has one parent, so there is no "set it once for all stages": a
|
|
@@ -66,9 +57,6 @@ export interface ViewportStage {
|
|
|
66
57
|
* it made that pass.
|
|
67
58
|
*/
|
|
68
59
|
export interface ViewportDoor {
|
|
69
|
-
rig(): ViewportRig | null;
|
|
70
|
-
/** Runs after the viewport's own per-frame update; the return unsubscribes. */
|
|
71
|
-
onFrame(fn: (dtSeconds: number) => void): () => void;
|
|
72
60
|
/**
|
|
73
61
|
* Show an editor-only helper object of `kind` in the authored viewport (a
|
|
74
62
|
* baked navmesh's debug mesh), replacing the previous one of that kind;
|
package/src/viewport-door.ts
CHANGED
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
* Registry for document stages' viewport rigs, helper displays and frame callbacks. Each mounted
|
|
3
3
|
* 3D stage binds under its document id and runs its frame hook from its own RAF loop.
|
|
4
4
|
*
|
|
5
|
-
* `viewportRig` and `onViewportFrame` follow the primary stage: the focused document's bound
|
|
6
|
-
* stage, else the first bound. The camera-flight seam reads that rig and uses its frame clock.
|
|
7
5
|
* `viewportStages()` exposes every bound stage so helpers can be attached to each separately.
|
|
8
6
|
* Stage subscriptions and helpers survive canvas remounts under the same document id.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import type { ViewportDoor, ViewportRig, ViewportStage } from './viewport-api';
|
|
12
|
-
import { activeWorkspaceDocumentId } from '@volter/sdk/workspace-document-registry';
|
|
13
10
|
|
|
14
11
|
/** An editor-only helper object, typed by the host door it is shown through. */
|
|
15
12
|
type HelperObject = Parameters<ViewportDoor['setHelper']>[1];
|
|
@@ -24,8 +21,7 @@ interface Binding {
|
|
|
24
21
|
readonly helpers: Map<string, HelperObject>;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
/** Every bound stage, keyed by the document it draws
|
|
28
|
-
* "the first bound" is a real answer. */
|
|
24
|
+
/** Every bound stage, keyed by the document it draws, in bind order. */
|
|
29
25
|
const bound = new Map<string, Binding>();
|
|
30
26
|
/** Retain frame subscribers and helper assignments by document across viewport remounts and before binding. */
|
|
31
27
|
const stageStateByDocument = new Map<
|
|
@@ -47,23 +43,8 @@ function stageStateFor(documentId: string): {
|
|
|
47
43
|
}
|
|
48
44
|
return state;
|
|
49
45
|
}
|
|
50
|
-
/** Listeners on the singular `onViewportFrame`: they follow the PRIMARY
|
|
51
|
-
* stage, so they are held here and fanned in rather than added to a
|
|
52
|
-
* binding's own set, which would strand them on a stage that stops being
|
|
53
|
-
* primary (or unmounts). */
|
|
54
|
-
const primaryFrameListeners = new Set<(dtSeconds: number) => void>();
|
|
55
46
|
const stageListeners = new Set<(stages: readonly ViewportStage[]) => void>();
|
|
56
47
|
|
|
57
|
-
/** THE PRIMARY STAGE: focused > first bound. */
|
|
58
|
-
function primary(): Binding | null {
|
|
59
|
-
const focused = activeWorkspaceDocumentId();
|
|
60
|
-
if (focused !== null) {
|
|
61
|
-
const match = bound.get(focused);
|
|
62
|
-
if (match) return match;
|
|
63
|
-
}
|
|
64
|
-
return bound.values().next().value ?? null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
48
|
let stagesCache: readonly ViewportStage[] | null = null;
|
|
68
49
|
|
|
69
50
|
function notifyStages(): void {
|
|
@@ -121,20 +102,6 @@ export function bindViewportRig(
|
|
|
121
102
|
};
|
|
122
103
|
}
|
|
123
104
|
|
|
124
|
-
export function viewportRig(): ViewportRig | null {
|
|
125
|
-
return primary()?.rig ?? null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Runs after the PRIMARY stage's own per-frame update, and RE-TARGETS: the
|
|
129
|
-
* listener follows whichever stage is primary, not the one that was primary
|
|
130
|
-
* when it subscribed. */
|
|
131
|
-
export function onViewportFrame(fn: (dtSeconds: number) => void): () => void {
|
|
132
|
-
primaryFrameListeners.add(fn);
|
|
133
|
-
return () => {
|
|
134
|
-
primaryFrameListeners.delete(fn);
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
105
|
/** Every bound stage, in bind order. */
|
|
139
106
|
export function viewportStages(): readonly ViewportStage[] {
|
|
140
107
|
if (!stagesCache)
|
|
@@ -156,12 +123,9 @@ export function onViewportStages(fn: (stages: readonly ViewportStage[]) => void)
|
|
|
156
123
|
};
|
|
157
124
|
}
|
|
158
125
|
|
|
159
|
-
/** A stage's RAF loop calls this after its own update each frame
|
|
160
|
-
* own listeners always run; the primary-stage listeners run here only when
|
|
161
|
-
* this stage IS the primary. */
|
|
126
|
+
/** A stage's RAF loop calls this after its own update each frame, running the stage's own listeners. */
|
|
162
127
|
export function runViewportFrame(documentId: string, dtSeconds: number): void {
|
|
163
128
|
const binding = bound.get(documentId);
|
|
164
129
|
if (!binding) return;
|
|
165
130
|
for (const fn of binding.frameListeners) fn(dtSeconds);
|
|
166
|
-
if (primary() === binding) for (const fn of primaryFrameListeners) fn(dtSeconds);
|
|
167
131
|
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE WORLD ROOT'S CONTENT BINDING, as the stage host sees it.
|
|
3
|
-
*
|
|
4
|
-
* A stage shows either one Object3D a caller builds (`build` content) or the
|
|
5
|
-
* project's world (`world-root` content): the manifest's roots mounted by a
|
|
6
|
-
* design session, drawn by the world's own image pipeline, with the presenter
|
|
7
|
-
* Play adopts live roots through. The host owns the surface's place, the frame
|
|
8
|
-
* session, the per-stage door, the furniture and the overlays for both. What
|
|
9
|
-
* mounts a world is the contributing package's — it runs the game runtime,
|
|
10
|
-
* which a modeling host never carries — so the package hands the host this
|
|
11
|
-
* binding and the host loads it only for a stage that shows a world.
|
|
12
|
-
*/
|
|
13
|
-
import type { ComponentType } from 'react';
|
|
14
|
-
import type * as THREE from 'three';
|
|
15
|
-
import type { EditorShellStore } from '../editor-shell-store';
|
|
16
|
-
import type { EditorStats } from '@volter/sdk/editor-runtime';
|
|
17
|
-
import type { EditorViewport } from '../editor-viewport';
|
|
18
|
-
|
|
19
|
-
export interface WorldRootStageOptions {
|
|
20
|
-
/** The session's store: the world is the session's own subject. */
|
|
21
|
-
readonly store: EditorShellStore;
|
|
22
|
-
readonly documentId: string;
|
|
23
|
-
readonly container: HTMLDivElement;
|
|
24
|
-
readonly canvas: HTMLCanvasElement;
|
|
25
|
-
readonly renderer: THREE.WebGLRenderer;
|
|
26
|
-
readonly stats: EditorStats;
|
|
27
|
-
readonly onMountStatus: (status: 'mounting' | 'ready') => void;
|
|
28
|
-
readonly onRootIds: (rootIds: readonly string[]) => void;
|
|
29
|
-
/** This stage's frame hook on the per-stage viewport door. */
|
|
30
|
-
readonly runFrame: (deltaSeconds: number) => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface WorldRootStage {
|
|
34
|
-
readonly scene: THREE.Scene;
|
|
35
|
-
readonly viewport: EditorViewport;
|
|
36
|
-
frame(timeMs: number, resumed: boolean): void;
|
|
37
|
-
setHelper(kind: string, object: THREE.Object3D | null): void;
|
|
38
|
-
dispose(): void;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface WorldRootOverlayProps {
|
|
42
|
-
readonly store: EditorShellStore;
|
|
43
|
-
/** The stage's own document — its stage context is read by this id. */
|
|
44
|
-
readonly documentId: string;
|
|
45
|
-
readonly mountStatus: 'mounting' | 'ready';
|
|
46
|
-
readonly rootIds: readonly string[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface WorldRootStageBinding {
|
|
50
|
-
/** The world's own drawing surface, built to the world's render settings. */
|
|
51
|
-
mountWorldRootSurface(
|
|
52
|
-
canvasHost: HTMLDivElement,
|
|
53
|
-
container: HTMLDivElement,
|
|
54
|
-
displayName: string,
|
|
55
|
-
): { canvas: HTMLCanvasElement; renderer: THREE.WebGLRenderer; lease: null };
|
|
56
|
-
installWorldRootStage(options: WorldRootStageOptions): WorldRootStage;
|
|
57
|
-
/** What the stage shows over a world: its mount state and camera authoring. */
|
|
58
|
-
readonly Overlays: ComponentType<WorldRootOverlayProps>;
|
|
59
|
-
}
|
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE PLAY ENTRY CAMERA FLIGHT — the Three integration's half of Play's immersive entry
|
|
3
|
-
* (`@volter/sdk/play-camera-flight`). The kit's transition owns the phases and the
|
|
4
|
-
* chrome; this owns the camera: the editor viewport flies along a Catmull-Rom spline from its
|
|
5
|
-
* preview pose to the AUTHORED game camera's pose, retargets to the game's live camera once it
|
|
6
|
-
* boots so the cross-fade is pixel-continuous, and restores the preview pose when Play ends. The
|
|
7
|
-
* flight reads the authored viewport's rig through the viewport door and rides its frame hook, so
|
|
8
|
-
* flight pacing rides the editor's own frame clock.
|
|
9
|
-
*/
|
|
10
|
-
import { registerPlayCameraFlight, type LiveCameraLookup } from '@volter/sdk/play-camera-flight';
|
|
11
|
-
import { onViewportFrame, viewportRig } from '../viewport-door';
|
|
12
|
-
import * as THREE from 'three';
|
|
13
|
-
import { threeStoreForHost } from './three-state';
|
|
14
|
-
|
|
15
|
-
/** Flight time scales mildly with distance so short hops feel snappy and
|
|
16
|
-
* cross-scene flights stay graceful, clamped to a tight band. */
|
|
17
|
-
export function flightDurationMs(distance: number): number {
|
|
18
|
-
return Math.min(1600, Math.max(800, 900 + distance * 25));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function easeInOutCubic(t: number): number {
|
|
22
|
-
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Control points for the camera flight spline: start, two interior points
|
|
27
|
-
* forming a gentle arc, end. The arc lifts along world-up and bows slightly
|
|
28
|
-
* sideways, both proportional to (but clamped against) the travel distance —
|
|
29
|
-
* a short hop barely arcs, a long flight gets a visible swoop, and a
|
|
30
|
-
* degenerate zero-length flight returns a straight (stationary) curve.
|
|
31
|
-
*/
|
|
32
|
-
export function computeFlightControlPoints(
|
|
33
|
-
start: THREE.Vector3,
|
|
34
|
-
end: THREE.Vector3,
|
|
35
|
-
up: THREE.Vector3 = new THREE.Vector3(0, 1, 0),
|
|
36
|
-
): THREE.Vector3[] {
|
|
37
|
-
const travel = new THREE.Vector3().subVectors(end, start);
|
|
38
|
-
const distance = travel.length();
|
|
39
|
-
if (distance < 1e-6) {
|
|
40
|
-
return [start.clone(), start.clone(), end.clone(), end.clone()];
|
|
41
|
-
}
|
|
42
|
-
const lift = Math.min(distance * 0.18, 6);
|
|
43
|
-
// Side bias perpendicular to travel and up — gives the arc a slight bank
|
|
44
|
-
// instead of a purely vertical hump. Falls back to zero when travel is
|
|
45
|
-
// parallel to up (straight vertical flights just lift).
|
|
46
|
-
const side = new THREE.Vector3().crossVectors(travel, up);
|
|
47
|
-
if (side.lengthSq() > 1e-9) side.normalize().multiplyScalar(Math.min(distance * 0.08, 2.5));
|
|
48
|
-
else side.set(0, 0, 0);
|
|
49
|
-
const liftVec = up.clone().normalize().multiplyScalar(lift);
|
|
50
|
-
const p1 = start.clone().addScaledVector(travel, 0.3).add(liftVec).add(side);
|
|
51
|
-
const p2 = end
|
|
52
|
-
.clone()
|
|
53
|
-
.addScaledVector(travel, -0.3)
|
|
54
|
-
.addScaledVector(liftVec, 0.6)
|
|
55
|
-
.addScaledVector(side, 0.5);
|
|
56
|
-
return [start.clone(), p1, p2, end.clone()];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The narrow store surface `resolveAuthoredCameraTarget` reads. */
|
|
60
|
-
export interface PlayCameraSourceStore {
|
|
61
|
-
readonly objectMap: ReadonlyMap<string, THREE.Object3D>;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface AuthoredCameraTarget {
|
|
65
|
-
entityId: string;
|
|
66
|
-
position: [number, number, number];
|
|
67
|
-
quaternion: [number, number, number, number];
|
|
68
|
-
fov: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Editor-only overlays (helpers, gizmos) live on layer 31 — never flight targets. */
|
|
72
|
-
const EDITOR_ONLY_LAYER_MASK = 1 << 31;
|
|
73
|
-
|
|
74
|
-
function isEditorOnlyOrGizmo(obj: THREE.Object3D): boolean {
|
|
75
|
-
return (obj.layers.mask & EDITOR_ONLY_LAYER_MASK) !== 0 || /gizmo/i.test(obj.name);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Depth-first search for the first real camera in a subtree, skipping
|
|
79
|
-
* editor-only/gizmo branches wholesale. */
|
|
80
|
-
function findCameraInSubtree(obj: THREE.Object3D): THREE.Camera | null {
|
|
81
|
-
if (isEditorOnlyOrGizmo(obj)) return null;
|
|
82
|
-
if ((obj as Partial<THREE.Camera>).isCamera) return obj as THREE.Camera;
|
|
83
|
-
for (const child of obj.children) {
|
|
84
|
-
const found = findCameraInSubtree(child);
|
|
85
|
-
if (found) return found;
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function targetFromObject(
|
|
91
|
-
entityId: string,
|
|
92
|
-
obj: THREE.Object3D,
|
|
93
|
-
fov: number,
|
|
94
|
-
): AuthoredCameraTarget {
|
|
95
|
-
const position = new THREE.Vector3();
|
|
96
|
-
const quaternion = new THREE.Quaternion();
|
|
97
|
-
obj.getWorldPosition(position);
|
|
98
|
-
obj.getWorldQuaternion(quaternion);
|
|
99
|
-
return {
|
|
100
|
-
entityId,
|
|
101
|
-
position: position.toArray() as [number, number, number],
|
|
102
|
-
quaternion: quaternion.toArray() as [number, number, number, number],
|
|
103
|
-
fov,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Find the authored camera in the current editor scene outside editor-only and gizmo branches, and
|
|
109
|
-
* return its live world transform. Return null when the mapped object graph contains no camera.
|
|
110
|
-
*/
|
|
111
|
-
export function resolveAuthoredCameraTarget(
|
|
112
|
-
store: PlayCameraSourceStore,
|
|
113
|
-
): AuthoredCameraTarget | null {
|
|
114
|
-
for (const [id, obj] of store.objectMap) {
|
|
115
|
-
const camera = findCameraInSubtree(obj);
|
|
116
|
-
if (!camera) continue;
|
|
117
|
-
const perspective = camera as Partial<THREE.PerspectiveCamera>;
|
|
118
|
-
return targetFromObject(id, camera, perspective.isPerspectiveCamera ? perspective.fov! : 60);
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface FlightRuntime {
|
|
124
|
-
startPos: THREE.Vector3;
|
|
125
|
-
startQuat: THREE.Quaternion;
|
|
126
|
-
startFov: number;
|
|
127
|
-
endPos: THREE.Vector3;
|
|
128
|
-
endQuat: THREE.Quaternion;
|
|
129
|
-
endFov: number;
|
|
130
|
-
curve: THREE.CatmullRomCurve3;
|
|
131
|
-
startedAt: number;
|
|
132
|
-
durationMs: number;
|
|
133
|
-
landed: boolean;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function rebuildCurve(flight: FlightRuntime): void {
|
|
137
|
-
flight.curve = new THREE.CatmullRomCurve3(
|
|
138
|
-
computeFlightControlPoints(flight.startPos, flight.endPos),
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
interface ActiveFlight {
|
|
143
|
-
readonly runtime: FlightRuntime;
|
|
144
|
-
readonly snapshot: { position: THREE.Vector3; target: THREE.Vector3; up: THREE.Vector3; fov: number };
|
|
145
|
-
readonly onLanded: () => void;
|
|
146
|
-
/** Live camera, registered at live-ready — the flight retargets to it
|
|
147
|
-
* each frame so the hand-off converges on whatever the project actually does
|
|
148
|
-
* with its camera during boot (follow rigs, SceneCamera components…). */
|
|
149
|
-
getLiveCamera: LiveCameraLookup | null;
|
|
150
|
-
/** Driving the camera: from `begin` until the game is on screen (`settle`) or Play ends. */
|
|
151
|
-
driving: boolean;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let _flight: ActiveFlight | null = null;
|
|
155
|
-
|
|
156
|
-
function begin(onLanded: () => void): boolean {
|
|
157
|
-
const viewport = viewportRig();
|
|
158
|
-
const objects = threeStoreForHost()?.objectMap;
|
|
159
|
-
const target = objects ? resolveAuthoredCameraTarget({ objectMap: objects }) : null;
|
|
160
|
-
if (!target || !viewport) return false;
|
|
161
|
-
const startPos = viewport.camera.position.clone();
|
|
162
|
-
const endPos = new THREE.Vector3().fromArray(target.position);
|
|
163
|
-
_flight = {
|
|
164
|
-
runtime: {
|
|
165
|
-
startPos,
|
|
166
|
-
startQuat: viewport.camera.quaternion.clone(),
|
|
167
|
-
startFov: viewport.camera.fov,
|
|
168
|
-
endPos,
|
|
169
|
-
endQuat: new THREE.Quaternion().fromArray(target.quaternion),
|
|
170
|
-
endFov: target.fov,
|
|
171
|
-
curve: new THREE.CatmullRomCurve3(computeFlightControlPoints(startPos, endPos)),
|
|
172
|
-
startedAt: performance.now(),
|
|
173
|
-
durationMs: flightDurationMs(startPos.distanceTo(endPos)),
|
|
174
|
-
landed: false,
|
|
175
|
-
},
|
|
176
|
-
snapshot: {
|
|
177
|
-
position: viewport.camera.position.clone(),
|
|
178
|
-
target: viewport.orbit.target.clone(),
|
|
179
|
-
up: viewport.camera.up.clone(),
|
|
180
|
-
fov: viewport.camera.fov,
|
|
181
|
-
},
|
|
182
|
-
onLanded,
|
|
183
|
-
getLiveCamera: null,
|
|
184
|
-
driving: true,
|
|
185
|
-
};
|
|
186
|
-
viewport.orbit.enabled = false;
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** After the flight, leave OrbitControls in a sane state at the landed pose
|
|
191
|
-
* so the viewport still orbits correctly during play. */
|
|
192
|
-
function settle(): void {
|
|
193
|
-
const viewport = viewportRig();
|
|
194
|
-
const active = _flight;
|
|
195
|
-
if (!active) return;
|
|
196
|
-
active.driving = false;
|
|
197
|
-
if (!viewport) return;
|
|
198
|
-
const camera = viewport.camera;
|
|
199
|
-
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
|
|
200
|
-
const distance = Math.max(1, active.snapshot.position.distanceTo(active.snapshot.target));
|
|
201
|
-
viewport.orbit.target.copy(camera.position).addScaledVector(forward, distance);
|
|
202
|
-
// The orbit re-aims with the camera's up: the landed pose's own, so its roll stands.
|
|
203
|
-
camera.up.set(0, 1, 0).applyQuaternion(camera.quaternion);
|
|
204
|
-
viewport.orbit.enabled = true;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Restore the pre-play camera pose. */
|
|
208
|
-
function end(): void {
|
|
209
|
-
const active = _flight;
|
|
210
|
-
if (!active) return;
|
|
211
|
-
_flight = null;
|
|
212
|
-
const viewport = viewportRig();
|
|
213
|
-
if (!viewport) return;
|
|
214
|
-
const snap = active.snapshot;
|
|
215
|
-
viewport.camera.position.copy(snap.position);
|
|
216
|
-
viewport.orbit.target.copy(snap.target);
|
|
217
|
-
viewport.camera.up.copy(snap.up);
|
|
218
|
-
viewport.camera.lookAt(snap.target);
|
|
219
|
-
if (viewport.camera.fov !== snap.fov) {
|
|
220
|
-
viewport.camera.fov = snap.fov;
|
|
221
|
-
viewport.camera.updateProjectionMatrix();
|
|
222
|
-
}
|
|
223
|
-
viewport.orbit.enabled = true;
|
|
224
|
-
viewport.orbit.update();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const _liveCamPos = new THREE.Vector3();
|
|
228
|
-
const _liveCamQuat = new THREE.Quaternion();
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Per-frame flight driver — called from the world root's stage's RAF loop right after
|
|
232
|
-
* `viewport.update(dt)` so the flight has final say over the camera pose.
|
|
233
|
-
*/
|
|
234
|
-
onViewportFrame(playTransitionFrame);
|
|
235
|
-
|
|
236
|
-
function playTransitionFrame(): void {
|
|
237
|
-
const active = _flight;
|
|
238
|
-
const viewport = viewportRig();
|
|
239
|
-
if (!active || !viewport || !active.driving) return;
|
|
240
|
-
const flight = active.runtime;
|
|
241
|
-
|
|
242
|
-
// Retarget to the live game camera once it exists (`track`).
|
|
243
|
-
// The kit hands the lane's lookup over unread; only an `Object3D` is a camera to converge on.
|
|
244
|
-
const found = active.getLiveCamera?.() ?? null;
|
|
245
|
-
const liveCamera = (found as Partial<THREE.Object3D> | null)?.isObject3D ? (found as THREE.Object3D) : null;
|
|
246
|
-
if (liveCamera) {
|
|
247
|
-
liveCamera.getWorldPosition(_liveCamPos);
|
|
248
|
-
liveCamera.getWorldQuaternion(_liveCamQuat);
|
|
249
|
-
if (_liveCamPos.distanceToSquared(flight.endPos) > 1e-8) {
|
|
250
|
-
flight.endPos.copy(_liveCamPos);
|
|
251
|
-
rebuildCurve(flight);
|
|
252
|
-
}
|
|
253
|
-
flight.endQuat.copy(_liveCamQuat);
|
|
254
|
-
const liveFov = (liveCamera as THREE.PerspectiveCamera).fov;
|
|
255
|
-
if (typeof liveFov === 'number') flight.endFov = liveFov;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const camera = viewport.camera;
|
|
259
|
-
if (flight.landed) {
|
|
260
|
-
// Holding/cross-fading: pin to the (possibly live-tracked) end pose.
|
|
261
|
-
camera.position.copy(flight.endPos);
|
|
262
|
-
camera.quaternion.copy(flight.endQuat);
|
|
263
|
-
if (camera.fov !== flight.endFov) {
|
|
264
|
-
camera.fov = flight.endFov;
|
|
265
|
-
camera.updateProjectionMatrix();
|
|
266
|
-
}
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const t = Math.min(1, (performance.now() - flight.startedAt) / flight.durationMs);
|
|
271
|
-
const eased = easeInOutCubic(t);
|
|
272
|
-
camera.position.copy(flight.curve.getPoint(eased));
|
|
273
|
-
camera.quaternion.slerpQuaternions(flight.startQuat, flight.endQuat, eased);
|
|
274
|
-
camera.fov = flight.startFov + (flight.endFov - flight.startFov) * eased;
|
|
275
|
-
camera.updateProjectionMatrix();
|
|
276
|
-
if (t >= 1) {
|
|
277
|
-
flight.landed = true;
|
|
278
|
-
active.onLanded();
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export function registerThreePlayCameraFlight(): () => void {
|
|
283
|
-
return registerPlayCameraFlight({
|
|
284
|
-
begin,
|
|
285
|
-
track: (liveCamera) => {
|
|
286
|
-
if (_flight) _flight.getLiveCamera = liveCamera;
|
|
287
|
-
},
|
|
288
|
-
settle,
|
|
289
|
-
end,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE SCENE'S VIEWPORT (`@volter/sdk/document-viewports`): the world
|
|
3
|
-
* stage draws the Scene through the session store's own camera, shading and
|
|
4
|
-
* grid, and frames the shell's selection. It keeps no selection of its own, so a
|
|
5
|
-
* view's selection is the shell's.
|
|
6
|
-
*/
|
|
7
|
-
import type { DocumentViewport } from '@volter/sdk/document-viewports';
|
|
8
|
-
import type { EditorShellStore } from './editor-shell-store';
|
|
9
|
-
import { viewGridVisible } from '@volter/sdk/viewport-presentation';
|
|
10
|
-
|
|
11
|
-
type ShadingMode = Parameters<EditorShellStore['setShadingMode']>[0];
|
|
12
|
-
|
|
13
|
-
const SCENE_MODES = new Set<string>(['solid', 'clay', 'unlit', 'wireframe', 'normals', 'overdraw']);
|
|
14
|
-
|
|
15
|
-
export function sceneDocumentViewport(store: EditorShellStore, documentId: string): DocumentViewport {
|
|
16
|
-
return {
|
|
17
|
-
read: () => {
|
|
18
|
-
const pose = store.cameraPose;
|
|
19
|
-
if (!pose) return null;
|
|
20
|
-
const { position, target } = pose;
|
|
21
|
-
return {
|
|
22
|
-
camera: {
|
|
23
|
-
position: { x: position.x, y: position.y, z: position.z },
|
|
24
|
-
target: { x: target.x, y: target.y, z: target.z },
|
|
25
|
-
...(pose.fov ? { fov: pose.fov } : {}),
|
|
26
|
-
},
|
|
27
|
-
diagnostic: store.shadingMode,
|
|
28
|
-
grid: viewGridVisible(documentId),
|
|
29
|
-
};
|
|
30
|
-
},
|
|
31
|
-
setDiagnostic: (diagnostic) => {
|
|
32
|
-
if (!SCENE_MODES.has(diagnostic)) return false;
|
|
33
|
-
store.setShadingMode(diagnostic as ShadingMode);
|
|
34
|
-
return true;
|
|
35
|
-
},
|
|
36
|
-
setCamera: (camera) => {
|
|
37
|
-
if (typeof camera !== 'string') {
|
|
38
|
-
store.shell.setCameraPose(camera.position, camera.target, camera.fov);
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
if (camera === 'isometric') return false;
|
|
42
|
-
store.shell.setViewPreset(camera);
|
|
43
|
-
return true;
|
|
44
|
-
},
|
|
45
|
-
frame: (target) => {
|
|
46
|
-
if (target === 'document') return false;
|
|
47
|
-
store.shell.focusOnSelection();
|
|
48
|
-
return true;
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|