From 97c383af6c5954a040d33b1f55c8413f3d6deead Mon Sep 17 00:00:00 2001 From: mehdikhfifi <145874140+mehdikhfifi@users.noreply.github.com> Date: Thu, 22 Aug 2024 14:37:44 -0700 Subject: [PATCH 01/21] Update CameraControls.tsx --- src/viser/client/src/CameraControls.tsx | 274 ++++++++++-------------- 1 file changed, 114 insertions(+), 160 deletions(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index a9c3ea7cf..548d083ba 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -1,110 +1,71 @@ import { ViewerContext } from "./App"; -import { CameraControls } from "@react-three/drei"; +import { makeThrottledMessageSender } from "./WebsocketFunctions"; +import { PointerLockControls, OrbitControls } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; -import * as holdEvent from "hold-event"; -import React, { useContext, useRef } from "react"; -import { PerspectiveCamera } from "three"; +import React, { useContext, useEffect, useRef, useCallback, useState } from "react"; import * as THREE from "three"; -import { computeT_threeworld_world } from "./WorldTransformUtils"; -import { useThrottledMessageSender } from "./WebsocketFunctions"; + +interface SynchronizedCameraControlsProps { + useFirstPersonControls: boolean; +} export function SynchronizedCameraControls() { const viewer = useContext(ViewerContext)!; - const camera = useThree((state) => state.camera as PerspectiveCamera); + const [isFirstPerson, setIsFirstPerson] = useState(false); - const sendCameraThrottled = useThrottledMessageSender(20); - // Helper for resetting camera poses. + const { camera, gl, scene } = useThree(); + const controlsRef = useRef(null); + const orbitControlsRef = useRef(null); + var speed = 0.03; + + const sendCameraThrottled = makeThrottledMessageSender( + viewer, + 20, + ); + + // helper for resetting camera poses. const initialCameraRef = useRef<{ - camera: PerspectiveCamera; - lookAt: THREE.Vector3; + position: THREE.Vector3; + rotation: THREE.Euler; } | null>(null); viewer.resetCameraViewRef.current = () => { - viewer.cameraControlRef.current!.setLookAt( - initialCameraRef.current!.camera.position.x, - initialCameraRef.current!.camera.position.y, - initialCameraRef.current!.camera.position.z, - initialCameraRef.current!.lookAt.x, - initialCameraRef.current!.lookAt.y, - 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(); + if (initialCameraRef.current) { + camera.position.copy(initialCameraRef.current.position); + camera.rotation.copy(initialCameraRef.current.rotation); + } }; // Callback for sending cameras. - // It makes the code more chaotic, but we preallocate a bunch of things to - // minimize garbage collection! - const R_threecam_cam = new THREE.Quaternion().setFromEuler( - new THREE.Euler(Math.PI, 0.0, 0.0), - ); - const R_world_threeworld = new THREE.Quaternion(); - const tmpMatrix4 = new THREE.Matrix4(); - const lookAt = new THREE.Vector3(); - const R_world_camera = new THREE.Quaternion(); - const t_world_camera = new THREE.Vector3(); - const scale = new THREE.Vector3(); - const sendCamera = React.useCallback(() => { - const three_camera = camera; - const camera_control = viewer.cameraControlRef.current; - - if (camera_control === null) { - // Camera controls not yet ready, let's re-try later. - setTimeout(sendCamera, 10); - return; - } + const sendCamera = useCallback(() => { + if (!controlsRef.current && !orbitControlsRef.current) return; + + const { position, quaternion } = camera; + const rotation = new THREE.Euler().setFromQuaternion(quaternion); - // We put Z up to match the scene tree, and convert threejs camera convention - // to the OpenCV one. - const T_world_threeworld = computeT_threeworld_world(viewer).invert(); - const T_world_camera = T_world_threeworld.clone() - .multiply( - tmpMatrix4 - .makeRotationFromQuaternion(three_camera.quaternion) - .setPosition(three_camera.position), - ) - .multiply(tmpMatrix4.makeRotationFromQuaternion(R_threecam_cam)); - R_world_threeworld.setFromRotationMatrix(T_world_threeworld); - - camera_control.getTarget(lookAt).applyQuaternion(R_world_threeworld); - const up = three_camera.up.clone().applyQuaternion(R_world_threeworld); - - //Store initial camera values + // Store initial camera values if (initialCameraRef.current === null) { initialCameraRef.current = { - camera: three_camera.clone(), - lookAt: camera_control.getTarget(new THREE.Vector3()), + position: position.clone(), + rotation: rotation.clone(), }; } - T_world_camera.decompose(t_world_camera, R_world_camera, scale); - sendCameraThrottled({ type: "ViewerCameraMessage", - wxyz: [ - R_world_camera.w, - R_world_camera.x, - R_world_camera.y, - R_world_camera.z, - ], - position: t_world_camera.toArray(), - aspect: three_camera.aspect, - fov: (three_camera.fov * Math.PI) / 180.0, - look_at: [lookAt.x, lookAt.y, lookAt.z], - up_direction: [up.x, up.y, up.z], + wxyz: [quaternion.w, quaternion.x, quaternion.y, quaternion.z], + position: position.toArray(), + aspect: (camera as THREE.PerspectiveCamera).aspect || 1, + fov: ((camera as THREE.PerspectiveCamera).fov * Math.PI) / 180.0 || 0, + look_at: [0, 0, 0], // Not used in first-person view + up_direction: [camera.up.x, camera.up.y, camera.up.z], }); }, [camera, sendCameraThrottled]); - // Send camera for new connections. - // We add a small delay to give the server time to add a callback. + // new connections. const connected = viewer.useGui((state) => state.websocketConnected); - React.useEffect(() => { + useEffect(() => { viewer.sendCameraRef.current = sendCamera; if (!connected) return; setTimeout(() => sendCamera(), 50); @@ -112,100 +73,93 @@ export function SynchronizedCameraControls() { // Send camera for 3D viewport changes. const canvas = viewer.canvasRef.current!; // R3F canvas. - React.useEffect(() => { + useEffect(() => { // Create a resize observer to resize the CSS canvas when the window is resized. const resizeObserver = new ResizeObserver(() => { sendCamera(); }); resizeObserver.observe(canvas); - // Cleanup. + // clean up . return () => resizeObserver.disconnect(); }, [canvas]); - // Keyboard controls. - React.useEffect(() => { - const cameraControls = viewer.cameraControlRef.current!; - - const wKey = new holdEvent.KeyboardKeyHold("KeyW", 20); - const aKey = new holdEvent.KeyboardKeyHold("KeyA", 20); - const sKey = new holdEvent.KeyboardKeyHold("KeyS", 20); - const dKey = new holdEvent.KeyboardKeyHold("KeyD", 20); - const qKey = new holdEvent.KeyboardKeyHold("KeyQ", 20); - const eKey = new holdEvent.KeyboardKeyHold("KeyE", 20); - - // TODO: these event listeners are currently never removed, even if this - // component gets unmounted. - aKey.addEventListener("holding", (event) => { - cameraControls.truck(-0.002 * event?.deltaTime, 0, true); - }); - dKey.addEventListener("holding", (event) => { - cameraControls.truck(0.002 * event?.deltaTime, 0, true); - }); - wKey.addEventListener("holding", (event) => { - cameraControls.forward(0.002 * event?.deltaTime, true); - }); - sKey.addEventListener("holding", (event) => { - cameraControls.forward(-0.002 * event?.deltaTime, true); - }); - qKey.addEventListener("holding", (event) => { - cameraControls.elevate(-0.002 * event?.deltaTime, true); - }); - eKey.addEventListener("holding", (event) => { - cameraControls.elevate(0.002 * event?.deltaTime, true); - }); + // state for the for camera velocity + const [velocity, setVelocity] = useState(new THREE.Vector3()); - const leftKey = new holdEvent.KeyboardKeyHold("ArrowLeft", 20); - const rightKey = new holdEvent.KeyboardKeyHold("ArrowRight", 20); - const upKey = new holdEvent.KeyboardKeyHold("ArrowUp", 20); - const downKey = new holdEvent.KeyboardKeyHold("ArrowDown", 20); - leftKey.addEventListener("holding", (event) => { - cameraControls.rotate( - -0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - 0, - true, - ); - }); - rightKey.addEventListener("holding", (event) => { - cameraControls.rotate( - 0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - 0, - true, - ); - }); - upKey.addEventListener("holding", (event) => { - cameraControls.rotate( - 0, - -0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - true, - ); - }); - downKey.addEventListener("holding", (event) => { - cameraControls.rotate( - 0, - 0.05 * THREE.MathUtils.DEG2RAD * event?.deltaTime, - true, - ); - }); + // Apply velocity to the camera + useEffect(() => { + const applyVelocity = () => { + camera.translateX(velocity.x); + camera.translateY(velocity.y); + camera.translateZ(velocity.z); + sendCamera(); + + // ~apply damping to simulate inertia + velocity.multiplyScalar(0.9); + + // Stop the loop if velocity is very small + if (velocity.length() > 0.001) { + requestAnimationFrame(applyVelocity); + } + }; + + applyVelocity(); + }, [velocity, camera, sendCamera]); + + // Keyboard controls for movement. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const newVelocity = velocity.clone(); + switch (event.key) { + case 'w': + newVelocity.z -= speed; + break; + case 's': + newVelocity.z += speed; + break; + case 'a': + newVelocity.x -= speed; + break; + case 'd': + newVelocity.x += speed; + break; + case 'q': + newVelocity.y -= speed; + break; + case 'e': + newVelocity.y += speed; + break; + case 'p': + + setIsFirstPerson(prev => { + if (prev) { + // If switching from first-person to orbit, release the pointer lock + document.exitPointerLock(); + } + return !prev;}); + break; + default: + break; + } + setVelocity(newVelocity); + }; + + window.addEventListener('keydown', handleKeyDown); - // 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. + // Cleanup event listener on component unmount return () => { - return; + window.removeEventListener('keydown', handleKeyDown); }; - }, [CameraControls]); + }, [velocity]); return ( - + <> + {isFirstPerson ? ( + + ) : ( + + )} + ); } From 6b13c6261b85772169aefc1ff6b89cc812e592f9 Mon Sep 17 00:00:00 2001 From: mehdikhfifi <145874140+mehdikhfifi@users.noreply.github.com> Date: Thu, 22 Aug 2024 14:48:09 -0700 Subject: [PATCH 02/21] Update CameraControls.tsx fixed eslint failure modes --- src/viser/client/src/CameraControls.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 548d083ba..ad6855da4 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -5,19 +5,17 @@ import { useThree } from "@react-three/fiber"; import React, { useContext, useEffect, useRef, useCallback, useState } from "react"; import * as THREE from "three"; -interface SynchronizedCameraControlsProps { - useFirstPersonControls: boolean; -} + export function SynchronizedCameraControls() { const viewer = useContext(ViewerContext)!; const [isFirstPerson, setIsFirstPerson] = useState(false); - const { camera, gl, scene } = useThree(); + const { camera, gl } = useThree(); const controlsRef = useRef(null); const orbitControlsRef = useRef(null); - var speed = 0.03; + const speed = 0.03; const sendCameraThrottled = makeThrottledMessageSender( viewer, From 6da0bf5b6c27bc5d01a7e1dc97fbb1f2bb1c72c9 Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 4 May 2026 22:20:57 -0700 Subject: [PATCH 03/21] `1.0.27` --- src/viser/__init__.py | 2 +- src/viser/client/src/VersionInfo.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index de76fb938..d53193f4c 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.27" diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 41eacd713..c36a16a9a 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.27"; // GitHub contributors for the viser project. export interface Contributor { From 6777227629a96174d53eb646befb836cf29cfe77 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 5 May 2026 17:34:14 +1200 Subject: [PATCH 04/21] . --- src/viser/client/src/App.tsx | 37 +++++++++++++++++- src/viser/client/src/CameraControls.tsx | 8 ++-- src/viser/client/src/ControlPanel/GuiState.ts | 3 ++ .../src/ControlPanel/ServerControls.tsx | 39 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 4ab271dec..af741d01b 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -455,6 +455,34 @@ function NotificationsPanel() { ); } +/** Read-only label so the active camera mode stays visible on the viewport. */ +function CameraModeIndicator() { + const viewer = React.useContext(ViewerContext)!; + const firstPerson = viewer.useGui((state) => state.firstPersonCamera); + const dark = viewer.useGui((state) => state.theme.dark_mode); + return ( + + {firstPerson ? "First person" : "Orbit"} + + ); +} + /** * Main 3D canvas component. */ @@ -505,7 +533,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 +592,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 +661,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 1eef6edd4..5e1be7b89 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -144,7 +144,7 @@ export function SynchronizedCameraControls() { const camera = useThree((state) => state.camera as PerspectiveCamera); const gl = useThree((state) => state.gl); - const [isFirstPerson, setIsFirstPerson] = useState(false); + const isFirstPerson = viewer.useGui((s) => s.firstPersonCamera); const sendCameraThrottled = useThrottledMessageSender(20).send; @@ -532,11 +532,11 @@ export function SynchronizedCameraControls() { return; } e.preventDefault(); - setIsFirstPerson((prev) => { - if (prev) { + viewer.useGui.set((state) => { + if (state.firstPersonCamera) { document.exitPointerLock(); } - return !prev; + return { firstPersonCamera: !state.firstPersonCamera }; }); }; window.addEventListener("keydown", onKey); 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..847db1d47 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,43 @@ export default function ServerControls() { Reset View + + Orbit: drag to rotate and zoom around a target. +
+ First person: click the 3D view to capture the mouse, then look +
+ with the mouse and move with WASD and the arrow keys. Press Esc to +
+ release the mouse. You can also press P to toggle modes. + + } + refProp="rootRef" + position="top-start" + > + + + Camera mode + + { + const next = value === "first-person"; + viewer.useGui.set({ firstPersonCamera: next }); + if (!next) { + document.exitPointerLock(); + } + }} + data={[ + { label: "Orbit", value: "orbit" }, + { label: "First person", value: "first-person" }, + ]} + /> + +
Date: Tue, 5 May 2026 17:38:38 +1200 Subject: [PATCH 05/21] . --- src/viser/__init__.py | 2 +- src/viser/client/src/VersionInfo.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index de76fb938..d53193f4c 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.27" diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 41eacd713..c36a16a9a 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.27"; // GitHub contributors for the viser project. export interface Contributor { From 797ec0779f24044724135429f556d0eb6378b903 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 5 May 2026 17:48:12 +1200 Subject: [PATCH 06/21] Fix mouse view --- src/viser/client/src/CameraControls.tsx | 106 +++++++++++++----------- src/viser/client/src/MessageHandler.tsx | 15 +++- 2 files changed, 69 insertions(+), 52 deletions(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 5e1be7b89..04a9fe2ef 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -9,7 +9,7 @@ import { } 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"; @@ -341,32 +341,32 @@ 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(); } }; @@ -395,9 +395,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 @@ -416,7 +417,7 @@ export function SynchronizedCameraControls() { three_camera.getWorldDirection(forwardTmp); lookAt.copy(three_camera.position).add(forwardTmp); } else { - camera_control.getTarget(lookAt); + camera_control!.getTarget(lookAt); } lookAt.applyQuaternion(R_world_threeworld); const up = three_camera.up.clone().applyQuaternion(R_world_threeworld); @@ -509,7 +510,7 @@ export function SynchronizedCameraControls() { }, [canvas, sendCamera]); const wasFirstPerson = useRef(false); - React.useEffect(() => { + useLayoutEffect(() => { if (wasFirstPerson.current && !isFirstPerson) { const cc = viewerMutable.cameraControl; if (cc !== null) { @@ -548,7 +549,10 @@ export function SynchronizedCameraControls() { if (isFirstPerson) { return; } - const cameraControls = viewerMutable.cameraControl!; + const cameraControls = viewerMutable.cameraControl; + if (cameraControls === null) { + return; + } const keys = { w: new holdEvent.KeyboardKeyHold("KeyW", 1000 / 60), @@ -711,22 +715,23 @@ export function SynchronizedCameraControls() { return ( <> - (viewerMutable.cameraControl = controls)} - minDistance={0.01} - dollySpeed={0.3} - smoothTime={0.05} - draggingSmoothTime={0.0} - enabled={!isFirstPerson} - onChange={sendCamera} - onStart={() => { - setPointerInteractionActive(true); - }} - onEnd={() => { - setPointerInteractionActive(false); - }} - makeDefault - /> + {!isFirstPerson ? ( + (viewerMutable.cameraControl = controls)} + minDistance={0.01} + dollySpeed={0.3} + smoothTime={0.05} + draggingSmoothTime={0.0} + onChange={sendCamera} + onStart={() => { + setPointerInteractionActive(true); + }} + onEnd={() => { + setPointerInteractionActive(false); + }} + makeDefault + /> + ) : null} {!isFirstPerson ? ( ) : null} {isFirstPerson ? ( - + ) : null} 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. From 16de3cb7a649f0c8b409c77025e2e9e938b95d66 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 5 May 2026 17:49:50 +1200 Subject: [PATCH 07/21] bump --- src/viser/__init__.py | 2 +- src/viser/client/src/VersionInfo.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index d53193f4c..daf880fb9 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.27" +__version__ = "1.0.28" diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index c36a16a9a..115a02d69 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.27"; +export const VISER_VERSION = "1.0.28"; // GitHub contributors for the viser project. export interface Contributor { From 6d13a777f2afe1d8e64d61c4cd46126247e72b26 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 5 May 2026 18:25:50 +1200 Subject: [PATCH 08/21] Un-invert X drag for FPS --- src/viser/client/src/CameraControls.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 04a9fe2ef..e4d2b6c94 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -145,6 +145,8 @@ export function SynchronizedCameraControls() { const gl = useThree((state) => state.gl); const isFirstPerson = viewer.useGui((s) => s.firstPersonCamera); + const pointerLockRef = + useRef>(null); const sendCameraThrottled = useThrottledMessageSender(20).send; @@ -713,6 +715,27 @@ export function SynchronizedCameraControls() { }; }, [isFirstPerson, camera, sendCamera]); + // Optional yaw sign for FPS feel with Viser's camera/world frame; drei matches + // the standard Three.js pointer-lock example, not a broken default. + useLayoutEffect(() => { + if (!isFirstPerson) return; + const c = pointerLockRef.current; + if (c == null) return; + const euler = new THREE.Euler(0, 0, 0, "YXZ"); + const sens = 2e-3; + const pl = c as unknown as { onMouseMove: (e: MouseEvent) => void }; + pl.onMouseMove = (e) => { + if (!c.domElement || !c.isLocked) return; + euler.setFromQuaternion(c.camera.quaternion); + euler.y += e.movementX * sens * c.pointerSpeed; + euler.x -= e.movementY * sens * c.pointerSpeed; + const h = Math.PI / 2; + euler.x = Math.max(h - c.maxPolarAngle, Math.min(h - c.minPolarAngle, euler.x)); + c.camera.quaternion.setFromEuler(euler); + c.dispatchEvent({ type: "change" } as never); + }; + }, [isFirstPerson]); + return ( <> {!isFirstPerson ? ( @@ -745,6 +768,7 @@ export function SynchronizedCameraControls() { ) : null} {isFirstPerson ? ( From 0e35d2bd0ab17aca582c9e6be65ee5d43f0d287a Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 5 May 2026 18:39:07 +1200 Subject: [PATCH 09/21] Inverted Y toggle --- src/viser/client/src/CameraControls.tsx | 23 ++++++++++++++------- src/viser/client/src/DevSettingsPanel.tsx | 25 +++++++++++++++++++++++ src/viser/client/src/DevSettingsStore.ts | 3 +++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index e4d2b6c94..032ae6b70 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -375,6 +375,9 @@ export function SynchronizedCameraControls() { 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 @@ -693,12 +696,17 @@ export function SynchronizedCameraControls() { ); sendCamera(); }); + const pitchMul = firstPersonInvertLookY ? 1 : -1; keys.up.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.rotateX(-0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0)); + camera.rotateX( + pitchMul * 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), + ); sendCamera(); }); keys.down.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.rotateX(0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0)); + camera.rotateX( + -pitchMul * 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), + ); sendCamera(); }); for (const key of Object.values(keys)) { @@ -713,28 +721,29 @@ export function SynchronizedCameraControls() { return () => { return; }; - }, [isFirstPerson, camera, sendCamera]); + }, [isFirstPerson, camera, sendCamera, firstPersonInvertLookY]); - // Optional yaw sign for FPS feel with Viser's camera/world frame; drei matches - // the standard Three.js pointer-lock example, not a broken default. + // 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; const euler = new THREE.Euler(0, 0, 0, "YXZ"); const sens = 2e-3; + const pitchMul = firstPersonInvertLookY ? 1 : -1; const pl = c as unknown as { onMouseMove: (e: MouseEvent) => void }; pl.onMouseMove = (e) => { if (!c.domElement || !c.isLocked) return; euler.setFromQuaternion(c.camera.quaternion); euler.y += e.movementX * sens * c.pointerSpeed; - euler.x -= e.movementY * sens * c.pointerSpeed; + euler.x += pitchMul * e.movementY * sens * c.pointerSpeed; const h = Math.PI / 2; euler.x = Math.max(h - c.maxPolarAngle, Math.min(h - c.minPolarAngle, euler.x)); c.camera.quaternion.setFromEuler(euler); c.dispatchEvent({ type: "change" } as never); }; - }, [isFirstPerson]); + }, [isFirstPerson, firstPersonInvertLookY]); return ( <> diff --git a/src/viser/client/src/DevSettingsPanel.tsx b/src/viser/client/src/DevSettingsPanel.tsx index dc96f248c..398064d57 100644 --- a/src/viser/client/src/DevSettingsPanel.tsx +++ b/src/viser/client/src/DevSettingsPanel.tsx @@ -18,6 +18,9 @@ export function DevSettingsPanel({ devSettingsStore }: DevSettingsPanelProps) { const enableOrbitCrosshair = devSettingsStore( (state) => 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]; } From bce4b5fbbb3912e0c7eec7c8c8d5b92c0d43f2f4 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 24 May 2026 19:57:21 +1200 Subject: [PATCH 10/21] Respect camera up in first-person controls --- src/viser/client/src/CameraControls.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 032ae6b70..a0796905c 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -644,7 +644,7 @@ export function SynchronizedCameraControls() { if (!isFirstPerson) { return; } - const worldY = new THREE.Vector3(0, 1, 0); + const yawAxis = new THREE.Vector3(); const keys = { w: new holdEvent.KeyboardKeyHold("KeyW", 1000 / 60), a: new holdEvent.KeyboardKeyHold("KeyA", 1000 / 60), @@ -683,15 +683,17 @@ export function SynchronizedCameraControls() { sendCamera(); }); keys.left.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { + yawAxis.copy(camera.up).normalize(); camera.rotateOnWorldAxis( - worldY, + yawAxis, 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), ); sendCamera(); }); keys.right.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { + yawAxis.copy(camera.up).normalize(); camera.rotateOnWorldAxis( - worldY, + yawAxis, -0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), ); sendCamera(); @@ -729,18 +731,15 @@ export function SynchronizedCameraControls() { if (!isFirstPerson) return; const c = pointerLockRef.current; if (c == null) return; - const euler = new THREE.Euler(0, 0, 0, "YXZ"); 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; - euler.setFromQuaternion(c.camera.quaternion); - euler.y += e.movementX * sens * c.pointerSpeed; - euler.x += pitchMul * e.movementY * sens * c.pointerSpeed; - const h = Math.PI / 2; - euler.x = Math.max(h - c.maxPolarAngle, Math.min(h - c.minPolarAngle, euler.x)); - c.camera.quaternion.setFromEuler(euler); + 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); }; }, [isFirstPerson, firstPersonInvertLookY]); From 4e8918e9cb71701808b79e2bc13a999fefe240dd Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 24 May 2026 20:09:05 +1200 Subject: [PATCH 11/21] Make camera mode indicator toggleable --- src/viser/client/src/App.tsx | 55 +++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index af741d01b..a61d22fb2 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. @@ -455,31 +457,46 @@ function NotificationsPanel() { ); } -/** Read-only label so the active camera mode stays visible on the viewport. */ +/** 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.useGui.set({ firstPersonCamera: !firstPerson }); + if (firstPerson) { + document.exitPointerLock(); + } + }; + return ( - - {firstPerson ? "First person" : "Orbit"} - + + + + ); } From b4b6f76aad124050a2703ae2126c619025030e0b Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 24 May 2026 20:15:57 +1200 Subject: [PATCH 12/21] Fix first-person mouse yaw direction --- src/viser/client/src/CameraControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index a0796905c..be14557fa 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -738,7 +738,7 @@ export function SynchronizedCameraControls() { 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.rotateOnWorldAxis(yawAxis, -e.movementX * sens * c.pointerSpeed); c.camera.rotateX(pitchMul * e.movementY * sens * c.pointerSpeed); c.dispatchEvent({ type: "change" } as never); }; From 079465f5725d7fad5253b8c8ca08f897e3de3492 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 24 May 2026 20:18:43 +1200 Subject: [PATCH 13/21] Bump version to 1.0.30 --- src/viser/__init__.py | 2 +- src/viser/client/src/VersionInfo.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index daf880fb9..2cbfd0b83 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.28" +__version__ = "1.0.30" diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 115a02d69..cd1d3304c 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.28"; +export const VISER_VERSION = "1.0.30"; // GitHub contributors for the viser project. export interface Contributor { From 50ca878bdd29fcd55a06c280ba66f14eefa5758e Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 24 May 2026 20:32:02 +1200 Subject: [PATCH 14/21] Build client during package builds --- hatch_build.py | 66 ++++++++++++++++++++++++++++++++++ pyproject.toml | 4 ++- src/viser/_client_autobuild.py | 6 ++-- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 hatch_build.py diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..61cb744b7 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,66 @@ +import os +import subprocess +import sys +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + 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 + + 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/_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, ) From 6db3bf51d55ea6beb53e3d09dc7e8b708f7ebbd4 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Mon, 25 May 2026 16:31:33 +1200 Subject: [PATCH 15/21] Fix first-person key and pointer lock state --- src/viser/__init__.py | 2 +- src/viser/client/src/App.tsx | 22 +- src/viser/client/src/CameraControls.tsx | 411 ++++++++++-------- .../src/ControlPanel/ServerControls.tsx | 37 +- src/viser/client/src/VersionInfo.ts | 2 +- 5 files changed, 280 insertions(+), 194 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 2cbfd0b83..9823a65f6 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.30" +__version__ = "1.0.31" diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index a61d22fb2..985d3b1f8 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -467,9 +467,27 @@ function CameraModeIndicator() { const Icon = firstPerson ? IconCamera : IconView360; const toggleMode = () => { - viewer.useGui.set({ firstPersonCamera: !firstPerson }); if (firstPerson) { - document.exitPointerLock(); + viewer.useGui.set({ firstPersonCamera: false }); + if (document.pointerLockElement === viewer.mutable.current.canvas) { + document.exitPointerLock(); + } + return; + } + + viewer.useGui.set({ firstPersonCamera: true }); + const canvas = viewer.mutable.current.canvas; + if (canvas === null) { + viewer.useGui.set({ firstPersonCamera: false }); + return; + } + try { + const request = canvas.requestPointerLock() as Promise | undefined; + void request?.catch(() => { + viewer.useGui.set({ firstPersonCamera: false }); + }); + } catch { + viewer.useGui.set({ firstPersonCamera: false }); } }; diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index be14557fa..9cb1972bd 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -8,7 +8,6 @@ import { PointerLockControls, } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; -import * as holdEvent from "hold-event"; import React, { useContext, useLayoutEffect, useRef, useState } from "react"; import { useFrame } from "@react-three/fiber"; import { PerspectiveCamera } from "three"; @@ -16,6 +15,29 @@ import * as THREE from "three"; import { computeT_threeworld_world } from "./WorldTransformUtils"; import { useThrottledMessageSender } from "./WebsocketUtils"; +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, children, @@ -154,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 { @@ -529,201 +550,220 @@ export function SynchronizedCameraControls() { wasFirstPerson.current = isFirstPerson; }, [isFirstPerson, camera, viewerMutable.cameraControl]); + const activeCameraKeysRef = useRef(new Set()); + const keyboardYawAxis = useRef(new THREE.Vector3()).current; + const clearCameraKeys = React.useCallback(() => { + activeCameraKeysRef.current.clear(); + setKeyboardInputActive(false); + }, []); + const requestPointerLock = React.useCallback(() => { + try { + const request = gl.domElement.requestPointerLock() as + | Promise + | undefined; + void request?.catch(() => { + viewer.useGui.set({ firstPersonCamera: false }); + }); + } catch { + viewer.useGui.set({ firstPersonCamera: false }); + } + }, [gl.domElement, viewer.useGui]); + const exitFirstPerson = React.useCallback(() => { + clearCameraKeys(); + viewer.useGui.set({ firstPersonCamera: false }); + if (document.pointerLockElement === gl.domElement) { + document.exitPointerLock(); + } + }, [clearCameraKeys, gl.domElement, viewer.useGui]); + const enterFirstPerson = React.useCallback(() => { + clearCameraKeys(); + viewer.useGui.set({ firstPersonCamera: true }); + requestPointerLock(); + }, [clearCameraKeys, requestPointerLock, viewer.useGui]); + + React.useEffect(() => { + clearCameraKeys(); + }, [clearCameraKeys, isFirstPerson]); + React.useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.repeat) { + const onKeyDown = (event: KeyboardEvent) => { + if (isInputEvent(event)) { return; } - if (e.key !== "p" && e.key !== "P") { + if (event.key === "p" || event.key === "P") { + if (event.repeat) { + return; + } + event.preventDefault(); + if (isFirstPerson) { + exitFirstPerson(); + } else { + enterFirstPerson(); + } return; } - e.preventDefault(); - viewer.useGui.set((state) => { - if (state.firstPersonCamera) { - document.exitPointerLock(); - } - return { firstPersonCamera: !state.firstPersonCamera }; - }); + if (!CAMERA_KEY_CODES.has(event.code)) { + return; + } + event.preventDefault(); + activeCameraKeysRef.current.add(event.code); + setKeyboardInputActive(true); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + const onKeyUp = (event: KeyboardEvent) => { + if (isInputEvent(event)) { + return; + } + if (!CAMERA_KEY_CODES.has(event.code)) { + 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, enterFirstPerson, exitFirstPerson, isFirstPerson]); - // Keyboard controls (orbit mode). React.useEffect(() => { + const onPointerLockChange = () => { + if ( + document.pointerLockElement !== gl.domElement && + viewer.useGui.get().firstPersonCamera + ) { + clearCameraKeys(); + viewer.useGui.set({ firstPersonCamera: false }); + } + }; + document.addEventListener("pointerlockchange", onPointerLockChange); + return () => { + document.removeEventListener("pointerlockchange", onPointerLockChange); + }; + }, [clearCameraKeys, gl.domElement, 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; } - - 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), - }; - - // 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)); - }); + let changed = false; + if (activeCameraKeys.has("KeyA")) { + cameraControls.truck(-moveScale, 0, false); + changed = true; } - - // 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. - return () => { - return; - }; - }, [viewerMutable.cameraControl, isFirstPerson]); - - // Keyboard controls (first-person mode). - React.useEffect(() => { - if (!isFirstPerson) { - return; + if (activeCameraKeys.has("KeyD")) { + cameraControls.truck(moveScale, 0, false); + changed = true; } - const yawAxis = new THREE.Vector3(); - 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), - }; - - keys.a.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateX(-0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.d.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateX(0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.w.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateZ(-0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.s.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateZ(0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.q.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateY(-0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.e.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.translateY(0.002 * (event?.deltaTime ?? 0)); - sendCamera(); - }); - keys.left.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - yawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis( - yawAxis, - 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), - ); - sendCamera(); - }); - keys.right.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - yawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis( - yawAxis, - -0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), - ); - sendCamera(); - }); - const pitchMul = firstPersonInvertLookY ? 1 : -1; - keys.up.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.rotateX( - pitchMul * 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), - ); - sendCamera(); - }); - keys.down.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLDING, (event) => { - camera.rotateX( - -pitchMul * 0.05 * THREE.MathUtils.DEG2RAD * (event?.deltaTime ?? 0), - ); + 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(); - }); - for (const key of Object.values(keys)) { - key.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLD_START, () => { - setKeyboardCrosshairCounter((count) => count + 1); - }); - key.addEventListener(holdEvent.HOLD_EVENT_TYPE.HOLD_END, () => { - setKeyboardCrosshairCounter((count) => Math.max(0, count - 1)); - }); } - - return () => { - return; - }; - }, [isFirstPerson, camera, sendCamera, firstPersonInvertLookY]); + }); // Yaw: +movementX matches common FPS feel vs stock three pointer-lock. // Pitch sign: default normal FPS; optional invert via dev settings. @@ -731,6 +771,9 @@ export function SynchronizedCameraControls() { 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(); @@ -742,7 +785,7 @@ export function SynchronizedCameraControls() { c.camera.rotateX(pitchMul * e.movementY * sens * c.pointerSpeed); c.dispatchEvent({ type: "change" } as never); }; - }, [isFirstPerson, firstPersonInvertLookY]); + }, [gl.domElement, isFirstPerson, firstPersonInvertLookY]); return ( <> @@ -779,6 +822,10 @@ export function SynchronizedCameraControls() { ref={pointerLockRef} domElement={gl.domElement} onChange={sendCamera} + onUnlock={() => { + clearCameraKeys(); + viewer.useGui.set({ firstPersonCamera: false }); + }} /> ) : null} diff --git a/src/viser/client/src/ControlPanel/ServerControls.tsx b/src/viser/client/src/ControlPanel/ServerControls.tsx index 847db1d47..ae0d742a3 100644 --- a/src/viser/client/src/ControlPanel/ServerControls.tsx +++ b/src/viser/client/src/ControlPanel/ServerControls.tsx @@ -30,6 +30,31 @@ export default function ServerControls() { const firstPersonCamera = viewer.useGui((state) => state.firstPersonCamera); const [showDevSettings, setShowDevSettings] = React.useState(false); + const setFirstPersonMode = (enabled: boolean) => { + viewer.useGui.set({ firstPersonCamera: enabled }); + if (!enabled) { + if (document.pointerLockElement === viewerMutable.canvas) { + document.exitPointerLock(); + } + return; + } + + if (viewerMutable.canvas === null) { + viewer.useGui.set({ firstPersonCamera: false }); + return; + } + try { + const request = viewerMutable.canvas.requestPointerLock() as + | Promise + | undefined; + void request?.catch(() => { + viewer.useGui.set({ firstPersonCamera: false }); + }); + } catch { + viewer.useGui.set({ firstPersonCamera: false }); + } + }; + return ( <> @@ -143,11 +168,11 @@ export default function ServerControls() { <> Orbit: drag to rotate and zoom around a target.
- First person: click the 3D view to capture the mouse, then look + First person: captures the mouse immediately. Look with the mouse
- with the mouse and move with WASD and the arrow keys. Press Esc to + and move with WASD and the arrow keys. Press Esc to
- release the mouse. You can also press P to toggle modes. + return to orbit mode. You can also press P to toggle modes. } refProp="rootRef" @@ -162,11 +187,7 @@ export default function ServerControls() { size="sm" value={firstPersonCamera ? "first-person" : "orbit"} onChange={(value) => { - const next = value === "first-person"; - viewer.useGui.set({ firstPersonCamera: next }); - if (!next) { - document.exitPointerLock(); - } + setFirstPersonMode(value === "first-person"); }} data={[ { label: "Orbit", value: "orbit" }, diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index cd1d3304c..4d67d12b7 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.30"; +export const VISER_VERSION = "1.0.31"; // GitHub contributors for the viser project. export interface Contributor { From 1f8589f598ebaf3cd8fad03aac615b55efaf1744 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Tue, 26 May 2026 00:37:32 +1200 Subject: [PATCH 16/21] Fix first-person arrow key yaw direction --- src/viser/__init__.py | 2 +- src/viser/client/src/CameraControls.tsx | 4 ++-- src/viser/client/src/VersionInfo.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 9823a65f6..ab896a5cc 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.31" +__version__ = "1.0.32" diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 9cb1972bd..e2a5e6044 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -692,12 +692,12 @@ export function SynchronizedCameraControls() { } if (activeCameraKeys.has("ArrowLeft")) { keyboardYawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis(keyboardYawAxis, rotateScale); + camera.rotateOnWorldAxis(keyboardYawAxis, -rotateScale); changed = true; } if (activeCameraKeys.has("ArrowRight")) { keyboardYawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis(keyboardYawAxis, -rotateScale); + camera.rotateOnWorldAxis(keyboardYawAxis, rotateScale); changed = true; } const pitchMul = firstPersonInvertLookY ? 1 : -1; diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 4d67d12b7..bb0f5fbb6 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.31"; +export const VISER_VERSION = "1.0.32"; // GitHub contributors for the viser project. export interface Contributor { From 682ca59036f72c15f99b4165c0d43aeccd56e82c Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Thu, 4 Jun 2026 19:31:18 +1200 Subject: [PATCH 17/21] Preserve orbit state when exiting first-person --- src/viser/__init__.py | 2 +- src/viser/client/src/CameraControls.tsx | 64 ++++++++++++++----------- src/viser/client/src/VersionInfo.ts | 2 +- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index ab896a5cc..f66f3d6b2 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.32" +__version__ = "1.0.33" diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index e2a5e6044..86060f4cf 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -536,16 +536,24 @@ export function SynchronizedCameraControls() { }, [canvas, sendCamera]); const wasFirstPerson = useRef(false); + const orbitTargetDistanceRef = useRef(1.0); useLayoutEffect(() => { - if (wasFirstPerson.current && !isFirstPerson) { - const cc = viewerMutable.cameraControl; - if (cc !== null) { + const cc = viewerMutable.cameraControl; + if (!wasFirstPerson.current && isFirstPerson && cc !== null) { + const target = cc.getTarget(new THREE.Vector3()); + orbitTargetDistanceRef.current = Math.max( + 0.01, + camera.position.distanceTo(target), + ); + } else if (wasFirstPerson.current && !isFirstPerson && cc !== null) { const pos = camera.position; const dir = new THREE.Vector3(); camera.getWorldDirection(dir); - const tgt = dir.add(pos); + const tgt = pos + .clone() + .addScaledVector(dir, orbitTargetDistanceRef.current); + cc.updateCameraUp(); cc.setLookAt(pos.x, pos.y, pos.z, tgt.x, tgt.y, tgt.z, false); - } } wasFirstPerson.current = isFirstPerson; }, [isFirstPerson, camera, viewerMutable.cameraControl]); @@ -587,10 +595,10 @@ export function SynchronizedCameraControls() { React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (isInputEvent(event)) { - return; - } if (event.key === "p" || event.key === "P") { + if (isInputEvent(event)) { + return; + } if (event.repeat) { return; } @@ -605,15 +613,18 @@ export function SynchronizedCameraControls() { 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 (isInputEvent(event)) { + if (!CAMERA_KEY_CODES.has(event.code)) { return; } - if (!CAMERA_KEY_CODES.has(event.code)) { + if (isInputEvent(event) && !isFirstPerson) { return; } event.preventDefault(); @@ -789,23 +800,22 @@ export function SynchronizedCameraControls() { return ( <> - {!isFirstPerson ? ( - (viewerMutable.cameraControl = controls)} - minDistance={0.01} - dollySpeed={0.3} - smoothTime={0.05} - draggingSmoothTime={0.0} - onChange={sendCamera} - onStart={() => { - setPointerInteractionActive(true); - }} - onEnd={() => { - setPointerInteractionActive(false); - }} - makeDefault - /> - ) : null} + (viewerMutable.cameraControl = controls)} + enabled={!isFirstPerson} + minDistance={0.01} + dollySpeed={0.3} + smoothTime={0.05} + draggingSmoothTime={0.0} + onChange={sendCamera} + onStart={() => { + setPointerInteractionActive(true); + }} + onEnd={() => { + setPointerInteractionActive(false); + }} + makeDefault + /> {!isFirstPerson ? ( Date: Thu, 4 Jun 2026 19:40:19 +1200 Subject: [PATCH 18/21] Centralize first-person mode transitions --- src/viser/__init__.py | 2 +- src/viser/client/src/App.tsx | 24 +---------- src/viser/client/src/CameraControls.tsx | 42 ++++++++++++------- .../src/ControlPanel/ServerControls.tsx | 27 +----------- src/viser/client/src/VersionInfo.ts | 2 +- src/viser/client/src/ViewerContext.ts | 1 + 6 files changed, 32 insertions(+), 66 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index f66f3d6b2..70d5f6ed3 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.33" +__version__ = "1.0.34" diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 985d3b1f8..7c4156496 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -204,6 +204,7 @@ function ViewerRoot() { : () => null, sendCamera: null, resetCameraPose: null, + setFirstPersonMode: () => null, // DOM/Three.js references. canvas: null, @@ -467,28 +468,7 @@ function CameraModeIndicator() { const Icon = firstPerson ? IconCamera : IconView360; const toggleMode = () => { - if (firstPerson) { - viewer.useGui.set({ firstPersonCamera: false }); - if (document.pointerLockElement === viewer.mutable.current.canvas) { - document.exitPointerLock(); - } - return; - } - - viewer.useGui.set({ firstPersonCamera: true }); - const canvas = viewer.mutable.current.canvas; - if (canvas === null) { - viewer.useGui.set({ firstPersonCamera: false }); - return; - } - try { - const request = canvas.requestPointerLock() as Promise | undefined; - void request?.catch(() => { - viewer.useGui.set({ firstPersonCamera: false }); - }); - } catch { - viewer.useGui.set({ firstPersonCamera: false }); - } + viewer.mutable.current.setFirstPersonMode(!firstPerson); }; return ( diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 86060f4cf..95e109172 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -564,7 +564,20 @@ export function SynchronizedCameraControls() { activeCameraKeysRef.current.clear(); setKeyboardInputActive(false); }, []); - const requestPointerLock = React.useCallback(() => { + + const setFirstPersonMode = React.useCallback((enabled: boolean) => { + clearCameraKeys(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + viewer.useGui.set({ firstPersonCamera: enabled }); + if (!enabled) { + if (document.pointerLockElement === gl.domElement) { + document.exitPointerLock(); + } + return; + } + try { const request = gl.domElement.requestPointerLock() as | Promise @@ -575,19 +588,16 @@ export function SynchronizedCameraControls() { } catch { viewer.useGui.set({ firstPersonCamera: false }); } - }, [gl.domElement, viewer.useGui]); - const exitFirstPerson = React.useCallback(() => { - clearCameraKeys(); - viewer.useGui.set({ firstPersonCamera: false }); - if (document.pointerLockElement === gl.domElement) { - document.exitPointerLock(); - } }, [clearCameraKeys, gl.domElement, viewer.useGui]); - const enterFirstPerson = React.useCallback(() => { - clearCameraKeys(); - viewer.useGui.set({ firstPersonCamera: true }); - requestPointerLock(); - }, [clearCameraKeys, requestPointerLock, viewer.useGui]); + + React.useLayoutEffect(() => { + viewerMutable.setFirstPersonMode = setFirstPersonMode; + return () => { + if (viewerMutable.setFirstPersonMode === setFirstPersonMode) { + viewerMutable.setFirstPersonMode = () => null; + } + }; + }, [setFirstPersonMode, viewerMutable]); React.useEffect(() => { clearCameraKeys(); @@ -604,9 +614,9 @@ export function SynchronizedCameraControls() { } event.preventDefault(); if (isFirstPerson) { - exitFirstPerson(); + setFirstPersonMode(false); } else { - enterFirstPerson(); + setFirstPersonMode(true); } return; } @@ -646,7 +656,7 @@ export function SynchronizedCameraControls() { window.removeEventListener("blur", clearCameraKeys); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [clearCameraKeys, enterFirstPerson, exitFirstPerson, isFirstPerson]); + }, [clearCameraKeys, isFirstPerson, setFirstPersonMode]); React.useEffect(() => { const onPointerLockChange = () => { diff --git a/src/viser/client/src/ControlPanel/ServerControls.tsx b/src/viser/client/src/ControlPanel/ServerControls.tsx index ae0d742a3..a05eca133 100644 --- a/src/viser/client/src/ControlPanel/ServerControls.tsx +++ b/src/viser/client/src/ControlPanel/ServerControls.tsx @@ -30,31 +30,6 @@ export default function ServerControls() { const firstPersonCamera = viewer.useGui((state) => state.firstPersonCamera); const [showDevSettings, setShowDevSettings] = React.useState(false); - const setFirstPersonMode = (enabled: boolean) => { - viewer.useGui.set({ firstPersonCamera: enabled }); - if (!enabled) { - if (document.pointerLockElement === viewerMutable.canvas) { - document.exitPointerLock(); - } - return; - } - - if (viewerMutable.canvas === null) { - viewer.useGui.set({ firstPersonCamera: false }); - return; - } - try { - const request = viewerMutable.canvas.requestPointerLock() as - | Promise - | undefined; - void request?.catch(() => { - viewer.useGui.set({ firstPersonCamera: false }); - }); - } catch { - viewer.useGui.set({ firstPersonCamera: false }); - } - }; - return ( <> @@ -187,7 +162,7 @@ export default function ServerControls() { size="sm" value={firstPersonCamera ? "first-person" : "orbit"} onChange={(value) => { - setFirstPersonMode(value === "first-person"); + viewerMutable.setFirstPersonMode(value === "first-person"); }} data={[ { label: "Orbit", value: "orbit" }, diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 1bf57bacb..3d4d59e1e 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.33"; +export const VISER_VERSION = "1.0.34"; // 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; From 18fb1a240400c8088c0eba7446a96130aaf8d606 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Thu, 4 Jun 2026 19:54:16 +1200 Subject: [PATCH 19/21] Stop orbit controls updating during first-person --- src/viser/__init__.py | 2 +- src/viser/client/src/CameraControls.tsx | 127 ++++++++++++++++-------- src/viser/client/src/VersionInfo.ts | 2 +- 3 files changed, 88 insertions(+), 43 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 70d5f6ed3..90ef47285 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.34" +__version__ = "1.0.35" diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 95e109172..9c050a978 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -535,28 +535,37 @@ export function SynchronizedCameraControls() { return () => resizeObserver.disconnect(); }, [canvas, sendCamera]); - const wasFirstPerson = useRef(false); const orbitTargetDistanceRef = useRef(1.0); - useLayoutEffect(() => { + const pendingOrbitPoseRef = useRef<{ + position: THREE.Vector3; + target: THREE.Vector3; + up: THREE.Vector3; + } | null>(null); + + const captureOrbitTargetDistance = React.useCallback(() => { const cc = viewerMutable.cameraControl; - if (!wasFirstPerson.current && isFirstPerson && cc !== null) { - const target = cc.getTarget(new THREE.Vector3()); - orbitTargetDistanceRef.current = Math.max( - 0.01, - camera.position.distanceTo(target), - ); - } else if (wasFirstPerson.current && !isFirstPerson && cc !== null) { - const pos = camera.position; - const dir = new THREE.Vector3(); - camera.getWorldDirection(dir); - const tgt = pos - .clone() - .addScaledVector(dir, orbitTargetDistanceRef.current); - cc.updateCameraUp(); - cc.setLookAt(pos.x, pos.y, pos.z, tgt.x, tgt.y, tgt.z, false); + if (cc === null) { + return; } - wasFirstPerson.current = isFirstPerson; - }, [isFirstPerson, camera, viewerMutable.cameraControl]); + 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; @@ -566,7 +575,13 @@ export function SynchronizedCameraControls() { }, []); 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(); } @@ -588,7 +603,13 @@ export function SynchronizedCameraControls() { } catch { viewer.useGui.set({ firstPersonCamera: false }); } - }, [clearCameraKeys, gl.domElement, viewer.useGui]); + }, [ + captureFirstPersonExitPose, + captureOrbitTargetDistance, + clearCameraKeys, + gl.domElement, + viewer.useGui, + ]); React.useLayoutEffect(() => { viewerMutable.setFirstPersonMode = setFirstPersonMode; @@ -664,15 +685,14 @@ export function SynchronizedCameraControls() { document.pointerLockElement !== gl.domElement && viewer.useGui.get().firstPersonCamera ) { - clearCameraKeys(); - viewer.useGui.set({ firstPersonCamera: false }); + setFirstPersonMode(false); } }; document.addEventListener("pointerlockchange", onPointerLockChange); return () => { document.removeEventListener("pointerlockchange", onPointerLockChange); }; - }, [clearCameraKeys, gl.domElement, viewer.useGui]); + }, [gl.domElement, setFirstPersonMode, viewer.useGui]); // Keyboard controls. useFrame((_, deltaSeconds) => { @@ -808,24 +828,50 @@ export function SynchronizedCameraControls() { }; }, [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)} - enabled={!isFirstPerson} - minDistance={0.01} - dollySpeed={0.3} - smoothTime={0.05} - draggingSmoothTime={0.0} - onChange={sendCamera} - onStart={() => { - setPointerInteractionActive(true); - }} - onEnd={() => { - setPointerInteractionActive(false); - }} - makeDefault - /> + {!isFirstPerson ? ( + { + setPointerInteractionActive(true); + }} + onEnd={() => { + setPointerInteractionActive(false); + }} + makeDefault + /> + ) : null} {!isFirstPerson ? ( { - clearCameraKeys(); - viewer.useGui.set({ firstPersonCamera: false }); + setFirstPersonMode(false); }} /> ) : null} diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index 3d4d59e1e..c79812860 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.34"; +export const VISER_VERSION = "1.0.35"; // GitHub contributors for the viser project. export interface Contributor { From d625e6f15563d0e4582c322159f0885742ce37a1 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Mon, 8 Jun 2026 22:35:18 +1200 Subject: [PATCH 20/21] Build client outside source tree --- hatch_build.py | 63 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/hatch_build.py b/hatch_build.py index 61cb744b7..6c32708e3 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -1,12 +1,16 @@ 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 @@ -16,24 +20,47 @@ def initialize(self, version: str, build_data: dict[str, object]) -> None: if not (client_dir / "package.json").exists(): return - 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, - ) + 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: From d3ce0e366687378bd78e832116846bb9a85370d8 Mon Sep 17 00:00:00 2001 From: Oliver Batchelor Date: Sun, 14 Jun 2026 01:38:39 +1200 Subject: [PATCH 21/21] Fix first-person arrow key yaw direction --- src/viser/__init__.py | 2 +- src/viser/client/src/CameraControls.tsx | 4 ++-- src/viser/client/src/VersionInfo.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 90ef47285..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.35" +__version__ = "1.0.36" diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 9c050a978..ab2b8fa73 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -733,12 +733,12 @@ export function SynchronizedCameraControls() { } if (activeCameraKeys.has("ArrowLeft")) { keyboardYawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis(keyboardYawAxis, -rotateScale); + camera.rotateOnWorldAxis(keyboardYawAxis, rotateScale); changed = true; } if (activeCameraKeys.has("ArrowRight")) { keyboardYawAxis.copy(camera.up).normalize(); - camera.rotateOnWorldAxis(keyboardYawAxis, rotateScale); + camera.rotateOnWorldAxis(keyboardYawAxis, -rotateScale); changed = true; } const pitchMul = firstPersonInvertLookY ? 1 : -1; diff --git a/src/viser/client/src/VersionInfo.ts b/src/viser/client/src/VersionInfo.ts index c79812860..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.35"; +export const VISER_VERSION = "1.0.36"; // GitHub contributors for the viser project. export interface Contributor {