From d4f1053528247468d10f46ba27a845edd2bbf61a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 9 Jul 2026 13:23:09 +0200 Subject: [PATCH] fix(editor): compass sync review fixes Route all pending-pose cancellations through clearPendingFloorplanNavigationPose() so savedSmoothTime is always restored. Add a controlstart listener to catch right-click orbit and other pointer drags that weren't handled. Guard the rotation-degree ref mirror to prevent React re-renders from clobbering the imperative 3D-owned value when the panel is hidden, and add a useLayoutEffect to keep the needle DOM in sync. Make the catch-up effect re-run directly on panel reopen. Co-Authored-By: Claude Fable 5 --- .../editor/custom-camera-controls.tsx | 36 +++++++++++++------ .../src/components/editor/floorplan-panel.tsx | 23 +++++++++--- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index d88cebcb..3fdec0ed 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -511,11 +511,7 @@ export const CustomCameraControls = () => { isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth) ) { lastPublishedNavigationSync.current = pendingFloorplanPose - pendingFloorplanNavigationPose.current = null - if (savedSmoothTimeRef.current !== null && controls.current) { - controls.current.smoothTime = savedSmoothTimeRef.current - savedSmoothTimeRef.current = null - } + clearPendingFloorplanNavigationPose() if (pendingFloorplanPose.publishOnComplete) { useEditor.getState().publishNavigationSyncPose({ source: '3d', @@ -556,7 +552,7 @@ export const CustomCameraControls = () => { azimuth: syncSpherical.theta, viewWidth, }) - }, [camera, isFirstPersonMode, viewportSize]) + }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize]) useEffect(() => { if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return @@ -589,7 +585,7 @@ export const CustomCameraControls = () => { ) const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) - pendingFloorplanNavigationPose.current = null + clearPendingFloorplanNavigationPose() if (horizontal !== 0) control.truck(horizontal * step, 0, true) if (vertical !== 0) control.forward(vertical * step, true) }) @@ -741,7 +737,7 @@ export const CustomCameraControls = () => { !isEditableKeyboardTarget(event.target) ) { setKeyboardPanKey(keyboardPanKeys.current, event.code, true) - pendingFloorplanNavigationPose.current = null + clearPendingFloorplanNavigationPose() event.preventDefault() event.stopPropagation() } @@ -804,7 +800,7 @@ export const CustomCameraControls = () => { const onPointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return - pendingFloorplanNavigationPose.current = null + clearPendingFloorplanNavigationPose() if (event.button !== 1 && !(event.button === 0 && keyState.space)) return panPointerId = event.pointerId @@ -813,7 +809,7 @@ export const CustomCameraControls = () => { } const onWheel = () => { - pendingFloorplanNavigationPose.current = null + clearPendingFloorplanNavigationPose() } const onPointerUp = (event: PointerEvent) => { @@ -855,7 +851,25 @@ export const CustomCameraControls = () => { clearKeyboardPanKeys() clearNavigationCursor() } - }, [cameraMode, gl, isPreviewMode, isFirstPersonMode]) + }, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose]) + + // Cancel any in-progress 2D-origin navigation pose when the user starts + // dragging (right-click orbit, middle-click pan, touch). `controlstart` + // fires only for user pointer interactions — not for programmatic + // moveTo/rotateTo which emit `transitionstart` instead. + useEffect(() => { + if (isFirstPersonMode) return + const control = controls.current + if (!control) return + + const onControlStart = () => { + clearPendingFloorplanNavigationPose() + } + control.addEventListener('controlstart', onControlStart) + return () => { + control.removeEventListener('controlstart', onControlStart) + } + }, [isFirstPersonMode, clearPendingFloorplanNavigationPose]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8ddfaae2..7335dac9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5181,7 +5181,11 @@ export function FloorplanPanel({ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg - latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg + // Only sync ref from state when floorplan is open (state is source of truth). + // When hidden, the imperative 3D path owns the ref and must not be clobbered. + if (isFloorplanOpenRef.current) { + latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg + } // Draft START points stay in panel state (set per click). The live END points // are the per-move hot values — they live in `useFloorplanDraftPreview` so a @@ -6543,6 +6547,8 @@ export function FloorplanPanel({ ) useEffect(() => { + if (!isFloorplanOpen) return + const pose = useEditor.getState().navigationSyncPose if (!pose) { return @@ -6550,12 +6556,9 @@ export function FloorplanPanel({ latestNavigationSyncPoseRef.current = pose if (pose.source === '3d') { - // Re-runs when the panel reopens (`isFloorplanOpen`) so the viewport - // catches up to the camera after the per-frame sync was skipped while - // hidden; a no-op while closed (the sync early-returns). syncFloorplanViewportToNavigationPose(pose) } - }, [syncFloorplanViewportToNavigationPose]) + }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen]) useEffect(() => { return useEditor.subscribe((state) => { @@ -6586,6 +6589,16 @@ export function FloorplanPanel({ }) }, [syncFloorplanViewportToNavigationPose]) + // When the panel is hidden the imperative path owns the compass needle. + // React re-renders can overwrite the needle's inline transform with stale + // state; this layout effect restores the authoritative ref value before + // the browser paints so the needle never visibly snaps to a stale angle. + useLayoutEffect(() => { + if (!isFloorplanOpen && compassNeedleRef.current) { + compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)` + } + }) + useEffect(() => { const host = viewportHostRef.current if (!host) {