Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Orbit origin tool #361

Merged
merged 9 commits into from
Jan 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 244 additions & 16 deletions src/viser/client/src/CameraControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<THREE.Group>;
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 (
<PivotControls
ref={pivotRef}
scale={200}
lineWidth={4}
fixed={true}
axisColors={["#ffaaff", "#ff33ff", "#ffaaff"]}
disableScaling={true}
onDragEnd={() => {
onPivotChange(pivotRef.current!.matrix);
}}
>
<mesh>
<sphereGeometry args={[0.1, 32, 32]} />
<shaderMaterial
transparent
uniforms={{
color: { value: new THREE.Color("#ff33ff") },
size: { value: 200.0 },
}}
vertexShader={`
// Custom shader for defining sphere size in screen space.
uniform float size;
void main() {
vec4 clipPos = projectionMatrix * modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec4 clipPosOffset = projectionMatrix * modelViewMatrix * vec4(position * size / 1000.0, 1.0);
gl_Position = clipPos + (clipPosOffset - clipPos) * clipPos.w;
}
`}
fragmentShader={`
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 0.8);
}
`}
/>
</mesh>
<Grid
args={[10, 10, 10, 10]}
infiniteGrid
fadeStrength={0}
fadeFrom={0}
fadeDistance={1000}
sectionColor={"#ffaaff"}
cellColor={"#ffccff"}
side={THREE.DoubleSide}
/>
</PivotControls>
);
}

export function SynchronizedCameraControls() {
const viewer = useContext(ViewerContext)!;
Expand All @@ -20,7 +92,157 @@ export function SynchronizedCameraControls() {
lookAt: THREE.Vector3;
} | null>(null);

const pivotRef = useRef<THREE.Group>(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<CameraAnimation | null>(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,
Expand All @@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -270,14 +488,24 @@ export function SynchronizedCameraControls() {
}, [CameraControls]);

return (
<CameraControls
ref={viewer.cameraControlRef}
minDistance={0.01}
dollySpeed={0.3}
smoothTime={0.05}
draggingSmoothTime={0.0}
onChange={sendCamera}
makeDefault
/>
<>
<CameraControls
ref={viewer.cameraControlRef}
minDistance={0.01}
dollySpeed={0.3}
smoothTime={0.05}
draggingSmoothTime={0.0}
onChange={sendCamera}
makeDefault
/>
<OrbitOriginTool
forceShow={logCamera !== null /* Always show if logging camera */}
pivotRef={pivotRef}
onPivotChange={(matrix) => {
updateCameraLookAtAndUpFromPivotControl(matrix);
}}
update={updatePivotControlFromCameraLookAtAndup}
/>
</>
);
}
2 changes: 1 addition & 1 deletion src/viser/client/src/ControlPanel/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function ControlPanel(props: {
>
<Tooltip
zIndex={100}
label={showSettings ? "Return to GUI" : "Connection & diagnostics"}
label={showSettings ? "Return to GUI" : "Configuration & diagnostics"}
withinPortal
>
{showSettings ? (
Expand Down
2 changes: 2 additions & 0 deletions src/viser/client/src/ControlPanel/GuiState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface GuiState {
shareUrl: string | null;
websocketConnected: boolean;
backgroundAvailable: boolean;
showOrbitOriginTool: boolean;
guiUuidSetFromContainerUuid: {
[containerUuid: string]: { [uuid: string]: true } | undefined;
};
Expand Down Expand Up @@ -65,6 +66,7 @@ const cleanGuiState: GuiState = {
shareUrl: null,
websocketConnected: false,
backgroundAvailable: false,
showOrbitOriginTool: false,
guiUuidSetFromContainerUuid: {},
modals: [],
guiOrderFromUuid: {},
Expand Down
28 changes: 28 additions & 0 deletions src/viser/client/src/ControlPanel/ServerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -130,6 +131,33 @@ export default function ServerControls() {
>
Reset View
</Button>
<Tooltip
label={
<>
Show tool for setting the look-at point and
<br />
up direction of the camera.
<br />
<br />
These can be used to set the origin of the
<br />
camera&apos;s orbit controls.
</>
}
refProp="rootRef"
position="top-start"
>
<Switch
radius="sm"
label="Orbit Origin Tool"
onChange={(event) => {
viewer.useGui.setState({
showOrbitOriginTool: event.currentTarget.checked,
});
}}
size="sm"
/>
</Tooltip>
<Switch
radius="sm"
label="WebGL Statistics"
Expand Down
Loading