Stabilize split view navigation sync

This commit is contained in:
Aymeric Rabot
2026-06-08 01:56:45 -04:00
parent 4445c60532
commit bd9e97e224
2 changed files with 213 additions and 28 deletions
@@ -43,6 +43,11 @@ type CameraPoseSnapshot = {
position: [number, number, number]
target: [number, number, number]
}
type NavigationCameraPoseSnapshot = {
target: [number, number, number]
azimuth: number
viewWidth: number
}
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
tuple[0] = vector.x
@@ -115,6 +120,25 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo
return Math.max(0.001, distance)
}
function getAngleDeltaRadians(a: number, b: number) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
function isCameraAtNavigationPose(
pose: NavigationCameraPoseSnapshot,
target: Vector3,
azimuth: number,
viewWidth: number,
) {
return (
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
)
}
function getCameraDistanceForViewWidth(
camera: Camera,
viewWidth: number,
@@ -233,11 +257,8 @@ export const CustomCameraControls = () => {
)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const lastPublishedNavigationSync = useRef<{
target: [number, number, number]
azimuth: number
viewWidth: number
} | null>(null)
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<NavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
@@ -326,6 +347,11 @@ export const CustomCameraControls = () => {
if (!control) return
lastApplied2dNavigationRevision.current = pose.revision
pendingFloorplanNavigationPose.current = {
target: [...pose.target],
azimuth: pose.azimuth,
viewWidth: pose.viewWidth,
}
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(pose.azimuth, control.polarAngle, true)
applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize)
@@ -339,13 +365,27 @@ export const CustomCameraControls = () => {
controls.current.getSpherical(syncSpherical, false)
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
const pendingFloorplanPose = pendingFloorplanNavigationPose.current
if (pendingFloorplanPose) {
// The camera is still damping toward a 2D-originated pose; do not echo
// intermediate 3D poses back into the floorplan.
if (
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
) {
lastPublishedNavigationSync.current = pendingFloorplanPose
pendingFloorplanNavigationPose.current = null
}
return
}
const previous = lastPublishedNavigationSync.current
if (
previous &&
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.azimuth - syncSpherical.theta) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
) {
return
@@ -564,6 +604,7 @@ export const CustomCameraControls = () => {
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
pendingFloorplanNavigationPose.current = null
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
panPointerId = event.pointerId
@@ -571,6 +612,10 @@ export const CustomCameraControls = () => {
updateNavigationCursor()
}
const onWheel = () => {
pendingFloorplanNavigationPose.current = null
}
const onPointerUp = (event: PointerEvent) => {
if (panPointerId === null) return
if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return
@@ -595,6 +640,7 @@ export const CustomCameraControls = () => {
window.addEventListener('pointerup', onPointerUp, true)
window.addEventListener('pointercancel', onPointerUp, true)
window.addEventListener('blur', onBlur)
gl.domElement.addEventListener('wheel', onWheel, { passive: true })
updateConfig()
return () => {
@@ -604,6 +650,7 @@ export const CustomCameraControls = () => {
window.removeEventListener('pointerup', onPointerUp, true)
window.removeEventListener('pointercancel', onPointerUp, true)
window.removeEventListener('blur', onBlur)
gl.domElement.removeEventListener('wheel', onWheel)
clearNavigationCursor()
}
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
@@ -217,18 +217,43 @@ const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
const FLOORPLAN_SITE_COLOR = '#10b981'
const FLOORPLAN_VIEW_ROTATION_DEG = 90
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005
const FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG = 0.01
type FloorplanViewport = {
centerX: number
centerY: number
width: number
}
type FloorplanNavigationViewOptions = {
smooth?: boolean
}
type FloorplanViewAnimationTarget = {
viewport: FloorplanViewport
userRotationDeg: number
lastTimestamp: number | null
}
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
if (a === b) return true
if (!(a && b)) return false
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
}
function floorplanViewportWithinEpsilon(
a: FloorplanViewport,
b: FloorplanViewport,
epsilon: number,
) {
return (
Math.abs(a.centerX - b.centerX) < epsilon &&
Math.abs(a.centerY - b.centerY) < epsilon &&
Math.abs(a.width - b.width) < epsilon
)
}
type SvgPoint = {
x: number
y: number
@@ -1684,13 +1709,13 @@ function nearestEquivalentDegrees(angle: number, reference: number) {
function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) {
return nearestEquivalentDegrees(
FLOORPLAN_VIEW_ROTATION_DEG - radiansToDegrees(azimuth),
radiansToDegrees(azimuth) - FLOORPLAN_VIEW_ROTATION_DEG,
reference,
)
}
function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
return degreesToRadians(FLOORPLAN_VIEW_ROTATION_DEG - rotationDeg)
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
}
function floorplanLocalToWorldPoint(
@@ -4272,6 +4297,8 @@ export function FloorplanPanel() {
const latestFloorplanUserRotationDegRef = useRef(0)
const latestViewportRef = useRef<FloorplanViewport | null>(null)
const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
const floorplanViewAnimationFrameRef = useRef<number | null>(null)
const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose,
)
@@ -5626,8 +5653,98 @@ export function FloorplanPanel() {
})
})
const applyFloorplanNavigationState = useCallback(
(nextViewport: FloorplanViewport, userRotationDeg: number) => {
hasUserAdjustedViewportRef.current = true
latestFloorplanUserRotationDegRef.current = userRotationDeg
latestViewportRef.current = nextViewport
setFloorplanUserRotationDeg((current) =>
current === userRotationDeg ? current : userRotationDeg,
)
setViewport((current) =>
floorplanViewportEquals(current, nextViewport) ? current : nextViewport,
)
},
[],
)
const stopFloorplanViewAnimation = useCallback(() => {
if (floorplanViewAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current)
floorplanViewAnimationFrameRef.current = null
}
floorplanViewAnimationTargetRef.current = null
}, [])
const animateFloorplanNavigationState = useCallback(
(nextViewport: FloorplanViewport, userRotationDeg: number) => {
floorplanViewAnimationTargetRef.current = {
viewport: nextViewport,
userRotationDeg,
lastTimestamp: floorplanViewAnimationTargetRef.current?.lastTimestamp ?? null,
}
if (floorplanViewAnimationFrameRef.current !== null) {
return
}
const step = (timestamp: number) => {
const target = floorplanViewAnimationTargetRef.current
if (!target) {
floorplanViewAnimationFrameRef.current = null
return
}
const currentViewport = latestViewportRef.current ?? target.viewport
const currentRotationDeg = latestFloorplanUserRotationDegRef.current
const deltaMs = target.lastTimestamp === null ? 16.67 : timestamp - target.lastTimestamp
target.lastTimestamp = timestamp
const alpha = 1 - Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS)
const targetRotationDeg = nearestEquivalentDegrees(
target.userRotationDeg,
currentRotationDeg,
)
const nextRotationDeg =
currentRotationDeg + (targetRotationDeg - currentRotationDeg) * alpha
const animatedViewport = {
centerX:
currentViewport.centerX + (target.viewport.centerX - currentViewport.centerX) * alpha,
centerY:
currentViewport.centerY + (target.viewport.centerY - currentViewport.centerY) * alpha,
width: currentViewport.width + (target.viewport.width - currentViewport.width) * alpha,
}
const isComplete =
floorplanViewportWithinEpsilon(
animatedViewport,
target.viewport,
FLOORPLAN_VIEW_ANIMATION_EPSILON,
) &&
Math.abs(targetRotationDeg - nextRotationDeg) < FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG
if (isComplete) {
applyFloorplanNavigationState(target.viewport, targetRotationDeg)
floorplanViewAnimationTargetRef.current = null
floorplanViewAnimationFrameRef.current = null
return
}
applyFloorplanNavigationState(animatedViewport, nextRotationDeg)
floorplanViewAnimationFrameRef.current = window.requestAnimationFrame(step)
}
floorplanViewAnimationFrameRef.current = window.requestAnimationFrame(step)
},
[applyFloorplanNavigationState],
)
const applyFloorplanNavigationView = useCallback(
(localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => {
(
localCenter: SvgPoint,
userRotationDeg: number,
viewWidth?: number,
options?: FloorplanNavigationViewOptions,
) => {
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) {
return
@@ -5647,19 +5764,34 @@ export function FloorplanPanel() {
width: nextWidth,
}
hasUserAdjustedViewportRef.current = true
latestFloorplanUserRotationDegRef.current = userRotationDeg
latestViewportRef.current = nextViewport
setFloorplanUserRotationDeg((current) =>
current === userRotationDeg ? current : userRotationDeg,
)
setViewport((current) =>
floorplanViewportEquals(current, nextViewport) ? current : nextViewport,
)
if (options?.smooth) {
animateFloorplanNavigationState(nextViewport, userRotationDeg)
} else {
stopFloorplanViewAnimation()
applyFloorplanNavigationState(nextViewport, userRotationDeg)
}
},
[buildingRotationDeg],
[
animateFloorplanNavigationState,
applyFloorplanNavigationState,
buildingRotationDeg,
stopFloorplanViewAnimation,
],
)
const smoothFloorplanNavigationView = useCallback(
(localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => {
applyFloorplanNavigationView(localCenter, userRotationDeg, viewWidth, { smooth: true })
},
[applyFloorplanNavigationView],
)
useEffect(() => {
return () => {
stopFloorplanViewAnimation()
}
}, [stopFloorplanViewAnimation])
const syncFloorplanViewportToNavigationPose = useCallback(
(pose: NavigationSyncPose) => {
if (floorplanRotationStateRef.current) {
@@ -5764,6 +5896,7 @@ export function FloorplanPanel() {
// to the current scene.
useEffect(() => {
if (!isFloorplanOpen) {
stopFloorplanViewAnimation()
floorplanSpacePanPressedRef.current = false
panStateRef.current = null
floorplanRotationStateRef.current = null
@@ -5775,13 +5908,14 @@ export function FloorplanPanel() {
setMeasuredSceneBBox(null)
if (!latestNavigationSyncPoseRef.current) {
stopFloorplanViewAnimation()
hasUserAdjustedViewportRef.current = false
latestFloorplanUserRotationDegRef.current = 0
latestViewportRef.current = null
setFloorplanUserRotationDeg(0)
setViewport(null)
}
}, [isFloorplanOpen])
}, [isFloorplanOpen, stopFloorplanViewAnimation])
useEffect(() => {
const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
@@ -5789,6 +5923,7 @@ export function FloorplanPanel() {
if (levelChanged) {
previousLevelIdRef.current = levelId ?? null
if (!latestNavigationSyncPoseRef.current) {
stopFloorplanViewAnimation()
hasUserAdjustedViewportRef.current = false
latestFloorplanUserRotationDegRef.current = 0
latestViewportRef.current = null
@@ -5824,6 +5959,7 @@ export function FloorplanPanel() {
movingFenceEndpoint,
movingNode,
siteVertexDragState,
stopFloorplanViewAnimation,
])
const viewBox = useMemo(() => {
@@ -6441,11 +6577,6 @@ export function FloorplanPanel() {
guideTransformDraftRef.current = guideTransformDraft
}, [guideTransformDraft])
const updateViewport = useCallback((nextViewport: FloorplanViewport) => {
hasUserAdjustedViewportRef.current = true
setViewport(nextViewport)
}, [])
const floorplanGridLocalY = useMemo(() => {
if (movingNode?.type === 'item' || movingNode?.type === 'spawn') {
return movingNode.position[1]
@@ -6661,6 +6792,11 @@ export function FloorplanPanel() {
}
const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg)
smoothFloorplanNavigationView(
localCenter,
latestFloorplanUserRotationDegRef.current,
nextWidth,
)
publishFloorplanNavigationPose(
localCenter,
latestFloorplanUserRotationDegRef.current,
@@ -6674,6 +6810,7 @@ export function FloorplanPanel() {
maxViewportWidth,
minViewportWidth,
publishFloorplanNavigationPose,
smoothFloorplanNavigationView,
svgAspectRatio,
viewBox,
viewport,
@@ -7833,10 +7970,10 @@ export function FloorplanPanel() {
event.stopPropagation()
const angleDeltaDeg =
(event.clientX - rotationState.startClientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL
(rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL
const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg
applyFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg)
smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg)
publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg)
setCursorPoint(null)
return
@@ -7860,6 +7997,7 @@ export function FloorplanPanel() {
FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg
const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
smoothFloorplanNavigationView(localCenter, currentUserRotationDeg)
publishFloorplanNavigationPose(localCenter, currentUserRotationDeg)
panStateRef.current = {
@@ -8158,8 +8296,8 @@ export function FloorplanPanel() {
isPolygonBuildActive,
isRoofBuildActive,
isWallBuildActive,
applyFloorplanNavigationView,
publishFloorplanNavigationPose,
smoothFloorplanNavigationView,
referenceScaleDraft,
roofDraftStart,
elevatorResizeDragState,