@volter/editor-threejs 0.5.213 → 0.5.215

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.
@@ -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
- let changed = false;
32
- if (played && !entry.clips.has(played.name)) { entry.clips.set(played.name, played); changed = true; }
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)) { entry.clips.set(clip.name, clip); changed = true; }
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.213",
5
+ "version": "0.5.215",
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.213",
417
+ "@volter/project": "0.5.215",
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.213",
421
+ "@volter/sdk": "0.5.215",
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
- let changed = false;
39
- if (played && !entry.clips.has(played.name)) { entry.clips.set(played.name, played); changed = true; }
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)) { entry.clips.set(clip.name, clip); changed = true; }
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(), listeners: new Set(), version: 0 });
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, subscribeLiveMixers } from '@volter/editor-threejs/animation/live-mixers';
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
- const refresh = (): void => {
132
- const seen = new Set<string>();
133
- // A getter for a stage whose rendered scene is not fixed at construction:
134
- // a world stage draws the session store's scene once the world mounts.
135
- const current = typeof root === 'function' ? root() : root;
136
- current?.traverse((object) => {
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: the minting walks
109
- * (`projection/three.ts`, `R3fSourceAuthoringAdapter.indexGraph`), the authority
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 , and a toolbar is a control over ITS document, never over the
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, registerDocumentViewport } from '@volter/sdk/document-viewports';
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> | ReturnType<WorldRootStageBinding['mountWorldRootSurface']>,
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 wantsSurface = active || worldRoot;
597
- const worldRootLoadRef = useRef(content?.kind === 'world-root' ? content.load : null);
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 && !worldRoot) {
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, worldRoot]);
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
- if (!build || sourcePath === undefined) {
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
- // Initialize draw mode from the document’s file, then the restored view, then the session default. Synchronize the view to the resulting session mode.
1582
- const restoredMode = ownLayer?.drawMode ?? viewPresentationSnapshot(documentId).drawMode;
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. World-root stages rescan as their trees update.
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 (a camera flight)
1871
- // has final say over the pose and orbit damping never fights it.
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
- // A world root keeps its clock while another document holds focus.
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, worldRoot]);
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; the world root uses the editor-wide projection. label shows the view
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`); the world root's stage runs on
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 `the world root's stage`).
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
- * `the world root's stage` has mounted).
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 Scene document: the free editor camera. */
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),
@@ -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`: the rig a camera flight drives, the frame loop it rides, the helpers it
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 ; this is the door
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: the rig
54
- * a camera flight drives and the frame loop it rides. Which native surface the
55
- * viewport renders is the host's; a lane that finds no rig (a headless tab, a
56
- * project with no Three root) mounts without one.
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;
@@ -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; insertion-ordered, so
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. The stage's
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
- }