@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,401 @@
1
+ /**
2
+ * The 3D board's LAYOUT MATH — pure, three-free, and the whole product.
3
+ *
4
+ * The board exists to answer one question a thumbnail grid structurally cannot:
5
+ * *how big is this thing, really, next to that thing?* So every rule here
6
+ * serves TRUE SCALE readability, and the module emits only TRANSLATIONS —
7
+ * there is no scale channel in the output at all, because normalizing sizes
8
+ * would destroy the only thing the surface is for.
9
+ *
10
+ * The rules, in the order they apply:
11
+ *
12
+ * 1. **One district per story group.** Districts are the story-grouping
13
+ * model's clusters (`stories/story-grouping.ts` — `storyGroupKey`), laid
14
+ * out along +Z on ONE ground plane, in FIRST-APPEARANCE order — the group
15
+ * of the first item that names it comes first. That is the same order the
16
+ * React story board clusters its frames in, so a project reads the same way
17
+ * on both boards; it is deterministic for the same input, which is all a
18
+ * surface needs to be comparable against itself.
19
+ * 2. **Every exhibit stands DIRECTLY ON THE FLOOR: its bounding-box BASE sits
20
+ * exactly at y = 0.** There is no vertical channel at all — not a lift, not
21
+ * a datum, not furniture under an exhibit. A 0.2 m prop is a 0.2 m prop
22
+ * seen from a 0.2 m-tall silhouette on the same floor a 30 m building
23
+ * stands on, which is exactly the comparison the board is for; the placard
24
+ * states the metres. {@link groundTranslationY} is the one place that
25
+ * number is computed, so "grounded" is a single expression rather than a
26
+ * convention spread across callers.
27
+ * 3. **Each district gets a ground PAD** — a floor rectangle sized to the
28
+ * district's own footprint, whose front edge (`minZ`) is the district
29
+ * label's anchor. The pad is the figure-ground device that makes a cluster
30
+ * read as a room instead of a loose row.
31
+ * 4. **Within a district, sorted by footprint (bbox XZ area), small → large,
32
+ * row-major along +X** — wrapping into further rows ({@link rowWrapWidth})
33
+ * so a large collection reads as a near-square gallery, not a queue. The
34
+ * monotonic size ramp is what makes the walk readable as a scale chart
35
+ * rather than a pile.
36
+ * 5. **Spacing is proportional to the LARGER of the two neighbouring
37
+ * FOOTPRINTS** ({@link BoardLayoutOptions.neighbourGapFactor}) — not to a
38
+ * constant and not to the item's own size. That is the anti-crowding rule:
39
+ * a 0.2 m prop standing next to a 30 m building gets a gap set by the
40
+ * BUILDING, so the small thing is never swallowed by its neighbour's
41
+ * silhouette. It is also what keeps two exhibits' ground-hugging geometry
42
+ * from ever overlapping in XZ, now that every base shares one plane.
43
+ *
44
+ * Degenerate input is normalized, never propagated: an empty `THREE.Box3` is
45
+ * `+Infinity`/`-Infinity`, and one `NaN` in a transform poisons an entire
46
+ * subtree, so non-finite bounds collapse to a zero-size point.
47
+ */
48
+
49
+ /** An axis-aligned bounding box in the mounted object's own world units. */
50
+ export interface BoardBounds {
51
+ readonly min: readonly [number, number, number];
52
+ readonly max: readonly [number, number, number];
53
+ }
54
+
55
+ /** One thing to lay out: a qualified story mount and the group it belongs to. */
56
+ export interface BoardLayoutItem {
57
+ readonly id: string;
58
+ /** `storyGroupKey(...)` — `''` is the ungrouped/flat case. */
59
+ readonly groupKey: string;
60
+ readonly bounds: BoardBounds;
61
+ }
62
+
63
+ export interface BoardItemPlacement {
64
+ readonly id: string;
65
+ readonly groupKey: string;
66
+ /**
67
+ * Translation to ADD to the mounted object's position so its bbox base sits
68
+ * on the floor (y = 0) and its bbox XZ centre sits in its district slot.
69
+ * Translation only — the board never scales an exhibit and never lifts one.
70
+ */
71
+ readonly translation: readonly [number, number, number];
72
+ /** World point at the slot's ground centre — the exhibit stands here. */
73
+ readonly anchor: readonly [number, number, number];
74
+ /** True-scale bbox spans, carried through for chrome (labels read them). */
75
+ readonly size: readonly [number, number, number];
76
+ /**
77
+ * The slot's reserved ground rectangle `[width, depth]`, centred on
78
+ * {@link anchor}: the exhibit's own footprint plus breathing room. It is what
79
+ * spacing and pad sizing are measured against — never a drawn object.
80
+ */
81
+ readonly footprint: readonly [number, number];
82
+ }
83
+
84
+ /** The ground-plane rectangle a district's pad occupies. */
85
+ export interface BoardExtent {
86
+ readonly minX: number;
87
+ readonly maxX: number;
88
+ readonly minZ: number;
89
+ readonly maxZ: number;
90
+ }
91
+
92
+ export interface BoardDistrictPlacement {
93
+ readonly groupKey: string;
94
+ /** Exhibits in their laid-out order: footprint area ascending. */
95
+ readonly items: readonly BoardItemPlacement[];
96
+ /** The district's floor pad rectangle. */
97
+ readonly extent: BoardExtent;
98
+ /** Label anchor for the district title — the pad's FRONT edge (`minZ`),
99
+ * centred in X, on the ground. */
100
+ readonly anchor: readonly [number, number, number];
101
+ }
102
+
103
+ export interface BoardLayout {
104
+ readonly districts: readonly BoardDistrictPlacement[];
105
+ readonly extent: BoardExtent;
106
+ }
107
+
108
+ export interface BoardLayoutOptions {
109
+ /** Gap between neighbouring slots as a fraction of the LARGER neighbour. */
110
+ readonly neighbourGapFactor?: number;
111
+ /** Floor for that gap, so two tiny props still read as two props (metres). */
112
+ readonly minGap?: number;
113
+ /** Gap between district pads as a fraction of the deeper district. */
114
+ readonly districtGapFactor?: number;
115
+ /** Floor for the district gap (metres). */
116
+ readonly minDistrictGap?: number;
117
+ }
118
+
119
+ const DEFAULTS = {
120
+ neighbourGapFactor: 0.45,
121
+ minGap: 0.5,
122
+ districtGapFactor: 0.6,
123
+ minDistrictGap: 3,
124
+ } satisfies Required<BoardLayoutOptions>;
125
+
126
+ /** The smallest ground rectangle a slot reserves — enough that a coin-sized
127
+ * exhibit still has a readable patch of floor to itself. */
128
+ const SLOT_MIN_FOOTPRINT = 0.7;
129
+
130
+ const ZERO_BOUNDS: BoardBounds = { min: [0, 0, 0], max: [0, 0, 0] };
131
+
132
+ /**
133
+ * An empty `THREE.Box3` is `[+Inf, -Inf]`, and a subtree of pure lights/audio
134
+ * has no measurable extent at all. Either way the honest answer is "a point",
135
+ * not `NaN` smeared through every downstream transform.
136
+ */
137
+ function normalizeBounds(bounds: BoardBounds): BoardBounds {
138
+ const values = [...bounds.min, ...bounds.max];
139
+ if (values.some((value) => !Number.isFinite(value))) return ZERO_BOUNDS;
140
+ return {
141
+ min: [
142
+ Math.min(bounds.min[0], bounds.max[0]),
143
+ Math.min(bounds.min[1], bounds.max[1]),
144
+ Math.min(bounds.min[2], bounds.max[2]),
145
+ ],
146
+ max: [
147
+ Math.max(bounds.min[0], bounds.max[0]),
148
+ Math.max(bounds.min[1], bounds.max[1]),
149
+ Math.max(bounds.min[2], bounds.max[2]),
150
+ ],
151
+ };
152
+ }
153
+
154
+ function sizeOf(bounds: BoardBounds): readonly [number, number, number] {
155
+ return [
156
+ bounds.max[0] - bounds.min[0],
157
+ bounds.max[1] - bounds.min[1],
158
+ bounds.max[2] - bounds.min[2],
159
+ ];
160
+ }
161
+
162
+ /** The sort key: ground-plane footprint area. */
163
+ export function footprintArea(bounds: BoardBounds): number {
164
+ const size = sizeOf(normalizeBounds(bounds));
165
+ return size[0] * size[2];
166
+ }
167
+
168
+ /**
169
+ * Rule 2 in one expression: the Y translation that puts a bbox base on the
170
+ * floor. Every exhibit gets this and nothing else, so there is exactly one
171
+ * place a vertical offset could ever be reintroduced.
172
+ */
173
+ export function groundTranslationY(bounds: BoardBounds): number {
174
+ return -normalizeBounds(bounds).min[1];
175
+ }
176
+
177
+ /** The ground rectangle an exhibit of `size` reserves: its own XZ footprint
178
+ * plus breathing room, never smaller than {@link SLOT_MIN_FOOTPRINT}. */
179
+ export function slotFootprint(size: readonly [number, number, number]): readonly [number, number] {
180
+ const groundSpan = Math.max(size[0], size[2]);
181
+ const margin = Math.max(0.15, 0.1 * groundSpan);
182
+ return [
183
+ Math.max(size[0] + 2 * margin, SLOT_MIN_FOOTPRINT),
184
+ Math.max(size[2] + 2 * margin, SLOT_MIN_FOOTPRINT),
185
+ ];
186
+ }
187
+
188
+ /** The single number a gap is measured against: the slot's larger ground span. */
189
+ function slotSpan(footprint: readonly [number, number]): number {
190
+ return Math.max(footprint[0], footprint[1]);
191
+ }
192
+
193
+ /** One district's members in reading order: footprint area ascending, ties on
194
+ * id so a rebuild can never reshuffle equal-footprint neighbours (sort
195
+ * stability is not enough — the INPUT order is discovery order, which moves). */
196
+ function orderDistrictMembers(
197
+ members: readonly BoardLayoutItem[],
198
+ ): { item: BoardLayoutItem; bounds: BoardBounds }[] {
199
+ return members
200
+ .map((item) => ({ item, bounds: normalizeBounds(item.bounds) }))
201
+ .sort((a, b) => {
202
+ const areaDelta = footprintArea(a.bounds) - footprintArea(b.bounds);
203
+ return areaDelta !== 0 ? areaDelta : a.item.id < b.item.id ? -1 : 1;
204
+ });
205
+ }
206
+
207
+ /** A slot mid-layout: everything known before the row's Z is settled. */
208
+ interface PendingSlot {
209
+ readonly item: BoardLayoutItem;
210
+ readonly bounds: BoardBounds;
211
+ readonly size: readonly [number, number, number];
212
+ readonly footprint: readonly [number, number];
213
+ readonly centreX: number;
214
+ }
215
+
216
+ interface PendingRow {
217
+ readonly slots: PendingSlot[];
218
+ /** The row's +X edge (last slot's right edge). */
219
+ readonly xEdge: number;
220
+ /** Deepest slot in the row. */
221
+ readonly depth: number;
222
+ }
223
+
224
+ /**
225
+ * The width a district's rows wrap at. A 13-piece collection in one single
226
+ * file reads as a queue, not a gallery — wrapping into aisles keeps a
227
+ * district's footprint near-square so the overview camera can actually get
228
+ * close. The cap is never allowed to split what cannot wrap: it always admits
229
+ * the widest single slot (a 40 m building is its own row).
230
+ */
231
+ function rowWrapWidth(members: readonly { bounds: BoardBounds }[]): number {
232
+ const widest = Math.max(
233
+ SLOT_MIN_FOOTPRINT,
234
+ ...members.map(({ bounds }) => slotSpan(slotFootprint(sizeOf(bounds)))),
235
+ );
236
+ return Math.max(10, 2.2 * widest);
237
+ }
238
+
239
+ /**
240
+ * Break one district's ordered members into rows along +X, wrapping at
241
+ * {@link rowWrapWidth}. Row-major reading order is preserved: the footprint
242
+ * sort walks row by row, small → large.
243
+ */
244
+ function wrapDistrictRows(
245
+ members: readonly { item: BoardLayoutItem; bounds: BoardBounds }[],
246
+ neighbourGapFactor: number,
247
+ minGap: number,
248
+ ): PendingRow[] {
249
+ const wrapWidth = rowWrapWidth(members);
250
+ const rows: PendingRow[] = [];
251
+ let current: { slots: PendingSlot[]; xEdge: number; depth: number; previousSpan: number | null } =
252
+ { slots: [], xEdge: 0, depth: 0, previousSpan: null };
253
+ const closeRow = () => {
254
+ if (current.slots.length > 0) {
255
+ rows.push({ slots: current.slots, xEdge: current.xEdge, depth: current.depth });
256
+ }
257
+ current = { slots: [], xEdge: 0, depth: 0, previousSpan: null };
258
+ };
259
+ for (const { item, bounds } of members) {
260
+ const size = sizeOf(bounds);
261
+ const footprint = slotFootprint(size);
262
+ const span = slotSpan(footprint);
263
+ const gap =
264
+ current.previousSpan === null
265
+ ? 0
266
+ : Math.max(minGap, neighbourGapFactor * Math.max(current.previousSpan, span));
267
+ if (current.slots.length > 0 && current.xEdge + gap + footprint[0] > wrapWidth) {
268
+ closeRow();
269
+ }
270
+ const lead = current.previousSpan === null ? 0 : gap;
271
+ const centreX = current.xEdge + lead + footprint[0] / 2;
272
+ current.xEdge = centreX + footprint[0] / 2;
273
+ current.depth = Math.max(current.depth, footprint[1]);
274
+ current.previousSpan = span;
275
+ current.slots.push({ item, bounds, size, footprint, centreX });
276
+ }
277
+ closeRow();
278
+ return rows;
279
+ }
280
+
281
+ /**
282
+ * Place one district's rows, front row at `zStart`, wrapping deeper rows
283
+ * behind it. Returns the placements and the district's own footprint.
284
+ */
285
+ function layoutDistrict(
286
+ groupKey: string,
287
+ members: readonly { item: BoardLayoutItem; bounds: BoardBounds }[],
288
+ zStart: number,
289
+ neighbourGapFactor: number,
290
+ minGap: number,
291
+ ): { placements: BoardItemPlacement[]; xEdge: number; depth: number } {
292
+ const rows = wrapDistrictRows(members, neighbourGapFactor, minGap);
293
+ const placements: BoardItemPlacement[] = [];
294
+ let zEdge = zStart;
295
+ let previousDepth: number | null = null;
296
+ let xEdge = 0;
297
+ for (const row of rows) {
298
+ if (previousDepth !== null) {
299
+ zEdge += Math.max(minGap, neighbourGapFactor * Math.max(previousDepth, row.depth));
300
+ }
301
+ const centreZ = zEdge + row.depth / 2;
302
+ zEdge = centreZ + row.depth / 2;
303
+ previousDepth = row.depth;
304
+ xEdge = Math.max(xEdge, row.xEdge);
305
+ for (const slot of row.slots) {
306
+ placements.push({
307
+ id: slot.item.id,
308
+ groupKey,
309
+ translation: [
310
+ slot.centreX - (slot.bounds.min[0] + slot.bounds.max[0]) / 2,
311
+ groundTranslationY(slot.bounds),
312
+ centreZ - (slot.bounds.min[2] + slot.bounds.max[2]) / 2,
313
+ ],
314
+ anchor: [slot.centreX, 0, centreZ],
315
+ size: slot.size,
316
+ footprint: slot.footprint,
317
+ });
318
+ }
319
+ }
320
+ return { placements, xEdge, depth: zEdge - zStart };
321
+ }
322
+
323
+ /** The pad's breathing room around its row of slots. */
324
+ function padMargin(rowDepth: number): number {
325
+ return Math.min(3, Math.max(0.6, 0.08 * rowDepth));
326
+ }
327
+
328
+ /**
329
+ * Lay out every qualified story mount at true scale. Pure: same input,
330
+ * same output, no three.js, no DOM.
331
+ */
332
+ export function layoutThreeBoard(
333
+ items: readonly BoardLayoutItem[],
334
+ options: BoardLayoutOptions = {},
335
+ ): BoardLayout {
336
+ const neighbourGapFactor = options.neighbourGapFactor ?? DEFAULTS.neighbourGapFactor;
337
+ const minGap = options.minGap ?? DEFAULTS.minGap;
338
+ const districtGapFactor = options.districtGapFactor ?? DEFAULTS.districtGapFactor;
339
+ const minDistrictGap = options.minDistrictGap ?? DEFAULTS.minDistrictGap;
340
+
341
+ const byGroup = new Map<string, BoardLayoutItem[]>();
342
+ for (const item of items) {
343
+ const bucket = byGroup.get(item.groupKey);
344
+ if (bucket) bucket.push(item);
345
+ else byGroup.set(item.groupKey, [item]);
346
+ }
347
+
348
+ const districts: BoardDistrictPlacement[] = [];
349
+ let zEdge = 0;
350
+ let previousDepth: number | null = null;
351
+
352
+ // `Map` iterates in insertion order, and insertion above is the order the
353
+ // groups first appear in `items` — rule 1's first-appearance ordering, shared
354
+ // with the React story board's sections.
355
+ for (const groupKey of byGroup.keys()) {
356
+ const members = orderDistrictMembers(byGroup.get(groupKey) ?? []);
357
+ // Two passes: measure the district's own footprint first, then place it —
358
+ // the pad margin depends on the measured depth.
359
+ const measured = layoutDistrict(groupKey, members, 0, neighbourGapFactor, minGap);
360
+ const margin = padMargin(Math.max(measured.depth, SLOT_MIN_FOOTPRINT));
361
+ const padDepth = measured.depth + 2 * margin;
362
+ if (previousDepth !== null) {
363
+ zEdge += Math.max(minDistrictGap, districtGapFactor * Math.max(previousDepth, padDepth));
364
+ }
365
+ const padMinZ = zEdge;
366
+ zEdge = padMinZ + padDepth;
367
+ previousDepth = padDepth;
368
+
369
+ const { placements, xEdge } = layoutDistrict(
370
+ groupKey,
371
+ members,
372
+ padMinZ + margin,
373
+ neighbourGapFactor,
374
+ minGap,
375
+ );
376
+ const extent: BoardExtent = {
377
+ minX: -margin,
378
+ maxX: xEdge + margin,
379
+ minZ: padMinZ,
380
+ maxZ: padMinZ + padDepth,
381
+ };
382
+ districts.push({
383
+ groupKey,
384
+ items: placements,
385
+ extent,
386
+ anchor: [(extent.minX + extent.maxX) / 2, 0, extent.minZ],
387
+ });
388
+ }
389
+
390
+ const extent: BoardExtent =
391
+ districts.length === 0
392
+ ? { minX: 0, maxX: 0, minZ: 0, maxZ: 0 }
393
+ : {
394
+ minX: Math.min(...districts.map((district) => district.extent.minX)),
395
+ maxX: Math.max(...districts.map((district) => district.extent.maxX)),
396
+ minZ: Math.min(...districts.map((district) => district.extent.minZ)),
397
+ maxZ: Math.max(...districts.map((district) => district.extent.maxZ)),
398
+ };
399
+
400
+ return { districts, extent };
401
+ }