fix(editor): compass sync + fast align-to-north

- Remove CSS transition on compass needle SVG so rotation tracks
  exactly without rubber-band lag (was transition-transform 150ms)
- Drive compass needle imperatively via ref when floorplan panel is
  hidden (3D-only mode), avoiding per-frame React re-renders of the
  full SVG while keeping the needle live
- Align-to-north in 3D-only mode derives from latestNavigationSyncPose
  instead of stale viewport, publishes directly to the 3D camera
- Temporarily lower camera-controls smoothTime from 0.25s to 0.18s
  during 2D-origin transitions (SmoothDamp τ≈90ms matches the 2D
  exponential decay), restoring on completion or cancellation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-09 13:09:31 +02:00
co-authored by Claude Fable 5
parent a477c4ade3
commit cb5cfc7e40
2 changed files with 56 additions and 1 deletions
@@ -369,10 +369,15 @@ export const CustomCameraControls = () => {
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null) const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null) const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0) const lastApplied2dNavigationRevision = useRef(0)
const savedSmoothTimeRef = useRef<number | null>(null)
const maxPolarAngle = const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const clearPendingFloorplanNavigationPose = useCallback(() => { const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
}, []) }, [])
const camera = useThree((state) => state.camera) const camera = useThree((state) => state.camera)
@@ -478,6 +483,13 @@ export const CustomCameraControls = () => {
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >= Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON, NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
} }
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
if (savedSmoothTimeRef.current === null) {
savedSmoothTimeRef.current = control.smoothTime
}
control.smoothTime = 0.18
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true) control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(targetAzimuth, control.polarAngle, true) control.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, viewWidthUpdate) applyCameraViewWidth(control, viewWidthUpdate)
@@ -500,6 +512,10 @@ export const CustomCameraControls = () => {
) { ) {
lastPublishedNavigationSync.current = pendingFloorplanPose lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
if (pendingFloorplanPose.publishOnComplete) { if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({ useEditor.getState().publishNavigationSyncPose({
source: '3d', source: '3d',
@@ -457,9 +457,11 @@ type GuideHandleHintAnchor = {
function FloorplanCompassButton({ function FloorplanCompassButton({
northRotationDeg, northRotationDeg,
onAlignNorth, onAlignNorth,
needleRef,
}: { }: {
northRotationDeg: number northRotationDeg: number
onAlignNorth: () => void onAlignNorth: () => void
needleRef?: React.RefObject<SVGSVGElement | null>
}) { }) {
return ( return (
<Tooltip> <Tooltip>
@@ -480,7 +482,8 @@ function FloorplanCompassButton({
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700"> <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
<svg <svg
aria-hidden="true" aria-hidden="true"
className="h-6 w-6 transition-transform duration-150 ease-out" className="h-6 w-6"
ref={needleRef}
style={{ transform: `rotate(${northRotationDeg}deg)` }} style={{ transform: `rotate(${northRotationDeg}deg)` }}
viewBox="0 0 48 48" viewBox="0 0 48 48"
> >
@@ -5078,6 +5081,7 @@ export function FloorplanPanel({
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>( const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose, useEditor.getState().navigationSyncPose,
) )
const compassNeedleRef = useRef<SVGSVGElement | 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)
@@ -6563,6 +6567,20 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose latestNavigationSyncPoseRef.current = pose
if (pose.source === '3d') { if (pose.source === '3d') {
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(
pose.azimuth,
latestFloorplanUserRotationDegRef.current,
)
latestFloorplanUserRotationDegRef.current = nextDeg
if (compassNeedleRef.current) {
compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
}
return
}
syncFloorplanViewportToNavigationPose(pose) syncFloorplanViewportToNavigationPose(pose)
} }
}) })
@@ -7345,6 +7363,25 @@ export function FloorplanPanel({
) )
const alignFloorplanViewToNorth = useCallback(() => { const alignFloorplanViewToNorth = useCallback(() => {
if (!isFloorplanOpenRef.current) {
// Panel hidden — derive from the live 3D camera pose and publish
// directly. The compass animates via the imperative subscription as
// the 3D camera transitions.
const pose = latestNavigationSyncPoseRef.current
if (!pose) return
const currentRotation = latestFloorplanUserRotationDegRef.current
const northAzimuth = cameraAzimuthFromFloorplanRotation(
nearestEquivalentDegrees(0, currentRotation),
)
useEditor.getState().publishNavigationSyncPose({
source: '2d',
target: [...pose.target],
azimuth: northAzimuth,
viewWidth: pose.viewWidth,
})
return
}
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) { if (!currentViewport) {
return return
@@ -10761,6 +10798,7 @@ export function FloorplanPanel({
(compassHost ? ( (compassHost ? (
createPortal( createPortal(
<FloorplanCompassButton <FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg} northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth} onAlignNorth={alignFloorplanViewToNorth}
/>, />,
@@ -10768,6 +10806,7 @@ export function FloorplanPanel({
) )
) : ( ) : (
<FloorplanCompassButton <FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg} northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth} onAlignNorth={alignFloorplanViewToNorth}
/> />