@pascal-app/editor 0.9.2 → 1.0.0-beta.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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -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,
@@ -45,19 +64,22 @@ import {
45
64
  } from 'three'
46
65
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
47
66
  import '../../three-types'
48
- import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
49
67
  import {
50
68
  closeDoorOpenState,
51
69
  DOOR_SWING_OPEN_ANGLE,
70
+ getDisplayedDoorValue,
52
71
  isOperationDoorType,
53
72
  toggleDoorOpenState,
54
73
  } from '../../lib/door-interaction'
55
74
  import {
56
75
  closeWindowOpenState,
76
+ getDisplayedWindowValue,
57
77
  isOperableWindowType,
58
78
  toggleWindowOpenState,
59
79
  } from '../../lib/window-interaction'
60
80
  import useEditor from '../../store/use-editor'
81
+ import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
82
+ import { WalkthroughHud } from '../walkthrough-hud'
61
83
  import {
62
84
  buildFirstPersonColliderWorldFromRegistry,
63
85
  deriveFirstPersonSpawn,
@@ -76,8 +98,8 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
76
98
  const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
77
99
  const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
78
100
  const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
79
- const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
80
101
  const VOID_FALL_RESPAWN_DEPTH = 12
102
+ const HUD_LABEL_SAMPLE_FRAMES = 10
81
103
 
82
104
  type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
83
105
 
@@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
120
142
  canvas.focus({ preventScroll: true })
121
143
  }
122
144
 
123
- const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
145
+ const cameraOffset = new Vector3()
124
146
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
147
+ const standClearanceRaycaster = new Raycaster()
148
+ const standClearanceUp = new Vector3(0, 1, 0)
125
149
  const centerScreenPoint = new Vector2(0, 0)
126
150
  const doorInteractionRaycaster = new Raycaster()
127
151
  const doorLeafBox = new Box3()
@@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
146
170
  const spawnWorldPosition = new Vector3()
147
171
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
148
172
  const windowInteractionRaycaster = new Raycaster()
173
+ const hudBuildingLocalEyePosition = new Vector3()
174
+ const hudWorldEyePosition = new Vector3()
175
+ const hudLevelBounds = new Box3()
149
176
 
150
177
  type ElevatorColliderKind =
151
178
  | 'cab-back'
@@ -202,6 +229,128 @@ type ElevatorButtonTarget = {
202
229
  levelId?: AnyNodeId
203
230
  }
204
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
+
205
354
  function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
206
355
  let current: Object3D | null = object
207
356
 
@@ -281,37 +430,17 @@ function isInsideElevatorCab(
281
430
  )
282
431
  }
283
432
 
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
433
  function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
308
434
  const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
435
+ const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
309
436
 
310
437
  const baseYByLevelId = new Map<string, number>()
311
438
  let cumulativeY = 0
312
439
  for (const level of allLevels) {
313
- baseYByLevelId.set(level.id, cumulativeY)
314
- 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))
315
444
  }
316
445
 
317
446
  const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
@@ -573,6 +702,11 @@ export const FirstPersonControls = () => {
573
702
  const yawRef = useRef(0)
574
703
  const pitchRef = useRef(0)
575
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)
576
710
  const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
577
711
  const ridingElevatorRef = useRef<{
578
712
  elevatorId: AnyNodeId
@@ -589,6 +723,35 @@ export const FirstPersonControls = () => {
589
723
  yaw: number
590
724
  } | null>(null)
591
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
+
592
755
  const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
593
756
  worldRef.current?.dispose()
594
757
  worldRef.current = nextWorld
@@ -965,6 +1128,20 @@ export const FirstPersonControls = () => {
965
1128
  return () => window.cancelAnimationFrame(frame)
966
1129
  }, [gl])
