ailloli_ui_openxr/openxr_runtime/
input.rs

1//! OpenXR actions, controller and hand rays, source selection, and frame polling.
2
3use std::mem::MaybeUninit;
4use std::ptr;
5use std::time::Instant;
6
7use ailloli_ui_core::Size;
8use openxr as xr;
9use openxr::AsHandle;
10
11use crate::input::{OpenXrPointerFrame, OpenXrPointerHit, OpenXrPointerSample, PointHit};
12use crate::math::{uv_to_logical, RayQuad, Vec3};
13
14use super::composer::OpenXrQuadLayerOptions;
15use super::error::OpenXrRuntimeError;
16use super::instance::OpenXrInstance;
17use super::ray_overlay::{OpenXrRayHitKind, OpenXrRaySample, OPENXR_RAY_MAX_LENGTH_METERS};
18
19/// Stable mapper ID for the right controller.
20const RIGHT_CONTROLLER_SOURCE_ID: u64 = 1;
21/// Stable mapper ID for the left controller.
22const LEFT_CONTROLLER_SOURCE_ID: u64 = 2;
23/// Stable mapper ID for the right tracked hand.
24const RIGHT_HAND_SOURCE_ID: u64 = 3;
25/// Stable mapper ID for the left tracked hand.
26const LEFT_HAND_SOURCE_ID: u64 = 4;
27
28/// Index-to-thumb distance at or below which fallback hand input is pressed.
29const PINCH_DISTANCE_METERS: f32 = 0.035;
30
31/// Oculus Touch interaction-profile path.
32const PROFILE_OCULUS_TOUCH: &str = "/interaction_profiles/oculus/touch_controller";
33/// Meta Quest Touch Plus interaction-profile path.
34const PROFILE_QUEST_TOUCH_PLUS: &str = "/interaction_profiles/meta/quest_touch_plus_controller";
35/// Meta Quest Touch Pro interaction-profile path.
36const PROFILE_QUEST_TOUCH_PRO: &str = "/interaction_profiles/meta/quest_touch_pro_controller";
37
38/// Profiles to which the same action bindings are suggested independently.
39const INTERACTION_PROFILES: &[&str] = &[
40    PROFILE_OCULUS_TOUCH,
41    PROFILE_QUEST_TOUCH_PLUS,
42    PROFILE_QUEST_TOUCH_PRO,
43];
44
45#[derive(Debug, Clone, Copy, PartialEq)]
46/// Input sources, thresholds, scroll scaling, and selection policy.
47///
48/// Trigger and pinch thresholds are compared directly with runtime values;
49/// scroll deadzone is absolute axis magnitude and scroll speed is logical pixels
50/// per second. Poll deltas are capped at 100 ms before scaling.
51///
52/// # Examples
53///
54/// ```
55/// use ailloli_ui_openxr::OpenXrUiInputOptions;
56/// let options = OpenXrUiInputOptions::default();
57/// assert_eq!(options.trigger_threshold, 0.75);
58/// assert_eq!(options.scroll_pixels_per_second, 720.0);
59/// ```
60pub struct OpenXrUiInputOptions {
61    /// Whether action creation and polling are enabled at all.
62    pub enabled: bool,
63    /// Whether controller aim, trigger, and thumbstick actions are considered.
64    pub controllers: bool,
65    /// Whether extension hand tracking may provide pointer candidates.
66    pub hands: bool,
67    /// Inclusive controller-trigger pressed threshold.
68    pub trigger_threshold: f32,
69    /// Inclusive FB hand-aim pinch-strength threshold.
70    pub pinch_threshold: f32,
71    /// Absolute thumbstick magnitude below which scroll is zero.
72    pub scroll_deadzone: f32,
73    /// Full-deflection scroll speed in logical pixels per second.
74    pub scroll_pixels_per_second: f32,
75    /// Deterministic preference when several sources are available.
76    pub pointer_selection: OpenXrPointerSelectionPolicy,
77}
78
79impl Default for OpenXrUiInputOptions {
80    fn default() -> Self {
81        Self {
82            enabled: true,
83            controllers: true,
84            hands: true,
85            trigger_threshold: 0.75,
86            pinch_threshold: 0.75,
87            scroll_deadzone: 0.20,
88            scroll_pixels_per_second: 720.0,
89            pointer_selection: OpenXrPointerSelectionPolicy::PreferRightController,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95/// Priority order for selecting one UI pointer from concurrent candidates.
96///
97/// A pressed source remains locked until release or disappearance, regardless of
98/// policy. Controller hits rank before hands; misses are considered only when no
99/// source hits the UI.
100///
101/// # Examples
102///
103/// ```
104/// use ailloli_ui_openxr::OpenXrPointerSelectionPolicy;
105/// assert_eq!(OpenXrPointerSelectionPolicy::PreferRightController, OpenXrPointerSelectionPolicy::PreferRightController);
106/// ```
107pub enum OpenXrPointerSelectionPolicy {
108    /// Right controller, left controller, right hand, then left hand.
109    PreferRightController,
110    /// Left controller, right controller, left hand, then right hand.
111    PreferLeftController,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115/// Anatomical side associated with an input source.
116///
117/// # Examples
118///
119/// ```
120/// use ailloli_ui_openxr::OpenXrInputHand;
121/// assert_ne!(OpenXrInputHand::Right, OpenXrInputHand::Left);
122/// ```
123pub enum OpenXrInputHand {
124    /// User's right hand.
125    Right,
126    /// User's left hand.
127    Left,
128}
129
130/// Internal short name used throughout polling code.
131type PointerHand = OpenXrInputHand;
132
133impl OpenXrInputHand {
134    /// Returns the stable lowercase label used in logs and errors.
135    fn label(self) -> &'static str {
136        match self {
137            Self::Right => "right",
138            Self::Left => "left",
139        }
140    }
141
142    /// Returns the stable controller mapper ID for this side.
143    fn controller_source_id(self) -> u64 {
144        match self {
145            Self::Right => RIGHT_CONTROLLER_SOURCE_ID,
146            Self::Left => LEFT_CONTROLLER_SOURCE_ID,
147        }
148    }
149
150    /// Returns the stable tracked-hand mapper ID for this side.
151    fn hand_source_id(self) -> u64 {
152        match self {
153            Self::Right => RIGHT_HAND_SOURCE_ID,
154            Self::Left => LEFT_HAND_SOURCE_ID,
155        }
156    }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160/// Hardware family that produced a pointer candidate.
161///
162/// # Examples
163///
164/// ```
165/// use ailloli_ui_openxr::OpenXrInputSourceKind;
166/// assert!(matches!(OpenXrInputSourceKind::Controller, OpenXrInputSourceKind::Controller));
167/// ```
168pub enum OpenXrInputSourceKind {
169    /// Pose action sourced from a tracked controller.
170    Controller,
171    /// Aim extension or index-joint ray sourced from hand tracking.
172    Hand,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176/// Stable identity and origin metadata for the selected pointer.
177///
178/// Source IDs `1..=4` are reserved internally for right/left controllers then
179/// right/left hands.
180///
181/// # Examples
182///
183/// ```
184/// use ailloli_ui_openxr::{OpenXrInputHand, OpenXrInputSourceInfo, OpenXrInputSourceKind};
185/// let source = OpenXrInputSourceInfo { source_id: 1, source_kind: OpenXrInputSourceKind::Controller, hand: OpenXrInputHand::Right };
186/// assert_eq!(source.source_id, 1);
187/// ```
188pub struct OpenXrInputSourceInfo {
189    /// Stable mapper ID; built-in sources use values `1` through `4`.
190    pub source_id: u64,
191    /// Controller or hand-tracking origin.
192    pub source_kind: OpenXrInputSourceKind,
193    /// Left/right side.
194    pub hand: OpenXrInputHand,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198/// Hand-input extensions available on a runtime/system pair.
199///
200/// Hand aim is forced false when base hand tracking is false.
201///
202/// # Examples
203///
204/// ```
205/// use ailloli_ui_openxr::OpenXrInputCapabilities;
206/// let capabilities = OpenXrInputCapabilities::new(false, true);
207/// assert!(!capabilities.hand_aim_supported);
208/// ```
209pub struct OpenXrInputCapabilities {
210    /// Whether `XR_EXT_hand_tracking` works for the selected system.
211    pub hand_tracking_supported: bool,
212    /// Whether `XR_FB_hand_tracking_aim` is enabled and usable.
213    pub hand_aim_supported: bool,
214}
215
216impl OpenXrInputCapabilities {
217    /// Creates a normalized capability pair.
218    ///
219    /// `hand_aim_supported` is retained only when hand tracking is also true.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use ailloli_ui_openxr::OpenXrInputCapabilities;
225    /// assert_eq!(OpenXrInputCapabilities::new(true, true), OpenXrInputCapabilities { hand_tracking_supported: true, hand_aim_supported: true });
226    /// assert!(!OpenXrInputCapabilities::new(false, true).hand_aim_supported);
227    /// ```
228    pub fn new(hand_tracking_supported: bool, hand_aim_supported: bool) -> Self {
229        Self {
230            hand_tracking_supported,
231            hand_aim_supported: hand_tracking_supported && hand_aim_supported,
232        }
233    }
234}
235
236impl From<&OpenXrInstance> for OpenXrInputCapabilities {
237    fn from(xr: &OpenXrInstance) -> Self {
238        Self {
239            hand_tracking_supported: xr.hand_tracking_supported,
240            hand_aim_supported: xr.hand_aim_supported,
241        }
242    }
243}
244
245#[derive(Debug, Clone)]
246/// Selected input result for one OpenXR polling iteration.
247///
248/// An empty result has no pointer samples, ray visualization, or source. When a
249/// previously selected source disappears, `pointer_frame` can contain a miss
250/// sample while the optional ray and source metadata are absent or synthesized.
251///
252/// # Examples
253///
254/// ```
255/// use ailloli_ui_openxr::OpenXrActionInputFrame;
256/// let frame = OpenXrActionInputFrame::empty();
257/// assert!(frame.pointer_frame.samples.is_empty());
258/// assert!(frame.ray_sample.is_none());
259/// ```
260pub struct OpenXrActionInputFrame {
261    /// Zero or one selected pointer sample routed to the UI mapper.
262    pub pointer_frame: OpenXrPointerFrame,
263    /// World-space ray for optional visualization.
264    pub ray_sample: Option<OpenXrRaySample>,
265    /// Identity of the selected or release-synthesized source.
266    pub source: Option<OpenXrInputSourceInfo>,
267}
268
269impl OpenXrActionInputFrame {
270    /// Returns a frame with no samples or metadata.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use ailloli_ui_openxr::OpenXrActionInputFrame;
276    /// assert!(OpenXrActionInputFrame::empty().source.is_none());
277    /// ```
278    pub fn empty() -> Self {
279        Self {
280            pointer_frame: OpenXrPointerFrame::default(),
281            ray_sample: None,
282            source: None,
283        }
284    }
285}
286
287impl Default for OpenXrActionInputFrame {
288    fn default() -> Self {
289        Self::empty()
290    }
291}
292
293#[derive(Debug, Clone, Copy)]
294/// Fully evaluated world ray and frame-local pointer values.
295struct PointerRay {
296    /// World-space origin in metres.
297    origin: Vec3,
298    /// World-space direction; downstream intersection normalizes it.
299    direction: Vec3,
300    /// Whether the source's primary action is active.
301    pressed: bool,
302    /// Horizontal logical-pixel scroll delta for this frame.
303    scroll_dx: f32,
304    /// Vertical logical-pixel scroll delta for this frame.
305    scroll_dy: f32,
306}
307
308#[derive(Debug, Clone, Copy)]
309/// Routed UI sample paired with visualization and source metadata.
310struct PointerCandidate {
311    /// Logical pointer sample.
312    sample: OpenXrPointerSample,
313    /// World ray for overlay rendering.
314    ray_sample: OpenXrRaySample,
315    /// Stable source identity.
316    source: OpenXrInputSourceInfo,
317}
318
319/// OpenXR action set and per-source state used to poll UI pointer frames.
320///
321/// Construction may return `Ok(None)` when input is disabled. A constructed
322/// value must be attached to exactly one compatible Vulkan session before
323/// polling; polling before attachment returns an empty frame.
324///
325/// # Examples
326///
327/// ```no_run
328/// use ailloli_ui_openxr::OpenXrActionInput;
329/// fn reset(input: &mut OpenXrActionInput) { input.clear(); }
330/// ```
331pub struct OpenXrActionInput {
332    /// OpenXR action set synchronized once per polling frame.
333    action_set: xr::ActionSet,
334    /// Controller/hand aim-pose action for both subaction paths.
335    aim_action: xr::Action<xr::Posef>,
336    /// Normalized primary-trigger action.
337    trigger_action: xr::Action<f32>,
338    /// Horizontal scroll-axis action.
339    scroll_x_action: xr::Action<f32>,
340    /// Vertical scroll-axis action.
341    scroll_y_action: xr::Action<f32>,
342    /// `/user/hand/right` subaction path.
343    right_path: xr::Path,
344    /// `/user/hand/left` subaction path.
345    left_path: xr::Path,
346    /// Right-hand aim space created after action-set attachment.
347    right_aim_space: Option<xr::Space>,
348    /// Left-hand aim space created after action-set attachment.
349    left_aim_space: Option<xr::Space>,
350    /// Optional right-hand joint tracker.
351    right_hand_tracker: Option<xr::HandTracker>,
352    /// Optional left-hand joint tracker.
353    left_hand_tracker: Option<xr::HandTracker>,
354    /// Dead zones, thresholds, source IDs, and hand-input policy.
355    options: OpenXrUiInputOptions,
356    /// Whether the runtime advertises hand-tracking support.
357    hand_tracking_supported: bool,
358    /// Whether the runtime advertises hand-aim support.
359    hand_aim_supported: bool,
360    /// Whether this action set has been attached to a session.
361    attached: bool,
362    /// Source retained through a pressed gesture to prevent hand switching.
363    locked_source_id: Option<u64>,
364    /// Most recently selected source used for deterministic tie breaking.
365    last_selected_source_id: Option<u64>,
366    /// Timestamp of the previous poll used to derive wheel delta time.
367    last_poll: Option<Instant>,
368}
369
370impl OpenXrActionInput {
371    /// Creates actions from a runtime's instance and negotiated capabilities.
372    ///
373    /// # Errors
374    ///
375    /// Returns an OpenXR error when action-set, action, path, or binding setup
376    /// fails. Returns `Ok(None)` when `options.enabled` is false.
377    ///
378    /// # Examples
379    ///
380    /// ```no_run
381    /// use ailloli_ui_openxr::{OpenXrActionInput, OpenXrInstance, OpenXrRuntimeError, OpenXrUiInputOptions};
382    /// fn create(instance: &OpenXrInstance) -> Result<Option<OpenXrActionInput>, OpenXrRuntimeError> {
383    ///     OpenXrActionInput::new_for_runtime(instance, OpenXrUiInputOptions::default())
384    /// }
385    /// ```
386    pub fn new_for_runtime(
387        xr: &OpenXrInstance,
388        options: OpenXrUiInputOptions,
389    ) -> Result<Option<Self>, OpenXrRuntimeError> {
390        Self::new_external(&xr.instance, OpenXrInputCapabilities::from(xr), options)
391    }
392
393    /// Creates actions from externally owned instance capabilities.
394    ///
395    /// # Errors
396    ///
397    /// Returns an OpenXR setup error for rejected action or binding operations.
398    /// Disabled input returns `Ok(None)` without touching the instance.
399    ///
400    /// # Examples
401    ///
402    /// ```no_run
403    /// use ailloli_ui_openxr::{OpenXrActionInput, OpenXrInputCapabilities, OpenXrRuntimeError, OpenXrUiInputOptions};
404    /// fn create(instance: &openxr::Instance) -> Result<Option<OpenXrActionInput>, OpenXrRuntimeError> {
405    ///     OpenXrActionInput::new_external(instance, OpenXrInputCapabilities::new(false, false), OpenXrUiInputOptions::default())
406    /// }
407    /// ```
408    pub fn new_external(
409        instance: &xr::Instance,
410        capabilities: OpenXrInputCapabilities,
411        options: OpenXrUiInputOptions,
412    ) -> Result<Option<Self>, OpenXrRuntimeError> {
413        if !options.enabled {
414            return Ok(None);
415        }
416
417        let action_set = instance
418            .create_action_set("ailloli_ui_ui", "Ailloli UI UI", 0)
419            .map_err(|result| OpenXrRuntimeError::CreateActionSet {
420                name: "ailloli_ui_ui",
421                result,
422            })?;
423        let right_path = path(instance, xr::USER_HAND_RIGHT)?;
424        let left_path = path(instance, xr::USER_HAND_LEFT)?;
425        let subaction_paths = [right_path, left_path];
426
427        let aim_action = action_set
428            .create_action::<xr::Posef>("aim_pose", "Aim Pose", &subaction_paths)
429            .map_err(|result| OpenXrRuntimeError::CreateAction {
430                name: "aim_pose",
431                result,
432            })?;
433        let trigger_action = action_set
434            .create_action::<f32>("trigger", "Trigger", &subaction_paths)
435            .map_err(|result| OpenXrRuntimeError::CreateAction {
436                name: "trigger",
437                result,
438            })?;
439        let scroll_x_action = action_set
440            .create_action::<f32>("scroll_x", "Scroll X", &subaction_paths)
441            .map_err(|result| OpenXrRuntimeError::CreateAction {
442                name: "scroll_x",
443                result,
444            })?;
445        let scroll_y_action = action_set
446            .create_action::<f32>("scroll_y", "Scroll Y", &subaction_paths)
447            .map_err(|result| OpenXrRuntimeError::CreateAction {
448                name: "scroll_y",
449                result,
450            })?;
451
452        suggest_bindings(
453            instance,
454            &aim_action,
455            &trigger_action,
456            &scroll_x_action,
457            &scroll_y_action,
458        )?;
459
460        Ok(Some(Self {
461            action_set,
462            aim_action,
463            trigger_action,
464            scroll_x_action,
465            scroll_y_action,
466            right_path,
467            left_path,
468            right_aim_space: None,
469            left_aim_space: None,
470            right_hand_tracker: None,
471            left_hand_tracker: None,
472            options,
473            hand_tracking_supported: options.hands && capabilities.hand_tracking_supported,
474            hand_aim_supported: options.hands && capabilities.hand_aim_supported,
475            attached: false,
476            locked_source_id: None,
477            last_selected_source_id: None,
478            last_poll: None,
479        }))
480    }
481
482    /// Attaches the action set and creates configured source spaces and trackers.
483    ///
484    /// Repeated calls after a successful attachment are a no-op. Hand-tracker
485    /// creation failures disable the affected tracker rather than failing setup.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error when attaching the action set or creating a controller
490    /// aim space fails.
491    ///
492    /// # Examples
493    ///
494    /// ```no_run
495    /// use ailloli_ui_openxr::{OpenXrActionInput, OpenXrRuntimeError};
496    /// fn attach(input: &mut OpenXrActionInput, session: &openxr::Session<openxr::Vulkan>) -> Result<(), OpenXrRuntimeError> {
497    ///     input.attach_session(session)
498    /// }
499    /// ```
500    pub fn attach_session(
501        &mut self,
502        session: &xr::Session<xr::Vulkan>,
503    ) -> Result<(), OpenXrRuntimeError> {
504        if self.attached {
505            return Ok(());
506        }
507
508        session
509            .attach_action_sets(&[&self.action_set])
510            .map_err(|result| OpenXrRuntimeError::AttachActionSets { result })?;
511
512        if self.options.controllers {
513            self.right_aim_space =
514                Some(self.create_aim_space(session, PointerHand::Right, self.right_path)?);
515            self.left_aim_space =
516                Some(self.create_aim_space(session, PointerHand::Left, self.left_path)?);
517        }
518
519        if self.hand_tracking_supported {
520            self.right_hand_tracker = session.create_hand_tracker(xr::Hand::RIGHT).ok();
521            self.left_hand_tracker = session.create_hand_tracker(xr::Hand::LEFT).ok();
522        }
523
524        self.attached = true;
525        Ok(())
526    }
527
528    /// Clears source locks and frame-time history without detaching actions.
529    ///
530    /// # Examples
531    ///
532    /// ```no_run
533    /// use ailloli_ui_openxr::OpenXrActionInput;
534    /// fn clear(input: &mut OpenXrActionInput) { input.clear(); }
535    /// ```
536    pub fn clear(&mut self) {
537        self.locked_source_id = None;
538        self.last_selected_source_id = None;
539        self.last_poll = None;
540    }
541
542    /// Logs the current left and right interaction-profile paths.
543    ///
544    /// Lookup failures are logged as `"<unknown>"` and are not returned.
545    ///
546    /// # Examples
547    ///
548    /// ```no_run
549    /// use ailloli_ui_openxr::OpenXrActionInput;
550    /// fn log(input: &OpenXrActionInput, instance: &openxr::Instance, session: &openxr::Session<openxr::Vulkan>) {
551    ///     input.log_interaction_profiles(instance, session);
552    /// }
553    /// ```
554    pub fn log_interaction_profiles(
555        &self,
556        instance: &xr::Instance,
557        session: &xr::Session<xr::Vulkan>,
558    ) {
559        for hand in [PointerHand::Right, PointerHand::Left] {
560            let profile = session
561                .current_interaction_profile(self.path_for_hand(hand))
562                .ok()
563                .and_then(|path| instance.path_to_string(path).ok())
564                .unwrap_or_else(|| "<unknown>".to_string());
565            log::info!(
566                "Ailloli UI OpenXR input profile {}={}",
567                hand.label(),
568                profile
569            );
570        }
571    }
572
573    /// Synchronizes actions and selects at most one pointer for the current frame.
574    ///
575    /// Controller scroll is scaled by elapsed time, using 1/60 second on the
576    /// first poll and clamping later deltas to 100 ms. Logical hit points are
577    /// clamped to the supplied top-left-origin size. Before attachment, an empty
578    /// frame is returned without synchronizing actions.
579    ///
580    /// # Errors
581    ///
582    /// Returns failures from action synchronization, action state, space
583    /// location, or hand-joint location.
584    ///
585    /// # Examples
586    ///
587    /// ```no_run
588    /// use ailloli_ui_core::Size;
589    /// use ailloli_ui_openxr::{OpenXrActionInput, OpenXrActionInputFrame, OpenXrQuadLayerOptions, OpenXrRuntimeError};
590    /// fn poll(input: &mut OpenXrActionInput, instance: &openxr::Instance, session: &openxr::Session<openxr::Vulkan>, space: &openxr::Space, time: openxr::Time) -> Result<OpenXrActionInputFrame, OpenXrRuntimeError> {
591    ///     input.poll_frame(instance, session, space, OpenXrQuadLayerOptions::default(), time, Size::new(1024.0, 576.0))
592    /// }
593    /// ```
594    pub fn poll_frame(
595        &mut self,
596        xr_instance: &xr::Instance,
597        session: &xr::Session<xr::Vulkan>,
598        reference_space: &xr::Space,
599        layer: OpenXrQuadLayerOptions,
600        time: xr::Time,
601        logical_size: Size,
602    ) -> Result<OpenXrActionInputFrame, OpenXrRuntimeError> {
603        if !self.attached {
604            return Ok(OpenXrActionInputFrame::empty());
605        }
606
607        session
608            .sync_actions(&[(&self.action_set).into()])
609            .map_err(|result| OpenXrRuntimeError::SyncActions { result })?;
610
611        let now = Instant::now();
612        let dt = self
613            .last_poll
614            .map(|last| now.saturating_duration_since(last).as_secs_f32())
615            .unwrap_or(1.0 / 60.0)
616            .clamp(0.0, 0.100);
617        self.last_poll = Some(now);
618
619        let logical_width = logical_size.w;
620        let logical_height = logical_size.h;
621        let quad = ray_quad_from_layer(layer);
622        let mut candidates = Vec::new();
623        for hand in [PointerHand::Right, PointerHand::Left] {
624            if let Some(candidate) = self.poll_controller_candidate(
625                session,
626                reference_space,
627                time,
628                hand,
629                &quad,
630                dt,
631                logical_width,
632                logical_height,
633            )? {
634                candidates.push(candidate);
635            }
636        }
637        for hand in [PointerHand::Right, PointerHand::Left] {
638            if let Some(candidate) = self.poll_hand_candidate(
639                xr_instance,
640                session,
641                reference_space,
642                time,
643                hand,
644                &quad,
645                logical_width,
646                logical_height,
647            )? {
648                candidates.push(candidate);
649            }
650        }
651
652        Ok(select_action_input_frame(
653            &candidates,
654            self.options.pointer_selection,
655            &mut self.locked_source_id,
656            &mut self.last_selected_source_id,
657            logical_width,
658            logical_height,
659        ))
660    }
661
662    /// Creates an identity-offset action space for one controller aim pose.
663    ///
664    /// # Errors
665    ///
666    /// Returns [`OpenXrRuntimeError::CreateActionSpace`] with the hand label and
667    /// runtime result when OpenXR rejects the action-space creation.
668    fn create_aim_space(
669        &self,
670        session: &xr::Session<xr::Vulkan>,
671        hand: PointerHand,
672        path: xr::Path,
673    ) -> Result<xr::Space, OpenXrRuntimeError> {
674        self.aim_action
675            .create_space(session, path, xr::Posef::IDENTITY)
676            .map_err(|result| OpenXrRuntimeError::CreateActionSpace {
677                source_name: hand.label(),
678                result,
679            })
680    }
681
682    /// Polls one active controller into a ray candidate with time-scaled scroll.
683    ///
684    /// # Errors
685    ///
686    /// Returns the matching action-state or action-space-location
687    /// [`OpenXrRuntimeError`] while reading aim, trigger, or scroll state.
688    fn poll_controller_candidate(
689        &self,
690        session: &xr::Session<xr::Vulkan>,
691        reference_space: &xr::Space,
692        time: xr::Time,
693        hand: PointerHand,
694        quad: &RayQuad,
695        dt: f32,
696        logical_width: f32,
697        logical_height: f32,
698    ) -> Result<Option<PointerCandidate>, OpenXrRuntimeError> {
699        if !self.options.controllers || !self.aim_active(session, hand)? {
700            return Ok(None);
701        }
702
703        let aim_space = match hand {
704            PointerHand::Right => self.right_aim_space.as_ref(),
705            PointerHand::Left => self.left_aim_space.as_ref(),
706        };
707        let Some(aim_space) = aim_space else {
708            return Ok(None);
709        };
710
711        let aim_loc = aim_space.locate(reference_space, time).map_err(|result| {
712            OpenXrRuntimeError::LocateActionSpace {
713                source_name: hand.label(),
714                result,
715            }
716        })?;
717        let flags = aim_loc.location_flags;
718        if !flags.contains(xr::SpaceLocationFlags::POSITION_VALID)
719            || !flags.contains(xr::SpaceLocationFlags::ORIENTATION_VALID)
720        {
721            return Ok(None);
722        }
723
724        let trigger = action_f32(
725            &self.trigger_action,
726            session,
727            self.path_for_hand(hand),
728            "trigger",
729            hand.label(),
730        )?;
731        let scroll_x = action_f32(
732            &self.scroll_x_action,
733            session,
734            self.path_for_hand(hand),
735            "scroll_x",
736            hand.label(),
737        )?;
738        let scroll_y = action_f32(
739            &self.scroll_y_action,
740            session,
741            self.path_for_hand(hand),
742            "scroll_y",
743            hand.label(),
744        )?;
745
746        let ray = PointerRay {
747            origin: vec3_from_xr(aim_loc.pose.position),
748            direction: forward_from_orientation(aim_loc.pose.orientation),
749            pressed: trigger >= self.options.trigger_threshold,
750            scroll_dx: scroll_delta(
751                scroll_x,
752                self.options.scroll_deadzone,
753                self.options.scroll_pixels_per_second,
754                dt,
755                false,
756            ),
757            scroll_dy: scroll_delta(
758                scroll_y,
759                self.options.scroll_deadzone,
760                self.options.scroll_pixels_per_second,
761                dt,
762                true,
763            ),
764        };
765
766        Ok(Some(candidate_from_ray(
767            source_info(
768                hand.controller_source_id(),
769                OpenXrInputSourceKind::Controller,
770                hand,
771            ),
772            ray,
773            quad,
774            logical_width,
775            logical_height,
776        )))
777    }
778
779    /// Polls one hand via FB aim or index-joint fallback when controller aim is inactive.
780    ///
781    /// # Errors
782    ///
783    /// Propagates controller aim-state errors and hand-joint location errors from
784    /// the selected FB-aim or joint-fallback path.
785    fn poll_hand_candidate(
786        &self,
787        xr_instance: &xr::Instance,
788        session: &xr::Session<xr::Vulkan>,
789        reference_space: &xr::Space,
790        time: xr::Time,
791        hand: PointerHand,
792        quad: &RayQuad,
793        logical_width: f32,
794        logical_height: f32,
795    ) -> Result<Option<PointerCandidate>, OpenXrRuntimeError> {
796        if !self.options.hands || !self.hand_tracking_supported || self.aim_active(session, hand)? {
797            return Ok(None);
798        }
799
800        let tracker = match hand {
801            PointerHand::Right => self.right_hand_tracker.as_ref(),
802            PointerHand::Left => self.left_hand_tracker.as_ref(),
803        };
804        let Some(tracker) = tracker else {
805            return Ok(None);
806        };
807
808        let ray = if self.hand_aim_supported {
809            locate_hand_with_aim(
810                xr_instance,
811                reference_space,
812                tracker,
813                time,
814                hand.label(),
815                self.options.pinch_threshold,
816            )?
817        } else {
818            locate_hand_joints_fallback(reference_space, tracker, time, hand.label())?
819        };
820        let Some(ray) = ray else {
821            return Ok(None);
822        };
823
824        Ok(Some(candidate_from_ray(
825            source_info(hand.hand_source_id(), OpenXrInputSourceKind::Hand, hand),
826            ray,
827            quad,
828            logical_width,
829            logical_height,
830        )))
831    }
832
833    /// Reports whether the configured controller aim action is active for one side.
834    ///
835    /// # Errors
836    ///
837    /// Returns [`OpenXrRuntimeError::ActionState`] when the runtime cannot query
838    /// the selected hand's aim-pose action.
839    fn aim_active(
840        &self,
841        session: &xr::Session<xr::Vulkan>,
842        hand: PointerHand,
843    ) -> Result<bool, OpenXrRuntimeError> {
844        if !self.options.controllers {
845            return Ok(false);
846        }
847        self.aim_action
848            .is_active(session, self.path_for_hand(hand))
849            .map_err(|result| OpenXrRuntimeError::ActionState {
850                action: "aim_pose",
851                source_name: hand.label(),
852                result,
853            })
854    }
855
856    /// Returns the cached OpenXR user path for a hand side.
857    fn path_for_hand(&self, hand: PointerHand) -> xr::Path {
858        match hand {
859            PointerHand::Right => self.right_path,
860            PointerHand::Left => self.left_path,
861        }
862    }
863}
864
865/// Converts a static interaction path and retains it in any error.
866///
867/// # Errors
868///
869/// Returns [`OpenXrRuntimeError::StringToPath`] with the original static path
870/// when OpenXR rejects its conversion.
871fn path(instance: &xr::Instance, path: &'static str) -> Result<xr::Path, OpenXrRuntimeError> {
872    instance
873        .string_to_path(path)
874        .map_err(|result| OpenXrRuntimeError::StringToPath { path, result })
875}
876
877/// Suggests identical actions for all known Touch profiles, requiring one success.
878///
879/// # Errors
880///
881/// Returns [`OpenXrRuntimeError::NoInteractionProfileBindings`] when every known
882/// profile fails. Individual profile errors are deliberately tolerated if at
883/// least one profile accepts the bindings.
884fn suggest_bindings(
885    instance: &xr::Instance,
886    aim_action: &xr::Action<xr::Posef>,
887    trigger_action: &xr::Action<f32>,
888    scroll_x_action: &xr::Action<f32>,
889    scroll_y_action: &xr::Action<f32>,
890) -> Result<(), OpenXrRuntimeError> {
891    let mut success_count = 0usize;
892    for profile in INTERACTION_PROFILES {
893        if suggest_bindings_for_profile(
894            instance,
895            profile,
896            aim_action,
897            trigger_action,
898            scroll_x_action,
899            scroll_y_action,
900        )
901        .is_ok()
902        {
903            success_count += 1;
904        }
905    }
906    if success_count == 0 {
907        return Err(OpenXrRuntimeError::NoInteractionProfileBindings);
908    }
909    Ok(())
910}
911
912/// Builds right/left aim, trigger, and thumbstick bindings for one profile.
913///
914/// # Errors
915///
916/// Propagates static path-conversion errors, or returns
917/// [`OpenXrRuntimeError::SuggestInteractionProfileBindings`] when the runtime
918/// rejects the complete binding set.
919fn suggest_bindings_for_profile(
920    instance: &xr::Instance,
921    profile: &'static str,
922    aim_action: &xr::Action<xr::Posef>,
923    trigger_action: &xr::Action<f32>,
924    scroll_x_action: &xr::Action<f32>,
925    scroll_y_action: &xr::Action<f32>,
926) -> Result<(), OpenXrRuntimeError> {
927    let profile_path = path(instance, profile)?;
928    let bindings = [
929        xr::Binding::new(
930            aim_action,
931            path(instance, "/user/hand/right/input/aim/pose")?,
932        ),
933        xr::Binding::new(
934            trigger_action,
935            path(instance, "/user/hand/right/input/trigger/value")?,
936        ),
937        xr::Binding::new(
938            scroll_x_action,
939            path(instance, "/user/hand/right/input/thumbstick/x")?,
940        ),
941        xr::Binding::new(
942            scroll_y_action,
943            path(instance, "/user/hand/right/input/thumbstick/y")?,
944        ),
945        xr::Binding::new(
946            aim_action,
947            path(instance, "/user/hand/left/input/aim/pose")?,
948        ),
949        xr::Binding::new(
950            trigger_action,
951            path(instance, "/user/hand/left/input/trigger/value")?,
952        ),
953        xr::Binding::new(
954            scroll_x_action,
955            path(instance, "/user/hand/left/input/thumbstick/x")?,
956        ),
957        xr::Binding::new(
958            scroll_y_action,
959            path(instance, "/user/hand/left/input/thumbstick/y")?,
960        ),
961    ];
962
963    instance
964        .suggest_interaction_profile_bindings(profile_path, &bindings)
965        .map_err(|result| OpenXrRuntimeError::SuggestInteractionProfileBindings { profile, result })
966}
967
968/// Reads a float action, mapping an inactive action to zero.
969///
970/// # Errors
971///
972/// Returns [`OpenXrRuntimeError::ActionState`] with the action/source labels when
973/// the runtime query fails.
974fn action_f32(
975    action: &xr::Action<f32>,
976    session: &xr::Session<xr::Vulkan>,
977    path: xr::Path,
978    action_name: &'static str,
979    source: &'static str,
980) -> Result<f32, OpenXrRuntimeError> {
981    let state = action
982        .state(session, path)
983        .map_err(|result| OpenXrRuntimeError::ActionState {
984            action: action_name,
985            source_name: source,
986            result,
987        })?;
988    Ok(if state.is_active {
989        state.current_state
990    } else {
991        0.0
992    })
993}
994
995/// Applies an exclusive deadzone and converts axis value to frame pixel delta.
996fn scroll_delta(value: f32, deadzone: f32, pixels_per_second: f32, dt: f32, invert: bool) -> f32 {
997    if value.abs() < deadzone {
998        return 0.0;
999    }
1000    let delta = value * pixels_per_second * dt;
1001    if invert {
1002        -delta
1003    } else {
1004        delta
1005    }
1006}
1007
1008/// Constructs stable identity metadata for a known source.
1009fn source_info(
1010    source_id: u64,
1011    source_kind: OpenXrInputSourceKind,
1012    hand: PointerHand,
1013) -> OpenXrInputSourceInfo {
1014    OpenXrInputSourceInfo {
1015        source_id,
1016        source_kind,
1017        hand,
1018    }
1019}
1020
1021/// Resolves the four reserved source IDs, returning `None` for external IDs.
1022fn source_info_for_id(source_id: u64) -> Option<OpenXrInputSourceInfo> {
1023    match source_id {
1024        RIGHT_CONTROLLER_SOURCE_ID => Some(source_info(
1025            source_id,
1026            OpenXrInputSourceKind::Controller,
1027            PointerHand::Right,
1028        )),
1029        LEFT_CONTROLLER_SOURCE_ID => Some(source_info(
1030            source_id,
1031            OpenXrInputSourceKind::Controller,
1032            PointerHand::Left,
1033        )),
1034        RIGHT_HAND_SOURCE_ID => Some(source_info(
1035            source_id,
1036            OpenXrInputSourceKind::Hand,
1037            PointerHand::Right,
1038        )),
1039        LEFT_HAND_SOURCE_ID => Some(source_info(
1040            source_id,
1041            OpenXrInputSourceKind::Hand,
1042            PointerHand::Left,
1043        )),
1044        _ => None,
1045    }
1046}
1047
1048/// Intersects a ray with the UI quad and pairs logical and overlay samples.
1049fn candidate_from_ray(
1050    source: OpenXrInputSourceInfo,
1051    ray: PointerRay,
1052    quad: &RayQuad,
1053    logical_width: f32,
1054    logical_height: f32,
1055) -> PointerCandidate {
1056    let intersection = quad.intersect(ray.origin, ray.direction);
1057    let hit = intersection
1058        .map(|hit| {
1059            OpenXrPointerHit::Hit(PointHit::new(
1060                uv_to_logical(hit.u, hit.v, logical_width, logical_height),
1061                Some(hit.t),
1062            ))
1063        })
1064        .unwrap_or(OpenXrPointerHit::Miss);
1065    let ray_sample = OpenXrRaySample::new(
1066        ray.origin,
1067        ray.direction,
1068        if intersection.is_some() {
1069            OpenXrRayHitKind::Ui
1070        } else {
1071            OpenXrRayHitKind::Miss
1072        },
1073        intersection
1074            .map(|hit| hit.t)
1075            .unwrap_or(OPENXR_RAY_MAX_LENGTH_METERS),
1076    );
1077    PointerCandidate {
1078        sample: OpenXrPointerSample {
1079            source_id: source.source_id,
1080            hit,
1081            trigger_pressed: ray.pressed,
1082            scroll_dx: ray.scroll_dx,
1083            scroll_dy: ray.scroll_dy,
1084        },
1085        ray_sample,
1086        source,
1087    }
1088}
1089
1090/// Selects one candidate and synthesizes a miss when the prior source disappears.
1091fn select_action_input_frame(
1092    candidates: &[PointerCandidate],
1093    policy: OpenXrPointerSelectionPolicy,
1094    locked_source_id: &mut Option<u64>,
1095    last_selected_source_id: &mut Option<u64>,
1096    logical_width: f32,
1097    logical_height: f32,
1098) -> OpenXrActionInputFrame {
1099    let samples = select_pointer_samples(
1100        candidates,
1101        policy,
1102        locked_source_id,
1103        logical_width,
1104        logical_height,
1105    );
1106    if let Some(sample) = samples.first().copied() {
1107        if let Some(candidate) = candidates
1108            .iter()
1109            .find(|candidate| candidate.sample.source_id == sample.source_id)
1110        {
1111            *last_selected_source_id = Some(candidate.source.source_id);
1112            return OpenXrActionInputFrame {
1113                pointer_frame: OpenXrPointerFrame::new(vec![sample]),
1114                ray_sample: Some(candidate.ray_sample),
1115                source: Some(candidate.source),
1116            };
1117        }
1118
1119        *last_selected_source_id = None;
1120        return OpenXrActionInputFrame {
1121            pointer_frame: OpenXrPointerFrame::new(vec![sample]),
1122            ray_sample: None,
1123            source: source_info_for_id(sample.source_id),
1124        };
1125    }
1126
1127    if let Some(source_id) = last_selected_source_id.take() {
1128        return OpenXrActionInputFrame {
1129            pointer_frame: OpenXrPointerFrame::new(vec![OpenXrPointerSample::new(
1130                source_id,
1131                OpenXrPointerHit::Miss,
1132                false,
1133            )]),
1134            ray_sample: None,
1135            source: source_info_for_id(source_id),
1136        };
1137    }
1138
1139    OpenXrActionInputFrame::empty()
1140}
1141
1142/// Preserves a pressed-source lock, otherwise applies deterministic policy order.
1143fn select_pointer_samples(
1144    candidates: &[PointerCandidate],
1145    policy: OpenXrPointerSelectionPolicy,
1146    locked_source_id: &mut Option<u64>,
1147    logical_width: f32,
1148    logical_height: f32,
1149) -> Vec<OpenXrPointerSample> {
1150    if let Some(source_id) = *locked_source_id {
1151        if let Some(candidate) = candidates
1152            .iter()
1153            .find(|candidate| candidate.sample.source_id == source_id)
1154        {
1155            if !candidate.sample.trigger_pressed {
1156                *locked_source_id = None;
1157            }
1158            return vec![candidate.sample];
1159        }
1160
1161        *locked_source_id = None;
1162        return vec![OpenXrPointerSample::new(
1163            source_id,
1164            OpenXrPointerHit::Miss,
1165            false,
1166        )];
1167    }
1168
1169    let Some(sample) = choose_candidate(candidates, policy) else {
1170        return Vec::new();
1171    };
1172    if sample.trigger_pressed {
1173        *locked_source_id = Some(sample.source_id);
1174    }
1175
1176    vec![OpenXrPointerSample {
1177        hit: sample
1178            .hit
1179            .point()
1180            .map(|point| {
1181                OpenXrPointerHit::Hit(PointHit::new(
1182                    clamp_point(point, logical_width, logical_height),
1183                    match sample.hit {
1184                        OpenXrPointerHit::Hit(hit) => hit.depth,
1185                        OpenXrPointerHit::Miss => None,
1186                    },
1187                ))
1188            })
1189            .unwrap_or(OpenXrPointerHit::Miss),
1190        ..sample
1191    }]
1192}
1193
1194/// Chooses the first policy-ranked hit, falling back to the first ranked miss.
1195fn choose_candidate(
1196    candidates: &[PointerCandidate],
1197    policy: OpenXrPointerSelectionPolicy,
1198) -> Option<OpenXrPointerSample> {
1199    let order = match policy {
1200        OpenXrPointerSelectionPolicy::PreferRightController => [
1201            RIGHT_CONTROLLER_SOURCE_ID,
1202            LEFT_CONTROLLER_SOURCE_ID,
1203            RIGHT_HAND_SOURCE_ID,
1204            LEFT_HAND_SOURCE_ID,
1205        ],
1206        OpenXrPointerSelectionPolicy::PreferLeftController => [
1207            LEFT_CONTROLLER_SOURCE_ID,
1208            RIGHT_CONTROLLER_SOURCE_ID,
1209            LEFT_HAND_SOURCE_ID,
1210            RIGHT_HAND_SOURCE_ID,
1211        ],
1212    };
1213
1214    for source_id in order {
1215        if let Some(candidate) = candidates.iter().find(|candidate| {
1216            candidate.sample.source_id == source_id && candidate.sample.hit.point().is_some()
1217        }) {
1218            return Some(candidate.sample);
1219        }
1220    }
1221    for source_id in order {
1222        if let Some(candidate) = candidates
1223            .iter()
1224            .find(|candidate| candidate.sample.source_id == source_id)
1225        {
1226            return Some(candidate.sample);
1227        }
1228    }
1229    None
1230}
1231
1232/// Clamps both logical point axes to inclusive panel bounds.
1233fn clamp_point(
1234    point: ailloli_ui_core::Point,
1235    logical_width: f32,
1236    logical_height: f32,
1237) -> ailloli_ui_core::Point {
1238    ailloli_ui_core::Point::new(
1239        point.x.clamp(0.0, logical_width),
1240        point.y.clamp(0.0, logical_height),
1241    )
1242}
1243
1244/// Derives normalized layer axes and half extents clamped to 0.5 mm.
1245fn ray_quad_from_layer(layer: OpenXrQuadLayerOptions) -> RayQuad {
1246    let orientation = layer.pose.orientation;
1247    RayQuad::new(
1248        vec3_from_xr(layer.pose.position),
1249        rotate_vec3(orientation, Vec3::new(0.0, 0.0, 1.0)).normalize_or(Vec3::new(0.0, 0.0, 1.0)),
1250        rotate_vec3(orientation, Vec3::new(1.0, 0.0, 0.0)).normalize_or(Vec3::new(1.0, 0.0, 0.0)),
1251        rotate_vec3(orientation, Vec3::new(0.0, 1.0, 0.0)).normalize_or(Vec3::new(0.0, 1.0, 0.0)),
1252        layer.size.width.max(0.001) * 0.5,
1253        layer.size.height.max(0.001) * 0.5,
1254    )
1255}
1256
1257/// Calls the FB hand-aim chain and returns a pinch-classified ray when valid.
1258///
1259/// # Errors
1260///
1261/// Returns [`OpenXrRuntimeError::LocateHandJoints`] when the raw extension call
1262/// reports a negative OpenXR result. Unsupported/inactive/invalid aim state is
1263/// represented by `Ok(None)`.
1264fn locate_hand_with_aim(
1265    instance: &xr::Instance,
1266    reference_space: &xr::Space,
1267    tracker: &xr::HandTracker,
1268    time: xr::Time,
1269    source: &'static str,
1270    pinch_threshold: f32,
1271) -> Result<Option<PointerRay>, OpenXrRuntimeError> {
1272    use xr::sys;
1273
1274    let Some(fp) = instance.exts().ext_hand_tracking.as_ref() else {
1275        return Ok(None);
1276    };
1277
1278    unsafe {
1279        let mut aim_state = sys::HandTrackingAimStateFB::out(ptr::null_mut());
1280        let mut joints = MaybeUninit::<[sys::HandJointLocationEXT; xr::HAND_JOINT_COUNT]>::uninit();
1281        let mut location_info = sys::HandJointLocationsEXT {
1282            ty: sys::HandJointLocationsEXT::TYPE,
1283            next: aim_state.as_mut_ptr().cast(),
1284            is_active: sys::Bool32::from(false),
1285            joint_count: xr::HAND_JOINT_COUNT as u32,
1286            joint_locations: joints.as_mut_ptr().cast(),
1287        };
1288        let locate_info = sys::HandJointsLocateInfoEXT {
1289            ty: sys::HandJointsLocateInfoEXT::TYPE,
1290            next: ptr::null(),
1291            base_space: reference_space.as_handle(),
1292            time,
1293        };
1294        let result = (fp.locate_hand_joints)(tracker.as_handle(), &locate_info, &mut location_info);
1295        if result.into_raw() < 0 {
1296            return Err(OpenXrRuntimeError::LocateHandJoints {
1297                source_name: source,
1298                result,
1299            });
1300        }
1301        if !bool::from(location_info.is_active) {
1302            return Ok(None);
1303        }
1304
1305        let aim = aim_state.assume_init();
1306        let status = aim.status;
1307        if !status.contains(sys::HandTrackingAimFlagsFB::VALID) {
1308            return Ok(None);
1309        }
1310
1311        Ok(Some(PointerRay {
1312            origin: vec3_from_xr(aim.aim_pose.position),
1313            direction: forward_from_orientation(aim.aim_pose.orientation),
1314            pressed: status.contains(sys::HandTrackingAimFlagsFB::INDEX_PINCHING)
1315                || aim.pinch_strength_index >= pinch_threshold,
1316            scroll_dx: 0.0,
1317            scroll_dy: 0.0,
1318        }))
1319    }
1320}
1321
1322/// Builds an index-finger ray and 3.5 cm thumb/index pinch from joint locations.
1323///
1324/// # Errors
1325///
1326/// Returns [`OpenXrRuntimeError::LocateHandJoints`] when the safe OpenXR joint
1327/// query fails. Inactive, invalid, or degenerate joints return `Ok(None)`.
1328fn locate_hand_joints_fallback(
1329    reference_space: &xr::Space,
1330    tracker: &xr::HandTracker,
1331    time: xr::Time,
1332    source: &'static str,
1333) -> Result<Option<PointerRay>, OpenXrRuntimeError> {
1334    let joints = reference_space
1335        .locate_hand_joints(tracker, time)
1336        .map_err(|result| OpenXrRuntimeError::LocateHandJoints {
1337            source_name: source,
1338            result,
1339        })?;
1340    let Some(joints) = joints else {
1341        return Ok(None);
1342    };
1343
1344    let tip = &joints[xr::HandJoint::INDEX_TIP];
1345    let proximal = &joints[xr::HandJoint::INDEX_PROXIMAL];
1346    let thumb_tip = &joints[xr::HandJoint::THUMB_TIP];
1347    if !tip
1348        .location_flags
1349        .contains(xr::SpaceLocationFlags::POSITION_VALID)
1350        || !proximal
1351            .location_flags
1352            .contains(xr::SpaceLocationFlags::POSITION_VALID)
1353        || !thumb_tip
1354            .location_flags
1355            .contains(xr::SpaceLocationFlags::POSITION_VALID)
1356    {
1357        return Ok(None);
1358    }
1359
1360    let origin = vec3_from_xr(tip.pose.position);
1361    let Some(direction) = (origin - vec3_from_xr(proximal.pose.position)).normalize() else {
1362        return Ok(None);
1363    };
1364    let pinching = (origin - vec3_from_xr(thumb_tip.pose.position)).len() <= PINCH_DISTANCE_METERS;
1365
1366    Ok(Some(PointerRay {
1367        origin,
1368        direction,
1369        pressed: pinching,
1370        scroll_dx: 0.0,
1371        scroll_dy: 0.0,
1372    }))
1373}
1374
1375/// Copies OpenXR vector components to the lightweight vector type.
1376fn vec3_from_xr(v: xr::Vector3f) -> Vec3 {
1377    Vec3::new(v.x, v.y, v.z)
1378}
1379
1380/// Rotates negative Z by a pose quaternion and normalizes with negative-Z fallback.
1381fn forward_from_orientation(q: xr::Quaternionf) -> Vec3 {
1382    rotate_vec3(q, Vec3::new(0.0, 0.0, -1.0)).normalize_or(Vec3::new(0.0, 0.0, -1.0))
1383}
1384
1385/// Rotates a vector by an assumed-normalized quaternion.
1386fn rotate_vec3(q: xr::Quaternionf, v: Vec3) -> Vec3 {
1387    let q_vec = Vec3::new(q.x, q.y, q.z);
1388    let uv = q_vec.cross(v);
1389    let uuv = q_vec.cross(uv);
1390    v + (uv * q.w + uuv) * 2.0
1391}
1392
1393#[cfg(test)]
1394/// Covers defaults, source priority and locking, loss releases, rays, and scroll.
1395mod tests {
1396    use super::*;
1397
1398    /// Builds a candidate fixture with matching ray hit classification.
1399    fn sample(source_id: u64, hit: OpenXrPointerHit, pressed: bool) -> PointerCandidate {
1400        PointerCandidate {
1401            sample: OpenXrPointerSample::new(source_id, hit, pressed),
1402            ray_sample: OpenXrRaySample::new(
1403                Vec3::new(0.0, 0.0, 0.0),
1404                Vec3::new(0.0, 0.0, -1.0),
1405                if hit.point().is_some() {
1406                    OpenXrRayHitKind::Ui
1407                } else {
1408                    OpenXrRayHitKind::Miss
1409                },
1410                1.0,
1411            ),
1412            source: source_info_for_id(source_id).unwrap(),
1413        }
1414    }
1415
1416    #[test]
1417    fn input_options_defaults_match_documented_contract() {
1418        let options = OpenXrUiInputOptions::default();
1419        assert!(options.enabled);
1420        assert!(options.controllers);
1421        assert!(options.hands);
1422        assert_eq!(options.trigger_threshold, 0.75);
1423        assert_eq!(options.pinch_threshold, 0.75);
1424        assert_eq!(options.scroll_deadzone, 0.20);
1425        assert_eq!(options.scroll_pixels_per_second, 720.0);
1426        assert_eq!(
1427            options.pointer_selection,
1428            OpenXrPointerSelectionPolicy::PreferRightController
1429        );
1430    }
1431
1432    #[test]
1433    fn pointer_selection_prefers_first_hit_by_policy() {
1434        let hit = OpenXrPointerHit::Hit(PointHit::new(ailloli_ui_core::Point::new(1.0, 1.0), None));
1435        let candidates = [
1436            sample(LEFT_CONTROLLER_SOURCE_ID, hit, false),
1437            sample(RIGHT_CONTROLLER_SOURCE_ID, hit, false),
1438        ];
1439        assert_eq!(
1440            choose_candidate(
1441                &candidates,
1442                OpenXrPointerSelectionPolicy::PreferRightController
1443            )
1444            .unwrap()
1445            .source_id,
1446            RIGHT_CONTROLLER_SOURCE_ID
1447        );
1448        assert_eq!(
1449            choose_candidate(
1450                &candidates,
1451                OpenXrPointerSelectionPolicy::PreferLeftController
1452            )
1453            .unwrap()
1454            .source_id,
1455            LEFT_CONTROLLER_SOURCE_ID
1456        );
1457    }
1458
1459    #[test]
1460    fn pointer_selection_locks_pressed_source_until_release() {
1461        let mut locked_source_id = None;
1462        let hit = OpenXrPointerHit::Hit(PointHit::new(ailloli_ui_core::Point::new(1.0, 1.0), None));
1463        let first = [
1464            sample(RIGHT_CONTROLLER_SOURCE_ID, hit, true),
1465            sample(LEFT_CONTROLLER_SOURCE_ID, hit, false),
1466        ];
1467        assert_eq!(
1468            select_pointer_samples(
1469                &first,
1470                OpenXrPointerSelectionPolicy::PreferRightController,
1471                &mut locked_source_id,
1472                10.0,
1473                10.0
1474            )[0]
1475            .source_id,
1476            RIGHT_CONTROLLER_SOURCE_ID
1477        );
1478        assert_eq!(locked_source_id, Some(RIGHT_CONTROLLER_SOURCE_ID));
1479
1480        let second = [
1481            sample(LEFT_CONTROLLER_SOURCE_ID, hit, true),
1482            sample(RIGHT_CONTROLLER_SOURCE_ID, hit, false),
1483        ];
1484        assert_eq!(
1485            select_pointer_samples(
1486                &second,
1487                OpenXrPointerSelectionPolicy::PreferRightController,
1488                &mut locked_source_id,
1489                10.0,
1490                10.0
1491            )[0]
1492            .source_id,
1493            RIGHT_CONTROLLER_SOURCE_ID
1494        );
1495        assert_eq!(locked_source_id, None);
1496    }
1497
1498    #[test]
1499    fn source_loss_emits_miss_for_last_selected_source() {
1500        let mut locked_source_id = None;
1501        let mut last_selected_source_id = Some(RIGHT_CONTROLLER_SOURCE_ID);
1502        let frame = select_action_input_frame(
1503            &[],
1504            OpenXrPointerSelectionPolicy::PreferRightController,
1505            &mut locked_source_id,
1506            &mut last_selected_source_id,
1507            10.0,
1508            10.0,
1509        );
1510
1511        assert_eq!(frame.pointer_frame.samples.len(), 1);
1512        assert_eq!(
1513            frame.pointer_frame.samples[0].source_id,
1514            RIGHT_CONTROLLER_SOURCE_ID
1515        );
1516        assert_eq!(frame.pointer_frame.samples[0].hit, OpenXrPointerHit::Miss);
1517        assert!(!frame.pointer_frame.samples[0].trigger_pressed);
1518        assert!(frame.ray_sample.is_none());
1519        assert!(last_selected_source_id.is_none());
1520    }
1521
1522    #[test]
1523    fn ray_hit_builds_pointer_frame_and_ray_sample() {
1524        let quad = ray_quad_from_layer(OpenXrQuadLayerOptions::default());
1525        let candidate = candidate_from_ray(
1526            source_info(
1527                RIGHT_CONTROLLER_SOURCE_ID,
1528                OpenXrInputSourceKind::Controller,
1529                PointerHand::Right,
1530            ),
1531            PointerRay {
1532                origin: Vec3::new(0.0, 0.0, 0.0),
1533                direction: Vec3::new(0.0, 0.0, -1.0),
1534                pressed: true,
1535                scroll_dx: 0.0,
1536                scroll_dy: 0.0,
1537            },
1538            &quad,
1539            1024.0,
1540            576.0,
1541        );
1542        let mut locked_source_id = None;
1543        let mut last_selected_source_id = None;
1544        let frame = select_action_input_frame(
1545            &[candidate],
1546            OpenXrPointerSelectionPolicy::PreferRightController,
1547            &mut locked_source_id,
1548            &mut last_selected_source_id,
1549            1024.0,
1550            576.0,
1551        );
1552
1553        assert_eq!(frame.pointer_frame.samples.len(), 1);
1554        assert!(frame.pointer_frame.samples[0].hit.point().is_some());
1555        assert!(frame.pointer_frame.samples[0].trigger_pressed);
1556        assert_eq!(frame.ray_sample.unwrap().hit_kind, OpenXrRayHitKind::Ui);
1557        assert_eq!(
1558            frame.source.unwrap().source_kind,
1559            OpenXrInputSourceKind::Controller
1560        );
1561        assert_eq!(locked_source_id, Some(RIGHT_CONTROLLER_SOURCE_ID));
1562    }
1563
1564    #[test]
1565    fn scroll_delta_applies_deadzone_and_inverts_y() {
1566        assert_eq!(scroll_delta(0.10, 0.20, 720.0, 1.0 / 60.0, false), 0.0);
1567        assert!((scroll_delta(1.0, 0.20, 720.0, 1.0 / 60.0, false) - 12.0).abs() < 1e-4);
1568        assert!((scroll_delta(1.0, 0.20, 720.0, 1.0 / 60.0, true) + 12.0).abs() < 1e-4);
1569    }
1570
1571    #[test]
1572    fn layer_pose_identity_builds_quad_hit_mapping() {
1573        let layer = OpenXrQuadLayerOptions::default();
1574        let quad = ray_quad_from_layer(layer);
1575        let hit = quad
1576            .intersect(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0))
1577            .unwrap();
1578        let point = uv_to_logical(hit.u, hit.v, 1024.0, 576.0);
1579        assert!((point.x - 512.0).abs() < 1e-4);
1580        assert!((point.y - 288.0).abs() < 1e-4);
1581    }
1582}