@pascal-app/editor 1.0.7 → 1.1.1

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 (336) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +101 -45
  5. package/src/components/editor/duplicate-node.ts +2 -1
  6. package/src/components/editor/editor-layout-v2.tsx +1 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +20 -1983
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +5 -3
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +8 -4
  15. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  16. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  17. package/src/components/editor/floorplan-panel.tsx +199 -30
  18. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  19. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  20. package/src/components/editor/follow-pascal.test.tsx +1 -2
  21. package/src/components/editor/follow-pascal.tsx +1 -1
  22. package/src/components/editor/footprint-height-handle.tsx +2 -2
  23. package/src/components/editor/group-actions.ts +1 -2
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-move-3d.ts +1 -2
  26. package/src/components/editor/group-rotate-handle.tsx +1 -2
  27. package/src/components/editor/group-transform-shared.ts +3 -1
  28. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  29. package/src/components/editor/index.tsx +43 -13
  30. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  31. package/src/components/editor/measurement-pill.tsx +1 -1
  32. package/src/components/editor/node-arrow-handles.tsx +2 -2
  33. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  34. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  35. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  36. package/src/components/editor/quick-measurement-card.tsx +1 -1
  37. package/src/components/editor/room-controls.tsx +10 -6
  38. package/src/components/editor/room-handles-2d.tsx +3 -3
  39. package/src/components/editor/room-handles.tsx +4 -5
  40. package/src/components/editor/room-highlight.tsx +6 -105
  41. package/src/components/editor/room-labels.test.ts +8 -822
  42. package/src/components/editor/room-labels.tsx +41 -1670
  43. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  44. package/src/components/editor/selection-manager.tsx +5 -3
  45. package/src/components/editor/site-edge-labels.tsx +7 -3
  46. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  47. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  48. package/src/components/editor/views/plugins-view.tsx +1 -1
  49. package/src/components/editor/views/view-bar.tsx +9 -9
  50. package/src/components/editor/wall-measurement-label.tsx +31 -15
  51. package/src/components/editor/wall-move-side-handles.tsx +11 -7
  52. package/src/components/editor/wall-pull-grip.tsx +2 -2
  53. package/src/components/editor/wall-region-handles.tsx +2 -2
  54. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  55. package/src/components/editor-2d/floorplan-group-move.tsx +3 -4
  56. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +13 -13
  57. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +2 -3
  58. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  59. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +23 -6
  60. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  62. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  63. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  64. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +14 -3
  65. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  66. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +30 -10
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +39 -35
  69. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  70. package/src/components/systems/roof/roof-edit-system.tsx +33 -21
  71. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  72. package/src/components/tools/distribution/distribution-run-tool.tsx +151 -33
  73. package/src/components/tools/item/move-tool.tsx +1 -6
  74. package/src/components/tools/item/placement-history.test.tsx +1 -1
  75. package/src/components/tools/item/use-placement-coordinator.tsx +3 -4
  76. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +7 -3
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +39 -16
  79. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  80. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  81. package/src/components/tools/shared/placement-box.tsx +2 -6
  82. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  83. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  84. package/src/components/tools/shared/segment-angle.ts +0 -4
  85. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  86. package/src/components/tools/tool-manager.tsx +1 -24
  87. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  88. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  89. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  90. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  91. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  92. package/src/components/ui/action-menu/index.tsx +3 -3
  93. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  94. package/src/components/ui/action-menu/placement.ts +0 -18
  95. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  96. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  97. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  98. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  99. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  100. package/src/components/ui/controls/select-control.tsx +1 -1
  101. package/src/components/ui/controls/slider-control.tsx +18 -6
  102. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  103. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  104. package/src/components/ui/floating-level-selector.tsx +3 -3
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  106. package/src/components/ui/helpers/helper-manager.test.tsx +54 -1
  107. package/src/components/ui/helpers/helper-manager.tsx +55 -1
  108. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  109. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  110. package/src/components/ui/panels/floor-foundation-panel.tsx +3 -3
  111. package/src/components/ui/panels/floor-opening-panel.tsx +5 -5
  112. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  113. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  114. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  115. package/src/components/ui/panels/node-display.ts +1 -1
  116. package/src/components/ui/panels/panel-manager.tsx +5 -3
  117. package/src/components/ui/panels/panel-wrapper.tsx +42 -19
  118. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  119. package/src/components/ui/panels/room-construction-rows.tsx +7 -7
  120. package/src/components/ui/panels/room-name-options.ts +1 -5
  121. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  122. package/src/components/ui/panels/room-panel.tsx +7 -9
  123. package/src/components/ui/primitives/color-dot.tsx +2 -2
  124. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  125. package/src/components/ui/primitives/sidebar.tsx +3 -3
  126. package/src/components/ui/right-stack.test.ts +13 -6
  127. package/src/components/ui/right-stack.tsx +22 -11
  128. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  129. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  130. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  131. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  132. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  133. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  134. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +2 -6
  135. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  136. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  137. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/site-panel/index.tsx +3 -10
  139. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  140. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  141. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  142. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  144. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  145. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  146. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  147. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  148. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  149. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +2 -2
  150. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  151. package/src/components/viewer-overlay.tsx +1 -2
  152. package/src/hooks/use-auto-frame.ts +1 -2
  153. package/src/hooks/use-keyboard.ts +1 -2
  154. package/src/hooks/use-selected-room.ts +5 -7
  155. package/src/i18n/floor-footprint-name.ts +1 -1
  156. package/src/i18n/locales/en/messages.po +1036 -306
  157. package/src/i18n/locales/en/messages.ts +1 -1
  158. package/src/i18n/locales/en-XA/messages.ts +1 -1
  159. package/src/i18n/locales/es/messages.po +2267 -439
  160. package/src/i18n/locales/fr/messages.po +2267 -439
  161. package/src/i18n/locales/ja/messages.po +2267 -439
  162. package/src/i18n/locales/zh-CN/messages.po +2267 -439
  163. package/src/i18n/locales.test.ts +1 -1
  164. package/src/index.tsx +183 -178
  165. package/src/lib/below-grade.test.tsx +4 -4
  166. package/src/lib/camera-facing.test.ts +1 -1
  167. package/src/lib/camera-facing.ts +2 -3
  168. package/src/lib/contextual-help.ts +6 -0
  169. package/src/lib/editor-level-display.ts +1 -1
  170. package/src/lib/elevation-guides.ts +21 -6
  171. package/src/lib/elevator-support.ts +1 -72
  172. package/src/lib/floor-footprints.test.ts +3 -3
  173. package/src/lib/floor-footprints.ts +1 -9
  174. package/src/lib/floor-foundation-support.test.ts +8 -3
  175. package/src/lib/floor-opening-draft.ts +2 -2
  176. package/src/lib/floor-region-session.ts +1 -1
  177. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  178. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  179. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  180. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  181. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  182. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  183. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  184. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  185. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  186. package/src/lib/floorplan/geometry.ts +2 -112
  187. package/src/lib/floorplan/index.ts +0 -9
  188. package/src/lib/floorplan/items.ts +2 -1
  189. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  190. package/src/lib/floorplan/schedules.ts +6 -3
  191. package/src/lib/floorplan/sheet-export.ts +7 -3
  192. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  193. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  194. package/src/lib/follow-pascal-presentation.ts +1 -1
  195. package/src/lib/gesture-lifecycle.ts +3 -1
  196. package/src/lib/glb-export.ts +1 -1
  197. package/src/lib/interaction/scope.ts +1 -0
  198. package/src/lib/layer-thickness.ts +1 -1
  199. package/src/lib/level-selection.test.ts +18 -2
  200. package/src/lib/level-selection.ts +31 -8
  201. package/src/lib/linear-display.ts +1 -1
  202. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  203. package/src/lib/material-paint.ts +1 -1
  204. package/src/lib/measurement-label.test.ts +1 -1
  205. package/src/lib/measurements.test.ts +7 -5
  206. package/src/lib/mezzanine-selection.test.ts +1 -1
  207. package/src/lib/node-action-movement.test.tsx +115 -0
  208. package/src/lib/node-action-movement.ts +2 -0
  209. package/src/lib/paint-region-mode.ts +1 -1
  210. package/src/lib/placement-metadata.ts +4 -0
  211. package/src/lib/point-ask/describe-target.ts +6 -8
  212. package/src/lib/point-ask/region.ts +1 -1
  213. package/src/lib/point-ask/session.ts +1 -2
  214. package/src/lib/print-shell-compiler.ts +12 -1
  215. package/src/lib/print-wall-solids.test.ts +11 -0
  216. package/src/lib/print-wall-solids.ts +9 -0
  217. package/src/lib/roof-duplication.test.ts +100 -0
  218. package/src/lib/roof-duplication.ts +30 -5
  219. package/src/lib/room-built-on.test.ts +2 -5
  220. package/src/lib/room-built-on.ts +1 -15
  221. package/src/lib/room-construction-commands.ts +3 -2
  222. package/src/lib/room-controls-phase6.test.ts +1 -1
  223. package/src/lib/room-handle-drag.ts +8 -37
  224. package/src/lib/room-name-catalog.test.ts +2 -1
  225. package/src/lib/room-name-messages.test.ts +2 -1
  226. package/src/lib/room-name-messages.ts +1 -118
  227. package/src/lib/room-name-storage.test.ts +2 -1
  228. package/src/lib/room-push-dimensions.test.ts +1 -1
  229. package/src/lib/room-selection-commands.ts +2 -2
  230. package/src/lib/room-selection.test.ts +6 -6
  231. package/src/lib/room-transform-session.ts +1 -2
  232. package/src/lib/room-zone-routing.ts +2 -2
  233. package/src/lib/scene-clipboard.ts +2 -1
  234. package/src/lib/selection-routing.test.ts +0 -28
  235. package/src/lib/selection-routing.ts +1 -40
  236. package/src/lib/sfx-bus.ts +8 -32
  237. package/src/lib/sfx-player.test.ts +72 -1
  238. package/src/lib/sfx-player.ts +49 -337
  239. package/src/lib/snapping-mode.test.ts +23 -1
  240. package/src/lib/viewer-selection.test.ts +38 -10
  241. package/src/lib/wall-pull.test.ts +1 -1
  242. package/src/lib/wall-pull.ts +1 -27
  243. package/src/lib/wall-region-list.ts +3 -1
  244. package/src/lib/window-interaction.test.ts +4 -2
  245. package/src/store/use-editor.tsx +22 -26
  246. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  247. package/src/store/use-placement-typing.test.ts +165 -0
  248. package/src/store/use-placement-typing.ts +186 -0
  249. package/src/store/use-wall-region-handles-selection.ts +1 -2
  250. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  251. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  252. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  253. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  254. package/src/components/editor/first-person/ground-holes.ts +0 -130
  255. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  256. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  257. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  258. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  259. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  260. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  261. package/src/components/editor/localized-html.tsx +0 -22
  262. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  263. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  264. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  265. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  266. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  267. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  268. package/src/components/editor-2d/svg-paths.ts +0 -124
  269. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  270. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  271. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  272. package/src/components/ui/action-menu/action-button.tsx +0 -89
  273. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  274. package/src/components/ui/primitives/button.tsx +0 -69
  275. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  276. package/src/components/ui/primitives/popover.tsx +0 -42
  277. package/src/components/ui/primitives/switch.tsx +0 -29
  278. package/src/components/ui/primitives/tooltip.tsx +0 -57
  279. package/src/components/ui/scene-loader.tsx +0 -73
  280. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  281. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  282. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  283. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  284. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  285. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  286. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  287. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  288. package/src/components/viewer/render-settings-menu.tsx +0 -294
  289. package/src/components/viewer/sky-control.tsx +0 -710
  290. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  291. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  292. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  293. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  294. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  295. package/src/components/viewer/viewer-rooms.tsx +0 -248
  296. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  297. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  298. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  299. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  300. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  301. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  302. package/src/components/viewer/viewer-stage.tsx +0 -221
  303. package/src/components/walkthrough-hud.tsx +0 -193
  304. package/src/hooks/use-mobile.ts +0 -19
  305. package/src/i18n/level-name.ts +0 -39
  306. package/src/i18n/scene-theme-name.ts +0 -16
  307. package/src/lib/below-grade.ts +0 -133
  308. package/src/lib/camera-pose.test.ts +0 -237
  309. package/src/lib/camera-pose.ts +0 -172
  310. package/src/lib/door-interaction.ts +0 -102
  311. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  312. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  313. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  314. package/src/lib/floorplan/marks.test.ts +0 -213
  315. package/src/lib/floorplan/marks.ts +0 -238
  316. package/src/lib/measurement-label.ts +0 -68
  317. package/src/lib/measurements.ts +0 -225
  318. package/src/lib/node-display-label.ts +0 -37
  319. package/src/lib/room-assembly-overlay.test.ts +0 -275
  320. package/src/lib/room-assembly-overlay.ts +0 -265
  321. package/src/lib/room-selection.ts +0 -417
  322. package/src/lib/scene-bounds.test.ts +0 -183
  323. package/src/lib/scene-bounds.ts +0 -169
  324. package/src/lib/typing-target.ts +0 -9
  325. package/src/lib/viewer-selection.ts +0 -595
  326. package/src/lib/window-interaction.ts +0 -142
  327. package/src/lib/zone-content.ts +0 -133
  328. package/src/store/camera-pose-store.test.ts +0 -29
  329. package/src/store/camera-pose-store.ts +0 -27
  330. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  331. package/src/store/navigation-sync-pose-store.ts +0 -27
  332. package/src/store/use-audio.test.ts +0 -146
  333. package/src/store/use-audio.tsx +0 -169
  334. package/src/store/use-first-person-hud.ts +0 -40
  335. package/src/store/use-follow-pascal.test.ts +0 -62
  336. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,1993 +1,30 @@
