@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,16 +1,25 @@
1
1
  'use client'
2
+
2
3
  import {
4
+ type AnyNodeId,
3
5
  type CameraControlEvent,
4
6
  type CameraControlFitSceneEvent,
5
7
  emitter,
6
8
  sceneRegistry,
7
9
  useScene,
8
10
  } from '@pascal-app/core'
9
- import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
11
+ import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
10
12
  import { CameraControls, CameraControlsImpl } from '@react-three/drei'
11
- import { useThree } from '@react-three/fiber'
13
+ import { useFrame, useThree } from '@react-three/fiber'
12
14
  import { useCallback, useEffect, useMemo, useRef } from 'react'
13
- import { Box3, Vector3 } from 'three'
15
+ import {
16
+ Box3,
17
+ type Camera,
18
+ type OrthographicCamera,
19
+ type PerspectiveCamera,
20
+ Spherical,
21
+ Vector3,
22
+ } from 'three'
14
23
  import { EDITOR_LAYER } from '../../lib/constants'
15
24
  import useEditor from '../../store/use-editor'
16
25
 
@@ -21,30 +30,359 @@ const tempDelta = new Vector3()
21
30
  const tempPosition = new Vector3()
22
31
  const tempSize = new Vector3()
23
32
  const tempTarget = new Vector3()
33
+ const syncTarget = new Vector3()
34
+ const syncSpherical = new Spherical()
35
+ const keyboardPanSpherical = new Spherical()
24
36
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
25
37
  const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
