@pascal-app/editor 0.8.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  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 +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  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 +5829 -12005
  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 +178 -149
  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 +433 -156
  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 +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  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-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,17 +1,25 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNodeId,
4
5
  type CameraControlEvent,
5
6
  type CameraControlFitSceneEvent,
6
7
  emitter,
7
8
  sceneRegistry,
8
9
  useScene,
9
10
  } from '@pascal-app/core'
10
- import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
11
+ import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
11
12
  import { CameraControls, CameraControlsImpl } from '@react-three/drei'
12
- import { useThree } from '@react-three/fiber'
13
+ import { useFrame, useThree } from '@react-three/fiber'
13
14
  import { useCallback, useEffect, useMemo, useRef } from 'react'
14
- 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'
15
23
  import { EDITOR_LAYER } from '../../lib/constants'
16
24
  import useEditor from '../../store/use-editor'
17
25
 
@@ -22,30 +30,359 @@ const tempDelta = new Vector3()
22
30
  const tempPosition = new Vector3()
23
31
  const tempSize = new Vector3()
24
32
  const tempTarget = new Vector3()
33
+ const syncTarget = new Vector3()
34
+ const syncSpherical = new Spherical()
35
+ const keyboardPanSpherical = new Spherical()
25
36
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
26
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
+ }
27
342
 
28
343
  export const CustomCameraControls = () => {
29
- 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
+ })
30
351
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
31
352
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
32
353
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
354
+ const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
33
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
+ )
34
362
  const currentLevelId = selection.levelId
35
363
  const firstLoad = useRef(true)
364
+ const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
365
+ const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
366
+ const lastApplied2dNavigationRevision = useRef(0)
36
367
  const maxPolarAngle =
37
368
  !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
369
+ const clearPendingFloorplanNavigationPose = useCallback(() => {
370
+ pendingFloorplanNavigationPose.current = null
371
+ }, [])
38
372
 
39
373
  const camera = useThree((state) => state.camera)
374
+ const gl = useThree((state) => state.gl)
40
375
  const raycaster = useThree((state) => state.raycaster)
376
+ const viewportSize = useThree((state) => state.size)
41
377
  useEffect(() => {
42
378
  camera.layers.enable(EDITOR_LAYER)
379
+ camera.layers.enable(GRID_LAYER)
43
380
  raycaster.layers.enable(EDITOR_LAYER)
44
381
  raycaster.layers.enable(ZONE_LAYER)
45
382
  }, [camera, raycaster])
46
383
 
47
384
  useEffect(() => {
48
- if (isPreviewMode) return // Preview mode uses auto-navigate instead
385
+ if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
49
386
  let targetY = 0
50
387
  if (currentLevelId) {
51
388
  const levelMesh = sceneRegistry.nodes.get(currentLevelId)
@@ -56,14 +393,22 @@ export const CustomCameraControls = () => {
56
393
  if (!controls.current) return
57
394
  if (firstLoad.current) {
58
395
  firstLoad.current = false
396
+ clearPendingFloorplanNavigationPose()
59
397
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
60
398
  }
61
399
  controls.current.getTarget(currentTarget)
400
+ clearPendingFloorplanNavigationPose()
62
401
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
63
- }, [currentLevelId, isPreviewMode])
402
+ }, [
403
+ clearPendingFloorplanNavigationPose,
404
+ currentLevelId,
405
+ isPreviewMode,
406
+ isFirstPersonMode,
407
+ isRestoringFirstPersonPose,
408
+ ])
64
409
 
65
410
  useEffect(() => {
66
- if (!controls.current) return
411
+ if (isFirstPersonMode || !controls.current) return
67
412
 
68
413
  controls.current.maxPolarAngle = maxPolarAngle
69
414
  controls.current.minPolarAngle = 0
@@ -71,11 +416,11 @@ export const CustomCameraControls = () => {
71
416
  if (controls.current.polarAngle > maxPolarAngle) {
72
417
  controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
73
418
  }
74
- }, [maxPolarAngle])
419
+ }, [isFirstPersonMode, maxPolarAngle])
75
420
 
