@volter/editor-threejs 0.5.212 → 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.
Files changed (29) hide show
  1. package/dist-node/serving.mjs +4 -20
  2. package/package.json +3 -5
  3. package/serving/animation-live-module.ts +4 -20
  4. package/src/animation/live-mixers.ts +1 -9
  5. package/src/kit/animation/three-clips-subject.ts +10 -50
  6. package/src/kit/authoring/component-instance-root.ts +2 -3
  7. package/src/kit/components/Object3DDocumentToolbar.tsx +1 -2
  8. package/src/kit/components/Object3DDocumentViewport.tsx +2 -4
  9. package/src/kit/components/StageHost.tsx +78 -208
  10. package/src/kit/components/ViewportViewMenu.tsx +1 -1
  11. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +2 -4
  12. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +4 -6
  13. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +7 -31
  14. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +4 -6
  15. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +4 -8
  16. package/src/kit/components/asset-viewers/Object3DPreview.tsx +3 -19
  17. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +4 -8
  18. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +37 -55
  19. package/src/kit/components/object3d-contribution-surfaces.tsx +4 -2
  20. package/src/kit/components/stage-transform-tools.tsx +1 -2
  21. package/src/kit/editor-shell-store.ts +2 -26
  22. package/src/kit/editor-viewport.ts +11 -1
  23. package/src/kit/three-integration.ts +0 -2
  24. package/src/viewport-api.ts +4 -16
  25. package/src/viewport-door.ts +2 -38
  26. package/src/kit/components/world-root-binding.ts +0 -59
  27. package/src/kit/play-camera-flight.ts +0 -291
  28. package/src/kit/scene-document-viewport.ts +0 -51
  29. package/src/kit/viewport-surface-status.tsx +0 -44
@@ -3,7 +3,7 @@ import { ShellStore } from '@volter/sdk/shell-store';
3
3
  import type { ToolObject3DAuthoringProps, ToolObject3DDocumentAuthoring, ToolObject3DPreviewSource } from '../../object3d-contributions';
4
4
  import { invalidateStages, stageGeneration } from '@volter/sdk/stage-invalidation';
5
5
  import type { StageTransportSnapshot } from '@volter/sdk/host';
6
- import { EditorIcon, editorIcons, IconButton, themeVars } from '@volter/sdk/widgets';
6
+ import { EditorBanner, EditorIcon, editorIcons, IconButton, themeVars } from '@volter/sdk/widgets';
7
7
  import type { AuthoringAdapter } from '@volter/project/adapter';
8
8
  import { contentWorldBounds } from '@volter/editor-threejs/viewport/content-bounds';
9
9
  import { isInEditorOwnedSubtree } from '@volter/editor-threejs/viewport/editor-layers';
@@ -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
@@ -105,7 +103,7 @@ import {
105
103
  watchPaletteBackdrop,
106
104
  } from './standard-viewport-dressing';
107
105
  import { ViewportFurniture } from './ViewportFurniture';
108
- import { OBJECT3D_SURFACE_BUILDING, ViewportSurfaceStatus } from '../viewport-surface-status';
106
+ import { Progress } from '@volter/sdk/progress';
109
107
  import { workspaceHistoryService } from '@volter/sdk/components/workspace-history';
