@pascal-app/editor 0.9.1 → 1.0.0-beta.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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -15,8 +15,11 @@ import {
15
15
  getElevatorShaftDepth,
16
16
  getElevatorShaftWallThickness,
17
17
  getElevatorShaftWidth,
18
+ getLevelDisplayName,
19
+ getLevelElevations,
18
20
  getResolvedElevatorDoorStyle,
19
21
  openElevatorDoor,
22
+ pointInPolygon2D,
20
23
  requestElevatorLevel,
21
24
  resolveElevatorBuildingLevels,
22
25
  resolveElevatorDispatchTarget,
@@ -25,7 +28,22 @@ import {
25
28
  useInteractive,
26
29
  useScene,
27
30
  } from '@pascal-app/core'
28
- import { useViewer } from '@pascal-app/viewer'
31
+ import {
32
+ BVHEcctrl,
33
+ type BVHEcctrlApi,
34
+ CROUCH_CAPSULE,
35
+ CROUCH_EYE_OFFSET,
36
+ CROUCH_FLOAT_HEIGHT,
37
+ CROUCH_RUN_SPEED,
38
+ CROUCH_WALK_SPEED,
39
+ EYE_LERP_SPEED,
40
+ type MovementInput,
41
+ STAND_CAPSULE,
42
+ STAND_CLEARANCE,
43
+ STAND_FLOAT_HEIGHT,
44
+ useViewer,
45
+ WALKTHROUGH_FOV,
46
+ } from '@pascal-app/viewer'
29
47
  import { KeyboardControls } from '@react-three/drei'
30
48
  import { useFrame, useThree } from '@react-three/fiber'
31
49
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -38,6 +56,7 @@ import {
38
56
  Mesh,
39
57
  MeshBasicMaterial,
40
58
  type Object3D,
59
+ type PerspectiveCamera,
41
60
  Ray,
42
61
  Raycaster,
43
62
  Vector2,
@@ -48,15 +67,19 @@ import '../../three-types'
48
67
  import {
49
68
  closeDoorOpenState,
50
69
  DOOR_SWING_OPEN_ANGLE,
70
+ getDisplayedDoorValue,
51
71
  isOperationDoorType,
52
72
  toggleDoorOpenState,
53
73
  } from '../../lib/door-interaction'
54
74
  import {
55
75
  closeWindowOpenState,
76
+ getDisplayedWindowValue,
56
77
  isOperableWindowType,
57
78
  toggleWindowOpenState,
58
79
  } from '../../lib/window-interaction'
59
80
  import useEditor from '../../store/use-editor'
81
+ import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
82
+ import { WalkthroughHud } from '../walkthrough-hud'
60
83
  import {
61
84
  buildFirstPersonColliderWorldFromRegistry,
62
85
  deriveFirstPersonSpawn,
@@ -64,8 +87,6 @@ import {
64
87
  type FirstPersonColliderWorld,
65
88
  type FirstPersonSpawn,
66
89
  } from './first-person/build-collider-world'
67
- import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
68
- import BVHEcctrl from './first-person/bvh-ecctrl'
69
90
 
70
91
  const CAMERA_EYE_OFFSET = 0.45
71
92
  const LOOK_SENSITIVITY = 0.002
@@ -77,8 +98,12 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
77
98
  const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
78
99
  const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
79
100
  const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
80
- const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
81
- const keyboardMap = [
101
+ const VOID_FALL_RESPAWN_DEPTH = 12
102
+ const HUD_LABEL_SAMPLE_FRAMES = 10
103
+
104
+ type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
105
+
106
+ const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
82
107
  { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
83
108
  { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
84
109
  { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
@@ -86,9 +111,41 @@ const keyboardMap = [
86
111
  { name: 'jump', keys: ['Space'] },
87
112
  { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
88
113
  ]
114
+ const keyboardMap = movementKeyboardBindings
115
+ const movementKeyToName = new Map<string, MovementKeyName>(
116
+ movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
117
+ )
118
+
119
+ const inactiveMovementInput: MovementInput = {
120
+ backward: false,
121
+ forward: false,
122
+ jump: false,
123
+ leftward: false,
124
+ rightward: false,
125
+ run: false,
126
+ }
127
+
128
+ function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
129
+ const name = movementKeyToName.get(code)
130
+ return name ? ({ [name]: active } as MovementInput) : null
131
+ }
132
+
133
+ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
134
+ const activeElement = document.activeElement
135
+ if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
136
+ activeElement.blur()
137
+ }
138
+
139
+ if (!canvas.hasAttribute('tabindex')) {
140
+ canvas.tabIndex = -1
141
+ }
142
+ canvas.focus({ preventScroll: true })
143
+ }
89
144
 
90
- const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
145
+ const cameraOffset = new Vector3()
91
146
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
147
+ const standClearanceRaycaster = new Raycaster()
148
+ const standClearanceUp = new Vector3(0, 1, 0)
92
149
  const centerScreenPoint = new Vector2(0, 0)
93
150
  const doorInteractionRaycaster = new Raycaster()
94
151
  const doorLeafBox = new Box3()
@@ -113,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
113
170
  const spawnWorldPosition = new Vector3()
114
171
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
115
172
  const windowInteractionRaycaster = new Raycaster()
173
+ const hudBuildingLocalEyePosition = new Vector3()
174
+ const hudWorldEyePosition = new Vector3()
175
+ const hudLevelBounds = new Box3()
116
176
 
117
177
  type ElevatorColliderKind =
118
178
  | 'cab-back'
@@ -169,6 +229,128 @@ type ElevatorButtonTarget = {
169
229
  levelId?: AnyNodeId
170
230
  }
171
231
 
232
+ function getLevelChildren(
233
+ level: Extract<AnyNode, { type: 'level' }>,
234
+ nodes: Record<string, AnyNode>,
235
+ ) {
236
+ const childIds = new Set<string>(level.children)
237
+ return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
238
+ }
239
+
240
+ function pointIsInLevelFootprint(
241
+ point: [number, number],
242
+ worldPoint: Vector3,
243
+ level: Extract<AnyNode, { type: 'level' }>,
244
+ nodes: Record<string, AnyNode>,
245
+ ) {
246
+ const children = getLevelChildren(level, nodes)
247
+ const slabs = children.filter(
248
+ (node): node is Extract<AnyNode, { type: 'slab' }> =>
249
+ node.type === 'slab' && node.polygon.length >= 3,
250
+ )
251
+ const zones = children.filter(
252
+ (node): node is Extract<AnyNode, { type: 'zone' }> =>
253
+ node.type === 'zone' && node.polygon.length >= 3,
254
+ )
255
+
256
+ if (slabs.length > 0) {
257
+ return slabs.some(
258
+ (slab) =>
259
+ pointInPolygon2D(point, slab.polygon) &&
260
+ !slab.holes.some((hole) => pointInPolygon2D(point, hole)),
261
+ )
262
+ }
263
+
264
+ if (zones.length > 0) {
265
+ if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
266
+ }
267
+
268
+ const levelObject = sceneRegistry.nodes.get(level.id)
269
+ if (!levelObject) return false
270
+ hudLevelBounds.setFromObject(levelObject)
271
+ return (
272
+ !hudLevelBounds.isEmpty() &&
273
+ worldPoint.x >= hudLevelBounds.min.x &&
274
+ worldPoint.x <= hudLevelBounds.max.x &&
275
+ worldPoint.z >= hudLevelBounds.min.z &&
276
+ worldPoint.z <= hudLevelBounds.max.z
277
+ )
278
+ }
279
+
280
+ function resolveFirstPersonHudLabels(worldPoint: Vector3) {
281
+ const nodes = useScene.getState().nodes
282
+ const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
283
+
284
+ for (const building of Object.values(nodes)) {
285
+ if (building.type !== 'building') continue
286
+ const buildingObject = sceneRegistry.nodes.get(building.id)
287
+ if (!buildingObject) continue
288
+
289
+ buildingObject.updateWorldMatrix(true, true)
290
+ hudBuildingLocalEyePosition.copy(worldPoint)
291
+ buildingObject.worldToLocal(hudBuildingLocalEyePosition)
292
+
293
+ const levels = Object.values(nodes)
294
+ .filter((node) => node.type === 'level')
295
+ .filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
296
+ .sort(
297
+ (left, right) =>
298
+ (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
299
+ )
300
+
301
+ let activeLevel: (typeof levels)[number] | null = null
302
+ for (const level of levels) {
303
+ const elevation = levelElevations.get(level.id)
304
+ if (!elevation) continue
305
+ if (
306
+ hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
307
+ hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
308
+ ) {
309
+ activeLevel = level
310
+ }
311
+ }
312
+ if (!activeLevel) continue
313
+
314
+ const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
315
+ if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
316
+
317
+ const zone = getLevelChildren(activeLevel, nodes).find(
318
+ (node) =>
319
+ node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
320
+ )
321
+
322
+ return {
323
+ floorLabel: getLevelDisplayName(activeLevel),
324
+ zoneLabel: zone?.type === 'zone' ? zone.name : null,
325
+ }
326
+ }
327
+
328
+ return { floorLabel: null, zoneLabel: null }
329
+ }
330
+
331
+ function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
332
+ if (!target) return null
333
+ if (target.type === 'elevator') {
334
+ return {
335
+ label: target.action === 'open-door' ? 'door button' : 'elevator button',
336
+ verb: 'press',
337
+ }
338
+ }
339
+
340
+ const node = useScene.getState().nodes[target.id]
341
+ if (target.type === 'window') {
342
+ if (node?.type !== 'window') return null
343
+ const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
344
+ return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
345
+ }
346
+
347
+ if (node?.type !== 'door') return null
348
+ const isOpen = isOperationDoorType(node.doorType)
349
+ ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
350
+ : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
351
+ return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
352
+ }
353
+
172
354
  function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
173
355
  let current: Object3D | null = object
174
356
 
@@ -248,37 +430,17 @@ function isInsideElevatorCab(
248
430
  )
249
431
  }
250
432
 
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
433
  function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
275
434
  const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
435
+ const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
276
436
 
277
437
  const baseYByLevelId = new Map<string, number>()
278
438
  let cumulativeY = 0
279
439
  for (const level of allLevels) {
280
- baseYByLevelId.set(level.id, cumulativeY)
281
- cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
440
+ const elevation = levelElevations.get(level.id)
441
+ const baseY = elevation?.baseY ?? 0
442
+ baseYByLevelId.set(level.id, baseY)
443
+ cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
282
444
  }
283
445
 
284
446
  const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
@@ -535,9 +697,16 @@ export const FirstPersonControls = () => {
535
697
  const selectedLevelId = useViewer((state) => state.selection.levelId)
536
698
  const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
537
699
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
700
+ const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
701
+ const hadPointerLockRef = useRef(false)
538
702
  const yawRef = useRef(0)
539
703
  const pitchRef = useRef(0)
540
704
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
705
+ const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
706
+ const crouchKeyRef = useRef(false)
707
+ const suspendRef = useRef(false)
708
+ const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
709
+ const [crouched, setCrouched] = useState(false)
541
710
  const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
542
711
  const ridingElevatorRef = useRef<{
543
712
  elevatorId: AnyNodeId
@@ -554,6 +723,35 @@ export const FirstPersonControls = () => {
554
723
  yaw: number
555
724
  } | null>(null)
556
725
 
726
+ useEffect(() => {
727
+ const previousCameraMode = useViewer.getState().cameraMode
728
+ if (previousCameraMode === 'orthographic') {
729
+ useViewer.getState().setCameraMode('perspective')
730
+ }
731
+ return () => {
732
+ if (previousCameraMode === 'orthographic') {
733
+ useViewer.getState().setCameraMode('orthographic')
734
+ }
735
+ }
736
+ }, [])
737
+
738
+ useEffect(() => {
739
+ const perspectiveCamera = camera as PerspectiveCamera
740
+ if (!perspectiveCamera.isPerspectiveCamera) return
741
+ const previousFov = perspectiveCamera.fov
742
+ perspectiveCamera.fov = WALKTHROUGH_FOV
743
+ perspectiveCamera.updateProjectionMatrix()
744
+ return () => {
745
+ perspectiveCamera.fov = previousFov
746
+ perspectiveCamera.updateProjectionMatrix()
747
+ }
748
+ }, [camera])
749
+
750
+ useEffect(() => {
751
+ useFirstPersonHud.getState().reset()
752
+ return () => useFirstPersonHud.getState().reset()
753
+ }, [])
754
+
557
755
  const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
558
756
  worldRef.current?.dispose()
559
757
  worldRef.current = nextWorld
@@ -577,6 +775,13 @@ export const FirstPersonControls = () => {
577
775
  setIsElevatorRideLocked(locked)
578
776
  }, [])
579
777
 
778
+ const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
779
+ controllerRef.current = api
780
+ if (api) {
781
+ api.setMovement(movementInputRef.current)
782
+ }
783
+ }, [])
784
+
580
785
  const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
581
786
  const nodes = useScene.getState().nodes
582
787
  camera.updateMatrixWorld(true)
@@ -915,6 +1120,28 @@ export const FirstPersonControls = () => {
915
1120
  })
916
1121
  }, [camera, controllerStart, placedSpawn, world])
