@pascal-app/editor 0.9.2 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNodeId,
5
5
  type CameraControlEvent,
6
6
  type CameraControlFitSceneEvent,
7
+ type CameraPose,
7
8
  emitter,
8
9
  sceneRegistry,
9
10
  useScene,
@@ -20,13 +21,25 @@ import {
20
21
  Spherical,
21
22
  Vector3,
22
23
  } from 'three'
24
+ import {
25
+ type CameraPoseApplicationPlan,
26
+ normalizeCameraPose,
27
+ planCameraPoseApplication,
28
+ publishInitialCameraPose,
29
+ releaseCameraPoseEventSuppression,
30
+ stepCameraPoseInterpolation,
31
+ withCameraPoseDistance,
32
+ } from '../../lib/camera-pose'
23
33
  import { EDITOR_LAYER } from '../../lib/constants'
34
+ import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
35
+ import { publishCameraPose } from '../../store/camera-pose-store'
24
36
  import useEditor from '../../store/use-editor'
25
37
  import {
26
38
  useActiveHandleDrag,
27
39
  useEndpointReshape,
28
40
  useMovingNode,
29
41
  } from '../../store/use-interaction-scope'
42
+ import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
30
43
 
31
44
  const currentTarget = new Vector3()
32
45
  const tempBox = new Box3()
@@ -35,14 +48,11 @@ const tempDelta = new Vector3()
35
48
  const tempPosition = new Vector3()
36
49
  const tempSize = new Vector3()
37
50
  const tempTarget = new Vector3()
38
- const syncTarget = new Vector3()
39
- const syncSpherical = new Spherical()
51
+ const transitionFreezePosition = new Vector3()
52
+ const transitionFreezeTarget = new Vector3()
40
53
  const keyboardPanSpherical = new Spherical()
41
54
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
42
55
  const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
43
- const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
44
- const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
45
- const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
46
56
  const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
47
57
  const KEYBOARD_PAN_MIN_SPEED = 2
48
58
  const KEYBOARD_PAN_MAX_SPEED = 55
@@ -52,14 +62,6 @@ type CameraPoseSnapshot = {
52
62
  position: [number, number, number]
53
63
  target: [number, number, number]
54
64
  }
55
- type NavigationCameraPoseSnapshot = {
56
- target: [number, number, number]
57
- azimuth: number
58
- viewWidth: number
59
- }
60
- type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
61
- publishOnComplete: boolean
62
- }
63
65
  type CameraViewWidthUpdate =
64
66
  | { type: 'distance'; distance: number; viewWidth: number }
65
67
  | { type: 'zoom'; viewWidth: number; zoom: number }
@@ -97,6 +99,21 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot
97
99
  )
98
100
  }
99
101
 
