diff --git a/deps/cloudxr/webxr_client/src/CloudXRUI.tsx b/deps/cloudxr/webxr_client/src/CloudXRUI.tsx index 4ff470b7e..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, Euler, Group, Mesh, MeshStandardMaterial, Quaternion, Vector3 } from 'three'; +import { Color, Group, Mesh, MeshStandardMaterial, Object3D, Vector3 } from 'three'; import { damp } from 'three/src/math/MathUtils.js'; // Face-camera rotation constants @@ -80,16 +80,25 @@ 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'); 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, @@ -161,17 +170,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 ( @@ -189,6 +205,7 @@ export default function CloudXR3DUI({ scale={false} multitouch={false} rotate={false} + apply={applyPositionSkipRotation} >