@pascal-app/editor 1.1.0 → 1.1.2

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