102
+ function freezeCameraControlTransition(control: CameraControlsImpl) {
103
+ // `stop()` snaps to the transition endpoint, so replace it with the current pose instead.
104
+ control.getPosition(transitionFreezePosition, false)
105
+ control.getTarget(transitionFreezeTarget, false)
106
+ void control.setLookAt(
107
+ transitionFreezePosition.x,
108
+ transitionFreezePosition.y,
109
+ transitionFreezePosition.z,
110
+ transitionFreezeTarget.x,
111
+ transitionFreezeTarget.y,
112
+ transitionFreezeTarget.z,
113
+ false,
114
+ )
115
+ }
116
+
100
117
  function isEditableKeyboardTarget(target: EventTarget | null) {
101
118
  return (
102
119
  target instanceof HTMLInputElement ||
@@ -141,6 +158,10 @@ function isKeyboardPanKey(code: string): boolean {
141
158
  return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
142
159
  }
143
160
 
161
+ function hasKeyboardPanInput(state: KeyboardPanState): boolean {
162
+ return state.forward || state.backward || state.left || state.right
163
+ }
164
+
144
165
  type CameraViewportSize = {
145
166
  width: number
146
167
  height: number
@@ -171,14 +192,6 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo
171
192
  return Math.max(0.001, distance)
172
193
  }
173
194
 
174
- function getAngleDeltaRadians(a: number, b: number) {
175
- return Math.atan2(Math.sin(a - b), Math.cos(a - b))
176
- }
177
-
178
- function nearestEquivalentRadians(angle: number, reference: number) {
179
- return reference + getAngleDeltaRadians(angle, reference)
180
- }
181
-
182
195
  function clampFinite(value: number, min: number, max: number) {
183
196
  const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
184
197
  const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
@@ -203,21 +216,6 @@ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
203
216
  )
204
217
  }
205
218
 
206
- function isCameraAtNavigationPose(
207
- pose: NavigationCameraPoseSnapshot,
208
- target: Vector3,
209
- azimuth: number,
210
- viewWidth: number,
211
- ) {
212
- return (
213
- Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
214
- Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
215
- Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
216
- Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
217
- Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
218
- )
219
- }
220
-
221
219
  function getCameraDistanceForViewWidth(
222
220
  camera: Camera,
223
221
  viewWidth: number,
@@ -272,14 +270,18 @@ function resolveCameraViewWidthUpdate(
272
270
  return { type: 'none', viewWidth }
273
271
  }
274
272
 
275
- function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
273
+ function applyCameraViewWidth(
274
+ control: CameraControlsImpl,
275
+ update: CameraViewWidthUpdate,
276
+ enableTransition = true,
277
+ ) {
276
278
  if (update.type === 'distance') {
277
- control.dollyTo(update.distance, true)
279
+ control.dollyTo(update.distance, enableTransition)
278
280
  return
279
281
  }
280
282
 
281
283
  if (update.type === 'zoom') {
282
- control.zoomTo(update.zoom, true)
284
+ control.zoomTo(update.zoom, enableTransition)
283
285
  }
284
286
  }
285
287
 
@@ -347,6 +349,13 @@ function useFirstPersonCameraPoseRestore(
347
349
 
348
350
  export const CustomCameraControls = () => {
349
351
  const controls = useRef<CameraControlsImpl | null>(null)
352
+ const pendingAppliedPose = useRef<CameraPoseApplicationPlan | null>(null)
353
+ const activePoseInterpolation = useRef<{
354
+ camera: Camera
355
+ control: CameraControlsImpl
356
+ plan: CameraPoseApplicationPlan
357
+ } | null>(null)
358
+ const suppressPoseEvents = useRef(false)
350
359
  const keyboardPanKeys = useRef<KeyboardPanState>({
351
360
  forward: false,
352
361
  backward: false,
@@ -356,7 +365,6 @@ export const CustomCameraControls = () => {
356
365
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
357
366
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
358
367
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
359
- const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
360
368
  const selection = useViewer((s) => s.selection)
361
369
  const cameraMode = useViewer((state) => state.cameraMode)
362
370
  const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
@@ -366,24 +374,21 @@ export const CustomCameraControls = () => {
366
374
  )
367
375
  const currentLevelId = selection.levelId
368
376
  const firstLoad = useRef(true)
369
- const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
370
- const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
371
- const lastApplied2dNavigationRevision = useRef(0)
372
- const savedSmoothTimeRef = useRef<number | null>(null)
373
377
  const maxPolarAngle =
374
378
  !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
375
- const clearPendingFloorplanNavigationPose = useCallback(() => {
376
- pendingFloorplanNavigationPose.current = null
377
- if (savedSmoothTimeRef.current !== null && controls.current) {
378
- controls.current.smoothTime = savedSmoothTimeRef.current
379
- savedSmoothTimeRef.current = null
380
- }
381
- }, [])
382
379
 
383
380
  const camera = useThree((state) => state.camera)
384
381
  const gl = useThree((state) => state.gl)
385
382
  const raycaster = useThree((state) => state.raycaster)
386
383
  const viewportSize = useThree((state) => state.size)
384
+ const cameraDraggingLifecycle = useMemo(
385
+ () =>
386
+ createCameraDraggingLifecycle({
387
+ setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging),
388
+ }),
389
+ [],
390
+ )
391
+ useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle])
387
392
  useEffect(() => {
388
393
  camera.layers.enable(EDITOR_LAYER)
389
394
  camera.layers.enable(GRID_LAYER)
@@ -391,6 +396,122 @@ export const CustomCameraControls = () => {
391
396
  raycaster.layers.enable(ZONE_LAYER)
392
397
  }, [camera, raycaster])
393
398
 
399
+ const freezeActivePoseInterpolation = useCallback(() => {
400
+ const active = activePoseInterpolation.current
401
+ if (!active) return
402
+
403
+ activePoseInterpolation.current = null
404
+ freezeCameraControlTransition(active.control)
405
+ }, [])
406
+
407
+ const cancelPoseApplication = useCallback(() => {
408
+ pendingAppliedPose.current = null
409
+ try {
410
+ freezeActivePoseInterpolation()
411
+ } finally {
412
+ suppressPoseEvents.current = false
413
+ }
414
+ }, [freezeActivePoseInterpolation])
415
+
416
+ const beginLocalCameraInteraction = useCallback(
417
+ ({ dragging = true }: { dragging?: boolean } = {}) => {
418
+ cancelPoseApplication()
419
+ if (dragging) cameraDraggingLifecycle.begin()
420
+ emitter.emit('camera-controls:interaction-start', undefined)
421
+ },
422
+ [cameraDraggingLifecycle, cancelPoseApplication],
423
+ )
424
+
425
+ const applyPendingPose = useCallback(() => {
426
+ if (isFirstPersonMode) {
427
+ cancelPoseApplication()
428
+ return
429
+ }
430
+
431
+ const plan = pendingAppliedPose.current
432
+ const control = controls.current
433
+
434
+ const active = activePoseInterpolation.current
435
+ if (active && (active.camera !== camera || active.control !== control)) {
436
+ try {
437
+ freezeActivePoseInterpolation()
438
+ } finally {
439
+ if (!plan) suppressPoseEvents.current = false
440
+ }
441
+ }
442
+
443
+ if (!(plan && control)) return
444
+
445
+ const cameraMatchesProjection =
446
+ (plan.pose.projection === 'perspective' && isPerspectiveCamera(camera)) ||
447
+ (plan.pose.projection === 'orthographic' && isOrthographicCamera(camera))
448
+ if (!cameraMatchesProjection) return
449
+
450
+ pendingAppliedPose.current = null
451
+ if (plan.perspectiveFov !== null && isPerspectiveCamera(camera)) {
452
+ camera.fov = plan.perspectiveFov
453
+ camera.updateProjectionMatrix()
454
+ }
455
+ let appliedPlan = plan
456
+ if (plan.pose.viewWidth !== undefined) {
457
+ const viewWidthUpdate = resolveCameraViewWidthUpdate(
458
+ control,
459
+ camera,
460
+ plan.pose.viewWidth,
461
+ viewportSize,
462
+ )
463
+ if (viewWidthUpdate.type === 'distance') {
464
+ appliedPlan = {
465
+ ...plan,
466
+ pose: withCameraPoseDistance(plan.pose, viewWidthUpdate.distance),
467
+ }
468
+ } else if (viewWidthUpdate.type === 'zoom') {
469
+ applyCameraViewWidth(control, viewWidthUpdate, false)
470
+ }
471
+ }
472
+
473
+ activePoseInterpolation.current = { camera, control, plan: appliedPlan }
474
+ }, [
475
+ camera,
476
+ cancelPoseApplication,
477
+ freezeActivePoseInterpolation,
478
+ isFirstPersonMode,
479
+ viewportSize,
480
+ ])
481
+
482
+ useEffect(() => {
483
+ applyPendingPose()
484
+ }, [applyPendingPose])
485
+
486
+ useEffect(() => {
487
+ const handleAppliedPose = (pose: CameraPose) => {
488
+ if (isFirstPersonMode) return
489
+
490
+ const plan = planCameraPoseApplication(pose)
491
+ if (!plan) return
492
+
493
+ pendingAppliedPose.current = plan
494
+ suppressPoseEvents.current = true
495
+
496
+ if (useViewer.getState().cameraMode !== plan.pose.projection) {
497
+ freezeActivePoseInterpolation()
498
+ useViewer.getState().setCameraMode(plan.pose.projection)
499
+ return
500
+ }
501
+
502
+ applyPendingPose()
503
+ }
504
+
505
+ emitter.on('camera-controls:apply-pose', handleAppliedPose)
506
+ emitter.on('camera-controls:cancel-pose', cancelPoseApplication)
507
+ return () => {
508
+ emitter.off('camera-controls:apply-pose', handleAppliedPose)
509
+ emitter.off('camera-controls:cancel-pose', cancelPoseApplication)
510
+ }
511
+ }, [applyPendingPose, cancelPoseApplication, freezeActivePoseInterpolation, isFirstPersonMode])
512
+
513
+ useEffect(() => cancelPoseApplication, [cancelPoseApplication])
514
+
394
515
  useEffect(() => {
395
516
  // Dev-only: deterministic camera poses for screenshot/automation tooling.
396
517
  // A getter, not a snapshot — drei recreates the impl when the default
@@ -417,19 +538,11 @@ export const CustomCameraControls = () => {
417
538
  if (!controls.current) return
418
539
  if (firstLoad.current) {
419
540
  firstLoad.current = false
420
- clearPendingFloorplanNavigationPose()
421
541
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
422
542
  }
423
543
  controls.current.getTarget(currentTarget)
424
- clearPendingFloorplanNavigationPose()
425
544
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
426
- }, [
427
- clearPendingFloorplanNavigationPose,
428
- currentLevelId,
429
- isPreviewMode,
430
- isFirstPersonMode,
431
- isRestoringFirstPersonPose,
432
- ])
545
+ }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
433
546
 
434
547
  useEffect(() => {
435
548
  if (isFirstPersonMode || !controls.current) return
@@ -457,7 +570,6 @@ export const CustomCameraControls = () => {
457
570
  controls.current.getTarget(tempTarget)
458
571
  tempDelta.copy(tempCenter).sub(tempTarget)
459
572
 
460
- clearPendingFloorplanNavigationPose()
461
573
  controls.current.setLookAt(
462
574
  tempPosition.x + tempDelta.x,
463
575
  tempPosition.y + tempDelta.y,
@@ -468,122 +580,88 @@ export const CustomCameraControls = () => {
468
580
  true,
469
581
  )
470
582
  },
471
- [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
583
+ [isPreviewMode, isFirstPersonMode],
472
584
  )
473
585
 
474
- useEffect(() => {
475
- if (isFirstPersonMode) return
476
-
477
- return useEditor.subscribe((state) => {
478
- const pose = state.navigationSyncPose
479
- if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
480
-
481
- const control = controls.current
482
- if (!control) return
483
-
484
- lastApplied2dNavigationRevision.current = pose.revision
485
- const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
486
- const viewWidthUpdate = resolveCameraViewWidthUpdate(
487
- control,
488
- camera,
489
- pose.viewWidth,
490
- viewportSize,
491
- )
492
- pendingFloorplanNavigationPose.current = {
493
- target: [...pose.target],
494
- azimuth: targetAzimuth,
495
- viewWidth: viewWidthUpdate.viewWidth,
496
- publishOnComplete:
497
- Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
498
- NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
499
- }
500
- // Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
501
- // effective time constant is smoothTime/2, so smoothTime=0.18 gives
502
- // τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
503
- if (savedSmoothTimeRef.current === null) {
504
- savedSmoothTimeRef.current = control.smoothTime
505
- }
506
- control.smoothTime = 0.18
507
- control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
508
- control.rotateTo(targetAzimuth, control.polarAngle, true)
509
- applyCameraViewWidth(control, viewWidthUpdate)
586
+ const publishCurrentPose = useCallback(() => {
587
+ if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
588
+
589
+ controls.current.getPosition(tempPosition, false)
590
+ controls.current.getTarget(tempTarget, false)
591
+ const targetDistance = tempPosition.distanceTo(tempTarget)
592
+ const projection = isPerspectiveCamera(camera)
593
+ ? 'perspective'
594
+ : isOrthographicCamera(camera)
595
+ ? 'orthographic'
596
+ : null
597
+ if (!projection) return
598
+
599
+ const pose = normalizeCameraPose({
600
+ position: [tempPosition.x, tempPosition.y, tempPosition.z],
601
+ target: [tempTarget.x, tempTarget.y, tempTarget.z],
602
+ projection,
603
+ viewWidth: getCameraViewWidth(camera, targetDistance, viewportSize),
604
+ ...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
510
605
  })
511
- }, [camera, isFirstPersonMode, viewportSize])
512
-
513
- const publishCurrentNavigationPose = useCallback(() => {
514
- if (isFirstPersonMode || !controls.current) return
515
-
516
- controls.current.getTarget(syncTarget, false)
517
- controls.current.getSpherical(syncSpherical, false)
518
- const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
519
-
520
- const pendingFloorplanPose = pendingFloorplanNavigationPose.current
521
- if (pendingFloorplanPose) {
522
- // The camera is still damping toward a 2D-originated pose; do not echo
523
- // intermediate 3D poses back into the floorplan.
524
- if (
525
- isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
526
- ) {
527
- lastPublishedNavigationSync.current = pendingFloorplanPose
528
- clearPendingFloorplanNavigationPose()
529
- if (pendingFloorplanPose.publishOnComplete) {
530
- useEditor.getState().publishNavigationSyncPose({
531
- source: '3d',
532
- target: [
533
- pendingFloorplanPose.target[0],
534
- pendingFloorplanPose.target[1],
535
- pendingFloorplanPose.target[2],
536
- ],
537
- azimuth: pendingFloorplanPose.azimuth,
538
- viewWidth: pendingFloorplanPose.viewWidth,
539
- })
540
- }
541
- }
542
- return
543
- }
544
-
545
- const previous = lastPublishedNavigationSync.current
546
- if (
547
- previous &&
548
- Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
549
- Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
550
- Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
551
- Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
552
- NAVIGATION_SYNC_AZIMUTH_EPSILON &&
553
- Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
554
- ) {
555
- return
606
+ if (pose) {
607
+ publishCameraPose(pose)
556
608
  }
609
+ }, [camera, isFirstPersonMode, viewportSize])
557
610
 
558
- lastPublishedNavigationSync.current = {
559
- target: [syncTarget.x, syncTarget.y, syncTarget.z],
560
- azimuth: syncSpherical.theta,
561
- viewWidth,
562
- }
563
- useEditor.getState().publishNavigationSyncPose({
564
- source: '3d',
565
- target: [syncTarget.x, syncTarget.y, syncTarget.z],
566
- azimuth: syncSpherical.theta,
567
- viewWidth,
568
- })
569
- }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
611
+ const handleCameraUpdate = useCallback(() => {
612
+ publishCurrentPose()
613
+ }, [publishCurrentPose])
570
614
 
571
615
  useEffect(() => {
572
- if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
573
-
574
- const frame = requestAnimationFrame(() => {
575
- lastPublishedNavigationSync.current = null
576
- publishCurrentNavigationPose()
577
- })
578
-
579
- return () => {
580
- cancelAnimationFrame(frame)
581
- }
582
- }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
616
+ publishInitialCameraPose(publishCurrentPose)
617
+ }, [publishCurrentPose])
583
618
 
584
619
  useFrame((_, delta) => {
585
620
  if (isFirstPersonMode || !controls.current) return
586
621
 
622
+ const activePose = activePoseInterpolation.current
623
+ if (activePose) {
624
+ const control = controls.current
625
+ if (activePose.camera !== camera || activePose.control !== control) {
626
+ try {
627
+ freezeActivePoseInterpolation()
628
+ } finally {
629
+ if (!pendingAppliedPose.current) suppressPoseEvents.current = false
630
+ }
631
+ } else {
632
+ control.getPosition(tempPosition, false)
633
+ control.getTarget(tempTarget, false)
634
+ const step = stepCameraPoseInterpolation(
635
+ [tempPosition.x, tempPosition.y, tempPosition.z],
636
+ [tempTarget.x, tempTarget.y, tempTarget.z],
637
+ activePose.plan.pose,
638
+ delta,
639
+ )
640
+ try {
641
+ // Transition-enabled calls retain one rest listener each, so this frame loop owns smoothing.
642
+ void control.setLookAt(
643
+ step.position[0],
644
+ step.position[1],
645
+ step.position[2],
646
+ step.target[0],
647
+ step.target[1],
648
+ step.target[2],
649
+ false,
650
+ )
651
+ control.update(0)
652
+ } catch {
653
+ if (activePoseInterpolation.current === activePose) {
654
+ activePoseInterpolation.current = null
655
+ releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
656
+ }
657
+ }
658
+ if (step.settled && activePoseInterpolation.current === activePose) {
659
+ activePoseInterpolation.current = null
660
+ releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
661
+ }
662
+ }
663
+ }
664
+
587
665
  const panKeys = keyboardPanKeys.current
588
666
  const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
589
667
  const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
@@ -599,10 +677,9 @@ export const CustomCameraControls = () => {
599
677
  )
600
678
  const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
601
679
 
602
- clearPendingFloorplanNavigationPose()
603
680
  if (horizontal !== 0) control.truck(horizontal * step, 0, true)
604
681
  if (vertical !== 0) control.forward(vertical * step, true)
605
- })
682
+ }, 0)
606
683
 
607
684
  // Configure mouse buttons based on control mode and camera mode
608
685
  const mouseButtons = useMemo(() => {
@@ -622,11 +699,11 @@ export const CustomCameraControls = () => {
622
699
 
623
700
  // Touch gestures (mobile / trackpad).
624
701
  // - One finger drag → rotate by default (much easier on a phone), but
625
- // falls back to NONE while the user is actively
626
- // placing/moving something OR in box-select mode,
627
- // so the editor's pointer handlers (place tool,
628
- // drag-to-move endpoint, marquee selection drag)
629
- // keep priority over the camera.
702
+ // falls back to NONE while the editor owns the drag —
703
+ // placing/moving something, box-select, or a brush mode
704
+ // so the editor's pointer handlers (place tool,
705
+ // drag-to-move endpoint, marquee selection drag, paint
706
+ // and sculpt strokes) keep priority over the camera.
630
707
  // In preview mode it's TOUCH_TRUCK (pan), matching
631
708
  // preview's left = SCREEN_PAN.
632
709
  // - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
@@ -641,9 +718,16 @@ export const CustomCameraControls = () => {
641
718
  const endpointReshape = useEndpointReshape()
642
719
  const activeHandleDrag = useActiveHandleDrag()
643
720
  const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
644
- const isInteracting = Boolean(
645
- tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
646
- )
721
+ // The mode term lives in `editorOwnsOneFingerDrag` rather than in this OR: a
722
+ // brush mode owns the drag the way an armed tool does, but none of the
723
+ // transient terms notice it (entering paint or sculpt *clears* `tool`, and its
724
+ // scope is `painting`/`sculpting`, not `handle-drag`).
725
+ const isInteracting = editorOwnsOneFingerDrag({
726
+ mode,
727
+ activeGesture: Boolean(
728
+ tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
729
+ ),
730
+ })
647
731
  const touches = useMemo(() => {
648
732
  const twoFingerAction =
649
733
  cameraMode === 'orthographic'
@@ -750,8 +834,8 @@ export const CustomCameraControls = () => {
750
834
  !(event.metaKey || event.ctrlKey || event.altKey) &&
751
835
  !isEditableKeyboardTarget(event.target)
752
836
  ) {
753
- setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
754
- clearPendingFloorplanNavigationPose()
837
+ const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
838
+ if (changed) beginLocalCameraInteraction()
755
839
  event.preventDefault()
756
840
  event.stopPropagation()
757
841
  }
@@ -783,6 +867,9 @@ export const CustomCameraControls = () => {
783
867
  if (isKeyboardPanKey(event.code)) {
784
868
  const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
785
869
  if (changed) {
870
+ if (!hasKeyboardPanInput(keyboardPanKeys.current)) {
871
+ cameraDraggingLifecycle.end()
872
+ }
786
873
  event.preventDefault()
787
874
  event.stopPropagation()
788
875
  }
@@ -814,7 +901,6 @@ export const CustomCameraControls = () => {
814
901
 
815
902
  const onPointerDown = (event: PointerEvent) => {
816
903
  if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
817
- clearPendingFloorplanNavigationPose()
818
904
  if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
819
905
 
820
906
  panPointerId = event.pointerId
@@ -823,7 +909,8 @@ export const CustomCameraControls = () => {
823
909
  }
824
910
 
825
911
  const onWheel = () => {
826
- clearPendingFloorplanNavigationPose()
912
+ beginLocalCameraInteraction()
913
+ cameraDraggingLifecycle.scheduleEnd()
827
914
  }
828
915
 
829
916
  const onPointerUp = (event: PointerEvent) => {
@@ -842,6 +929,7 @@ export const CustomCameraControls = () => {
842
929
  panPointerId = null
843
930
  panPointerButton = null
844
931
  clearNavigationCursor()
932
+ cameraDraggingLifecycle.end()
845
933
  updateConfig()
846
934
  }
847
935
 
@@ -851,7 +939,7 @@ export const CustomCameraControls = () => {
851
939
  window.addEventListener('pointerup', onPointerUp, true)
852
940
  window.addEventListener('pointercancel', onPointerUp, true)
853
941
  window.addEventListener('blur', onBlur)
854
- gl.domElement.addEventListener('wheel', onWheel, { passive: true })
942
+ gl.domElement.addEventListener('wheel', onWheel, { capture: true, passive: true })
855
943
  updateConfig()
856
944
 
857
945
  return () => {
@@ -861,29 +949,30 @@ export const CustomCameraControls = () => {
861
949
  window.removeEventListener('pointerup', onPointerUp, true)
862
950
  window.removeEventListener('pointercancel', onPointerUp, true)
863
951
  window.removeEventListener('blur', onBlur)
864
- gl.domElement.removeEventListener('wheel', onWheel)
952
+ gl.domElement.removeEventListener('wheel', onWheel, true)
865
953
  clearKeyboardPanKeys()
866
954
  clearNavigationCursor()
955
+ cameraDraggingLifecycle.end()
867
956
  }
868
- }, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
869
-
870
- // Cancel any in-progress 2D-origin navigation pose when the user starts
871
- // dragging (right-click orbit, middle-click pan, touch). `controlstart`
872
- // fires only for user pointer interactions — not for programmatic
873
- // moveTo/rotateTo which emit `transitionstart` instead.
874
- useEffect(() => {
875
- if (isFirstPersonMode) return
876
- const control = controls.current
877
- if (!control) return
957
+ }, [
958
+ beginLocalCameraInteraction,
959
+ cameraDraggingLifecycle,
960
+ cameraMode,
961
+ gl,
962
+ isPreviewMode,
963
+ isFirstPersonMode,
964
+ ])
878
965
 
879
- const onControlStart = () => {
880
- clearPendingFloorplanNavigationPose()
881
- }
882
- control.addEventListener('controlstart', onControlStart)
883
- return () => {
884
- control.removeEventListener('controlstart', onControlStart)
885
- }
886
- }, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
966
+ // `controlstart` fires only for user pointer interactions. Pointerdowns
967
+ // mapped to ACTION.NONE must not flag the camera as dragging because no
968
+ // rest/sleep event follows to clear the flag.
969
+ const handleControlStart = useCallback(() => {
970
+ beginLocalCameraInteraction({
971
+ dragging: controls.current
972
+ ? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
973
+ : false,
974
+ })
975
+ }, [beginLocalCameraInteraction])
887
976
 
888
977
  // Preview mode: auto-navigate camera to selected node (viewer behavior)
889
978
  const previewTargetNodeId = isPreviewMode
@@ -1090,7 +1179,6 @@ export const CustomCameraControls = () => {
1090
1179
  if (!node?.camera) return
1091
1180
  const { position, target } = node.camera
1092
1181
 
1093
- clearPendingFloorplanNavigationPose()
1094
1182
  controls.current.setLookAt(
1095
1183
  position[0],
1096
1184
  position[1],
@@ -1111,7 +1199,6 @@ export const CustomCameraControls = () => {
1111
1199
  // Otherwise, go to top view (0°)
1112
1200
  const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
1113
1201
 
1114
- clearPendingFloorplanNavigationPose()
1115
1202
  controls.current.rotatePolarTo(targetAngle, true)
1116
1203
  }
1117
1204
 
@@ -1124,7 +1211,6 @@ export const CustomCameraControls = () => {
1124
1211
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1125
1212
  const target = rounded - Math.PI / 2
1126
1213
 
1127
- clearPendingFloorplanNavigationPose()
1128
1214
  controls.current.rotateTo(target, currentPolar, true)
1129
1215
  }
1130
1216
 
@@ -1137,7 +1223,6 @@ export const CustomCameraControls = () => {
1137
1223
  const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
1138
1224
  const target = rounded + Math.PI / 2
1139
1225
 
1140
- clearPendingFloorplanNavigationPose()
1141
1226
  controls.current.rotateTo(target, currentPolar, true)
1142
1227
  }
1143
1228
 
@@ -1149,7 +1234,6 @@ export const CustomCameraControls = () => {
1149
1234
  if (isFirstPersonMode || !controls.current || isPreviewMode) return
1150
1235
  if (!bounds) {
1151
1236
  // Restore default framing pose when no bounds were computed.
1152
- clearPendingFloorplanNavigationPose()
1153
1237
  controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
1154
1238
  return
1155
1239
  }
@@ -1160,7 +1244,6 @@ export const CustomCameraControls = () => {
1160
1244
  const maxExtent = Math.max(w, d)
1161
1245
  const distance = Math.max(maxExtent * 1.4, 15)
1162
1246
  const height = Math.max(maxExtent * 0.8, 10)
1163
- clearPendingFloorplanNavigationPose()
1164
1247
  controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
1165
1248
  }
1166
1249
 
@@ -1181,15 +1264,25 @@ export const CustomCameraControls = () => {
1181
1264
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
1182
1265
  emitter.off('camera-controls:fit-scene', handleFitScene)
1183
1266
  }
1184
- }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
1267
+ }, [focusNode, isPreviewMode, isFirstPersonMode])
1185
1268
 
1186
1269
  const onTransitionStart = useCallback(() => {
1187
- useViewer.getState().setCameraDragging(true)
1188
- }, [])
1270
+ cameraDraggingLifecycle.begin()
1271
+ }, [cameraDraggingLifecycle])
1189
1272
 
1190
1273
  const onRest = useCallback(() => {
1191
- useViewer.getState().setCameraDragging(false)
1192
- }, [])
1274
+ cameraDraggingLifecycle.end()
1275
+ }, [cameraDraggingLifecycle])
1276
+
1277
+ const onControlEnd = useCallback(() => {
1278
+ // A mapped-button tap with zero camera movement never wakes the
1279
+ // controls, so no rest/sleep follows — clear the dragging flag on
1280
+ // release. While damping is still settling (`active`), rest/sleep
1281
+ // clears it instead.
1282
+ if (!controls.current?.active) {
1283
+ cameraDraggingLifecycle.end()
1284
+ }
1285
+ }, [cameraDraggingLifecycle])
1193
1286
 
1194
1287
  // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1195
1288
  // so the default 2m minDistance prevents the user from getting close
@@ -1211,7 +1304,9 @@ export const CustomCameraControls = () => {
1211
1304
  minDistance={minDistance}
1212
1305
  minPolarAngle={0}
1213
1306
  mouseButtons={mouseButtons}
1214
- onUpdate={publishCurrentNavigationPose}
1307
+ onControlEnd={onControlEnd}
1308
+ onControlStart={handleControlStart}
1309
+ onUpdate={handleCameraUpdate}
1215
1310
  onRest={onRest}
1216
1311
  onSleep={onRest}
1217
1312
  onTransitionStart={onTransitionStart}