38
+ const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
39
+ const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
40
+ const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
41
+ const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
42
+ const KEYBOARD_PAN_MIN_SPEED = 2
43
+ const KEYBOARD_PAN_MAX_SPEED = 55
44
+ type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
45
+ type CameraPoseSnapshot = {
46
+ mode: CameraMode
47
+ position: [number, number, number]
48
+ target: [number, number, number]
49
+ }
50
+ type NavigationCameraPoseSnapshot = {
51
+ target: [number, number, number]
52
+ azimuth: number
53
+ viewWidth: number
54
+ }
55
+ type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
56
+ publishOnComplete: boolean
57
+ }
58
+ type CameraViewWidthUpdate =
59
+ | { type: 'distance'; distance: number; viewWidth: number }
60
+ | { type: 'zoom'; viewWidth: number; zoom: number }
61
+ | { type: 'none'; viewWidth: number }
62
+
63
+ function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
64
+ tuple[0] = vector.x
65
+ tuple[1] = vector.y
66
+ tuple[2] = vector.z
67
+ }
68
+
69
+ function saveCameraPose(
70
+ control: CameraControlsImpl,
71
+ mode: CameraMode,
72
+ pose: CameraPoseSnapshot,
73
+ position: Vector3,
74
+ target: Vector3,
75
+ ) {
76
+ control.getPosition(position)
77
+ control.getTarget(target)
78
+ pose.mode = mode
79
+ writeVectorTuple(pose.position, position)
80
+ writeVectorTuple(pose.target, target)
81
+ }
82
+
83
+ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) {
84
+ control.setLookAt(
85
+ pose.position[0],
86
+ pose.position[1],
87
+ pose.position[2],
88
+ pose.target[0],
89
+ pose.target[1],
90
+ pose.target[2],
91
+ false,
92
+ )
93
+ }
94
+
95
+ function isEditableKeyboardTarget(target: EventTarget | null) {
96
+ return (
97
+ target instanceof HTMLInputElement ||
98
+ target instanceof HTMLTextAreaElement ||
99
+ target instanceof HTMLSelectElement ||
100
+ (target instanceof HTMLElement && target.isContentEditable)
101
+ )
102
+ }
103
+
104
+ type KeyboardPanState = {
105
+ forward: boolean
106
+ backward: boolean
107
+ left: boolean
108
+ right: boolean
109
+ }
110
+
111
+ function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
112
+ if (code === 'KeyW') {
113
+ const changed = state.forward !== pressed
114
+ state.forward = pressed
115
+ return changed
116
+ }
117
+ if (code === 'KeyS') {
118
+ const changed = state.backward !== pressed
119
+ state.backward = pressed
120
+ return changed
121
+ }
122
+ if (code === 'KeyA') {
123
+ const changed = state.left !== pressed
124
+ state.left = pressed
125
+ return changed
126
+ }
127
+ if (code === 'KeyD') {
128
+ const changed = state.right !== pressed
129
+ state.right = pressed
130
+ return changed
131
+ }
132
+ return false
133
+ }
134
+
135
+ function isKeyboardPanKey(code: string): boolean {
136
+ return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
137
+ }
138
+
139
+ type CameraViewportSize = {
140
+ width: number
141
+ height: number
142
+ }
143
+
144
+ function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera {
145
+ return (camera as PerspectiveCamera).isPerspectiveCamera === true
146
+ }
147
+
148
+ function isOrthographicCamera(camera: Camera): camera is OrthographicCamera {
149
+ return (camera as OrthographicCamera).isOrthographicCamera === true
150
+ }
151
+
152
+ function getCameraViewAspect(size: CameraViewportSize) {
153
+ return Math.max(size.width, 1) / Math.max(size.height, 1)
154
+ }
155
+
156
+ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewportSize) {
157
+ if (isPerspectiveCamera(camera)) {
158
+ const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
159
+ return Math.max(0.001, 2 * distance * Math.tan(fovRadians / 2) * getCameraViewAspect(size))
160
+ }
161
+
162
+ if (isOrthographicCamera(camera)) {
163
+ return Math.max(0.001, (camera.right - camera.left) / camera.zoom)
164
+ }
165
+
166
+ return Math.max(0.001, distance)
167
+ }
168
+
169
+ function getAngleDeltaRadians(a: number, b: number) {
170
+ return Math.atan2(Math.sin(a - b), Math.cos(a - b))
171
+ }
172
+
173
+ function nearestEquivalentRadians(angle: number, reference: number) {
174
+ return reference + getAngleDeltaRadians(angle, reference)
175
+ }
176
+
177
+ function clampFinite(value: number, min: number, max: number) {
178
+ const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
179
+ const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
180
+ return Math.min(Math.max(value, resolvedMin), resolvedMax)
181
+ }
182
+
183
+ function clampCameraControlDistance(control: CameraControlsImpl, distance: number) {
184
+ const bounds = control as { minDistance?: number; maxDistance?: number }
185
+ return clampFinite(
186
+ distance,
187
+ bounds.minDistance ?? Number.NEGATIVE_INFINITY,
188
+ bounds.maxDistance ?? Number.POSITIVE_INFINITY,
189
+ )
190
+ }
191
+
192
+ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
193
+ const bounds = control as { minZoom?: number; maxZoom?: number }
194
+ return clampFinite(
195
+ zoom,
196
+ bounds.minZoom ?? Number.NEGATIVE_INFINITY,
197
+ bounds.maxZoom ?? Number.POSITIVE_INFINITY,
198
+ )
199
+ }
200
+
201
+ function isCameraAtNavigationPose(
202
+ pose: NavigationCameraPoseSnapshot,
203
+ target: Vector3,
204
+ azimuth: number,
205
+ viewWidth: number,
206
+ ) {
207
+ return (
208
+ Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
209
+ Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
210
+ Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
211
+ Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
212
+ Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
213
+ )
214
+ }
215
+
216
+ function getCameraDistanceForViewWidth(
217
+ camera: Camera,
218
+ viewWidth: number,
219
+ size: CameraViewportSize,
220
+ ) {
221
+ if (!isPerspectiveCamera(camera)) {
222
+ return null
223
+ }
224
+
225
+ const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
226
+ const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size)
227
+
228
+ return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null
229
+ }
230
+
231
+ function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
232
+ if (!isOrthographicCamera(camera)) {
233
+ return null
234
+ }
235
+
236
+ return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
237
+ }
238
+
239
+ function resolveCameraViewWidthUpdate(
240
+ control: CameraControlsImpl,
241
+ camera: Camera,
242
+ viewWidth: number,
243
+ size: CameraViewportSize,
244
+ ): CameraViewWidthUpdate {
245
+ const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
246
+ if (nextDistance !== null) {
247
+ const appliedDistance = clampCameraControlDistance(control, nextDistance)
248
+ return {
249
+ type: 'distance',
250
+ distance: appliedDistance,
251
+ viewWidth: getCameraViewWidth(camera, appliedDistance, size),
252
+ }
253
+ }
254
+
255
+ const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
256
+ if (nextZoom !== null) {
257
+ const appliedZoom = clampCameraControlZoom(control, nextZoom)
258
+ if (isOrthographicCamera(camera)) {
259
+ return {
260
+ type: 'zoom',
261
+ zoom: appliedZoom,
262
+ viewWidth: Math.max(0.001, (camera.right - camera.left) / Math.max(appliedZoom, 0.001)),
263
+ }
264
+ }
265
+ }
266
+
267
+ return { type: 'none', viewWidth }
268
+ }
269
+
270
+ function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
271
+ if (update.type === 'distance') {
272
+ control.dollyTo(update.distance, true)
273
+ return
274
+ }
275
+
276
+ if (update.type === 'zoom') {
277
+ control.zoomTo(update.zoom, true)
278
+ }
279
+ }
280
+
281
+ function useFirstPersonCameraPoseRestore(
282
+ controls: { current: CameraControlsImpl | null },
283
+ isFirstPersonMode: boolean,
284
+ cameraMode: CameraMode,
285
+ ) {
286
+ const restorePose = useRef<CameraPoseSnapshot>({
287
+ mode: cameraMode,
288
+ position: [0, 0, 0],
289
+ target: [0, 0, 0],
290
+ })
291
+ const hasRestorePose = useRef(false)
292
+ const isRestoring = useRef(false)
293
+ const wasFirstPersonMode = useRef(isFirstPersonMode)
294
+ const snapshotPosition = useRef(new Vector3())
295
+ const snapshotTarget = useRef(new Vector3())
296
+
297
+ useFrame(() => {
298
+ if (isFirstPersonMode || isRestoring.current) return
299
+ const control = controls.current
300
+ if (!control) return
301
+
302
+ saveCameraPose(
303
+ control,
304
+ cameraMode,
305
+ restorePose.current,
306
+ snapshotPosition.current,
307
+ snapshotTarget.current,
308
+ )
309
+ hasRestorePose.current = true
310
+ })
311
+
312
+ useEffect(() => {
313
+ const wasFirstPerson = wasFirstPersonMode.current
314
+ wasFirstPersonMode.current = isFirstPersonMode
315
+
316
+ if (isFirstPersonMode) {
317
+ return
318
+ }
319
+
320
+ if (!wasFirstPerson || !hasRestorePose.current) return
321
+
322
+ const pose = restorePose.current
323
+ isRestoring.current = true
324
+ useViewer.getState().setCameraMode(pose.mode)
325
+
326
+ const restoreFrame = requestAnimationFrame(() => {
327
+ const currentControls = controls.current
328
+ if (currentControls) {
329
+ restoreCameraPose(currentControls, pose)
330
+ }
331
+ isRestoring.current = false
332
+ })
333
+
334
+ return () => {
335
+ cancelAnimationFrame(restoreFrame)
336
+ isRestoring.current = false
337
+ }
338
+ }, [controls, isFirstPersonMode])
339
+
340
+ return useCallback(() => isRestoring.current, [])
341
+ }
26
342
 
