@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,901 @@
1
+ /**
2
+ * The 3D board's SCENE: every qualifying `three` PREFAB mounted once, standing
3
+ * DIRECTLY ON THE FLOOR at true scale where `board-layout.ts` put it, on
4
+ * district floor pads, under one `THREE.Object3D` the caller hands to the
5
+ * Object3D document viewport. Project components that HAVE no story yet appear
6
+ * too, as reserved floor slots — see "Ghost slots" below.
7
+ *
8
+ * ## Membership — one exhibit per COMPONENT
9
+ *
10
+ * The board's unit is the component, not the story VARIANT: a prefab with five
11
+ * design-time states stands on the floor once, in the state
12
+ * `pickComponentPreviewStory` selects, and its other states live in its story
13
+ * document (a double-click away). See {@link isComponentPreviewStory} for what
14
+ * that bought and what it costs a story with no `meta.component`.
15
+ *
16
+ * ## Membership — declared `three`, then mounted as an exhibit
17
+ *
18
+ * A story is a candidate iff `declaredStoryMedium` says `three`. An
19
+ * undeclared story is a named gap and is never a candidate — the board does
20
+ * not mount to guess a medium. A declared three story is then mounted
21
+ * (`stories/story-three-preview.ts`'s `mountStoryObject3D`) as the exhibit;
22
+ * a mount that yields no content, or throws, lands in
23
+ * {@link ThreeBoardScene.skipped} with its reason (same "one bad module never
24
+ * takes the rest down" physics as `story-registry.ts`).
25
+ *
26
+ * The "holds content" half is the shared `mountedStoryHasThreeContent`
27
+ * (`stories/three-story-model.ts`). The board's mount IS the exhibit and
28
+ * must outlive the question, and the board needs the failure REASON, which
29
+ * a boolean would discard.
30
+ *
31
+ * Mounts run ONE AT A TIME, and that is a correctness requirement rather than a
32
+ * simplification. A story's loaders may touch PROCESS-GLOBAL state — the squash
33
+ * fixture's stories each init Rapier and spawn a character into a shared scene
34
+ * tree — so overlapping two mounts interleaves their setup and they collide:
35
+ * measured live, concurrent mounting made the `Player` story fail with
36
+ * "a kinematic body spawned inside geometry it collides with" and silently cost
37
+ * the board an exhibit. Serial mounting is also no slower here, because the
38
+ * thing concurrency was hiding is gone: `CrashNullBoundary` rejects a
39
+ * non-three story in MILLISECONDS instead of burning the 10s `onCreated`
40
+ * ceiling, which is what used to make a serial pass over a DOM board unusable.
41
+ * A story's own loader latency is now the only cost, and paying it in sequence
42
+ * is what keeps each story's world to itself.
43
+ *
44
+ * ## Ghost slots — the project's story-LESS 3D components
45
+ *
46
+ * The board is the project's 3D shelf, so a `three`-surface component with NO
47
+ * story must be VISIBLE AS MISSING rather than silently absent. Discovery
48
+ * reuses the Content gallery's own machinery, never a second scan: the caller
49
+ * hands in the `listProjectComponents()` index (the same source-defined
50
+ * visual-component list the Prefabs section reads), and the story↔component
51
+ * join is `pickComponentPreviewStory` (`stories/story-registry.ts`) — a
52
+ * component that join resolves is REPRESENTED by its story's exhibit; one it
53
+ * cannot resolve becomes a ghost slot in a trailing district. A ghost is a
54
+ * RESERVED RECTANGLE OF FLOOR — a dashed outline on the ground and its name
55
+ * placard, deliberately no fabricated render of the component, because the
56
+ * composed story render is the board's sole visual authority (the anti-shim
57
+ * rule): an empty patch of floor is honest, a guessed render is not.
58
+ * Double-clicking a ghost routes to the component's SOURCE (the document layer
59
+ * wires that through the editor's standing open-source affordance).
60
+ *
61
+ * ## Resource ownership (stated here, once)
62
+ *
63
+ * `ThreeBoardScene` OWNS: the district/exhibit `THREE.Group`s it creates, the
64
+ * pads and reserved-slot meshes it builds (and their
65
+ * geometries/materials), and the ordered list of per-story `dispose()` handles
66
+ * `mountStoryObject3D` returned. {@link ThreeBoardScene.dispose} is the ONE
67
+ * teardown path for all of it; it is idempotent and it never disposes anything
68
+ * the board did not create — a story's own geometry/material lifetime belongs
69
+ * to that story's fiber root, which its `dispose()` unmounts.
70
+ *
71
+ * The board is GENERATED and NEVER PERSISTED: nothing here writes, and nothing
72
+ * anywhere serializes this graph. Rebuilding from the story registry is the
73
+ * only way it comes back.
74
+ */
75
+
76
+ import { getProjectStoryRegions } from '@volter/editor-core/stories/project-story-regions';
77
+ import {
78
+ declaredStoryMedium,
79
+ reportUndeclaredStoryMedium,
80
+ } from '@volter/editor-core/stories/story-declared-medium';
81
+ import {
82
+ deriveStoryGroupPath,
83
+ formatStoryGroupPath,
84
+ storyGroupKey,
85
+ } from '@volter/editor-core/stories/story-grouping';
86
+ import { type ProjectStoryModule, pickComponentPreviewStory } from '@volter/editor-core/stories/story-registry';
87
+ import {
88
+ lastStoryMountPhaseTiming,
89
+ type MountedStoryObject3D,
90
+ type StoryMountInTurn,
91
+ type StoryPreviewComponent,
92
+ withStoryMountTurn,
93
+ } from '@volter/editor-core/stories/story-three-preview';
94
+ import { mountedStoryHasThreeContent } from '@volter/editor-core/stories/three-story-model';
95
+ import {
96
+ markViewportSegment,
97
+ noteViewportBreakdownCounts,
98
+ recordViewportStoryMount,
99
+ } from '@volter/editor-core/viewport-activation-timings';
100
+ import { collectContentNodeRecords } from '@volter/editor-threejs/viewport/content-bounds';
101
+ import { EDITOR_LAYER } from '@volter/editor-threejs/viewport/editor-layers';
102
+ import { setUserData } from '@volter/threejs-runtime/ecs/user-data';
103
+ import * as THREE from 'three';
104
+ import {
105
+ type BoardHelperKind,
106
+ type BoardHelperVolume,
107
+ type BoardNode,
108
+ frameThreeBoard,
109
+ translateBounds,
110
+ } from './board-framing';
111
+ import type { BoardBounds, BoardDistrictPlacement, BoardItemPlacement } from './board-layout';
112
+
113
+ /** `userData` key carrying an exhibit's story id, read by the picking path. */
114
+ export const BOARD_STORY_ID_KEY = 'vgaiBoardStoryId';
115
+
116
+ /** `userData` key carrying a ghost slot's component key (`<path>#<name>`),
117
+ * read by the picking path exactly like {@link BOARD_STORY_ID_KEY}. */
118
+ export const BOARD_COMPONENT_KEY = 'vgaiBoardComponentKey';
119
+
120
+ /** The district group key ghost slots share. A sentinel outside the story
121
+ * grouping model's vocabulary, so it can never collide with an authored
122
+ * group; the scene labels it, the layout only clusters by it. */
123
+ export const BOARD_GHOST_GROUP_KEY = 'vgai:board:no-story';
124
+
125
+ /** Who a given exhibit is — everything the Inspector shows for a picked object. */
126
+ export interface BoardStoryIdentity {
127
+ /** `<modulePath>#<storyName>` — stable within one project. */
128
+ readonly id: string;
129
+ /** The CSF export name, exactly as authored. */
130
+ readonly storyName: string;
131
+ /** Project-relative source module. */
132
+ readonly modulePath: string;
133
+ /** `storyGroupKey(...)` — the district this exhibit belongs to. */
134
+ readonly groupKey: string;
135
+ /** The group path's leaf — the component/document name (`Mob`, `Player`). */
136
+ readonly groupLeaf: string;
137
+ /** Human-facing group path (`UI/Button`). */
138
+ readonly groupPath: string;
139
+ /**
140
+ * What the overlay prints under this exhibit. The CSF export name alone is
141
+ * NOT it: `Default` is the conventional export, so a district of five
142
+ * components would label five different things `Default`. The leaf names the
143
+ * component, and the story's own label is appended only where one leaf
144
+ * contributes more than one exhibit — which is the only case where it
145
+ * disambiguates.
146
+ *
147
+ * That appended half is the REGISTRY's label (`ComposedProjectStory.label` —
148
+ * the authored `name`, else `compose-project-stories.ts`'s humanized export
149
+ * name), never the raw export: it is the same string every other story
150
+ * surface prints, so one story reads identically wherever it appears.
151
+ */
152
+ readonly label: string;
153
+ }
154
+
155
+ /** One placed exhibit: who it is, and where the layout put it. */
156
+ export interface BoardExhibit extends BoardStoryIdentity {
157
+ readonly placement: BoardItemPlacement;
158
+ /** Authored extent, true scale — includes helper volumes. */
159
+ readonly fullSize: readonly [number, number, number];
160
+ /** Named volumes kept out of the slot and the default camera. */
161
+ readonly helpers: readonly BoardHelperVolume[];
162
+ }
163
+
164
+ /** A helper volume on a specific exhibit, for the status line. */
165
+ export interface BoardExhibitHelper {
166
+ readonly exhibitId: string;
167
+ readonly exhibitLabel: string;
168
+ readonly kind: BoardHelperKind;
169
+ readonly size: readonly [number, number, number];
170
+ }
171
+
172
+ /**
173
+ * The narrow slice of the component index a ghost slot needs. Structurally
174
+ * satisfied by `ProjectComponentEntry` (`asset-workflow/project-content.ts`) —
175
+ * the Content gallery's own discovery output, reused rather than re-derived.
176
+ */
177
+ export interface BoardComponentRef {
178
+ readonly name: string;
179
+ /** Project-root-relative source file. */
180
+ readonly path: string;
181
+ readonly line: number;
182
+ readonly surface: string;
183
+ readonly contentKind?: string | undefined;
184
+ }
185
+
186
+ /** One storyless component's slot: who it is, and which patch of floor it
187
+ * reserves. */
188
+ export interface BoardGhostSlot {
189
+ /** `<path>#<name>` — the pick tag and the slot's layout id. */
190
+ readonly key: string;
191
+ readonly name: string;
192
+ readonly path: string;
193
+ readonly line: number;
194
+ readonly placement: BoardItemPlacement;
195
+ }
196
+
197
+ /** One district: the group, its label anchor, and the exhibits in it. */
198
+ export interface BoardDistrict {
199
+ readonly groupKey: string;
200
+ /** What the overlay prints — the group key, or `Ungrouped` for the flat case. */
201
+ readonly label: string;
202
+ readonly placement: BoardDistrictPlacement;
203
+ }
204
+
205
+ /** A story that could not become an exhibit, and why. */
206
+ export interface BoardSkippedStory {
207
+ readonly id: string;
208
+ readonly modulePath: string;
209
+ readonly storyName: string;
210
+ readonly reason: string;
211
+ }
212
+
213
+ /**
214
+ * The skip tally at the granularity that matches reality.
215
+ *
216
+ * A skip is per STORY, but the cause is almost always per MODULE: one `dom`
217
+ * board contributes every one of its stories at once, so a bare `14 not 3D`
218
+ * reads as fourteen independent failures when it is one module that simply is
219
+ * not 3D. Reporting both numbers keeps that honest for a mixed project too,
220
+ * where some modules are partly 3D.
221
+ */
222
+ export function summarizeBoardSkips(skipped: readonly BoardSkippedStory[]): {
223
+ readonly stories: number;
224
+ readonly modules: number;
225
+ } {
226
+ return {
227
+ stories: skipped.length,
228
+ modules: new Set(skipped.map((entry) => entry.modulePath)).size,
229
+ };
230
+ }
231
+
232
+ export interface ThreeBoardScene {
233
+ /** The generated content graph. Never written anywhere. */
234
+ readonly root: THREE.Object3D;
235
+ readonly exhibits: readonly BoardExhibit[];
236
+ readonly districts: readonly BoardDistrict[];
237
+ /** Storyless 3D components, each marked out as an empty reserved floor slot. */
238
+ readonly ghostSlots: readonly BoardGhostSlot[];
239
+ readonly skipped: readonly BoardSkippedStory[];
240
+ /**
241
+ * Union of every exhibit's PRESENCE box after layout — Frame with no
242
+ * selection fits this, not the authored union. Helper volumes stay in the
243
+ * graph at true scale; they just do not own the overview.
244
+ */
245
+ readonly frameBounds: BoardBounds;
246
+ /** One exhibit's placed presence: the readable first-paint camera. */
247
+ readonly openingFrameBounds: BoardBounds;
248
+ /** Helper volumes the default frame left out, named per exhibit. */
249
+ readonly helperVolumes: readonly BoardExhibitHelper[];
250
+ /** The ONE teardown path (see the module doc). Idempotent. */
251
+ dispose(): void;
252
+ }
253
+
254
+ /** The label an ungrouped district prints. */
255
+ const UNGROUPED_LABEL = 'Ungrouped';
256
+
257
+ /** The label the ghost slots' trailing district prints. */
258
+ export const GHOST_DISTRICT_LABEL = 'No story yet';
259
+
260
+ /** The nominal ground span a ghost slot reserves (metres, square). Not a claim
261
+ * about the component's size — the slot is empty — just enough floor for the
262
+ * dashed rectangle to read as a reserved place. It is FLAT: a reserved slot
263
+ * has no height, because nothing stands in it. */
264
+ const GHOST_SLOT_SPAN = 0.9;
265
+
266
+ function describeError(error: unknown): string {
267
+ return error instanceof Error ? error.message : String(error);
268
+ }
269
+
270
+ /** One story's mount attempt, resolved to either an exhibit candidate or a skip. */
271
+ interface MountAttempt {
272
+ readonly identity: BoardStoryIdentity;
273
+ readonly mounted: MountedStoryObject3D | null;
274
+ readonly reason: string | null;
275
+ }
276
+
277
+ async function attemptMount(
278
+ identity: BoardStoryIdentity,
279
+ Component: StoryPreviewComponent,
280
+ mount: StoryMountInTurn,
281
+ ): Promise<MountAttempt> {
282
+ const started = Date.now();
283
+ let mounted: MountedStoryObject3D | null = null;
284
+ let reason: string | null = null;
285
+ try {
286
+ mounted = await mount(Component);
287
+ if (!mountedStoryHasThreeContent(mounted.root)) {
288
+ // Mounted, but rendered nothing three-shaped — the honest "not a 3D
289
+ // story" outcome for a story that reconciles to an empty subtree.
290
+ mounted.dispose();
291
+ mounted = null;
292
+ reason = 'The story mounted no three content.';
293
+ }
294
+ } catch (error) {
295
+ reason = describeError(error);
296
+ }
297
+ const phases = lastStoryMountPhaseTiming();
298
+ recordViewportStoryMount({
299
+ id: identity.id,
300
+ ms: Date.now() - started,
301
+ runtimeMs: phases?.runtimeMs ?? 0,
302
+ loadMs: phases?.loadMs ?? 0,
303
+ fiberMs: phases?.fiberMs ?? 0,
304
+ settleMs: phases?.settleMs ?? 0,
305
+ ok: mounted !== null,
306
+ });
307
+ return { identity, mounted, reason };
308
+ }
309
+
310
+ // ---------------------------------------------------------------- furniture
311
+
312
+ /** Marks one object (and its subtree) as editor chrome per `editor-layers.ts`:
313
+ * `EDITOR_LAYER` is ENABLED (not set) so layer 0 stays on and the document
314
+ * host's own lights still light it. */
315
+ function markChrome(object: THREE.Object3D): void {
316
+ object.traverse((child) => {
317
+ child.layers.enable(EDITOR_LAYER);
318
+ setUserData(child, 'editorHelper', true);
319
+ });
320
+ }
321
+
322
+ /**
323
+ * THE FLOOR RULE — the board's one anti-z-fighting mechanism, stated once.
324
+ *
325
+ * Everything the board puts on the ground wants the SAME plane, y = 0: every
326
+ * exhibit's base (rule 2), the district pad under it, and a reserved slot's
327
+ * outline and hit plane. That is not incidental — a story's world very often
328
+ * carries a flat ground as its lowest geometry, so grounding an exhibit lands a
329
+ * 180 m plane exactly on the pad's top face. Two coplanar surfaces is what
330
+ * shimmers at grazing angles.
331
+ *
332
+ * The separation is a DEPTH-BUFFER offset on the pad's own material
333
+ * (`polygonOffset`), not a geometric gap — and that choice was MEASURED, not
334
+ * assumed. A geometric epsilon was tried first: 5 mm, which is invisible on a
335
+ * decimetre prop and comfortably above depth resolution at the distances a
336
+ * small district is viewed from. It failed on the starter template's own board,
337
+ * whose `Ground` story is a 180 m plane: framing a district that wide puts the
338
+ * camera hundreds of metres out, where one depth-buffer step is tens of
339
+ * millimetres, and the pad and the plane fought in broad bands of constant
340
+ * depth. No fixed epsilon can be right for a surface whose whole product claim
341
+ * is that it holds a 0.1 m prop and a 180 m ground plane at once, and a
342
+ * scale-derived epsilon buys the far exhibit's correctness by visibly floating
343
+ * the near one off the same pad.
344
+ *
345
+ * `polygonOffset` is exactly the scale-invariant version of the same idea: it
346
+ * biases in units of the depth buffer's own resolution AT THAT FRAGMENT, so
347
+ * "one step behind" means one step at 3 m and one step at 300 m. It costs
348
+ * nothing elsewhere because the pad is CHROME — picks skip editor-owned
349
+ * subtrees, and it only ever receives shadows.
350
+ *
351
+ * So: everything on the board's floor is at y = 0, and the pad is the one thing
352
+ * that yields. The dressing's ground grid keeps its own -0.02 (that module owns
353
+ * it), still occluded inside the pad slab.
354
+ */
355
+ const PAD_MATERIAL_PARAMS = {
356
+ color: 0x323b49,
357
+ roughness: 0.92,
358
+ metalness: 0.04,
359
+ polygonOffset: true,
360
+ polygonOffsetFactor: 1,
361
+ polygonOffsetUnits: 2,
362
+ };
363
+
364
+ /** The pad slab's thickness; its TOP face is the exhibit datum y = 0. */
365
+ const PAD_THICKNESS = 0.06;
366
+ const PAD_TOP_Y = 0;
367
+
368
+ /** A reserved slot lies flat ON the exhibit datum — that is where the component
369
+ * WOULD stand. Both its hit plane and its outline share it; the pad beneath
370
+ * yields to them by the rule above. */
371
+ const RESERVED_SLOT_Y = 0;
372
+ const GHOST_LINE_COLOR = 0xa9bdd6;
373
+ const GHOST_FILL_COLOR = 0x8fa3bd;
374
+
375
+ /** One district's floor pad. Editor chrome. */
376
+ function createDistrictPad(district: BoardDistrictPlacement): THREE.Mesh {
377
+ const width = district.extent.maxX - district.extent.minX;
378
+ const depth = district.extent.maxZ - district.extent.minZ;
379
+ const pad = new THREE.Mesh(
380
+ new THREE.BoxGeometry(width, PAD_THICKNESS, depth),
381
+ new THREE.MeshStandardMaterial(PAD_MATERIAL_PARAMS),
382
+ );
383
+ pad.name = `vgai:board-pad:${district.groupKey || UNGROUPED_LABEL}`;
384
+ pad.position.set(
385
+ (district.extent.minX + district.extent.maxX) / 2,
386
+ PAD_TOP_Y - PAD_THICKNESS / 2,
387
+ (district.extent.minZ + district.extent.maxZ) / 2,
388
+ );
389
+ pad.receiveShadow = true;
390
+ markChrome(pad);
391
+ return pad;
392
+ }
393
+
394
+ /** Dashed outline of a `width × depth` rectangle lying in the ground plane,
395
+ * centred at the origin. Four explicit segments rather than `EdgesGeometry`
396
+ * over a degenerate box: a zero-height box has coincident top and bottom
397
+ * loops, which is the very artifact this whole module is removing. */
398
+ function dashedFloorRectangle(width: number, depth: number): THREE.LineSegments {
399
+ const x = width / 2;
400
+ const z = depth / 2;
401
+ const corners: readonly (readonly [number, number])[] = [
402
+ [-x, -z],
403
+ [x, -z],
404
+ [x, z],
405
+ [-x, z],
406
+ ];
407
+ const positions: number[] = [];
408
+ for (let index = 0; index < corners.length; index++) {
409
+ const from = corners[index]!;
410
+ const to = corners[(index + 1) % corners.length]!;
411
+ positions.push(from[0], 0, from[1], to[0], 0, to[1]);
412
+ }
413
+ const geometry = new THREE.BufferGeometry();
414
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
415
+ const outline = new THREE.LineSegments(
416
+ geometry,
417
+ new THREE.LineDashedMaterial({ color: GHOST_LINE_COLOR, dashSize: 0.09, gapSize: 0.06 }),
418
+ );
419
+ outline.computeLineDistances();
420
+ return outline;
421
+ }
422
+
423
+ /**
424
+ * One ghost slot: a dashed rectangle of RESERVED FLOOR, flat on the ground,
425
+ * and nothing standing in it. NOT chrome — it is pickable content whose
426
+ * double-click routes to the component's source, so it carries
427
+ * {@link BOARD_COMPONENT_KEY} and stays out of the editor-owned layer (picks
428
+ * skip editor-owned subtrees). The barely-tinted hit plane exists because a
429
+ * raycast needs a surface to land on; it depicts nothing.
430
+ */
431
+ function createGhostSlot(slot: BoardGhostSlot): THREE.Object3D {
432
+ const group = new THREE.Group();
433
+ group.name = `vgai:board-ghost:${slot.name}`;
434
+ group.userData[BOARD_COMPONENT_KEY] = slot.key;
435
+
436
+ const [width, depth] = slot.placement.footprint;
437
+ const hit = new THREE.Mesh(
438
+ new THREE.PlaneGeometry(width, depth),
439
+ new THREE.MeshBasicMaterial({
440
+ color: GHOST_FILL_COLOR,
441
+ transparent: true,
442
+ opacity: 0.1,
443
+ depthWrite: false,
444
+ side: THREE.DoubleSide,
445
+ }),
446
+ );
447
+ hit.rotation.x = -Math.PI / 2;
448
+ hit.position.y = RESERVED_SLOT_Y;
449
+
450
+ const outline = dashedFloorRectangle(width, depth);
451
+ outline.position.y = RESERVED_SLOT_Y;
452
+
453
+ group.add(hit, outline);
454
+ group.position.set(slot.placement.anchor[0], 0, slot.placement.anchor[2]);
455
+ return group;
456
+ }
457
+
458
+ // ---------------------------------------------------------------- candidates
459
+
460
+ interface BoardCandidate {
461
+ readonly identity: BoardStoryIdentity;
462
+ readonly Component: StoryPreviewComponent;
463
+ }
464
+
465
+ /**
466
+ * ONE EXHIBIT PER PREFAB — is this story the one that represents its component
467
+ * on the board?
468
+ *
469
+ * The board's unit is the COMPONENT, not the variant. A component with five
470
+ * design-time states is still one thing standing in the museum; its other
471
+ * states live in its story document, which a double-click opens. Rendering a
472
+ * variant each put the same prefab on the floor N times, and — measured on the
473
+ * vendored racing-game — put two Vehicles there, one of them authored at its
474
+ * GAME position (`[-110, 0.75, 220]`), so that exhibit's bounds were computed
475
+ * around content ~240 units from its own slot and the layout reserved a
476
+ * district-sized hole for a car.
477
+ *
478
+ * The join is `pickComponentPreviewStory` — the same association the Content
479
+ * gallery's cards and `collectStorylessComponents` below already use, so no
480
+ * surface can disagree with another about which state represents a component.
481
+ * It is passed the story's OWN module path as the component path, which is
482
+ * what makes it resolve within one source directory: colocation is the shipped
483
+ * convention (a prefab and its story sit together), so two same-named
484
+ * components in different folders each keep their own exhibit instead of one
485
+ * silently swallowing the other.
486
+ *
487
+ * A story whose CSF declares no `meta.component` is joined to nothing and
488
+ * therefore represents only itself — it keeps its own exhibit rather than
489
+ * being dropped.
490
+ */
491
+ function isComponentPreviewStory(
492
+ modules: readonly ProjectStoryModule[],
493
+ modulePath: string,
494
+ story: { name: string; componentName?: string },
495
+ ): boolean {
496
+ if (!story.componentName) return true;
497
+ const picked = pickComponentPreviewStory(modules, story.componentName, modulePath);
498
+ return !picked || (picked.modulePath === modulePath && picked.name === story.name);
499
+ }
500
+
501
+ /** The composed stories that REPRESENT a component (see
502
+ * {@link isComponentPreviewStory}), with each district resolved through the
503
+ * shared story-grouping model (authored CSF title first, then the module path
504
+ * — `story-grouping.ts` owns that precedence). */
505
+ /** How many stories this board will visit — the honest N for the building copy. */
506
+ export function threeBoardCandidateCount(modules: readonly ProjectStoryModule[]): number {
507
+ return collectCandidates(modules).length;
508
+ }
509
+
510
+ function collectCandidates(modules: readonly ProjectStoryModule[]): BoardCandidate[] {
511
+ const draft: {
512
+ identity: Omit<BoardStoryIdentity, 'label'>;
513
+ /** The registry's own human-facing story label — see {@link BoardStoryIdentity.label}. */
514
+ storyLabel: string;
515
+ Component: StoryPreviewComponent;
516
+ }[] = [];
517
+ const perLeaf = new Map<string, number>();
518
+ const regions = getProjectStoryRegions();
519
+ for (const module_ of modules) {
520
+ if (!module_.ok) continue;
521
+ for (const story of module_.stories) {
522
+ const declared = declaredStoryMedium({ modulePath: module_.modulePath, regions });
523
+ if (declared.medium !== 'three') {
524
+ if (declared.via === 'undeclared') {
525
+ reportUndeclaredStoryMedium(module_.modulePath, declared.reason);
526
+ }
527
+ continue;
528
+ }
529
+ if (!isComponentPreviewStory(modules, module_.modulePath, story)) continue;
530
+ const group = deriveStoryGroupPath({
531
+ modulePath: module_.modulePath,
532
+ ...(story.title === undefined ? {} : { title: story.title }),
533
+ });
534
+ const groupKey = storyGroupKey(group);
535
+ const leafKey = `${groupKey}/${group.leaf}`;
536
+ perLeaf.set(leafKey, (perLeaf.get(leafKey) ?? 0) + 1);
537
+ draft.push({
538
+ identity: {
539
+ id: `${module_.modulePath}#${story.name}`,
540
+ storyName: story.name,
541
+ modulePath: module_.modulePath,
542
+ groupKey,
543
+ groupLeaf: group.leaf,
544
+ groupPath: formatStoryGroupPath(group),
545
+ },
546
+ storyLabel: story.label,
547
+ Component: story.Component as unknown as StoryPreviewComponent,
548
+ });
549
+ }
550
+ }
551
+ return draft.map(({ identity, storyLabel, Component }) => ({
552
+ identity: {
553
+ ...identity,
554
+ label:
555
+ (perLeaf.get(`${identity.groupKey}/${identity.groupLeaf}`) ?? 0) > 1
556
+ ? `${identity.groupLeaf} · ${storyLabel}`
557
+ : identity.groupLeaf,
558
+ },
559
+ Component,
560
+ }));
561
+ }
562
+
563
+ /**
564
+ * The `three`-surface components no story represents — the ghost-slot set.
565
+ *
566
+ * The join is the Content gallery's own (`pickComponentPreviewStory`), so the
567
+ * two surfaces can never disagree about which components "have" a story: the
568
+ * gallery admits exactly the components this filter drops. Inline-SVG entries
569
+ * are image assets, not components (`contentKind`), and non-`three` surfaces
570
+ * belong to other boards.
571
+ */
572
+ function collectStorylessComponents(
573
+ modules: readonly ProjectStoryModule[],
574
+ components: readonly BoardComponentRef[],
575
+ ): BoardComponentRef[] {
576
+ const seen = new Set<string>();
577
+ const storyless: BoardComponentRef[] = [];
578
+ for (const component of components) {
579
+ if (component.surface !== 'three') continue;
580
+ if (component.contentKind === 'image') continue;
581
+ const key = `${component.path}#${component.name}`;
582
+ if (seen.has(key)) continue;
583
+ seen.add(key);
584
+ if (pickComponentPreviewStory(modules, component.name, component.path)) continue;
585
+ storyless.push(component);
586
+ }
587
+ return storyless;
588
+ }
589
+
590
+ /** One exhibit's wrapper: the placement translation, and the story tag the
591
+ * picking path reads back. The mounted root keeps its own authored transform,
592
+ * so nothing here touches the exhibit's true scale. */
593
+ function createExhibitGroup(
594
+ identity: BoardStoryIdentity,
595
+ placement: BoardItemPlacement,
596
+ mounted: MountedStoryObject3D,
597
+ ): THREE.Object3D {
598
+ const exhibit = new THREE.Group();
599
+ exhibit.name = `vgai:board-exhibit:${identity.label}`;
600
+ exhibit.position.set(...(placement.translation as [number, number, number]));
601
+ exhibit.add(mounted.root);
602
+ exhibit.userData[BOARD_STORY_ID_KEY] = identity.id;
603
+ return exhibit;
604
+ }
605
+
606
+ /**
607
+ * Tear a built board down, taking a shared story-mount turn so its stories'
608
+ * effect cleanups never run inside another build's mounts. `dispose()` is
609
+ * idempotent, so this is safe even when the scene is already gone.
610
+ */
611
+ export function disposeThreeBoard(scene: ThreeBoardScene): Promise<void> {
612
+ return withStoryMountTurn(async () => {
613
+ scene.dispose();
614
+ });
615
+ }
616
+
617
+ /**
618
+ * Build the whole board from a story-registry snapshot plus the project's
619
+ * component index (the Content gallery's `listProjectComponents()` output —
620
+ * see "Ghost slots" in the module doc). Resolves once every story has either
621
+ * become an exhibit or been recorded as skipped, and every storyless `three`
622
+ * component has its ghost slot.
623
+ *
624
+ * Builds never overlap ({@link withStoryMountTurn}). A `signal` supersedes this
625
+ * build: it stops mounting further stories and disposes whatever it already
626
+ * mounted INSIDE its own turn, then rejects — so the caller never receives a
627
+ * scene it would have to tear down out of turn.
628
+ */
629
+ export async function buildThreeBoard(
630
+ modules: readonly ProjectStoryModule[],
631
+ components: readonly BoardComponentRef[] = [],
632
+ signal?: AbortSignal,
633
+ ): Promise<ThreeBoardScene> {
634
+ return withStoryMountTurn((mount) => assembleThreeBoard(modules, components, signal, mount));
635
+ }
636
+
637
+ /** Mount each candidate in turn, stopping the moment the build is superseded. */
638
+ async function mountEveryCandidate(
639
+ candidates: readonly BoardCandidate[],
640
+ signal: AbortSignal | undefined,
641
+ mount: StoryMountInTurn,
642
+ ): Promise<MountAttempt[]> {
643
+ const attempts: MountAttempt[] = [];
644
+ for (const { identity, Component } of candidates) {
645
+ // What a superseded build already mounted is released by its caller,
646
+ // through the scene's own single teardown path.
647
+ if (signal?.aborted) break;
648
+ attempts.push(await attemptMount(identity, Component, mount));
649
+ }
650
+ return attempts;
651
+ }
652
+
653
+ function box3ToBounds(box: THREE.Box3): BoardBounds {
654
+ return { min: box.min.toArray(), max: box.max.toArray() };
655
+ }
656
+
657
+ /** Split mount attempts into the exhibits to place and the skips to report. */
658
+ function sortAttempts(attempts: readonly MountAttempt[]): {
659
+ mounts: Map<string, MountedStoryObject3D>;
660
+ identities: Map<string, BoardStoryIdentity>;
661
+ measures: { id: string; groupKey: string; nodes: BoardNode[] }[];
662
+ skipped: BoardSkippedStory[];
663
+ } {
664
+ const mounts = new Map<string, MountedStoryObject3D>();
665
+ const identities = new Map<string, BoardStoryIdentity>();
666
+ const measures: { id: string; groupKey: string; nodes: BoardNode[] }[] = [];
667
+ const skipped: BoardSkippedStory[] = [];
668
+ for (const attempt of attempts) {
669
+ if (!attempt.mounted) {
670
+ skipped.push({
671
+ id: attempt.identity.id,
672
+ modulePath: attempt.identity.modulePath,
673
+ storyName: attempt.identity.storyName,
674
+ reason: attempt.reason ?? 'The story produced no Object3D.',
675
+ });
676
+ continue;
677
+ }
678
+ const nodes = collectContentNodeRecords(attempt.mounted.root).map((record) => ({
679
+ bounds: box3ToBounds(record.box),
680
+ overlay: record.overlay,
681
+ enclosure: record.enclosure,
682
+ }));
683
+ mounts.set(attempt.identity.id, attempt.mounted);
684
+ identities.set(attempt.identity.id, attempt.identity);
685
+ measures.push({
686
+ id: attempt.identity.id,
687
+ groupKey: attempt.identity.groupKey,
688
+ nodes,
689
+ });
690
+ }
691
+ return { mounts, identities, measures, skipped };
692
+ }
693
+
694
+ /** A ghost slot reserves a nominal, FLAT patch of ground; the layout's own
695
+ * slot math then gives it the same breathing room a real exhibit of that
696
+ * footprint would get. */
697
+ function ghostMeasure(component: BoardComponentRef): {
698
+ id: string;
699
+ groupKey: string;
700
+ nodes: BoardBounds[];
701
+ } {
702
+ const half = GHOST_SLOT_SPAN / 2;
703
+ return {
704
+ id: `${component.path}#${component.name}`,
705
+ groupKey: BOARD_GHOST_GROUP_KEY,
706
+ nodes: [{ min: [-half, 0, -half], max: [half, 0, half] }],
707
+ };
708
+ }
709
+
710
+ async function assembleThreeBoard(
711
+ modules: readonly ProjectStoryModule[],
712
+ components: readonly BoardComponentRef[],
713
+ signal: AbortSignal | undefined,
714
+ mount: StoryMountInTurn,
715
+ ): Promise<ThreeBoardScene> {
716
+ const tCandidates = Date.now();
717
+ const candidates = collectCandidates(modules);
718
+ markViewportSegment('candidate-collect', Date.now() - tCandidates);
719
+ noteViewportBreakdownCounts({ candidateCount: candidates.length });
720
+
721
+ const tMounts = Date.now();
722
+ const attempts = await mountEveryCandidate(candidates, signal, mount);
723
+ markViewportSegment('story-mounts', Date.now() - tMounts);
724
+
725
+ const tBounds = Date.now();
726
+ const { mounts, identities, measures, skipped } = sortAttempts(attempts);
727
+ markViewportSegment('bounds', Date.now() - tBounds);
728
+
729
+ const tLayout = Date.now();
730
+ const storyless = collectStorylessComponents(modules, components);
731
+ const ghostByKey = new Map<string, BoardComponentRef>(
732
+ storyless.map((component) => [`${component.path}#${component.name}`, component]),
733
+ );
734
+ // Ghost items APPENDED, so their sentinel group first appears last and the
735
+ // layout's first-appearance rule puts the ghost district behind every real
736
+ // one — a trailing "still to do" shelf, never interleaved with the museum.
737
+ // Layout AND the default camera use each exhibit's PRESENCE box, so an
738
+ // authored overlay volume (the lighthouse beam) or a 180 m ground plane
739
+ // cannot own the opening view.
740
+ const framed = frameThreeBoard([...measures, ...storyless.map(ghostMeasure)]);
741
+ const layout = framed.layout;
742
+ const framedById = new Map(framed.exhibits.map((entry) => [entry.id, entry]));
743
+ markViewportSegment('layout', Date.now() - tLayout);
744
+ const tAssemble = Date.now();
745
+
746
+ const root = new THREE.Group();
747
+ root.name = 'vgai:three-board';
748
+ const chrome = new THREE.Group();
749
+ chrome.name = 'vgai:board-chrome';
750
+ markChrome(chrome);
751
+ root.add(chrome);
752
+
753
+ const exhibits: BoardExhibit[] = [];
754
+ const districts: BoardDistrict[] = [];
755
+ const ghostSlots: BoardGhostSlot[] = [];
756
+ /** Everything the board itself built (chrome furniture + ghost meshes) —
757
+ * NEVER an exhibit wrapper, whose subtree is a story's own graph and whose
758
+ * geometry lifetime belongs to that story's fiber root. */
759
+ const boardOwned: THREE.Object3D[] = [chrome];
760
+
761
+ for (const district of layout.districts) {
762
+ const ghostDistrict = district.groupKey === BOARD_GHOST_GROUP_KEY;
763
+ const districtGroup = new THREE.Group();
764
+ districtGroup.name = `vgai:board-district:${
765
+ ghostDistrict ? GHOST_DISTRICT_LABEL : district.groupKey || UNGROUPED_LABEL
766
+ }`;
767
+ root.add(districtGroup);
768
+ chrome.add(createDistrictPad(district));
769
+
770
+ for (const placement of district.items) {
771
+ if (ghostDistrict) {
772
+ const component = ghostByKey.get(placement.id);
773
+ if (!component) continue;
774
+ const slot: BoardGhostSlot = {
775
+ key: placement.id,
776
+ name: component.name,
777
+ path: component.path,
778
+ line: component.line,
779
+ placement,
780
+ };
781
+ const ghost = createGhostSlot(slot);
782
+ districtGroup.add(ghost);
783
+ boardOwned.push(ghost);
784
+ ghostSlots.push(slot);
785
+ continue;
786
+ }
787
+ const mounted = mounts.get(placement.id);
788
+ const identity = identities.get(placement.id);
789
+ const framedExhibit = framedById.get(placement.id);
790
+ if (!mounted || !identity || !framedExhibit) continue;
791
+ districtGroup.add(createExhibitGroup(identity, placement, mounted));
792
+ exhibits.push({
793
+ ...identity,
794
+ placement,
795
+ fullSize: [
796
+ framedExhibit.full.max[0] - framedExhibit.full.min[0],
797
+ framedExhibit.full.max[1] - framedExhibit.full.min[1],
798
+ framedExhibit.full.max[2] - framedExhibit.full.min[2],
799
+ ],
800
+ helpers: framedExhibit.helpers,
801
+ });
802
+ }
803
+
804
+ districts.push({
805
+ groupKey: district.groupKey,
806
+ label: ghostDistrict ? GHOST_DISTRICT_LABEL : district.groupKey || UNGROUPED_LABEL,
807
+ placement: district,
808
+ });
809
+ }
810
+
811
+ const helperVolumes: BoardExhibitHelper[] = exhibits.flatMap((exhibit) =>
812
+ exhibit.helpers.map((helper) => ({
813
+ exhibitId: exhibit.id,
814
+ exhibitLabel: exhibit.label,
815
+ kind: helper.kind,
816
+ size: helper.size,
817
+ })),
818
+ );
819
+
820
+ let disposed = false;
821
+ const scene: ThreeBoardScene = {
822
+ root,
823
+ exhibits,
824
+ districts,
825
+ ghostSlots,
826
+ skipped,
827
+ frameBounds: framed.frame,
828
+ openingFrameBounds: framed.exhibits[0]
829
+ ? translateBounds(framed.exhibits[0].presence, framed.exhibits[0].placement.translation)
830
+ : framed.frame,
831
+ helperVolumes,
832
+ dispose(): void {
833
+ if (disposed) return;
834
+ disposed = true;
835
+ // Board-built geometry first: the board created it, so the board frees
836
+ // it. `boardOwned` never contains an exhibit wrapper, so a mounted
837
+ // story's own graph is never walked here.
838
+ for (const owned of boardOwned) {
839
+ owned.traverse((object) => {
840
+ const mesh = object as THREE.Mesh;
841
+ mesh.geometry?.dispose();
842
+ const material = mesh.material;
843
+ if (Array.isArray(material)) for (const entry of material) entry.dispose();
844
+ else material?.dispose();
845
+ });
846
+ owned.removeFromParent();
847
+ }
848
+ // Then every story mount's OWN dispose — the fiber root that built it is
849
+ // the only thing entitled to free its geometries and materials.
850
+ for (const mounted of mounts.values()) {
851
+ try {
852
+ mounted.dispose();
853
+ } catch (error) {
854
+ // biome-ignore lint/suspicious/noConsole: a failed unmount must stay diagnosable without stranding the remaining mounts
855
+ console.error('[three-board] a story mount failed to dispose.', error);
856
+ }
857
+ }
858
+ mounts.clear();
859
+ root.clear();
860
+ },
861
+ };
862
+
863
+ // The LAST thing inside the turn: a superseded build hands back nothing, and
864
+ // frees what it mounted here rather than leaving the caller to do it after
865
+ // the turn has passed to the next build.
866
+ markViewportSegment('assemble', Date.now() - tAssemble);
867
+ noteViewportBreakdownCounts({
868
+ exhibitCount: exhibits.length,
869
+ skippedCount: skipped.length,
870
+ });
871
+
872
+ if (signal?.aborted) {
873
+ scene.dispose();
874
+ throw new Error('The 3D board build was superseded by a newer one.');
875
+ }
876
+ return scene;
877
+ }
878
+
879
+ /**
880
+ * The story id of the exhibit a picked object belongs to, or `null` when the
881
+ * pick landed on furniture or outside the board. Walks ancestry because a pick
882
+ * hits a leaf mesh, and the identity is tagged on the exhibit wrapper.
883
+ */
884
+ export function boardStoryIdForObject(object: THREE.Object3D | null): string | null {
885
+ return tagForObject(object, BOARD_STORY_ID_KEY);
886
+ }
887
+
888
+ /** The ghost slot's component key a picked object belongs to, or `null`. */
889
+ export function boardComponentKeyForObject(object: THREE.Object3D | null): string | null {
890
+ return tagForObject(object, BOARD_COMPONENT_KEY);
891
+ }
892
+
893
+ function tagForObject(object: THREE.Object3D | null, key: string): string | null {
894
+ let current: THREE.Object3D | null = object;
895
+ while (current) {
896
+ const value = current.userData[key];
897
+ if (typeof value === 'string') return value;
898
+ current = current.parent;
899
+ }
900
+ return null;
901
+ }