Skip to content

Commit 3693db1

Browse files
Aymericrclaude
andcommitted
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>
1 parent 5831b7b commit 3693db1

1 file changed

Lines changed: 66 additions & 12 deletions

File tree

packages/editor/src/components/editor/floorplan-panel.tsx

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)