@pascal-app/editor 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,12 +1,50 @@
1
1
  'use client'
2
2
 
3
- import '../../three-types'
4
- import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
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
+ getResolvedElevatorDoorStyle,
19
+ openElevatorDoor,
20
+ requestElevatorLevel,
21
+ resolveElevatorBuildingLevels,
22
+ resolveElevatorDispatchTarget,
23
+ resolveElevatorServiceLevels,
24
+ sceneRegistry,
25
+ useInteractive,
26
+ useScene,
27
+ } from '@pascal-app/core'
5
28
  import { useViewer } from '@pascal-app/viewer'
6
29
  import { KeyboardControls } from '@react-three/drei'
7
30
  import { useFrame, useThree } from '@react-three/fiber'
8
31
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
- import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
32
+ import {
33
+ Box3,
34
+ BoxGeometry,
35
+ Euler,
36
+ type Group,
37
+ Matrix4,
38
+ Mesh,
39
+ MeshBasicMaterial,
40
+ type Object3D,
41
+ Ray,
42
+ Raycaster,
43
+ Vector2,
44
+ Vector3,
45
+ } from 'three'
46
+ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
47
+ import '../../three-types'
10
48
  import {
11
49
  closeDoorOpenState,
12
50
  DOOR_SWING_OPEN_ANGLE,
@@ -34,6 +72,12 @@ const LOOK_SENSITIVITY = 0.002
34
72
  const CONTROLLER_CENTER_FROM_EYE = 0.85
35
73
  const DOOR_INTERACTION_DISTANCE = 2.5
36
74
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
75
+ const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
76
+ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
77
+ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
78
+ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
79
+ const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
80
+ const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
37
81
  const keyboardMap = [
38
82
  { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
39
83
  { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -59,13 +103,421 @@ const doorOpeningLocalHit = new Vector3()
59
103
  const doorOpeningLocalRay = new Ray()
60
104
  const doorOpeningMatrix = new Matrix4()
61
105
  const doorOpeningWorldHit = new Vector3()
106
+ const elevatorLocalControllerPosition = new Vector3()
107
+ const elevatorInteractionRaycaster = new Raycaster()
108
+ const elevatorColliderMatrix = new Matrix4()
109
+ const elevatorColliderLocalMatrix = new Matrix4()
110
+ const elevatorLocalEyePosition = new Vector3()
111
+ const elevatorWorldControllerPosition = new Vector3()
112
+ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
62
113
  const spawnWorldPosition = new Vector3()
63
114
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
64
115
  const windowInteractionRaycaster = new Raycaster()
65
116
 
66
- type FirstPersonInteractableTarget = {
67
- id: AnyNodeId
68
- type: 'door' | 'window'
117
+ type ElevatorColliderKind =
118
+ | 'cab-back'
119
+ | 'cab-ceiling'
120
+ | 'cab-door-left'
121
+ | 'cab-door-right'
122
+ | 'cab-door-gate'
123
+ | 'cab-floor'
124
+ | 'cab-left'
125
+ | 'cab-right'
126
+ | 'landing-door-gate'
127
+ | 'landing-door-left'
128
+ | 'landing-door-right'
129
+ | 'shaft-back'
130
+ | 'shaft-front-header'
131
+ | 'shaft-front-left'
132
+ | 'shaft-front-right'
133
+ | 'shaft-left'
134
+ | 'shaft-right'
135
+ | 'shaft-top'
136
+
137
+ type ElevatorColliderUserData = {
138
+ doorWidth?: number
139
+ dynamic?: boolean
140
+ elevatorId: AnyNodeId
141
+ kind: ElevatorColliderKind
142
+ levelId?: AnyNodeId
143
+ localPosition: [number, number, number]
144
+ matrixInitialized?: boolean
145
+ side?: ElevatorDoorSide
146
+ }
147
+
148
+ type ElevatorColliderMesh = Mesh & {
149
+ userData: Mesh['userData'] & ElevatorColliderUserData
150
+ }
151
+
152
+ type FirstPersonInteractableTarget =
153
+ | {
154
+ id: AnyNodeId
155
+ type: 'door' | 'window'
156
+ }
157
+ | {
158
+ action: 'open-door' | 'request-level'
159
+ buttonKind: 'cab' | 'landing'
160
+ id: AnyNodeId
161
+ levelId?: AnyNodeId
162
+ type: 'elevator'
163
+ }
164
+
165
+ type ElevatorButtonTarget = {
166
+ action: 'open-door' | 'request-level'
167
+ buttonKind: 'cab' | 'landing'
168
+ elevatorId: AnyNodeId
169
+ levelId?: AnyNodeId
170
+ }
171
+
172
+ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
173
+ let current: Object3D | null = object
174
+
175
+ while (current) {
176
+ const candidate = (
177
+ current.userData as {
178
+ elevatorButton?: {
179
+ action?: unknown
180
+ disabled?: unknown
181
+ elevatorId?: unknown
182
+ kind?: unknown
183
+ levelId?: unknown
184
+ }
185
+ }
186
+ ).elevatorButton
187
+
188
+ if (candidate?.disabled === true) {
189
+ return null
190
+ }
191
+
192
+ if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
193
+ const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
194
+ if (action === 'open-door') {
195
+ return {
196
+ action,
197
+ buttonKind: candidate.kind,
198
+ elevatorId: candidate.elevatorId as AnyNodeId,
199
+ }
200
+ }
201
+ }
202
+
203
+ if (
204
+ typeof candidate?.elevatorId === 'string' &&
205
+ typeof candidate.levelId === 'string' &&
206
+ (candidate.kind === 'cab' || candidate.kind === 'landing')
207
+ ) {
208
+ return {
209
+ action: 'request-level',
210
+ buttonKind: candidate.kind,
211
+ elevatorId: candidate.elevatorId as AnyNodeId,
212
+ levelId: candidate.levelId as AnyNodeId,
213
+ }
214
+ }
215
+
216
+ current = current.parent
217
+ }
218
+
219
+ return null
220
+ }
221
+
222
+ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
223
+ if (!target) return null
224
+ return target.type === 'elevator'
225
+ ? `${target.type}:${target.id}:${target.levelId}`
226
+ : `${target.type}:${target.id}`
227
+ }
228
+
229
+ function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
230
+ return kind.startsWith('cab-') || kind.startsWith('landing-door')
231
+ }
232
+
233
+ function isInsideElevatorCab(
234
+ elevator: ElevatorNode,
235
+ runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
236
+ localEyePosition: Vector3,
237
+ ) {
238
+ const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
239
+ const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
240
+ const cabCenterZ = getElevatorCabCenterZ(elevator)
241
+ const cabHeight = Math.max(elevator.cabHeight, 1.4)
242
+
243
+ return (
244
+ Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
245
+ Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
246
+ localEyePosition.y >= runtime.carY + 0.35 &&
247
+ localEyePosition.y <= runtime.carY + cabHeight + 0.7
248
+ )
249
+ }
250
+
251
+ function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
252
+ const level = nodes[levelId as AnyNodeId]
253
+ if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
254
+
255
+ let maxTop = 0
256
+ for (const childId of level.children) {
257
+ const child = nodes[childId as AnyNodeId]
258
+ if (!child) continue
259
+
260
+ if (child.type === 'ceiling') {
261
+ maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
262
+ continue
263
+ }
264
+
265
+ if (child.type === 'wall') {
266
+ const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
267
+ maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
268
+ }
269
+ }
270
+
271
+ return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
272
+ }
273
+
274
+ function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
275
+ const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
276
+
277
+ const baseYByLevelId = new Map<string, number>()
278
+ let cumulativeY = 0
279
+ for (const level of allLevels) {
280
+ baseYByLevelId.set(level.id, cumulativeY)
281
+ cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
282
+ }
283
+
284
+ const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
285
+ const entries = serviceLevels.map((level) => ({
286
+ baseY: baseYByLevelId.get(level.id) ?? 0,
287
+ id: level.id as AnyNodeId,
288
+ }))
289
+ const firstServedLevel = serviceLevels[0] ?? null
290
+ const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
291
+ const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
292
+ const lastServedIndex = lastServedLevel
293
+ ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
294
+ : -1
295
+ const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
296
+ const shaftTopY = nextLevel
297
+ ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
298
+ : lastServedLevel
299
+ ? cumulativeY
300
+ : elevator.cabHeight + 0.3
301
+
302
+ return {
303
+ entries,
304
+ shaftBaseY,
305
+ shaftTopY,
306
+ totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
307
+ }
308
+ }
309
+
310
+ function createElevatorColliderMesh(
311
+ elevatorId: AnyNodeId,
312
+ kind: ElevatorColliderKind,
313
+ size: [number, number, number],
314
+ localPosition: [number, number, number],
315
+ userData: Partial<ElevatorColliderUserData> = {},
316
+ ) {
317
+ const geometry = new BoxGeometry(size[0], size[1], size[2])
318
+
319
+ const bvhGeometry = geometry as typeof geometry & {
320
+ computeBoundsTree?: typeof computeBoundsTree
321
+ disposeBoundsTree?: typeof disposeBoundsTree
322
+ }
323
+ ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
324
+ ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
325
+ bvhGeometry.computeBoundsTree?.({
326
+ maxLeafSize: 12,
327
+ strategy: 0,
328
+ } as never)
329
+ bvhGeometry.computeBoundingBox()
330
+
331
+ const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
332
+ mesh.raycast = acceleratedRaycast
333
+ mesh.matrixAutoUpdate = false
334
+ mesh.visible = true
335
+ mesh.userData = {
336
+ ...userData,
337
+ dynamic: isDynamicElevatorCollider(kind),
338
+ elevatorId,
339
+ excludeCollisionCheck: false,
340
+ excludeFloatHit: false,
341
+ friction: 0.8,
342
+ kind,
343
+ localPosition,
344
+ matrixInitialized: false,
345
+ restitution: 0.03,
346
+ type: 'ELEVATOR_COLLIDER',
347
+ }
348
+ return mesh
349
+ }
350
+
351
+ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
352
+ const nodes = useScene.getState().nodes
353
+ const meshes: ElevatorColliderMesh[] = []
354
+
355
+ for (const elevatorId of sceneRegistry.byType.elevator!) {
356
+ const typedElevatorId = elevatorId as AnyNodeId
357
+ const node = nodes[typedElevatorId]
358
+ if (node?.type !== 'elevator' || node.visible === false) continue
359
+
360
+ const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
361
+ node,
362
+ nodes,
363
+ )
364
+ const cabWidth = getElevatorCabWidth(node)
365
+ const cabDepth = getElevatorCabDepth(node)
366
+ const shaftWidth = getElevatorShaftWidth(node, cabWidth)
367
+ const shaftDepth = getElevatorShaftDepth(node, cabDepth)
368
+ const cabHeight = Math.max(node.cabHeight, 1.4)
369
+ const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
370
+ const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
371
+ const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
372
+ const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
373
+ const wallThickness = getElevatorShaftWallThickness(node)
374
+ const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
375
+ const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
376
+ const frontWallZ = -shaftDepth / 2 - wallThickness / 2
377
+ const frontZ = frontWallZ - wallThickness / 2 - 0.018
378
+ const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
379
+ const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
380
+ const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
381
+ const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
382
+
383
+ meshes.push(
384
+ createElevatorColliderMesh(
385
+ typedElevatorId,
386
+ 'shaft-back',
387
+ [shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
388
+ [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
389
+ ),
390
+ createElevatorColliderMesh(
391
+ typedElevatorId,
392
+ 'shaft-left',
393
+ [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
394
+ [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
395
+ ),
396
+ createElevatorColliderMesh(
397
+ typedElevatorId,
398
+ 'shaft-right',
399
+ [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
400
+ [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
401
+ ),
402
+ createElevatorColliderMesh(
403
+ typedElevatorId,
404
+ 'shaft-top',
405
+ [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
406
+ [0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
407
+ ),
408
+ createElevatorColliderMesh(
409
+ typedElevatorId,
410
+ 'cab-floor',
411
+ [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
412
+ [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
413
+ ),
414
+ createElevatorColliderMesh(
415
+ typedElevatorId,
416
+ 'cab-ceiling',
417
+ [cabWidth, wallThickness, cabDepth],
418
+ [0, cabHeight - wallThickness / 2, cabCenterZ],
419
+ ),
420
+ createElevatorColliderMesh(
421
+ typedElevatorId,
422
+ 'cab-back',
423
+ [cabWidth, cabHeight, wallThickness],
424
+ [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
425
+ ),
426
+ createElevatorColliderMesh(
427
+ typedElevatorId,
428
+ 'cab-left',
429
+ [wallThickness, cabHeight, cabDepth],
430
+ [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
431
+ ),
432
+ createElevatorColliderMesh(
433
+ typedElevatorId,
434
+ 'cab-right',
435
+ [wallThickness, cabHeight, cabDepth],
436
+ [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
437
+ ),
438
+ createElevatorColliderMesh(
439
+ typedElevatorId,
440
+ 'cab-door-gate',
441
+ [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
442
+ [0, doorHeight / 2, frontZ],
443
+ { doorWidth },
444
+ ),
445
+ ...doorLeafSides.map((side) =>
446
+ createElevatorColliderMesh(
447
+ typedElevatorId,
448
+ side === 'left' ? 'cab-door-left' : 'cab-door-right',
449
+ [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
450
+ [0, doorHeight / 2, frontZ],
451
+ { doorWidth, side },
452
+ ),
453
+ ),
454
+ )
455
+
456
+ const entrySpans = entries.map((entry, index) => {
457
+ const nextEntry = entries[index + 1]
458
+ return {
459
+ entry,
460
+ levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
461
+ }
462
+ })
463
+
464
+ for (const { entry, levelTopY } of entrySpans) {
465
+ const wallDepth = wallThickness
466
+ const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
467
+ const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
468
+ const jambCenterOffset = doorWidth / 2 + jambWidth / 2
469
+ const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
470
+
471
+ meshes.push(
472
+ createElevatorColliderMesh(
473
+ typedElevatorId,
474
+ 'shaft-front-left',
475
+ [jambWidth, levelHeight, wallDepth],
476
+ [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
477
+ ),
478
+ createElevatorColliderMesh(
479
+ typedElevatorId,
480
+ 'shaft-front-right',
481
+ [jambWidth, levelHeight, wallDepth],
482
+ [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
483
+ ),
484
+ createElevatorColliderMesh(
485
+ typedElevatorId,
486
+ 'shaft-front-header',
487
+ [shaftWidth, headerHeight, wallDepth],
488
+ [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
489
+ ),
490
+ createElevatorColliderMesh(
491
+ typedElevatorId,
492
+ 'landing-door-gate',
493
+ [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
494
+ [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
495
+ { doorWidth, levelId: entry.id },
496
+ ),
497
+ ...doorLeafSides.map((side) =>
498
+ createElevatorColliderMesh(
499
+ typedElevatorId,
500
+ side === 'left' ? 'landing-door-left' : 'landing-door-right',
501
+ [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
502
+ [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
503
+ { doorWidth, levelId: entry.id, side },
504
+ ),
505
+ ),
506
+ )
507
+ }
508
+ }
509
+
510
+ return meshes
511
+ }
512
+
513
+ function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
514
+ for (const mesh of meshes) {
515
+ const geometry = mesh.geometry as typeof mesh.geometry & {
516
+ disposeBoundsTree?: typeof disposeBoundsTree
517
+ }
518
+ geometry.disposeBoundsTree?.()
519
+ geometry.dispose()
520
+ }
69
521
  }
70
522
 
71
523
  const resolvePlacedSpawnNode = (
@@ -86,8 +538,17 @@ export const FirstPersonControls = () => {
86
538
  const yawRef = useRef(0)
87
539
  const pitchRef = useRef(0)
88
540
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
541
+ const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
542
+ const ridingElevatorRef = useRef<{
543
+ elevatorId: AnyNodeId
544
+ localControllerY: number | null
545
+ previousCarY: number
546
+ } | null>(null)
547
+ const rideLockedRef = useRef(false)
89
548
  const worldRef = useRef<FirstPersonColliderWorld | null>(null)
549
+ const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
90
550
  const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
551
+ const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
91
552
  const [controllerStart, setControllerStart] = useState<{
92
553
  position: [number, number, number]
93
554
  yaw: number
@@ -99,9 +560,22 @@ export const FirstPersonControls = () => {
99
560
  setWorld(nextWorld)
100
561
  }, [])
101
562
 
563
+ const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
564
+ disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
565
+ elevatorColliderMeshesRef.current = nextMeshes
566
+ setElevatorColliderMeshes(nextMeshes)
567
+ }, [])
568
+
102
569
  const rebuildColliderWorld = useCallback(() => {
103
570
  replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
104
- }, [replaceColliderWorld])
571
+ replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
572
+ }, [replaceColliderWorld, replaceElevatorColliderMeshes])
573
+
574
+ const setElevatorRideLocked = useCallback((locked: boolean) => {
575
+ if (rideLockedRef.current === locked) return
576
+ rideLockedRef.current = locked
577
+ setIsElevatorRideLocked(locked)
578
+ }, [])
105
579
 
106
580
  const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
107
581
  const nodes = useScene.getState().nodes
@@ -111,7 +585,7 @@ export const FirstPersonControls = () => {
111
585
  let closestDoorId: AnyNodeId | null = null
112
586
  let closestDistance = DOOR_INTERACTION_DISTANCE
113
587
 
114
- for (const doorId of sceneRegistry.byType.door) {
588
+ for (const doorId of sceneRegistry.byType.door!) {
115
589
  const node = nodes[doorId as AnyNodeId]
116
590
  if (node?.type !== 'door') continue
117
591
  if (node.openingKind === 'opening') continue
@@ -209,7 +683,7 @@ export const FirstPersonControls = () => {
209
683
  let closestWindowId: AnyNodeId | null = null
210
684
  let closestDistance = DOOR_INTERACTION_DISTANCE
211
685
 
212
- for (const windowId of sceneRegistry.byType.window) {
686
+ for (const windowId of sceneRegistry.byType.window!) {
213
687
  const node = nodes[windowId as AnyNodeId]
214
688
  if (node?.type !== 'window') continue
215
689
  if (node.openingKind === 'opening') continue
@@ -230,7 +704,61 @@ export const FirstPersonControls = () => {
230
704
  return closestWindowId
231
705
  }, [camera])
232
706
 
707
+ const resolveInteractableElevatorTarget =
708
+ useCallback((): FirstPersonInteractableTarget | null => {
709
+ const nodes = useScene.getState().nodes
710
+ camera.updateMatrixWorld(true)
711
+ elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
712
+
713
+ let closestTarget: FirstPersonInteractableTarget | null = null
714
+ let closestDistance = DOOR_INTERACTION_DISTANCE
715
+
716
+ for (const elevatorId of sceneRegistry.byType.elevator!) {
717
+ const typedElevatorId = elevatorId as AnyNodeId
718
+ const node = nodes[typedElevatorId]
719
+ if (node?.type !== 'elevator') continue
720
+
721
+ const object = sceneRegistry.nodes.get(typedElevatorId)
722
+ if (!object) continue
723
+
724
+ const runtime = useInteractive.getState().elevators[typedElevatorId]
725
+ object.updateWorldMatrix(true, true)
726
+ if (runtime) {
727
+ elevatorLocalEyePosition.copy(camera.position)
728
+ object.worldToLocal(elevatorLocalEyePosition)
729
+ }
730
+ const canUseCabButtons =
731
+ runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
732
+
733
+ const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
734
+ for (const intersection of intersections) {
735
+ if (intersection.distance > closestDistance) continue
736
+
737
+ const target = resolveElevatorButtonTarget(intersection.object)
738
+ if (!target || target.elevatorId !== elevatorId) continue
739
+ if (target.action === 'request-level') {
740
+ if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
741
+ }
742
+ if (target.buttonKind === 'cab' && !canUseCabButtons) continue
743
+
744
+ closestTarget = {
745
+ action: target.action,
746
+ buttonKind: target.buttonKind,
747
+ id: target.elevatorId,
748
+ levelId: target.levelId,
749
+ type: 'elevator',
750
+ }
751
+ closestDistance = intersection.distance
752
+ }
753
+ }
754
+
755
+ return closestTarget
756
+ }, [camera])
757
+
233
758
  const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
759
+ const elevatorTarget = resolveInteractableElevatorTarget()
760
+ if (elevatorTarget) return elevatorTarget
761
+
234
762
  const doorId = resolveInteractableDoorId()
235
763
  if (doorId) return { id: doorId, type: 'door' }
236
764
 
@@ -238,12 +766,42 @@ export const FirstPersonControls = () => {
238
766
  if (windowId) return { id: windowId, type: 'window' }
239
767
 
240
768
  return null
241
- }, [resolveInteractableDoorId, resolveInteractableWindowId])
769
+ }, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
242
770
 
243
771
  const toggleInteractableTarget = useCallback(() => {
244
772
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
245
773
  if (!target) return
246
774
 
775
+ if (target.type === 'elevator') {
776
+ if (target.buttonKind === 'cab') {
777
+ const state = useInteractive.getState().elevators[target.id]
778
+ if (state) {
779
+ ridingElevatorRef.current = {
780
+ elevatorId: target.id,
781
+ localControllerY: null,
782
+ previousCarY: state.carY,
783
+ }
784
+ }
785
+ }
786
+ if (target.action === 'open-door') {
787
+ openElevatorDoor(target.id)
788
+ return
789
+ }
790
+ if (target.levelId) {
791
+ const targetElevatorId =
792
+ target.buttonKind === 'landing'
793
+ ? resolveElevatorDispatchTarget({
794
+ elevators: useInteractive.getState().elevators,
795
+ levelId: target.levelId,
796
+ nodes: useScene.getState().nodes,
797
+ requestedElevatorId: target.id,
798
+ })
799
+ : target.id
800
+ requestElevatorLevel(targetElevatorId, target.levelId)
801
+ }
802
+ return
803
+ }
804
+
247
805
  if (target.type === 'window') {
248
806
  const node = useScene.getState().nodes[target.id]
249
807
  if (
@@ -270,6 +828,8 @@ export const FirstPersonControls = () => {
270
828
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
271
829
  if (!target) return
272
830
 
831
+ if (target.type === 'elevator') return
832
+
273
833
  if (target.type === 'window') {
274
834
  const node = useScene.getState().nodes[target.id]
275
835
  if (
@@ -325,6 +885,9 @@ export const FirstPersonControls = () => {
325
885
  return () => {
326
886
  worldRef.current?.dispose()
327
887
  worldRef.current = null
888
+ disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
889
+ elevatorColliderMeshesRef.current = []
890
+ setElevatorColliderMeshes([])
328
891
  setWorld(null)
329
892
  }
330
893
  }, [rebuildColliderWorld])
@@ -373,17 +936,28 @@ export const FirstPersonControls = () => {
373
936
  }
374
937
  }
375
938
 
939
+ const handleMouseDown = (event: MouseEvent) => {
940
+ if (document.pointerLockElement !== canvas) return
941
+ if (event.button !== 0) return
942
+
943
+ event.preventDefault()
944
+ event.stopPropagation()
945
+ toggleInteractableTarget()
946
+ }
947
+
376
948
  document.addEventListener('mousemove', handleMouseMove)
377
949
  document.addEventListener('click', handleClick)
950
+ document.addEventListener('mousedown', handleMouseDown, true)
378
951
 
379
952
  return () => {
380
953
  document.removeEventListener('mousemove', handleMouseMove)
381
954
  document.removeEventListener('click', handleClick)
955
+ document.removeEventListener('mousedown', handleMouseDown, true)
382
956
  if (document.pointerLockElement === canvas) {
383
957
  document.exitPointerLock()
384
958
  }
385
959
  }
386
- }, [gl])
960
+ }, [gl, toggleInteractableTarget])
387
961
 
388
962
  useEffect(() => {
389
963
  const canvas = gl.domElement
@@ -417,7 +991,229 @@ export const FirstPersonControls = () => {
417
991
  }
418
992
  }, [closeInteractableTarget, gl, toggleInteractableTarget])
419
993
 
420
- useFrame((_, delta) => {
994
+ const syncElevatorColliderMeshes = useCallback(() => {
995
+ const nodes = useScene.getState().nodes
996
+ const interactive = useInteractive.getState()
997
+
998
+ for (const mesh of elevatorColliderMeshesRef.current) {
999
+ const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
1000
+ const node = nodes[elevatorId]
1001
+ const runtime = interactive.elevators[elevatorId]
1002
+ const object = sceneRegistry.nodes.get(elevatorId)
1003
+ if (!(node?.type === 'elevator' && object && node.visible !== false)) {
1004
+ mesh.visible = false
1005
+ continue
1006
+ }
1007
+
1008
+ if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
1009
+ continue
1010
+ }
1011
+
1012
+ let [localX, localY, localZ] = localPosition
1013
+ const isCabCollider = kind.startsWith('cab-')
1014
+ const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
1015
+ const isCabDoorGate = kind === 'cab-door-gate'
1016
+ const isLandingDoorGate = kind === 'landing-door-gate'
1017
+
1018
+ if (isCabCollider) {
1019
+ localY += runtime?.carY ?? 0
1020
+ }
1021
+
1022
+ if (kind === 'cab-door-left' || kind === 'cab-door-right') {
1023
+ if (!runtime) {
1024
+ mesh.visible = false
1025
+ continue
1026
+ }
1027
+ localX = getElevatorDoorLeafX(
1028
+ side ?? 'left',
1029
+ doorWidth ?? node.doorWidth,
1030
+ runtime.doorOpen,
1031
+ node.doorStyle,
1032
+ )
1033
+ mesh.visible = true
1034
+ } else if (isCabDoorGate) {
1035
+ if (!runtime) {
1036
+ mesh.visible = false
1037
+ continue
1038
+ }
1039
+ mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1040
+ } else if (isDoorCollider) {
1041
+ const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1042
+ localX = getElevatorDoorLeafX(
1043
+ side ?? 'left',
1044
+ doorWidth ?? node.doorWidth,
1045
+ doorOpen,
1046
+ node.doorStyle,
1047
+ )
1048
+ mesh.visible = true
1049
+ } else if (isLandingDoorGate) {
1050
+ const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1051
+ mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1052
+ } else {
1053
+ mesh.visible = true
1054
+ }
1055
+
1056
+ object.updateWorldMatrix(true, false)
1057
+ elevatorColliderMatrix.copy(object.matrixWorld)
1058
+ elevatorColliderMatrix.multiply(
1059
+ elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
1060
+ )
1061
+ mesh.matrix.copy(elevatorColliderMatrix)
1062
+ mesh.matrixWorld.copy(elevatorColliderMatrix)
1063
+ mesh.userData.matrixInitialized = true
1064
+ }
1065
+ }, [])
1066
+
1067
+ useFrame(() => {
1068
+ syncElevatorColliderMeshes()
1069
+ }, -1)
1070
+
1071
+ const syncElevatorRide = useCallback(
1072
+ (group: Group) => {
1073
+ const nodes = useScene.getState().nodes
1074
+ const interactive = useInteractive.getState()
1075
+ const activeRide = ridingElevatorRef.current
1076
+ let nextRide: {
1077
+ cabHeight: number
1078
+ cabCenterZ: number
1079
+ carY: number
1080
+ doorOpen: number
1081
+ elevatorId: AnyNodeId
1082
+ halfDepth: number
1083
+ halfWidth: number
1084
+ object: Object3D
1085
+ phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
1086
+ } | null = null
1087
+
1088
+ const elevatorIds = activeRide
1089
+ ? [
1090
+ activeRide.elevatorId,
1091
+ ...Array.from(sceneRegistry.byType.elevator!).filter(
1092
+ (elevatorId) => elevatorId !== activeRide.elevatorId,
1093
+ ),
1094
+ ]
1095
+ : Array.from(sceneRegistry.byType.elevator!)
1096
+
1097
+ for (const elevatorId of elevatorIds) {
1098
+ const typedElevatorId = elevatorId as AnyNodeId
1099
+ const node = nodes[typedElevatorId]
1100
+ if (node?.type !== 'elevator') continue
1101
+
1102
+ const runtime = interactive.elevators[typedElevatorId]
1103
+ const object = sceneRegistry.nodes.get(typedElevatorId)
1104
+ if (!(runtime && object)) continue
1105
+
1106
+ object.updateWorldMatrix(true, true)
1107
+ elevatorLocalEyePosition.copy(camera.position)
1108
+ object.worldToLocal(elevatorLocalEyePosition)
1109
+
1110
+ const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1111
+ const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1112
+ const cabCenterZ = getElevatorCabCenterZ(node)
1113
+ const cabHeight = Math.max(node.cabHeight, 1.4)
1114
+ const insideFootprint =
1115
+ Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
1116
+ Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
1117
+ const insideCabHeight =
1118
+ elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
1119
+ elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
1120
+ const continuingRide =
1121
+ activeRide?.elevatorId === typedElevatorId &&
1122
+ insideFootprint &&
1123
+ elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
1124
+ elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
1125
+
1126
+ if ((insideFootprint && insideCabHeight) || continuingRide) {
1127
+ nextRide = {
1128
+ cabHeight,
1129
+ cabCenterZ,
1130
+ carY: runtime.carY,
1131
+ doorOpen: runtime.doorOpen,
1132
+ elevatorId: typedElevatorId,
1133
+ halfDepth: Math.max(halfDepth, 0.24),
1134
+ halfWidth: Math.max(halfWidth, 0.24),
1135
+ object,
1136
+ phase: runtime.phase,
1137
+ }
1138
+ break
1139
+ }
1140
+ }
1141
+
1142
+ if (!nextRide) {
1143
+ ridingElevatorRef.current = null
1144
+ setElevatorRideLocked(false)
1145
+ return
1146
+ }
1147
+
1148
+ const previousCarY =
1149
+ activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
1150
+ const deltaY = nextRide.carY - previousCarY
1151
+ nextRide.object.updateWorldMatrix(true, true)
1152
+ elevatorLocalControllerPosition.copy(group.position)
1153
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1154
+ const localControllerY =
1155
+ activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
1156
+ ? activeRide.localControllerY
1157
+ : elevatorLocalControllerPosition.y - nextRide.carY
1158
+
1159
+ if (Math.abs(deltaY) > 0.0001) {
1160
+ group.position.y += deltaY
1161
+ controllerRef.current?.resetLinVel()
1162
+ }
1163
+
1164
+ const shouldLockToCab =
1165
+ nextRide.phase === 'closing' ||
1166
+ nextRide.phase === 'moving' ||
1167
+ (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
1168
+ if (shouldLockToCab) {
1169
+ elevatorLocalControllerPosition.copy(group.position)
1170
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1171
+ const desiredLocalY = nextRide.carY + localControllerY
1172
+ if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
1173
+ elevatorLocalControllerPosition.y = desiredLocalY
1174
+ elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1175
+ nextRide.object.localToWorld(elevatorWorldControllerPosition)
1176
+ group.position.y = elevatorWorldControllerPosition.y
1177
+ controllerRef.current?.resetLinVel()
1178
+ elevatorLocalControllerPosition.copy(group.position)
1179
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1180
+ }
1181
+
1182
+ const clampedX = Math.max(
1183
+ -nextRide.halfWidth,
1184
+ Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
1185
+ )
1186
+ const clampedZ = Math.max(
1187
+ nextRide.cabCenterZ - nextRide.halfDepth,
1188
+ Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
1189
+ )
1190
+
1191
+ if (
1192
+ Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
1193
+ Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
1194
+ ) {
1195
+ elevatorLocalControllerPosition.x = clampedX
1196
+ elevatorLocalControllerPosition.z = clampedZ
1197
+ elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1198
+ nextRide.object.localToWorld(elevatorWorldControllerPosition)
1199
+ group.position.x = elevatorWorldControllerPosition.x
1200
+ group.position.z = elevatorWorldControllerPosition.z
1201
+ controllerRef.current?.resetLinVel()
1202
+ }
1203
+ }
1204
+
1205
+ setElevatorRideLocked(shouldLockToCab)
1206
+
1207
+ ridingElevatorRef.current = {
1208
+ elevatorId: nextRide.elevatorId,
1209
+ localControllerY,
1210
+ previousCarY: nextRide.carY,
1211
+ }
1212
+ },
1213
+ [camera, setElevatorRideLocked],
1214
+ )
1215
+
1216
+ useFrame(() => {
421
1217
  if (!controllerRef.current?.group) return
422
1218
 
423
1219
  const group = controllerRef.current.group
@@ -426,17 +1222,20 @@ export const FirstPersonControls = () => {
426
1222
  cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
427
1223
  camera.quaternion.setFromEuler(cameraEuler)
428
1224
  camera.updateMatrixWorld(true)
1225
+ syncElevatorRide(group)
1226
+ camera.position.copy(group.position).add(cameraOffset)
1227
+ camera.updateMatrixWorld(true)
429
1228
 
430
1229
  const nextInteractableTarget = resolveInteractableTarget()
431
1230
  const previousInteractableTarget = interactableTargetRef.current
432
1231
  if (
433
- previousInteractableTarget?.id !== nextInteractableTarget?.id ||
434
- previousInteractableTarget?.type !== nextInteractableTarget?.type
1232
+ getInteractableTargetKey(previousInteractableTarget) !==
1233
+ getInteractableTargetKey(nextInteractableTarget)
435
1234
  ) {
436
1235
  interactableTargetRef.current = nextInteractableTarget
437
1236
  useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
438
1237
  }
439
- })
1238
+ }, 2.5)
440
1239
 
441
1240
  useEffect(() => {
442
1241
  return () => {
@@ -446,6 +1245,11 @@ export const FirstPersonControls = () => {
446
1245
  }
447
1246
  }, [])
448
1247
 
1248
+ const firstPersonColliderMeshes = useMemo(
1249
+ () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
1250
+ [world, elevatorColliderMeshes],
1251
+ )
1252
+
449
1253
  if (!world) {
450
1254
  return null
451
1255
  }
@@ -458,7 +1262,7 @@ export const FirstPersonControls = () => {
458
1262
  acceleration={26}
459
1263
  airDragFactor={0.3}
460
1264
  colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
461
- colliderMeshes={[world.mesh]}
1265
+ colliderMeshes={firstPersonColliderMeshes}
462
1266
  collisionCheckIteration={3}
463
1267
  collisionPushBackDamping={0.1}
464
1268
  collisionPushBackThreshold={0.001}
@@ -478,6 +1282,7 @@ export const FirstPersonControls = () => {
478
1282
  maxRunSpeed={5.5}
479
1283
  maxSlope={1.2}
480
1284
  maxWalkSpeed={4}
1285
+ paused={isElevatorRideLocked}
481
1286
  position={controllerStart.position}
482
1287
  ref={controllerRef}
483
1288
  />
@@ -519,7 +1324,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
519
1324
  return (
520
1325
  <>
521
1326
  {isLocked && (
522
- <div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
1327
+ <div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
523
1328
  <div className="relative h-7 w-7">
524
1329
  <div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
525
1330
  <div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
@@ -527,7 +1332,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
527
1332
  </div>
528
1333
  )}
529
1334
 
530
- <div className="fixed top-4 right-4 z-50">
1335
+ <div className="absolute top-4 right-4 z-50">
531
1336
  <button
532
1337
  className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
533
1338
  onClick={handleExit}
@@ -541,7 +1346,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
541
1346
  </div>
542
1347
 
543
1348
  {!hasPlacedSpawn && (
544
- <div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
1349
+ <div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
545
1350
  <div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
546
1351
  Place a Spawn Point from the Build tab to control where walkthrough starts.
547
1352
  </div>
@@ -549,7 +1354,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
549
1354
  )}
550
1355
 
551
1356
  {isLocked && (
552
- <div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
1357
+ <div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
553
1358
  <div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
554
1359
  <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
555
1360
  <div className="h-px w-full bg-border/30" />