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;