diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index f8e6503c..430c3c81 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -2,11 +2,83 @@ import { ViewerContext } from "./App"; import { CameraControls } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; import * as holdEvent from "hold-event"; -import React, { useContext, useRef } from "react"; +import React, { useContext, useRef, useState } from "react"; +import { useFrame } from "@react-three/fiber"; import { PerspectiveCamera } from "three"; import * as THREE from "three"; import { computeT_threeworld_world } from "./WorldTransformUtils"; import { useThrottledMessageSender } from "./WebsocketFunctions"; +import { Grid, PivotControls } from "@react-three/drei"; + +function OrbitOriginTool({ + forceShow, + pivotRef, + onPivotChange, + update, +}: { + forceShow: boolean; + pivotRef: React.RefObject; + onPivotChange: (matrix: THREE.Matrix4) => void; + update: () => void; +}) { + const viewer = useContext(ViewerContext)!; + const showCameraControls = viewer.useGui( + (state) => state.showOrbitOriginTool, + ); + React.useEffect(update, [showCameraControls]); + + if (!showCameraControls && !forceShow) return null; + + return ( + { + onPivotChange(pivotRef.current!.matrix); + }} + > + + + + + + + ); +} export function SynchronizedCameraControls() { const viewer = useContext(ViewerContext)!; @@ -20,7 +92,157 @@ export function SynchronizedCameraControls() { lookAt: THREE.Vector3; } | null>(null); + const pivotRef = useRef(null); + + const cameraControlRef = viewer.cameraControlRef; + + // Animation state interface + interface CameraAnimation { + startUp: THREE.Vector3; + targetUp: THREE.Vector3; + startLookAt: THREE.Vector3; + targetLookAt: THREE.Vector3; + startTime: number; + duration: number; + } + + const [cameraAnimation, setCameraAnimation] = + useState(null); + + // Animation parameters + const ANIMATION_DURATION = 0.5; // seconds + + useFrame((state) => { + if (cameraAnimation && cameraControlRef.current) { + const cameraControls = cameraControlRef.current; + const camera = cameraControls.camera; + + const elapsed = state.clock.getElapsedTime() - cameraAnimation.startTime; + const progress = Math.min(elapsed / cameraAnimation.duration, 1); + + // Smooth step easing + const t = progress * progress * (3 - 2 * progress); + + // Interpolate up vector + const newUp = new THREE.Vector3() + .copy(cameraAnimation.startUp) + .lerp(cameraAnimation.targetUp, t) + .normalize(); + + // Interpolate look-at position + const newLookAt = new THREE.Vector3() + .copy(cameraAnimation.startLookAt) + .lerp(cameraAnimation.targetLookAt, t); + + camera.up.copy(newUp); + + // Back up position + const prevPosition = new THREE.Vector3(); + cameraControls.getPosition(prevPosition); + + cameraControls.updateCameraUp(); + + // Restore position and set new look-at + cameraControls.setPosition( + prevPosition.x, + prevPosition.y, + prevPosition.z, + false, + ); + + cameraControls.setLookAt( + prevPosition.x, + prevPosition.y, + prevPosition.z, + newLookAt.x, + newLookAt.y, + newLookAt.z, + false, + ); + + // Clear animation when complete + if (progress >= 1) { + setCameraAnimation(null); + } + } + }); + + const { clock } = useThree(); + + const updateCameraLookAtAndUpFromPivotControl = (matrix: THREE.Matrix4) => { + if (!cameraControlRef.current) return; + + const targetPosition = new THREE.Vector3(); + targetPosition.setFromMatrixPosition(matrix); + + const cameraControls = cameraControlRef.current; + const camera = cameraControlRef.current.camera; + + // Get target up vector from matrix + const targetUp = new THREE.Vector3().setFromMatrixColumn(matrix, 1); + + // Get current look-at position + const currentLookAt = cameraControls.getTarget(new THREE.Vector3()); + + // Start new animation + setCameraAnimation({ + startUp: camera.up.clone(), + targetUp: targetUp, + startLookAt: currentLookAt, + targetLookAt: targetPosition, + startTime: clock.getElapsedTime(), + duration: ANIMATION_DURATION, + }); + }; + + const updatePivotControlFromCameraLookAtAndup = () => { + if (cameraAnimation !== null) return; + if (!cameraControlRef.current) return; + if (!pivotRef.current) return; + + const cameraControls = cameraControlRef.current; + const lookAt = cameraControls.getTarget(new THREE.Vector3()); + + // Rotate matrix s.t. it's y-axis aligns with the camera's up vector. + // We'll do this with math. + const origRotation = new THREE.Matrix4().extractRotation( + pivotRef.current.matrix, + ); + + const cameraUp = camera.up.clone().normalize(); + const pivotUp = new THREE.Vector3(0, 1, 0) + .applyMatrix4(origRotation) + .normalize(); + const axis = new THREE.Vector3() + .crossVectors(pivotUp, cameraUp) + .normalize(); + const angle = Math.acos(Math.min(1, Math.max(-1, cameraUp.dot(pivotUp)))); + + // Create rotation matrix + const rotationMatrix = new THREE.Matrix4(); + if (axis.lengthSq() > 0.0001) { + // Check if cross product is valid + rotationMatrix.makeRotationAxis(axis, angle); + } + // rotationMatrix.premultiply(origRotation); + + // Combine rotation with position + const matrix = new THREE.Matrix4(); + matrix.multiply(rotationMatrix); + matrix.multiply(origRotation); + matrix.setPosition(lookAt); + + pivotRef.current.matrix.copy(matrix); + pivotRef.current.updateMatrixWorld(true); + }; + viewer.resetCameraViewRef.current = () => { + viewer.cameraRef.current!.up.set( + initialCameraRef.current!.camera.up.x, + initialCameraRef.current!.camera.up.y, + initialCameraRef.current!.camera.up.z, + ); + viewer.cameraControlRef.current!.updateCameraUp(); viewer.cameraControlRef.current!.setLookAt( initialCameraRef.current!.camera.position.x, initialCameraRef.current!.camera.position.y, @@ -30,12 +252,6 @@ export function SynchronizedCameraControls() { initialCameraRef.current!.lookAt.z, true, ); - viewer.cameraRef.current!.up.set( - initialCameraRef.current!.camera.up.x, - initialCameraRef.current!.camera.up.y, - initialCameraRef.current!.camera.up.z, - ); - viewer.cameraControlRef.current!.updateCameraUp(); }; // Callback for sending cameras. @@ -51,6 +267,8 @@ export function SynchronizedCameraControls() { const t_world_camera = new THREE.Vector3(); const scale = new THREE.Vector3(); const sendCamera = React.useCallback(() => { + updatePivotControlFromCameraLookAtAndup(); + const three_camera = camera; const camera_control = viewer.cameraControlRef.current; @@ -270,14 +488,24 @@ export function SynchronizedCameraControls() { }, [CameraControls]); return ( - + <> + + { + updateCameraLookAtAndUpFromPivotControl(matrix); + }} + update={updatePivotControlFromCameraLookAtAndup} + /> + ); } diff --git a/src/viser/client/src/ControlPanel/ControlPanel.tsx b/src/viser/client/src/ControlPanel/ControlPanel.tsx index 6a143de2..b4856118 100644 --- a/src/viser/client/src/ControlPanel/ControlPanel.tsx +++ b/src/viser/client/src/ControlPanel/ControlPanel.tsx @@ -83,7 +83,7 @@ export default function ControlPanel(props: { > {showSettings ? ( diff --git a/src/viser/client/src/ControlPanel/GuiState.tsx b/src/viser/client/src/ControlPanel/GuiState.tsx index 7e84ac91..c6aa3124 100644 --- a/src/viser/client/src/ControlPanel/GuiState.tsx +++ b/src/viser/client/src/ControlPanel/GuiState.tsx @@ -16,6 +16,7 @@ interface GuiState { shareUrl: string | null; websocketConnected: boolean; backgroundAvailable: boolean; + showOrbitOriginTool: boolean; guiUuidSetFromContainerUuid: { [containerUuid: string]: { [uuid: string]: true } | undefined; }; @@ -65,6 +66,7 @@ const cleanGuiState: GuiState = { shareUrl: null, websocketConnected: false, backgroundAvailable: false, + showOrbitOriginTool: false, guiUuidSetFromContainerUuid: {}, modals: [], guiOrderFromUuid: {}, diff --git a/src/viser/client/src/ControlPanel/ServerControls.tsx b/src/viser/client/src/ControlPanel/ServerControls.tsx index 38d7cd21..4741faf1 100644 --- a/src/viser/client/src/ControlPanel/ServerControls.tsx +++ b/src/viser/client/src/ControlPanel/ServerControls.tsx @@ -7,6 +7,7 @@ import { Switch, Text, TextInput, + Tooltip, } from "@mantine/core"; import { IconHomeMove, IconPhoto } from "@tabler/icons-react"; import { Stats } from "@react-three/drei"; @@ -130,6 +131,33 @@ export default function ServerControls() { > Reset View + + Show tool for setting the look-at point and +
+ up direction of the camera. +
+
+ These can be used to set the origin of the +
+ camera's orbit controls. + + } + refProp="rootRef" + position="top-start" + > + { + viewer.useGui.setState({ + showOrbitOriginTool: event.currentTarget.checked, + }); + }} + size="sm" + /> +