diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 4bca280e..59da1f4a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -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(null) + const pendingFloorplanNavigationPose = useRef(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]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 81cb25f0..c4e95b30 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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(null) const latestFittedViewportRef = useRef(null) + const floorplanViewAnimationFrameRef = useRef(null) + const floorplanViewAnimationTargetRef = useRef(null) const latestNavigationSyncPoseRef = useRef( 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,