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 {