From db103454ba4fb3bf6a3b0665b9aa710d656a453e Mon Sep 17 00:00:00 2001 From: Yanzi Zhu Date: Thu, 4 Jun 2026 21:30:03 -0700 Subject: [PATCH 1/2] fix(webclient): fix XR panel snapping to face away from user when dragged to the side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the face-camera rotation logic in CloudXRUI: 1. Missing angle wrapping before damp(): eulerHelper.y lives in [-π, +π]. When the panel is near the side of the scene the target angle can flip from +π-ε to -π+ε — geometrically ~0° but numerically ~2π. damp() is a plain lerp so it interpolated the full ~2π the wrong way, spinning the panel to face backward. Fix wraps the diff to [-π, π] before calling damp() so it always takes the shortest rotation path. 2. 3D quaternion for a yaw-only problem: setFromUnitVectors(zAxis, dir3D) computed the shortest-path 3D rotation including height offset, so the extracted Euler Y was not the correct horizontal yaw when camera and panel heights differed. Replaced with Math.atan2(dx, dz) on the XZ plane which gives correct horizontal yaw directly. Co-Authored-By: Claude Sonnet 4.6 --- deps/cloudxr/webxr_client/src/CloudXRUI.tsx | 34 ++++++++++++--------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/CloudXRUI.tsx b/deps/cloudxr/webxr_client/src/CloudXRUI.tsx index 4ff470b7e..649367727 100644 --- a/deps/cloudxr/webxr_client/src/CloudXRUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXRUI.tsx @@ -43,7 +43,7 @@ import { Handle, HandleTarget } from '@react-three/handle'; import { Container, Text, Image } from '@react-three/uikit'; import { Button } from '@react-three/uikit-default'; import React, { useRef, useState, useEffect } from 'react'; -import { Color, Euler, Group, Mesh, MeshStandardMaterial, Quaternion, Vector3 } from 'three'; +import { Color, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three'; import { damp } from 'three/src/math/MathUtils.js'; // Face-camera rotation constants @@ -80,11 +80,8 @@ interface CloudXRUIProps { } // Reusable objects for face-camera rotation (avoid allocations in render loop) -const eulerHelper = new Euler(); -const quaternionHelper = new Quaternion(); const cameraPositionHelper = new Vector3(); const uiPositionHelper = new Vector3(); -const zAxis = new Vector3(0, 0, 1); // Handle hover colors (module-level to avoid per-render allocations) const HANDLE_COLOR_DEFAULT = new Color('#666666'); @@ -161,17 +158,24 @@ export default function CloudXR3DUI({ } state.camera.getWorldPosition(cameraPositionHelper); groupRef.current.getWorldPosition(uiPositionHelper); - quaternionHelper.setFromUnitVectors( - zAxis, - cameraPositionHelper.sub(uiPositionHelper).normalize() - ); - eulerHelper.setFromQuaternion(quaternionHelper, 'YXZ'); - groupRef.current.rotation.y = damp( - groupRef.current.rotation.y, - eulerHelper.y, - FACE_CAMERA_DAMPING, - dt - ); + + // Project onto the horizontal plane (XZ) to get a pure yaw angle. + // Using atan2 avoids the 3D-quaternion→Euler extraction that can give wrong + // yaw when the camera has significant height offset relative to the panel. + const dx = cameraPositionHelper.x - uiPositionHelper.x; + const dz = cameraPositionHelper.z - uiPositionHelper.z; + let targetY = Math.atan2(dx, dz); + + // Wrap the angular difference to [-π, π] so damp() always takes the + // shortest path. Without this, when the target crosses the ±π boundary + // (camera near the side/behind the panel), damp interpolates the long way + // around and the panel snaps to face away from the user. + const currentY = groupRef.current.rotation.y; + let diff = targetY - currentY; + diff = diff - Math.round(diff / (2 * Math.PI)) * (2 * Math.PI); + targetY = currentY + diff; + + groupRef.current.rotation.y = damp(currentY, targetY, FACE_CAMERA_DAMPING, dt); }); return ( From 5968908e7c6e148f16a84c4d3e56240497ff7636 Mon Sep 17 00:00:00 2001 From: yanziz-nv Date: Sat, 13 Jun 2026 18:55:58 -0700 Subject: [PATCH 2/2] fix(webclient): stop Handle from resetting face-camera rotation on drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle's defaultApply copies state.current.quaternion to the target on every drag frame (useFrame priority -1) and again on drag release. With rotate={false}, state.current.quaternion is always the quaternion captured at drag-start, so it continuously undoes whatever rotation face-camera (priority 0) applied, and wipes all face-camera work entirely when the drag ends. This caused the panel to snap to the rotation it had when the drag began — up to 90° away from the correct facing direction. Fix: pass a custom apply function (applyPositionSkipRotation) to Handle that copies only position, leaving quaternion/rotation entirely under face-camera control throughout and after the drag. Co-Authored-By: Claude Sonnet 4.6 --- deps/cloudxr/webxr_client/src/CloudXRUI.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/CloudXRUI.tsx b/deps/cloudxr/webxr_client/src/CloudXRUI.tsx index 649367727..4b5c034d9 100644 --- a/deps/cloudxr/webxr_client/src/CloudXRUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXRUI.tsx @@ -39,11 +39,11 @@ import { PerformanceCanvasImage } from '@helpers/react/PerformanceCanvasImage'; import { useXRButton } from '@helpers/react/useXRButton'; import { ReadonlySignal } from '@preact/signals-react'; import { useFrame } from '@react-three/fiber'; -import { Handle, HandleTarget } from '@react-three/handle'; +import { Handle, HandleTarget, HandleState } from '@react-three/handle'; import { Container, Text, Image } from '@react-three/uikit'; import { Button } from '@react-three/uikit-default'; import React, { useRef, useState, useEffect } from 'react'; -import { Color, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three'; +import { Color, Group, Mesh, MeshStandardMaterial, Object3D, Vector3 } from 'three'; import { damp } from 'three/src/math/MathUtils.js'; // Face-camera rotation constants @@ -87,6 +87,18 @@ const uiPositionHelper = new Vector3(); const HANDLE_COLOR_DEFAULT = new Color('#666666'); const HANDLE_COLOR_HOVER = new Color('#aaaaaa'); +// Workaround for @pmndrs/handle defaultApply behavior: defaultApply copies +// state.current.quaternion to the target on every drag frame AND on drag release. +// With rotate={false}, state.current.quaternion is always the drag-start quaternion, +// so it resets our face-camera rotation on every frame (priority -1 runs before +// face-camera priority 0) and wipes it entirely on drag release. +// By providing a custom apply that skips quaternion, face-camera owns rotation fully. +// Scale is intentionally omitted too: scale={false} keeps it constant, so copying +// it would be a no-op. If scale is ever enabled on this Handle, add it back here. +function applyPositionSkipRotation(state: HandleState, target: Object3D): void { + target.position.copy(state.current.position); +} + export default function CloudXR3DUI({ onStartTeleop, onDisconnect, @@ -193,6 +205,7 @@ export default function CloudXR3DUI({ scale={false} multitouch={false} rotate={false} + apply={applyPositionSkipRotation} >