967
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
+
968
1145
  useEffect(() => {
969
1146
  const canvas = gl.domElement
970
1147
  const handleMouseMove = (e: MouseEvent) => {
@@ -992,16 +1169,22 @@ export const FirstPersonControls = () => {
992
1169
 
993
1170
  event.preventDefault()
994
1171
  event.stopPropagation()
995
- toggleInteractableTarget()
1172
+ toggleInteractableTargetRef.current()
996
1173
  }
997
1174
 
998
1175
  const handlePointerLockChange = () => {
999
1176
  const isLocked = document.pointerLockElement === canvas
1000
1177
  if (isLocked) {
1001
1178
  hadPointerLockRef.current = true
1179
+ suspendRef.current = false
1180
+ useViewer.getState().setWalkthroughSuspended(false)
1002
1181
  return
1003
1182
  }
1004
1183
 
1184
+ // Deliberately released (screenshot pause) — stay in first person;
1185
+ // clicking the canvas re-locks.
1186
+ if (suspendRef.current) return
1187
+
1005
1188
  if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1006
1189
  useEditor.getState().setFirstPersonMode(false)
1007
1190
  }
@@ -1018,11 +1201,12 @@ export const FirstPersonControls = () => {
1018
1201
  document.removeEventListener('click', handleClick)
1019
1202
  document.removeEventListener('mousedown', handleMouseDown, true)
1020
1203
  document.removeEventListener('pointerlockchange', handlePointerLockChange)
1204
+ useViewer.getState().setWalkthroughSuspended(false)
1021
1205
  if (document.pointerLockElement === canvas) {
1022
1206
  document.exitPointerLock()
1023
1207
  }
1024
1208
  }
1025
- }, [gl, toggleInteractableTarget])
1209
+ }, [gl])
1026
1210
 
1027
1211
  useEffect(() => {
1028
1212
  const canvas = gl.domElement
@@ -1049,7 +1233,11 @@ export const FirstPersonControls = () => {
1049
1233
  return
1050
1234
  }
1051
1235
 
1052
- 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') {
1053
1241
  event.preventDefault()
1054
1242
  event.stopPropagation()
1055
1243
  if (document.pointerLockElement === canvas) {
@@ -1064,18 +1252,41 @@ export const FirstPersonControls = () => {
1064
1252
  event.preventDefault()
1065
1253
  event.stopPropagation()
1066
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
+ }
1067
1269
  }
1068
1270
  }
1069
1271
 
1070
1272
  const handleKeyUp = (event: KeyboardEvent) => {
1273
+ if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
1274
+ crouchKeyRef.current = false
1275
+ }
1071
1276
  applyMovementKey(event, false)
1072
1277
  }
1073
1278
 
1279
+ const handleBlur = () => {
1280
+ if (!suspendRef.current) crouchKeyRef.current = false
1281
+ }
1282
+
1074
1283
  document.addEventListener('keydown', handleKeyDown, true)
1075
1284
  document.addEventListener('keyup', handleKeyUp, true)
1285
+ window.addEventListener('blur', handleBlur)
1076
1286
  return () => {
1077
1287
  document.removeEventListener('keydown', handleKeyDown, true)
1078
1288
  document.removeEventListener('keyup', handleKeyUp, true)
1289
+ window.removeEventListener('blur', handleBlur)
1079
1290
  }
1080
1291
  }, [closeInteractableTarget, gl, toggleInteractableTarget])
1081
1292
 
@@ -1301,11 +1512,33 @@ export const FirstPersonControls = () => {
1301
1512
  [camera, setElevatorRideLocked],
1302
1513
  )
1303
1514
 
1304
- 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) => {
1305
1527
  if (!controllerRef.current?.group) return
1306
1528
 
1307
1529
  const group = controllerRef.current.group
1308
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
+
1309
1542
  // The site ground collider is effectively unbounded, but scenes without a
1310
1543
  // site node only have finite fallback floors — if the controller still ends
1311
1544
  // up below every collider it can never land, so put it back at the spawn.
@@ -1346,6 +1579,17 @@ export const FirstPersonControls = () => {
1346
1579
  interactableTargetRef.current = nextInteractableTarget
1347
1580
  useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
1348
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
+ }
1349
1593
  }, 2.5)
1350
1594
 
