@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,888 @@
1
+ import { registerAvailableWorkspaceDocument } from '@volter/editor-core/workspace-available-documents';
2
+ /**
3
+ * The 3D COMPONENTS board — a generated, never-persisted 3D scene laying out
4
+ * every qualifying `three` story's mounted `Object3D` at TRUE SCALE, so a
5
+ * player-sized prop and a building can be compared by eye instead of by two
6
+ * thumbnails cropped to the same box.
7
+ *
8
+ * It is the three-side counterpart of the project's `UI` board
9
+ * (`components/world-documents.tsx`): one pinned center document per project,
10
+ * installed by `installRootDocuments()`, showing everything the project has
11
+ * built and opening ONE of them on demand.
12
+ *
13
+ * It is a workspace document like any other and it reuses the EXISTING Object3D
14
+ * document surface (`components/StageHost.tsx`'s
15
+ * `Object3DDocumentViewport`) — orbit, pick, framing, the document toolbar and
16
+ * the presentation session all come free, and there is deliberately no second
17
+ * viewport class in this repo.
18
+ *
19
+ * ## Opening one story
20
+ *
21
+ * Double-clicking an exhibit opens that story's own turntable document
22
+ * (`THREE_STORY_DOCUMENT_OPENER`), exactly as double-clicking a story
23
+ * node in the hierarchy opens a React story document (`GameHierarchy.tsx` →
24
+ * `openStoryDocumentByStoryId`). Double-click, not single: a single click
25
+ * SELECTS (which is what feeds the Exhibit section below), and opening a tab
26
+ * on every stray click while orbiting would make the board unusable.
27
+ *
28
+ * ## Lifetime — one owner
29
+ *
30
+ * This component owns the built {@link ThreeBoardScene}. Its effect disposes the
31
+ * previous scene whenever the story registry changes and on unmount; the
32
+ * viewport's own `build().dispose` is a no-op precisely so there is exactly ONE
33
+ * teardown path (`ThreeBoardScene.dispose`, whose ownership is stated in
34
+ * `board-scene.ts`). React runs the child viewport's cleanup before this
35
+ * component's, so the graph is out of the render scene before it is freed.
36
+ *
37
+ * ## What is chrome and what is content
38
+ *
39
+ * District, exhibit and ghost-slot LABELS are HTML in the overlay below —
40
+ * projected each frame from the document session's live camera — never
41
+ * in-scene sprites, so the generated content graph contains exhibits and ghost
42
+ * slots and nothing else. The one piece of in-scene furniture (the district
43
+ * floor pads) is built and marked editor-owned by `board-scene.ts`; exhibits
44
+ * themselves stand directly on the floor.
45
+ *
46
+ * ## Ghost slots open SOURCE
47
+ *
48
+ * A ghost slot has no story document to open, so its double-click routes to
49
+ * the component's source through the editor's standing open-source affordance
50
+ * (`instance-source-actions.ts` — copy the `file:line` locator and SAY so;
51
+ * there is no code surface in the workspace, and that module owns the honest
52
+ * degradation). Same sentences, same clipboard path, never a silent no-op.
53
+ */
54
+
55
+ import { object3DDocumentSession } from '@volter/editor-core/authoring/object3d-document-session-registry';
56
+ import { Object3DDocumentViewport } from '@volter/editor-core/components/Object3DDocumentViewport';
57
+ import { STANDARD_COMPONENT_CAMERA_DIRECTION } from '@volter/editor-core/components/standard-viewport-dressing';
58
+ import { threeBoardBuildingCopy } from '@volter/editor-core/components/viewport-surface-status';
59
+ import { openRegisteredDocument } from '@volter/editor-core/document-open-registry';
60
+ import { listProjectComponents } from '@volter/editor-core/editor-api';
61
+ import { editorConsole } from '@volter/editor-core/editor-console';
62
+ import { createHmrRegistrationGroup } from '@volter/editor-core/hmr-registration-group';
63
+ import { CONTRIBUTED_SECTION_ORDER } from '@volter/editor-core/inspection/model';
64
+ import {
65
+ type InspectorSectionProps,
66
+ registerInspectorSections,
67
+ } from '@volter/editor-core/inspector-section-registry';
68
+ import { type ClipboardWriter, runInstanceSourceAction } from '@volter/editor-core/instance-source-actions';
69
+ import { getProjectStoryRegions } from '@volter/editor-core/stories/project-story-regions';
70
+ import { declaredStoryMedium } from '@volter/editor-core/stories/story-declared-medium';
71
+ import { THREE_STORY_DOCUMENT_OPENER } from '@volter/editor-core/stories/story-document-openers';
72
+ import {
73
+ getProjectStoryModules,
74
+ type ProjectStoryModule,
75
+ subscribeProjectStoryModules,
76
+ whenProjectStoriesReady,
77
+ } from '@volter/editor-core/stories/story-registry';
78
+ import { showTransientHint } from '@volter/editor-core/transient-hint';
79
+ import {
80
+ beginViewportBreakdown,
81
+ cancelViewportBreakdown,
82
+ markViewportBoardReady,
83
+ markViewportReactActive,
84
+ markViewportSegment,
85
+ } from '@volter/editor-core/viewport-activation-timings';
86
+ import { THREE_COMPONENTS_DOCUMENT_ID } from '@volter/editor-core/workspace-document-ids';
87
+ import {
88
+ activeWorkspaceDocument,
89
+ type WorkspaceDocumentContentProps,
90
+ } from '@volter/editor-core/workspace-document-registry';
91
+ import type { WorkspaceStateStore } from '@volter/editor-core/workspace-document-restore';
92
+ import { faCircleInfo } from '@fortawesome/free-solid-svg-icons';
93
+ import { themeVars } from '@volter/editor-sdk/widgets';
94
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
95
+ import * as THREE from 'three';
96
+ import {
97
+ BOARD_GHOST_GROUP_KEY,
98
+ type BoardExhibit,
99
+ type BoardGhostSlot,
100
+ boardComponentKeyForObject,
101
+ boardStoryIdForObject,
102
+ buildThreeBoard,
103
+ disposeThreeBoard,
104
+ summarizeBoardSkips,
105
+ type ThreeBoardScene,
106
+ threeBoardCandidateCount,
107
+ } from './board-scene';
108
+
109
+ /** The board is a project-wide singleton: one generated view of one registry.
110
+ * Symmetric with `UI_COMPONENTS_DOCUMENT_ID` — a stable project-level id, so
111
+ * a persisted layout never strands the panel when a root or file is renamed. */
112
+ export { THREE_COMPONENTS_DOCUMENT_ID };
113
+
114
+ /** The tab reads `3D`, the peer of `Scene` and `UI` in the center strip. The
115
+ * document ID keeps its longer historical spelling because persisted layouts
116
+ * address panels by id. */
117
+ const THREE_COMPONENTS_TITLE = '3D';
118
+
119
+ /** The narrow store surface the document needs. It is the seam's own
120
+ * (`workspace-document-restore.ts`), because opening a story document is the
121
+ * board's click-through — one type, not two identical ones. */
122
+ export type ThreeBoardStore = WorkspaceStateStore;
123
+
124
+ // --- Live scene state, shared with the Inspector section -------------------
125
+
126
+ let _scene: ThreeBoardScene | null = null;
127
+ let _version = 0;
128
+ const _listeners = new Set<() => void>();
129
+
130
+ function notifyChanged(): void {
131
+ _version++;
132
+ for (const listener of _listeners) listener();
133
+ }
134
+
135
+ function subscribeThreeBoard(listener: () => void): () => void {
136
+ _listeners.add(listener);
137
+ return () => {
138
+ _listeners.delete(listener);
139
+ };
140
+ }
141
+
142
+ function threeBoardVersion(): number {
143
+ return _version;
144
+ }
145
+
146
+ // --- Document ---------------------------------------------------------------
147
+
148
+ type BuildState =
149
+ | { readonly status: 'idle' }
150
+ | { readonly status: 'building' }
151
+ | { readonly status: 'ready'; readonly scene: ThreeBoardScene }
152
+ | { readonly status: 'error'; readonly message: string };
153
+
154
+ interface ProjectedLabel {
155
+ readonly key: string;
156
+ readonly text: string;
157
+ readonly detail: string;
158
+ readonly district: boolean;
159
+ /** Ghost-slot styling: dashed border, muted text — the "No story yet" look. */
160
+ readonly ghost: boolean;
161
+ readonly x: number;
162
+ readonly y: number;
163
+ readonly depth: number;
164
+ }
165
+
166
+ /** Metres, rendered the way a scale readout should read (`0.4 m`, `12 m`). */
167
+ function formatMetres(value: number): string {
168
+ if (!Number.isFinite(value)) return '—';
169
+ if (value >= 10) return `${Math.round(value)} m`;
170
+ if (value >= 1) return `${value.toFixed(1)} m`;
171
+ return `${value.toFixed(2)} m`;
172
+ }
173
+
174
+ /**
175
+ * The board's click-through: resolve a picked object to the exhibit it belongs
176
+ * to and open that story's own document. Exported for the headless test —
177
+ * everything it needs is an object and the built scene, so it never touches a
178
+ * renderer.
179
+ *
180
+ * Returns the opened document's id, or `null` when the pick landed on chrome,
181
+ * on nothing, or on an exhibit the current scene no longer knows.
182
+ */
183
+ export function openExhibitStoryDocument(
184
+ store: ThreeBoardStore,
185
+ scene: ThreeBoardScene | null,
186
+ object: THREE.Object3D | null,
187
+ ): string | null {
188
+ const exhibit = findExhibitIn(scene, object);
189
+ if (!exhibit) return null;
190
+ return openRegisteredDocument(THREE_STORY_DOCUMENT_OPENER, store, {
191
+ modulePath: exhibit.modulePath,
192
+ storyName: exhibit.storyName,
193
+ title: exhibit.label,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * The ghost slot's click-through: a storyless component has no story document,
199
+ * so its double-click routes to the component's SOURCE through the editor's
200
+ * standing open-source affordance (`runInstanceSourceAction` — the same
201
+ * sentences and clipboard path the hierarchy's "Open Component Source" uses;
202
+ * the locator is the component index's own `path:line`). Returns the pending
203
+ * sentence to show, or `null` when the pick was not a ghost slot. Exported for
204
+ * the headless test, which injects its own clipboard writer.
205
+ */
206
+ export function openGhostComponentSource(
207
+ scene: ThreeBoardScene | null,
208
+ object: THREE.Object3D | null,
209
+ writeText?: ClipboardWriter,
210
+ ): Promise<string> | null {
211
+ const ghost = findGhostIn(scene, object);
212
+ if (!ghost) return null;
213
+ const locator = {
214
+ definitionLocation: () => ({ file: ghost.path, line: ghost.line }),
215
+ };
216
+ return writeText
217
+ ? runInstanceSourceAction(locator, ghost.key, 'definition', writeText)
218
+ : runInstanceSourceAction(locator, ghost.key, 'definition');
219
+ }
220
+
221
+ /** Keep the board subscribed only to modules that can contribute a 3D story.
222
+ * The registry publishes declared modules first and its DOM/Dev tail at idle;
223
+ * retaining equal module objects prevents that unrelated tail from rebuilding
224
+ * every already-mounted exhibit. A real HMR refresh creates new module result
225
+ * objects and therefore still rebuilds. */
226
+ function useThreeBoardModules(
227
+ modules: readonly ProjectStoryModule[],
228
+ ): readonly ProjectStoryModule[] {
229
+ const regions = getProjectStoryRegions();
230
+ const selected = modules.filter(
231
+ (module_) =>
232
+ module_.ok &&
233
+ declaredStoryMedium({ modulePath: module_.modulePath, regions }).medium === 'three',
234
+ );
235
+ const retained = useRef<readonly ProjectStoryModule[]>([]);
236
+ const previous = retained.current;
237
+ if (
238
+ previous.length !== selected.length ||
239
+ previous.some((module_, index) => module_ !== selected[index])
240
+ ) {
241
+ retained.current = selected;
242
+ }
243
+ return retained.current;
244
+ }
245
+
246
+ function ThreeBoardContent({
247
+ documentId,
248
+ active,
249
+ store,
250
+ }: WorkspaceDocumentContentProps & { readonly store: ThreeBoardStore }) {
251
+ const modules = useSyncExternalStore(subscribeProjectStoryModules, getProjectStoryModules);
252
+ const boardModules = useThreeBoardModules(modules);
253
+ const [state, setState] = useState<BuildState>({ status: 'idle' });
254
+ if (active) markViewportReactActive(documentId);
255
+
256
+ // The board is a STANDING tab: its panel is present in every project from
257
+ // boot, and the layout host mounts every panel's content
258
+ // whether or not it is the active tab. So mounting is not the trigger —
259
+ // FIRST ACTIVATION is. Building the board mounts every story off-screen, one
260
+ // at a time, paying each story's loader latency (`board-scene.ts`), and a tab
261
+ // nobody has looked at must cost nothing at boot.
262
+ //
263
+ // It latches: once shown, the board stays live across tab switches, so
264
+ // flipping away does not throw the graph away and rebuild it on return.
265
+ const [revealed, setRevealed] = useState(active);
266
+ useEffect(() => {
267
+ if (active) setRevealed(true);
268
+ }, [active]);
269
+
270
+ // Rebuild on reveal and when a THREE module changes; the previous scene's ONE
271
+ // teardown runs here and nowhere else. A later idle publication of DOM/Dev
272
+ // modules must not throw away and remount an already-usable 3D board.
273
+ //
274
+ // Both halves are SEQUENCED by `board-scene.ts`'s mount turn, which is what
275
+ // makes "one mount at a time" true across builds and not merely within one:
276
+ // a registry change mid-build aborts the in-flight build (it stops mounting
277
+ // and frees what it has, inside its own turn) and the replacement build
278
+ // queues behind it, so two builds can never interleave their mounts on the
279
+ // process-global loader state a story's `.load()` may touch. Teardown of a
280
+ // READY scene queues the same way, so its stories' effect cleanups cannot
281
+ // land mid-way through the next build.
282
+ useEffect(() => {
283
+ if (!revealed) return;
284
+ const controller = new AbortController();
285
+ let built: ThreeBoardScene | null = null;
286
+ setState({ status: 'building' });
287
+ beginViewportBreakdown(documentId, { modulesAlreadyLoaded: boardModules.length > 0 });
288
+ // The component index rides along so storyless `three` components get
289
+ // their ghost slots — the Content gallery's own discovery
290
+ // (`listProjectComponents`), never a second scan. An index failure
291
+ // degrades to "no ghosts", not to a failed board.
292
+ const indexStarted = Date.now();
293
+ void listProjectComponents()
294
+ .then((listing) => {
295
+ const wall = Date.now() - indexStarted;
296
+ markViewportSegment('component-index', wall, { busyMs: 0, waitMs: wall });
297
+ // Still "no ghosts, not a failed board" — but SAID OUT LOUD. A failed
298
+ // index and a project with no components produced the same silent
299
+ // empty board, so a missing ghost slot looked like a design decision.
300
+ if (!listing.ok) {
301
+ editorConsole.warn(
302
+ `Component index unavailable, so this board shows no ghost slots: ${listing.reason}`,
303
+ 'three-board',
304
+ );
305
+ }
306
+ return buildThreeBoard(boardModules, listing.ok ? listing.entries : [], controller.signal);
307
+ })
308
+ .then(
309
+ (scene) => {
310
+ // A build can RESOLVE in the same microtask turn that unmount aborts
311
+ // it: cleanup then saw `built === null` and disposed nothing, so a
312
+ // success landing after abort must dispose its own scene or it leaks.
313
+ if (controller.signal.aborted) {
314
+ cancelViewportBreakdown(documentId);
315
+ void disposeThreeBoard(scene);
316
+ return;
317
+ }
318
+ built = scene;
319
+ _scene = scene;
320
+ notifyChanged();
321
+ markViewportBoardReady();
322
+ setState({ status: 'ready', scene });
323
+ },
324
+ (error: unknown) => {
325
+ // A superseded build already freed itself in its own turn; the only
326
+ // reportable failures are the ones this build is still current for.
327
+ if (controller.signal.aborted) {
328
+ cancelViewportBreakdown(documentId);
329
+ return;
330
+ }
331
+ setState({
332
+ status: 'error',
333
+ message: error instanceof Error ? error.message : String(error),
334
+ });
335
+ },
336
+ );
337
+ return () => {
338
+ controller.abort();
339
+ if (built) {
340
+ if (_scene === built) _scene = null;
341
+ void disposeThreeBoard(built);
342
+ notifyChanged();
343
+ } else {
344
+ cancelViewportBreakdown(documentId);
345
+ }
346
+ };
347
+ }, [boardModules, revealed, documentId]);
348
+
349
+ const scene = state.status === 'ready' ? state.scene : null;
350
+ const build = useCallback(() => {
351
+ if (!scene) throw new Error('The 3D board is still building.');
352
+ return {
353
+ root: scene.root,
354
+ // Deliberately empty: this component owns the scene's lifetime (see the
355
+ // module doc). Two disposers for one graph is how a double-free starts.
356
+ dispose(): void {},
357
+ };
358
+ }, [scene]);
359
+
360
+ // The viewport hands back the picked node's own `Object3D`; the story (or
361
+ // ghost-slot component) identity is tagged on the wrapper above it
362
+ // (`board-scene.ts`). Exhibits open their story document; ghost slots route
363
+ // to the component's source and SAY what happened.
364
+ const openPicked = useCallback(
365
+ (object: THREE.Object3D) => {
366
+ if (openExhibitStoryDocument(store, scene, object) !== null) return;
367
+ const pending = openGhostComponentSource(scene, object);
368
+ if (pending) void pending.then(showTransientHint);
369
+ },
370
+ [scene, store],
371
+ );
372
+
373
+ return (
374
+ <div style={{ position: 'relative', flex: 1, minHeight: 0, pointerEvents: 'auto' }}>
375
+ {scene ? (
376
+ <>
377
+ <Object3DDocumentViewport
378
+ documentId={documentId}
379
+ sourcePath="Generated from this project’s stories"
380
+ displayName={THREE_COMPONENTS_TITLE}
381
+ build={build}
382
+ active={active}
383
+ onOpenNode={openPicked}
384
+ // Open on one readable exhibit, like a component editor; a shared
385
+ // true-scale overview necessarily makes a ship tiny beside a 30m
386
+ // lighthouse. Frame Selection follows the picked exhibit/node;
387
+ // clearing selection and framing restores the presence-box union.
388
+ // Helpers remain authored and visible at true scale in either view.
389
+ cameraDirection={STANDARD_COMPONENT_CAMERA_DIRECTION}
390
+ frameBounds={scene.frameBounds}
391
+ openingFrameBounds={scene.openingFrameBounds}
392
+ // Standard dressing plus the ground grid: exhibits stand on y=0
393
+ // by design, so the board earns the one opt-IN the contract has.
394
+ dressing={{ grid: true }}
395
+ />
396
+ <ThreeBoardLabels documentId={documentId} scene={scene} active={active} />
397
+ <ThreeBoardStatus scene={scene} />
398
+ </>
399
+ ) : (
400
+ <div
401
+ data-testid="three-board-status"
402
+ style={{ padding: 16, fontSize: 12, color: themeVars.content.muted }}
403
+ >
404
+ {state.status === 'error' && state.message}
405
+ {state.status === 'building' &&
406
+ threeBoardBuildingCopy(threeBoardCandidateCount(boardModules))}
407
+ {state.status === 'idle' && 'Open this tab to lay out every story at true scale.'}
408
+ </div>
409
+ )}
410
+ </div>
411
+ );
412
+ }
413
+
414
+ /** Counts (exhibits, districts, storyless components) + the honest list of
415
+ * stories that produced no Object3D. Ghost slots and skips are DIFFERENT
416
+ * facts — a ghost is a component with no story, a skip is a story that
417
+ * mounted nothing 3D — and the footer states both, hiding neither. */
418
+ function ThreeBoardStatus({ scene }: { readonly scene: ThreeBoardScene }) {
419
+ const [open, setOpen] = useState(false);
420
+ const skips = summarizeBoardSkips(scene.skipped);
421
+ const realDistricts = scene.districts.filter(
422
+ (district) => district.groupKey !== BOARD_GHOST_GROUP_KEY,
423
+ ).length;
424
+ return (
425
+ <div
426
+ data-testid="three-board-status"
427
+ style={{
428
+ position: 'absolute',
429
+ left: 8,
430
+ bottom: 8,
431
+ maxWidth: 420,
432
+ padding: '4px 8px',
433
+ borderRadius: themeVars.shape.small,
434
+ background: themeVars.surface.raised,
435
+ border: `1px solid ${themeVars.boundary.default}`,
436
+ fontSize: 10,
437
+ color: themeVars.content.muted,
438
+ pointerEvents: 'auto',
439
+ }}
440
+ >
441
+ {scene.exhibits.length} exhibit{scene.exhibits.length === 1 ? '' : 's'} · {realDistricts}{' '}
442
+ district{realDistricts === 1 ? '' : 's'}
443
+ {scene.helperVolumes.length > 0 && (
444
+ <>
445
+ {' · '}
446
+ {scene.helperVolumes.length} helper volume
447
+ {scene.helperVolumes.length === 1 ? '' : 's'} at true scale, outside the default frame
448
+ </>
449
+ )}
450
+ {scene.ghostSlots.length > 0 && (
451
+ <>
452
+ {' · '}
453
+ {scene.ghostSlots.length} component{scene.ghostSlots.length === 1 ? '' : 's'} without
454
+ stories
455
+ </>
456
+ )}
457
+ {scene.skipped.length > 0 && (
458
+ <>
459
+ {' · '}
460
+ {/* Inline disclosure toggle — the role-annotated span is the
461
+ guard-sanctioned shape for text-link chrome (a raw native
462
+ button trips the product-chrome scan). */}
463
+ <span
464
+ role="button"
465
+ tabIndex={0}
466
+ aria-expanded={open}
467
+ onClick={() => setOpen((value) => !value)}
468
+ onKeyDown={(event) => {
469
+ if (event.key === ' ' || event.key === 'Enter') {
470
+ event.preventDefault();
471
+ setOpen((value) => !value);
472
+ }
473
+ }}
474
+ style={{
475
+ cursor: 'pointer',
476
+ color: themeVars.accent.default,
477
+ }}
478
+ >
479
+ {skips.stories} {skips.stories === 1 ? 'story' : 'stories'} in {skips.modules}{' '}
480
+ {skips.modules === 1 ? 'module' : 'modules'} not on this board
481
+ </span>
482
+ </>
483
+ )}
484
+ {open && (
485
+ <div style={{ marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
486
+ {scene.skipped.map((entry) => (
487
+ <div key={entry.id} style={{ padding: '1px 0' }}>
488
+ <code style={{ color: themeVars.content.dim }}>
489
+ {entry.modulePath}#{entry.storyName}
490
+ </code>{' '}
491
+ — {entry.reason}
492
+ </div>
493
+ ))}
494
+ </div>
495
+ )}
496
+ </div>
497
+ );
498
+ }
499
+
500
+ /**
501
+ * District + exhibit labels, projected from the live document camera into HTML.
502
+ * Chrome, not content: the generated graph never learns these exist.
503
+ */
504
+ function ThreeBoardLabels({
505
+ documentId,
506
+ scene,
507
+ active,
508
+ }: {
509
+ readonly documentId: string;
510
+ readonly scene: ThreeBoardScene;
511
+ readonly active: boolean;
512
+ }) {
513
+ const hostRef = useRef<HTMLDivElement>(null);
514
+ const [labels, setLabels] = useState<readonly ProjectedLabel[]>([]);
515
+
516
+ useEffect(() => {
517
+ if (!active) return;
518
+ let frame = 0;
519
+ let cancelled = false;
520
+ const anchors = labelAnchors(scene);
521
+ const tick = () => {
522
+ if (cancelled) return;
523
+ frame = requestAnimationFrame(tick);
524
+ const host = hostRef.current;
525
+ const session = object3DDocumentSession(documentId);
526
+ if (!host || !session) return;
527
+ if (host.clientWidth === 0 || host.clientHeight === 0) return;
528
+ setLabels(projectLabels(anchors, session.camera(), host.clientWidth, host.clientHeight));
529
+ };
530
+ frame = requestAnimationFrame(tick);
531
+ return () => {
532
+ cancelled = true;
533
+ cancelAnimationFrame(frame);
534
+ };
535
+ }, [active, documentId, scene]);
536
+
537
+ return (
538
+ <div
539
+ ref={hostRef}
540
+ data-testid="three-board-labels"
541
+ // Matches the viewport host's own `inset: -12`, so projected NDC maps
542
+ // onto the same rectangle the canvas covers.
543
+ style={{ position: 'absolute', inset: -12, pointerEvents: 'none', overflow: 'hidden' }}
544
+ >
545
+ {labels.map((label) => (
546
+ <div
547
+ key={label.key}
548
+ style={{
549
+ position: 'absolute',
550
+ left: label.x,
551
+ top: label.y,
552
+ transform: 'translate(-50%, -100%)',
553
+ whiteSpace: 'nowrap',
554
+ padding: label.district ? '2px 7px' : '1px 5px',
555
+ borderRadius: themeVars.shape.small,
556
+ background: themeVars.surface.raised,
557
+ border: `1px ${label.ghost ? 'dashed' : 'solid'} ${
558
+ label.district && !label.ghost ? themeVars.accent.default : themeVars.boundary.default
559
+ }`,
560
+ color:
561
+ label.district && !label.ghost ? themeVars.content.primary : themeVars.content.muted,
562
+ fontSize: label.district ? 12 : 10,
563
+ fontWeight: label.district ? 600 : 400,
564
+ opacity: label.ghost ? 0.9 : 1,
565
+ }}
566
+ >
567
+ {label.text}
568
+ <span style={{ marginLeft: 6, color: themeVars.content.dim, fontWeight: 400 }}>
569
+ {label.detail}
570
+ </span>
571
+ </div>
572
+ ))}
573
+ </div>
574
+ );
575
+ }
576
+
577
+ /** One reused vector so a 200-exhibit board allocates nothing per frame. */
578
+ const scratch = new THREE.Vector3();
579
+
580
+ interface LabelAnchor {
581
+ readonly key: string;
582
+ readonly text: string;
583
+ readonly detail: string;
584
+ readonly district: boolean;
585
+ readonly ghost: boolean;
586
+ readonly point: readonly [number, number, number];
587
+ }
588
+
589
+ /** A placard's world anchor: the FRONT EDGE of the slot's own patch of floor,
590
+ * on the ground — where a floor placard sits, so a label reads as belonging to
591
+ * the thing standing behind it and never floats mid-air over one. */
592
+ function placardPoint(placement: {
593
+ readonly anchor: readonly [number, number, number];
594
+ readonly footprint: readonly [number, number];
595
+ }): readonly [number, number, number] {
596
+ return [placement.anchor[0], 0, placement.anchor[2] - placement.footprint[1] / 2];
597
+ }
598
+
599
+ /** What the overlay prints and where each label is pinned in world space:
600
+ * a district's title on its pad's FRONT edge on the ground, an exhibit's (or
601
+ * ghost slot's) placard on its own slot's front edge, also on the ground. The
602
+ * exhibit detail is the TRUE height in metres — nothing on this board resizes
603
+ * or lifts an exhibit, and the placard is what says so. */
604
+ function labelAnchors(scene: ThreeBoardScene): LabelAnchor[] {
605
+ return [
606
+ ...scene.districts.map((district) => {
607
+ const ghost = district.groupKey === BOARD_GHOST_GROUP_KEY;
608
+ const count = district.placement.items.length;
609
+ return {
610
+ key: `district:${district.groupKey}`,
611
+ text: district.label,
612
+ detail: ghost
613
+ ? `${count} component${count === 1 ? '' : 's'}`
614
+ : `${count} exhibit${count === 1 ? '' : 's'}`,
615
+ district: true,
616
+ ghost,
617
+ point: district.placement.anchor,
618
+ };
619
+ }),
620
+ ...scene.exhibits.map((exhibit) => ({
621
+ key: `exhibit:${exhibit.id}`,
622
+ text: exhibit.label,
623
+ detail: formatMetres(exhibit.placement.size[1]),
624
+ district: false,
625
+ ghost: false,
626
+ point: placardPoint(exhibit.placement),
627
+ })),
628
+ ...scene.ghostSlots.map((slot) => ({
629
+ key: `ghost:${slot.key}`,
630
+ text: slot.name,
631
+ detail: 'No story yet',
632
+ district: false,
633
+ ghost: true,
634
+ point: placardPoint(slot.placement),
635
+ })),
636
+ ];
637
+ }
638
+
639
+ /** Project every anchor to pixels, dropping what is off the far plane and
640
+ * sorting far → near so nearer labels paint over further ones. */
641
+ function projectLabels(
642
+ anchors: readonly LabelAnchor[],
643
+ camera: THREE.Camera,
644
+ width: number,
645
+ height: number,
646
+ ): ProjectedLabel[] {
647
+ camera.updateMatrixWorld();
648
+ const projected: ProjectedLabel[] = [];
649
+ for (const anchor of anchors) {
650
+ scratch.set(anchor.point[0], anchor.point[1], anchor.point[2]).project(camera);
651
+ // `project` of a point behind the camera wraps around to a plausible
652
+ // on-screen NDC; the far-plane test is what rejects it.
653
+ if (scratch.z > 1) continue;
654
+ projected.push({
655
+ key: anchor.key,
656
+ text: anchor.text,
657
+ detail: anchor.detail,
658
+ district: anchor.district,
659
+ ghost: anchor.ghost,
660
+ x: (scratch.x * 0.5 + 0.5) * width,
661
+ y: (-scratch.y * 0.5 + 0.5) * height,
662
+ depth: scratch.z,
663
+ });
664
+ }
665
+ return cullOverlappingLabels(projected).sort((a, b) => b.depth - a.depth);
666
+ }
667
+
668
+ /** Approximate on-screen box of one label (the overlay's own font metrics:
669
+ * ~0.62 em average advance for the UI face, plus padding and the gap the
670
+ * detail suffix adds). Estimation is fine — the culling below only needs to
671
+ * agree with the DOM to within a few pixels. */
672
+ function labelRect(label: ProjectedLabel): readonly [number, number, number, number] {
673
+ const fontSize = label.district ? 12 : 10;
674
+ const textWidth = (label.text.length + label.detail.length) * fontSize * 0.62 + 24;
675
+ const boxHeight = fontSize + 8;
676
+ return [label.x - textWidth / 2, label.y - boxHeight, label.x + textWidth / 2, label.y];
677
+ }
678
+
679
+ /**
680
+ * LABEL SANITY: at a wide framing, a dense row of placards projects into an
681
+ * unreadable pile. Nearer (and district) labels win; a label whose box would
682
+ * overlap an already-kept one is dropped for this frame — it reappears the
683
+ * moment the camera gives it room. Nothing is abbreviated or nudged: a placard
684
+ * either shows whole and anchored, or not at all.
685
+ */
686
+ function cullOverlappingLabels(projected: readonly ProjectedLabel[]): ProjectedLabel[] {
687
+ const byPriority = [...projected].sort(
688
+ (a, b) => (b.district ? 1 : 0) - (a.district ? 1 : 0) || a.depth - b.depth,
689
+ );
690
+ const kept: { label: ProjectedLabel; rect: readonly [number, number, number, number] }[] = [];
691
+ for (const label of byPriority) {
692
+ const rect = labelRect(label);
693
+ const collides = kept.some(
694
+ (entry) =>
695
+ rect[0] < entry.rect[2] &&
696
+ rect[2] > entry.rect[0] &&
697
+ rect[1] < entry.rect[3] &&
698
+ rect[3] > entry.rect[1],
699
+ );
700
+ if (collides) continue;
701
+ kept.push({ label, rect });
702
+ }
703
+ return kept.map((entry) => entry.label);
704
+ }
705
+
706
+ // --- Inspector: the picked exhibit's story identity -------------------------
707
+
708
+ function findExhibitIn(
709
+ scene: ThreeBoardScene | null,
710
+ object: THREE.Object3D | null,
711
+ ): BoardExhibit | null {
712
+ if (!scene) return null;
713
+ const storyId = boardStoryIdForObject(object);
714
+ if (!storyId) return null;
715
+ return scene.exhibits.find((exhibit) => exhibit.id === storyId) ?? null;
716
+ }
717
+
718
+ function findGhostIn(
719
+ scene: ThreeBoardScene | null,
720
+ object: THREE.Object3D | null,
721
+ ): BoardGhostSlot | null {
722
+ if (!scene) return null;
723
+ const componentKey = boardComponentKeyForObject(object);
724
+ if (!componentKey) return null;
725
+ return scene.ghostSlots.find((slot) => slot.key === componentKey) ?? null;
726
+ }
727
+
728
+ /**
729
+ * Registered section: story identity for whatever is picked on the board.
730
+ *
731
+ * The subject is resolved from the shell's `nodeId` AND the adapter's own live
732
+ * selection, because a pick in this document surface reaches the adapter's
733
+ * selection first — reading only the shell's node id showed "pick an exhibit"
734
+ * while the hierarchy already had the picked mesh highlighted.
735
+ */
736
+ export function BoardExhibitSection({ adapter, nodeId }: InspectorSectionProps) {
737
+ useSyncExternalStore(subscribeThreeBoard, threeBoardVersion);
738
+ const candidateIds = [...(nodeId ? [nodeId] : []), ...(adapter.selection?.get() ?? [])];
739
+ const exhibit = candidateIds.reduce<BoardExhibit | null>(
740
+ (found, id) => found ?? findExhibitIn(_scene, adapter.hierarchy.object3D?.(id) ?? null),
741
+ null,
742
+ );
743
+ return (
744
+ <div
745
+ data-testid="inspector-exhibit"
746
+ style={{
747
+ pointerEvents: 'auto',
748
+ padding: 8,
749
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
750
+ }}
751
+ >
752
+ {/* No mini-label of its own: the projection heads this section with its
753
+ title and icon (`components/InspectionProjection.tsx`). */}
754
+ {!exhibit ? (
755
+ <div style={{ fontSize: 11, color: themeVars.content.dim }}>
756
+ Pick an exhibit to read its story.
757
+ </div>
758
+ ) : (
759
+ <div style={{ display: 'grid', gap: 3, fontSize: 11 }}>
760
+ <div style={{ color: themeVars.content.primary, fontWeight: 600 }}>{exhibit.label}</div>
761
+ <IdentityRow label="Story" value={exhibit.storyName} />
762
+ <IdentityRow label="Group" value={exhibit.groupPath} />
763
+ <IdentityRow label="Module" value={exhibit.modulePath} />
764
+ <IdentityRow
765
+ label="Size"
766
+ value={exhibit.placement.size.map((value) => formatMetres(value)).join(' × ')}
767
+ />
768
+ </div>
769
+ )}
770
+ </div>
771
+ );
772
+ }
773
+
774
+ function IdentityRow({ label, value }: { readonly label: string; readonly value: string }) {
775
+ return (
776
+ <div style={{ display: 'flex', gap: 6 }}>
777
+ <span style={{ width: 52, flexShrink: 0, color: themeVars.content.muted }}>{label}</span>
778
+ <code
779
+ style={{ color: themeVars.content.primary, overflow: 'hidden', textOverflow: 'ellipsis' }}
780
+ title={value}
781
+ >
782
+ {value}
783
+ </code>
784
+ </div>
785
+ );
786
+ }
787
+
788
+ const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'three-board-contributions');
789
+
790
+ /** Idempotent registration of the board's Inspector section. */
791
+ export function ensureThreeBoardContributionsRegistered(): void {
792
+ registrationGroup.ensure((track) => {
793
+ track(
794
+ registerInspectorSections({
795
+ match: () => activeWorkspaceDocument()?.descriptor.id === THREE_COMPONENTS_DOCUMENT_ID,
796
+ // The FACET, not the surface that renders it (ARCHITECTURE-CORE
797
+ // §Editor chrome, facet taxonomy): what you picked on the board is an
798
+ // exhibit, and this block reads its story, group, module and size.
799
+ id: 'exhibit',
800
+ title: 'Exhibit',
801
+ // The board's read-only "what am I looking at" block.
802
+ icon: faCircleInfo,
803
+ order: CONTRIBUTED_SECTION_ORDER,
804
+ Section: BoardExhibitSection,
805
+ }),
806
+ );
807
+ });
808
+ }
809
+
810
+ // --- Installation -----------------------------------------------------------
811
+
812
+ /** Capture the same true-scale composition without activating its document. */
813
+ const threeBoardPreview = {
814
+ revision: () => JSON.stringify(getProjectStoryModules().map((module_) => module_.modulePath)),
815
+ subscribe: subscribeProjectStoryModules,
816
+ capture: async (
817
+ request: import('@volter/editor-core/document-preview-source').DocumentPreviewCaptureRequest,
818
+ ) => {
819
+ await whenProjectStoriesReady();
820
+ const regions = getProjectStoryRegions();
821
+ const modules = getProjectStoryModules().filter(
822
+ (module_) =>
823
+ module_.ok &&
824
+ declaredStoryMedium({ modulePath: module_.modulePath, regions }).medium === 'three',
825
+ );
826
+ const listing = await listProjectComponents();
827
+ const board = await buildThreeBoard(modules, listing.ok ? listing.entries : []);
828
+ try {
829
+ const { captureAuthoredThreeScenePreview } = await import('../../host/document-preview-three');
830
+ const { createStandardEnvironment } = await import('@volter/editor-threejs/viewport/environment');
831
+ const { applyStandardViewportDressing } = await import(
832
+ '@volter/editor-core/components/standard-viewport-dressing'
833
+ );
834
+ const { isEditorOwnedObject } = await import('@volter/editor-threejs/viewport/editor-layers');
835
+ board.root.traverse((object) => {
836
+ if (isEditorOwnedObject(object)) object.visible = false;
837
+ });
838
+ const scene = new THREE.Scene();
839
+ scene.add(board.root);
840
+ const bounds = new THREE.Box3(
841
+ new THREE.Vector3(...board.frameBounds.min),
842
+ new THREE.Vector3(...board.frameBounds.max),
843
+ );
844
+ const target = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3());
845
+ const radius = bounds.isEmpty()
846
+ ? 1
847
+ : Math.max(0.01, bounds.getBoundingSphere(new THREE.Sphere()).radius);
848
+ const distance = (radius / Math.sin(THREE.MathUtils.degToRad(42 / 2))) * 1.15;
849
+ const position = target
850
+ .clone()
851
+ .addScaledVector(new THREE.Vector3(1, 0.75, 1).normalize(), distance);
852
+ // A board's framing authority is its complete layout, never a camera
853
+ // inside one exhibit (which would photograph only that prefab).
854
+ const framing = { ...request, camera: { position, target, fov: 42 } };
855
+ return captureAuthoredThreeScenePreview(board.root, scene, framing, (renderer) => {
856
+ const dressing = applyStandardViewportDressing(scene, {
857
+ environment: createStandardEnvironment(renderer),
858
+ background: false,
859
+ });
860
+ return () => dressing.dispose();
861
+ });
862
+ } finally {
863
+ await disposeThreeBoard(board);
864
+ }
865
+ },
866
+ };
867
+
868
+ /** Register the project's 3D component canvas for Content and session restore.
869
+ * Its renderer mounts only when the user opens its document tab. */
870
+ export function installThreeBoardDocument(store: ThreeBoardStore): string {
871
+ ensureThreeBoardContributionsRegistered();
872
+ return registerAvailableWorkspaceDocument(
873
+ {
874
+ id: THREE_COMPONENTS_DOCUMENT_ID,
875
+ title: THREE_COMPONENTS_TITLE,
876
+ // A generated browse-over-the-project's-content view, not an authored
877
+ // subject: it is regenerated from the story registry, never persisted.
878
+ kind: 'content-browser',
879
+ workspaceRole: 'workspace-reference',
880
+ Content: (props) => <ThreeBoardContent {...props} store={store} />,
881
+ readOnly: true,
882
+ preview: threeBoardPreview,
883
+ presentation: () => ({ kind: 'workspace', id: THREE_COMPONENTS_DOCUMENT_ID }),
884
+ onActivate: () => store.setActiveViewportTab('edit'),
885
+ },
886
+ { category: 'canvas' },
887
+ );
888
+ }