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:
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) {
|
const nextDeg = floorplanRotationFromCameraAzimuth(
|
||||||
|
pose.azimuth,
|
||||||
|
latestFloorplanUserRotationDegRef.current,
|
||||||
|
)
|
||||||
|
if (pose.source === '3d') {
|
||||||
// Panel hidden — drive the compass needle imperatively without
|
// Panel hidden — drive the compass needle imperatively without
|
||||||
// triggering React state (setViewport) that would re-render the
|
// triggering React state (setViewport) that would re-render the
|
||||||
// full floorplan SVG every camera frame.
|
// full floorplan SVG every camera frame. The live camera stream
|
||||||
const nextDeg = floorplanRotationFromCameraAzimuth(
|
// owns the needle, so any local animation yields to it.
|
||||||
pose.azimuth,
|
cancelHiddenCompassAnimation()
|
||||||
latestFloorplanUserRotationDegRef.current,
|
|
||||||
)
|
|
||||||
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)`
|
||||||
}
|
}
|
||||||
return
|
} else {
|
||||||
|
animateHiddenCompassNeedle(nextDeg)
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user