917
1122
 
1123
+ useEffect(() => {
1124
+ const canvas = gl.domElement
1125
+ focusFirstPersonCanvas(canvas)
1126
+
1127
+ const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
1128
+ return () => window.cancelAnimationFrame(frame)
1129
+ }, [gl])
1130
+
1131
+ // The pointer-lock effect below must be mount-stable. Its cleanup exits
1132
+ // pointer lock, and `toggleInteractableTarget` is recreated whenever the
1133
+ // camera object changes — which happens right after entry when the
1134
+ // persisted orthographic mode swaps to perspective. If the async lock
1135
+ // grant lands before that re-run, the cleanup's exitPointerLock fires an
1136
+ // unlock the handler reads as "user left walkthrough", instantly
1137
+ // cancelling a fresh entry (and arming the browser's ~1.25s re-lock
1138
+ // cooldown, so the next presses fail too). Route the callback through a
1139
+ // ref so the effect deps stay `[gl]`.
1140
+ const toggleInteractableTargetRef = useRef(toggleInteractableTarget)
1141
+ useEffect(() => {
1142
+ toggleInteractableTargetRef.current = toggleInteractableTarget
1143
+ }, [toggleInteractableTarget])
1144
+
918
1145
  useEffect(() => {
919
1146
  const canvas = gl.domElement
920
1147
  const handleMouseMove = (e: MouseEvent) => {
@@ -942,32 +1169,75 @@ export const FirstPersonControls = () => {
942
1169
 
943
1170
  event.preventDefault()
944
1171
  event.stopPropagation()
945
- toggleInteractableTarget()
1172
+ toggleInteractableTargetRef.current()
1173
+ }
1174
+
1175
+ const handlePointerLockChange = () => {
1176
+ const isLocked = document.pointerLockElement === canvas
1177
+ if (isLocked) {
1178
+ hadPointerLockRef.current = true
1179
+ suspendRef.current = false
1180
+ useViewer.getState().setWalkthroughSuspended(false)
1181
+ return
1182
+ }
1183
+
1184
+ // Deliberately released (screenshot pause) — stay in first person;
1185
+ // clicking the canvas re-locks.
1186
+ if (suspendRef.current) return
1187
+
1188
+ if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1189
+ useEditor.getState().setFirstPersonMode(false)
1190
+ }
946
1191
  }
947
1192
 
1193
+ handlePointerLockChange()
948
1194
  document.addEventListener('mousemove', handleMouseMove)
949
1195
  document.addEventListener('click', handleClick)
950
1196
  document.addEventListener('mousedown', handleMouseDown, true)
1197
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
951
1198
 
952
1199
  return () => {
953
1200
  document.removeEventListener('mousemove', handleMouseMove)
954
1201
  document.removeEventListener('click', handleClick)
955
1202
  document.removeEventListener('mousedown', handleMouseDown, true)
1203
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
1204
+ useViewer.getState().setWalkthroughSuspended(false)
956
1205
  if (document.pointerLockElement === canvas) {
957
1206
  document.exitPointerLock()
958
1207
  }
959
1208
  }
960
- }, [gl, toggleInteractableTarget])
1209
+ }, [gl])
961
1210
 
