fix(editor): animate compass needle on align-north in 3D-only view

When the 2D panel is hidden, align-north publishes a single '2d' pose that
the camera applies through the echo-suppressed pending-pose path, so no
'3d' poses ever reach the compass subscription and the needle stayed
frozen. Drive the needle with a local rAF exponential decay (same 90ms
time constant as the 2D view animation and the camera's effective
smoothTime); a live '3d' pose cancels it and takes back ownership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-10 10:15:48 +02:00
co-authored by Claude Fable 5
parent 5831b7b234
commit cbcc705173
@@ -5082,6 +5082,7 @@ export function FloorplanPanel({
useEditor.getState().navigationSyncPose, useEditor.getState().navigationSyncPose,
) )
const compassNeedleRef = useRef<SVGSVGElement | null>(null) const compassNeedleRef = useRef<SVGSVGElement | null>(null)
const hiddenCompassAnimationRef = useRef<number | null>(null)
const levelId = useViewer((state) => state.selection.levelId) const levelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -6560,8 +6561,50 @@ export function FloorplanPanel({
} }
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen]) }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
const cancelHiddenCompassAnimation = useCallback(() => {
if (hiddenCompassAnimationRef.current !== null) {
cancelAnimationFrame(hiddenCompassAnimationRef.current)
hiddenCompassAnimationRef.current = null
}
}, [])
// Align-north while the panel is hidden publishes a single '2d' pose that
// the 3D camera applies through the echo-suppressed pending-pose path — it
// never publishes '3d' frames back, so the needle must animate itself.
// Same time constant as the 2D view animation and the camera's effective
// smoothTime, so all three stay visually in step.
const animateHiddenCompassNeedle = useCallback(
(targetDeg: number) => {
cancelHiddenCompassAnimation()
let last = performance.now()
const tick = (now: number) => {
hiddenCompassAnimationRef.current = null
if (isFloorplanOpenRef.current) {
return
}
const deltaMs = now - last
last = now
const currentDeg = latestFloorplanUserRotationDegRef.current
const decay = Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS)
let nextDeg = targetDeg - (targetDeg - currentDeg) * decay
if (Math.abs(targetDeg - nextDeg) < 0.05) {
nextDeg = targetDeg
}
latestFloorplanUserRotationDegRef.current = nextDeg
if (compassNeedleRef.current) {
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
}
if (nextDeg !== targetDeg) {
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
}
}
hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
},
[cancelHiddenCompassAnimation],
)
useEffect(() => { useEffect(() => {
return useEditor.subscribe((state) => { const unsubscribe = useEditor.subscribe((state) => {
const pose = state.navigationSyncPose const pose = state.navigationSyncPose
if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) { if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
return return
@@ -6569,25 +6612,40 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose latestNavigationSyncPoseRef.current = pose
if (pose.source === '3d') {
if (!isFloorplanOpenRef.current) { if (!isFloorplanOpenRef.current) {
// Panel hidden — drive the compass needle imperatively without
// triggering React state (setViewport) that would re-render the
// full floorplan SVG every camera frame.
const nextDeg = floorplanRotationFromCameraAzimuth( const nextDeg = floorplanRotationFromCameraAzimuth(
pose.azimuth, pose.azimuth,
latestFloorplanUserRotationDegRef.current, latestFloorplanUserRotationDegRef.current,
) )
if (pose.source === '3d') {
// Panel hidden — drive the compass needle imperatively without
// triggering React state (setViewport) that would re-render the
// full floorplan SVG every camera frame. The live camera stream
// owns the needle, so any local animation yields to it.
cancelHiddenCompassAnimation()
latestFloorplanUserRotationDegRef.current = nextDeg latestFloorplanUserRotationDegRef.current = nextDeg
if (compassNeedleRef.current) { if (compassNeedleRef.current) {
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)` compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
} }
} else {
animateHiddenCompassNeedle(nextDeg)
}
return return
} }
if (pose.source === '3d') {
syncFloorplanViewportToNavigationPose(pose) syncFloorplanViewportToNavigationPose(pose)
} }
}) })
}, [syncFloorplanViewportToNavigationPose]) return () => {
unsubscribe()
cancelHiddenCompassAnimation()
}
}, [
syncFloorplanViewportToNavigationPose,
animateHiddenCompassNeedle,
cancelHiddenCompassAnimation,
])
// When the panel is hidden the imperative path owns the compass needle. // When the panel is hidden the imperative path owns the compass needle.
// React re-renders can overwrite the needle's inline transform with stale // React re-renders can overwrite the needle's inline transform with stale
@@ -7378,8 +7436,8 @@ export function FloorplanPanel({
const alignFloorplanViewToNorth = useCallback(() => { const alignFloorplanViewToNorth = useCallback(() => {
if (!isFloorplanOpenRef.current) { if (!isFloorplanOpenRef.current) {
// Panel hidden — derive from the live 3D camera pose and publish // Panel hidden — derive from the live 3D camera pose and publish
// directly. The compass animates via the imperative subscription as // directly. The pose subscription picks this '2d' pose up and animates
// the 3D camera transitions. // the needle locally (the camera transition suppresses '3d' echoes).
const pose = latestNavigationSyncPoseRef.current const pose = latestNavigationSyncPoseRef.current
if (!pose) return if (!pose) return
const currentRotation = latestFloorplanUserRotationDegRef.current const currentRotation = latestFloorplanUserRotationDegRef.current