27
343
  export const CustomCameraControls = () => {
28
- const controls = useRef<CameraControlsImpl>(null!)
344
+ const controls = useRef<CameraControlsImpl | null>(null)
345
+ const keyboardPanKeys = useRef<KeyboardPanState>({
346
+ forward: false,
347
+ backward: false,
348
+ left: false,
349
+ right: false,
350
+ })
29
351
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
30
352
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
31
353
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
354
+ const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
32
355
  const selection = useViewer((s) => s.selection)
356
+ const cameraMode = useViewer((state) => state.cameraMode)
357
+ const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
358
+ controls,
359
+ isFirstPersonMode,
360
+ cameraMode,
361
+ )
33
362
  const currentLevelId = selection.levelId
34
363
  const firstLoad = useRef(true)
364
+ const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
365
+ const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
366
+ const lastApplied2dNavigationRevision = useRef(0)
35
367
  const maxPolarAngle =
36
368
  !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
369
+ const clearPendingFloorplanNavigationPose = useCallback(() => {
370
+ pendingFloorplanNavigationPose.current = null
371
+ }, [])
37
372
 
38
373
  const camera = useThree((state) => state.camera)
374
+ const gl = useThree((state) => state.gl)
39
375
  const raycaster = useThree((state) => state.raycaster)
376
+ const viewportSize = useThree((state) => state.size)
40
377
  useEffect(() => {
41
378
  camera.layers.enable(EDITOR_LAYER)
379
+ camera.layers.enable(GRID_LAYER)
42
380
  raycaster.layers.enable(EDITOR_LAYER)
43
381
  raycaster.layers.enable(ZONE_LAYER)
44
382
  }, [camera, raycaster])
45
383
 
46
384
  useEffect(() => {
47
- if (isPreviewMode) return // Preview mode uses auto-navigate instead
385
+ if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
48
386
  let targetY = 0
49
387
  if (currentLevelId) {
50
388
  const levelMesh = sceneRegistry.nodes.get(currentLevelId)
@@ -55,14 +393,22 @@ export const CustomCameraControls = () => {
55
393
  if (!controls.current) return
56
394
  if (firstLoad.current) {
57
395
  firstLoad.current = false
396
+ clearPendingFloorplanNavigationPose()
58
397
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
59
398
  }
60
399
  controls.current.getTarget(currentTarget)
400
+ clearPendingFloorplanNavigationPose()
61
401
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
62
- }, [currentLevelId, isPreviewMode])
402
+ }, [
403
+ clearPendingFloorplanNavigationPose,
404
+ currentLevelId,
405
+ isPreviewMode,
406
+ isFirstPersonMode,
407
+ isRestoringFirstPersonPose,
408
+ ])
63
409
 
64
410
  useEffect(() => {
65
- if (!controls.current) return
411
+ if (isFirstPersonMode || !controls.current) return
66
412
 
67
413
  controls.current.maxPolarAngle = maxPolarAngle
68
414
  controls.current.minPolarAngle = 0
@@ -70,11 +416,11 @@ export const CustomCameraControls = () => {
70
416
  if (controls.current.polarAngle > maxPolarAngle) {
71
417
  controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
72
418
  }
73
- }, [maxPolarAngle])
419
+ }, [isFirstPersonMode, maxPolarAngle])
74
420
 