1
1
  'use client'
2
2
 
3
- import {
4
- type AnyNode,
5
- type AnyNodeId,
6
- type ElevatorDoorSide,
7
- type ElevatorNode,
8
- emitter,
9
- getElevatorCabCenterZ,
10
- getElevatorCabDepth,
11
- getElevatorCabWidth,
12
- getElevatorDoorLeafSides,
13
- getElevatorDoorLeafWidth,
14
- getElevatorDoorLeafX,
15
- getElevatorShaftDepth,
16
- getElevatorShaftWallThickness,
17
- getElevatorShaftWidth,
18
- getLevelDisplayName,
19
- getLevelElevations,
20
- getResolvedElevatorDoorStyle,
21
- type InteractionSound,
22
- itemPrompt,
23
- nodeMechanism,
24
- openElevatorDoor,
25
- operateItem,
26
- pointInPolygon2D,
27
- requestElevatorLevel,
28
- resolveElevatorDispatchTarget,
29
- resolveElevatorLevels,
30
- sceneRegistry,
31
- toggleNodeMechanism,
32
- useInteractive,
33
- useScene,
34
- } from '@pascal-app/core'
35
- import {
36
- operablePartFor,
37
- operableParts,
38
- type ProceduralItemNode,
39
- } from '@pascal-app/core/procedural-items'
40
- import {
41
- applyWalkthroughCameraClipping,
42
- BVHEcctrl,
43
- type BVHEcctrlApi,
44
- CROUCH_CAPSULE,
45
- CROUCH_EYE_OFFSET,
46
- CROUCH_FLOAT_HEIGHT,
47
- CROUCH_RUN_SPEED,
48
- CROUCH_WALK_SPEED,
49
- createDroneInput,
50
- DRONE_SLOW_MULTIPLIER,
51
- type DroneLook,
52
- EYE_LERP_SPEED,
53
- type MovementInput,
54
- openingSettled,
55
- proceduralSettled,
56
- STAND_CAPSULE,
57
- STAND_CLEARANCE,
58
- STAND_FLOAT_HEIGHT,
59
- seedDroneLook,
60
- setSurfaceRaycastLayers,
61
- stepDroneFlight,
62
- turnFirstPersonLook,
63
- useViewer,
64
- WALKTHROUGH_FOV,
65
- } from '@pascal-app/viewer'
66
- import { KeyboardControls } from '@react-three/drei'
67
- import { useFrame, useThree } from '@react-three/fiber'
68
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
69
- import {
70
- Box3,
71
- BoxGeometry,
72
- Euler,
73
- type Group,
74
- Matrix4,
75
- Mesh,
76
- MeshBasicMaterial,
77
- type Object3D,
78
- type PerspectiveCamera,
79
- Ray,
80
- Raycaster,
81
- Vector2,
82
- Vector3,
83
- } from 'three'
84
- import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
85
- import '../../three-types'
86
- import {
87
- closeDoorOpenState,
88
- DOOR_SWING_OPEN_ANGLE,
89
- getDisplayedDoorValue,
90
- isOperationDoorType,
91
- toggleDoorOpenState,
92
- } from '../../lib/door-interaction'
93
- import { isTypingTarget } from '../../lib/typing-target'
94
- import {
95
- closeWindowOpenState,
96
- getDisplayedWindowValue,
97
- isOperableWindowType,
98
- toggleWindowOpenState,
99
- } from '../../lib/window-interaction'
3
+ import { useScene } from '@pascal-app/core'
4
+ import { LiveWalkthroughController, useFirstPersonHud, useViewer } from '@pascal-app/viewer'
5
+ import { DroneHud, WalkthroughHud } from '@pascal-app/viewer/ui'
6
+ import { useCallback } from 'react'
100
7
  import useEditor from '../../store/use-editor'
101
- import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
102
- import { DroneHud, WalkthroughHud } from '../walkthrough-hud'
103
- import {
104
- buildFirstPersonColliderWorldFromRegistry,
105
- deriveFirstPersonSpawn,
106
- FIRST_PERSON_SPAWN_EYE_HEIGHT,
107
- type FirstPersonColliderWorld,
108
- type FirstPersonSpawn,
109
- } from './first-person/build-collider-world'
110
- import {
111
- hasNativeLeaf,
112
- mechanismHudInteract,
113
- mechanismTargetIds,
114
- nativeOpeningIds,
115
- watchScriptedDoors,
116
- } from './first-person/mechanism-targets'
117
8
 