110
108
  import {
111
109
  bindViewPresentation,
@@ -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,44 +2238,12 @@ 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
- <div
2350
- role="alert"
2351
- style={{
2352
- position: 'absolute',
2353
- insetInline: 0,
2354
- top: 0,
2355
- display: 'flex',
2356
- justifyContent: 'center',
2357
- padding: 'var(--volter-space-3)',
2358
- pointerEvents: 'none',
2359
- }}
2360
- >
2361
- <div
2362
- className="volter-chrome-island"
2363
- style={{
2364
- display: 'flex',
2365
- alignItems: 'center',
2366
- gap: 'var(--volter-space-3)',
2367
- maxWidth: '80%',
2368
- padding: 'var(--volter-space-2) var(--volter-space-3)',
2369
- borderRadius: 'var(--volter-radius-md)',
2370
- border: `var(--volter-stroke-resting) solid ${themeVars.semantic.danger}`,
2371
- background: themeVars.surface.raised,
2372
- color: themeVars.semantic.danger,
2373
- pointerEvents: 'auto',
2374
- }}
2375
- >
2376
- <span>{error}</span>
2243
+ <EditorBanner
2244
+ tone="error"
2245
+ style={{ position: 'absolute', insetInline: 0, top: 0 }}
2246
+ actions={
2377
2247
  <IconButton
2378
2248
  size="compact"
2379
2249
  aria-label="Dismiss"
@@ -2382,13 +2252,13 @@ export function Object3DDocumentViewport({
2382
2252
  >
2383
2253
  <EditorIcon icon={editorIcons.action.close} />
2384
2254
  </IconButton>
2385
- </div>
2386
- </div>
2255
+ }
2256
+ >
2257
+ {error}
2258
+ </EditorBanner>
2387
2259
  )}
2388
- {!error && surfaceStatus === 'building' && (
2389
- <ViewportSurfaceStatus testId="object3d-document-status">
2390
- {OBJECT3D_SURFACE_BUILDING}
2391
- </ViewportSurfaceStatus>
2260
+ {!error && !chromeless && surfaceStatus === 'building' && (
2261
+ <Progress documentId={documentId} title="Preparing the viewport" />
2392
2262
  )}
2393
2263
  {/* Inset furniture by the canvas bleed so chrome stays inside the visible panel. */}
2394
2264
  <div
@@ -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({
@@ -1,5 +1,5 @@
1
1
  import { threeStateOf } from '../../three-state';
2
- import { themeVars } from '@volter/sdk/widgets';
2
+ import { StateSurface } from '@volter/sdk/widgets';
3
3
  import { liveMixerFor } from '@volter/editor-threejs/animation/live-mixers';
4
4
  import { useCallback, useSyncExternalStore } from 'react';
5
5
  import type * as THREE from 'three';
@@ -94,9 +94,7 @@ export function EntityModelDocument({
94
94
 
95
95
  if (!source)
96
96
  return (
97
- <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
98
- Scene entity {entityId} is no longer available.
99
- </div>
97
+ <StateSurface tone="error" title={`Scene entity ${entityId} is no longer available`} />
100
98
  );
101
99
  return (
102
100
  <Object3DSnapshotDocument
@@ -1,6 +1,7 @@
1
1
  import { editorHost } from '@volter/sdk/host';
2
2
  import { faCloudSun, faSliders } from '@fortawesome/free-solid-svg-icons';
3
- import { NumberInput, themeVars } from '@volter/sdk/widgets';
3
+ import { NumberInput, StateSurface, themeVars } from '@volter/sdk/widgets';
4
+ import { Progress } from '@volter/sdk/progress';
4
5
  import { useEffect, useMemo, useRef, useState } from 'react';
5
6
  import * as THREE from 'three';
6
7
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -386,9 +387,7 @@ export function EnvironmentAssetDocument({
386
387
  title={title}
387
388
  status="HDR image unavailable"
388
389
  >
389
- <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
390
- {loadError}
391
- </div>
390
+ <StateSurface tone="error" title={`Could not load ${title}`} detail={loadError} />
392
391
  </AssetEditorShell>
393
392
  );
394
393
  }
@@ -400,9 +399,8 @@ export function EnvironmentAssetDocument({
400
399
  active={active}
401
400
  type="environment image"
402
401
  title={title}
403
- status="Loading HDR image"
404
402
  >
405
- <div style={{ padding: 20, color: themeVars.content.muted }}>Loading environment…</div>
403
+ <Progress documentId={documentId} title={`Loading ${title}`} />
406
404
  </AssetEditorShell>
407
405
  );
408
406
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { beginLiveModuleRevision } from '@volter/sdk/project-module-url';
14
- import { themeVars } from '@volter/sdk/widgets';
14
+ import { EditorBanner, themeVars } from '@volter/sdk/widgets';
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import * as THREE from 'three';
17
17
  import { disposeProjectAssetModel } from '../../asset-preview';
@@ -281,37 +281,13 @@ export function LiveModuleDocument({
281
281
  * and never a toast — the document itself has to say why it is stale. */
282
282
  function BuildFailureBanner({ failure }: { readonly failure: BuildFailure }) {
283
283
  return (
284
- <div
285
- role="alert"
284
+ <EditorBanner
285
+ tone="error"
286
286
  data-testid="live-module-error"
287
- style={{
288
- position: 'absolute',
289
- top: 8,
290
- left: 8,
291
- right: 8,
292
- zIndex: 2,
293
- maxHeight: '60%',
294
- overflow: 'auto',
295
- padding: '8px 10px',
296
- borderRadius: themeVars.shape.small,
297
- background: themeVars.surface.raised,
298
- border: `1px solid ${themeVars.semantic.danger}`,
299
- color: themeVars.semantic.danger,
300
- fontFamily: themeVars.typography.mono,
301
- fontSize: 11,
302
- lineHeight: 1.5,
303
- whiteSpace: 'pre-wrap',
304
- wordBreak: 'break-word',
305
- pointerEvents: 'auto',
306
- }}
287
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 2 }}
288
+ detail={failure.stack ? `${failure.message}\n${failure.stack}` : failure.message}
307
289
  >
308
- <div style={{ fontWeight: 600 }}>
309
- This module did not build — showing the last good model.
310
- </div>
311
- <div style={{ marginTop: 4 }}>{failure.message}</div>
312
- {failure.stack ? (
313
- <div style={{ marginTop: 6, color: themeVars.content.muted }}>{failure.stack}</div>
314
- ) : null}
315
- </div>
290
+ This module did not build — showing the last good model.
291
+ </EditorBanner>
316
292
  );
317
293
  }
@@ -1,6 +1,7 @@
1
1
  import { editorHost } from '@volter/sdk/host';
2
2
  import { faCircleHalfStroke, faSliders } from '@fortawesome/free-solid-svg-icons';
3
- import { Checkbox, NumberInput, themeVars } from '@volter/sdk/widgets';
3
+ import { Checkbox, NumberInput, StateSurface, themeVars } from '@volter/sdk/widgets';
4
+ import { Progress } from '@volter/sdk/progress';
4
5
  import { EffectComposer, EffectPass, LUT3DEffect, RenderPass } from 'postprocessing';
5
6
  import { useEffect, useMemo, useRef, useState } from 'react';
6
7
  import * as THREE from 'three';
@@ -367,9 +368,7 @@ export function LutAssetDocument({
367
368
  title={basename(assetPath)}
368
369
  status="CUBE LUT unavailable"
369
370
  >
370
- <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
371
- {loadError}
372
- </div>
371
+ <StateSurface tone="error" title={`Could not load ${basename(assetPath)}`} detail={loadError} />
373
372
  </AssetEditorShell>
374
373
  );
375
374
  }
@@ -381,9 +380,8 @@ export function LutAssetDocument({
381
380
  active={active}
382
381
  type="color grade"
383
382
  title={basename(assetPath)}
384
- status="Loading CUBE LUT"
385
383
  >
386
- <div style={{ padding: 20, color: themeVars.content.muted }}>Loading color grade…</div>
384
+ <Progress documentId={documentId} title={`Loading ${basename(assetPath)}`} />
387
385
  </AssetEditorShell>
388
386
  );
389
387
  }
@@ -1,4 +1,5 @@
1
- import { themeVars } from '@volter/sdk/widgets';
1
+ import { StateSurface } from '@volter/sdk/widgets';
2
+ import { Progress } from '@volter/sdk/progress';
2
3
  import { useCallback, useEffect, useState } from 'react';
3
4
  import type * as THREE from 'three';
4
5
  import {
@@ -83,14 +84,9 @@ export function ModelAssetDocument({
83
84
  }, [assetPath, model]);
84
85
  if (error)
85
86
  return (
86
- <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
87
- {error}
88
- </div>
89
- );
90
- if (!model)
91
- return (
92
- <div style={{ padding: 20, color: themeVars.content.muted }}>Loading {displayName}…</div>
87
+ <StateSurface tone="error" title={`Could not load ${displayName}`} detail={error} />
93
88
  );
89
+ if (!model) return <Progress documentId={documentId} title={`Loading ${displayName}`} />;
94
90
  return (
95
91
  <Object3DDocumentViewport
96
92
  documentId={documentId}