@@ -5146,6 +5146,7 @@ export function FloorplanPanel({
51465146 useEditor . getState ( ) . navigationSyncPose ,
51475147 )
51485148 const compassNeedleRef = useRef < SVGSVGElement | null > ( null )
5149+ const hiddenCompassAnimationRef = useRef < number | null > ( null )
51495150 const levelId = useViewer ( ( state ) => state . selection . levelId )
51505151 const buildingId = useViewer ( ( state ) => state . selection . buildingId )
51515152 const selectedZoneId = useViewer ( ( state ) => state . selection . zoneId )
@@ -6624,34 +6625,91 @@ export function FloorplanPanel({
66246625 }
66256626 } , [ syncFloorplanViewportToNavigationPose , isFloorplanOpen ] )
66266627
6628+ const cancelHiddenCompassAnimation = useCallback ( ( ) => {
6629+ if ( hiddenCompassAnimationRef . current !== null ) {
6630+ cancelAnimationFrame ( hiddenCompassAnimationRef . current )
6631+ hiddenCompassAnimationRef . current = null
6632+ }
6633+ } , [ ] )
6634+
6635+ // Align-north while the panel is hidden publishes a single '2d' pose that
6636+ // the 3D camera applies through the echo-suppressed pending-pose path — it
6637+ // never publishes '3d' frames back, so the needle must animate itself.
6638+ // Same time constant as the 2D view animation and the camera's effective
6639+ // smoothTime, so all three stay visually in step.
6640+ const animateHiddenCompassNeedle = useCallback (
6641+ ( targetDeg : number ) => {
6642+ cancelHiddenCompassAnimation ( )
6643+ let last = performance . now ( )
6644+ const tick = ( now : number ) => {
6645+ hiddenCompassAnimationRef . current = null
6646+ if ( isFloorplanOpenRef . current ) {
6647+ return
6648+ }
6649+ const deltaMs = now - last
6650+ last = now
6651+ const currentDeg = latestFloorplanUserRotationDegRef . current
6652+ const decay = Math . exp ( - deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS )
6653+ let nextDeg = targetDeg - ( targetDeg - currentDeg ) * decay
6654+ if ( Math . abs ( targetDeg - nextDeg ) < 0.05 ) {
6655+ nextDeg = targetDeg
6656+ }
6657+ latestFloorplanUserRotationDegRef . current = nextDeg
6658+ if ( compassNeedleRef . current ) {
6659+ compassNeedleRef . current . style . transform = `rotate(${ nextDeg } deg)`
6660+ }
6661+ if ( nextDeg !== targetDeg ) {
6662+ hiddenCompassAnimationRef . current = requestAnimationFrame ( tick )
6663+ }
6664+ }
6665+ hiddenCompassAnimationRef . current = requestAnimationFrame ( tick )
6666+ } ,
6667+ [ cancelHiddenCompassAnimation ] ,
6668+ )
6669+
66276670 useEffect ( ( ) => {
6628- return useEditor . subscribe ( ( state ) => {
6671+ const unsubscribe = useEditor . subscribe ( ( state ) => {
66296672 const pose = state . navigationSyncPose
66306673 if ( ! pose || latestNavigationSyncPoseRef . current ?. revision === pose . revision ) {
66316674 return
66326675 }
66336676
66346677 latestNavigationSyncPoseRef . current = pose
66356678
6636- if ( pose . source === '3d' ) {
6637- if ( ! isFloorplanOpenRef . current ) {
6679+ if ( ! isFloorplanOpenRef . current ) {
6680+ const nextDeg = floorplanRotationFromCameraAzimuth (
6681+ pose . azimuth ,
6682+ latestFloorplanUserRotationDegRef . current ,
6683+ )
6684+ if ( pose . source === '3d' ) {
66386685 // Panel hidden — drive the compass needle imperatively without
66396686 // triggering React state (setViewport) that would re-render the
6640- // full floorplan SVG every camera frame.
6641- const nextDeg = floorplanRotationFromCameraAzimuth (
6642- pose . azimuth ,
6643- latestFloorplanUserRotationDegRef . current ,
6644- )
6687+ // full floorplan SVG every camera frame. The live camera stream
6688+ // owns the needle, so any local animation yields to it.
6689+ cancelHiddenCompassAnimation ( )
66456690 latestFloorplanUserRotationDegRef . current = nextDeg
66466691 if ( compassNeedleRef . current ) {
66476692 compassNeedleRef . current . style . transform = `rotate(${ nextDeg } deg)`
66486693 }
6649- return
6694+ } else {
6695+ animateHiddenCompassNeedle ( nextDeg )
66506696 }
6697+ return
6698+ }
6699+
6700+ if ( pose . source === '3d' ) {
66516701 syncFloorplanViewportToNavigationPose ( pose )
66526702 }
66536703 } )
6654- } , [ syncFloorplanViewportToNavigationPose ] )
6704+ return ( ) => {
6705+ unsubscribe ( )
6706+ cancelHiddenCompassAnimation ( )
6707+ }
6708+ } , [
6709+ syncFloorplanViewportToNavigationPose ,
6710+ animateHiddenCompassNeedle ,
6711+ cancelHiddenCompassAnimation ,
6712+ ] )
66556713
66566714 // When the panel is hidden the imperative path owns the compass needle.
66576715 // React re-renders can overwrite the needle's inline transform with stale
@@ -7442,8 +7500,8 @@ export function FloorplanPanel({
74427500 const alignFloorplanViewToNorth = useCallback ( ( ) => {
74437501 if ( ! isFloorplanOpenRef . current ) {
74447502 // Panel hidden — derive from the live 3D camera pose and publish
7445- // directly. The compass animates via the imperative subscription as
7446- // the 3D camera transitions .
7503+ // directly. The pose subscription picks this '2d' pose up and animates
7504+ // the needle locally (the camera transition suppresses '3d' echoes) .
74477505 const pose = latestNavigationSyncPoseRef . current
74487506 if ( ! pose ) return
74497507 const currentRotation = latestFloorplanUserRotationDegRef . current
0 commit comments