@volter/editor-game 0.5.66 → 0.5.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +1 -1
  11. package/contributions/godot.style.ts +26 -6
  12. package/contributions/godot.view.ts +6 -0
  13. package/contributions/ingest.service.ts +2 -2
  14. package/contributions/instances.command.ts +1 -1
  15. package/contributions/navmesh.menu.ts +1 -1
  16. package/contributions/network-observer.service.ts +14 -0
  17. package/contributions/play.command.ts +10 -4
  18. package/contributions/react/component-board.service.ts +1 -1
  19. package/contributions/react/design-time-mount.service.ts +1 -1
  20. package/contributions/scene-document.service.ts +3 -3
  21. package/contributions/state-watch.menu.ts +1 -1
  22. package/contributions/state-watch.utility.tsx +1 -1
  23. package/contributions/team-playtest.service.ts +4 -4
  24. package/contributions/three/component-board.service.ts +1 -1
  25. package/contributions/three/component-verbs.command.ts +6 -6
  26. package/contributions/three/three-authoring.service.ts +8 -5
  27. package/contributions/three-story-capture.service.ts +12 -0
  28. package/contributions/unity.style.ts +17 -7
  29. package/contributions/unity.view.ts +6 -0
  30. package/contributions/unreal.style.ts +10 -2
  31. package/contributions/unreal.view.ts +5 -0
  32. package/package.json +20 -10
  33. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  34. package/src/asset-budget/asset-budget-model.ts +1 -1
  35. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  36. package/src/bridge/dispatch.ts +14 -14
  37. package/src/bridge/live-frames.ts +1 -1
  38. package/src/bridge/screenshot.ts +3 -3
  39. package/src/build/BuildProfilesPanel.tsx +3 -3
  40. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  41. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  42. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  43. package/src/canvas/canvas-design-mount.ts +524 -0
  44. package/src/canvas/design-time-canvas-mount.ts +79 -0
  45. package/src/coverage/live-authoring-surface.ts +5 -5
  46. package/src/coverage/live-project-verbs.ts +1 -1
  47. package/src/coverage/native-system-coverage.ts +5 -5
  48. package/src/coverage/root-coverage.ts +1 -1
  49. package/src/coverage/session-coverage.ts +3 -3
  50. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  51. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  52. package/src/edit-mode/edit-mode-audio.ts +4 -4
  53. package/src/edit-mode/edit-mode-networking.ts +2 -2
  54. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  55. package/src/game-document/GameDocument.tsx +2 -2
  56. package/src/game-document/GamePanel.tsx +4 -4
  57. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  58. package/src/game-document/crowd-debug.ts +1 -1
  59. package/src/game-document/device-preview.ts +10 -11
  60. package/src/game-document/physics-debug.ts +1 -1
  61. package/src/generation/GenerationActivity.tsx +3 -3
  62. package/src/generation/generation-documents.tsx +5 -5
  63. package/src/generation/generation-jobs.ts +1 -1
  64. package/src/host/adapter-runtime-bindings.ts +96 -5
  65. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  66. package/src/host/authoring/gesture-persist.ts +1 -1
  67. package/src/host/authoring/ingest-data-writer.ts +1 -1
  68. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  69. package/src/host/authoring/mounted-authoring.ts +4 -4
  70. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  71. package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
  72. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  73. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  74. package/src/host/authoring/pixi-source-identity.ts +3 -3
  75. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  76. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  77. package/src/host/authoring/pixi-structure-history.ts +2 -2
  78. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  79. package/src/host/authoring/source-persistence-backend.ts +3 -3
  80. package/src/host/authoring/struct-write-pipe.ts +1 -1
  81. package/src/host/binding-resolver.ts +8 -9
  82. package/src/host/browser-transpile.ts +1 -1
  83. package/src/host/canvas-entry-runtime.ts +58 -47
  84. package/src/host/canvas-preview-frames.ts +482 -0
  85. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  86. package/src/host/components/HeaderTelemetry.tsx +4 -4
  87. package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
  88. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  89. package/src/host/components/frame-debugger-model.ts +2 -2
  90. package/src/host/components/header-telemetry-model.ts +2 -2
  91. package/src/host/components/scene-document.tsx +14 -14
  92. package/src/host/components/utility-view-state.ts +1 -1
  93. package/src/host/components/world-root-stage-binding.tsx +12 -12
  94. package/src/host/components/world-root-stage.ts +70 -49
  95. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  96. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  97. package/src/host/document-preview-three.ts +1 -1
  98. package/src/host/entry-adjudication.ts +6 -6
  99. package/src/host/game-css-scope-transform.ts +4 -0
  100. package/src/host/game-realm-page.ts +1 -1
  101. package/src/host/gameplay-export.ts +25 -14
  102. package/src/host/gameplay-recording.ts +5 -5
  103. package/src/host/gated-globals.ts +2 -2
  104. package/src/host/history/json-history-resource.ts +1 -1
  105. package/src/host/projection/pixi.ts +24 -2
  106. package/src/host/r3f-entry-runtime.ts +65 -34
  107. package/src/host/react-mount-runtime.ts +7 -48
  108. package/src/host/realm-services.ts +1 -1
  109. package/src/host/roots/canvas-root.tsx +361 -0
  110. package/src/host/roots/r3f-root.tsx +473 -0
  111. package/src/host/roots/react-root.ts +9 -43
  112. package/src/host/served-bundle-runtime-modules.ts +1 -19
  113. package/src/host/server-log-bridge.ts +2 -2
  114. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  115. package/src/host/stories/pixi-story-model.ts +30 -0
  116. package/src/host/stories/story-media-captures.ts +46 -0
  117. package/src/host/stories/story-media-presence.ts +3 -3
  118. package/src/host/stories/story-pixi-preview.ts +408 -0
  119. package/src/host/stories/story-three-preview.ts +806 -0
  120. package/src/host/stories/three-story-captures.ts +35 -0
  121. package/src/host/story-three-preview-runtime.ts +56 -0
  122. package/src/host/use-active-performance-source.ts +2 -2
  123. package/src/host/viewport-pose-memory.ts +1 -1
  124. package/src/host/viewport-root-presentation.ts +6 -5
  125. package/src/ingest/active-ingest.ts +1 -1
  126. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  127. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  128. package/src/ingest/deferred-ingest-play.ts +6 -6
  129. package/src/ingest/discovery-public-ingest.ts +2 -2
  130. package/src/ingest/ingest-boot-viewport.ts +2 -2
  131. package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
  132. package/src/ingest/ingest-canvas-scene.ts +3 -3
  133. package/src/ingest/ingest-evidence-hook.ts +1 -1
  134. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  135. package/src/ingest/ingest-play-control.ts +1 -1
  136. package/src/ingest/ingest-render-debug.ts +10 -10
  137. package/src/ingest/ingest-siblings.ts +13 -25
  138. package/src/ingest/module-mode.ts +14 -14
  139. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  140. package/src/ingest/mount-coverage.ts +2 -2
  141. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  142. package/src/ingest/mount-ingest-root.ts +8 -8
  143. package/src/ingest/mount-three-ingest-root.ts +8 -8
  144. package/src/ingest/resolve-canvas.ts +1 -1
  145. package/src/ingest/served-html-boot.ts +1 -1
  146. package/src/ingest/surface-canvas.ts +1 -1
  147. package/src/ingest/unmount-ingest-root.ts +5 -5
  148. package/src/navmesh/navmesh-handler.ts +24 -16
  149. package/src/network/NetworkInspectorPanel.tsx +449 -14
  150. package/src/network/network-inspector-model.ts +14 -2
  151. package/src/play/play-log-events.ts +1 -1
  152. package/src/play/play-mode.ts +76 -133
  153. package/src/play/play-recording.ts +1 -1
  154. package/src/play/react-play-live-authoring.ts +3 -3
  155. package/src/play/run-selection.ts +93 -0
  156. package/src/play-bar/PlayBar.tsx +20 -39
  157. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  158. package/src/profiler/PerformancePanel.tsx +3 -3
  159. package/src/react/design-time-react-mount.ts +23 -65
  160. package/src/react/dom-authoring-adapter.ts +9 -9
  161. package/src/react/react-inspector-section.tsx +5 -5
  162. package/src/react/react-world-authoring-adapter.ts +11 -11
  163. package/src/react/story-documents/story-documents.tsx +9 -9
  164. package/src/react/ui-board-document.tsx +7 -7
  165. package/src/react/ui-component-board.ts +2 -2
  166. package/src/runtime/adapter/audio-meter.ts +21 -0
  167. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  168. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  169. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  170. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  171. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  172. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  173. package/src/runtime/adapter/native-debug-module.ts +498 -0
  174. package/src/runtime/audio/bus-mixer.ts +161 -0
  175. package/src/runtime/audio/pose-guard.ts +80 -0
  176. package/src/runtime/core/frame-pacing.ts +126 -0
  177. package/src/runtime/core/game-loop.ts +225 -0
  178. package/src/runtime/core/game-scoped-slot.ts +28 -0
  179. package/src/runtime/core/seeded-random.ts +162 -0
  180. package/src/runtime/core/sim-clock.ts +391 -0
  181. package/src/runtime/core/system-runner.ts +269 -0
  182. package/src/runtime/core/types.ts +104 -0
  183. package/src/runtime/create-runtime.ts +1128 -0
  184. package/src/runtime/debug-bridge.ts +570 -0
  185. package/src/runtime/debug-registry.ts +899 -0
  186. package/src/runtime/dev/chrome-trace.ts +153 -0
  187. package/src/runtime/dev/instruments.ts +403 -0
  188. package/src/runtime/dev/logger.ts +119 -0
  189. package/src/runtime/dev/performance-profiler.ts +367 -0
  190. package/src/runtime/dev/register-render-vitals.ts +276 -0
  191. package/src/runtime/dev/render-census.ts +354 -0
  192. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  193. package/src/runtime/dev/render-memory.ts +226 -0
  194. package/src/runtime/dev/render-vitals.ts +338 -0
  195. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  196. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  197. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  198. package/src/runtime/dev-build.ts +47 -0
  199. package/src/runtime/game.ts +1636 -0
  200. package/src/runtime/gameplay-rng-trap.ts +135 -0
  201. package/src/runtime/host-context.ts +64 -0
  202. package/src/runtime/input-router.ts +169 -0
  203. package/src/runtime/mount-manifest.ts +480 -0
  204. package/src/runtime/pixi/authoring.ts +706 -0
  205. package/src/runtime/pixi/ingest.ts +116 -0
  206. package/src/runtime/pixi/physics-registry.ts +49 -0
  207. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  208. package/src/runtime/pixi/scene-capture.ts +179 -0
  209. package/src/runtime/pixi/system-adapters.ts +69 -0
  210. package/src/runtime/playtest.ts +22 -0
  211. package/src/runtime/presentation.ts +141 -0
  212. package/src/runtime/render-control.ts +642 -0
  213. package/src/runtime/render-seed.ts +77 -0
  214. package/src/runtime/run-ticks-settled.ts +73 -0
  215. package/src/runtime/setup/setup-audio.ts +72 -0
  216. package/src/services/audio-pose-guard.ts +2 -2
  217. package/src/services/game-audio.ts +152 -0
  218. package/src/services/game-network.ts +657 -0
  219. package/src/services/game-physics.ts +334 -0
  220. package/src/state-watch/StateWatchPanel.tsx +1 -1
  221. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  222. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  223. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  224. package/src/three/authoring/oid-source-persistence.ts +7 -7
  225. package/src/three/authoring/r3f-design-session.ts +58 -54
  226. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  227. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  228. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  229. package/src/three/component-verbs/extract-menu.ts +7 -6
  230. package/src/three/component-verbs/fork-menu.ts +7 -6
  231. package/src/three/component-verbs/internals-menu.ts +2 -2
  232. package/src/three/story-documents/three-story-documents.tsx +13 -13
  233. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  234. package/src/three/three-board/board-scene.ts +6 -6
  235. package/src/three/three-board/three-component-board.ts +2 -2
  236. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Off-screen `three`/R3F story preview — render a composed CSF story that
