@pascal-app/editor 0.6.0 → 0.7.0

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 (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -1,178 +1,490 @@
1
1
  'use client'
2
2
 
3
+ import '../../three-types'
4
+ import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
5
+ import { useViewer } from '@pascal-app/viewer'
6
+ import { KeyboardControls } from '@react-three/drei'
3
7
  import { useFrame, useThree } from '@react-three/fiber'
4
- import { useCallback, useEffect, useRef } from 'react'
5
- import { Euler, Vector3 } from 'three'
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
+ import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
10
+ import {
11
+ closeDoorOpenState,
12
+ DOOR_SWING_OPEN_ANGLE,
13
+ isOperationDoorType,
14
+ toggleDoorOpenState,
15
+ } from '../../lib/door-interaction'
16
+ import {
17
+ closeWindowOpenState,
18
+ isOperableWindowType,
19
+ toggleWindowOpenState,
20
+ } from '../../lib/window-interaction'
6
21
  import useEditor from '../../store/use-editor'
22
+ import {
23
+ buildFirstPersonColliderWorldFromRegistry,
24
+ deriveFirstPersonSpawn,
25
+ FIRST_PERSON_SPAWN_EYE_HEIGHT,
26
+ type FirstPersonColliderWorld,
27
+ type FirstPersonSpawn,
28
+ } from './first-person/build-collider-world'
29
+ import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
30
+ import BVHEcctrl from './first-person/bvh-ecctrl'
31
+
32
+ const CAMERA_EYE_OFFSET = 0.45
33
+ const LOOK_SENSITIVITY = 0.002
34
+ const CONTROLLER_CENTER_FROM_EYE = 0.85
35
+ const DOOR_INTERACTION_DISTANCE = 2.5
36
+ const DOOR_LEAF_INTERACTION_DEPTH = 0.08
37
+ const keyboardMap = [
38
+ { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
39
+ { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
40
+ { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
41
+ { name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
42
+ { name: 'jump', keys: ['Space'] },
43
+ { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
44
+ ]
45
+
46
+ const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
47
+ const cameraEuler = new Euler(0, 0, 0, 'YXZ')
48
+ const centerScreenPoint = new Vector2(0, 0)
49
+ const doorInteractionRaycaster = new Raycaster()
50
+ const doorLeafBox = new Box3()
51
+ const doorLeafInverseMatrix = new Matrix4()
52
+ const doorLeafLocalHit = new Vector3()
53
+ const doorLeafLocalRay = new Ray()
54
+ const doorLeafMatrix = new Matrix4()
55
+ const doorLeafWorldHit = new Vector3()
56
+ const doorOpeningBox = new Box3()
57
+ const doorOpeningInverseMatrix = new Matrix4()
58
+ const doorOpeningLocalHit = new Vector3()
59
+ const doorOpeningLocalRay = new Ray()
60
+ const doorOpeningMatrix = new Matrix4()
61
+ const doorOpeningWorldHit = new Vector3()
62
+ const spawnWorldPosition = new Vector3()
63
+ const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
64
+ const windowInteractionRaycaster = new Raycaster()
65
+
66
+ type FirstPersonInteractableTarget = {
67
+ id: AnyNodeId
68
+ type: 'door' | 'window'
69
+ }
70
+
71
+ const resolvePlacedSpawnNode = (
72
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
73
+ _levelId: string | null,
74
+ ) => {
75
+ const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
76
+ if (candidates.length === 0) return null
7
77
 
8
- // Average human eye height in meters
9
- const EYE_HEIGHT = 1.65
10
- // Movement speed in meters per second
11
- const MOVE_SPEED = 5
12
- // Sprint multiplier when holding Shift
13
- const SPRINT_MULTIPLIER = 2
14
- // Vertical float speed in meters per second
15
- const VERTICAL_SPEED = 3
16
- // Mouse look sensitivity
17
- const MOUSE_SENSITIVITY = 0.002
18
- // Min Y position (eye height above ground)
19
- const MIN_Y = EYE_HEIGHT
20
-
21
- // Reusable vectors to avoid allocations in the render loop
22
- const _forward = new Vector3()
23
- const _right = new Vector3()
24
- const _moveVector = new Vector3()
25
- const _euler = new Euler(0, 0, 0, 'YXZ')
78
+ return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
79
+ }
26
80
 
27
81
  export const FirstPersonControls = () => {
28
82
  const { camera, gl } = useThree()
29
- const keysRef = useRef<Set<string>>(new Set())
83
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
84
+ const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
85
+ const controllerRef = useRef<BVHEcctrlApi | null>(null)
30
86
  const yawRef = useRef(0)
31
87
  const pitchRef = useRef(0)
32
- const isLockedRef = useRef(false)
33
- const initializedRef = useRef(false)
88
+ const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
89
+ const worldRef = useRef<FirstPersonColliderWorld | null>(null)
90
+ const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
91
+ const [controllerStart, setControllerStart] = useState<{
92
+ position: [number, number, number]
93
+ yaw: number
94
+ } | null>(null)
95
+
96
+ const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
97
+ worldRef.current?.dispose()
98
+ worldRef.current = nextWorld
99
+ setWorld(nextWorld)
100
+ }, [])
101
+
102
+ const rebuildColliderWorld = useCallback(() => {
103
+ replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
104
+ }, [replaceColliderWorld])
105
+
106
+ const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
107
+ const nodes = useScene.getState().nodes
108
+ camera.updateMatrixWorld(true)
109
+ doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
110
+
111
+ let closestDoorId: AnyNodeId | null = null
112
+ let closestDistance = DOOR_INTERACTION_DISTANCE
113
+
114
+ for (const doorId of sceneRegistry.byType.door) {
115
+ const node = nodes[doorId as AnyNodeId]
116
+ if (node?.type !== 'door') continue
117
+ if (node.openingKind === 'opening') continue
118
+ if (node.segments.every((segment) => segment.type === 'empty')) continue
119
+
120
+ const object = sceneRegistry.nodes.get(doorId)
121
+ if (!object) continue
122
+
123
+ object.updateWorldMatrix(true, true)
124
+
125
+ const placementHit = doorInteractionRaycaster
126
+ .intersectObject(object, true)
127
+ .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
128
+ if (placementHit && placementHit.distance < closestDistance) {
129
+ closestDoorId = doorId as AnyNodeId
130
+ closestDistance = placementHit.distance
131
+ }
34
132
 
35
- // Initialize camera for first-person view: start at center of scene, on the ground
36
- useEffect(() => {
37
- if (initializedRef.current) return
38
- initializedRef.current = true
133
+ const leafW = node.width - 2 * node.frameThickness
134
+ const leafH = node.height - node.frameThickness
135
+ if (leafW <= 0 || leafH <= 0) continue
136
+
137
+ const leafCenterY = -node.frameThickness / 2
138
+
139
+ if (isOperationDoorType(node.doorType)) {
140
+ doorOpeningMatrix
141
+ .copy(object.matrixWorld)
142
+ .multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
143
+ doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
144
+ doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
145
+ doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
146
+ doorOpeningLocalRay
147
+ .copy(doorInteractionRaycaster.ray)
148
+ .applyMatrix4(doorOpeningInverseMatrix)
149
+
150
+ const localOpeningHit = doorOpeningLocalRay.intersectBox(
151
+ doorOpeningBox,
152
+ doorOpeningLocalHit,
153
+ )
154
+ if (!localOpeningHit) continue
155
+
156
+ doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
157
+ const openingHitDistance = doorOpeningWorldHit.distanceTo(
158
+ doorInteractionRaycaster.ray.origin,
159
+ )
160
+
161
+ if (
162
+ openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
163
+ openingHitDistance < closestDistance
164
+ ) {
165
+ closestDoorId = doorId as AnyNodeId
166
+ closestDistance = openingHitDistance
167
+ }
168
+ continue
169
+ }
39
170
 
40
- // Place camera at the origin (center of grid) at eye height, looking along +X
41
- camera.position.set(0, EYE_HEIGHT, 0)
42
- yawRef.current = 0
43
- pitchRef.current = 0
171
+ const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
172
+ const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
173
+ const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
174
+ const currentSwingAngle =
175
+ useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
176
+ const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
177
+ const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
178
+
179
+ doorLeafMatrix
180
+ .copy(object.matrixWorld)
181
+ .multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
182
+ .multiply(new Matrix4().makeRotationY(leafSwingRotation))
183
+ .multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
184
+ doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
185
+ doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
186
+ doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
187
+ doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
188
+
189
+ const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
190
+ if (!localHit) continue
191
+
192
+ doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
193
+ const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
194
+
195
+ if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
196
+ closestDoorId = doorId as AnyNodeId
197
+ closestDistance = hitDistance
198
+ }
199
+ }
200
+
201
+ return closestDoorId
44
202
  }, [camera])
45
203
 
46
- // Pointer lock and event handlers
47
- useEffect(() => {
48
- const canvas = gl.domElement
204
+ const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
205
+ const nodes = useScene.getState().nodes
206
+ camera.updateMatrixWorld(true)
207
+ windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
49
208
 
50
- const requestLock = () => {
51
- if (!isLockedRef.current) {
52
- canvas.requestPointerLock()
53
- }
54
- }
209
+ let closestWindowId: AnyNodeId | null = null
210
+ let closestDistance = DOOR_INTERACTION_DISTANCE
55
211
 
56
- const handlePointerLockChange = () => {
57
- isLockedRef.current = document.pointerLockElement === canvas
58
- }
212
+ for (const windowId of sceneRegistry.byType.window) {
213
+ const node = nodes[windowId as AnyNodeId]
214
+ if (node?.type !== 'window') continue
215
+ if (node.openingKind === 'opening') continue
216
+ if (!isOperableWindowType(node.windowType)) continue
59
217
 
60
- const handleMouseMove = (e: MouseEvent) => {
61
- if (!isLockedRef.current) return
218
+ const object = sceneRegistry.nodes.get(windowId)
219
+ if (!object) continue
62
220
 
63
- yawRef.current -= e.movementX * MOUSE_SENSITIVITY
64
- pitchRef.current -= e.movementY * MOUSE_SENSITIVITY
65
- // Clamp pitch to prevent flipping (almost straight up/down)
66
- pitchRef.current = Math.max(
67
- -Math.PI / 2 + 0.05,
68
- Math.min(Math.PI / 2 - 0.05, pitchRef.current),
69
- )
221
+ const hit = windowInteractionRaycaster
222
+ .intersectObject(object, true)
223
+ .find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
224
+ if (!(hit && hit.distance < closestDistance)) continue
225
+
226
+ closestWindowId = windowId as AnyNodeId
227
+ closestDistance = hit.distance
70
228
  }
71
229
 
72
- const handleKeyDown = (e: KeyboardEvent) => {
73
- // Skip if user is typing in an input
74
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
230
+ return closestWindowId
231
+ }, [camera])
232
+
233
+ const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
234
+ const doorId = resolveInteractableDoorId()
235
+ if (doorId) return { id: doorId, type: 'door' }
236
+
237
+ const windowId = resolveInteractableWindowId()
238
+ if (windowId) return { id: windowId, type: 'window' }
239
+
240
+ return null
241
+ }, [resolveInteractableDoorId, resolveInteractableWindowId])
242
+
243
+ const toggleInteractableTarget = useCallback(() => {
244
+ const target = interactableTargetRef.current ?? resolveInteractableTarget()
245
+ if (!target) return
246
+
247
+ if (target.type === 'window') {
248
+ const node = useScene.getState().nodes[target.id]
249
+ if (
250
+ node?.type !== 'window' ||
251
+ node.openingKind === 'opening' ||
252
+ !isOperableWindowType(node.windowType)
253
+ ) {
75
254
  return
76
255
  }
77
256
 
78
- const code = e.code
257
+ toggleWindowOpenState(target.id, { persist: false })
258
+ return
259
+ }
260
+
261
+ const doorId = target.id
79
262
 
80
- // Movement keys
263
+ const node = useScene.getState().nodes[doorId]
264
+ if (node?.type !== 'door' || node.openingKind === 'opening') return
265
+
266
+ toggleDoorOpenState(doorId, { persist: false })
267
+ }, [resolveInteractableTarget])
268
+
269
+ const closeInteractableTarget = useCallback(() => {
270
+ const target = interactableTargetRef.current ?? resolveInteractableTarget()
271
+ if (!target) return
272
+
273
+ if (target.type === 'window') {
274
+ const node = useScene.getState().nodes[target.id]
81
275
  if (
82
- code === 'KeyW' ||
83
- code === 'KeyA' ||
84
- code === 'KeyS' ||
85
- code === 'KeyD' ||
86
- code === 'KeyQ' ||
87
- code === 'KeyE' ||
88
- code === 'ShiftLeft' ||
89
- code === 'ShiftRight'
276
+ node?.type !== 'window' ||
277
+ node.openingKind === 'opening' ||
278
+ !isOperableWindowType(node.windowType)
90
279
  ) {
91
- e.preventDefault()
92
- e.stopPropagation()
93
- keysRef.current.add(code)
280
+ return
94
281
  }
95
282
 
96
- // ESC exits first-person mode
97
- if (code === 'Escape') {
98
- e.preventDefault()
99
- e.stopPropagation()
100
- if (document.pointerLockElement === canvas) {
101
- document.exitPointerLock()
102
- }
103
- useEditor.getState().setFirstPersonMode(false)
283
+ closeWindowOpenState(target.id, { persist: false })
284
+ return
285
+ }
286
+
287
+ const node = useScene.getState().nodes[target.id]
288
+ if (node?.type !== 'door' || node.openingKind === 'opening') return
289
+
290
+ closeDoorOpenState(target.id, { persist: false })
291
+ }, [resolveInteractableTarget])
292
+
293
+ const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
294
+ if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
295
+
296
+ const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
297
+ if (spawnObject) {
298
+ spawnObject.updateWorldMatrix(true, false)
299
+ spawnObject.getWorldPosition(spawnWorldPosition)
300
+ spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
301
+
302
+ return {
303
+ position: [
304
+ spawnWorldPosition.x,
305
+ spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
306
+ spawnWorldPosition.z,
307
+ ],
308
+ yaw: spawnWorldEuler.y,
104
309
  }
105
310
  }
106
311
 
107
- const handleKeyUp = (e: KeyboardEvent) => {
108
- keysRef.current.delete(e.code)
312
+ return {
313
+ position: [
314
+ placedSpawnNode.position[0],
315
+ placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
316
+ placedSpawnNode.position[2],
317
+ ],
318
+ yaw: placedSpawnNode.rotation,
319
+ }
320
+ }, [placedSpawnNode])
321
+
322
+ useEffect(() => {
323
+ rebuildColliderWorld()
324
+
325
+ return () => {
326
+ worldRef.current?.dispose()
327
+ worldRef.current = null
328
+ setWorld(null)
329
+ }
330
+ }, [rebuildColliderWorld])
331
+
332
+ useEffect(() => {
333
+ emitter.on('door:animation-completed', rebuildColliderWorld)
334
+ emitter.on('window:animation-completed', rebuildColliderWorld)
335
+ return () => {
336
+ emitter.off('door:animation-completed', rebuildColliderWorld)
337
+ emitter.off('window:animation-completed', rebuildColliderWorld)
338
+ }
339
+ }, [rebuildColliderWorld])
340
+
341
+ useEffect(() => {
342
+ if (!world) return
343
+ if (controllerStart) return
344
+
345
+ const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
346
+ const [x, y, z] = spawn.position
347
+ yawRef.current = spawn.yaw
348
+ pitchRef.current = 0
349
+ setControllerStart({
350
+ position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
351
+ yaw: spawn.yaw,
352
+ })
353
+ }, [camera, controllerStart, placedSpawn, world])
354
+
355
+ useEffect(() => {
356
+ const canvas = gl.domElement
357
+ const handleMouseMove = (e: MouseEvent) => {
358
+ if (document.pointerLockElement !== canvas) return
359
+
360
+ yawRef.current -= e.movementX * LOOK_SENSITIVITY
361
+ pitchRef.current = Math.max(
362
+ -(Math.PI / 2 - 0.05),
363
+ Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
364
+ )
365
+ }
366
+
367
+ const handleClick = (event: MouseEvent) => {
368
+ const target = event.target
369
+ if (!(target instanceof HTMLElement)) return
370
+ if (!canvas.contains(target)) return
371
+ if (document.pointerLockElement !== canvas) {
372
+ canvas.requestPointerLock?.()
373
+ }
109
374
  }
110
375
 
111
- canvas.addEventListener('click', requestLock)
112
- document.addEventListener('pointerlockchange', handlePointerLockChange)
113
376
  document.addEventListener('mousemove', handleMouseMove)
114
- // Use capture phase so we intercept movement keys before the global keyboard handler
115
- document.addEventListener('keydown', handleKeyDown, true)
116
- document.addEventListener('keyup', handleKeyUp)
377
+ document.addEventListener('click', handleClick)
117
378
 
118
379
  return () => {
119
- canvas.removeEventListener('click', requestLock)
120
- document.removeEventListener('pointerlockchange', handlePointerLockChange)
121
380
  document.removeEventListener('mousemove', handleMouseMove)
122
- document.removeEventListener('keydown', handleKeyDown, true)
123
- document.removeEventListener('keyup', handleKeyUp)
381
+ document.removeEventListener('click', handleClick)
124
382
  if (document.pointerLockElement === canvas) {
125
383
  document.exitPointerLock()
126
384
  }
127
- keysRef.current.clear()
128
385
  }
129
386
  }, [gl])
130
387
 
131
- // Per-frame movement and camera rotation
132
- useFrame((_, delta) => {
133
- // Clamp delta to avoid huge jumps (e.g. tab switching)
134
- const dt = Math.min(delta, 0.1)
135
- const keys = keysRef.current
136
-
137
- const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
138
- const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
139
-
140
- // Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
141
- _forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
142
- _right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
143
-
144
- _moveVector.set(0, 0, 0)
388
+ useEffect(() => {
389
+ const canvas = gl.domElement
145
390
 
146
- if (keys.has('KeyW')) _moveVector.add(_forward)
147
- if (keys.has('KeyS')) _moveVector.sub(_forward)
148
- if (keys.has('KeyA')) _moveVector.sub(_right)
149
- if (keys.has('KeyD')) _moveVector.add(_right)
391
+ const handleKeyDown = (event: KeyboardEvent) => {
392
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
393
+ return
394
+ }
150
395
 
151
- // Normalize diagonal movement so it's not faster
152
- if (_moveVector.lengthSq() > 0) {
153
- _moveVector.normalize().multiplyScalar(speed * dt)
154
- camera.position.add(_moveVector)
396
+ if (event.code === 'Escape') {
397
+ event.preventDefault()
398
+ event.stopPropagation()
399
+ if (document.pointerLockElement === canvas) {
400
+ document.exitPointerLock()
401
+ }
402
+ useEditor.getState().setFirstPersonMode(false)
403
+ } else if (event.code === 'KeyE' || event.code === 'KeyR') {
404
+ event.preventDefault()
405
+ event.stopPropagation()
406
+ toggleInteractableTarget()
407
+ } else if (event.code === 'KeyT') {
408
+ event.preventDefault()
409
+ event.stopPropagation()
410
+ closeInteractableTarget()
411
+ }
155
412
  }
156
413
 
157
- // Vertical movement (Q = up, E = down)
158
- if (keys.has('KeyQ')) {
159
- camera.position.y += VERTICAL_SPEED * dt
414
+ document.addEventListener('keydown', handleKeyDown, true)
415
+ return () => {
416
+ document.removeEventListener('keydown', handleKeyDown, true)
160
417
  }
161
- if (keys.has('KeyE')) {
162
- camera.position.y -= VERTICAL_SPEED * dt
418
+ }, [closeInteractableTarget, gl, toggleInteractableTarget])
419
+
420
+ useFrame((_, delta) => {
421
+ if (!controllerRef.current?.group) return
422
+
423
+ const group = controllerRef.current.group
424
+ group.rotation.y = 0
425
+ camera.position.copy(group.position).add(cameraOffset)
426
+ cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
427
+ camera.quaternion.setFromEuler(cameraEuler)
428
+ camera.updateMatrixWorld(true)
429
+
430
+ const nextInteractableTarget = resolveInteractableTarget()
431
+ const previousInteractableTarget = interactableTargetRef.current
432
+ if (
433
+ previousInteractableTarget?.id !== nextInteractableTarget?.id ||
434
+ previousInteractableTarget?.type !== nextInteractableTarget?.type
435
+ ) {
436
+ interactableTargetRef.current = nextInteractableTarget
437
+ useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
163
438
  }
439
+ })
164
440
 
165
- // Clamp Y so camera never goes below ground level + eye height
166
- if (camera.position.y < MIN_Y) {
167
- camera.position.y = MIN_Y
441
+ useEffect(() => {
442
+ return () => {
443
+ if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
444
+ useViewer.getState().setHoveredId(null)
445
+ }
168
446
  }
447
+ }, [])
169
448
 
170
- // Apply look rotation
171
- _euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
172
- camera.quaternion.setFromEuler(_euler)
173
- })
449
+ if (!world) {
450
+ return null
451
+ }
174
452
 
175
- return null
453
+ return (
454
+ <>
455
+ {controllerStart && (
456
+ <KeyboardControls map={keyboardMap}>
457
+ <BVHEcctrl
458
+ ref={controllerRef}
459
+ key="first-person-controller"
460
+ colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
461
+ colliderMeshes={[world.mesh]}
462
+ collisionCheckIteration={3}
463
+ collisionPushBackDamping={0.1}
464
+ collisionPushBackThreshold={0.001}
465
+ debug={false}
466
+ delay={0}
467
+ fallGravityFactor={4}
468
+ floatCheckType="BOTH"
469
+ floatDampingC={36}
470
+ floatHeight={0.5}
471
+ floatPullBackHeight={0.35}
472
+ floatSensorRadius={0.15}
473
+ floatSpringK={1200}
474
+ gravity={9.81}
475
+ jumpVel={6}
476
+ maxRunSpeed={5.5}
477
+ maxSlope={1.2}
478
+ maxWalkSpeed={4}
479
+ position={controllerStart.position}
480
+ acceleration={26}
481
+ airDragFactor={0.3}
482
+ deceleration={30}
483
+ />
484
+ </KeyboardControls>
485
+ )}
486
+ </>
487
+ )
176
488
  }
177
489
 
178
490
  /**
@@ -180,6 +492,23 @@ export const FirstPersonControls = () => {
180
492
  * Rendered as a regular DOM overlay (not inside the Canvas).
181
493
  */
182
494
  export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
495
+ const [isLocked, setIsLocked] = useState(false)
496
+ const hasPlacedSpawn = useScene((state) =>
497
+ Object.values(state.nodes).some((node) => node.type === 'spawn'),
498
+ )
499
+
500
+ useEffect(() => {
501
+ const handlePointerLockChange = () => {
502
+ setIsLocked(document.pointerLockElement != null)
503
+ }
504
+
505
+ handlePointerLockChange()
506
+ document.addEventListener('pointerlockchange', handlePointerLockChange)
507
+ return () => {
508
+ document.removeEventListener('pointerlockchange', handlePointerLockChange)
509
+ }
510
+ }, [])
511
+
183
512
  const handleExit = useCallback(() => {
184
513
  if (document.pointerLockElement) {
185
514
  document.exitPointerLock()
@@ -189,15 +518,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
189
518
 
190
519
  return (
191
520
  <>
192
- {/* Crosshair */}
193
- <div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
194
- <div className="relative h-6 w-6">
195
- <div className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60" />
196
- <div className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60" />
521
+ {isLocked && (
522
+ <div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
523
+ <div className="relative h-7 w-7">
524
+ <div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
525
+ <div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
526
+ </div>
197
527
  </div>
198
- </div>
528
+ )}
199
529
 
200
- {/* Exit button — top-right */}
201
530
  <div className="fixed top-4 right-4 z-50">
202
531
  <button
203
532
  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"
@@ -211,30 +540,41 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
211
540
  </button>
212
541
  </div>
213
542
 
214
- {/* Controls hint — bottom-center */}
215
- <div className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2">
216
- <div className="flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 shadow-lg backdrop-blur-xl">
217
- <ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
218
- <div className="h-5 w-px bg-border/30" />
219
- <ControlHint label="Up" keys={['Q']} />
220
- <ControlHint label="Down" keys={['E']} />
221
- <div className="h-5 w-px bg-border/30" />
222
- <ControlHint label="Sprint" keys={['Shift']} />
223
- <div className="h-5 w-px bg-border/30" />
224
- <span className="text-muted-foreground/60 text-xs">Click to look around</span>
543
+ {!hasPlacedSpawn && (
544
+ <div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
545
+ <div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
546
+ Place a Spawn Point from the Build tab to control where walkthrough starts.
547
+ </div>
225
548
  </div>
226
- </div>
549
+ )}
550
+
551
+ {isLocked && (
552
+ <div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
553
+ <div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
554
+ <ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
555
+ <div className="h-px w-full bg-border/30" />
556
+ <InlineControlHint label="Jump" keyLabel="Space" />
557
+ <InlineControlHint label="Sprint" keyLabel="Shift" />
558
+ <InlineControlHint label="Interact" keyLabel="E / R" />
559
+ <InlineControlHint label="Close" keyLabel="T" />
560
+ <div className="h-px w-full bg-border/30" />
561
+ <span className="text-center text-muted-foreground/60 text-xs">
562
+ Click to look around
563
+ </span>
564
+ </div>
565
+ </div>
566
+ )}
227
567
  </>
228
568
  )
229
569
  }
230
570
 
231
571
  function ControlHint({ label, keys }: { label: string; keys: string[] }) {
232
572
  return (
233
- <div className="flex flex-col items-center gap-1.5">
573
+ <div className="flex flex-col items-center gap-1.5 text-center">
234
574
  <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
235
575
  {label}
236
576
  </span>
237
- <div className="flex items-center gap-1">
577
+ <div className="flex flex-wrap items-center justify-center gap-1">
238
578
  {keys.map((key) => (
239
579
  <kbd
240
580
  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"
@@ -247,3 +587,16 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
247
587
  </div>
248
588
  )
249
589
  }
590
+
591
+ function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
592
+ return (
593
+ <div className="flex items-center justify-between gap-3">
594
+ <span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
595
+ {label}
596
+ </span>
597
+ <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">
598
+ {keyLabel}
599
+ </kbd>
600
+ </div>
601
+ )
602
+ }