@@ -5082,6 +5082,7 @@ export function FloorplanPanel({
50825082 useEditor . getState ( ) . navigationSyncPose ,
50835083 )
50845084 const compassNeedleRef = useRef < SVGSVGElement | null > ( null )
5085+ const hiddenCompassAnimationRef = useRef < number | null > ( null )
50855086 const levelId = useViewer ( ( state ) => state . selection . levelId )
50865087 const buildingId = useViewer ( ( state ) => state . selection . buildingId )
50875088 const selectedZoneId = useViewer ( ( state ) => state . selection . zoneId )
@@ -6560,34 +6561,87 @@ export function FloorplanPanel({
65606561 }
65616562 } , [ syncFloorplanViewportToNavigationPose , isFloorplanOpen ] )
65626563
6564+ const cancelHiddenCompassAnimation = useCallback ( ( ) => {
6565+ if ( hiddenCompassAnimationRef . current !== null ) {
6566+ cancelAnimationFrame ( hiddenCompassAnimationRef . current )
6567+ hiddenCompassAnimationRef . current = null
6568+ }
6569+ } , [ ] )
6570+
6571+ // Align-north while the panel is hidden publishes a single '2d' pose that
6572+ // the 3D camera applies through the echo-suppressed pending-pose path — it
6573+ // never publishes '3d' frames back, so the needle must animate itself.
6574+ // Same time constant as the 2D view animation and the camera's effective
6575+ // smoothTime, so all three stay visually in step.
6576+ const animateHiddenCompassNeedle = useCallback (
6577+ ( targetDeg : number ) => {
6578+ cancelHiddenCompassAnimation ( )
6579+ let last = performance . now ( )
6580+ const tick = ( now : number ) => {
6581+ hiddenCompassAnimationRef . current = null
6582+ if ( isFloorplanOpenRef . current ) {
6583+ return
6584+ }
6585+ const deltaMs = now - last
6586+ last = now
6587+ const currentDeg = latestFloorplanUserRotationDegRef . current
6588+ const decay = Math . exp ( - deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS )
6589+ let nextDeg = targetDeg - ( targetDeg - currentDeg ) * decay
6590+ if ( Math . abs ( targetDeg - nextDeg ) < 0.05 ) {
6591+ nextDeg = targetDeg
6592+ }
6593+ latestFloorplanUserRotationDegRef . current = nextDeg
6594+ if ( compassNeedleRef . current ) {
6595+ compassNeedleRef . current . style . transform = `rotate(${ nextDeg } deg)`
6596+ }
6597+ if ( nextDeg !== targetDeg ) {
6598+ hiddenCompassAnimationRef . current = requestAnimationFrame ( tick )
6599+ }
6600+ }
6601+ hiddenCompassAnimationRef . current = requestAnimationFrame ( tick )
6602+ } ,
6603+ [ cancelHiddenCompassAnimation ] ,
6604+ )
6605+
65636606 useEffect ( ( ) => {
6564- return useEditor . subscribe ( ( state ) => {
6607+ const unsubscribe = useEditor . subscribe ( ( state ) => {
65656608 const pose = state . navigationSyncPose
65666609 if ( ! pose || latestNavigationSyncPoseRef . current ?. revision === pose . revision ) {
65676610 return
65686611 }
65696612
65706613 latestNavigationSyncPoseRef . current = pose
65716614
6572- if ( pose . source === '3d' ) {
6573- if ( ! isFloorplanOpenRef . current ) {
6615+ if ( ! isFloorplanOpenRef . current ) {
6616+ const nextDeg = floorplanRotationFromCameraAzimuth (
6617+ pose . azimuth ,
6618+ latestFloorplanUserRotationDegRef . current ,
6619+ )
6620+ if ( pose . source === '3d' ) {
65746621 // Panel hidden — drive the compass needle imperatively without
65756622 // triggering React state (setViewport) that would re-render the
6576- // full floorplan SVG every camera frame.
6577- const nextDeg = floorplanRotationFromCameraAzimuth (
6578- pose . azimuth ,
6579- latestFloorplanUserRotationDegRef . current ,
6580- )
6623+ // full floorplan SVG every camera frame. The live camera stream
6624+ // owns the needle, so any local animation yields to it.
6625+ cancelHiddenCompassAnimation ( )
65816626 latestFloorplanUserRotationDegRef . current = nextDeg
65826627 if ( compassNeedleRef . current ) {
65836628 compassNeedleRef . current . style . transform = `rotate(${ nextDeg } deg)`
65846629 }
6585- return
6630+ } else {
6631+ animateHiddenCompassNeedle ( nextDeg )
65866632 }
6633+ return
6634+ }
6635+
6636+ if ( pose . source === '3d' ) {
65876637 syncFloorplanViewportToNavigationPose ( pose )
65886638 }
65896639 } )
6590- } , [ syncFloorplanViewportToNavigationPose ] )
6640+ return ( ) => {
6641+ unsubscribe ( )
6642+ cancelHiddenCompassAnimation ( )
6643+ }
6644+ } , [ syncFloorplanViewportToNavigationPose , animateHiddenCompassNeedle , cancelHiddenCompassAnimation ] )
65916645
65926646 // When the panel is hidden the imperative path owns the compass needle.
65936647 // React re-renders can overwrite the needle's inline transform with stale
@@ -7378,8 +7432,8 @@ export function FloorplanPanel({
73787432 const alignFloorplanViewToNorth = useCallback ( ( ) => {
73797433 if ( ! isFloorplanOpenRef . current ) {
73807434 // Panel hidden — derive from the live 3D camera pose and publish
7381- // directly. The compass animates via the imperative subscription as
7382- // the 3D camera transitions .
7435+ // directly. The pose subscription picks this '2d' pose up and animates
7436+ // the needle locally (the camera transition suppresses '3d' echoes) .
73837437 const pose = latestNavigationSyncPoseRef . current
73847438 if ( ! pose ) return
73857439 const currentRotation = latestFloorplanUserRotationDegRef . current
0 commit comments