75
421
  const focusNode = useCallback(
76
422
  (nodeId: string) => {
77
- if (isPreviewMode || !controls.current) return
423
+ if (isPreviewMode || isFirstPersonMode || !controls.current) return
78
424
 
79
425
  const object3D = sceneRegistry.nodes.get(nodeId)
80
426
  if (!object3D) return
@@ -87,6 +433,7 @@ export const CustomCameraControls = () => {
87
433
  controls.current.getTarget(tempTarget)
88
434
  tempDelta.copy(tempCenter).sub(tempTarget)
89
435
 
436
+ clearPendingFloorplanNavigationPose()
90
437
  controls.current.setLookAt(
91
438
  tempPosition.x + tempDelta.x,
92
439
  tempPosition.y + tempDelta.y,
@@ -97,11 +444,141 @@ export const CustomCameraControls = () => {
97
444
  true,
98
445
  )
99
446
  },
100
- [isPreviewMode],
447
+ [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
101
448
  )
102
449
 
450
+ useEffect(() => {
451
+ if (isFirstPersonMode) return
452
+
453
+ return useEditor.subscribe((state) => {
454
+ const pose = state.navigationSyncPose
455
+ if (
456
+ !pose ||
457
+ pose.source !== '2d' ||
458
+ pose.revision === lastApplied2dNavigationRevision.current
459
+ )
460
+ return
461
+
462
+ const control = controls.current
463
+ if (!control) return
464
+
465
+ lastApplied2dNavigationRevision.current = pose.revision
466
+ const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
467
+ const viewWidthUpdate = resolveCameraViewWidthUpdate(
468
+ control,
469
+ camera,
470
+ pose.viewWidth,
471
+ viewportSize,
472
+ )
473
+ pendingFloorplanNavigationPose.current = {
474
+ target: [...pose.target],
475
+ azimuth: targetAzimuth,
476
+ viewWidth: viewWidthUpdate.viewWidth,
477
+ publishOnComplete:
478
+ Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
479
+ NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
480
+ }
481
+ control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
482
+ control.rotateTo(targetAzimuth, control.polarAngle, true)
483
+ applyCameraViewWidth(control, viewWidthUpdate)
484
+ })
485
+ }, [camera, isFirstPersonMode, viewportSize])
486
+
487
+ const publishCurrentNavigationPose = useCallback(() => {
488
+ if (isFirstPersonMode || !controls.current) return
489
+
490
+ controls.current.getTarget(syncTarget, false)
491
+ controls.current.getSpherical(syncSpherical, false)
492
+ const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
493
+
494
+ const pendingFloorplanPose = pendingFloorplanNavigationPose.current
495
+ if (pendingFloorplanPose) {
496
+ // The camera is still damping toward a 2D-originated pose; do not echo
497
+ // intermediate 3D poses back into the floorplan.
498
+ if (
499
+ isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
500
+ ) {
501
+ lastPublishedNavigationSync.current = pendingFloorplanPose
502
+ pendingFloorplanNavigationPose.current = null
503
+ if (pendingFloorplanPose.publishOnComplete) {
504
+ useEditor.getState().publishNavigationSyncPose({
505
+ source: '3d',
506
+ target: [
507
+ pendingFloorplanPose.target[0],
508
+ pendingFloorplanPose.target[1],
509
+ pendingFloorplanPose.target[2],
510
+ ],
511
+ azimuth: pendingFloorplanPose.azimuth,
512
+ viewWidth: pendingFloorplanPose.viewWidth,
513
+ })
514
+ }
515
+ }
516
+ return
517
+ }
518
+
519
+ const previous = lastPublishedNavigationSync.current
520
+ if (
521
+ previous &&
522
+ Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
523
+ Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
524
+ Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
525
+ Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
526
+ NAVIGATION_SYNC_AZIMUTH_EPSILON &&
527
+ Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
528
+ ) {
529
+ return
530
+ }
531
+
532
+ lastPublishedNavigationSync.current = {
533
+ target: [syncTarget.x, syncTarget.y, syncTarget.z],
534
+ azimuth: syncSpherical.theta,
535
+ viewWidth,
536
+ }
537
+ useEditor.getState().publishNavigationSyncPose({
538
+ source: '3d',
539
+ target: [syncTarget.x, syncTarget.y, syncTarget.z],
540
+ azimuth: syncSpherical.theta,
541
+ viewWidth,
542
+ })
543
+ }, [camera, isFirstPersonMode, viewportSize])
544
+
545
+ useEffect(() => {
546
+ if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
547
+
548
+ const frame = requestAnimationFrame(() => {
549
+ lastPublishedNavigationSync.current = null
550
+ publishCurrentNavigationPose()
551
+ })
552
+
553
+ return () => {
554
+ cancelAnimationFrame(frame)
555
+ }
556
+ }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
557
+
558
+ useFrame((_, delta) => {
559
+ if (isFirstPersonMode || !controls.current) return
560
+
561
+ const panKeys = keyboardPanKeys.current
562
+ const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
563
+ const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
564
+ if (horizontal === 0 && vertical === 0) return
565
+
566
+ const control = controls.current
567
+
568
+ control.getSpherical(keyboardPanSpherical, false)
569
+ const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
570
+ const speed = Math.min(
571
+ Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
572
+ KEYBOARD_PAN_MAX_SPEED,
573
+ )
574
+ const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
575
+
576
+ pendingFloorplanNavigationPose.current = null
577
+ if (horizontal !== 0) control.truck(horizontal * step, 0, true)
578
+ if (vertical !== 0) control.forward(vertical * step, true)
579
+ })
580
+
103
581
  // Configure mouse buttons based on control mode and camera mode
104
- const cameraMode = useViewer((state) => state.cameraMode)
105
582
  const mouseButtons = useMemo(() => {
106
583
  // Use ZOOM for orthographic camera, DOLLY for perspective camera
107
584
  const wheelAction =
@@ -137,9 +614,15 @@ export const CustomCameraControls = () => {
137
614
  const movingNode = useEditor((s) => s.movingNode)
138
615
  const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
139
616
  const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
617
+ const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
140
618
  const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
141
619
  const isInteracting = Boolean(
142
- tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
620
+ tool ||
621
+ movingNode ||
622
+ movingWallEndpoint ||
623
+ movingFenceEndpoint ||
624
+ activeHandleDrag ||
625
+ isBoxSelectActive,
143
626
  )
144
627
  const touches = useMemo(() => {
145
628
  const twoFingerAction =
@@ -161,6 +644,8 @@ export const CustomCameraControls = () => {
161
644
  }, [cameraMode, isPreviewMode, isInteracting])
162
645
 
163
646
  useEffect(() => {
647
+ if (isFirstPersonMode) return
648
+
164
649
  const keyState = {
165
650
  shiftRight: false,
166
651
  shiftLeft: false,
@@ -168,6 +653,52 @@ export const CustomCameraControls = () => {
168
653
  controlLeft: false,
169
654
  space: false,
170
655
  }
656
+ let ownsNavigationCursor = false
657
+ let panPointerId: number | null = null
658
+ let panPointerButton: number | null = null
659
+
660
+ const clearKeyboardPanKeys = () => {
661
+ keyboardPanKeys.current.forward = false
662
+ keyboardPanKeys.current.backward = false
663
+ keyboardPanKeys.current.left = false
664
+ keyboardPanKeys.current.right = false
665
+ }
666
+
667
+ const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
668
+ document.body.style.cursor = cursor
669
+ gl.domElement.style.cursor = cursor
670
+ ownsNavigationCursor = true
671
+ }
672
+
673
+ const clearNavigationCursor = () => {
674
+ if (
675
+ ownsNavigationCursor &&
676
+ (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing')
677
+ ) {
678
+ document.body.style.cursor = ''
679
+ }
680
+ if (ownsNavigationCursor && gl.domElement.style.cursor === 'grab') {
681
+ gl.domElement.style.cursor = ''
682
+ }
683
+ if (ownsNavigationCursor && gl.domElement.style.cursor === 'grabbing') {
684
+ gl.domElement.style.cursor = ''
685
+ }
686
+ ownsNavigationCursor = false
687
+ }
688
+
689
+ const updateNavigationCursor = () => {
690
+ if (panPointerId !== null) {
691
+ setNavigationCursor('grabbing')
692
+ return
693
+ }
694
+
695
+ if (keyState.space) {
696
+ setNavigationCursor('grab')
697
+ return
698
+ }
699
+
700
+ clearNavigationCursor()
701
+ }
171
702
 
172
703
  const updateConfig = () => {
173
704
  if (!controls.current) return
@@ -194,9 +725,24 @@ export const CustomCameraControls = () => {
194
725
  }
195
726
 
196
727
  const onKeyDown = (event: KeyboardEvent) => {
728
+ if (isKeyboardPanKey(event.code)) {
729
+ if (
730
+ !(event.metaKey || event.ctrlKey || event.altKey) &&
731
+ !isEditableKeyboardTarget(event.target)
732
+ ) {
733
+ setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
734
+ pendingFloorplanNavigationPose.current = null
735
+ event.preventDefault()
736
+ event.stopPropagation()
737
+ }
738
+ return
739
+ }
740
+
197
741
  if (event.code === 'Space') {
742
+ if (isEditableKeyboardTarget(event.target)) return
743
+ event.preventDefault()
198
744
  keyState.space = true
199
- document.body.style.cursor = 'grab'
745
+ updateNavigationCursor()
200
746
  }
201
747
  if (event.code === 'ShiftRight') {
202
748
  keyState.shiftRight = true
@@ -214,9 +760,22 @@ export const CustomCameraControls = () => {
214
760
  }
215
761
 
216
762
  const onKeyUp = (event: KeyboardEvent) => {
763
+ if (isKeyboardPanKey(event.code)) {
764
+ const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
765
+ if (changed) {
766
+ event.preventDefault()
767
+ event.stopPropagation()
768
+ }
769
+ return
770
+ }
771
+
217
772
  if (event.code === 'Space') {
218
773
  keyState.space = false
219
- document.body.style.cursor = ''
774
+ if (panPointerButton === 0) {
775
+ panPointerId = null
776
+ panPointerButton = null
777
+ }
778
+ updateNavigationCursor()
220
779
  }
221
780
  if (event.code === 'ShiftRight') {
222
781
  keyState.shiftRight = false
@@ -233,15 +792,60 @@ export const CustomCameraControls = () => {
233
792
  updateConfig()
234
793
  }
235
794
 
795
+ const onPointerDown = (event: PointerEvent) => {
796
+ if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
797
+ pendingFloorplanNavigationPose.current = null
798
+ if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
799
+
800
+ panPointerId = event.pointerId
801
+ panPointerButton = event.button
802
+ updateNavigationCursor()
803
+ }
804
+
805
+ const onWheel = () => {
806
+ pendingFloorplanNavigationPose.current = null
807
+ }
808
+
809
+ const onPointerUp = (event: PointerEvent) => {
810
+ if (panPointerId === null) return
811
+ if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return
812
+ if (event.type !== 'pointercancel' && event.button !== panPointerButton) return
813
+
814
+ panPointerId = null
815
+ panPointerButton = null
816
+ updateNavigationCursor()
817
+ }
818
+
819
+ const onBlur = () => {
820
+ keyState.space = false
821
+ clearKeyboardPanKeys()
822
+ panPointerId = null
823
+ panPointerButton = null
824
+ clearNavigationCursor()
825
+ updateConfig()
826
+ }
827
+
236
828
  document.addEventListener('keydown', onKeyDown)
237
829
  document.addEventListener('keyup', onKeyUp)
830
+ window.addEventListener('pointerdown', onPointerDown, true)
831
+ window.addEventListener('pointerup', onPointerUp, true)
832
+ window.addEventListener('pointercancel', onPointerUp, true)
833
+ window.addEventListener('blur', onBlur)
834
+ gl.domElement.addEventListener('wheel', onWheel, { passive: true })
238
835
  updateConfig()
239
836
 
240
837
  return () => {
241
838
  document.removeEventListener('keydown', onKeyDown)
242
839
  document.removeEventListener('keyup', onKeyUp)
840
+ window.removeEventListener('pointerdown', onPointerDown, true)
841
+ window.removeEventListener('pointerup', onPointerUp, true)
842
+ window.removeEventListener('pointercancel', onPointerUp, true)
843
+ window.removeEventListener('blur', onBlur)
844
+ gl.domElement.removeEventListener('wheel', onWheel)
845
+ clearKeyboardPanKeys()
846
+ clearNavigationCursor()
243
847
  }
244
- }, [cameraMode, isPreviewMode])
848
+ }, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
245
849
 
246
850
  // Preview mode: auto-navigate camera to selected node (viewer behavior)
247
851
  const previewTargetNodeId = isPreviewMode
@@ -249,7 +853,7 @@ export const CustomCameraControls = () => {
249
853
  : null
250
854
 
251
855
  useEffect(() => {
252
- if (!(isPreviewMode && controls.current)) return
856
+ if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
253
857
 
254
858
  const nodes = useScene.getState().nodes
255
859
  let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
@@ -309,11 +913,122 @@ export const CustomCameraControls = () => {
309
913
  tempCenter.z,
310
914
  true,
311
915
  )
312
- }, [isPreviewMode, previewTargetNodeId])
916
+ }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
313
917
 
918
+ // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
919
+ // isolated })` fires, fly the camera to a pose that fits the union
920
+ // bounds of the isolated subtree inside the locked square crop. The
921
+ // user can still pan / orbit / zoom from there; we only set the
922
+ // initial pose. On exit (`mode: 'idle'`), we restore the previous
923
+ // pose so the user lands back exactly where they were before the
924
+ // modal opened.
925
+ const captureMode = useEditor((s) => s.captureMode)
314
926
  useEffect(() => {
315
- const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
927
+ if (isFirstPersonMode) return
928
+ if (!controls.current) return
929
+ if (captureMode.mode !== 'preset') return
930
+ const ids = captureMode.isolated
931
+ if (ids.length === 0) return
932
+
933
+ // Stash the pre-capture pose so we can restore it on exit. Using
934
+ // a ref keeps the value across the cleanup phase without
935
+ // re-renders.
936
+ const restorePos = new Vector3()
937
+ const restoreTarget = new Vector3()
938
+ controls.current.getPosition(restorePos)
939
+ controls.current.getTarget(restoreTarget)
940
+
941
+ // Union the bounds of every isolated subtree root. `setFromObject`
942
+ // walks the Three.js descendants automatically, so this picks up
943
+ // synthesized children (door/window cutouts under a wall, etc.).
944
+ tempBox.makeEmpty()
945
+ for (const id of ids) {
946
+ const obj = sceneRegistry.nodes.get(id)
947
+ if (!obj) continue
948
+ const sub = new Box3().setFromObject(obj)
949
+ if (!sub.isEmpty()) tempBox.union(sub)
950
+ }
951
+ if (captureMode.framingBounds) {
952
+ const { center, max, min, size } = captureMode.framingBounds
953
+ const fallbackHeight = Math.max(Math.max(size[0], size[1]) * 0.35, 2.5)
954
+ const minY = tempBox.isEmpty() ? 0 : tempBox.min.y
955
+ const maxY = tempBox.isEmpty() ? fallbackHeight : tempBox.max.y
956
+ tempBox.min.set(min[0], minY, min[1])
957
+ tempBox.max.set(max[0], Math.max(maxY, minY + 0.1), max[1])
958
+ tempCenter.set(center[0], (tempBox.min.y + tempBox.max.y) / 2, center[1])
959
+ tempSize.set(size[0], tempBox.max.y - tempBox.min.y, size[1])
960
+ } else {
961
+ if (tempBox.isEmpty()) return
962
+
963
+ tempBox.getCenter(tempCenter)
964
+ tempBox.getSize(tempSize)
965
+ }
966
+
967
+ // Distance heuristic: fit the subject inside the 75%-of-shorter-
968
+ // side square crop with comfortable padding. Multiplier 2.4 leaves
969
+ // ~25-30% margin around the bounds so the user can frame without
970
+ // immediately needing to zoom out, but isn't so far away that the
971
+ // subject reads as small in the thumbnail.
972
+ const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
973
+ const distance = Math.max(maxDim * 2.4, 4)
974
+
975
+ // Frame the subject from a 3/4 view of its front face. The node's
976
+ // local +Z is its forward axis in this scene's authoring convention
977
+ // (the face the user sets up to be photographed). When a single
978
+ // subtree is isolated we read its yaw and rotate the camera around
979
+ // the bounds center so the framing follows the user's authored
980
+ // orientation; for multi-isolate sets we fall back to world +Z.
981
+ // The 3/4 view offsets the camera by 35° to the right of dead-on
982
+ // so both the front face and a side are visible — the "nice angle"
983
+ // that reads as a product shot rather than a flat elevation. ~25°
984
+ // elevation keeps the top visible without going isometric.
985
+ const SIDE_OFFSET_RAD = (35 * Math.PI) / 180
986
+ const ELEVATION_RAD = (25 * Math.PI) / 180
987
+ let yaw = 0
988
+ if (ids.length === 1) {
989
+ const node = useScene.getState().nodes[ids[0] as AnyNodeId]
990
+ if (node && 'rotation' in node) {
991
+ const r = (node as { rotation?: unknown }).rotation
992
+ if (typeof r === 'number') yaw = r
993
+ else if (Array.isArray(r)) yaw = (r as [number, number, number])[1] ?? 0
994
+ }
995
+ }
996
+ // World-space direction the camera should sit *along* relative to
997
+ // bounds center: in front (object's local +Z under yaw) + a right
998
+ // offset around Y for the 3/4 read.
999
+ const viewAngle = yaw + SIDE_OFFSET_RAD
1000
+ const horizontal = distance * Math.cos(ELEVATION_RAD)
1001
+ const elevation = distance * Math.sin(ELEVATION_RAD)
1002
+ controls.current.setLookAt(
1003
+ tempCenter.x + Math.sin(viewAngle) * horizontal,
1004
+ tempCenter.y + elevation,
1005
+ tempCenter.z + Math.cos(viewAngle) * horizontal,
1006
+ tempCenter.x,
1007
+ tempCenter.y,
1008
+ tempCenter.z,
1009
+ true,
1010
+ )
1011
+
1012
+ return () => {
1013
+ // Cleanup runs on captureMode change *or* unmount. Restore the
1014
+ // pre-capture pose only if the controls are still around (during
1015
+ // unmount they might be torn down already).
316
1016
  if (!controls.current) return
1017
+ controls.current.setLookAt(
1018
+ restorePos.x,
1019
+ restorePos.y,
1020
+ restorePos.z,
1021
+ restoreTarget.x,
1022
+ restoreTarget.y,
1023
+ restoreTarget.z,
1024
+ true,
1025
+ )
1026
+ }
1027
+ }, [captureMode, isFirstPersonMode])
1028
+
1029
+ useEffect(() => {
1030
+ const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
1031
+ if (isFirstPersonMode || !controls.current) return
317
1032
 
318
1033
  const position = new Vector3()
319
1034
  const target = new Vector3()
@@ -331,12 +1046,13 @@ export const CustomCameraControls = () => {
331
1046
  })
332
1047
  }
333
1048
  const handleNodeView = ({ nodeId }: CameraControlEvent) => {
334
- if (!controls.current) return
1049
+ if (isFirstPersonMode || !controls.current) return
335
1050
 
336
1051
  const node = useScene.getState().nodes[nodeId]
337
1052
  if (!node?.camera) return
338
1053
  const { position, target } = node.camera
339
1054
 
1055
+ clearPendingFloorplanNavigationPose()
340
1056
  controls.current.setLookAt(
341
1057
  position[0],
342
1058
  position[1],
@@ -349,7 +1065,7 @@ export const CustomCameraControls = () => {
349
1065
  }
350
1066
 
351
1067
  const handleTopView = () => {
352
- if (!controls.current) return
1068
+ if (isFirstPersonMode || !controls.current) return
353
1069
 
354
1070
  const currentPolarAngle = controls.current.polarAngle
355
1071
 
@@ -357,11 +1073,12 @@ export const CustomCameraControls = () => {
357
1073
  // Otherwise, go to top view (0°)
358
1074
  const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
359
1075
 
1076
+ clearPendingFloorplanNavigationPose()
360
1077
  controls.current.rotatePolarTo(targetAngle, true)
361
1078
  }
362
1079
 
363
1080
  const handleOrbitCW = () => {
364
- if (!controls.current) return
1081
+ if (isFirstPersonMode || !controls.current) return
365
1082
 
366
1083
  const currentAzimuth = controls.current.azimuthAngle
367
1084
  const currentPolar = controls.current.polarAngle
@@ -369,11 +1086,12 @@ export const CustomCameraControls = () => {
369
1086
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
370
1087
  const target = rounded - Math.PI / 2
371
1088
 
1089
+ clearPendingFloorplanNavigationPose()
372
1090
  controls.current.rotateTo(target, currentPolar, true)
373
1091
  }
374
1092
 
375
1093
  const handleOrbitCCW = () => {
376
- if (!controls.current) return
1094
+ if (isFirstPersonMode || !controls.current) return
377
1095
 
378
1096
  const currentAzimuth = controls.current.azimuthAngle
379
1097
  const currentPolar = controls.current.polarAngle
@@ -381,6 +1099,7 @@ export const CustomCameraControls = () => {
381
1099
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
382
1100
  const target = rounded + Math.PI / 2
383
1101
 
1102
+ clearPendingFloorplanNavigationPose()
384
1103
  controls.current.rotateTo(target, currentPolar, true)
385
1104
  }
386
1105
 
@@ -389,9 +1108,10 @@ export const CustomCameraControls = () => {
389
1108
  }
390
1109
 
391
1110
  const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
392
- if (!controls.current || isPreviewMode) return
1111
+ if (isFirstPersonMode || !controls.current || isPreviewMode) return
393
1112
  if (!bounds) {
394
1113
  // Restore default framing pose when no bounds were computed.
1114
+ clearPendingFloorplanNavigationPose()
395
1115
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
396
1116
  return
397
1117
  }
@@ -402,6 +1122,7 @@ export const CustomCameraControls = () => {
402
1122
  const maxExtent = Math.max(w, d)
403
1123
  const distance = Math.max(maxExtent * 1.4, 15)
404
1124
  const height = Math.max(maxExtent * 0.8, 10)
1125
+ clearPendingFloorplanNavigationPose()
405
1126
  controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
406
1127
  }
407
1128
 
@@ -422,7 +1143,7 @@ export const CustomCameraControls = () => {
422
1143
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
423
1144
  emitter.off('camera-controls:fit-scene', handleFitScene)
424
1145
  }
425
- }, [focusNode, isPreviewMode])
1146
+ }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
426
1147
 
427
1148
  const onTransitionStart = useCallback(() => {
428
1149
  useViewer.getState().setCameraDragging(true)
@@ -432,6 +1153,14 @@ export const CustomCameraControls = () => {
432
1153
  useViewer.getState().setCameraDragging(false)
433
1154
  }, [])
434
1155
 
1156
+ // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1157
+ // so the default 6m minDistance prevents the user from getting close
1158
+ // enough to compose a good thumbnail. Relax the clamp to 0.5m while
1159
+ // capturing presets; reset on exit so general editing keeps the looser
1160
+ // navigation guardrails.
1161
+ const isPresetCapture = captureMode.mode === 'preset'
1162
+ const minDistance = isPresetCapture ? 0.5 : 6
1163
+
435
1164
  if (isFirstPersonMode) {
436
1165
  return null
437
1166
  }
@@ -441,9 +1170,10 @@ export const CustomCameraControls = () => {
441
1170
  makeDefault
442
1171
  maxDistance={100}
443
1172
  maxPolarAngle={maxPolarAngle}
444
- minDistance={10}
1173
+ minDistance={minDistance}
445
1174
  minPolarAngle={0}
446
1175
  mouseButtons={mouseButtons}
1176
+ onUpdate={publishCurrentNavigationPose}
447
1177
  onRest={onRest}
448
1178
  onSleep={onRest}
449
1179
  onTransitionStart={onTransitionStart}