962
1211
  useEffect(() => {
963
1212
  const canvas = gl.domElement
964
1213
 
1214
+ const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
1215
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1216
+ return false
1217
+ }
1218
+
1219
+ const movement = getMovementInputForKey(event.code, active)
1220
+ if (!movement) return false
1221
+
1222
+ event.preventDefault()
1223
+ Object.assign(movementInputRef.current, movement)
1224
+ controllerRef.current?.setMovement(movement)
1225
+ return true
1226
+ }
1227
+
965
1228
  const handleKeyDown = (event: KeyboardEvent) => {
1229
+ const handledMovement = applyMovementKey(event, true)
1230
+ if (handledMovement) return
1231
+
966
1232
  if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
967
1233
  return
968
1234
  }
969
1235
 
970
- if (event.code === 'Escape') {
1236
+ if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
1237
+ // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
1238
+ // screenshot) must not toggle it under the user.
1239
+ if (!suspendRef.current) crouchKeyRef.current = true
1240
+ } else if (event.code === 'Escape') {
971
1241
  event.preventDefault()
972
1242
  event.stopPropagation()
973
1243
  if (document.pointerLockElement === canvas) {
@@ -982,12 +1252,41 @@ export const FirstPersonControls = () => {
982
1252
  event.preventDefault()
983
1253
  event.stopPropagation()
984
1254
  closeInteractableTarget()
1255
+ } else if (event.code === 'KeyP') {
1256
+ // P toggles a cursor pause (advertised in the HUD): frees the pointer
1257
+ // without leaving first person — e.g. for an OS screenshot, which
1258
+ // needs a movable cursor — and click or P resumes.
1259
+ event.preventDefault()
1260
+ event.stopPropagation()
1261
+ if (document.pointerLockElement === canvas) {
1262
+ suspendRef.current = true
1263
+ useViewer.getState().setWalkthroughSuspended(true)
1264
+ document.exitPointerLock()
1265
+ } else if (suspendRef.current) {
1266
+ const result = canvas.requestPointerLock?.() as Promise<void> | undefined
1267
+ if (result && typeof result.catch === 'function') result.catch(() => {})
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ const handleKeyUp = (event: KeyboardEvent) => {
1273
+ if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
1274
+ crouchKeyRef.current = false
985
1275
  }
1276
+ applyMovementKey(event, false)
1277
+ }
1278
+
1279
+ const handleBlur = () => {
1280
+ if (!suspendRef.current) crouchKeyRef.current = false
986
1281
  }
987
1282
 
988
1283
  document.addEventListener('keydown', handleKeyDown, true)
1284
+ document.addEventListener('keyup', handleKeyUp, true)
1285
+ window.addEventListener('blur', handleBlur)
989
1286
  return () => {
990
1287
  document.removeEventListener('keydown', handleKeyDown, true)
1288
+ document.removeEventListener('keyup', handleKeyUp, true)
1289
+ window.removeEventListener('blur', handleBlur)
991
1290
  }
992
1291
  }, [closeInteractableTarget, gl, toggleInteractableTarget])
993
1292
 
@@ -1213,10 +1512,55 @@ export const FirstPersonControls = () => {
1213
1512
  [camera, setElevatorRideLocked],
1214
1513
  )
1215
1514
 
1216
- useFrame(() => {
1515
+ const hasStandingClearance = useCallback((position: Vector3) => {
1516
+ standClearanceRaycaster.set(position, standClearanceUp)
1517
+ standClearanceRaycaster.far = STAND_CLEARANCE
1518
+ const meshes: Mesh[] = []
1519
+ if (worldRef.current) meshes.push(worldRef.current.mesh)
1520
+ for (const mesh of elevatorColliderMeshesRef.current) {
1521
+ if (mesh.visible) meshes.push(mesh)
1522
+ }
1523
+ return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1524
+ }, [])
1525
+
1526
+ useFrame((_, delta) => {
1217
1527
  if (!controllerRef.current?.group) return
1218
1528
 
1219
1529
  const group = controllerRef.current.group
1530
+
1531
+ // Crouch follows the held key; standing back up waits for headroom.
1532
+ // Frozen while the cursor pause is active.
1533
+ if (!suspendRef.current && crouchKeyRef.current !== crouched) {
1534
+ if (crouchKeyRef.current) setCrouched(true)
1535
+ else if (hasStandingClearance(group.position)) setCrouched(false)
1536
+ }
1537
+ const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
1538
+ eyeOffsetRef.current +=
1539
+ (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
1540
+ cameraOffset.set(0, eyeOffsetRef.current, 0)
1541
+
1542
+ // The site ground collider is effectively unbounded, but scenes without a
1543
+ // site node only have finite fallback floors — if the controller still ends
1544
+ // up below every collider it can never land, so put it back at the spawn.
1545
+ // Prefer the live spawn node over the mount-time start position so a spawn
1546
+ // moved mid-walkthrough doesn't respawn the player at stale coordinates.
1547
+ const worldBounds = worldRef.current?.bounds
1548
+ if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
1549
+ const respawnPosition = placedSpawn
1550
+ ? [
1551
+ placedSpawn.position[0],
1552
+ placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
1553
+ placedSpawn.position[2],
1554
+ ]
1555
+ : controllerStart?.position
1556
+ if (respawnPosition) {
1557
+ group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
1558
+ controllerRef.current.resetLinVel()
1559
+ ridingElevatorRef.current = null
1560
+ setElevatorRideLocked(false)
1561
+ }
1562
+ }
1563
+
1220
1564
  group.rotation.y = 0
1221
1565
  camera.position.copy(group.position).add(cameraOffset)
1222
1566
  cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
@@ -1235,6 +1579,17 @@ export const FirstPersonControls = () => {
1235
1579
  interactableTargetRef.current = nextInteractableTarget
1236
1580
  useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
1237
1581
  }
1582
+
1583
+ useFirstPersonHud.getState().setHud({
1584
+ interact: resolveHudInteract(nextInteractableTarget),
1585
+ })
1586
+
1587
+ hudLabelFrameRef.current += 1
1588
+ if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
1589
+ hudLabelFrameRef.current = 0
1590
+ camera.getWorldPosition(hudWorldEyePosition)
1591
+ useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
1592
+ }
1238
1593
  }, 2.5)
1239
1594
 
1240
1595
  useEffect(() => {
@@ -1261,7 +1616,7 @@ export const FirstPersonControls = () => {
1261
1616
  <BVHEcctrl
1262
1617
  acceleration={26}
1263
1618
  airDragFactor={0.3}
1264
- colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
1619
+ colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
1265
1620
  colliderMeshes={firstPersonColliderMeshes}
1266
1621
  collisionCheckIteration={3}
1267
1622
  collisionPushBackDamping={0.1}
@@ -1272,19 +1627,19 @@ export const FirstPersonControls = () => {
1272
1627
  fallGravityFactor={4}
1273
1628
  floatCheckType="BOTH"
1274
1629
  floatDampingC={36}
1275
- floatHeight={0.5}
1630
+ floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
1276
1631
  floatPullBackHeight={0.35}
1277
1632
  floatSensorRadius={0.15}
1278
1633
  floatSpringK={1200}
1279
1634
  gravity={9.81}
1280
- jumpVel={6}
1635
+ jumpVel={5}
1281
1636
  key="first-person-controller"
1282
- maxRunSpeed={5.5}
1637
+ maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
1283
1638
  maxSlope={1.2}
1284
- maxWalkSpeed={4}
1639
+ maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
1285
1640
  paused={isElevatorRideLocked}
1286
1641
  position={controllerStart.position}
1287
- ref={controllerRef}
1642
+ ref={setControllerApi}
1288
1643
  />
1289
1644
  </KeyboardControls>
1290
1645
  )}
@@ -1292,27 +1647,14 @@ export const FirstPersonControls = () => {
1292
1647
  )
1293
1648
  }
1294
1649
 
1295
- /**
1296
- * Overlay UI for first-person mode: crosshair, controls hint, exit button.
1297
- * Rendered as a regular DOM overlay (not inside the Canvas).
1298
- */
1299
1650
  export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1300
- const [isLocked, setIsLocked] = useState(false)
1301
1651
  const hasPlacedSpawn = useScene((state) =>
1302
1652
  Object.values(state.nodes).some((node) => node.type === 'spawn'),
1303
1653
  )
1304
-
1305
- useEffect(() => {
1306
- const handlePointerLockChange = () => {
1307
- setIsLocked(document.pointerLockElement != null)
1308
- }
1309
-
1310
- handlePointerLockChange()
1311
- document.addEventListener('pointerlockchange', handlePointerLockChange)
1312
- return () => {
1313
- document.removeEventListener('pointerlockchange', handlePointerLockChange)
1314
- }
1315
- }, [])
1654
+ const floorLabel = useFirstPersonHud((state) => state.floorLabel)
1655
+ const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
1656
+ const interact = useFirstPersonHud((state) => state.interact)
1657
+ const suspended = useViewer((state) => state.walkthroughSuspended)
1316
1658
 
1317
1659
  const handleExit = useCallback(() => {
1318
1660
  if (document.pointerLockElement) {
@@ -1322,86 +1664,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1322
1664
  }, [onExit])
1323
1665
 
1324
1666
  return (
1325
- <>
1326
- {isLocked && (
1327
- <div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
1328
- <div className="relative h-7 w-7">
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" />
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" />
1331
- </div>
1332
- </div>
1333
- )}
1334
-
1335
- <div className="absolute top-4 right-4 z-50">
1336
- <button
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"
1338
- onClick={handleExit}
1339
- type="button"
1340
- >
1341
- <kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
1342
- ESC
1343
- </kbd>
1344
- Exit Street View
1345
- </button>
1346
- </div>
1347
-
1667
+ <WalkthroughHud
1668
+ floorLabel={floorLabel}
1669
+ interact={interact}
1670
+ onExit={handleExit}
1671
+ suspended={suspended}
1672
+ zoneLabel={zoneLabel}
1673
+ >
1348
1674
  {!hasPlacedSpawn && (
1349
- <div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
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">
1351
- Place a Spawn Point from the Build tab to control where walkthrough starts.
1352
- </div>
1353
- </div>
1354
- )}
1355
-
1356
- {isLocked && (
1357
- <div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
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">
1359
- <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
1360
- <div className="h-px w-full bg-border/30" />
1361
- <InlineControlHint keyLabel="Space" label="Jump" />
1362
- <InlineControlHint keyLabel="Shift" label="Sprint" />
1363
- <InlineControlHint keyLabel="E / R" label="Interact" />
1364
- <InlineControlHint keyLabel="T" label="Close" />
1365
- <div className="h-px w-full bg-border/30" />
1366
- <span className="text-center text-muted-foreground/60 text-xs">
1367
- Click to look around
1368
- </span>
1369
- </div>
1675
+ <div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 text-center text-muted-foreground text-xs shadow-elevation-3 backdrop-blur-xl">
1676
+ Place a spawn point from the Build tab to control where walkthrough starts.
1370
1677
  </div>
1371
1678
  )}
1372
- </>
1373
- )
1374
- }
1375
-
1376
- function ControlHint({ label, keys }: { label: string; keys: string[] }) {
1377
- return (
1378
- <div className="flex flex-col items-center gap-1.5 text-center">
1379
- <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
1380
- {label}
1381
- </span>
1382
- <div className="flex flex-wrap items-center justify-center gap-1">
1383
- {keys.map((key) => (
1384
- <kbd
1385
- className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
1386
- key={key}
1387
- >
1388
- {key}
1389
- </kbd>
1390
- ))}
1391
- </div>
1392
- </div>
1393
- )
1394
- }
1395
-
1396
- function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
1397
- return (
1398
- <div className="flex items-center justify-between gap-3">
1399
- <span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
1400
- {label}
1401
- </span>
1402
- <kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
1403
- {keyLabel}
1404
- </kbd>
1405
- </div>
1679
+ </WalkthroughHud>
1406
1680
  )
1407
1681
  }