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 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-09 13:23:09 +02:00
co-authored by Claude Fable 5
parent cb5cfc7e40
commit d4f1053528
2 changed files with 43 additions and 16 deletions
@@ -511,11 +511,7 @@ export const CustomCameraControls = () => {
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth) isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
) { ) {
lastPublishedNavigationSync.current = pendingFloorplanPose lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
if (pendingFloorplanPose.publishOnComplete) { if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({ useEditor.getState().publishNavigationSyncPose({
source: '3d', source: '3d',
@@ -556,7 +552,7 @@ export const CustomCameraControls = () => {
azimuth: syncSpherical.theta, azimuth: syncSpherical.theta,
viewWidth, viewWidth,
}) })
}, [camera, isFirstPersonMode, viewportSize]) }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return 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) 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 (horizontal !== 0) control.truck(horizontal * step, 0, true)
if (vertical !== 0) control.forward(vertical * step, true) if (vertical !== 0) control.forward(vertical * step, true)
}) })
@@ -741,7 +737,7 @@ export const CustomCameraControls = () => {
!isEditableKeyboardTarget(event.target) !isEditableKeyboardTarget(event.target)
) { ) {
setKeyboardPanKey(keyboardPanKeys.current, event.code, true) setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
@@ -804,7 +800,7 @@ export const CustomCameraControls = () => {
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return 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 if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
panPointerId = event.pointerId panPointerId = event.pointerId
@@ -813,7 +809,7 @@ export const CustomCameraControls = () => {
} }
const onWheel = () => { const onWheel = () => {
pendingFloorplanNavigationPose.current = null clearPendingFloorplanNavigationPose()
} }
const onPointerUp = (event: PointerEvent) => { const onPointerUp = (event: PointerEvent) => {
@@ -855,7 +851,25 @@ export const CustomCameraControls = () => {
clearKeyboardPanKeys() clearKeyboardPanKeys()
clearNavigationCursor() 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) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -5181,7 +5181,11 @@ export function FloorplanPanel({
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg = const floorplanSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg 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 // 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 // are the per-move hot values — they live in `useFloorplanDraftPreview` so a
@@ -6543,6 +6547,8 @@ export function FloorplanPanel({
) )
useEffect(() => { useEffect(() => {
if (!isFloorplanOpen) return
const pose = useEditor.getState().navigationSyncPose const pose = useEditor.getState().navigationSyncPose
if (!pose) { if (!pose) {
return return
@@ -6550,12 +6556,9 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose latestNavigationSyncPoseRef.current = pose
if (pose.source === '3d') { 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(pose)
} }
}, [syncFloorplanViewportToNavigationPose]) }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
useEffect(() => { useEffect(() => {
return useEditor.subscribe((state) => { return useEditor.subscribe((state) => {
@@ -6586,6 +6589,16 @@ export function FloorplanPanel({
}) })
}, [syncFloorplanViewportToNavigationPose]) }, [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(() => { useEffect(() => {
const host = viewportHostRef.current const host = viewportHostRef.current
if (!host) { if (!host) {