3
+ * produces an R3F world OFF-SCREEN and hand back its root `THREE.Object3D`.
4
+ * Portable CSF is a prefab's explicit declaration and its sole preview
5
+ * authority, whether or not instances happen to exist in a scene.
6
+ *
7
+ * ## Why this module exists
8
+ *
9
+ * A discoverable CSF story (`*.stories.tsx`, found by `story-discovery.ts` and
10
+ * composed by `compose-project-stories.ts`) is the author's portable "here is
11
+ * how this prefab renders." This module mounts that story's R3F content in an
12
+ * isolated fiber root and returns the resulting scene as an ordinary
13
+ * `THREE.Object3D`, which callers feed to `captureObjectAssetPreview`.
14
+ * Scene placement, authoring snapshots, and model literals are deliberately
15
+ * outside this path: they describe instances or dependencies, not the prefab's
16
+ * authored representative state.
17
+ *
18
+ * ## How the off-screen mount works (and why no real WebGL is needed to build
19
+ * the graph)
20
+ *
21
+ * This mirrors the editor's three root mount (`host/roots/r3f-root.tsx`), minus the
22
+ * engine runtime: `createRoot(canvas)` + `configure({ frameloop: 'never' })`,
23
+ * waiting for fiber's `onCreated` for the real `THREE.Scene` (React 19 gives no
24
+ * synchronous first commit). Two deliberate choices:
25
+ *
26
+ * - `extend(THREE)` first — fiber v9 tree-shakes the THREE catalogue, so a
27
+ * bare `createRoot` (no `<Canvas>`) throws "X is not part of the THREE
28
+ * namespace" on the first intrinsic (`<object3D>`, `<mesh>`, …) without it.
29
+ * This is the exact reason `r3f-adapter.tsx` calls `extend(host.three)`.
30
+ * - The renderer is a STUB (`createStubRenderer`). Under `frameloop: 'never'`
31
+ * fiber never calls `gl.render`, and the pixels are produced later by
32
+ * `captureObjectAssetPreview`, which builds its OWN `WebGLRenderer`. So the
33
+ * reconciler builds the real object graph with no WebGL context at all —
34
+ * which is what lets the mount (the load-bearing logic) run headless under
35
+ * jsdom while the snapshot stays environment-bound.
36
+ *
37
+ * `.load()` (the story's `loaders`) is awaited before the first render, the
38
+ * same contract `StoryPreviewMount.tsx` documents: Storybook 9 only populates a
39
+ * story's loaded data once `.load()` itself has run. A `three` prefab story's
40
+ * loader is where it builds the runtime context its component needs (e.g. the
41
+ * squash `Mob` story loads its `.glb` and inits Rapier there).
42
+ *
43
+ * ## What the mount photographs: the FIRST COMMIT
44
+ *
45
+ * Because the subject is whatever fiber commits first, a story that wraps its
46
+ * three content in `<Suspense>` hands back the FALLBACK — the boundary commits
47
+ * immediately with its (non-three) fallback subtree, so the story is skipped as
48
+ * "no three content" while the real children arrive on a later commit nobody is
49
+ * waiting for. `useGLTF` and drei's other suspending loaders WITHOUT a boundary
50
+ * are the shape that works: with nothing to catch it the whole root suspends,
51
+ * and the first commit is the one that already carries the loaded content.
52
+ *
53
+ * That shape has a price, and it is the other half of the same fact: an
54
+ * unboundaried suspend delays the FIRST COMMIT until the content is ready, so a
55
+ * story whose load outruns {@link ONCREATED_TIMEOUT_MS} rejects at the ceiling —
56
+ * a big asset either loads fast here or the ceiling is its limit.
57
+ *
58
+ * ## Physics: the context a naive story of a `RigidBody` component never has
59
+ *
60
+ * `@react-three/rapier`'s hooks read a React context that only `<Physics>`
61
+ * provides, so a colocated story of an ordinary gameplay prefab — the common
62
+ * case, since template/example prefabs wrap themselves in `<RigidBody>` — dies
63
+ * on `react-three-rapier: useRapier must be used within <Physics />!` the
64
+ * moment a preview surface mounts it. That is a collision between two shipped
65
+ * rules (every reusable gameplay component gets a colocated story; gameplay
66
+ * components use rapier), not a story-authoring mistake, so the mount resolves
67
+ * it: {@link mountStoryObject3D} RETRIES the mount once inside a real
68
+ * `<Physics paused>` when — and only when — the first attempt failed with
69
+ * exactly that error (see {@link isMissingPhysicsContext}).
70
+ *
71
+ * Retry rather than always-wrap, deliberately:
72
+ * - a story that needs no physics never imports rapier, never initializes its
73
+ * WASM, and mounts byte-identically to before;
74
+ * - a story that provides its OWN `<Physics>` never throws, so it never
75
+ * reaches the retry and is never double-wrapped. (Nesting is harmless
76
+ * anyway — measured: an inner `<Physics>` simply overrides the outer
77
+ * context and the tree mounts identically — but not relying on that is
78
+ * cheaper than relying on it.)
79
+ * The retry's `<Physics>` is NOT paused, and that is the whole point: every
80
+ * story mount runs the ONE bounded design-time settle below before it is handed
81
+ * to anybody, so what a surface photographs is the component's REST pose — a
82
+ * body on its suspension, a wheel the physics worker has actually placed — and
83
+ * then content time is held forever. A paused provider would freeze the world
84
+ * at a spawn pose whose wheels no worker ever positioned (measured: the
85
+ * racing-game `Vehicle` exhibit rendered wheelless).
86
+ *
87
+ * The context is only real if the `<Physics>` provider and the story's hooks
88
+ * come from the SAME `@react-three/rapier` module instance — a scaffolded
89
+ * project resolves its own copy from its own `node_modules`, which would make
90
+ * two contexts and leave the error exactly where it was. Repo-root
91
+ * `vite.config.ts`'s `resolve.dedupe` carries the package for that reason, the
92
+ * same identity contract already spelled out there for `@react-three/fiber`.
93
+ * The packaged runtime resolves both the preview renderer and this lazy
94
+ * provider through the project-rooted Vite graph, so the context identity is
95
+ * the same there too; `story-three-preview-runtime.ts` owns that seam.
96
+ */
97
+
98
+ import type { RootState } from '@react-three/fiber';
99
+ import { Component, type ReactNode } from 'react';
100
+ import * as THREE from 'three';
101
+ import { captureObjectAssetPreview } from '@volter/editor-threejs/kit/asset-preview';
102
+ import { settleDesignWorld } from '@volter/editor-threejs/kit/authoring/design-time-settle';
103
+ import { CrashNullBoundary } from '@volter/editor-sdk/kit/crash-null-boundary';
104
+ import {
105
+ resolveStoryThreePreviewRuntime,
106
+ type StoryThreePreviewRuntime,
107
+ } from '../story-three-preview-runtime';
108
+ import { viewportTimingsEnabled } from '@volter/editor-sdk/kit/viewport-activation-timings';
109
+ import { runInStoryMountTurn } from '@volter/editor-sdk/kit/stories/story-mount-turn';
110
+
111
+ /** Last mount's phase split — only written when timings are on. The board
112
+ * reads this after each attempt so a per-story row can name load vs fiber
113
+ * vs settle without threading a callback through the mount turn. */
114
+ export interface StoryMountPhaseTiming {
115
+ readonly runtimeMs: number;
116
+ readonly loadMs: number;
117
+ readonly fiberMs: number;
118
+ readonly settleMs: number;
119
+ }
120
+
121
+ let lastMountPhases: StoryMountPhaseTiming | null = null;
122
+
123
+ export function lastStoryMountPhaseTiming(): StoryMountPhaseTiming | null {
124
+ return lastMountPhases;
125
+ }
126
+
127
+ /** A composed portable story is a callable React component; Storybook attaches
128
+ * an optional `.load()` when the CSF export declares `loaders`. A plain React
129
+ * component (no `.load`) is accepted too — the load step is simply skipped. */
130
+ import type { StoryPreviewComponent } from '@volter/editor-sdk/kit/stories/story-preview-component';
131
+ export type { StoryPreviewComponent };
132
+
133
+ export interface MountedStoryObject3D {
134
+ /** The named wrapper group the story rendered INTO — a single `THREE.Object3D`
135
+ * holding exactly the story's content (see {@link PREVIEW_ROOT_NAME}). Hand
136
+ * it straight to `captureObjectAssetPreview`; framing a precise wrapper
137
+ * rather than the whole `THREE.Scene` keeps default lights/helpers fiber may
138
+ * add out of the shot. */
139
+ readonly root: THREE.Object3D;
140
+ /** The authored R3F scene around `root`, including its background,
141
+ * environment, fog, and scene-level camera declarations. */
142
+ readonly scene: THREE.Scene;
143
+ /** Advance only through an owning Asset Lab transport (animation/physics
144
+ * preview). Merely mounting a story never calls this, so Edit remains held. */
145
+ advance(deltaSeconds: number): void;
146
+ /** Tear the isolated fiber root down. Runs the story's own effect cleanups
147
+ * (a translated scene's `detachNodes`, releasing its Rapier handles). */
148
+ dispose(): void;
149
+ }
150
+
151
+ /** The name of the wrapper `<group>` every story is rendered into, so its root
152
+ * `Object3D` can be extracted unambiguously from fiber's scene. */
153
+ export const PREVIEW_ROOT_NAME = 'vgai:story-preview-root';
154
+
155
+ /** How long to wait for fiber's first commit before giving up. The LAST-RESORT
156
+ * ceiling only: a reconcile-time crash is now caught by
157
+ * {@link CrashNullBoundary} and rejects immediately (see its doc comment), so
158
+ * reaching this timeout means the story neither committed nor threw. TWO causes
159
+ * reach it, and the common one is not a bug: a story still SUSPENDED on a slow
160
+ * load (the unboundaried shape the module doc recommends holds the first commit
161
+ * until its content is ready), or a genuinely hung render. Matches the 10s the
162
+ * engine's own `r3f-adapter.tsx` uses. */
163
+ const ONCREATED_TIMEOUT_MS = 10_000;
164
+
165
+ /**
166
+ * A React fiber, just enough to walk the committed tree for a `<Physics>`
167
+ * provider. Both `@react-three/rapier` and `@react-three/cannon` export that
168
+ * name; the settle must not import either library to ask.
169
+ */
170
+ interface ReactFiberLike {
171
+ readonly type?: unknown;
172
+ readonly elementType?: unknown;
173
+ readonly memoizedProps?: unknown;
174
+ readonly child?: ReactFiberLike | null;
175
+ readonly sibling?: ReactFiberLike | null;
176
+ }
177
+
178
+ function reactComponentName(type: unknown): string {
179
+ if (typeof type === 'function') {
180
+ const named = type as { name?: string; displayName?: string };
181
+ return named.displayName || named.name || '';
182
+ }
183
+ if (type && typeof type === 'object') {
184
+ const wrapped = type as {
185
+ displayName?: string;
186
+ name?: string;
187
+ type?: unknown;
188
+ render?: unknown;
189
+ };
190
+ if (typeof wrapped.displayName === 'string' && wrapped.displayName.length > 0) {
191
+ return wrapped.displayName;
192
+ }
193
+ if (typeof wrapped.name === 'string' && wrapped.name.length > 0) return wrapped.name;
194
+ return reactComponentName(wrapped.type ?? wrapped.render);
195
+ }
196
+ return '';
197
+ }
198
+
199
+ function reactFiberOf(instance: object): ReactFiberLike | undefined {
200
+ const record = instance as Record<string, unknown>;
201
+ const fiber = record['_reactInternals'] ?? record['_reactInternalFiber'];
202
+ return fiber && typeof fiber === 'object' ? (fiber as ReactFiberLike) : undefined;
203
+ }
204
+
205
+ function physicsNodeFacts(node: ReactFiberLike): {
206
+ readonly provider: boolean;
207
+ readonly body: boolean;
208
+ readonly movingBody: boolean;
209
+ } {
210
+ const names = [reactComponentName(node.type), reactComponentName(node.elementType)];
211
+ const body = names.includes('RigidBody') || names.includes('InstancedRigidBodies');
212
+ if (!body) return { provider: names.includes('Physics'), body: false, movingBody: false };
213
+ const props =
214
+ node.memoizedProps && typeof node.memoizedProps === 'object'
215
+ ? (node.memoizedProps as Record<string, unknown>)
216
+ : {};
217
+ // Rapier's native default is dynamic. Kinematic bodies also own their pose
218
+ // and must settle; only `fixed` is inert.
219
+ return { provider: names.includes('Physics'), body: true, movingBody: props['type'] !== 'fixed' };
220
+ }
221
+
222
+ /**
223
+ * Whether this committed tree has physics that can change its design pose.
224
+ *
225
+ * A provider containing only fixed Rapier bodies has a physics subscriber,
226
+ * but there is nothing to settle: stepping it for the full "never observed
227
+ * motion" budget cost third-person's static tower/tree stories ~1.3 seconds
228
+ * apiece. Read the library's own `RigidBody type` declaration from the
229
+ * committed React tree. Unknown physics remains conservative and settles;
230
+ * only a tree whose identified bodies are ALL explicitly fixed skips.
231
+ */
232
+ function reactSubtreeNeedsPhysicsSettle(fiber: ReactFiberLike | undefined): boolean {
233
+ const stack: ReactFiberLike[] = fiber ? [fiber] : [];
234
+ const seen = new Set<ReactFiberLike>();
235
+ let hasPhysics = false;
236
+ let identifiedBody = false;
237
+ let movingBody = false;
238
+ while (stack.length > 0) {
239
+ const node = stack.pop()!;
240
+ if (seen.has(node)) continue;
241
+ seen.add(node);
242
+ const facts = physicsNodeFacts(node);
243
+ hasPhysics ||= facts.provider;
244
+ identifiedBody ||= facts.body;
245
+ movingBody ||= facts.movingBody;
246
+ if (node.child) stack.push(node.child);
247
+ if (node.sibling) stack.push(node.sibling);
248
+ }
249
+ if (!hasPhysics) return false;
250
+ return !identifiedBody || movingBody;
251
+ }
252
+
253
+ /**
254
+ * Walks the committed story tree after mount and reports whether physics can
255
+ * change its pose. Fixed-only worlds are already at their final pose.
256
+ */
257
+ class PhysicsSubscriberProbe extends Component<{
258
+ readonly onReport: (hasPhysics: boolean) => void;
259
+ readonly children?: ReactNode;
260
+ }> {
261
+ override componentDidMount(): void {
262
+ this.props.onReport(reactSubtreeNeedsPhysicsSettle(reactFiberOf(this)));
263
+ }
264
+
265
+ override render(): ReactNode {
266
+ return this.props.children ?? null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * The R3F reconciler refusing a non-`three` intrinsic — the QUALIFICATION
272
+ * REJECTION class, and the ONLY error class this module silences.
273
+ *
274
+ * A `<div>` in a story means "this is a DOM story", which is a question both 3D
275
+ * surfaces ask on purpose and answer by skipping. It is matched on the
276
+ * reconciler's own message because that message is what names the class: a
277
+ * story that DOES render three content and then throws for its own reason
278
+ * (a failed load, a bad prop) produces a different error and must still be
279
+ * reported like any other.
280
+ */
281
+ function isQualificationRejection(error: unknown): boolean {
282
+ return error instanceof Error && error.message.includes('is not part of the THREE namespace');
283
+ }
284
+
285
+ /**
286
+ * Errors the qualify / off-screen classify path produces for ITS OWN
287
+ * scaffolding — missing engine context, R3F hooks outside a Canvas — that
288
+ * must not reach the user's console as if the game threw them.
289
+ *
290
+ * "Hooks can only be used within the Canvas component" and "useGame: no Game
291
+ * in context" both leaked past {@link isQualificationRejection}: a `<Physics>`
292
+ * without a Canvas, or a story component reading the non-optional Game handle,
293
+ * fails the classify mount and Fiber's `reportError` files it as a session
294
+ * error. Both are classification-internal, the same class as the reconciler
295
+ * refusal.
296
+ */
297
+ function isQualifyInternalError(error: unknown): boolean {
298
+ if (isQualificationRejection(error)) return true;
299
+ if (!(error instanceof Error)) return false;
300
+ return (
301
+ error.message.includes('Hooks can only be used within the Canvas component') ||
302
+ error.message.includes('useGame: no Game in context')
303
+ );
304
+ }
305
+
306
+ /**
307
+ * The refusals the story CAPTURE lane may fall through to its DOM leg silently —
308
+ * "this is a DOM story", asked and answered by classification. Anything outside
309
+ * this class is a THREE story that genuinely failed, and per the contract above
310
+ * it must be NAMED where it falls through, never swallowed: an entire project's
311
+ * prefab sheet once photographed blank with the real error (`Asset preview
312
+ * source has no renderable bounds`) invisible behind a bare `catch`.
313
+ */
314
+ export function isStoryThreeClassificationRefusal(error: unknown): boolean {
315
+ return isQualifyInternalError(error);
316
+ }
317
+
318
+ /**
319
+ * `@react-three/rapier` refusing to hand out its context — the PHYSICS-CONTEXT
320
+ * class, and the one failure this module answers by mounting a second time (see
321
+ * the module doc's "Physics" section).
322
+ *
323
+ * Matched on the library's own message (`useRapier must be used within
324
+ * <Physics />`), which every rapier hook and `<RigidBody>` funnels through, for
325
+ * the same reason {@link isQualificationRejection} matches the reconciler's: the
326
+ * message is what names the class. A story that DOES have physics context and
327
+ * then fails for its own reason produces a different error and must still be
328
+ * reported.
329
+ *
330
+ * The rejection {@link mountStoryObject3DOnce} raises wraps the original
331
+ * message, so this reads the whole string rather than the `cause`.
332
+ */
333
+ function isMissingPhysicsContext(error: unknown): boolean {
334
+ return error instanceof Error && error.message.includes('useRapier must be used within <Physics');
335
+ }
336
+
337
+ /**
338
+ * The REAL `@react-three/rapier` `<Physics>` component, imported only once a
339
+ * story has proven it needs one (see {@link isMissingPhysicsContext}) — the same
340
+ * dynamic-import discipline the networking path uses, so a project with no
341
+ * physics never pulls rapier or its WASM into the editor session at all.
342
+ *
343
+ * `null` when the package cannot be resolved: nothing to retry with, so the
344
+ * caller re-throws the story's own error rather than inventing a second one.
345
+ */
346
+ async function loadPhysicsProvider(runtime: StoryThreePreviewRuntime): Promise<React.ComponentType<{
347
+ paused?: boolean;
348
+ children?: ReactNode;
349
+ }> | null> {
350
+ try {
351
+ const projectRapierPath = '/@id/@react-three/rapier';
352
+ const rapier = runtime.packaged
353
+ ? await import(/* @vite-ignore */ projectRapierPath)
354
+ : await import('@react-three/rapier');
355
+ return rapier.Physics as React.ComponentType<{ paused?: boolean; children?: ReactNode }>;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ interface ErrorReportingGlobal {
362
+ reportError?: ((error: unknown) => void) | undefined;
363
+ }
364
+
365
+ /**
366
+ * `createRoot`, with the qualification-rejection class filtered out of the
367
+ * root's error reporting.
368
+ *
369
+ * {@link CrashNullBoundary} already HANDLES that error, but handling it is not
370
+ * enough to keep it off the page: fiber pins React 19's
371
+ * `onUncaughtError`/`onCaughtError`/`onRecoverableError` to one
372
+ * `logRecoverableError` and exposes no override, and that reporter is
373
+ * `reportError` — which dispatches a real uncaught-`error` event. `the world root's stage`
374
+ * listens for exactly that and files it in `editorConsole`, so scanning a
375
+ * project's DOM stories used to leave the editor showing a red `N errors` badge
376
+ * for entirely by-design behaviour.
377
+ *
378
+ * Fiber captures that reporter SYNCHRONOUSLY inside `createRoot` (as
379
+ * `typeof reportError === 'function' ? reportError : console.error`), so the
380
+ * captured value is the one seam available — and the patch therefore lives
381
+ * exactly as long as the `createRoot` call, while the root itself keeps the
382
+ * filtering reporter for its whole life. Everything that is not a qualification
383
+ * rejection is forwarded to the reporter fiber would otherwise have captured,
384
+ * so no other failure is quietened anywhere.
385
+ *
386
+ * `retryable` extends the same containment to the physics-context class on the
387
+ * attempt that HAS a retry behind it: an unwrapped mount of a physics story is
388
+ * expected to fail and is immediately re-run inside `<Physics paused>`, so
389
+ * surfacing its first failure would put a red error badge on the editor for a
390
+ * preview that then renders perfectly — the flooded-console half of the same
391
+ * defect. The retry itself is NOT retryable, so a story that still fails under
392
+ * real physics reports exactly as loudly as any other broken story.
393
+ */
394
+ function createFilteredStoryRoot(
395
+ canvas: HTMLCanvasElement,
396
+ retryable: boolean,
397
+ createRoot: StoryThreePreviewRuntime['createR3FRoot'],
398
+ ): ReturnType<StoryThreePreviewRuntime['createR3FRoot']> {
399
+ const globals = globalThis as ErrorReportingGlobal;
400
+ const previous = globals.reportError;
401
+ const forward: (error: unknown) => void =
402
+ typeof previous === 'function'
403
+ ? previous.bind(globalThis)
404
+ : (error) => {
405
+ // biome-ignore lint/suspicious/noConsole: fiber's own fallback when the environment has no `reportError`; anything that is not a qualification rejection must still surface exactly as it would have.
406
+ console.error(error);
407
+ };
408
+ globals.reportError = (error: unknown) => {
409
+ if (isQualifyInternalError(error)) return;
410
+ if (retryable && isMissingPhysicsContext(error)) return;
411
+ forward(error);
412
+ };
413
+ try {
414
+ return createRoot(canvas);
415
+ } finally {
416
+ globals.reportError = previous;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * A do-nothing stand-in for `THREE.WebGLRenderer`, enough for fiber's
422
+ * `configure()` to accept it while `frameloop: 'never'` guarantees `render()`
423
+ * is never called. Only the members fiber touches during configure/teardown
424
+ * are present.
425
+ */
426
+ function createStubRenderer(canvas: HTMLCanvasElement): Record<string, unknown> {
427
+ return {
428
+ domElement: canvas,
429
+ xr: {
430
+ enabled: false,
431
+ addEventListener() {},
432
+ removeEventListener() {},
433
+ setAnimationLoop() {},
434
+ getSession() {
435
+ return null;
436
+ },
437
+ },
438
+ shadowMap: { enabled: false, type: THREE.PCFSoftShadowMap },
439
+ outputColorSpace: THREE.SRGBColorSpace,
440
+ toneMapping: THREE.ACESFilmicToneMapping,
441
+ setPixelRatio() {},
442
+ setSize() {},
443
+ setClearColor() {},
444
+ setClearAlpha() {},
445
+ setViewport() {},
446
+ setScissor() {},
447
+ setScissorTest() {},
448
+ setAnimationLoop() {},
449
+ clear() {},
450
+ render() {},
451
+ compile() {},
452
+ dispose() {},
453
+ getPixelRatio() {
454
+ return 1;
455
+ },
456
+ getContext() {
457
+ // This non-rendering mount owns no GPU context. An empty object lies
458
+ // to components checking for one before constructing postprocessing.
459
+ return null;
460
+ },
461
+ };
462
+ }
463
+
464
+ let catalogueExtendedWith: StoryThreePreviewRuntime['extendThree'] | null = null;
465
+
466
+ /** A mount function valid inside an already-acquired story turn. Compound
467
+ * callers receive it so they can mount repeatedly without recursively joining
468
+ * the queue and deadlocking themselves. */
469
+ export type StoryMountInTurn = (
470
+ Component: StoryPreviewComponent,
471
+ props?: Record<string, unknown>,
472
+ ) => Promise<MountedStoryObject3D>;
473
+
474
+ /**
475
+ * Run one compound story-mount operation without any other mount interleaving.
476
+ *
477
+ * The queue itself is `story-mount-turn.ts` — shared with the canvas surface's
478
+ * own preview mount, because the process-global loader state two mounts collide
479
+ * over is not per-medium.
480
+ */
481
+ export function withStoryMountTurn<T>(task: (mount: StoryMountInTurn) => Promise<T>): Promise<T> {
482
+ return runInStoryMountTurn(() => task(mountStoryObject3DInTurn));
483
+ }
484
+
485
+ /** `extend(THREE)` once per session — it merges into a module-global catalogue,
486
+ * so repeating it per mount is wasted work, and it must run before ANY three
487
+ * intrinsic reconciles (see the module doc). */
488
+ function ensureThreeCatalogue(runtime: StoryThreePreviewRuntime): void {
489
+ if (catalogueExtendedWith === runtime.extendThree) return;
490
+ catalogueExtendedWith = runtime.extendThree;
491
+ runtime.extendThree(runtime.three as unknown as Parameters<typeof runtime.extendThree>[0]);
492
+ }
493
+
494
+ /**
495
+ * Mount one composed `three`/R3F story off-screen and return the named wrapper
496
+ * group it rendered into (see {@link PREVIEW_ROOT_NAME}) plus a `dispose()`. The
497
+ * group's children are exactly what the story rendered —
498
+ * `captureObjectAssetPreview` traverses it and frames the renderable geometry.
499
+ *
500
+ * `props` are passed straight to the composed story, which is Storybook's own
501
+ * portable-story contract for overriding args at render time (partial props
502
+ * over the composed args — the same mechanism the story documents use for
503
+ * its Inspector arg edits). Omitted, the story renders with its authored args.
504
+ *
505
+ * A story whose component uses `@react-three/rapier` is mounted a second time
506
+ * inside a real `<Physics>` — see the module doc's "Physics" section for
507
+ * why that retry is the shape, and why a story carrying its own `<Physics>`
508
+ * never takes it.
509
+ *
510
+ * The returned mount is ALREADY SETTLED and its content time is held: every
511
+ * mount runs one bounded {@link settleDesignWorld} before returning. Its
512
+ * explicit `advance(dt)` handle is inert until a document transport chooses to
513
+ * call it; mounting a story never starts content time by itself.
514
+ */
515
+ export async function mountStoryObject3D(
516
+ Component: StoryPreviewComponent,
517
+ props: Record<string, unknown> = {},
518
+ ): Promise<MountedStoryObject3D> {
519
+ return withStoryMountTurn((mount) => mount(Component, props));
520
+ }
521
+
522
+ /** Dispose a standalone story mount in the same shared turn domain. Compound
523
+ * callers already holding a turn (the board) dispose their raw mounts inside
524
+ * that turn instead. */
525
+ export function disposeStoryObject3D(mounted: MountedStoryObject3D): Promise<void> {
526
+ return withStoryMountTurn(async () => mounted.dispose());
527
+ }
528
+
529
+ /** The mount body for callers that already own the process-wide turn. */
530
+ async function mountStoryObject3DInTurn(
531
+ Component: StoryPreviewComponent,
532
+ props: Record<string, unknown> = {},
533
+ ): Promise<MountedStoryObject3D> {
534
+ const timed = viewportTimingsEnabled();
535
+ lastMountPhases = null;
536
+ const t0 = timed ? Date.now() : 0;
537
+ const runtime = await resolveStoryThreePreviewRuntime();
538
+ const runtimeMs = timed ? Date.now() - t0 : 0;
539
+ // Loaders (Storybook 9 portable-story contract): must run before the first
540
+ // render or the story's `loaded` data is empty. See the module doc and
541
+ // `StoryPreviewMount.tsx`. Hoisted ABOVE the mount so the physics retry
542
+ // below re-renders the story without re-running its loaders — `.load()` is
543
+ // the story's own side-effecting setup (a `.glb` fetch, a Rapier init), and
544
+ // running it twice for one preview is work nobody asked for.
545
+ const tLoad = timed ? Date.now() : 0;
546
+ if (typeof Component.load === 'function') await Component.load();
547
+ const loadMs = timed ? Date.now() - tLoad : 0;
548
+
549
+ try {
550
+ return await mountStoryObject3DOnce(runtime, Component, props, undefined, {
551
+ timed,
552
+ runtimeMs,
553
+ loadMs,
554
+ });
555
+ } catch (error) {
556
+ if (!isMissingPhysicsContext(error)) throw error;
557
+ const Physics = await loadPhysicsProvider(runtime);
558
+ if (!Physics) throw error;
559
+ return await mountStoryObject3DOnce(
560
+ runtime,
561
+ Component,
562
+ props,
563
+ (story) => runtime.createElement(Physics, { paused: false }, story),
564
+ { timed, runtimeMs, loadMs },
565
+ );
566
+ }
567
+ }
568
+
569
+ /**
570
+ * One mount attempt. Everything {@link mountStoryObject3D} documents happens
571
+ * here; it exists separately only so the physics retry can re-run it with a
572
+ * `wrap` around the story (and without re-running the story's loaders).
573
+ */
574
+ async function mountStoryObject3DOnce(
575
+ runtime: StoryThreePreviewRuntime,
576
+ Component: StoryPreviewComponent,
577
+ props: Record<string, unknown>,
578
+ wrap?: (story: ReactNode) => ReactNode,
579
+ phases?: { timed: boolean; runtimeMs: number; loadMs: number },
580
+ ): Promise<MountedStoryObject3D> {
581
+ ensureThreeCatalogue(runtime);
582
+
583
+ // A detached canvas — never added to the document. Fiber needs a canvas
584
+ // handle for `createRoot`; nothing ever draws to it (stub renderer +
585
+ // `frameloop: 'never'`).
586
+ const canvas = document.createElement('canvas');
587
+ // An UNWRAPPED attempt always has the physics retry behind it (see
588
+ // `mountStoryObject3D`), which is exactly what makes its physics-context
589
+ // failure containable rather than reportable.
590
+ const root = createFilteredStoryRoot(canvas, wrap === undefined, runtime.createR3FRoot);
591
+
592
+ let resolveState!: (state: RootState) => void;
593
+ const statePromise = new Promise<RootState>((resolve) => {
594
+ resolveState = resolve;
595
+ });
596
+
597
+ await root.configure({
598
+ gl: (() => createStubRenderer(canvas)) as never,
599
+ frameloop: 'never',
600
+ size: { width: 128, height: 96, top: 0, left: 0 },
601
+ onCreated: (state) => resolveState(state),
602
+ });
603
+
604
+ // A reconcile crash is reported by the boundary, not by a throw fiber lets
605
+ // through (see {@link CrashNullBoundary}) — turn it into an immediate
606
+ // rejection so a non-`three` story answers in milliseconds.
607
+ let reconcileError: Error | null = null;
608
+ let rejectOnReconcileError!: (reason: Error) => void;
609
+ const reconcileFailure = new Promise<never>((_, reject) => {
610
+ rejectOnReconcileError = reject;
611
+ });
612
+ const reportReconcileError = (error: unknown): void => {
613
+ reconcileError ??= new Error(
614
+ 'mountStoryObject3D: the story threw while reconciling into an R3F root — it does not ' +
615
+ 'render `three` content (a DOM/React story), or its component crashed. Original error: ' +
616
+ String(error instanceof Error ? error.message : error),
617
+ { cause: error },
618
+ );
619
+ rejectOnReconcileError(reconcileError);
620
+ };
621
+
622
+ // Render the story INTO a named wrapper group so its root `Object3D` is
623
+ // extractable by name (point: a stable capture subject, not the whole scene).
624
+ const story = runtime.createElement(Component, props);
625
+ // The probe reports after commit whether physics can change the authored
626
+ // pose. A mesh with no provider, and a fixed-only physics world, must not pay
627
+ // the worker quiet-wait and "never observed motion" budget.
628
+ let hasPhysicsSubscriber = false;
629
+ const notePhysicsSubscriber = (hasPhysics: boolean): void => {
630
+ hasPhysicsSubscriber = hasPhysics;
631
+ };
632
+ root.render(
633
+ runtime.createElement(
634
+ 'group',
635
+ { name: PREVIEW_ROOT_NAME },
636
+ runtime.createElement(
637
+ CrashNullBoundary,
638
+ { onCaught: reportReconcileError },
639
+ runtime.createElement(
640
+ PhysicsSubscriberProbe,
641
+ { onReport: notePhysicsSubscriber },
642
+ // The physics retry's `<Physics paused>` goes INSIDE the boundary, so a
643
+ // story that still fails under it reports through the same path.
644
+ wrap ? wrap(story) : story,
645
+ ),
646
+ ),
647
+ ),
648
+ );
649
+
650
+ // Neither committing nor throwing — a story still suspended on a slow load,
651
+ // or a hung render. The last-resort ceiling (the same guard
652
+ // `r3f-adapter.tsx` uses); see {@link ONCREATED_TIMEOUT_MS}.
653
+ const tFiber = phases?.timed ? Date.now() : 0;
654
+ let timer: ReturnType<typeof setTimeout> | undefined;
655
+ const state = await Promise.race([
656
+ statePromise,
657
+ reconcileFailure,
658
+ new Promise<never>((_, reject) => {
659
+ timer = setTimeout(
660
+ () =>
661
+ reject(
662
+ new Error(
663
+ 'mountStoryObject3D: onCreated did not fire within 10s — the story never reached ' +
664
+ 'its first R3F commit and never threw. Either it is still SUSPENDED on a slow ' +
665
+ 'load (a suspending loader with no <Suspense> boundary holds the first commit ' +
666
+ 'until its content is ready), or its render is hung.',
667
+ ),
668
+ ),
669
+ ONCREATED_TIMEOUT_MS,
670
+ );
671
+ }),
672
+ ])
673
+ .catch((reason: unknown) => {
674
+ if (phases?.timed) {
675
+ lastMountPhases = {
676
+ runtimeMs: phases.runtimeMs,
677
+ loadMs: phases.loadMs,
678
+ fiberMs: Date.now() - tFiber,
679
+ settleMs: 0,
680
+ };
681
+ }
682
+ root.unmount();
683
+ throw reason;
684
+ })
685
+ .finally(() => clearTimeout(timer));
686
+
687
+ // `componentDidCatch` and `onCreated` can both fire for the SAME commit (the
688
+ // boundary's fallback commits successfully), so a resolved race does not by
689
+ // itself mean the story rendered. The captured error is the authority.
690
+ if (reconcileError) {
691
+ root.unmount();
692
+ throw reconcileError;
693
+ }
694
+
695
+ // Fiber's clock leaks wall time into `useFrame` deltas via `getDelta()` unless
696
+ // stopped. The Asset Lab host may advance this root later, but only with its
697
+ // own deterministic document delta through the `advance` handle below.
698
+ state.clock.autoStart = false;
699
+ state.clock.stop();
700
+
701
+ const previewRoot = state.scene.getObjectByName(PREVIEW_ROOT_NAME);
702
+ if (!previewRoot) {
703
+ root.unmount();
704
+ throw new Error(
705
+ `mountStoryObject3D: the story's wrapper group ("${PREVIEW_ROOT_NAME}") is not in the ` +
706
+ 'scene after its first commit — the story rendered nothing mountable.',
707
+ );
708
+ }
709
+
710
+ // THE ONE BOUNDED SETTLE (`authoring/design-time-settle.ts`) — the same
711
+ // mechanism, constants and rest test the Scene view's design session runs, on
712
+ // the same seam (the world's own tick). Without it a physics-owned story shows
713
+ // a spawn pose the game never has, and for a raycast vehicle the wheels are
714
+ // never placed at all. After it returns, nothing advances this mount again.
715
+ const fiberMs = phases?.timed ? Date.now() - tFiber : 0;
716
+ const tSettle = phases?.timed ? Date.now() : 0;
717
+ await settleDesignWorld({
718
+ scene: previewRoot,
719
+ update: (deltaSeconds) => {
720
+ state.advance(state.clock.elapsedTime + Math.min(deltaSeconds, 0.1), false);
721
+ },
722
+ hasPhysicsSubscriber,
723
+ });
724
+ if (phases?.timed) {
725
+ lastMountPhases = {
726
+ runtimeMs: phases.runtimeMs,
727
+ loadMs: phases.loadMs,
728
+ fiberMs,
729
+ settleMs: Date.now() - tSettle,
730
+ };
731
+ }
732
+
733
+ let explicitContentTime = state.clock.elapsedTime;
734
+
735
+ return {
736
+ root: previewRoot,
737
+ scene: state.scene,
738
+ advance(deltaSeconds): void {
739
+ if (!(deltaSeconds > 0) || !Number.isFinite(deltaSeconds)) return;
740
+ explicitContentTime += Math.min(deltaSeconds, 0.1);
741
+ state.advance(explicitContentTime, false);
742
+ },
743
+ dispose(): void {
744
+ root.unmount();
745
+ },
746
+ };
747
+ }
748
+
749
+ /** Options for {@link captureStoryComponentThumbnail}, mirroring the asset
750
+ * browser's own live-instance capture dimensions. */
751
+ export interface StoryThumbnailOptions {
752
+ readonly width?: number;
753
+ readonly height?: number;
754
+ /** Arg overrides for the composed story — see {@link mountStoryObject3D}'s
755
+ * `props`. A card photographed with edited args must photograph THOSE args. */
756
+ readonly props?: Record<string, unknown>;
757
+ /** Free capture camera, forwarded to `captureObjectAssetPreview` — one view
758
+ * from the chosen angle instead of the default perspective view. */
759
+ readonly camera?: import('@volter/editor-sdk').AssetPreviewCameraChoice;
760
+ /** Clip pose, forwarded to `captureObjectAssetPreview` — sample a named
761
+ * clip at a time on the capture snapshot before framing. */
762
+ readonly pose?: import('@volter/editor-sdk').AssetPreviewPose;
763
+ }
764
+
765
+ /**
766
+ * The thumbnail entry point: mount a `three` prefab's story off-screen, capture a
767
+ * perspective thumbnail from the resulting root `THREE.Object3D` via
768
+ * `captureObjectAssetPreview`, then tear the
769
+ * off-screen root down. Returns a `data:` URL.
770
+ *
771
+ * Requires a real WebGL context (`captureObjectAssetPreview` builds a
772
+ * `WebGLRenderer`), so it throws under jsdom — the caller gates on
773
+ * {@link supportsThreeStoryCapture} exactly as the asset browser gates its
774
+ * live capture, and honestly keeps the glyph when capture is unavailable.
775
+ */
776
+ export async function captureStoryComponentThumbnail(
777
+ Component: StoryPreviewComponent,
778
+ options: StoryThumbnailOptions = {},
779
+ ): Promise<string> {
780
+ const mounted = await mountStoryObject3D(Component, options.props ?? {});
781
+ try {
782
+ const capture = captureObjectAssetPreview(mounted.root, {
783
+ width: options.width ?? 128,
784
+ height: options.height ?? 96,
785
+ ...(options.camera === undefined ? {} : { camera: options.camera }),
786
+ ...(options.pose === undefined ? {} : { pose: options.pose }),
787
+ });
788
+ const image = capture.views.find((view) => view.view === 'perspective') ?? capture.views[0];
789
+ if (!image) throw new Error('Story preview capture returned no views.');
790
+ return `data:${image.mimeType};base64,${image.base64}`;
791
+ } finally {
792
+ await disposeStoryObject3D(mounted);
793
+ }
794
+ }
795
+
796
+ /** Whether a WebGL snapshot can be taken here — the same environment gate the
797
+ * asset browser applies to its live-instance capture (jsdom has no real WebGL, so
798
+ * the off-screen render still builds the graph but a snapshot cannot be
799
+ * rasterized). */
800
+ export function supportsThreeStoryCapture(): boolean {
801
+ return (
802
+ typeof WebGLRenderingContext !== 'undefined' &&
803
+ typeof navigator !== 'undefined' &&
804
+ !navigator.userAgent.toLowerCase().includes('jsdom')
805
+ );
806
+ }