@pascal-app/editor 0.8.0 → 0.9.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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,12 +1,51 @@
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'
48
+ import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
10
49
  import {
11
50
  closeDoorOpenState,
12
51
  DOOR_SWING_OPEN_ANGLE,
@@ -26,15 +65,23 @@ import {
26
65
  type FirstPersonColliderWorld,
27
66
  type FirstPersonSpawn,
28
67
  } from './first-person/build-collider-world'
29
- import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
30
- import BVHEcctrl from './first-person/bvh-ecctrl'
31
68
 
32
69
  const CAMERA_EYE_OFFSET = 0.45
33
70
  const LOOK_SENSITIVITY = 0.002
34
71
  const CONTROLLER_CENTER_FROM_EYE = 0.85
35
72
  const DOOR_INTERACTION_DISTANCE = 2.5
36
73
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
37
- const keyboardMap = [
74
+ const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
75
+ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
76
+ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
77
+ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
78
+ const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
79
+ const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
80
+ const VOID_FALL_RESPAWN_DEPTH = 12
81
+
82
+ type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
83
+
84
+ const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
38
85
  { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
39
86
  { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
40
87
  { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
@@ -42,6 +89,36 @@ const keyboardMap = [
42
89
  { name: 'jump', keys: ['Space'] },
43
90
  { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
44
91
  ]
92
+ const keyboardMap = movementKeyboardBindings
93
+ const movementKeyToName = new Map<string, MovementKeyName>(
94
+ movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
95
+ )
96
+
97
+ const inactiveMovementInput: MovementInput = {
98
+ backward: false,
99
+ forward: false,
100
+ jump: false,
101
+ leftward: false,
102
+ rightward: false,
103
+ run: false,
104
+ }
105
+
106
+ function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
107
+ const name = movementKeyToName.get(code)
108
+ return name ? ({ [name]: active } as MovementInput) : null
109
+ }
110
+
111
+ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
112
+ const activeElement = document.activeElement
113
+ if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
114
+ activeElement.blur()
115
+ }
116
+
117
+ if (!canvas.hasAttribute('tabindex')) {
118
+ canvas.tabIndex = -1
119
+ }
120
+ canvas.focus({ preventScroll: true })
121
+ }
45
122
 
46
123
  const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
47
124
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
@@ -59,13 +136,421 @@ const doorOpeningLocalHit = new Vector3()
59
136
  const doorOpeningLocalRay = new Ray()
60
137
  const doorOpeningMatrix = new Matrix4()
61
138
  const doorOpeningWorldHit = new Vector3()
139
+ const elevatorLocalControllerPosition = new Vector3()
140
+ const elevatorInteractionRaycaster = new Raycaster()
141
+ const elevatorColliderMatrix = new Matrix4()
142
+ const elevatorColliderLocalMatrix = new Matrix4()
143
+ const elevatorLocalEyePosition = new Vector3()
144
+ const elevatorWorldControllerPosition = new Vector3()
145
+ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
62
146
  const spawnWorldPosition = new Vector3()
63
147
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
64
148
  const windowInteractionRaycaster = new Raycaster()
65
149
 
66
- type FirstPersonInteractableTarget = {
67
- id: AnyNodeId
68
- type: 'door' | 'window'
150
+ type ElevatorColliderKind =
151
+ | 'cab-back'
152
+ | 'cab-ceiling'
153
+ | 'cab-door-left'
154
+ | 'cab-door-right'
155
+ | 'cab-door-gate'
156
+ | 'cab-floor'
157
+ | 'cab-left'
158
+ | 'cab-right'
159
+ | 'landing-door-gate'
160
+ | 'landing-door-left'
161
+ | 'landing-door-right'
162
+ | 'shaft-back'
163
+ | 'shaft-front-header'
164
+ | 'shaft-front-left'
165
+ | 'shaft-front-right'
166
+ | 'shaft-left'
167
+ | 'shaft-right'
168
+ | 'shaft-top'
169
+
170
+ type ElevatorColliderUserData = {
171
+ doorWidth?: number
172
+ dynamic?: boolean
173
+ elevatorId: AnyNodeId
174
+ kind: ElevatorColliderKind
175
+ levelId?: AnyNodeId
176
+ localPosition: [number, number, number]
177
+ matrixInitialized?: boolean
178
+ side?: ElevatorDoorSide
179
+ }
180
+
181
+ type ElevatorColliderMesh = Mesh & {
182
+ userData: Mesh['userData'] & ElevatorColliderUserData
183
+ }
184
+
185
+ type FirstPersonInteractableTarget =
186
+ | {
187
+ id: AnyNodeId
188
+ type: 'door' | 'window'
189
+ }
190
+ | {
191
+ action: 'open-door' | 'request-level'
192
+ buttonKind: 'cab' | 'landing'
193
+ id: AnyNodeId
194
+ levelId?: AnyNodeId
195
+ type: 'elevator'
196
+ }
197
+
198
+ type ElevatorButtonTarget = {
199
+ action: 'open-door' | 'request-level'
200
+ buttonKind: 'cab' | 'landing'
201
+ elevatorId: AnyNodeId
202
+ levelId?: AnyNodeId
203
+ }
204
+
205
+ function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
206
+ let current: Object3D | null = object
207
+
208
+ while (current) {
209
+ const candidate = (
210
+ current.userData as {
211
+ elevatorButton?: {
212
+ action?: unknown
213
+ disabled?: unknown
214
+ elevatorId?: unknown
215
+ kind?: unknown
216
+ levelId?: unknown
217
+ }
218
+ }
219
+ ).elevatorButton
220
+
221
+ if (candidate?.disabled === true) {
222
+ return null
223
+ }
224
+
225
+ if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
226
+ const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
227
+ if (action === 'open-door') {
228
+ return {
229
+ action,
230
+ buttonKind: candidate.kind,
231
+ elevatorId: candidate.elevatorId as AnyNodeId,
232
+ }
233
+ }
234
+ }
235
+
236
+ if (
237
+ typeof candidate?.elevatorId === 'string' &&
238
+ typeof candidate.levelId === 'string' &&
239
+ (candidate.kind === 'cab' || candidate.kind === 'landing')
240
+ ) {
241
+ return {
242
+ action: 'request-level',
243
+ buttonKind: candidate.kind,
244
+ elevatorId: candidate.elevatorId as AnyNodeId,
245
+ levelId: candidate.levelId as AnyNodeId,
246
+ }
247
+ }
248
+
249
+ current = current.parent
250
+ }
251
+
252
+ return null
253
+ }
254
+
255
+ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
256
+ if (!target) return null
257
+ return target.type === 'elevator'
258
+ ? `${target.type}:${target.id}:${target.levelId}`
259
+ : `${target.type}:${target.id}`
260
+ }
261
+
262
+ function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
263
+ return kind.startsWith('cab-') || kind.startsWith('landing-door')
264
+ }
265
+
266
+ function isInsideElevatorCab(
267
+ elevator: ElevatorNode,
268
+ runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
269
+ localEyePosition: Vector3,
270
+ ) {
271
+ const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
272
+ const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
273
+ const cabCenterZ = getElevatorCabCenterZ(elevator)
274
+ const cabHeight = Math.max(elevator.cabHeight, 1.4)
275
+
276
+ return (
277
+ Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
278
+ Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
279
+ localEyePosition.y >= runtime.carY + 0.35 &&
280
+ localEyePosition.y <= runtime.carY + cabHeight + 0.7
281
+ )
282
+ }
283
+
284
+ function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
285
+ const level = nodes[levelId as AnyNodeId]
286
+ if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
287
+
288
+ let maxTop = 0
289
+ for (const childId of level.children) {
290
+ const child = nodes[childId as AnyNodeId]
291
+ if (!child) continue
292
+
293
+ if (child.type === 'ceiling') {
294
+ maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
295
+ continue
296
+ }
297
+
298
+ if (child.type === 'wall') {
299
+ const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
300
+ maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
301
+ }
302
+ }
303
+
304
+ return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
305
+ }
306
+
307
+ function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
308
+ const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
309
+
310
+ const baseYByLevelId = new Map<string, number>()
311
+ let cumulativeY = 0
312
+ for (const level of allLevels) {
313
+ baseYByLevelId.set(level.id, cumulativeY)
314
+ cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
315
+ }
316
+
317
+ const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
318
+ const entries = serviceLevels.map((level) => ({
319
+ baseY: baseYByLevelId.get(level.id) ?? 0,
320
+ id: level.id as AnyNodeId,
321
+ }))
322
+ const firstServedLevel = serviceLevels[0] ?? null
323
+ const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
324
+ const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
325
+ const lastServedIndex = lastServedLevel
326
+ ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
327
+ : -1
328
+ const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
329
+ const shaftTopY = nextLevel
330
+ ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
331
+ : lastServedLevel
332
+ ? cumulativeY
333
+ : elevator.cabHeight + 0.3
334
+
335
+ return {
336
+ entries,
337
+ shaftBaseY,
338
+ shaftTopY,
339
+ totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
340
+ }
341
+ }
342
+
343
+ function createElevatorColliderMesh(
344
+ elevatorId: AnyNodeId,
345
+ kind: ElevatorColliderKind,
346
+ size: [number, number, number],
347
+ localPosition: [number, number, number],
348
+ userData: Partial<ElevatorColliderUserData> = {},
349
+ ) {
350
+ const geometry = new BoxGeometry(size[0], size[1], size[2])
351
+
352
+ const bvhGeometry = geometry as typeof geometry & {
353
+ computeBoundsTree?: typeof computeBoundsTree
354
+ disposeBoundsTree?: typeof disposeBoundsTree
355
+ }
356
+ ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
357
+ ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
358
+ bvhGeometry.computeBoundsTree?.({
359
+ maxLeafSize: 12,
360
+ strategy: 0,
361
+ } as never)
362
+ bvhGeometry.computeBoundingBox()
363
+
364
+ const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
365
+ mesh.raycast = acceleratedRaycast
366
+ mesh.matrixAutoUpdate = false
367
+ mesh.visible = true
368
+ mesh.userData = {
369
+ ...userData,
370
+ dynamic: isDynamicElevatorCollider(kind),
371
+ elevatorId,
372
+ excludeCollisionCheck: false,
373
+ excludeFloatHit: false,
374
+ friction: 0.8,
375
+ kind,
376
+ localPosition,
377
+ matrixInitialized: false,
378
+ restitution: 0.03,
379
+ type: 'ELEVATOR_COLLIDER',
380
+ }
381
+ return mesh
382
+ }
383
+
384
+ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
385
+ const nodes = useScene.getState().nodes
386
+ const meshes: ElevatorColliderMesh[] = []
387
+
388
+ for (const elevatorId of sceneRegistry.byType.elevator!) {
389
+ const typedElevatorId = elevatorId as AnyNodeId
390
+ const node = nodes[typedElevatorId]
391
+ if (node?.type !== 'elevator' || node.visible === false) continue
392
+
393
+ const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
394
+ node,
395
+ nodes,
396
+ )
397
+ const cabWidth = getElevatorCabWidth(node)
398
+ const cabDepth = getElevatorCabDepth(node)
399
+ const shaftWidth = getElevatorShaftWidth(node, cabWidth)
400
+ const shaftDepth = getElevatorShaftDepth(node, cabDepth)
401
+ const cabHeight = Math.max(node.cabHeight, 1.4)
402
+ const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
403
+ const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
404
+ const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
405
+ const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
406
+ const wallThickness = getElevatorShaftWallThickness(node)
407
+ const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
408
+ const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
409
+ const frontWallZ = -shaftDepth / 2 - wallThickness / 2
410
+ const frontZ = frontWallZ - wallThickness / 2 - 0.018
411
+ const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
412
+ const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
413
+ const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
414
+ const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
415
+
416
+ meshes.push(
417
+ createElevatorColliderMesh(
418
+ typedElevatorId,
419
+ 'shaft-back',
420
+ [shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
421
+ [0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
422
+ ),
423
+ createElevatorColliderMesh(
424
+ typedElevatorId,
425
+ 'shaft-left',
426
+ [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
427
+ [-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
428
+ ),
429
+ createElevatorColliderMesh(
430
+ typedElevatorId,
431
+ 'shaft-right',
432
+ [wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
433
+ [shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
434
+ ),
435
+ createElevatorColliderMesh(
436
+ typedElevatorId,
437
+ 'shaft-top',
438
+ [shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
439
+ [0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
440
+ ),
441
+ createElevatorColliderMesh(
442
+ typedElevatorId,
443
+ 'cab-floor',
444
+ [cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
445
+ [0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
446
+ ),
447
+ createElevatorColliderMesh(
448
+ typedElevatorId,
449
+ 'cab-ceiling',
450
+ [cabWidth, wallThickness, cabDepth],
451
+ [0, cabHeight - wallThickness / 2, cabCenterZ],
452
+ ),
453
+ createElevatorColliderMesh(
454
+ typedElevatorId,
455
+ 'cab-back',
456
+ [cabWidth, cabHeight, wallThickness],
457
+ [0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
458
+ ),
459
+ createElevatorColliderMesh(
460
+ typedElevatorId,
461
+ 'cab-left',
462
+ [wallThickness, cabHeight, cabDepth],
463
+ [-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
464
+ ),
465
+ createElevatorColliderMesh(
466
+ typedElevatorId,
467
+ 'cab-right',
468
+ [wallThickness, cabHeight, cabDepth],
469
+ [cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
470
+ ),
471
+ createElevatorColliderMesh(
472
+ typedElevatorId,
473
+ 'cab-door-gate',
474
+ [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
475
+ [0, doorHeight / 2, frontZ],
476
+ { doorWidth },
477
+ ),
478
+ ...doorLeafSides.map((side) =>
479
+ createElevatorColliderMesh(
480
+ typedElevatorId,
481
+ side === 'left' ? 'cab-door-left' : 'cab-door-right',
482
+ [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
483
+ [0, doorHeight / 2, frontZ],
484
+ { doorWidth, side },
485
+ ),
486
+ ),
487
+ )
488
+
489
+ const entrySpans = entries.map((entry, index) => {
490
+ const nextEntry = entries[index + 1]
491
+ return {
492
+ entry,
493
+ levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
494
+ }
495
+ })
496
+
497
+ for (const { entry, levelTopY } of entrySpans) {
498
+ const wallDepth = wallThickness
499
+ const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
500
+ const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
501
+ const jambCenterOffset = doorWidth / 2 + jambWidth / 2
502
+ const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
503
+
504
+ meshes.push(
505
+ createElevatorColliderMesh(
506
+ typedElevatorId,
507
+ 'shaft-front-left',
508
+ [jambWidth, levelHeight, wallDepth],
509
+ [-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
510
+ ),
511
+ createElevatorColliderMesh(
512
+ typedElevatorId,
513
+ 'shaft-front-right',
514
+ [jambWidth, levelHeight, wallDepth],
515
+ [jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
516
+ ),
517
+ createElevatorColliderMesh(
518
+ typedElevatorId,
519
+ 'shaft-front-header',
520
+ [shaftWidth, headerHeight, wallDepth],
521
+ [0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
522
+ ),
523
+ createElevatorColliderMesh(
524
+ typedElevatorId,
525
+ 'landing-door-gate',
526
+ [doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
527
+ [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
528
+ { doorWidth, levelId: entry.id },
529
+ ),
530
+ ...doorLeafSides.map((side) =>
531
+ createElevatorColliderMesh(
532
+ typedElevatorId,
533
+ side === 'left' ? 'landing-door-left' : 'landing-door-right',
534
+ [leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
535
+ [0, entry.baseY + doorHeight / 2, frontZ - 0.02],
536
+ { doorWidth, levelId: entry.id, side },
537
+ ),
538
+ ),
539
+ )
540
+ }
541
+ }
542
+
543
+ return meshes
544
+ }
545
+
546
+ function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
547
+ for (const mesh of meshes) {
548
+ const geometry = mesh.geometry as typeof mesh.geometry & {
549
+ disposeBoundsTree?: typeof disposeBoundsTree
550
+ }
551
+ geometry.disposeBoundsTree?.()
552
+ geometry.dispose()
553
+ }
69
554
  }
70
555
 
71
556
  const resolvePlacedSpawnNode = (
@@ -83,11 +568,22 @@ export const FirstPersonControls = () => {
83
568
  const selectedLevelId = useViewer((state) => state.selection.levelId)
84
569
  const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
85
570
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
571
+ const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
572
+ const hadPointerLockRef = useRef(false)
86
573
  const yawRef = useRef(0)
87
574
  const pitchRef = useRef(0)
88
575
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
576
+ const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
577
+ const ridingElevatorRef = useRef<{
578
+ elevatorId: AnyNodeId
579
+ localControllerY: number | null
580
+ previousCarY: number
581
+ } | null>(null)
582
+ const rideLockedRef = useRef(false)
89
583
  const worldRef = useRef<FirstPersonColliderWorld | null>(null)
584
+ const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
90
585
  const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
586
+ const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
91
587
  const [controllerStart, setControllerStart] = useState<{
92
588
  position: [number, number, number]
93
589
  yaw: number
@@ -99,9 +595,29 @@ export const FirstPersonControls = () => {
99
595
  setWorld(nextWorld)
100
596
  }, [])
101
597
 
598
+ const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
599
+ disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
600
+ elevatorColliderMeshesRef.current = nextMeshes
601
+ setElevatorColliderMeshes(nextMeshes)
602
+ }, [])
603
+
102
604
  const rebuildColliderWorld = useCallback(() => {
103
605
  replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
104
- }, [replaceColliderWorld])
606
+ replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
607
+ }, [replaceColliderWorld, replaceElevatorColliderMeshes])
608
+
609
+ const setElevatorRideLocked = useCallback((locked: boolean) => {
610
+ if (rideLockedRef.current === locked) return
611
+ rideLockedRef.current = locked
612
+ setIsElevatorRideLocked(locked)
613
+ }, [])
614
+
615
+ const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
616
+ controllerRef.current = api
617
+ if (api) {
618
+ api.setMovement(movementInputRef.current)
619
+ }
620
+ }, [])
105
621
 
106
622
  const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
107
623
  const nodes = useScene.getState().nodes
@@ -111,7 +627,7 @@ export const FirstPersonControls = () => {
111
627
  let closestDoorId: AnyNodeId | null = null
112
628
  let closestDistance = DOOR_INTERACTION_DISTANCE
113
629
 
114
- for (const doorId of sceneRegistry.byType.door) {
630
+ for (const doorId of sceneRegistry.byType.door!) {
115
631
  const node = nodes[doorId as AnyNodeId]
116
632
  if (node?.type !== 'door') continue
117
633
  if (node.openingKind === 'opening') continue
@@ -209,7 +725,7 @@ export const FirstPersonControls = () => {
209
725
  let closestWindowId: AnyNodeId | null = null
210
726
  let closestDistance = DOOR_INTERACTION_DISTANCE
211
727
 
212
- for (const windowId of sceneRegistry.byType.window) {
728
+ for (const windowId of sceneRegistry.byType.window!) {
213
729
  const node = nodes[windowId as AnyNodeId]
214
730
  if (node?.type !== 'window') continue
215
731
  if (node.openingKind === 'opening') continue
@@ -230,7 +746,61 @@ export const FirstPersonControls = () => {
230
746
  return closestWindowId
231
747
  }, [camera])
232
748
 
749
+ const resolveInteractableElevatorTarget =
750
+ useCallback((): FirstPersonInteractableTarget | null => {
751
+ const nodes = useScene.getState().nodes
752
+ camera.updateMatrixWorld(true)
753
+ elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
754
+
755
+ let closestTarget: FirstPersonInteractableTarget | null = null
756
+ let closestDistance = DOOR_INTERACTION_DISTANCE
757
+
758
+ for (const elevatorId of sceneRegistry.byType.elevator!) {
759
+ const typedElevatorId = elevatorId as AnyNodeId
760
+ const node = nodes[typedElevatorId]
761
+ if (node?.type !== 'elevator') continue
762
+
763
+ const object = sceneRegistry.nodes.get(typedElevatorId)
764
+ if (!object) continue
765
+
766
+ const runtime = useInteractive.getState().elevators[typedElevatorId]
767
+ object.updateWorldMatrix(true, true)
768
+ if (runtime) {
769
+ elevatorLocalEyePosition.copy(camera.position)
770
+ object.worldToLocal(elevatorLocalEyePosition)
771
+ }
772
+ const canUseCabButtons =
773
+ runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
774
+
775
+ const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
776
+ for (const intersection of intersections) {
777
+ if (intersection.distance > closestDistance) continue
778
+
779
+ const target = resolveElevatorButtonTarget(intersection.object)
780
+ if (!target || target.elevatorId !== elevatorId) continue
781
+ if (target.action === 'request-level') {
782
+ if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
783
+ }
784
+ if (target.buttonKind === 'cab' && !canUseCabButtons) continue
785
+
786
+ closestTarget = {
787
+ action: target.action,
788
+ buttonKind: target.buttonKind,
789
+ id: target.elevatorId,
790
+ levelId: target.levelId,
791
+ type: 'elevator',
792
+ }
793
+ closestDistance = intersection.distance
794
+ }
795
+ }
796
+
797
+ return closestTarget
798
+ }, [camera])
799
+
233
800
  const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
801
+ const elevatorTarget = resolveInteractableElevatorTarget()
802
+ if (elevatorTarget) return elevatorTarget
803
+
234
804
  const doorId = resolveInteractableDoorId()
235
805
  if (doorId) return { id: doorId, type: 'door' }
236
806
 
@@ -238,12 +808,42 @@ export const FirstPersonControls = () => {
238
808
  if (windowId) return { id: windowId, type: 'window' }
239
809
 
240
810
  return null
241
- }, [resolveInteractableDoorId, resolveInteractableWindowId])
811
+ }, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
242
812
 
243
813
  const toggleInteractableTarget = useCallback(() => {
244
814
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
245
815
  if (!target) return
246
816
 
817
+ if (target.type === 'elevator') {
818
+ if (target.buttonKind === 'cab') {
819
+ const state = useInteractive.getState().elevators[target.id]
820
+ if (state) {
821
+ ridingElevatorRef.current = {
822
+ elevatorId: target.id,
823
+ localControllerY: null,
824
+ previousCarY: state.carY,
825
+ }
826
+ }
827
+ }
828
+ if (target.action === 'open-door') {
829
+ openElevatorDoor(target.id)
830
+ return
831
+ }
832
+ if (target.levelId) {
833
+ const targetElevatorId =
834
+ target.buttonKind === 'landing'
835
+ ? resolveElevatorDispatchTarget({
836
+ elevators: useInteractive.getState().elevators,
837
+ levelId: target.levelId,
838
+ nodes: useScene.getState().nodes,
839
+ requestedElevatorId: target.id,
840
+ })
841
+ : target.id
842
+ requestElevatorLevel(targetElevatorId, target.levelId)
843
+ }
844
+ return
845
+ }
846
+
247
847
  if (target.type === 'window') {
248
848
  const node = useScene.getState().nodes[target.id]
249
849
  if (
@@ -270,6 +870,8 @@ export const FirstPersonControls = () => {
270
870
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
271
871
  if (!target) return
272
872
 
873
+ if (target.type === 'elevator') return
874
+
273
875
  if (target.type === 'window') {
274
876
  const node = useScene.getState().nodes[target.id]
275
877
  if (
@@ -325,6 +927,9 @@ export const FirstPersonControls = () => {
325
927
  return () => {
326
928
  worldRef.current?.dispose()
327
929
  worldRef.current = null
930
+ disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
931
+ elevatorColliderMeshesRef.current = []
932
+ setElevatorColliderMeshes([])
328
933
  setWorld(null)
329
934
  }
330
935
  }, [rebuildColliderWorld])
@@ -352,6 +957,14 @@ export const FirstPersonControls = () => {
352
957
  })
353
958
  }, [camera, controllerStart, placedSpawn, world])
354
959
 
960
+ useEffect(() => {
961
+ const canvas = gl.domElement
962
+ focusFirstPersonCanvas(canvas)
963
+
964
+ const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
965
+ return () => window.cancelAnimationFrame(frame)
966
+ }, [gl])
967
+
355
968
  useEffect(() => {
356
969
  const canvas = gl.domElement
357
970
  const handleMouseMove = (e: MouseEvent) => {
@@ -373,22 +986,65 @@ export const FirstPersonControls = () => {
373
986
  }
374
987
  }
375
988
 
989
+ const handleMouseDown = (event: MouseEvent) => {
990
+ if (document.pointerLockElement !== canvas) return
991
+ if (event.button !== 0) return
992
+
993
+ event.preventDefault()
994
+ event.stopPropagation()
995
+ toggleInteractableTarget()
996
+ }
997
+
998
+ const handlePointerLockChange = () => {
999
+ const isLocked = document.pointerLockElement === canvas
1000
+ if (isLocked) {
1001
+ hadPointerLockRef.current = true
1002
+ return
1003
+ }
1004
+
1005
+ if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1006
+ useEditor.getState().setFirstPersonMode(false)
1007
+ }
1008
+ }
1009
+
1010
+ handlePointerLockChange()
376
1011
  document.addEventListener('mousemove', handleMouseMove)
377
1012
  document.addEventListener('click', handleClick)
1013
+ document.addEventListener('mousedown', handleMouseDown, true)
1014
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
378
1015
 
379
1016
  return () => {
380
1017
  document.removeEventListener('mousemove', handleMouseMove)
381
1018
  document.removeEventListener('click', handleClick)
1019
+ document.removeEventListener('mousedown', handleMouseDown, true)
1020
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
382
1021
  if (document.pointerLockElement === canvas) {
383
1022
  document.exitPointerLock()
384
1023
  }
385
1024
  }
386
- }, [gl])
1025
+ }, [gl, toggleInteractableTarget])
387
1026
 
388
1027
  useEffect(() => {
389
1028
  const canvas = gl.domElement
390
1029
 
1030
+ const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
1031
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1032
+ return false
1033
+ }
1034
+
1035
+ const movement = getMovementInputForKey(event.code, active)
1036
+ if (!movement) return false
1037
+
1038
+ event.preventDefault()
1039
+ Object.assign(movementInputRef.current, movement)
1040
+ controllerRef.current?.setMovement(movement)
1041
+ return true
1042
+ }
1043
+
391
1044
  const handleKeyDown = (event: KeyboardEvent) => {
1045
+ const handledMovement = applyMovementKey(event, true)
1046
+ if (handledMovement) return
1047
+
392
1048
  if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
393
1049
  return
394
1050
  }
@@ -411,32 +1067,286 @@ export const FirstPersonControls = () => {
411
1067
  }
412
1068
  }
413
1069
 
1070
+ const handleKeyUp = (event: KeyboardEvent) => {
1071
+ applyMovementKey(event, false)
1072
+ }
1073
+
414
1074
  document.addEventListener('keydown', handleKeyDown, true)
1075
+ document.addEventListener('keyup', handleKeyUp, true)
415
1076
  return () => {
416
1077
  document.removeEventListener('keydown', handleKeyDown, true)
1078
+ document.removeEventListener('keyup', handleKeyUp, true)
417
1079
  }
418
1080
  }, [closeInteractableTarget, gl, toggleInteractableTarget])
419
1081
 
420
- useFrame((_, delta) => {
1082
+ const syncElevatorColliderMeshes = useCallback(() => {
1083
+ const nodes = useScene.getState().nodes
1084
+ const interactive = useInteractive.getState()
1085
+
1086
+ for (const mesh of elevatorColliderMeshesRef.current) {
1087
+ const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
1088
+ const node = nodes[elevatorId]
1089
+ const runtime = interactive.elevators[elevatorId]
1090
+ const object = sceneRegistry.nodes.get(elevatorId)
1091
+ if (!(node?.type === 'elevator' && object && node.visible !== false)) {
1092
+ mesh.visible = false
1093
+ continue
1094
+ }
1095
+
1096
+ if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
1097
+ continue
1098
+ }
1099
+
1100
+ let [localX, localY, localZ] = localPosition
1101
+ const isCabCollider = kind.startsWith('cab-')
1102
+ const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
1103
+ const isCabDoorGate = kind === 'cab-door-gate'
1104
+ const isLandingDoorGate = kind === 'landing-door-gate'
1105
+
1106
+ if (isCabCollider) {
1107
+ localY += runtime?.carY ?? 0
1108
+ }
1109
+
1110
+ if (kind === 'cab-door-left' || kind === 'cab-door-right') {
1111
+ if (!runtime) {
1112
+ mesh.visible = false
1113
+ continue
1114
+ }
1115
+ localX = getElevatorDoorLeafX(
1116
+ side ?? 'left',
1117
+ doorWidth ?? node.doorWidth,
1118
+ runtime.doorOpen,
1119
+ node.doorStyle,
1120
+ )
1121
+ mesh.visible = true
1122
+ } else if (isCabDoorGate) {
1123
+ if (!runtime) {
1124
+ mesh.visible = false
1125
+ continue
1126
+ }
1127
+ mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1128
+ } else if (isDoorCollider) {
1129
+ const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1130
+ localX = getElevatorDoorLeafX(
1131
+ side ?? 'left',
1132
+ doorWidth ?? node.doorWidth,
1133
+ doorOpen,
1134
+ node.doorStyle,
1135
+ )
1136
+ mesh.visible = true
1137
+ } else if (isLandingDoorGate) {
1138
+ const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
1139
+ mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
1140
+ } else {
1141
+ mesh.visible = true
1142
+ }
1143
+
1144
+ object.updateWorldMatrix(true, false)
1145
+ elevatorColliderMatrix.copy(object.matrixWorld)
1146
+ elevatorColliderMatrix.multiply(
1147
+ elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
1148
+ )
1149
+ mesh.matrix.copy(elevatorColliderMatrix)
1150
+ mesh.matrixWorld.copy(elevatorColliderMatrix)
1151
+ mesh.userData.matrixInitialized = true
1152
+ }
1153
+ }, [])
1154
+
1155
+ useFrame(() => {
1156
+ syncElevatorColliderMeshes()
1157
+ }, -1)
1158
+
1159
+ const syncElevatorRide = useCallback(
1160
+ (group: Group) => {
1161
+ const nodes = useScene.getState().nodes
1162
+ const interactive = useInteractive.getState()
1163
+ const activeRide = ridingElevatorRef.current
1164
+ let nextRide: {
1165
+ cabHeight: number
1166
+ cabCenterZ: number
1167
+ carY: number
1168
+ doorOpen: number
1169
+ elevatorId: AnyNodeId
1170
+ halfDepth: number
1171
+ halfWidth: number
1172
+ object: Object3D
1173
+ phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
1174
+ } | null = null
1175
+
1176
+ const elevatorIds = activeRide
1177
+ ? [
1178
+ activeRide.elevatorId,
1179
+ ...Array.from(sceneRegistry.byType.elevator!).filter(
1180
+ (elevatorId) => elevatorId !== activeRide.elevatorId,
1181
+ ),
1182
+ ]
1183
+ : Array.from(sceneRegistry.byType.elevator!)
1184
+
1185
+ for (const elevatorId of elevatorIds) {
1186
+ const typedElevatorId = elevatorId as AnyNodeId
1187
+ const node = nodes[typedElevatorId]
1188
+ if (node?.type !== 'elevator') continue
1189
+
1190
+ const runtime = interactive.elevators[typedElevatorId]
1191
+ const object = sceneRegistry.nodes.get(typedElevatorId)
1192
+ if (!(runtime && object)) continue
1193
+
1194
+ object.updateWorldMatrix(true, true)
1195
+ elevatorLocalEyePosition.copy(camera.position)
1196
+ object.worldToLocal(elevatorLocalEyePosition)
1197
+
1198
+ const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1199
+ const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
1200
+ const cabCenterZ = getElevatorCabCenterZ(node)
1201
+ const cabHeight = Math.max(node.cabHeight, 1.4)
1202
+ const insideFootprint =
1203
+ Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
1204
+ Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
1205
+ const insideCabHeight =
1206
+ elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
1207
+ elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
1208
+ const continuingRide =
1209
+ activeRide?.elevatorId === typedElevatorId &&
1210
+ insideFootprint &&
1211
+ elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
1212
+ elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
1213
+
1214
+ if ((insideFootprint && insideCabHeight) || continuingRide) {
1215
+ nextRide = {
1216
+ cabHeight,
1217
+ cabCenterZ,
1218
+ carY: runtime.carY,
1219
+ doorOpen: runtime.doorOpen,
1220
+ elevatorId: typedElevatorId,
1221
+ halfDepth: Math.max(halfDepth, 0.24),
1222
+ halfWidth: Math.max(halfWidth, 0.24),
1223
+ object,
1224
+ phase: runtime.phase,
1225
+ }
1226
+ break
1227
+ }
1228
+ }
1229
+
1230
+ if (!nextRide) {
1231
+ ridingElevatorRef.current = null
1232
+ setElevatorRideLocked(false)
1233
+ return
1234
+ }
1235
+
1236
+ const previousCarY =
1237
+ activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
1238
+ const deltaY = nextRide.carY - previousCarY
1239
+ nextRide.object.updateWorldMatrix(true, true)
1240
+ elevatorLocalControllerPosition.copy(group.position)
1241
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1242
+ const localControllerY =
1243
+ activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
1244
+ ? activeRide.localControllerY
1245
+ : elevatorLocalControllerPosition.y - nextRide.carY
1246
+
1247
+ if (Math.abs(deltaY) > 0.0001) {
1248
+ group.position.y += deltaY
1249
+ controllerRef.current?.resetLinVel()
1250
+ }
1251
+
1252
+ const shouldLockToCab =
1253
+ nextRide.phase === 'closing' ||
1254
+ nextRide.phase === 'moving' ||
1255
+ (nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
1256
+ if (shouldLockToCab) {
1257
+ elevatorLocalControllerPosition.copy(group.position)
1258
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1259
+ const desiredLocalY = nextRide.carY + localControllerY
1260
+ if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
1261
+ elevatorLocalControllerPosition.y = desiredLocalY
1262
+ elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1263
+ nextRide.object.localToWorld(elevatorWorldControllerPosition)
1264
+ group.position.y = elevatorWorldControllerPosition.y
1265
+ controllerRef.current?.resetLinVel()
1266
+ elevatorLocalControllerPosition.copy(group.position)
1267
+ nextRide.object.worldToLocal(elevatorLocalControllerPosition)
1268
+ }
1269
+
1270
+ const clampedX = Math.max(
1271
+ -nextRide.halfWidth,
1272
+ Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
1273
+ )
1274
+ const clampedZ = Math.max(
1275
+ nextRide.cabCenterZ - nextRide.halfDepth,
1276
+ Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
1277
+ )
1278
+
1279
+ if (
1280
+ Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
1281
+ Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
1282
+ ) {
1283
+ elevatorLocalControllerPosition.x = clampedX
1284
+ elevatorLocalControllerPosition.z = clampedZ
1285
+ elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
1286
+ nextRide.object.localToWorld(elevatorWorldControllerPosition)
1287
+ group.position.x = elevatorWorldControllerPosition.x
1288
+ group.position.z = elevatorWorldControllerPosition.z
1289
+ controllerRef.current?.resetLinVel()
1290
+ }
1291
+ }
1292
+
1293
+ setElevatorRideLocked(shouldLockToCab)
1294
+
1295
+ ridingElevatorRef.current = {
1296
+ elevatorId: nextRide.elevatorId,
1297
+ localControllerY,
1298
+ previousCarY: nextRide.carY,
1299
+ }
1300
+ },
1301
+ [camera, setElevatorRideLocked],
1302
+ )
1303
+
1304
+ useFrame(() => {
421
1305
  if (!controllerRef.current?.group) return
422
1306
 
423
1307
  const group = controllerRef.current.group
1308
+
1309
+ // The site ground collider is effectively unbounded, but scenes without a
1310
+ // site node only have finite fallback floors — if the controller still ends
1311
+ // up below every collider it can never land, so put it back at the spawn.
1312
+ // Prefer the live spawn node over the mount-time start position so a spawn
1313
+ // moved mid-walkthrough doesn't respawn the player at stale coordinates.
1314
+ const worldBounds = worldRef.current?.bounds
1315
+ if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
1316
+ const respawnPosition = placedSpawn
1317
+ ? [
1318
+ placedSpawn.position[0],
1319
+ placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
1320
+ placedSpawn.position[2],
1321
+ ]
1322
+ : controllerStart?.position
1323
+ if (respawnPosition) {
1324
+ group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
1325
+ controllerRef.current.resetLinVel()
1326
+ ridingElevatorRef.current = null
1327
+ setElevatorRideLocked(false)
1328
+ }
1329
+ }
1330
+
424
1331
  group.rotation.y = 0
425
1332
  camera.position.copy(group.position).add(cameraOffset)
426
1333
  cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
427
1334
  camera.quaternion.setFromEuler(cameraEuler)
428
1335
  camera.updateMatrixWorld(true)
1336
+ syncElevatorRide(group)
1337
+ camera.position.copy(group.position).add(cameraOffset)
1338
+ camera.updateMatrixWorld(true)
429
1339
 
430
1340
  const nextInteractableTarget = resolveInteractableTarget()
431
1341
  const previousInteractableTarget = interactableTargetRef.current
432
1342
  if (
433
- previousInteractableTarget?.id !== nextInteractableTarget?.id ||
434
- previousInteractableTarget?.type !== nextInteractableTarget?.type
1343
+ getInteractableTargetKey(previousInteractableTarget) !==
1344
+ getInteractableTargetKey(nextInteractableTarget)
435
1345
  ) {
436
1346
  interactableTargetRef.current = nextInteractableTarget
437
1347
  useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
438
1348
  }
439
- })
1349
+ }, 2.5)
440
1350
 
441
1351
  useEffect(() => {
442
1352
  return () => {
@@ -446,6 +1356,11 @@ export const FirstPersonControls = () => {
446
1356
  }
447
1357
  }, [])
448
1358
 
1359
+ const firstPersonColliderMeshes = useMemo(
1360
+ () => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
1361
+ [world, elevatorColliderMeshes],
1362
+ )
1363
+
449
1364
  if (!world) {
450
1365
  return null
451
1366
  }
@@ -458,7 +1373,7 @@ export const FirstPersonControls = () => {
458
1373
  acceleration={26}
459
1374
  airDragFactor={0.3}
460
1375
  colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
461
- colliderMeshes={[world.mesh]}
1376
+ colliderMeshes={firstPersonColliderMeshes}
462
1377
  collisionCheckIteration={3}
463
1378
  collisionPushBackDamping={0.1}
464
1379
  collisionPushBackThreshold={0.001}
@@ -473,13 +1388,14 @@ export const FirstPersonControls = () => {
473
1388
  floatSensorRadius={0.15}
474
1389
  floatSpringK={1200}
475
1390
  gravity={9.81}
476
- jumpVel={6}
1391
+ jumpVel={5}
477
1392
  key="first-person-controller"
478
- maxRunSpeed={5.5}
1393
+ maxRunSpeed={5}
479
1394
  maxSlope={1.2}
480
- maxWalkSpeed={4}
1395
+ maxWalkSpeed={2}
1396
+ paused={isElevatorRideLocked}
481
1397
  position={controllerStart.position}
482
- ref={controllerRef}
1398
+ ref={setControllerApi}
483
1399
  />
484
1400
  </KeyboardControls>
485
1401
  )}
@@ -519,7 +1435,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
519
1435
  return (
520
1436
  <>
521
1437
  {isLocked && (
522
- <div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
1438
+ <div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
523
1439
  <div className="relative h-7 w-7">
524
1440
  <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
1441
  <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 +1443,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
527
1443
  </div>
528
1444
  )}
529
1445
 
530
- <div className="fixed top-4 right-4 z-50">
1446
+ <div className="absolute top-4 right-4 z-50">
531
1447
  <button
532
1448
  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
1449
  onClick={handleExit}
@@ -541,7 +1457,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
541
1457
  </div>
542
1458
 
543
1459
  {!hasPlacedSpawn && (
544
- <div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
1460
+ <div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
545
1461
  <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
1462
  Place a Spawn Point from the Build tab to control where walkthrough starts.
547
1463
  </div>
@@ -549,7 +1465,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
549
1465
  )}
550
1466
 
551
1467
  {isLocked && (
552
- <div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
1468
+ <div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
553
1469
  <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
1470
  <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
555
1471
  <div className="h-px w-full bg-border/30" />