118
- const CAMERA_EYE_OFFSET = 0.45
119
- const CONTROLLER_CENTER_FROM_EYE = 0.85
120
- const DOOR_INTERACTION_DISTANCE = 2.5
121
- const DOOR_LEAF_INTERACTION_DEPTH = 0.08
122
- const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
123
- const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
124
- const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
125
- const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
126
- const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
127
- const VOID_FALL_RESPAWN_DEPTH = 12
128
- const HUD_LABEL_SAMPLE_FRAMES = 10
129
-
130
- type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
131
-
132
- const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
133
- { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
134
- { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
135
- { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
136
- { name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
137
- { name: 'jump', keys: ['Space'] },
138
- { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
139
- ]
140
- const keyboardMap = movementKeyboardBindings
141
- const movementKeyToName = new Map<string, MovementKeyName>(
142
- movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
143
- )
144
-
145
- const inactiveMovementInput: MovementInput = {
146
- backward: false,
147
- forward: false,
148
- jump: false,
149
- leftward: false,
150
- rightward: false,
151
- run: false,
152
- }
153
-
154
- function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
155
- const name = movementKeyToName.get(code)
156
- return name ? ({ [name]: active } as MovementInput) : null
157
- }
158
-
159
- function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
160
- const activeElement = document.activeElement
161
- if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
162
- activeElement.blur()
163
- }
164
-
165
- if (!canvas.hasAttribute('tabindex')) {
166
- canvas.tabIndex = -1
167
- }
168
- canvas.focus({ preventScroll: true })
169
- }
170
-
171
- const cameraOffset = new Vector3()
172
- const cameraEuler = new Euler(0, 0, 0, 'YXZ')
173
- const droneInput = createDroneInput()
174
- const standClearanceRaycaster = new Raycaster()
175
- const standClearanceUp = new Vector3(0, 1, 0)
176
- const centerScreenPoint = new Vector2(0, 0)
177
- const doorInteractionRaycaster = new Raycaster()
178
- setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
179
- const proceduralInteractionRaycaster = new Raycaster()
180
- setSurfaceRaycastLayers(proceduralInteractionRaycaster.layers)
181
- const doorLeafBox = new Box3()
182
- const doorLeafInverseMatrix = new Matrix4()
183
- const doorLeafLocalHit = new Vector3()
184
- const doorLeafLocalRay = new Ray()
185
- const doorLeafMatrix = new Matrix4()
186
- const doorLeafWorldHit = new Vector3()
187
- const doorOpeningBox = new Box3()
188
- const doorOpeningInverseMatrix = new Matrix4()
189
- const doorOpeningLocalHit = new Vector3()
190
- const doorOpeningLocalRay = new Ray()
191
- const doorOpeningMatrix = new Matrix4()
192
- const doorOpeningWorldHit = new Vector3()
193
- const elevatorLocalControllerPosition = new Vector3()
194
- const elevatorInteractionRaycaster = new Raycaster()
195
- const elevatorColliderMatrix = new Matrix4()
196
- const elevatorColliderLocalMatrix = new Matrix4()
197
- const elevatorLocalEyePosition = new Vector3()
198
- const elevatorWorldControllerPosition = new Vector3()
199
- const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
200
- const spawnWorldPosition = new Vector3()
201
- const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
202
- const windowInteractionRaycaster = new Raycaster()
203
- setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
204
- const hudBuildingLocalEyePosition = new Vector3()
205
- const hudWorldEyePosition = new Vector3()
206
- const hudLevelBounds = new Box3()
207
-
208
- type ElevatorColliderKind =
209
- | 'cab-back'
210
- | 'cab-ceiling'
211
- | 'cab-door-left'
212
- | 'cab-door-right'
213
- | 'cab-door-gate'
214
- | 'cab-floor'
215
- | 'cab-left'
216
- | 'cab-right'
217
- | 'landing-door-gate'
218
- | 'landing-door-left'
219
- | 'landing-door-right'
220
- | 'shaft-back'
221
- | 'shaft-front-header'
222
- | 'shaft-front-left'
223
- | 'shaft-front-right'
224
- | 'shaft-left'
225
- | 'shaft-right'
226
- | 'shaft-top'
227
-
228
- type ElevatorColliderUserData = {
229
- doorWidth?: number
230
- dynamic?: boolean
231
- elevatorId: AnyNodeId
232
- kind: ElevatorColliderKind
233
- levelId?: AnyNodeId
234
- localPosition: [number, number, number]
235
- matrixInitialized?: boolean
236
- side?: ElevatorDoorSide
237
- }
238
-
239
- type ElevatorColliderMesh = Mesh & {
240
- userData: Mesh['userData'] & ElevatorColliderUserData
241
- }
242
-
243
- type FirstPersonInteractableTarget =
244
- | {
245
- id: AnyNodeId
246
- type: 'door' | 'window'
247
- }
248
- | {
249
- id: AnyNodeId
250
- type: 'item'
251
- }
252
- | {
253
- id: AnyNodeId
254
- partId?: string
255
- kind?: 'hinge' | 'slide' | 'spin'
256
- type: 'procedural'
257
- }
258
- | {
259
- id: AnyNodeId
260
- type: 'mechanism'
261
- }
262
- | {
263
- action: 'open-door' | 'request-level'
264
- buttonKind: 'cab' | 'landing'
265
- id: AnyNodeId
266
- levelId?: AnyNodeId
267
- type: 'elevator'
268
- }
269
-
270
- type ElevatorButtonTarget = {
271
- action: 'open-door' | 'request-level'
272
- buttonKind: 'cab' | 'landing'
273
- elevatorId: AnyNodeId
274
- levelId?: AnyNodeId
275
- }
276
-
277
- function getLevelChildren(
278
- level: Extract<AnyNode, { type: 'level' }>,
279
- nodes: Record<string, AnyNode>,
280
- ) {
281
- const childIds = new Set<string>(level.children)
282
- return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
283
- }
284
-
285
- function pointIsInLevelFootprint(
286
- point: [number, number],
287
- worldPoint: Vector3,
288
- level: Extract<AnyNode, { type: 'level' }>,
289
- nodes: Record<string, AnyNode>,
290
- ) {
291
- const children = getLevelChildren(level, nodes)
292
- const slabs = children.filter(
293
- (node): node is Extract<AnyNode, { type: 'slab' }> =>
294
- node.type === 'slab' && node.polygon.length >= 3,
295
- )
296
- const zones = children.filter(
297
- (node): node is Extract<AnyNode, { type: 'zone' }> =>
298
- node.type === 'zone' && node.polygon.length >= 3,
299
- )
300
-
301
- if (slabs.length > 0) {
302
- return slabs.some(
303
- (slab) =>
304
- pointInPolygon2D(point, slab.polygon) &&
305
- !slab.holes.some((hole) => pointInPolygon2D(point, hole)),
306
- )
307
- }
308
-
309
- if (zones.length > 0) {
310
- if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
311
- }
312
-
313
- const levelObject = sceneRegistry.nodes.get(level.id)
314
- if (!levelObject) return false
315
- hudLevelBounds.setFromObject(levelObject)
316
- return (
317
- !hudLevelBounds.isEmpty() &&
318
- worldPoint.x >= hudLevelBounds.min.x &&
319
- worldPoint.x <= hudLevelBounds.max.x &&
320
- worldPoint.z >= hudLevelBounds.min.z &&
321
- worldPoint.z <= hudLevelBounds.max.z
322
- )
323
- }
324
-
325
- function resolveFirstPersonHudLabels(worldPoint: Vector3) {
326
- const nodes = useScene.getState().nodes
327
- const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
328
-
329
- for (const building of Object.values(nodes)) {
330
- if (building.type !== 'building') continue
331
- const buildingObject = sceneRegistry.nodes.get(building.id)
332
- if (!buildingObject) continue
333
-
334
- buildingObject.updateWorldMatrix(true, true)
335
- hudBuildingLocalEyePosition.copy(worldPoint)
336
- buildingObject.worldToLocal(hudBuildingLocalEyePosition)
337
-
338
- const levels = Object.values(nodes)
339
- .filter((node) => node.type === 'level')
340
- .filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
341
- .sort(
342
- (left, right) =>
343
- (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
344
- )
345
-
346
- let activeLevel: (typeof levels)[number] | null = null
347
- for (const level of levels) {
348
- const elevation = levelElevations.get(level.id)
349
- if (!elevation) continue
350
- if (
351
- hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
352
- hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
353
- ) {
354
- activeLevel = level
355
- }
356
- }
357
- if (!activeLevel) continue
358
-
359
- const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
360
- if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
361
-
362
- const zone = getLevelChildren(activeLevel, nodes).find(
363
- (node) =>
364
- node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
365
- )
366
-
367
- return {
368
- floorLabel: getLevelDisplayName(activeLevel),
369
- zoneLabel: zone?.type === 'zone' ? zone.name : null,
370
- }
371
- }
372
-
373
- return { floorLabel: null, zoneLabel: null }
374
- }
375
-
376
- function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
377
- if (!target) return null
378
- if (target.type === 'elevator') {
379
- return {
380
- label: target.action === 'open-door' ? 'door button' : 'elevator button',
381
- verb: 'press',
382
- }
383
- }
384
-
385
- const node = useScene.getState().nodes[target.id]
386
- if (target.type === 'mechanism') return node ? mechanismHudInteract(node) : null
387
- if (target.type === 'item') {
388
- if (node?.type !== 'item' || !node.asset.interactive) return null
389
- const { label, verb } = itemPrompt(
390
- target.id,
391
- node.name ?? node.asset.name,
392
- node.asset.interactive,
393
- )
394
- return { label, verb }
395
- }
396
- if (target.type === 'procedural') {
397
- if (node?.type !== 'procedural-item') return null
398
- const procedural = node as ProceduralItemNode
399
- const parts = operableParts(procedural.recipe)
400
- if (parts.length === 0 && procedural.recipe.parts.some((part) => part.light)) {
401
- const state = useInteractive.getState()
402
- const isOn = state.procedural[target.id]?.lightsOn ?? state.lampDefault
403
- return { label: procedural.name ?? 'Lights', verb: isOn ? 'turn off' : 'turn on' }
404
- }
405
- const part = target.partId ? parts.find((entry) => entry.id === target.partId) : undefined
406
- const active = useInteractive.getState().procedural[target.id]?.parts
407
- const isOn = part ? Boolean(active?.[part.id]) : parts.some((entry) => active?.[entry.id])
408
- const kind = part?.kind ?? (parts.every((entry) => entry.kind === 'spin') ? 'spin' : 'hinge')
409
- return {
410
- label: part?.label ?? procedural.name ?? 'item',
411
- verb: kind === 'spin' ? (isOn ? 'turn off' : 'turn on') : isOn ? 'close' : 'open',
412
- }
413
- }
414
- if (target.type === 'window') {
415
- if (node?.type !== 'window') return null
416
- const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
417
- return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
418
- }
419
-
420
- if (node?.type !== 'door') return null
421
- const isOpen = isOperationDoorType(node.doorType)
422
- ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
423
- : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
424
- return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
425
- }
426
-
427
- /** E plays what the viewer's callout plays for the same door, window or switch. */
428
- function playTargetSound(target: FirstPersonInteractableTarget) {
429
- const verb = resolveHudInteract(target)?.verb
430
- if (!verb || verb === 'press') return
431
- const node = useScene.getState().nodes[target.id]
432
- // A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end.
433
- if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) {
434
- if (verb === 'open') {
435
- emitter.emit('interaction:sound', 'panel-open')
436
- return
437
- }
438
- if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing')
439
- void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close'))
440
- return
441
- }
442
- let sound: InteractionSound
443
- if (verb !== 'open' && verb !== 'close')
444
- sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off'
445
- else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage'
446
- else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close'
447
- else sound = verb === 'open' ? 'door-open' : 'door-close'
448
- // A built-in leaf or sash swings shut, then latches on the frame it lands.
449
- if (
450
- (sound === 'door-close' || sound === 'window-close') &&
451
- (target.type === 'door' || target.type === 'window') &&
452
- node
453
- ) {
454
- emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing')
455
- void openingSettled(node).then(() => emitter.emit('interaction:sound', sound))
456
- return
457
- }
458
- emitter.emit('interaction:sound', sound)
459
- }
460
-
461
- function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
462
- let current: Object3D | null = object
463
-
464
- while (current) {
465
- const candidate = (
466
- current.userData as {
467
- elevatorButton?: {
468
- action?: unknown
469
- disabled?: unknown
470
- elevatorId?: unknown
471
- kind?: unknown
472
- levelId?: unknown
473
- }
474
- }
475
- ).elevatorButton
476
-
477
- if (candidate?.disabled === true) {
478
- return null
479
- }
480
-
481
- if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
482
- const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
483
- if (action === 'open-door') {
484
- return {
485
- action,
486
- buttonKind: candidate.kind,
487
- elevatorId: candidate.elevatorId as AnyNodeId,
488
- }
489
- }
490
- }
491
-
492
- if (
493
- typeof candidate?.elevatorId === 'string' &&
494
- typeof candidate.levelId === 'string' &&
495
- (candidate.kind === 'cab' || candidate.kind === 'landing')
496
- ) {
497
- return {
498
- action: 'request-level',
499
- buttonKind: candidate.kind,
500
- elevatorId: candidate.elevatorId as AnyNodeId,
501
- levelId: candidate.levelId as AnyNodeId,
502
- }
503
- }
504
-
505
- current = current.parent
506
- }
507
-
508
- return null
509
- }
510
-
511
- function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
512
- if (!target) return null
513
- return target.type === 'elevator'
514
- ? `${target.type}:${target.id}:${target.levelId}`
515
- : target.type === 'procedural'
516
- ? `${target.type}:${target.id}:${target.partId ?? 'all'}`
517
- : `${target.type}:${target.id}`
518
- }
519
-
520
- function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
521
- return kind.startsWith('cab-') || kind.startsWith('landing-door')
522
- }
523
-
524
- function isInsideElevatorCab(
525
- elevator: ElevatorNode,
526
- runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
527
- localEyePosition: Vector3,
528
- ) {
529
- const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
530
- const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
531
- const cabCenterZ = getElevatorCabCenterZ(elevator)
532
- const cabHeight = Math.max(elevator.cabHeight, 1.4)
533
-
534
- return (
535
- Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
536
- Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
537
- localEyePosition.y >= runtime.carY + 0.35 &&
538
- localEyePosition.y <= runtime.carY + cabHeight + 0.7
539
- )
540
- }
541
-
542
- function createElevatorColliderMesh(
543
- elevatorId: AnyNodeId,
544
- kind: ElevatorColliderKind,
545
- size: [number, number, number],
546
- localPosition: [number, number, number],
547
- userData: Partial<ElevatorColliderUserData> = {},
548
- ) {
549
- const geometry = new BoxGeometry(size[0], size[1], size[2])
550
-
551
- const bvhGeometry = geometry as typeof geometry & {
552
- computeBoundsTree?: typeof computeBoundsTree
553
- disposeBoundsTree?: typeof disposeBoundsTree
554
- }
555
- ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
556
- ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
557
- bvhGeometry.computeBoundsTree?.({
558
- targetLeafSize: 12,
559
- strategy: 0,
560
- } as never)
561
- bvhGeometry.computeBoundingBox()
562
-
563
- const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
564
- mesh.raycast = acceleratedRaycast
565
- mesh.matrixAutoUpdate = false
566
- mesh.visible = true
567
- mesh.userData = {
568
- ...userData,
569
- dynamic: isDynamicElevatorCollider(kind),
570
- elevatorId,
571
- excludeCollisionCheck: false,
572
- excludeFloatHit: false,
573
- friction: 0.8,
574
- kind,
575
- localPosition,
576
- matrixInitialized: false,
577
- restitution: 0.03,
578
- type: 'ELEVATOR_COLLIDER',
579
- }
580
- return mesh
581
- }
582
-
583
- function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
584
- const nodes = useScene.getState().nodes
585
- const meshes: ElevatorColliderMesh[] = []
586
-
587
- for (const elevatorId of sceneRegistry.byType.elevator!) {
588
- const typedElevatorId = elevatorId as AnyNodeId
589
- const node = nodes[typedElevatorId]
590
- if (node?.type !== 'elevator' || node.visible === false) continue
591
-
592
- const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
593
- const cabWidth = getElevatorCabWidth(node)
594
- const cabDepth = getElevatorCabDepth(node)
595
- const shaftWidth = getElevatorShaftWidth(node, cabWidth)
596
- const shaftDepth = getElevatorShaftDepth(node, cabDepth)
597
- const cabHeight = Math.max(node.cabHeight, 1.4)
598
- const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
599
- const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
600
- const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
601
- const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
602
- const wallThickness = getElevatorShaftWallThickness(node)
603
- const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
604
- const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
605
- const frontWallZ = -shaftDepth / 2 - wallThickness / 2
606
- const frontZ = frontWallZ - wallThickness / 2 - 0.018
607
- const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
608
- const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
609
- const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
610
- const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
611
-
612
- meshes.push(
613
- createElevatorColliderMesh(
614
- typedElevatorId,
615
- 'shaft-back',
616
- [shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
617
- [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
618
- ),
619
- createElevatorColliderMesh(
620
- typedElevatorId,
621
- 'shaft-left',
622
- [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
623
- [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
624
- ),
625
- createElevatorColliderMesh(
626
- typedElevatorId,
627
- 'shaft-right',
628
- [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
629
- [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
630
- ),
631
- createElevatorColliderMesh(
632
- typedElevatorId,
633
- 'shaft-top',
634
- [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
635
- [0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
636
- ),
637
- createElevatorColliderMesh(
638
- typedElevatorId,
639
- 'cab-floor',
640
- [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
641
- [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
642
- ),
643
- createElevatorColliderMesh(
644
- typedElevatorId,
645
- 'cab-ceiling',
646
- [cabWidth, wallThickness, cabDepth],
647
- [0, cabHeight - wallThickness / 2, cabCenterZ],
648
- ),
649
- createElevatorColliderMesh(
650
- typedElevatorId,
651
- 'cab-back',
652
- [cabWidth, cabHeight, wallThickness],
653
- [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
654
- ),
655
- createElevatorColliderMesh(
656
- typedElevatorId,
657
- 'cab-left',
658
- [wallThickness, cabHeight, cabDepth],
659
- [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
660
- ),
661
- createElevatorColliderMesh(
662
- typedElevatorId,
663
- 'cab-right',
664
- [wallThickness, cabHeight, cabDepth],
665
- [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
666
- ),
667
- createElevatorColliderMesh(
668
- typedElevatorId,
669
- 'cab-door-gate',
670
- [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
671
- [0, doorHeight / 2, frontZ],
672
- { doorWidth },
673
- ),
674
- ...doorLeafSides.map((side) =>
675
- createElevatorColliderMesh(
676
- typedElevatorId,
677
- side === 'left' ? 'cab-door-left' : 'cab-door-right',
678
- [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
679
- [0, doorHeight / 2, frontZ],
680
- { doorWidth, side },
681
- ),
682
- ),
683
- )
684
-
685
- const entrySpans = entries.map((entry, index) => {
686
- const nextEntry = entries[index + 1]
687
- return {
688
- entry,
689
- levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
690
- }
691
- })
692
-
693
- for (const { entry, levelTopY } of entrySpans) {
694
- const wallDepth = wallThickness
695
- const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
696
- const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
697
- const jambCenterOffset = doorWidth / 2 + jambWidth / 2
698
- const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
699
-
700
- meshes.push(
701
- createElevatorColliderMesh(
702
- typedElevatorId,
703
- 'shaft-front-left',
704
- [jambWidth, levelHeight, wallDepth],
705
- [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
706
- ),
707
- createElevatorColliderMesh(
708
- typedElevatorId,
709
- 'shaft-front-right',
710
- [jambWidth, levelHeight, wallDepth],
711
- [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
712
- ),
713
- createElevatorColliderMesh(
714
- typedElevatorId,
715
- 'shaft-front-header',
716
- [shaftWidth, headerHeight, wallDepth],
717
- [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
718
- ),
719
- createElevatorColliderMesh(
720
- typedElevatorId,
721
- 'landing-door-gate',
722
- [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
723
- [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
724
- { doorWidth, levelId: entry.id },
725
- ),
726
- ...doorLeafSides.map((side) =>
727
- createElevatorColliderMesh(
728
- typedElevatorId,
729
- side === 'left' ? 'landing-door-left' : 'landing-door-right',
730
- [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
731
- [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
732
- { doorWidth, levelId: entry.id, side },
733
- ),
734
- ),
735
- )
736
- }
737
- }
738
-
739
- return meshes
740
- }
741
-
742
- function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
743
- for (const mesh of meshes) {
744
- const geometry = mesh.geometry as typeof mesh.geometry & {
745
- disposeBoundsTree?: typeof disposeBoundsTree
746
- }
747
- geometry.disposeBoundsTree?.()
748
- geometry.dispose()
749
- }
750
- }
751
-
752
- const resolvePlacedSpawnNode = (
753
- nodes: ReturnType<typeof useScene.getState>['nodes'],
754
- _levelId: string | null,
755
- ) => {
756
- const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
757
- if (candidates.length === 0) return null
758
-
759
- return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
760
- }
761
-
762
- export const FirstPersonControls = () => {
763
- const { camera, gl } = useThree()
764
- const selectedLevelId = useViewer((state) => state.selection.levelId)
765
- const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
766
- const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
767
- const controllerRef = useRef<BVHEcctrlApi | null>(null)
768
- const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
769
- const hadPointerLockRef = useRef(false)
770
- const lookRef = useRef<DroneLook>({ yaw: 0, pitch: 0 })
771
- const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
772
- const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
773
- const crouchKeyRef = useRef(false)
774
- const droneAscendKeyRef = useRef(false)
775
- const droneSlowKeyRef = useRef(false)
776
- const droneDescendKeyRef = useRef(false)
777
- const droneVelocityRef = useRef(new Vector3())
778
- const suspendRef = useRef(false)
779
- const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
780
- const [crouched, setCrouched] = useState(false)
781
- const captureShutterHold = useEditor((state) => state.captureShutterHold)
782
- const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
783
- const ridingElevatorRef = useRef<{
784
- elevatorId: AnyNodeId
785
- localControllerY: number | null
786
- previousCarY: number
787
- } | null>(null)
788
- const rideLockedRef = useRef(false)
789
- const worldRef = useRef<FirstPersonColliderWorld | null>(null)
790
- const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
791
- const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
792
- const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
793
- const [controllerStart, setControllerStart] = useState<{
794
- position: [number, number, number]
795
- yaw: number
796
- } | null>(null)
797
-
798
- useEffect(() => {
799
- if (!(camera as PerspectiveCamera).isPerspectiveCamera) return
800
- return applyWalkthroughCameraClipping(camera as PerspectiveCamera)
801
- }, [camera])
802
-
803
- useEffect(() => {
804
- const previousCameraMode = useViewer.getState().cameraMode
805
- if (previousCameraMode === 'orthographic') {
806
- useViewer.getState().setCameraMode('perspective')
807
- }
808
- return () => {
809
- if (previousCameraMode === 'orthographic') {
810
- useViewer.getState().setCameraMode('orthographic')
811
- }
812
- }
813
- }, [])
814
-
815
- // While a snapshot is being framed the capture rig owns the fov (the user is
816
- // driving it from the overlay's slider), so walkthrough neither applies its
817
- // own nor restores one underneath it. Read imperatively: this must not re-run
818
- // — and therefore restore — when capture mode toggles mid-walkthrough.
819
- useEffect(() => {
820
- const perspectiveCamera = camera as PerspectiveCamera
821
- if (!perspectiveCamera.isPerspectiveCamera) return
822
- if (useEditor.getState().isCaptureMode) return
823
- const previousFov = perspectiveCamera.fov
824
- perspectiveCamera.fov = WALKTHROUGH_FOV
825
- perspectiveCamera.updateProjectionMatrix()
826
- return () => {
827
- if (useEditor.getState().isCaptureMode) return
828
- perspectiveCamera.fov = previousFov
829
- perspectiveCamera.updateProjectionMatrix()
830
- }
831
- }, [camera])
832
-
833
- useEffect(() => {
834
- useFirstPersonHud.getState().reset()
835
- return () => useFirstPersonHud.getState().reset()
836
- }, [])
837
-
838
- const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
839
- worldRef.current?.dispose()
840
- worldRef.current = nextWorld
841
- setWorld(nextWorld)
842
- }, [])
843
-
844
- const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
845
- disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
846
- elevatorColliderMeshesRef.current = nextMeshes
847
- setElevatorColliderMeshes(nextMeshes)
9
+ export function FirstPersonControls() {
10
+ const capture = useEditor((state) => state.isCaptureMode)
11
+ const paused = useEditor((state) => state.captureShutterHold)
12
+ // Read at walkthrough start and end, as the capture rig may own the FOV by then.
13
+ const captureOwnsFov = useCallback(() => useEditor.getState().isCaptureMode, [])
14
+ const onExit = useCallback(() => {
15
+ const editor = useEditor.getState()
16
+ if (editor.isCaptureMode) editor.setCaptureMode(false)
17
+ else editor.setFirstPersonMode(false)
848
18
  }, [])
849
19
 
850
- const rebuildColliderWorld = useCallback(() => {
851
- replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
852
- replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
853
- }, [replaceColliderWorld, replaceElevatorColliderMeshes])
854
-
855
- const setElevatorRideLocked = useCallback((locked: boolean) => {
856
- if (rideLockedRef.current === locked) return
857
- rideLockedRef.current = locked
858
- setIsElevatorRideLocked(locked)
859
- }, [])
860
-
861
- const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
862
- controllerRef.current = api
863
- if (api) {
864
- api.setMovement(movementInputRef.current)
865
- }
866
- }, [])
867
-
868
- const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
869
- const nodes = useScene.getState().nodes
870
- camera.updateMatrixWorld(true)
871
- doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
872
-
873
- let closestDoorId: AnyNodeId | null = null
874
- let closestDistance = DOOR_INTERACTION_DISTANCE
875
-
876
- for (const doorId of sceneRegistry.byType.door!) {
877
- const node = nodes[doorId as AnyNodeId]
878
- if (node?.type !== 'door') continue
879
- if (node.openingKind === 'opening') continue
880
- if (!hasNativeLeaf(node)) continue
881
- if (node.segments.every((segment) => segment.type === 'empty')) continue
882
-
883
- const object = sceneRegistry.nodes.get(doorId)
884
- if (!object) continue
885
-
886
- object.updateWorldMatrix(true, true)
887
-
888
- const placementHit = doorInteractionRaycaster
889
- .intersectObject(object, true)
890
- .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
891
- if (placementHit && placementHit.distance < closestDistance) {
892
- closestDoorId = doorId as AnyNodeId
893
- closestDistance = placementHit.distance
894
- }
895
-
896
- const leafW = node.width - 2 * node.frameThickness
897
- const leafH = node.height - node.frameThickness
898
- if (leafW <= 0 || leafH <= 0) continue
899
-
900
- const leafCenterY = -node.frameThickness / 2
901
-
902
- if (isOperationDoorType(node.doorType)) {
903
- doorOpeningMatrix
904
- .copy(object.matrixWorld)
905
- .multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
906
- doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
907
- doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
908
- doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
909
- doorOpeningLocalRay
910
- .copy(doorInteractionRaycaster.ray)
911
- .applyMatrix4(doorOpeningInverseMatrix)
912
-
913
- const localOpeningHit = doorOpeningLocalRay.intersectBox(
914
- doorOpeningBox,
915
- doorOpeningLocalHit,
916
- )
917
- if (!localOpeningHit) continue
918
-
919
- doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
920
- const openingHitDistance = doorOpeningWorldHit.distanceTo(
921
- doorInteractionRaycaster.ray.origin,
922
- )
923
-
924
- if (
925
- openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
926
- openingHitDistance < closestDistance
927
- ) {
928
- closestDoorId = doorId as AnyNodeId
929
- closestDistance = openingHitDistance
930
- }
931
- continue
932
- }
933
-
934
- const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
935
- const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
936
- const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
937
- const currentSwingAngle =
938
- useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
939
- const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
940
- const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
941
-
942
- doorLeafMatrix
943
- .copy(object.matrixWorld)
944
- .multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
945
- .multiply(new Matrix4().makeRotationY(leafSwingRotation))
946
- .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
947
- doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
948
- doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
949
- doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
950
- doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
951
-
952
- const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
953
- if (!localHit) continue
954
-
955
- doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
956
- const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
957
-
958
- if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
959
- closestDoorId = doorId as AnyNodeId
960
- closestDistance = hitDistance
961
- }
962
- }
963
-
964
- return closestDoorId
965
- }, [camera])
966
-
967
- const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
968
- const nodes = useScene.getState().nodes
969
- camera.updateMatrixWorld(true)
970
- windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
971
-
972
- let closestWindowId: AnyNodeId | null = null
973
- let closestDistance = DOOR_INTERACTION_DISTANCE
974
-
975
- for (const windowId of sceneRegistry.byType.window!) {
976
- const node = nodes[windowId as AnyNodeId]
977
- if (node?.type !== 'window') continue
978
- if (node.openingKind === 'opening') continue
979
- if (!hasNativeLeaf(node)) continue
980
- if (!isOperableWindowType(node.windowType)) continue
981
-
982
- const object = sceneRegistry.nodes.get(windowId)
983
- if (!object) continue
984
-
985
- const hit = windowInteractionRaycaster
986
- .intersectObject(object, true)
987
- .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
988
- if (!(hit && hit.distance < closestDistance)) continue
989
-
990
- closestWindowId = windowId as AnyNodeId
991
- closestDistance = hit.distance
992
- }
993
-
994
- return closestWindowId
995
- }, [camera])
996
-
997
- const resolveInteractableElevatorTarget =
998
- useCallback((): FirstPersonInteractableTarget | null => {
999
- const nodes = useScene.getState().nodes
1000
- camera.updateMatrixWorld(true)
1001
- elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
1002
-
1003
- let closestTarget: FirstPersonInteractableTarget | null = null
1004
- let closestDistance = DOOR_INTERACTION_DISTANCE
1005
-
1006
- for (const elevatorId of sceneRegistry.byType.elevator!) {
1007
- const typedElevatorId = elevatorId as AnyNodeId
1008
- const node = nodes[typedElevatorId]
1009
- if (node?.type !== 'elevator') continue
1010
-
1011
- const object = sceneRegistry.nodes.get(typedElevatorId)
1012
- if (!object) continue
1013
-
1014
- const runtime = useInteractive.getState().elevators[typedElevatorId]
1015
- object.updateWorldMatrix(true, true)
1016
- if (runtime) {
1017
- elevatorLocalEyePosition.copy(camera.position)
1018
- object.worldToLocal(elevatorLocalEyePosition)
1019
- }
1020
- const canUseCabButtons =
1021
- runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
1022
-
1023
- const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
1024
- for (const intersection of intersections) {
1025
- if (intersection.distance > closestDistance) continue
1026
-
1027
- const target = resolveElevatorButtonTarget(intersection.object)
1028
- if (!target || target.elevatorId !== elevatorId) continue
1029
- if (target.action === 'request-level') {
1030
- if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
1031
- }
1032
- if (target.buttonKind === 'cab' && !canUseCabButtons) continue
1033
-
1034
- closestTarget = {
1035
- action: target.action,
1036
- buttonKind: target.buttonKind,
1037
- id: target.elevatorId,
1038
- levelId: target.levelId,
1039
- type: 'elevator',
1040
- }
1041
- closestDistance = intersection.distance
1042
- }
1043
- }
1044
-
1045
- return closestTarget
1046
- }, [camera])
1047
-
1048
- const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
1049
- const elevatorTarget = resolveInteractableElevatorTarget()
1050
- if (elevatorTarget) return elevatorTarget
1051
-
1052
- const doorId = resolveInteractableDoorId()
1053
- if (doorId) return { id: doorId, type: 'door' }
1054
-
1055
- const windowId = resolveInteractableWindowId()
1056
- if (windowId) return { id: windowId, type: 'window' }
1057
-
1058
- camera.updateMatrixWorld(true)
1059
- proceduralInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
1060
- proceduralInteractionRaycaster.far = DOOR_INTERACTION_DISTANCE
1061
- let closest: FirstPersonInteractableTarget | null = null
1062
- let closestDistance = DOOR_INTERACTION_DISTANCE
1063
- const nodes = useScene.getState().nodes
1064
- const covered = new Set<string>()
1065
- for (const rawId of sceneRegistry.byType['procedural-item'] ?? []) {
1066
- covered.add(rawId)
1067
- const id = rawId as AnyNodeId
1068
- const node = nodes[id]
1069
- if (node?.type !== 'procedural-item') continue
1070
- const procedural = node as ProceduralItemNode
1071
- if (
1072
- !operableParts(procedural.recipe).length &&
1073
- !procedural.recipe.parts.some((part) => part.light)
1074
- )
1075
- continue
1076
- const object = sceneRegistry.nodes.get(id)
1077
- if (!object) continue
1078
- for (const hit of proceduralInteractionRaycaster.intersectObject(object, true)) {
1079
- if (hit.distance >= closestDistance) break
1080
- // Skip what is not drawn: a joint tree's inactive rest or joint meshes.
1081
- let drawn = true
1082
- for (let at: Object3D | null = hit.object; at && at !== object; at = at.parent)
1083
- if (!at.visible) drawn = false
1084
- if (!drawn) continue
1085
- let ancestor: Object3D | null = hit.object
1086
- while (ancestor && ancestor !== object && !ancestor.userData.proceduralMotion)
1087
- ancestor = ancestor.parent
1088
- let motion = ancestor?.userData.proceduralMotion as
1089
- | { nodeId?: string; partId?: string; kind?: 'hinge' | 'slide' | 'spin' }
1090
- | undefined
1091
- // An idle joint tree draws one merged mesh per slot; its face ranges name the part.
1092
- const ranges = hit.object.userData.proceduralRanges as
1093
- | { end: number; partId: string }[]
1094
- | undefined
1095
- if (!motion && ranges && hit.faceIndex != null) {
1096
- const face = hit.faceIndex
1097
- const partId = ranges.find((range) => face < range.end)?.partId
1098
- const part = partId ? operablePartFor(procedural.recipe, partId) : undefined
1099
- if (part) motion = { nodeId: id, partId: part.id, kind: part.kind }
1100
- }
1101
- closest =
1102
- motion?.nodeId === id && motion.partId
1103
- ? { id, partId: motion.partId, kind: motion.kind, type: 'procedural' }
1104
- : { id, type: 'procedural' }
1105
- closestDistance = hit.distance
1106
- break
1107
- }
1108
- }
1109
- for (const rawId of sceneRegistry.byType.item ?? []) {
1110
- covered.add(rawId)
1111
- const id = rawId as AnyNodeId
1112
- const node = nodes[id]
1113
- if (
1114
- node?.type !== 'item' ||
1115
- !node.asset.interactive?.controls.some((control) => control.kind === 'toggle')
1116
- )
1117
- continue
1118
- const object = sceneRegistry.nodes.get(id)
1119
- if (!object) continue
1120
- const hit = proceduralInteractionRaycaster.intersectObject(object, true)[0]
1121
- if (hit && hit.distance < closestDistance) {
1122
- closest = { id, type: 'item' }
1123
- closestDistance = hit.distance
1124
- }
1125
- }
1126
- // Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those
1127
- // built from a script: their clips run through the mechanism target below.
1128
- for (const id of nativeOpeningIds()) covered.add(id)
1129
- for (const id of mechanismTargetIds(covered)) {
1130
- const object = sceneRegistry.nodes.get(id)
1131
- const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0]
1132
- if (hit && hit.distance < closestDistance) {
1133
- closest = { id, type: 'mechanism' }
1134
- closestDistance = hit.distance
1135
- }
1136
- }
1137
- if (!closest) {
1138
- const selectedId = useViewer.getState().selection.selectedIds.at(-1) as AnyNodeId | undefined
1139
- const selected = selectedId ? nodes[selectedId] : undefined
1140
- if (
1141
- selected?.type === 'item' &&
1142
- selected.asset.interactive?.controls.some((control) => control.kind === 'toggle')
1143
- )
1144
- return { id: selected.id, type: 'item' }
1145
- }
1146
- if (closest) return closest
1147
-
1148
- return null
1149
- }, [
1150
- camera,
1151
- resolveInteractableDoorId,
1152
- resolveInteractableElevatorTarget,
1153
- resolveInteractableWindowId,
1154
- ])
1155
-
1156
- const toggleInteractableTarget = useCallback(() => {
1157
- // Drone is a camera, not an avatar: the click that re-acquires pointer lock
1158
- // must not swing a door open under the shot being framed. (In capture
1159
- // mode's walk camera the CLICK path is gated at handleMouseDown — there a
1160
- // locked-pointer click is the shutter — but E/R still open doors, so the
1161
- // photographer can stage the shot.)
1162
- if (isDroneMode) return
1163
-
1164
- const target = interactableTargetRef.current ?? resolveInteractableTarget()
1165
- if (!target) return
1166
- playTargetSound(target)
1167
- if (target.type === 'mechanism') {
1168
- const node = useScene.getState().nodes[target.id]
1169
- if (node) toggleNodeMechanism(node)
1170
- return
1171
- }
1172
- if (target.type === 'item') {
1173
- const node = useScene.getState().nodes[target.id]
1174
- if (node?.type === 'item' && node.asset.interactive)
1175
- operateItem(target.id, node.asset.interactive)
1176
- return
1177
- }
1178
-
1179
- if (target.type === 'procedural') {
1180
- const node = useScene.getState().nodes[target.id]
1181
- if (node?.type !== 'procedural-item') return
1182
- const parts = operableParts((node as ProceduralItemNode).recipe).map((part) => part.id)
1183
- const state = useInteractive.getState()
1184
- if (parts.length === 0) {
1185
- state.toggleProceduralLights(target.id)
1186
- return
1187
- }
1188
- if (target.partId) state.toggleProceduralPart(target.id, target.partId)
1189
- else
1190
- state.setProceduralParts(
1191
- target.id,
1192
- parts,
1193
- !parts.some((id) => state.procedural[target.id]?.parts[id]),
1194
- )
1195
- return
1196
- }
1197
-
1198
- if (target.type === 'elevator') {
1199
- if (target.buttonKind === 'cab') {
1200
- const state = useInteractive.getState().elevators[target.id]
1201
- if (state) {
1202
- ridingElevatorRef.current = {
1203
- elevatorId: target.id,
1204
- localControllerY: null,
1205
- previousCarY: state.carY,
1206
- }
1207
- }
1208
- }
1209
- if (target.action === 'open-door') {
1210
- openElevatorDoor(target.id)
1211
- return
1212
- }
1213
- if (target.levelId) {
1214
- const targetElevatorId =
1215
- target.buttonKind === 'landing'
1216
- ? resolveElevatorDispatchTarget({
1217
- elevators: useInteractive.getState().elevators,
1218
- levelId: target.levelId,
1219
- nodes: useScene.getState().nodes,
1220
- requestedElevatorId: target.id,
1221
- })
1222
- : target.id
1223
- requestElevatorLevel(targetElevatorId, target.levelId)
1224
- }
1225
- return
1226
- }
1227
-
1228
- if (target.type === 'window') {
1229
- const node = useScene.getState().nodes[target.id]
1230
- if (
1231
- node?.type !== 'window' ||
1232
- node.openingKind === 'opening' ||
1233
- !isOperableWindowType(node.windowType)
1234
- ) {
1235
- return
1236
- }
1237
-
1238
- toggleWindowOpenState(target.id, { persist: false })
1239
- return
1240
- }
1241
-
1242
- const doorId = target.id
1243
-
1244
- const node = useScene.getState().nodes[doorId]
1245
- if (node?.type !== 'door' || node.openingKind === 'opening') return
1246
-
1247
- toggleDoorOpenState(doorId, { persist: false })
1248
- }, [isDroneMode, resolveInteractableTarget])
1249
-
1250
- const closeInteractableTarget = useCallback(() => {
1251
- if (isDroneMode) return
1252
-
1253
- const target = interactableTargetRef.current ?? resolveInteractableTarget()
1254
- if (!target) return
1255
-
1256
- if (target.type === 'mechanism') {
1257
- const node = useScene.getState().nodes[target.id]
1258
- if (node) nodeMechanism(node)?.set(node, false)
1259
- return
1260
- }
1261
-
1262
- if (target.type === 'procedural') {
1263
- const node = useScene.getState().nodes[target.id]
1264
- if (node?.type !== 'procedural-item') return
1265
- const parts = target.partId
1266
- ? [target.partId]
1267
- : operableParts((node as ProceduralItemNode).recipe).map((part) => part.id)
1268
- useInteractive.getState().setProceduralParts(target.id, parts, false)
1269
- return
1270
- }
1271
-
1272
- if (target.type === 'elevator') return
1273
-
1274
- if (target.type === 'window') {
1275
- const node = useScene.getState().nodes[target.id]
1276
- if (
1277
- node?.type !== 'window' ||
1278
- node.openingKind === 'opening' ||
1279
- !isOperableWindowType(node.windowType)
1280
- ) {
1281
- return
1282
- }
1283
-
1284
- closeWindowOpenState(target.id, { persist: false })
1285
- return
1286
- }
1287
-
1288
- const node = useScene.getState().nodes[target.id]
1289
- if (node?.type !== 'door' || node.openingKind === 'opening') return
1290
-
1291
- closeDoorOpenState(target.id, { persist: false })
1292
- }, [isDroneMode, resolveInteractableTarget])
1293
-
1294
- const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
1295
- if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
1296
-
1297
- const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
1298
- if (spawnObject) {
1299
- spawnObject.updateWorldMatrix(true, false)
1300
- spawnObject.getWorldPosition(spawnWorldPosition)
1301
- spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
1302
-
1303
- return {
1304
- position: [
1305
- spawnWorldPosition.x,
1306
- spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
1307
- spawnWorldPosition.z,
1308
- ],
1309
- yaw: spawnWorldEuler.y,
1310
- }
1311
- }
1312
-
1313
- return {
1314
- position: [
1315
- placedSpawnNode.position[0],
1316
- placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
1317
- placedSpawnNode.position[2],
1318
- ],
1319
- yaw: placedSpawnNode.rotation,
1320
- }
1321
- }, [placedSpawnNode])
1322
-
1323
- useEffect(() => {
1324
- // Drone has no gravity, no floor and no collision, so the BVH collider world
1325
- // (a full-scene traversal, rebuilt on every door/window animation) is pure
1326
- // cost there. Switching modes disposes it and rebuilds on the way back.
1327
- if (!isDroneMode) rebuildColliderWorld()
1328
-
1329
- return () => {
1330
- worldRef.current?.dispose()
1331
- worldRef.current = null
1332
- disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
1333
- elevatorColliderMeshesRef.current = []
1334
- setElevatorColliderMeshes([])
1335
- setWorld(null)
1336
- }
1337
- }, [isDroneMode, rebuildColliderWorld])
1338
-
1339
- useEffect(() => {
1340
- if (isDroneMode) return
1341
- emitter.on('door:animation-completed', rebuildColliderWorld)
1342
- emitter.on('window:animation-completed', rebuildColliderWorld)
1343
- // A door built from a script opens by its model's clip, which reports no animation end.
1344
- const stopWatching = watchScriptedDoors(rebuildColliderWorld)
1345
- return () => {
1346
- emitter.off('door:animation-completed', rebuildColliderWorld)
1347
- emitter.off('window:animation-completed', rebuildColliderWorld)
1348
- stopWatching()
1349
- }
1350
- }, [isDroneMode, rebuildColliderWorld])
1351
-
1352
- // A walk session started before a drone detour would otherwise resume at its
1353
- // original spawn; drop it so the next walk re-derives one.
1354
- useEffect(() => {
1355
- if (isDroneMode) setControllerStart(null)
1356
- }, [isDroneMode])
1357
-
1358
- // Drone picks up wherever the camera currently is — orbit pose or walk eye.
1359
- useEffect(() => {
1360
- if (!isDroneMode) return
1361
- seedDroneLook(lookRef.current, camera.quaternion)
1362
- droneVelocityRef.current.set(0, 0, 0)
1363
-
1364
- // Interaction prompts belong to walk; drop whatever its frame left behind.
1365
- if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
1366
- useViewer.getState().setHoveredId(null)
1367
- }
1368
- interactableTargetRef.current = null
1369
- useFirstPersonHud.getState().reset()
1370
- }, [camera, isDroneMode])
1371
-
1372
- useEffect(() => {
1373
- if (!world) return
1374
- if (controllerStart) return
1375
-
1376
- const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
1377
- const [x, y, z] = spawn.position
1378
- lookRef.current.yaw = spawn.yaw
1379
- lookRef.current.pitch = 0
1380
- setControllerStart({
1381
- position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
1382
- yaw: spawn.yaw,
1383
- })
1384
- }, [camera, controllerStart, placedSpawn, world])
1385
-
1386
- useEffect(() => {
1387
- const canvas = gl.domElement
1388
- focusFirstPersonCanvas(canvas)
1389
-
1390
- const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
1391
- return () => window.cancelAnimationFrame(frame)
1392
- }, [gl])
1393
-
1394
- // The pointer-lock effect below must be mount-stable. Its cleanup exits
1395
- // pointer lock, and `toggleInteractableTarget` is recreated whenever the
1396
- // camera object changes — which happens right after entry when the
1397
- // persisted orthographic mode swaps to perspective. If the async lock
1398
- // grant lands before that re-run, the cleanup's exitPointerLock fires an
1399
- // unlock the handler reads as "user left walkthrough", instantly
1400
- // cancelling a fresh entry (and arming the browser's ~1.25s re-lock
1401
- // cooldown, so the next presses fail too). Route the callback through a
1402
- // ref so the effect deps stay `[gl]`.
1403
- const toggleInteractableTargetRef = useRef(toggleInteractableTarget)
1404
- useEffect(() => {
1405
- toggleInteractableTargetRef.current = toggleInteractableTarget
1406
- }, [toggleInteractableTarget])
1407
-
1408
- useEffect(() => {
1409
- const canvas = gl.domElement
1410
- const handleMouseMove = (e: MouseEvent) => {
1411
- if (document.pointerLockElement !== canvas) return
1412
- // Shutter hold: the shot is rendering — a mouse twitch must not pan it.
1413
- if (useEditor.getState().captureShutterHold) return
1414
-
1415
- turnFirstPersonLook(
1416
- lookRef.current,
1417
- e.movementX,
1418
- e.movementY,
1419
- useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1420
- ? DRONE_SLOW_MULTIPLIER
1421
- : 1,
1422
- )
1423
- }
1424
-
1425
- const handleClick = (event: MouseEvent) => {
1426
- const target = event.target
1427
- if (!(target instanceof HTMLElement)) return
1428
- if (!canvas.contains(target)) return
1429
- if (document.pointerLockElement !== canvas) {
1430
- canvas.requestPointerLock?.()
1431
- }
1432
- }
1433
-
1434
- const handleMouseDown = (event: MouseEvent) => {
1435
- if (document.pointerLockElement !== canvas) return
1436
- if (event.button !== 0) return
1437
-
1438
- // Capture mode: the locked-pointer click is the SHUTTER (the snapshot
1439
- // overlay's window-capture listener already fired); doors stay on E/R.
1440
- if (useEditor.getState().isCaptureMode) return
1441
-
1442
- event.preventDefault()
1443
- event.stopPropagation()
1444
- toggleInteractableTargetRef.current()
1445
- }
1446
-
1447
- const handlePointerLockChange = () => {
1448
- const isLocked = document.pointerLockElement === canvas
1449
- if (isLocked) {
1450
- hadPointerLockRef.current = true
1451
- suspendRef.current = false
1452
- useViewer.getState().setWalkthroughSuspended(false)
1453
- return
1454
- }
1455
-
1456
- // Deliberately released (screenshot pause) — stay in first person;
1457
- // clicking the canvas re-locks.
1458
- if (suspendRef.current) return
1459
-
1460
- // Capture mode: Esc (the browser's own unlock — no keydown reaches us)
1461
- // acts like P. Dropping back to orbit would throw away the framed pose,
1462
- // which reads as a crash to anyone who never noticed P.
1463
- if (
1464
- hadPointerLockRef.current &&
1465
- useEditor.getState().isCaptureMode &&
1466
- useEditor.getState().isFirstPersonMode
1467
- ) {
1468
- suspendRef.current = true
1469
- useViewer.getState().setWalkthroughSuspended(true)
1470
- return
1471
- }
1472
-
1473
- if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1474
- useEditor.getState().setFirstPersonMode(false)
1475
- }
1476
- }
1477
-
1478
- handlePointerLockChange()
1479
- document.addEventListener('mousemove', handleMouseMove)
1480
- document.addEventListener('click', handleClick)
1481
- document.addEventListener('mousedown', handleMouseDown, true)
1482
- document.addEventListener('pointerlockchange', handlePointerLockChange)
1483
-
1484
- return () => {
1485
- document.removeEventListener('mousemove', handleMouseMove)
1486
- document.removeEventListener('click', handleClick)
1487
- document.removeEventListener('mousedown', handleMouseDown, true)
1488
- document.removeEventListener('pointerlockchange', handlePointerLockChange)
1489
- useViewer.getState().setWalkthroughSuspended(false)
1490
- if (document.pointerLockElement === canvas) {
1491
- document.exitPointerLock()
1492
- }
1493
- }
1494
- }, [gl])
1495
-
1496
- useEffect(() => {
1497
- const canvas = gl.domElement
1498
-
1499
- const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
1500
- if (isTypingTarget(event.target)) return false
1501
-
1502
- const movement = getMovementInputForKey(event.code, active)
1503
- if (!movement) return false
1504
-
1505
- event.preventDefault()
1506
- Object.assign(movementInputRef.current, movement)
1507
- controllerRef.current?.setMovement(movement)
1508
- return true
1509
- }
1510
-
1511
- const handleKeyDown = (event: KeyboardEvent) => {
1512
- const handledMovement = applyMovementKey(event, true)
1513
- if (handledMovement) return
1514
-
1515
- if (isTypingTarget(event.target)) return
1516
-
1517
- if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
1518
- // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
1519
- // screenshot) must not toggle it under the user.
1520
- if (!suspendRef.current) crouchKeyRef.current = true
1521
- } else if (event.code === 'KeyQ') {
1522
- // Drone descend. Space (already bound to jump) and E are the matching ascend.
1523
- event.preventDefault()
1524
- event.stopPropagation()
1525
- if (!suspendRef.current) droneDescendKeyRef.current = true
1526
- } else if (event.code === 'KeyE' && isDroneMode) {
1527
- event.preventDefault()
1528
- event.stopPropagation()
1529
- if (!suspendRef.current) droneAscendKeyRef.current = true
1530
- } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) {
1531
- event.preventDefault()
1532
- event.stopPropagation()
1533
- if (!suspendRef.current) droneSlowKeyRef.current = true
1534
- } else if (event.code === 'Escape') {
1535
- event.preventDefault()
1536
- event.stopPropagation()
1537
- // Capture mode, first Esc frees the cursor (see handlePointerLockChange
1538
- // — while locked the browser usually unlocks without delivering the
1539
- // keydown); with the cursor already free, Esc cancels the snapshot
1540
- // (setCaptureMode(false) also lands the camera back on orbit).
1541
- if (useEditor.getState().isCaptureMode) {
1542
- if (document.pointerLockElement === canvas) {
1543
- suspendRef.current = true
1544
- useViewer.getState().setWalkthroughSuspended(true)
1545
- document.exitPointerLock()
1546
- } else {
1547
- useEditor.getState().setCaptureMode(false)
1548
- }
1549
- return
1550
- }
1551
- if (document.pointerLockElement === canvas) {
1552
- document.exitPointerLock()
1553
- }
1554
- useEditor.getState().setFirstPersonMode(false)
1555
- } else if (event.code === 'KeyE' || event.code === 'KeyR') {
1556
- event.preventDefault()
1557
- event.stopPropagation()
1558
- toggleInteractableTarget()
1559
- } else if (event.code === 'KeyT') {
1560
- event.preventDefault()
1561
- event.stopPropagation()
1562
- closeInteractableTarget()
1563
- } else if (event.code === 'KeyP') {
1564
- // P toggles a cursor pause (advertised in the HUD): frees the pointer
1565
- // without leaving first person — e.g. for an OS screenshot, which
1566
- // needs a movable cursor — and click or P resumes.
1567
- event.preventDefault()
1568
- event.stopPropagation()
1569
- if (document.pointerLockElement === canvas) {
1570
- suspendRef.current = true
1571
- useViewer.getState().setWalkthroughSuspended(true)
1572
- document.exitPointerLock()
1573
- } else if (suspendRef.current) {
1574
- const result = canvas.requestPointerLock?.() as Promise<void> | undefined
1575
- if (result && typeof result.catch === 'function') result.catch(() => {})
1576
- }
1577
- }
1578
- }
1579
-
1580
- const handleKeyUp = (event: KeyboardEvent) => {
1581
- if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
1582
- crouchKeyRef.current = false
1583
- }
1584
- if (event.code === 'KeyQ' && !suspendRef.current) {
1585
- droneDescendKeyRef.current = false
1586
- }
1587
- if (event.code === 'KeyE' && !suspendRef.current) {
1588
- droneAscendKeyRef.current = false
1589
- }
1590
- if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) {
1591
- droneSlowKeyRef.current = false
1592
- }
1593
- applyMovementKey(event, false)
1594
- }
1595
-
1596
- const handleBlur = () => {
1597
- if (!suspendRef.current) {
1598
- crouchKeyRef.current = false
1599
- droneAscendKeyRef.current = false
1600
- droneDescendKeyRef.current = false
1601
- droneSlowKeyRef.current = false
1602
- }
1603
- }
1604
-
1605
- document.addEventListener('keydown', handleKeyDown, true)
1606
- document.addEventListener('keyup', handleKeyUp, true)
1607
- window.addEventListener('blur', handleBlur)
1608
- return () => {
1609
- document.removeEventListener('keydown', handleKeyDown, true)
1610
- document.removeEventListener('keyup', handleKeyUp, true)
1611
- window.removeEventListener('blur', handleBlur)
1612
- }
1613
- }, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget])
1614
-
1615
- const syncElevatorColliderMeshes = useCallback(() => {
1616
- const nodes = useScene.getState().nodes
1617
- const interactive = useInteractive.getState()
1618
-
1619
- for (const mesh of elevatorColliderMeshesRef.current) {
1620
- const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
1621
- const node = nodes[elevatorId]
1622
- const runtime = interactive.elevators[elevatorId]
1623
- const object = sceneRegistry.nodes.get(elevatorId)
1624
- if (!(node?.type === 'elevator' && object && node.visible !== false)) {
1625
- mesh.visible = false
1626
- continue
1627
- }
1628
-
1629
- if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
1630
- continue
1631
- }
1632
-
1633
- let [localX, localY, localZ] = localPosition
1634
- const isCabCollider = kind.startsWith('cab-')
1635
- const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
1636
- const isCabDoorGate = kind === 'cab-door-gate'
1637
- const isLandingDoorGate = kind === 'landing-door-gate'
1638
-
1639
- if (isCabCollider) {
1640
- localY += runtime?.carY ?? 0
1641
- }
1642
-
1643
- if (kind === 'cab-door-left' || kind === 'cab-door-right') {
1644
- if (!runtime) {
1645
- mesh.visible = false
1646
- continue
1647
- }
1648
- localX = getElevatorDoorLeafX(
1649
- side ?? 'left',
1650
- doorWidth ?? node.doorWidth,
1651
- runtime.doorOpen,
1652
- node.doorStyle,
1653
- )
1654
- mesh.visible = true
1655
- } else if (isCabDoorGate) {
1656
- if (!runtime) {
1657
- mesh.visible = false
1658
- continue
1659
- }
1660
- mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1661
- } else if (isDoorCollider) {
1662
- const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1663
- localX = getElevatorDoorLeafX(
1664
- side ?? 'left',
1665
- doorWidth ?? node.doorWidth,
1666
- doorOpen,
1667
- node.doorStyle,
1668
- )
1669
- mesh.visible = true
1670
- } else if (isLandingDoorGate) {
1671
- const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1672
- mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1673
- } else {
1674
- mesh.visible = true
1675
- }
1676
-
1677
- object.updateWorldMatrix(true, false)
1678
- elevatorColliderMatrix.copy(object.matrixWorld)
1679
- elevatorColliderMatrix.multiply(
1680
- elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
1681
- )
1682
- mesh.matrix.copy(elevatorColliderMatrix)
1683
- mesh.matrixWorld.copy(elevatorColliderMatrix)
1684
- mesh.userData.matrixInitialized = true
1685
- }
1686
- }, [])
1687
-
1688
- useFrame(() => {
1689
- if (isDroneMode) return
1690
- syncElevatorColliderMeshes()
1691
- }, -1)
1692
-
1693
- const syncElevatorRide = useCallback(
1694
- (group: Group) => {
1695
- const nodes = useScene.getState().nodes
1696
- const interactive = useInteractive.getState()
1697
- const activeRide = ridingElevatorRef.current
1698
- let nextRide: {
1699
- cabHeight: number
1700
- cabCenterZ: number
1701
- carY: number
1702
- doorOpen: number
1703
- elevatorId: AnyNodeId
1704
- halfDepth: number
1705
- halfWidth: number
1706
- object: Object3D
1707
- phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
1708
- } | null = null
1709
-
1710
- const elevatorIds = activeRide
1711
- ? [
1712
- activeRide.elevatorId,
1713
- ...Array.from(sceneRegistry.byType.elevator!).filter(
1714
- (elevatorId) => elevatorId !== activeRide.elevatorId,
1715
- ),
1716
- ]
1717
- : Array.from(sceneRegistry.byType.elevator!)
1718
-
1719
- for (const elevatorId of elevatorIds) {
1720
- const typedElevatorId = elevatorId as AnyNodeId
1721
- const node = nodes[typedElevatorId]
1722
- if (node?.type !== 'elevator') continue
1723
-
1724
- const runtime = interactive.elevators[typedElevatorId]
1725
- const object = sceneRegistry.nodes.get(typedElevatorId)
1726
- if (!(runtime && object)) continue
1727
-
1728
- object.updateWorldMatrix(true, true)
1729
- elevatorLocalEyePosition.copy(camera.position)
1730
- object.worldToLocal(elevatorLocalEyePosition)
1731
-
1732
- const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1733
- const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1734
- const cabCenterZ = getElevatorCabCenterZ(node)
1735
- const cabHeight = Math.max(node.cabHeight, 1.4)
1736
- const insideFootprint =
1737
- Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
1738
- Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
1739
- const insideCabHeight =
1740
- elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
1741
- elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
1742
- const continuingRide =
1743
- activeRide?.elevatorId === typedElevatorId &&
1744
- insideFootprint &&
1745
- elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
1746
- elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
1747
-
1748
- if ((insideFootprint && insideCabHeight) || continuingRide) {
1749
- nextRide = {
1750
- cabHeight,
1751
- cabCenterZ,
1752
- carY: runtime.carY,
1753
- doorOpen: runtime.doorOpen,
1754
- elevatorId: typedElevatorId,
1755
- halfDepth: Math.max(halfDepth, 0.24),
1756
- halfWidth: Math.max(halfWidth, 0.24),
1757
- object,
1758
- phase: runtime.phase,
1759
- }
1760
- break
1761
- }
1762
- }
1763
-
1764
- if (!nextRide) {
1765
- ridingElevatorRef.current = null
1766
- setElevatorRideLocked(false)
1767
- return
1768
- }
1769
-
1770
- const previousCarY =
1771
- activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
1772
- const deltaY = nextRide.carY - previousCarY
1773
- nextRide.object.updateWorldMatrix(true, true)
1774
- elevatorLocalControllerPosition.copy(group.position)
1775
- nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1776
- const localControllerY =
1777
- activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
1778
- ? activeRide.localControllerY
1779
- : elevatorLocalControllerPosition.y - nextRide.carY
1780
-
1781
- if (Math.abs(deltaY) > 0.0001) {
1782
- group.position.y += deltaY
1783
- controllerRef.current?.resetLinVel()
1784
- }
1785
-
1786
- const shouldLockToCab =
1787
- nextRide.phase === 'closing' ||
1788
- nextRide.phase === 'moving' ||
1789
- (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
1790
- if (shouldLockToCab) {
1791
- elevatorLocalControllerPosition.copy(group.position)
1792
- nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1793
- const desiredLocalY = nextRide.carY + localControllerY
1794
- if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
1795
- elevatorLocalControllerPosition.y = desiredLocalY
1796
- elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1797
- nextRide.object.localToWorld(elevatorWorldControllerPosition)
1798
- group.position.y = elevatorWorldControllerPosition.y
1799
- controllerRef.current?.resetLinVel()
1800
- elevatorLocalControllerPosition.copy(group.position)
1801
- nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1802
- }
1803
-
1804
- const clampedX = Math.max(
1805
- -nextRide.halfWidth,
1806
- Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
1807
- )
1808
- const clampedZ = Math.max(
1809
- nextRide.cabCenterZ - nextRide.halfDepth,
1810
- Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
1811
- )
1812
-
1813
- if (
1814
- Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
1815
- Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
1816
- ) {
1817
- elevatorLocalControllerPosition.x = clampedX
1818
- elevatorLocalControllerPosition.z = clampedZ
1819
- elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1820
- nextRide.object.localToWorld(elevatorWorldControllerPosition)
1821
- group.position.x = elevatorWorldControllerPosition.x
1822
- group.position.z = elevatorWorldControllerPosition.z
1823
- controllerRef.current?.resetLinVel()
1824
- }
1825
- }
1826
-
1827
- setElevatorRideLocked(shouldLockToCab)
1828
-
1829
- ridingElevatorRef.current = {
1830
- elevatorId: nextRide.elevatorId,
1831
- localControllerY,
1832
- previousCarY: nextRide.carY,
1833
- }
1834
- },
1835
- [camera, setElevatorRideLocked],
1836
- )
1837
-
1838
- const hasStandingClearance = useCallback((position: Vector3) => {
1839
- standClearanceRaycaster.set(position, standClearanceUp)
1840
- standClearanceRaycaster.far = STAND_CLEARANCE
1841
- const meshes: Mesh[] = []
1842
- if (worldRef.current) meshes.push(worldRef.current.mesh)
1843
- for (const mesh of elevatorColliderMeshesRef.current) {
1844
- if (mesh.visible) meshes.push(mesh)
1845
- }
1846
- return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1847
- }, [])
1848
-
1849
- // Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's
1850
- // `DroneController`) fed from this controller's keys — WASD along the view
1851
- // axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows.
1852
- useFrame((_, delta) => {
1853
- if (!isDroneMode) return
1854
- // Shutter hold: freeze the drone mid-air while the shot renders.
1855
- if (useEditor.getState().captureShutterHold) return
1856
-
1857
- const movement = movementInputRef.current
1858
- droneInput.forward = movement.forward === true
1859
- droneInput.backward = movement.backward === true
1860
- droneInput.leftward = movement.leftward === true
1861
- droneInput.rightward = movement.rightward === true
1862
- droneInput.up = movement.jump === true || droneAscendKeyRef.current
1863
- droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current
1864
- droneInput.boost = movement.run === true
1865
- droneInput.slow = droneSlowKeyRef.current
1866
- stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta)
1867
- }, 2.5)
1868
-
1869
- useFrame((_, delta) => {
1870
- if (isDroneMode) return
1871
- if (!controllerRef.current?.group) return
1872
-
1873
- const group = controllerRef.current.group
1874
-
1875
- // Crouch follows the held key; standing back up waits for headroom.
1876
- // Frozen while the cursor pause is active.
1877
- if (!suspendRef.current && crouchKeyRef.current !== crouched) {
1878
- if (crouchKeyRef.current) setCrouched(true)
1879
- else if (hasStandingClearance(group.position)) setCrouched(false)
1880
- }
1881
- const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
1882
- eyeOffsetRef.current +=
1883
- (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
1884
- cameraOffset.set(0, eyeOffsetRef.current, 0)
1885
-
1886
- // The site ground collider is effectively unbounded, but scenes without a
1887
- // site node only have finite fallback floors — if the controller still ends
1888
- // up below every collider it can never land, so put it back at the spawn.
1889
- // Prefer the live spawn node over the mount-time start position so a spawn
1890
- // moved mid-walkthrough doesn't respawn the player at stale coordinates.
1891
- const worldBounds = worldRef.current?.bounds
1892
- if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
1893
- const respawnPosition = placedSpawn
1894
- ? [
1895
- placedSpawn.position[0],
1896
- placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
1897
- placedSpawn.position[2],
1898
- ]
1899
- : controllerStart?.position
1900
- if (respawnPosition) {
1901
- group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
1902
- controllerRef.current.resetLinVel()
1903
- ridingElevatorRef.current = null
1904
- setElevatorRideLocked(false)
1905
- }
1906
- }
1907
-
1908
- group.rotation.y = 0
1909
- camera.position.copy(group.position).add(cameraOffset)
1910
- cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ')
1911
- camera.quaternion.setFromEuler(cameraEuler)
1912
- camera.updateMatrixWorld(true)
1913
- syncElevatorRide(group)
1914
- camera.position.copy(group.position).add(cameraOffset)
1915
- camera.updateMatrixWorld(true)
1916
-
1917
- const nextInteractableTarget = resolveInteractableTarget()
1918
- const previousInteractableTarget = interactableTargetRef.current
1919
- if (
1920
- getInteractableTargetKey(previousInteractableTarget) !==
1921
- getInteractableTargetKey(nextInteractableTarget)
1922
- ) {
1923
- interactableTargetRef.current = nextInteractableTarget
1924
- useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
1925
- }
1926
-
1927
- useFirstPersonHud.getState().setHud({
1928
- interact: resolveHudInteract(nextInteractableTarget),
1929
- })
1930
-
1931
- hudLabelFrameRef.current += 1
1932
- if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
1933
- hudLabelFrameRef.current = 0
1934
- camera.getWorldPosition(hudWorldEyePosition)
1935
- useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
1936
- }
1937
- }, 2.5)
1938
-
1939
- useEffect(() => {
1940
- return () => {
1941
- if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
1942
- useViewer.getState().setHoveredId(null)
1943
- }
1944
- }
1945
- }, [])
1946
-
1947
- const firstPersonColliderMeshes = useMemo(
1948
- () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
1949
- [world, elevatorColliderMeshes],
1950
- )
1951
-
1952
- if (isDroneMode || !world) {
1953
- return null
1954
- }
1955
-
1956
20
  return (
1957
- <>
1958
- {controllerStart && (
1959
- <KeyboardControls map={keyboardMap}>
1960
- <BVHEcctrl
1961
- acceleration={26}
1962
- airDragFactor={0.3}
1963
- colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
1964
- colliderMeshes={firstPersonColliderMeshes}
1965
- collisionCheckIteration={3}
1966
- collisionPushBackDamping={0.1}
1967
- collisionPushBackThreshold={0.001}
1968
- debug={false}
1969
- deceleration={30}
1970
- delay={0}
1971
- fallGravityFactor={4}
1972
- floatCheckType="BOTH"
1973
- floatDampingC={36}
1974
- floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
1975
- floatPullBackHeight={0.35}
1976
- floatSensorRadius={0.15}
1977
- floatSpringK={1200}
1978
- gravity={9.81}
1979
- jumpVel={5}
1980
- key="first-person-controller"
1981
- maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
1982
- maxSlope={1.2}
1983
- maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
1984
- paused={isElevatorRideLocked || captureShutterHold}
1985
- position={controllerStart.position}
1986
- ref={setControllerApi}
1987
- />
1988
- </KeyboardControls>
1989
- )}
1990
- </>
21
+ <LiveWalkthroughController
22
+ externalFov={captureOwnsFov}
23
+ onExit={onExit}
24
+ paused={paused}
25
+ pointerInteraction={!capture}
26
+ suspendOnUnlock={capture}
27
+ />
1991
28
  )
1992
29
  }
1993
30