76
421
  const focusNode = useCallback(
77
422
  (nodeId: string) => {
78
- if (isPreviewMode || !controls.current) return
423
+ if (isPreviewMode || isFirstPersonMode || !controls.current) return
79
424
 
80
425
  const object3D = sceneRegistry.nodes.get(nodeId)
81
426
  if (!object3D) return
@@ -88,6 +433,7 @@ export const CustomCameraControls = () => {
88
433
  controls.current.getTarget(tempTarget)
89
434
  tempDelta.copy(tempCenter).sub(tempTarget)
90
435
 
436
+ clearPendingFloorplanNavigationPose()
91
437
  controls.current.setLookAt(
92
438
  tempPosition.x + tempDelta.x,
93
439
  tempPosition.y + tempDelta.y,
@@ -98,11 +444,141 @@ export const CustomCameraControls = () => {
98
444
  true,
99
445
  )
100
446
  },
101
- [isPreviewMode],
447
+ [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
102
448
  )
103
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
+
104
581
  // Configure mouse buttons based on control mode and camera mode
105
- const cameraMode = useViewer((state) => state.cameraMode)
106
582
  const mouseButtons = useMemo(() => {
107
583
  // Use ZOOM for orthographic camera, DOLLY for perspective camera
108
584
  const wheelAction =
@@ -138,9 +614,15 @@ export const CustomCameraControls = () => {
138
614
  const movingNode = useEditor((s) => s.movingNode)
139
615
  const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
140
616
  const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
617
+ const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
141
618
  const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
142
619
  const isInteracting = Boolean(
143
- tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
620
+ tool ||
621
+ movingNode ||
622
+ movingWallEndpoint ||
623
+ movingFenceEndpoint ||
624
+ activeHandleDrag ||
625
+ isBoxSelectActive,
144
626
  )
145
627
  const touches = useMemo(() => {
146
628
  const twoFingerAction =
@@ -162,6 +644,8 @@ export const CustomCameraControls = () => {
162
644
  }, [cameraMode, isPreviewMode, isInteracting])
163
645
 
164
646
  useEffect(() => {
647
+ if (isFirstPersonMode) return
648
+
165
649
  const keyState = {
166
650
  shiftRight: false,
167
651
  shiftLeft: false,
@@ -169,6 +653,52 @@ export const CustomCameraControls = () => {
169
653
  controlLeft: false,
170
654
  space: false,
171
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
+ }
172
702
 
173
703
  const updateConfig = () => {
174
704
  if (!controls.current) return
@@ -195,9 +725,24 @@ export const CustomCameraControls = () => {
195
725
  }
196
726
 
197
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
+
198
741
  if (event.code === 'Space') {
742
+ if (isEditableKeyboardTarget(event.target)) return
743
+ event.preventDefault()
199
744
  keyState.space = true
200
- document.body.style.cursor = 'grab'
745
+ updateNavigationCursor()
201
746
  }
202
747
  if (event.code === 'ShiftRight') {
203
748
  keyState.shiftRight = true
@@ -215,9 +760,22 @@ export const CustomCameraControls = () => {
215
760
  }
216
761
 
217
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
+
218
772
  if (event.code === 'Space') {
219
773
  keyState.space = false
220
- document.body.style.cursor = ''
774
+ if (panPointerButton === 0) {
775
+ panPointerId = null
776
+ panPointerButton = null
777
+ }
778
+ updateNavigationCursor()
221
779
  }
222
780
  if (event.code === 'ShiftRight') {
223
781
  keyState.shiftRight = false
@@ -234,15 +792,60 @@ export const CustomCameraControls = () => {
234
792
  updateConfig()
235
793
  }
236
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
+
237
828
  document.addEventListener('keydown', onKeyDown)
238
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 })
239
835
  updateConfig()
240
836
 
241
837
  return () => {
242
838
  document.removeEventListener('keydown', onKeyDown)
243
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()
244
847
  }
245
- }, [cameraMode, isPreviewMode])
848
+ }, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
246
849
 
247
850
  // Preview mode: auto-navigate camera to selected node (viewer behavior)
248
851
  const previewTargetNodeId = isPreviewMode
@@ -250,7 +853,7 @@ export const CustomCameraControls = () => {
250
853
  : null
251
854
 
252
855
  useEffect(() => {
253
- if (!(isPreviewMode && controls.current)) return
856
+ if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
254
857
 
255
858
  const nodes = useScene.getState().nodes
256
859
  let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
@@ -310,11 +913,122 @@ export const CustomCameraControls = () => {
310
913
  tempCenter.z,
311
914
  true,
312
915
  )
313
- }, [isPreviewMode, previewTargetNodeId])
916
+ }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
314
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)
315
926
  useEffect(() => {
316
- 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).
317
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
318
1032
 
319
1033
  const position = new Vector3()
320
1034
  const target = new Vector3()
@@ -332,12 +1046,13 @@ export const CustomCameraControls = () => {
332
1046
  })
333
1047
  }
334
1048
  const handleNodeView = ({ nodeId }: CameraControlEvent) => {
335
- if (!controls.current) return
1049
+ if (isFirstPersonMode || !controls.current) return
336
1050
 
337
1051
  const node = useScene.getState().nodes[nodeId]
338
1052
  if (!node?.camera) return
339
1053
  const { position, target } = node.camera
340
1054
 
1055
+ clearPendingFloorplanNavigationPose()
341
1056
  controls.current.setLookAt(
342
1057
  position[0],
343
1058
  position[1],
@@ -350,7 +1065,7 @@ export const CustomCameraControls = () => {
350
1065
  }
351
1066
 
352
1067
  const handleTopView = () => {
353
- if (!controls.current) return
1068
+ if (isFirstPersonMode || !controls.current) return
354
1069
 
355
1070
  const currentPolarAngle = controls.current.polarAngle
356
1071
 
@@ -358,11 +1073,12 @@ export const CustomCameraControls = () => {
358
1073
  // Otherwise, go to top view (0°)
359
1074
  const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
360
1075
 
1076
+ clearPendingFloorplanNavigationPose()
361
1077
  controls.current.rotatePolarTo(targetAngle, true)
362
1078
  }
363
1079
 
364
1080
  const handleOrbitCW = () => {
365
- if (!controls.current) return
1081
+ if (isFirstPersonMode || !controls.current) return
366
1082
 
367
1083
  const currentAzimuth = controls.current.azimuthAngle
368
1084
  const currentPolar = controls.current.polarAngle
@@ -370,11 +1086,12 @@ export const CustomCameraControls = () => {
370
1086
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
371
1087
  const target = rounded - Math.PI / 2
372
1088
 
1089
+ clearPendingFloorplanNavigationPose()
373
1090
  controls.current.rotateTo(target, currentPolar, true)
374
1091
  }
375
1092
 
376
1093
  const handleOrbitCCW = () => {
377
- if (!controls.current) return
1094
+ if (isFirstPersonMode || !controls.current) return
378
1095
 
379
1096
  const currentAzimuth = controls.current.azimuthAngle
380
1097
  const currentPolar = controls.current.polarAngle
@@ -382,6 +1099,7 @@ export const CustomCameraControls = () => {
382
1099
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
383
1100
  const target = rounded + Math.PI / 2
384
1101
 
1102
+ clearPendingFloorplanNavigationPose()
385
1103
  controls.current.rotateTo(target, currentPolar, true)
386
1104
  }
387
1105
 
@@ -390,9 +1108,10 @@ export const CustomCameraControls = () => {
390
1108
  }
391
1109
 
392
1110
  const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
393
- if (!controls.current || isPreviewMode) return
1111
+ if (isFirstPersonMode || !controls.current || isPreviewMode) return
394
1112
  if (!bounds) {
395
1113
  // Restore default framing pose when no bounds were computed.
1114
+ clearPendingFloorplanNavigationPose()
396
1115
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
397
1116
  return
398
1117
  }
@@ -403,6 +1122,7 @@ export const CustomCameraControls = () => {
403
1122
  const maxExtent = Math.max(w, d)
404
1123
  const distance = Math.max(maxExtent * 1.4, 15)
405
1124
  const height = Math.max(maxExtent * 0.8, 10)
1125
+ clearPendingFloorplanNavigationPose()
406
1126
  controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
407
1127
  }
408
1128
 
@@ -423,7 +1143,7 @@ export const CustomCameraControls = () => {
423
1143
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
424
1144
  emitter.off('camera-controls:fit-scene', handleFitScene)
425
1145
  }
426
- }, [focusNode, isPreviewMode])
1146
+ }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
427
1147
 
428
1148
  const onTransitionStart = useCallback(() => {
429
1149
  useViewer.getState().setCameraDragging(true)
@@ -433,6 +1153,14 @@ export const CustomCameraControls = () => {
433
1153
  useViewer.getState().setCameraDragging(false)
434
1154
  }, [])
435
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
+
436
1164
  if (isFirstPersonMode) {
437
1165
  return null
438
1166
  }
@@ -442,9 +1170,10 @@ export const CustomCameraControls = () => {
442
1170
  makeDefault
443
1171
  maxDistance={100}
444
1172
  maxPolarAngle={maxPolarAngle}
445
- minDistance={10}
1173
+ minDistance={minDistance}
446
1174
  minPolarAngle={0}
447
1175
  mouseButtons={mouseButtons}
1176
+ onUpdate={publishCurrentNavigationPose}
448
1177
  onRest={onRest}
449
1178
  onSleep={onRest}
450
1179
  onTransitionStart={onTransitionStart}