1351
1595
  useEffect(() => {
@@ -1372,7 +1616,7 @@ export const FirstPersonControls = () => {
1372
1616
  <BVHEcctrl
1373
1617
  acceleration={26}
1374
1618
  airDragFactor={0.3}
1375
- colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
1619
+ colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
1376
1620
  colliderMeshes={firstPersonColliderMeshes}
1377
1621
  collisionCheckIteration={3}
1378
1622
  collisionPushBackDamping={0.1}
@@ -1383,16 +1627,16 @@ export const FirstPersonControls = () => {
1383
1627
  fallGravityFactor={4}
1384
1628
  floatCheckType="BOTH"
1385
1629
  floatDampingC={36}
1386
- floatHeight={0.5}
1630
+ floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
1387
1631
  floatPullBackHeight={0.35}
1388
1632
  floatSensorRadius={0.15}
1389
1633
  floatSpringK={1200}
1390
1634
  gravity={9.81}
1391
1635
  jumpVel={5}
1392
1636
  key="first-person-controller"
1393
- maxRunSpeed={5}
1637
+ maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
1394
1638
  maxSlope={1.2}
1395
- maxWalkSpeed={2}
1639
+ maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
1396
1640
  paused={isElevatorRideLocked}
1397
1641
  position={controllerStart.position}
1398
1642
  ref={setControllerApi}
@@ -1403,27 +1647,14 @@ export const FirstPersonControls = () => {
1403
1647
  )
1404
1648
  }
1405
1649
 
1406
- /**
1407
- * Overlay UI for first-person mode: crosshair, controls hint, exit button.
1408
- * Rendered as a regular DOM overlay (not inside the Canvas).
1409
- */
1410
1650
  export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1411
- const [isLocked, setIsLocked] = useState(false)
1412
1651
  const hasPlacedSpawn = useScene((state) =>
1413
1652
  Object.values(state.nodes).some((node) => node.type === 'spawn'),
1414
1653
  )
1415
-
1416
- useEffect(() => {
1417
- const handlePointerLockChange = () => {
1418
- setIsLocked(document.pointerLockElement != null)
1419
- }
1420
-
1421
- handlePointerLockChange()
1422
- document.addEventListener('pointerlockchange', handlePointerLockChange)
1423
- return () => {
1424
- document.removeEventListener('pointerlockchange', handlePointerLockChange)
1425
- }
1426
- }, [])
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)
1427
1658
 
1428
1659
  const handleExit = useCallback(() => {
1429
1660
  if (document.pointerLockElement) {
@@ -1433,86 +1664,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1433
1664
  }, [onExit])
1434
1665
 
1435
1666
  return (
1436
- <>
1437
- {isLocked && (
1438
- <div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
1439
- <div className="relative h-7 w-7">
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" />
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" />
1442
- </div>
1443
- </div>
1444
- )}
1445
-
1446
- <div className="absolute top-4 right-4 z-50">
1447
- <button
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"
1449
- onClick={handleExit}
1450
- type="button"
1451
- >
1452
- <kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
1453
- ESC
1454
- </kbd>
1455
- Exit Street View
1456
- </button>
1457
- </div>
1458
-
1667
+ <WalkthroughHud
1668
+ floorLabel={floorLabel}
1669
+ interact={interact}
1670
+ onExit={handleExit}
1671
+ suspended={suspended}
1672
+ zoneLabel={zoneLabel}
1673
+ >
1459
1674
  {!hasPlacedSpawn && (
1460
- <div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
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">
1462
- Place a Spawn Point from the Build tab to control where walkthrough starts.
1463
- </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.
1464
1677
  </div>
1465
1678
  )}
1466
-
1467
- {isLocked && (
1468
- <div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
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">
1470
- <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
1471
- <div className="h-px w-full bg-border/30" />
1472
- <InlineControlHint keyLabel="Space" label="Jump" />
1473
- <InlineControlHint keyLabel="Shift" label="Sprint" />
1474
- <InlineControlHint keyLabel="E / R" label="Interact" />
1475
- <InlineControlHint keyLabel="T" label="Close" />
1476
- <div className="h-px w-full bg-border/30" />
1477
- <span className="text-center text-muted-foreground/60 text-xs">
1478
- Click to look around
1479
- </span>
1480
- </div>
1481
- </div>
1482
- )}
1483
- </>
1484
- )
1485
- }
1486
-
1487
- function ControlHint({ label, keys }: { label: string; keys: string[] }) {
1488
- return (
1489
- <div className="flex flex-col items-center gap-1.5 text-center">
1490
- <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
1491
- {label}
1492
- </span>
1493
- <div className="flex flex-wrap items-center justify-center gap-1">
1494
- {keys.map((key) => (
1495
- <kbd
1496
- 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"
1497
- key={key}
1498
- >
1499
- {key}
1500
- </kbd>
1501
- ))}
1502
- </div>
1503
- </div>
1504
- )
1505
- }
1506
-
1507
- function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
1508
- return (
1509
- <div className="flex items-center justify-between gap-3">
1510
- <span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
1511
- {label}
1512
- </span>
1513
- <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">
1514
- {keyLabel}
1515
- </kbd>
1516
- </div>
1679
+ </WalkthroughHud>
1517
1680
  )
1518
1681
  }