ailloli_ui_openxr/openxr_runtime/
panel.rs

1//! Panel-facing, stable-grab, logical/world conversion, and depth-drag geometry.
2
3use ailloli_ui_core::{Point, Size};
4use openxr as xr;
5
6use crate::math::Vec3;
7
8use super::composer::OpenXrQuadLayerOptions;
9
10/// Squared-distance threshold below which facing direction is considered absent.
11const PANEL_FACING_EPSILON: f32 = 1e-6;
12/// Default minimum panel pitch, negative 45 degrees in radians.
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_openxr::DEFAULT_PANEL_PITCH_MIN_RAD;
18/// assert_eq!(DEFAULT_PANEL_PITCH_MIN_RAD.to_degrees(), -45.0);
19/// ```
20pub const DEFAULT_PANEL_PITCH_MIN_RAD: f32 = -std::f32::consts::FRAC_PI_4;
21/// Default maximum panel pitch, positive 45 degrees in radians.
22///
23/// # Examples
24///
25/// ```
26/// use ailloli_ui_openxr::DEFAULT_PANEL_PITCH_MAX_RAD;
27/// assert_eq!(DEFAULT_PANEL_PITCH_MAX_RAD.to_degrees(), 45.0);
28/// ```
29pub const DEFAULT_PANEL_PITCH_MAX_RAD: f32 = std::f32::consts::FRAC_PI_4;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32/// Policy controlling whether panel orientation follows the viewer.
33///
34/// # Examples
35///
36/// ```
37/// use ailloli_ui_openxr::OpenXrPanelFacingMode;
38/// assert_ne!(OpenXrPanelFacingMode::Fixed, OpenXrPanelFacingMode::FaceUserOnDrag);
39/// ```
40pub enum OpenXrPanelFacingMode {
41    /// Preserve the existing layer orientation.
42    Fixed,
43    /// Apply yaw-only viewer facing while the panel is dragged.
44    FaceUserOnDrag,
45    /// Apply yaw-only viewer facing continuously when the host requests updates.
46    FaceUserAlways,
47    /// Apply viewer yaw and clamped pitch while the panel is dragged.
48    FaceUserYawPitchOnDrag,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
52/// Facing policy and inclusive pitch bounds in radians.
53///
54/// Non-finite bounds normalize to +/-45 degrees and reversed bounds are swapped.
55///
56/// # Examples
57///
58/// ```
59/// use ailloli_ui_openxr::{OpenXrPanelFacingMode, OpenXrPanelFacingOptions};
60/// let options = OpenXrPanelFacingOptions::default();
61/// assert_eq!(options.mode, OpenXrPanelFacingMode::FaceUserOnDrag);
62/// ```
63pub struct OpenXrPanelFacingOptions {
64    /// Orientation policy.
65    pub mode: OpenXrPanelFacingMode,
66    /// Inclusive minimum pitch in radians.
67    pub pitch_min_rad: f32,
68    /// Inclusive maximum pitch in radians.
69    pub pitch_max_rad: f32,
70}
71
72impl Default for OpenXrPanelFacingOptions {
73    fn default() -> Self {
74        Self {
75            mode: OpenXrPanelFacingMode::FaceUserOnDrag,
76            pitch_min_rad: DEFAULT_PANEL_PITCH_MIN_RAD,
77            pitch_max_rad: DEFAULT_PANEL_PITCH_MAX_RAD,
78        }
79    }
80}
81
82impl OpenXrPanelFacingOptions {
83    /// Creates options and immediately normalizes both pitch bounds.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use ailloli_ui_openxr::{OpenXrPanelFacingMode, OpenXrPanelFacingOptions};
89    /// let options = OpenXrPanelFacingOptions::new(OpenXrPanelFacingMode::FaceUserYawPitchOnDrag, 0.5, -0.5);
90    /// assert_eq!((options.pitch_min_rad, options.pitch_max_rad), (-0.5, 0.5));
91    /// ```
92    pub fn new(mode: OpenXrPanelFacingMode, pitch_min_rad: f32, pitch_max_rad: f32) -> Self {
93        let mut options = Self {
94            mode,
95            pitch_min_rad,
96            pitch_max_rad,
97        };
98        options.normalize_pitch_bounds();
99        options
100    }
101
102    /// Replaces non-finite defaults and orders the pitch bounds in place.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use ailloli_ui_openxr::{OpenXrPanelFacingMode, OpenXrPanelFacingOptions};
108    /// let mut options = OpenXrPanelFacingOptions { mode: OpenXrPanelFacingMode::Fixed, pitch_min_rad: f32::NAN, pitch_max_rad: f32::INFINITY };
109    /// options.normalize_pitch_bounds();
110    /// assert_eq!((options.pitch_min_rad.to_degrees(), options.pitch_max_rad.to_degrees()), (-45.0, 45.0));
111    /// ```
112    pub fn normalize_pitch_bounds(&mut self) {
113        if !self.pitch_min_rad.is_finite() {
114            self.pitch_min_rad = DEFAULT_PANEL_PITCH_MIN_RAD;
115        }
116        if !self.pitch_max_rad.is_finite() {
117            self.pitch_max_rad = DEFAULT_PANEL_PITCH_MAX_RAD;
118        }
119        if self.pitch_min_rad > self.pitch_max_rad {
120            std::mem::swap(&mut self.pitch_min_rad, &mut self.pitch_max_rad);
121        }
122    }
123}
124
125#[derive(Debug, Clone, Copy)]
126/// Retained state that keeps a grabbed panel point stable while it reorients.
127///
128/// World-space vectors use metres. Optional head and ray fields are `None` until
129/// the corresponding valid pose has been observed.
130///
131/// # Examples
132///
133/// ```
134/// use ailloli_ui_openxr::openxr_runtime::OpenXrPanelGrabState;
135/// use ailloli_ui_openxr::math::Vec3;
136/// let grab = OpenXrPanelGrabState::new(Vec3::default(), openxr::Posef::IDENTITY.orientation);
137/// assert!(grab.depth_axis.is_none());
138/// ```
139pub struct OpenXrPanelGrabState {
140    /// Grabbed point in panel-local metres.
141    pub local_grab_point_m: Vec3,
142    /// Most recently observed valid HMD position in world metres.
143    pub last_valid_head_pos: Option<Vec3>,
144    /// Most recently applied valid panel orientation.
145    pub last_valid_rotation: xr::Quaternionf,
146    /// Normalized initial pointer direction used for depth translation.
147    pub depth_axis: Option<Vec3>,
148    /// Last pointer origin paired with `depth_axis`.
149    pub last_ray_origin: Option<Vec3>,
150}
151
152impl OpenXrPanelGrabState {
153    /// Creates a grab state with no head pose or pointer-depth history.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use ailloli_ui_openxr::openxr_runtime::OpenXrPanelGrabState;
159    /// use ailloli_ui_openxr::math::Vec3;
160    /// let state = OpenXrPanelGrabState::new(Vec3::new(0.1, 0.2, 0.0), openxr::Posef::IDENTITY.orientation);
161    /// assert_eq!(state.local_grab_point_m.x, 0.1);
162    /// ```
163    pub fn new(local_grab_point_m: Vec3, initial_rotation: xr::Quaternionf) -> Self {
164        Self {
165            local_grab_point_m,
166            last_valid_head_pos: None,
167            last_valid_rotation: initial_rotation,
168            depth_axis: None,
169            last_ray_origin: None,
170        }
171    }
172
173    /// Initializes pointer-depth tracking and returns the updated state.
174    ///
175    /// A near-zero direction leaves both depth fields `None`.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ailloli_ui_openxr::openxr_runtime::OpenXrPanelGrabState;
181    /// use ailloli_ui_openxr::math::Vec3;
182    /// let state = OpenXrPanelGrabState::new(Vec3::default(), openxr::Posef::IDENTITY.orientation).with_pointer_depth(Vec3::default(), Vec3::new(0.0, 0.0, -2.0));
183    /// assert_eq!(state.depth_axis, Some(Vec3::new(0.0, 0.0, -1.0)));
184    /// ```
185    pub fn with_pointer_depth(mut self, ray_origin: Vec3, ray_direction: Vec3) -> Self {
186        self.set_pointer_depth(ray_origin, ray_direction);
187        self
188    }
189
190    /// Replaces the normalized depth axis and its initial ray origin.
191    ///
192    /// A direction of length at most `1e-6` clears both values.
193    ///
194    /// # Examples
195    ///
196    /// ```
197    /// use ailloli_ui_openxr::openxr_runtime::OpenXrPanelGrabState;
198    /// use ailloli_ui_openxr::math::Vec3;
199    /// let mut state = OpenXrPanelGrabState::new(Vec3::default(), openxr::Posef::IDENTITY.orientation);
200    /// state.set_pointer_depth(Vec3::new(1.0, 2.0, 3.0), Vec3::default());
201    /// assert!(state.last_ray_origin.is_none());
202    /// ```
203    pub fn set_pointer_depth(&mut self, ray_origin: Vec3, ray_direction: Vec3) {
204        self.depth_axis = ray_direction.normalize();
205        self.last_ray_origin = self.depth_axis.map(|_| ray_origin);
206    }
207}
208
209#[derive(Clone, Copy)]
210/// Result of applying a stable-grab facing update.
211///
212/// # Examples
213///
214/// ```
215/// use ailloli_ui_openxr::OpenXrQuadLayerOptions;
216/// use ailloli_ui_openxr::openxr_runtime::panel::OpenXrPanelGrabUpdate;
217/// let update = OpenXrPanelGrabUpdate { layer: OpenXrQuadLayerOptions::default(), hmd_pose_seen: false, yaw_applied: false, grab_point_stable: false, pitch_deg: 0.0, pitch_clamped: false };
218/// assert!(!update.hmd_pose_seen);
219/// ```
220pub struct OpenXrPanelGrabUpdate {
221    /// Updated layer pose and unchanged layer presentation properties.
222    pub layer: OpenXrQuadLayerOptions,
223    /// Whether the call received an HMD position.
224    pub hmd_pose_seen: bool,
225    /// Whether a facing rotation was successfully computed and applied.
226    pub yaw_applied: bool,
227    /// Whether the transformed grab point is within one millimetre of its target.
228    pub grab_point_stable: bool,
229    /// Applied pitch in degrees; zero for yaw-only/fixed paths.
230    pub pitch_deg: f32,
231    /// Whether pitch was clipped to its configured bounds.
232    pub pitch_clamped: bool,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq)]
236/// World grab point after optional controller-origin depth translation.
237///
238/// # Examples
239///
240/// ```
241/// use ailloli_ui_openxr::openxr_runtime::PanelDepthUpdate;
242/// use ailloli_ui_openxr::math::Vec3;
243/// let update = PanelDepthUpdate { adjusted_grab_world: Vec3::default(), depth_delta_m: 0.0, depth_applied: false };
244/// assert!(!update.depth_applied);
245/// ```
246pub struct PanelDepthUpdate {
247    /// Original or translated grab point in world metres.
248    pub adjusted_grab_world: Vec3,
249    /// Signed movement projected onto the initial pointer axis, in metres.
250    pub depth_delta_m: f32,
251    /// Whether the absolute projected movement exceeded `1e-6` metres.
252    pub depth_applied: bool,
253}
254
255/// Converts an OpenXR vector to the crate's lightweight vector unchanged.
256///
257/// # Examples
258///
259/// ```
260/// use ailloli_ui_openxr::openxr_runtime::panel::vec3_from_xr;
261/// assert_eq!(vec3_from_xr(openxr::Vector3f { x: 1.0, y: 2.0, z: 3.0 }).z, 3.0);
262/// ```
263pub fn vec3_from_xr(v: xr::Vector3f) -> Vec3 {
264    Vec3::new(v.x, v.y, v.z)
265}
266
267/// Converts the crate's lightweight vector to an OpenXR vector unchanged.
268///
269/// # Examples
270///
271/// ```
272/// use ailloli_ui_openxr::math::Vec3;
273/// use ailloli_ui_openxr::openxr_runtime::panel::xr_vec3;
274/// let vector = xr_vec3(Vec3::new(1.0, 2.0, 3.0));
275/// assert_eq!((vector.x, vector.y, vector.z), (1.0, 2.0, 3.0));
276/// ```
277pub fn xr_vec3(v: Vec3) -> xr::Vector3f {
278    xr::Vector3f {
279        x: v.x,
280        y: v.y,
281        z: v.z,
282    }
283}
284
285/// Rotates a vector by an OpenXR quaternion.
286///
287/// The quaternion is assumed normalized and is not validated.
288///
289/// # Examples
290///
291/// ```
292/// use ailloli_ui_openxr::math::Vec3;
293/// use ailloli_ui_openxr::openxr_runtime::panel::rotate_vec3;
294/// assert_eq!(rotate_vec3(openxr::Posef::IDENTITY.orientation, Vec3::new(1.0, 2.0, 3.0)), Vec3::new(1.0, 2.0, 3.0));
295/// ```
296pub fn rotate_vec3(q: xr::Quaternionf, v: Vec3) -> Vec3 {
297    let q_vec = Vec3::new(q.x, q.y, q.z);
298    let uv = q_vec.cross(v);
299    let uuv = q_vec.cross(uv);
300    v + (uv * q.w + uuv) * 2.0
301}
302
303/// Transforms a panel-local point into world space using the layer pose.
304///
305/// Coordinates conventionally use metres.
306///
307/// # Examples
308///
309/// ```
310/// use ailloli_ui_openxr::OpenXrQuadLayerOptions;
311/// use ailloli_ui_openxr::openxr_runtime::panel_local_to_world;
312/// use ailloli_ui_openxr::math::Vec3;
313/// let world = panel_local_to_world(OpenXrQuadLayerOptions::default(), Vec3::default());
314/// assert_eq!(world, Vec3::new(0.0, 0.0, -2.0));
315/// ```
316pub fn panel_local_to_world(layer: OpenXrQuadLayerOptions, local: Vec3) -> Vec3 {
317    vec3_from_xr(layer.pose.position) + rotate_vec3(layer.pose.orientation, local)
318}
319
320/// Maps a top-left-origin logical UI point into panel-local metres.
321///
322/// Logical width and height are clamped to at least one to avoid division by
323/// zero. Points are not clamped, so coordinates beyond the UI map beyond the
324/// panel. Local Z is always zero.
325///
326/// # Examples
327///
328/// ```
329/// use ailloli_ui_core::{Point, Size};
330/// use ailloli_ui_openxr::openxr_runtime::logical_point_to_panel_local;
331/// let local = logical_point_to_panel_local(Point::new(50.0, 25.0), Size::new(100.0, 50.0), openxr::Extent2Df { width: 2.0, height: 1.0 });
332/// assert_eq!((local.x, local.y, local.z), (0.0, 0.0, 0.0));
333/// ```
334pub fn logical_point_to_panel_local(
335    point: Point,
336    logical_size: Size,
337    layer_size: xr::Extent2Df,
338) -> Vec3 {
339    let w = logical_size.w.max(1.0);
340    let h = logical_size.h.max(1.0);
341    Vec3::new(
342        (point.x / w - 0.5) * layer_size.width,
343        (0.5 - point.y / h) * layer_size.height,
344        0.0,
345    )
346}
347
348/// Computes yaw-only orientation whose local positive Z faces the viewer.
349///
350/// Vertical separation is ignored. If horizontal squared distance is at most
351/// `1e-6`, returns `(fallback, false)`; otherwise the second value is true.
352///
353/// # Examples
354///
355/// ```
356/// use ailloli_ui_openxr::openxr_runtime::face_user_yaw_only;
357/// use ailloli_ui_openxr::math::Vec3;
358/// let (rotation, applied) = face_user_yaw_only(Vec3::new(0.0, 0.0, -2.0), Vec3::default(), openxr::Posef::IDENTITY.orientation);
359/// assert!(applied);
360/// assert_eq!(rotation, openxr::Posef::IDENTITY.orientation);
361/// ```
362pub fn face_user_yaw_only(
363    panel_pos: Vec3,
364    head_pos: Vec3,
365    fallback: xr::Quaternionf,
366) -> (xr::Quaternionf, bool) {
367    let mut dir = head_pos - panel_pos;
368    dir.y = 0.0;
369    if dir.len_sq() <= PANEL_FACING_EPSILON {
370        return (fallback, false);
371    }
372
373    let dir = dir.normalize_or(Vec3::new(0.0, 0.0, 1.0));
374    let yaw = dir.x.atan2(dir.z);
375    (yaw_quaternion(yaw), true)
376}
377
378/// Computes viewer-facing yaw and pitch while keeping roll at zero.
379///
380/// Non-finite pitch bounds fall back to +/-45 degrees and reversed bounds are
381/// swapped. The returned tuple contains rotation, whether it was applied,
382/// applied pitch in degrees, and whether pitch was clamped. Degenerate horizontal
383/// direction returns the fallback with both booleans false.
384///
385/// # Examples
386///
387/// ```
388/// use ailloli_ui_openxr::{DEFAULT_PANEL_PITCH_MAX_RAD, DEFAULT_PANEL_PITCH_MIN_RAD};
389/// use ailloli_ui_openxr::openxr_runtime::face_user_yaw_pitch_clamped;
390/// use ailloli_ui_openxr::math::Vec3;
391/// let (_, applied, pitch_deg, clamped) = face_user_yaw_pitch_clamped(Vec3::new(0.0, 0.0, -2.0), Vec3::new(0.0, 100.0, -1.9), openxr::Posef::IDENTITY.orientation, DEFAULT_PANEL_PITCH_MIN_RAD, DEFAULT_PANEL_PITCH_MAX_RAD);
392/// assert!(applied && clamped);
393/// assert!((pitch_deg - 45.0).abs() < 1e-4);
394/// ```
395pub fn face_user_yaw_pitch_clamped(
396    panel_pos: Vec3,
397    head_pos: Vec3,
398    fallback: xr::Quaternionf,
399    pitch_min_rad: f32,
400    pitch_max_rad: f32,
401) -> (xr::Quaternionf, bool, f32, bool) {
402    let dir = head_pos - panel_pos;
403    if dir.len_sq() <= PANEL_FACING_EPSILON {
404        return (fallback, false, 0.0, false);
405    }
406
407    let horizontal_len = (dir.x * dir.x + dir.z * dir.z).sqrt();
408    if horizontal_len <= PANEL_FACING_EPSILON {
409        return (fallback, false, 0.0, false);
410    }
411
412    let mut min = if pitch_min_rad.is_finite() {
413        pitch_min_rad
414    } else {
415        DEFAULT_PANEL_PITCH_MIN_RAD
416    };
417    let mut max = if pitch_max_rad.is_finite() {
418        pitch_max_rad
419    } else {
420        DEFAULT_PANEL_PITCH_MAX_RAD
421    };
422    if min > max {
423        std::mem::swap(&mut min, &mut max);
424    }
425
426    let unclamped_pitch = dir.y.atan2(horizontal_len);
427    let pitch = unclamped_pitch.clamp(min, max);
428    let pitch_clamped = (pitch - unclamped_pitch).abs() > 1e-5;
429    let yaw = dir.x.atan2(dir.z);
430
431    let cos_pitch = pitch.cos();
432    let forward = Vec3::new(yaw.sin() * cos_pitch, pitch.sin(), yaw.cos() * cos_pitch)
433        .normalize_or(Vec3::new(0.0, 0.0, 1.0));
434    let right = Vec3::new(0.0, 1.0, 0.0)
435        .cross(forward)
436        .normalize_or(Vec3::new(1.0, 0.0, 0.0));
437    let up = forward.cross(right).normalize_or(Vec3::new(0.0, 1.0, 0.0));
438
439    (
440        rotation_matrix_to_quaternion([
441            [right.x, up.x, forward.x],
442            [right.y, up.y, forward.y],
443            [right.z, up.z, forward.z],
444        ]),
445        true,
446        pitch.to_degrees(),
447        pitch_clamped,
448    )
449}
450
451/// Projects pointer-origin movement onto the initial ray axis.
452///
453/// The signed projection translates `grab_world` along the normalized axis.
454/// Missing/near-zero axes or either missing origin return an unchanged result.
455/// `depth_applied` is true only above `1e-6` metres.
456///
457/// # Examples
458///
459/// ```
460/// use ailloli_ui_openxr::openxr_runtime::apply_pointer_depth_delta;
461/// use ailloli_ui_openxr::math::Vec3;
462/// let update = apply_pointer_depth_delta(Vec3::new(0.0, 0.0, -2.0), Some(Vec3::new(0.0, 0.0, -1.0)), Some(Vec3::default()), Some(Vec3::new(0.0, 0.0, -0.25)));
463/// assert_eq!(update.adjusted_grab_world, Vec3::new(0.0, 0.0, -2.25));
464/// ```
465pub fn apply_pointer_depth_delta(
466    grab_world: Vec3,
467    depth_axis: Option<Vec3>,
468    last_origin: Option<Vec3>,
469    current_origin: Option<Vec3>,
470) -> PanelDepthUpdate {
471    let Some(axis) = depth_axis.and_then(Vec3::normalize) else {
472        return PanelDepthUpdate {
473            adjusted_grab_world: grab_world,
474            depth_delta_m: 0.0,
475            depth_applied: false,
476        };
477    };
478    let (Some(last_origin), Some(current_origin)) = (last_origin, current_origin) else {
479        return PanelDepthUpdate {
480            adjusted_grab_world: grab_world,
481            depth_delta_m: 0.0,
482            depth_applied: false,
483        };
484    };
485
486    let depth_delta_m = (current_origin - last_origin).dot(axis);
487    PanelDepthUpdate {
488        adjusted_grab_world: grab_world + axis * depth_delta_m,
489        depth_delta_m,
490        depth_applied: depth_delta_m.abs() > 1e-6,
491    }
492}
493
494/// Reorients a dragged panel in yaw while preserving the grabbed world point.
495///
496/// Without a head position the layer is unchanged and `hmd_pose_seen` is false.
497/// Stability is reported when reconstruction error is at most one millimetre.
498///
499/// # Examples
500///
501/// ```
502/// use ailloli_ui_openxr::OpenXrQuadLayerOptions;
503/// use ailloli_ui_openxr::math::Vec3;
504/// use ailloli_ui_openxr::openxr_runtime::{OpenXrPanelGrabState, panel::apply_yaw_only_stable_grab};
505/// let layer = OpenXrQuadLayerOptions::default();
506/// let mut grab = OpenXrPanelGrabState::new(Vec3::default(), layer.pose.orientation);
507/// let update = apply_yaw_only_stable_grab(layer, &mut grab, Vec3::new(0.0, 0.0, -2.0), Some(Vec3::default()));
508/// assert!(update.hmd_pose_seen && update.grab_point_stable);
509/// ```
510pub fn apply_yaw_only_stable_grab(
511    layer: OpenXrQuadLayerOptions,
512    grab: &mut OpenXrPanelGrabState,
513    grab_world: Vec3,
514    head_pos: Option<Vec3>,
515) -> OpenXrPanelGrabUpdate {
516    let Some(head_pos) = head_pos else {
517        return OpenXrPanelGrabUpdate {
518            layer,
519            hmd_pose_seen: false,
520            yaw_applied: false,
521            grab_point_stable: false,
522            pitch_deg: 0.0,
523            pitch_clamped: false,
524        };
525    };
526
527    grab.last_valid_head_pos = Some(head_pos);
528    let provisional_pos = grab_world - rotate_vec3(layer.pose.orientation, grab.local_grab_point_m);
529    let (rotation, yaw_applied) =
530        face_user_yaw_only(provisional_pos, head_pos, grab.last_valid_rotation);
531    if yaw_applied {
532        grab.last_valid_rotation = rotation;
533    }
534
535    let panel_pos = grab_world - rotate_vec3(rotation, grab.local_grab_point_m);
536    let mut next = layer;
537    next.pose.position = xr_vec3(panel_pos);
538    next.pose.orientation = rotation;
539    let stable_error = (panel_local_to_world(next, grab.local_grab_point_m) - grab_world).len();
540
541    OpenXrPanelGrabUpdate {
542        layer: next,
543        hmd_pose_seen: true,
544        yaw_applied,
545        grab_point_stable: stable_error <= 0.001,
546        pitch_deg: 0.0,
547        pitch_clamped: false,
548    }
549}
550
551/// Applies the selected facing policy while preserving the grabbed world point.
552///
553/// Fixed mode preserves orientation; yaw-only modes ignore vertical separation;
554/// yaw-pitch mode normalizes and applies configured pitch bounds. Missing HMD
555/// position leaves the layer unchanged. Stability tolerance is one millimetre.
556///
557/// # Examples
558///
559/// ```
560/// use ailloli_ui_openxr::{OpenXrPanelFacingMode, OpenXrPanelFacingOptions, OpenXrQuadLayerOptions};
561/// use ailloli_ui_openxr::math::Vec3;
562/// use ailloli_ui_openxr::openxr_runtime::{OpenXrPanelGrabState, panel::apply_facing_stable_grab};
563/// let layer = OpenXrQuadLayerOptions::default();
564/// let mut grab = OpenXrPanelGrabState::new(Vec3::default(), layer.pose.orientation);
565/// let update = apply_facing_stable_grab(layer, &mut grab, Vec3::new(0.0, 0.0, -2.0), Some(Vec3::default()), OpenXrPanelFacingOptions::new(OpenXrPanelFacingMode::Fixed, -1.0, 1.0));
566/// assert!(update.hmd_pose_seen && update.grab_point_stable);
567/// ```
568pub fn apply_facing_stable_grab(
569    layer: OpenXrQuadLayerOptions,
570    grab: &mut OpenXrPanelGrabState,
571    grab_world: Vec3,
572    head_pos: Option<Vec3>,
573    facing: OpenXrPanelFacingOptions,
574) -> OpenXrPanelGrabUpdate {
575    let Some(head_pos) = head_pos else {
576        return OpenXrPanelGrabUpdate {
577            layer,
578            hmd_pose_seen: false,
579            yaw_applied: false,
580            grab_point_stable: false,
581            pitch_deg: 0.0,
582            pitch_clamped: false,
583        };
584    };
585
586    grab.last_valid_head_pos = Some(head_pos);
587    let provisional_pos = grab_world - rotate_vec3(layer.pose.orientation, grab.local_grab_point_m);
588    let (rotation, applied, pitch_deg, pitch_clamped) = match facing.mode {
589        OpenXrPanelFacingMode::FaceUserYawPitchOnDrag => face_user_yaw_pitch_clamped(
590            provisional_pos,
591            head_pos,
592            grab.last_valid_rotation,
593            facing.pitch_min_rad,
594            facing.pitch_max_rad,
595        ),
596        OpenXrPanelFacingMode::FaceUserOnDrag | OpenXrPanelFacingMode::FaceUserAlways => {
597            let (rotation, applied) =
598                face_user_yaw_only(provisional_pos, head_pos, grab.last_valid_rotation);
599            (rotation, applied, 0.0, false)
600        }
601        OpenXrPanelFacingMode::Fixed => (layer.pose.orientation, false, 0.0, false),
602    };
603
604    if applied {
605        grab.last_valid_rotation = rotation;
606    }
607
608    let panel_pos = grab_world - rotate_vec3(rotation, grab.local_grab_point_m);
609    let mut next = layer;
610    next.pose.position = xr_vec3(panel_pos);
611    next.pose.orientation = rotation;
612    let stable_error = (panel_local_to_world(next, grab.local_grab_point_m) - grab_world).len();
613
614    OpenXrPanelGrabUpdate {
615        layer: next,
616        hmd_pose_seen: true,
617        yaw_applied: applied,
618        grab_point_stable: stable_error <= 0.001,
619        pitch_deg,
620        pitch_clamped,
621    }
622}
623
624/// Creates a normalized pure-Y-axis quaternion from yaw radians.
625fn yaw_quaternion(yaw: f32) -> xr::Quaternionf {
626    let half = yaw * 0.5;
627    xr::Quaternionf {
628        x: 0.0,
629        y: half.sin(),
630        z: 0.0,
631        w: half.cos(),
632    }
633}
634
635/// Converts a right/up/forward 3x3 rotation matrix to a quaternion.
636fn rotation_matrix_to_quaternion(m: [[f32; 3]; 3]) -> xr::Quaternionf {
637    let trace = m[0][0] + m[1][1] + m[2][2];
638    if trace > 0.0 {
639        let s = (trace + 1.0).sqrt() * 2.0;
640        xr::Quaternionf {
641            w: 0.25 * s,
642            x: (m[2][1] - m[1][2]) / s,
643            y: (m[0][2] - m[2][0]) / s,
644            z: (m[1][0] - m[0][1]) / s,
645        }
646    } else if m[0][0] > m[1][1] && m[0][0] > m[2][2] {
647        let s = (1.0 + m[0][0] - m[1][1] - m[2][2]).sqrt() * 2.0;
648        xr::Quaternionf {
649            w: (m[2][1] - m[1][2]) / s,
650            x: 0.25 * s,
651            y: (m[0][1] + m[1][0]) / s,
652            z: (m[0][2] + m[2][0]) / s,
653        }
654    } else if m[1][1] > m[2][2] {
655        let s = (1.0 + m[1][1] - m[0][0] - m[2][2]).sqrt() * 2.0;
656        xr::Quaternionf {
657            w: (m[0][2] - m[2][0]) / s,
658            x: (m[0][1] + m[1][0]) / s,
659            y: 0.25 * s,
660            z: (m[1][2] + m[2][1]) / s,
661        }
662    } else {
663        let s = (1.0 + m[2][2] - m[0][0] - m[1][1]).sqrt() * 2.0;
664        xr::Quaternionf {
665            w: (m[1][0] - m[0][1]) / s,
666            x: (m[0][2] + m[2][0]) / s,
667            y: (m[1][2] + m[2][1]) / s,
668            z: 0.25 * s,
669        }
670    }
671}
672
673#[cfg(test)]
674/// Covers facing degeneracy, pitch normalization, coordinate mapping, and stable drags.
675mod tests {
676    use super::*;
677
678    /// Returns an identity quaternion fixture.
679    fn identity() -> xr::Quaternionf {
680        xr::Quaternionf {
681            x: 0.0,
682            y: 0.0,
683            z: 0.0,
684            w: 1.0,
685        }
686    }
687
688    /// Returns the default-sized identity-facing panel fixture at -2 metres.
689    fn layer() -> OpenXrQuadLayerOptions {
690        OpenXrQuadLayerOptions {
691            pose: xr::Posef {
692                orientation: identity(),
693                position: xr::Vector3f {
694                    x: 0.0,
695                    y: 0.0,
696                    z: -2.0,
697                },
698            },
699            size: xr::Extent2Df {
700                width: 1.6,
701                height: 0.9,
702            },
703            eye_visibility: xr::EyeVisibility::BOTH,
704            layer_flags: xr::CompositionLayerFlags::EMPTY,
705        }
706    }
707
708    /// Asserts two floats differ by less than `1e-5`.
709    fn approx(a: f32, b: f32) {
710        assert!(
711            (a - b).abs() < 1e-5,
712            "expected {a:?} to be approximately {b:?}"
713        );
714    }
715
716    /// Applies [`approx`] component-wise to two vectors.
717    fn approx_vec(a: Vec3, b: Vec3) {
718        approx(a.x, b.x);
719        approx(a.y, b.y);
720        approx(a.z, b.z);
721    }
722
723    /// Converts degree bounds into normalized facing options.
724    fn facing_options(
725        mode: OpenXrPanelFacingMode,
726        min_deg: f32,
727        max_deg: f32,
728    ) -> OpenXrPanelFacingOptions {
729        OpenXrPanelFacingOptions::new(mode, min_deg.to_radians(), max_deg.to_radians())
730    }
731
732    #[test]
733    fn face_user_yaw_only_keeps_identity_when_head_is_in_front() {
734        let (rotation, applied) = face_user_yaw_only(
735            Vec3::new(0.0, 0.0, -2.0),
736            Vec3::new(0.0, 0.0, 0.0),
737            identity(),
738        );
739        assert!(applied);
740        approx(rotation.x, 0.0);
741        approx(rotation.y, 0.0);
742        approx(rotation.z, 0.0);
743        approx(rotation.w, 1.0);
744    }
745
746    #[test]
747    fn face_user_yaw_only_rotates_local_plus_z_toward_head_x() {
748        let (rotation, applied) = face_user_yaw_only(
749            Vec3::new(0.0, 0.0, -2.0),
750            Vec3::new(2.0, 0.0, -2.0),
751            identity(),
752        );
753        assert!(applied);
754        let forward = rotate_vec3(rotation, Vec3::new(0.0, 0.0, 1.0));
755        approx_vec(forward, Vec3::new(1.0, 0.0, 0.0));
756    }
757
758    #[test]
759    fn face_user_yaw_only_ignores_vertical_difference() {
760        let (low, _) = face_user_yaw_only(
761            Vec3::new(0.0, 0.0, -2.0),
762            Vec3::new(1.0, -10.0, 0.0),
763            identity(),
764        );
765        let (high, _) = face_user_yaw_only(
766            Vec3::new(0.0, 0.0, -2.0),
767            Vec3::new(1.0, 10.0, 0.0),
768            identity(),
769        );
770        approx(low.y, high.y);
771        approx(low.w, high.w);
772    }
773
774    #[test]
775    fn face_user_yaw_only_keeps_fallback_when_direction_is_too_short() {
776        let fallback = xr::Quaternionf {
777            x: 0.0,
778            y: 0.25,
779            z: 0.0,
780            w: 0.96,
781        };
782        let (rotation, applied) =
783            face_user_yaw_only(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 2.0, 0.0), fallback);
784        assert!(!applied);
785        assert_eq!(rotation, fallback);
786    }
787
788    #[test]
789    fn facing_options_default_use_yaw_only_and_forty_five_degree_pitch_clamp() {
790        let options = OpenXrPanelFacingOptions::default();
791        assert_eq!(options.mode, OpenXrPanelFacingMode::FaceUserOnDrag);
792        approx(options.pitch_min_rad.to_degrees(), -45.0);
793        approx(options.pitch_max_rad.to_degrees(), 45.0);
794    }
795
796    #[test]
797    fn facing_options_normalize_invalid_or_reversed_pitch_bounds() {
798        let options = OpenXrPanelFacingOptions::new(
799            OpenXrPanelFacingMode::FaceUserYawPitchOnDrag,
800            30.0_f32.to_radians(),
801            -10.0_f32.to_radians(),
802        );
803        approx(options.pitch_min_rad.to_degrees(), -10.0);
804        approx(options.pitch_max_rad.to_degrees(), 30.0);
805
806        let options = OpenXrPanelFacingOptions::new(
807            OpenXrPanelFacingMode::FaceUserYawPitchOnDrag,
808            f32::NAN,
809            f32::INFINITY,
810        );
811        approx(options.pitch_min_rad.to_degrees(), -45.0);
812        approx(options.pitch_max_rad.to_degrees(), 45.0);
813    }
814
815    #[test]
816    fn face_user_yaw_pitch_tracks_head_above_without_roll() {
817        let (rotation, applied, pitch_deg, clamped) = face_user_yaw_pitch_clamped(
818            Vec3::new(0.0, 0.0, -2.0),
819            Vec3::new(0.0, 1.0, 0.0),
820            identity(),
821            DEFAULT_PANEL_PITCH_MIN_RAD,
822            DEFAULT_PANEL_PITCH_MAX_RAD,
823        );
824        assert!(applied);
825        assert!(!clamped);
826        assert!(pitch_deg > 20.0);
827
828        let forward = rotate_vec3(rotation, Vec3::new(0.0, 0.0, 1.0));
829        assert!(forward.y > 0.4);
830        let right = rotate_vec3(rotation, Vec3::new(1.0, 0.0, 0.0));
831        approx(right.y, 0.0);
832    }
833
834    #[test]
835    fn face_user_yaw_pitch_clamps_to_default_limits() {
836        let (rotation, applied, pitch_deg, clamped) = face_user_yaw_pitch_clamped(
837            Vec3::new(0.0, 0.0, -2.0),
838            Vec3::new(0.0, 100.0, -1.9),
839            identity(),
840            DEFAULT_PANEL_PITCH_MIN_RAD,
841            DEFAULT_PANEL_PITCH_MAX_RAD,
842        );
843        assert!(applied);
844        assert!(clamped);
845        approx(pitch_deg, 45.0);
846        let forward = rotate_vec3(rotation, Vec3::new(0.0, 0.0, 1.0));
847        approx(forward.y, 45.0_f32.to_radians().sin());
848    }
849
850    #[test]
851    fn face_user_yaw_pitch_uses_custom_clamp_limits() {
852        let (_, applied, pitch_deg, clamped) = face_user_yaw_pitch_clamped(
853            Vec3::new(0.0, 0.0, -2.0),
854            Vec3::new(0.0, 100.0, -1.9),
855            identity(),
856            -15.0_f32.to_radians(),
857            20.0_f32.to_radians(),
858        );
859        assert!(applied);
860        assert!(clamped);
861        approx(pitch_deg, 20.0);
862    }
863
864    #[test]
865    fn logical_point_to_panel_local_maps_center_and_corners() {
866        let size = xr::Extent2Df {
867            width: 1.6,
868            height: 0.9,
869        };
870        approx_vec(
871            logical_point_to_panel_local(Point::new(512.0, 288.0), Size::new(1024.0, 576.0), size),
872            Vec3::new(0.0, 0.0, 0.0),
873        );
874        approx_vec(
875            logical_point_to_panel_local(Point::new(0.0, 0.0), Size::new(1024.0, 576.0), size),
876            Vec3::new(-0.8, 0.45, 0.0),
877        );
878        approx_vec(
879            logical_point_to_panel_local(Point::new(1024.0, 576.0), Size::new(1024.0, 576.0), size),
880            Vec3::new(0.8, -0.45, 0.0),
881        );
882    }
883
884    #[test]
885    fn pointer_depth_delta_moves_grab_world_forward_on_initial_ray_axis() {
886        let grab_world = Vec3::new(0.0, 0.0, -2.0);
887        let update = apply_pointer_depth_delta(
888            grab_world,
889            Some(Vec3::new(0.0, 0.0, -1.0)),
890            Some(Vec3::new(0.0, 0.0, 0.0)),
891            Some(Vec3::new(0.0, 0.0, -0.25)),
892        );
893        assert!(update.depth_applied);
894        approx(update.depth_delta_m, 0.25);
895        approx_vec(update.adjusted_grab_world, Vec3::new(0.0, 0.0, -2.25));
896    }
897
898    #[test]
899    fn pointer_depth_delta_moves_grab_world_backward_on_initial_ray_axis() {
900        let grab_world = Vec3::new(0.0, 0.0, -2.0);
901        let update = apply_pointer_depth_delta(
902            grab_world,
903            Some(Vec3::new(0.0, 0.0, -1.0)),
904            Some(Vec3::new(0.0, 0.0, 0.0)),
905            Some(Vec3::new(0.0, 0.0, 0.25)),
906        );
907        assert!(update.depth_applied);
908        approx(update.depth_delta_m, -0.25);
909        approx_vec(update.adjusted_grab_world, Vec3::new(0.0, 0.0, -1.75));
910    }
911
912    #[test]
913    fn pointer_depth_delta_ignores_perpendicular_origin_motion() {
914        let grab_world = Vec3::new(0.0, 0.0, -2.0);
915        let update = apply_pointer_depth_delta(
916            grab_world,
917            Some(Vec3::new(0.0, 0.0, -1.0)),
918            Some(Vec3::new(0.0, 0.0, 0.0)),
919            Some(Vec3::new(0.5, 0.25, 0.0)),
920        );
921        assert!(!update.depth_applied);
922        approx(update.depth_delta_m, 0.0);
923        approx_vec(update.adjusted_grab_world, grab_world);
924    }
925
926    #[test]
927    fn pointer_depth_delta_does_not_drift_when_origin_is_unchanged() {
928        let grab_world = Vec3::new(0.2, -0.1, -2.0);
929        let update = apply_pointer_depth_delta(
930            grab_world,
931            Some(Vec3::new(0.0, 0.0, -1.0)),
932            Some(Vec3::new(0.1, 0.2, 0.3)),
933            Some(Vec3::new(0.1, 0.2, 0.3)),
934        );
935        assert!(!update.depth_applied);
936        approx(update.depth_delta_m, 0.0);
937        approx_vec(update.adjusted_grab_world, grab_world);
938    }
939
940    #[test]
941    fn pointer_depth_delta_without_axis_or_origins_leaves_grab_world_unchanged() {
942        let grab_world = Vec3::new(0.2, -0.1, -2.0);
943        for update in [
944            apply_pointer_depth_delta(
945                grab_world,
946                None,
947                Some(Vec3::new(0.0, 0.0, 0.0)),
948                Some(Vec3::new(0.0, 0.0, -0.2)),
949            ),
950            apply_pointer_depth_delta(
951                grab_world,
952                Some(Vec3::new(0.0, 0.0, -1.0)),
953                None,
954                Some(Vec3::new(0.0, 0.0, -0.2)),
955            ),
956            apply_pointer_depth_delta(
957                grab_world,
958                Some(Vec3::new(0.0, 0.0, -1.0)),
959                Some(Vec3::new(0.0, 0.0, 0.0)),
960                None,
961            ),
962        ] {
963            assert!(!update.depth_applied);
964            approx(update.depth_delta_m, 0.0);
965            approx_vec(update.adjusted_grab_world, grab_world);
966        }
967    }
968
969    #[test]
970    fn stable_grab_keeps_non_center_point_under_world_hit() {
971        let mut grab = OpenXrPanelGrabState::new(Vec3::new(0.4, 0.2, 0.0), identity());
972        let grab_world = Vec3::new(0.8, 0.25, -1.6);
973        let update = apply_yaw_only_stable_grab(
974            layer(),
975            &mut grab,
976            grab_world,
977            Some(Vec3::new(1.4, 0.3, 0.0)),
978        );
979        assert!(update.hmd_pose_seen);
980        assert!(update.yaw_applied);
981        assert!(update.grab_point_stable);
982        approx_vec(
983            panel_local_to_world(update.layer, grab.local_grab_point_m),
984            grab_world,
985        );
986    }
987
988    #[test]
989    fn yaw_pitch_stable_grab_keeps_non_center_point_under_world_hit() {
990        let mut grab = OpenXrPanelGrabState::new(Vec3::new(0.4, 0.2, 0.0), identity());
991        let grab_world = Vec3::new(0.8, 0.25, -1.6);
992        let update = apply_facing_stable_grab(
993            layer(),
994            &mut grab,
995            grab_world,
996            Some(Vec3::new(1.4, 1.2, 0.0)),
997            facing_options(OpenXrPanelFacingMode::FaceUserYawPitchOnDrag, -45.0, 45.0),
998        );
999        assert!(update.hmd_pose_seen);
1000        assert!(update.yaw_applied);
1001        assert!(update.grab_point_stable);
1002        assert!(update.pitch_deg > 0.0);
1003        approx_vec(
1004            panel_local_to_world(update.layer, grab.local_grab_point_m),
1005            grab_world,
1006        );
1007        let right = rotate_vec3(update.layer.pose.orientation, Vec3::new(1.0, 0.0, 0.0));
1008        approx(right.y, 0.0);
1009    }
1010}