diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..6c32708e3 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,93 @@ +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + generated_build_dir: Path | None = None + + def initialize(self, version: str, build_data: dict[str, object]) -> None: + if os.environ.get("VISER_SKIP_CLIENT_BUILD") == "1": + return + + root = Path(self.root) + client_dir = root / "src" / "viser" / "client" + if not (client_dir / "package.json").exists(): + return + + with tempfile.TemporaryDirectory(prefix="viser-client-build-") as temp_dir: + temp_client_dir = Path(temp_dir) / "client" + shutil.copytree( + client_dir, + temp_client_dir, + ignore=shutil.ignore_patterns(".nodeenv", "node_modules", "build"), + ) + + build_client(temp_client_dir) + + build_dir = client_dir / "build" + shutil.rmtree(build_dir, ignore_errors=True) + shutil.copytree(temp_client_dir / "build", build_dir) + self.generated_build_dir = build_dir + + def finalize( + self, version: str, build_data: dict[str, object], artifact_path: str + ) -> None: + if self.generated_build_dir is not None: + shutil.rmtree(self.generated_build_dir, ignore_errors=True) + + +def build_client(client_dir: Path) -> None: + node_bin_dir = install_sandboxed_node(client_dir) + npm_path = node_bin_dir / "npm" + if sys.platform == "win32": + npm_path = npm_path.with_suffix(".cmd") + + env = os.environ.copy() + env["NODE_VIRTUAL_ENV"] = str(node_bin_dir.parent) + env["PATH"] = ( + str(node_bin_dir) + (";" if sys.platform == "win32" else ":") + env["PATH"] + ) + + subprocess.run([str(npm_path), "ci"], cwd=client_dir, env=env, check=True) + subprocess.run( + [str(npm_path), "run", "build"], + cwd=client_dir, + env=env, + check=True, + ) + + +def install_sandboxed_node(client_dir: Path) -> Path: + env_dir = client_dir / ".nodeenv" + + def get_node_bin_dir() -> Path: + node_bin_dir = env_dir / "bin" + if not node_bin_dir.exists(): + node_bin_dir = env_dir / "Scripts" + return node_bin_dir + + node_bin_dir = get_node_bin_dir() + + npx_path = node_bin_dir / "npx" + if sys.platform == "win32": + npx_path = npx_path.with_suffix(".cmd") + if npx_path.exists(): + return node_bin_dir + + subprocess.run( + [sys.executable, "-m", "nodeenv", "--node=24.12.0", str(env_dir)], + check=True, + ) + node_bin_dir = get_node_bin_dir() + npx_path = node_bin_dir / "npx" + if sys.platform == "win32": + npx_path = npx_path.with_suffix(".cmd") + if not npx_path.exists(): + raise RuntimeError(f"nodeenv did not create {npx_path}") + return node_bin_dir diff --git a/pyproject.toml b/pyproject.toml index e4e199ba8..71355e21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "nodeenv>=1.9.1,<2.0.0"] build-backend = "hatchling.build" [tool.hatch.build] @@ -7,6 +7,8 @@ exclude = ["src/viser/client/.nodeenv", "src/viser/client/node_modules", "**/__p # Client build is in the gitignore, but we still want it in the distribution. ignore-vcs = true +[tool.hatch.build.hooks.custom] + [tool.hatch.version] path = "src/viser/__init__.py" diff --git a/src/viser/__init__.py b/src/viser/__init__.py index de76fb938..aaa3a394c 100644 --- a/src/viser/__init__.py +++ b/src/viser/__init__.py @@ -79,4 +79,4 @@ if not _TYPE_CHECKING: from ._scene_handles import ScenePointerEvent as ScenePointerEvent -__version__ = "1.0.26" +__version__ = "1.0.36" diff --git a/src/viser/_client_autobuild.py b/src/viser/_client_autobuild.py index 5af92c9ca..c16a2759a 100644 --- a/src/viser/_client_autobuild.py +++ b/src/viser/_client_autobuild.py @@ -136,10 +136,10 @@ def _build_viser_client(out_dir: Path, cached: bool = True) -> None: npm_path = npm_path.with_suffix(".cmd") subprocess.run( - args=[str(npm_path), "install"], + args=[str(npm_path), "ci"], env=subprocess_env, cwd=client_dir, - check=False, + check=True, ) subprocess.run( args=[ @@ -154,7 +154,7 @@ def _build_viser_client(out_dir: Path, cached: bool = True) -> None: ], env=subprocess_env, cwd=client_dir, - check=False, + check=True, ) diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 4ab271dec..7c4156496 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -14,6 +14,7 @@ import React, { useEffect, useMemo } from "react"; import { ViewerMutable } from "./ViewerContext"; import { Anchor, + ActionIcon, Box, Divider, Image, @@ -24,6 +25,7 @@ import { useMantineColorScheme, useMantineTheme, } from "@mantine/core"; +import { IconCamera, IconView360 } from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; // Local imports. @@ -202,6 +204,7 @@ function ViewerRoot() { : () => null, sendCamera: null, resetCameraPose: null, + setFirstPersonMode: () => null, // DOM/Three.js references. canvas: null, @@ -455,6 +458,46 @@ function NotificationsPanel() { ); } +/** Viewport camera-mode control. */ +function CameraModeIndicator() { + const viewer = React.useContext(ViewerContext)!; + const firstPerson = viewer.useGui((state) => state.firstPersonCamera); + const dark = viewer.useGui((state) => state.theme.dark_mode); + const label = firstPerson ? "First person mode" : "Orbit mode"; + const nextLabel = firstPerson ? "orbit mode" : "first-person mode"; + const Icon = firstPerson ? IconCamera : IconView360; + + const toggleMode = () => { + viewer.mutable.current.setFirstPersonMode(!firstPerson); + }; + + return ( + + + + + + ); +} + /** * Main 3D canvas component. */ @@ -505,7 +548,9 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { pointerInfo.modifierAtDown = modifier; pointerInfo.activeEventTypes = activeEventTypes; pointerInfo.isDragging = true; - mutable.current.cameraControl!.enabled = false; + if (!viewer.useGui.get().firstPersonCamera) { + mutable.current.cameraControl!.enabled = false; + } const ctx = mutable.current.canvas2d!.getContext("2d")!; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); @@ -562,12 +607,14 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { // Reset gesture state and erase the rectangle overlay before any // early return -- otherwise a server callback removed mid-gesture // can leave stale ``isDragging`` or a drawn rectangle behind. - mutable.current.cameraControl!.enabled = true; pointerInfo.isDragging = false; const activeEventTypes = pointerInfo.activeEventTypes; pointerInfo.activeEventTypes = new Set(); const ctx = mutable.current.canvas2d!.getContext("2d")!; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + if (!viewer.useGui.get().firstPersonCamera) { + mutable.current.cameraControl!.enabled = true; + } if (!wasDragging || activeEventTypes.size === 0) return; const modifier = pointerInfo.modifierAtDown; @@ -629,6 +676,7 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { {!inView && } {sceneContents} + ); } diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 665029590..ab2b8fa73 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -1,14 +1,42 @@ import { ViewerContext } from "./ViewerContext"; -import { CameraControls, Instance, Instances } from "@react-three/drei"; +import { + CameraControls, + Grid, + Instance, + Instances, + PivotControls, + PointerLockControls, +} from "@react-three/drei"; import { useThree } from "@react-three/fiber"; -import * as holdEvent from "hold-event"; -import React, { useContext, useRef, useState } from "react"; +import React, { useContext, useLayoutEffect, 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 "./WebsocketUtils"; -import { Grid, PivotControls } from "@react-three/drei"; + +const CAMERA_KEY_CODES = new Set([ + "KeyW", + "KeyA", + "KeyS", + "KeyD", + "KeyQ", + "KeyE", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", +]); + +function isInputEvent(event: KeyboardEvent) { + const target = event.target; + return ( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} function CrosshairVisual({ visible, @@ -86,7 +114,7 @@ function OrbitOriginTool({ crosshairVisible, }: { forceShow: boolean; - pivotRef: React.RefObject; + pivotRef: React.RefObject; onPivotChange: (matrix: THREE.Matrix4) => void; update: () => void; crosshairVisible: boolean; @@ -103,7 +131,7 @@ function OrbitOriginTool({ const show = showOrbitOriginTool || forceShow; return ( } scale={200} lineWidth={3} fixed={true} @@ -136,6 +164,11 @@ function OrbitOriginTool({ export function SynchronizedCameraControls() { const viewer = useContext(ViewerContext)!; const camera = useThree((state) => state.camera as PerspectiveCamera); + const gl = useThree((state) => state.gl); + + const isFirstPerson = viewer.useGui((s) => s.firstPersonCamera); + const pointerLockRef = + useRef>(null); const sendCameraThrottled = useThrottledMessageSender(20).send; @@ -143,14 +176,13 @@ export function SynchronizedCameraControls() { const viewerMutable = viewer.mutable.current; - // Crosshair visibility state: separate counter for keyboard and flag for pointer interactions. - const [keyboardCrosshairCounter, setKeyboardCrosshairCounter] = useState(0); + // Crosshair visibility state: separate keyboard and pointer interaction flags. + const [keyboardInputActive, setKeyboardInputActive] = useState(false); const [pointerInteractionActive, setPointerInteractionActive] = useState(false); // Crosshair is visible if either keyboard keys are held or pointer interaction is active. - const crosshairVisible = - keyboardCrosshairCounter > 0 || pointerInteractionActive; + const crosshairVisible = keyboardInputActive || pointerInteractionActive; // Animation state interface. interface CameraAnimation { @@ -332,38 +364,41 @@ export function SynchronizedCameraControls() { initialLookAt.applyMatrix4(T_threeworld_world); camera.up.set(initialUp.x, initialUp.y, initialUp.z); - viewerMutable.cameraControl!.updateCameraUp(); - if (animate) { - viewerMutable.cameraControl!.setLookAt( - initialPos.x, - initialPos.y, - initialPos.z, - initialLookAt.x, - initialLookAt.y, - initialLookAt.z, - true, - ); + const cc = viewerMutable.cameraControl; + if (cc !== null) { + cc.updateCameraUp(); + if (animate) { + cc.setLookAt( + initialPos.x, + initialPos.y, + initialPos.z, + initialLookAt.x, + initialLookAt.y, + initialLookAt.z, + true, + ); + } else { + cc.setPosition(initialPos.x, initialPos.y, initialPos.z, false); + cc.setTarget( + initialLookAt.x, + initialLookAt.y, + initialLookAt.z, + false, + ); + } } else { - // Calling setLookAt with animate = false seems to break future calls to - // setLookAt. Possible dpeendency bug. - viewerMutable.cameraControl!.setPosition( - initialPos.x, - initialPos.y, - initialPos.z, - false, - ); - viewerMutable.cameraControl!.setTarget( - initialLookAt.x, - initialLookAt.y, - initialLookAt.z, - false, - ); + camera.position.copy(initialPos); + camera.lookAt(initialLookAt); + camera.updateMatrixWorld(); } }; const searchParams = new URLSearchParams(window.location.search); const forceOrbitOriginTool = searchParams.get("forceOrbitOriginTool") === "1"; const logCamera = viewer.useDevSettings((state) => state.logCamera); + const firstPersonInvertLookY = viewer.useDevSettings( + (state) => state.firstPersonInvertLookY, + ); // Callback for sending cameras. // It makes the code more chaotic, but we preallocate a bunch of things to @@ -374,6 +409,7 @@ export function SynchronizedCameraControls() { const R_world_threeworld = new THREE.Quaternion(); const tmpMatrix4 = new THREE.Matrix4(); const lookAt = new THREE.Vector3(); + const forwardTmp = new THREE.Vector3(); const R_world_camera = new THREE.Quaternion(); const t_world_camera = new THREE.Vector3(); const scale = new THREE.Vector3(); @@ -385,9 +421,10 @@ export function SynchronizedCameraControls() { const canvas = viewerMutable.canvas!; if (camera_control === null) { - // Camera controls not yet ready, let's re-try later. - setTimeout(sendCamera, 10); - return; + if (!isFirstPerson) { + setTimeout(sendCamera, 10); + return; + } } // We put Z up to match the scene tree, and convert threejs camera convention @@ -402,7 +439,13 @@ export function SynchronizedCameraControls() { .multiply(tmpMatrix4.makeRotationFromQuaternion(R_threecam_cam)); R_world_threeworld.setFromRotationMatrix(T_world_threeworld); - camera_control.getTarget(lookAt).applyQuaternion(R_world_threeworld); + if (isFirstPerson) { + three_camera.getWorldDirection(forwardTmp); + lookAt.copy(three_camera.position).add(forwardTmp); + } else { + camera_control!.getTarget(lookAt); + } + lookAt.applyQuaternion(R_world_threeworld); const up = three_camera.up.clone().applyQuaternion(R_world_threeworld); T_world_camera.decompose(t_world_camera, R_world_camera, scale); @@ -443,7 +486,7 @@ export function SynchronizedCameraControls() { `&initialCameraFar=${three_camera.far}`, ); } - }, [camera, sendCameraThrottled, logCamera]); + }, [camera, sendCameraThrottled, logCamera, isFirstPerson]); // Send camera for new connections. // We add a small delay to give the server time to add a callback. @@ -492,116 +535,364 @@ export function SynchronizedCameraControls() { return () => resizeObserver.disconnect(); }, [canvas, sendCamera]); - // Keyboard controls. - React.useEffect(() => { - const cameraControls = viewerMutable.cameraControl!; - - const keys = { - w: new holdEvent.KeyboardKeyHold("KeyW", 1000 / 60), - a: new holdEvent.KeyboardKeyHold("KeyA", 1000 / 60), - s: new holdEvent.KeyboardKeyHold("KeyS", 1000 / 60), - d: new holdEvent.KeyboardKeyHold("KeyD", 1000 / 60), - q: new holdEvent.KeyboardKeyHold("KeyQ", 1000 / 60), - e: new holdEvent.KeyboardKeyHold("KeyE", 1000 / 60), - up: new holdEvent.KeyboardKeyHold("ArrowUp", 1000 / 60), - down: new holdEvent.KeyboardKeyHold("ArrowDown", 1000 / 60), - left: new holdEvent.KeyboardKeyHold("ArrowLeft", 1000 / 60), - right: new holdEvent.KeyboardKeyHold("ArrowRight", 1000 / 60), + const orbitTargetDistanceRef = useRef(1.0); + const pendingOrbitPoseRef = useRef<{ + position: THREE.Vector3; + target: THREE.Vector3; + up: THREE.Vector3; + } | null>(null); + + const captureOrbitTargetDistance = React.useCallback(() => { + const cc = viewerMutable.cameraControl; + if (cc === null) { + return; + } + const target = cc.getTarget(new THREE.Vector3()); + orbitTargetDistanceRef.current = Math.max( + 0.01, + camera.position.distanceTo(target), + ); + }, [camera, viewerMutable]); + + const captureFirstPersonExitPose = React.useCallback(() => { + const position = camera.position.clone(); + const direction = new THREE.Vector3(); + camera.getWorldDirection(direction); + pendingOrbitPoseRef.current = { + position, + target: position + .clone() + .addScaledVector(direction, orbitTargetDistanceRef.current), + up: camera.up.clone(), }; + }, [camera]); + + const activeCameraKeysRef = useRef(new Set()); + const keyboardYawAxis = useRef(new THREE.Vector3()).current; + const clearCameraKeys = React.useCallback(() => { + activeCameraKeysRef.current.clear(); + setKeyboardInputActive(false); + }, []); + + const setFirstPersonMode = React.useCallback((enabled: boolean) => { + const isCurrentlyFirstPerson = viewer.useGui.get().firstPersonCamera; + clearCameraKeys(); + if (enabled && !isCurrentlyFirstPerson) { + captureOrbitTargetDistance(); + } else if (!enabled && isCurrentlyFirstPerson) { + captureFirstPersonExitPose(); + } + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + viewer.useGui.set({ firstPersonCamera: enabled }); + if (!enabled) { + if (document.pointerLockElement === gl.domElement) { + document.exitPointerLock(); + } + return; + } - // TODO: these event listeners are currently never removed, even if this - // component gets unmounted. - keys.a.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.truck(-0.002 * event?.deltaTime, 0, false); - }); - keys.d.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.truck(0.002 * event?.deltaTime, 0, false); - }); - keys.w.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.forward(0.002 * event?.deltaTime, false); - }); - keys.s.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.forward(-0.002 * event?.deltaTime, false); - }); - keys.q.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.elevate(-0.002 * event?.deltaTime, false); - }); - keys.e.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.elevate(0.002 * event?.deltaTime, false); - }); - keys.left.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.rotate( - -0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - 0, - true, - ); - }); - keys.right.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.rotate( - 0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - 0, - true, - ); - }); - keys.up.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.rotate( - 0, - -0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - true, - ); - }); - keys.down.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - cameraControls.rotate( - 0, - 0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - true, - ); - }); - for (const key of Object.values(keys)) { - key.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLD_START, () => { - // Keyboard inputs can overlap, so increment counter. - setKeyboardCrosshairCounter((count) => count + 1); - }); - key.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLD_END, () => { - // Decrement counter when key is released. - setKeyboardCrosshairCounter((count) => Math.max(0, count - 1)); + try { + const request = gl.domElement.requestPointerLock() as + | Promise + | undefined; + void request?.catch(() => { + viewer.useGui.set({ firstPersonCamera: false }); }); + } catch { + viewer.useGui.set({ firstPersonCamera: false }); } + }, [ + captureFirstPersonExitPose, + captureOrbitTargetDistance, + clearCameraKeys, + gl.domElement, + viewer.useGui, + ]); + + React.useLayoutEffect(() => { + viewerMutable.setFirstPersonMode = setFirstPersonMode; + return () => { + if (viewerMutable.setFirstPersonMode === setFirstPersonMode) { + viewerMutable.setFirstPersonMode = () => null; + } + }; + }, [setFirstPersonMode, viewerMutable]); - // TODO: we currently don't remove any event listeners. This is a bit messy - // because KeyboardKeyHold attaches listeners directly to the - // document/window; it's unclear if we can remove these. + React.useEffect(() => { + clearCameraKeys(); + }, [clearCameraKeys, isFirstPerson]); + + React.useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "p" || event.key === "P") { + if (isInputEvent(event)) { + return; + } + if (event.repeat) { + return; + } + event.preventDefault(); + if (isFirstPerson) { + setFirstPersonMode(false); + } else { + setFirstPersonMode(true); + } + return; + } + if (!CAMERA_KEY_CODES.has(event.code)) { + return; + } + if (isInputEvent(event) && !isFirstPerson) { + return; + } + event.preventDefault(); + activeCameraKeysRef.current.add(event.code); + setKeyboardInputActive(true); + }; + const onKeyUp = (event: KeyboardEvent) => { + if (!CAMERA_KEY_CODES.has(event.code)) { + return; + } + if (isInputEvent(event) && !isFirstPerson) { + return; + } + event.preventDefault(); + activeCameraKeysRef.current.delete(event.code); + setKeyboardInputActive(activeCameraKeysRef.current.size > 0); + }; + const onVisibilityChange = () => { + if (document.visibilityState !== "visible") { + clearCameraKeys(); + } + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", clearCameraKeys); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", clearCameraKeys); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [clearCameraKeys, isFirstPerson, setFirstPersonMode]); + + React.useEffect(() => { + const onPointerLockChange = () => { + if ( + document.pointerLockElement !== gl.domElement && + viewer.useGui.get().firstPersonCamera + ) { + setFirstPersonMode(false); + } + }; + document.addEventListener("pointerlockchange", onPointerLockChange); return () => { + document.removeEventListener("pointerlockchange", onPointerLockChange); + }; + }, [gl.domElement, setFirstPersonMode, viewer.useGui]); + + // Keyboard controls. + useFrame((_, deltaSeconds) => { + const activeCameraKeys = activeCameraKeysRef.current; + if (activeCameraKeys.size === 0) { + return; + } + + const milliseconds = deltaSeconds * 1000.0; + const moveScale = 0.002 * milliseconds; + const rotateScale = 0.05 * THREE.MathUtils.DEG2RAD * milliseconds; + + if (isFirstPerson) { + let changed = false; + if (activeCameraKeys.has("KeyA")) { + camera.translateX(-moveScale); + changed = true; + } + if (activeCameraKeys.has("KeyD")) { + camera.translateX(moveScale); + changed = true; + } + if (activeCameraKeys.has("KeyW")) { + camera.translateZ(-moveScale); + changed = true; + } + if (activeCameraKeys.has("KeyS")) { + camera.translateZ(moveScale); + changed = true; + } + if (activeCameraKeys.has("KeyQ")) { + camera.translateY(-moveScale); + changed = true; + } + if (activeCameraKeys.has("KeyE")) { + camera.translateY(moveScale); + changed = true; + } + if (activeCameraKeys.has("ArrowLeft")) { + keyboardYawAxis.copy(camera.up).normalize(); + camera.rotateOnWorldAxis(keyboardYawAxis, rotateScale); + changed = true; + } + if (activeCameraKeys.has("ArrowRight")) { + keyboardYawAxis.copy(camera.up).normalize(); + camera.rotateOnWorldAxis(keyboardYawAxis, -rotateScale); + changed = true; + } + const pitchMul = firstPersonInvertLookY ? 1 : -1; + if (activeCameraKeys.has("ArrowUp")) { + camera.rotateX(pitchMul * rotateScale); + changed = true; + } + if (activeCameraKeys.has("ArrowDown")) { + camera.rotateX(-pitchMul * rotateScale); + changed = true; + } + if (changed) { + sendCamera(); + } return; + } + + const cameraControls = viewerMutable.cameraControl; + if (cameraControls === null) { + return; + } + let changed = false; + if (activeCameraKeys.has("KeyA")) { + cameraControls.truck(-moveScale, 0, false); + changed = true; + } + if (activeCameraKeys.has("KeyD")) { + cameraControls.truck(moveScale, 0, false); + changed = true; + } + if (activeCameraKeys.has("KeyW")) { + cameraControls.forward(moveScale, false); + changed = true; + } + if (activeCameraKeys.has("KeyS")) { + cameraControls.forward(-moveScale, false); + changed = true; + } + if (activeCameraKeys.has("KeyQ")) { + cameraControls.elevate(-moveScale, false); + changed = true; + } + if (activeCameraKeys.has("KeyE")) { + cameraControls.elevate(moveScale, false); + changed = true; + } + if (activeCameraKeys.has("ArrowLeft")) { + cameraControls.rotate(-rotateScale, 0, true); + changed = true; + } + if (activeCameraKeys.has("ArrowRight")) { + cameraControls.rotate(rotateScale, 0, true); + changed = true; + } + if (activeCameraKeys.has("ArrowUp")) { + cameraControls.rotate(0, -rotateScale, true); + changed = true; + } + if (activeCameraKeys.has("ArrowDown")) { + cameraControls.rotate(0, rotateScale, true); + changed = true; + } + if (changed) { + sendCamera(); + } + }); + + // Yaw: +movementX matches common FPS feel vs stock three pointer-lock. + // Pitch sign: default normal FPS; optional invert via dev settings. + useLayoutEffect(() => { + if (!isFirstPerson) return; + const c = pointerLockRef.current; + if (c == null) return; + if (document.pointerLockElement === gl.domElement) { + c.isLocked = true; + } + const sens = 2e-3; + const pitchMul = firstPersonInvertLookY ? 1 : -1; + const yawAxis = new THREE.Vector3(); + const pl = c as unknown as { onMouseMove: (e: MouseEvent) => void }; + pl.onMouseMove = (e) => { + if (!c.domElement || !c.isLocked) return; + yawAxis.copy(c.camera.up).normalize(); + c.camera.rotateOnWorldAxis(yawAxis, -e.movementX * sens * c.pointerSpeed); + c.camera.rotateX(pitchMul * e.movementY * sens * c.pointerSpeed); + c.dispatchEvent({ type: "change" } as never); }; - }, [viewerMutable.cameraControl]); + }, [gl.domElement, isFirstPerson, firstPersonInvertLookY]); + + const setCameraControlsRef = React.useCallback( + (controls: React.ElementRef | null) => { + viewerMutable.cameraControl = controls; + const pendingOrbitPose = pendingOrbitPoseRef.current; + if (controls === null || pendingOrbitPose === null) { + return; + } + pendingOrbitPoseRef.current = null; + camera.position.copy(pendingOrbitPose.position); + camera.up.copy(pendingOrbitPose.up); + controls.updateCameraUp(); + controls.setLookAt( + pendingOrbitPose.position.x, + pendingOrbitPose.position.y, + pendingOrbitPose.position.z, + pendingOrbitPose.target.x, + pendingOrbitPose.target.y, + pendingOrbitPose.target.z, + false, + ); + sendCamera(); + }, + [camera, sendCamera, viewerMutable], + ); return ( <> - (viewerMutable.cameraControl = controls)} - minDistance={0.01} - dollySpeed={0.3} - smoothTime={0.05} - draggingSmoothTime={0.0} - onChange={sendCamera} - onStart={() => { - setPointerInteractionActive(true); - }} - onEnd={() => { - setPointerInteractionActive(false); - }} - makeDefault - /> - { - updateCameraLookAtAndUpFromPivotControl(matrix); - }} - update={updatePivotControlFromCameraLookAtAndup} - crosshairVisible={crosshairVisible} - /> + {!isFirstPerson ? ( + { + setPointerInteractionActive(true); + }} + onEnd={() => { + setPointerInteractionActive(false); + }} + makeDefault + /> + ) : null} + {!isFirstPerson ? ( + { + updateCameraLookAtAndUpFromPivotControl(matrix); + }} + update={updatePivotControlFromCameraLookAtAndup} + crosshairVisible={crosshairVisible} + /> + ) : null} + {isFirstPerson ? ( + { + setFirstPersonMode(false); + }} + /> + ) : null} ); diff --git a/src/viser/client/src/ControlPanel/GuiState.ts b/src/viser/client/src/ControlPanel/GuiState.ts index 7a9ceae8b..8dd72d64b 100644 --- a/src/viser/client/src/ControlPanel/GuiState.ts +++ b/src/viser/client/src/ControlPanel/GuiState.ts @@ -17,6 +17,8 @@ export interface GuiState { websocketState: "connected" | "reconnecting" | "inactive"; backgroundAvailable: boolean; showOrbitOriginTool: boolean; + /** When true: pointer-look + WASD-style fly mode. When false: orbit camera. */ + firstPersonCamera: boolean; guiUuidSetFromContainerUuid: { [containerUuid: string]: { [uuid: string]: true } | undefined; }; @@ -78,6 +80,7 @@ const cleanGuiState: GuiState = { websocketState: "inactive", backgroundAvailable: false, showOrbitOriginTool: false, + firstPersonCamera: false, guiUuidSetFromContainerUuid: { root: {} }, modals: [], guiOrderFromUuid: {}, diff --git a/src/viser/client/src/ControlPanel/ServerControls.tsx b/src/viser/client/src/ControlPanel/ServerControls.tsx index 5aebc0dfb..a05eca133 100644 --- a/src/viser/client/src/ControlPanel/ServerControls.tsx +++ b/src/viser/client/src/ControlPanel/ServerControls.tsx @@ -6,6 +6,7 @@ import { Checkbox, Divider, Group, + SegmentedControl, Stack, Text, TextInput, @@ -26,6 +27,7 @@ export default function ServerControls() { const viewer = React.useContext(ViewerContext)!; const viewerMutable = viewer.mutable.current; // Get mutable once const controlWidth = viewer.useGui((state) => state.theme.control_width); + const firstPersonCamera = viewer.useGui((state) => state.firstPersonCamera); const [showDevSettings, setShowDevSettings] = React.useState(false); return ( @@ -136,6 +138,39 @@ export default function ServerControls() { Reset View + + Orbit: drag to rotate and zoom around a target. +
+ First person: captures the mouse immediately. Look with the mouse +
+ and move with WASD and the arrow keys. Press Esc to +
+ return to orbit mode. You can also press P to toggle modes. + + } + refProp="rootRef" + position="top-start" + > + + + Camera mode + + { + viewerMutable.setFirstPersonMode(value === "first-person"); + }} + data={[ + { label: "Orbit", value: "orbit" }, + { label: "First person", value: "first-person" }, + ]} + /> + +
state.enableOrbitCrosshair, ); + const firstPersonInvertLookY = devSettingsStore( + (state) => state.firstPersonInvertLookY, + ); const darkMode = viewer.useGui((state) => state.theme.dark_mode); const setDarkMode = (dark: boolean) => { @@ -108,6 +111,28 @@ export function DevSettingsPanel({ devSettingsStore }: DevSettingsPanelProps) { /> + + When on, first-person mouse and arrow-up/down pitch are flipped + (flight-sim style). + + } + refProp="rootRef" + > + + devSettingsStore.set({ + firstPersonInvertLookY: event.currentTarget.checked, + }) + } + size="xs" + /> + + diff --git a/src/viser/client/src/DevSettingsStore.ts b/src/viser/client/src/DevSettingsStore.ts index ee73a3a05..10920eb7c 100644 --- a/src/viser/client/src/DevSettingsStore.ts +++ b/src/viser/client/src/DevSettingsStore.ts @@ -6,6 +6,8 @@ type DevSettingsState = { fixedDpr: number | null; logCamera: boolean; enableOrbitCrosshair: boolean; + /** First person: flip mouse (and arrow) pitch; default is normal FPS pitch. */ + firstPersonInvertLookY: boolean; }; /** Create a dev settings store with initial values from URL search params for backward compatibility. */ @@ -24,6 +26,7 @@ export function useDevSettingsStore() { fixedDpr, logCamera, enableOrbitCrosshair: true, + firstPersonInvertLookY: false, }); })[0]; } diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 3afdf62be..f61491a35 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -303,7 +303,10 @@ function useMessageHandler() { return; } - const cameraControls = viewerMutable.cameraControl!; + const cameraControls = viewerMutable.cameraControl; + if (cameraControls === null) { + return; + } const T_threeworld_world = computeT_threeworld_world(viewer); const target = new THREE.Vector3( @@ -324,7 +327,10 @@ function useMessageHandler() { } const camera = viewerMutable.camera!; - const cameraControls = viewerMutable.cameraControl!; + const cameraControls = viewerMutable.cameraControl; + if (cameraControls === null) { + return; + } const T_threeworld_world = computeT_threeworld_world(viewer); const updir = new THREE.Vector3( message.position[0], @@ -360,7 +366,10 @@ function useMessageHandler() { return; } - const cameraControls = viewerMutable.cameraControl!; + const cameraControls = viewerMutable.cameraControl; + if (cameraControls === null) { + return; + } // Set the camera position. Due to the look-at, note that this will // shift the orientation as-well. diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 41eacd713..b5060bcf3 100644 --- a/src/viser/client/src/VersionInfo.ts +++ b/src/viser/client/src/VersionInfo.ts @@ -1,6 +1,6 @@ // Automatically generated file - do not edit manually. // This is synchronized with the Python package version in viser/__init__.py. -export const VISER_VERSION = "1.0.26"; +export const VISER_VERSION = "1.0.36"; // GitHub contributors for the viser project. export interface Contributor { diff --git a/src/viser/client/src/ViewerContext.ts b/src/viser/client/src/ViewerContext.ts index 43fc2cf94..3d395de1c 100644 --- a/src/viser/client/src/ViewerContext.ts +++ b/src/viser/client/src/ViewerContext.ts @@ -31,6 +31,7 @@ export type ViewerMutable = { sendMessage: (message: Message) => void; sendCamera: (() => void) | null; resetCameraPose: ((animate: boolean) => void) | null; + setFirstPersonMode: (enabled: boolean) => void; // DOM/Three.js references. canvas: HTMLCanvasElement | null;