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 pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const savedSmoothTimeRef = useRef<number | null>(null)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
}, [])
const camera = useThree((state) => state.camera)
@@ -478,6 +483,13 @@ export const CustomCameraControls = () => {
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
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.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, viewWidthUpdate)
@@ -500,6 +512,10 @@ export const CustomCameraControls = () => {
) {
lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({
source: '3d',
@@ -457,9 +457,11 @@ type GuideHandleHintAnchor = {
function FloorplanCompassButton({
northRotationDeg,
onAlignNorth,
needleRef,
}: {
northRotationDeg: number
onAlignNorth: () => void
needleRef?: React.RefObject<SVGSVGElement | null>
}) {
return (
<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">
<svg
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)` }}
viewBox="0 0 48 48"
>
@@ -5078,6 +5081,7 @@ export function FloorplanPanel({
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose,
)
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
const levelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -6563,6 +6567,20 @@ export function FloorplanPanel({
latestNavigationSyncPoseRef.current = pose
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)
}
})
@@ -7345,6 +7363,25 @@ export function FloorplanPanel({
)
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
if (!currentViewport) {
return
@@ -10761,6 +10798,7 @@ export function FloorplanPanel({
(compassHost ? (
createPortal(
<FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>,
@@ -10768,6 +10806,7 @@ export function FloorplanPanel({
)
) : (
<FloorplanCompassButton
needleRef={compassNeedleRef}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>