1use 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
19const RIGHT_CONTROLLER_SOURCE_ID: u64 = 1;
21const LEFT_CONTROLLER_SOURCE_ID: u64 = 2;
23const RIGHT_HAND_SOURCE_ID: u64 = 3;
25const LEFT_HAND_SOURCE_ID: u64 = 4;
27
28const PINCH_DISTANCE_METERS: f32 = 0.035;
30
31const PROFILE_OCULUS_TOUCH: &str = "/interaction_profiles/oculus/touch_controller";
33const PROFILE_QUEST_TOUCH_PLUS: &str = "/interaction_profiles/meta/quest_touch_plus_controller";
35const PROFILE_QUEST_TOUCH_PRO: &str = "/interaction_profiles/meta/quest_touch_pro_controller";
37
38const 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)]
46pub struct OpenXrUiInputOptions {
61 pub enabled: bool,
63 pub controllers: bool,
65 pub hands: bool,
67 pub trigger_threshold: f32,
69 pub pinch_threshold: f32,
71 pub scroll_deadzone: f32,
73 pub scroll_pixels_per_second: f32,
75 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)]
95pub enum OpenXrPointerSelectionPolicy {
108 PreferRightController,
110 PreferLeftController,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum OpenXrInputHand {
124 Right,
126 Left,
128}
129
130type PointerHand = OpenXrInputHand;
132
133impl OpenXrInputHand {
134 fn label(self) -> &'static str {
136 match self {
137 Self::Right => "right",
138 Self::Left => "left",
139 }
140 }
141
142 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 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)]
160pub enum OpenXrInputSourceKind {
169 Controller,
171 Hand,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub struct OpenXrInputSourceInfo {
189 pub source_id: u64,
191 pub source_kind: OpenXrInputSourceKind,
193 pub hand: OpenXrInputHand,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct OpenXrInputCapabilities {
210 pub hand_tracking_supported: bool,
212 pub hand_aim_supported: bool,
214}
215
216impl OpenXrInputCapabilities {
217 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)]
246pub struct OpenXrActionInputFrame {
261 pub pointer_frame: OpenXrPointerFrame,
263 pub ray_sample: Option<OpenXrRaySample>,
265 pub source: Option<OpenXrInputSourceInfo>,
267}
268
269impl OpenXrActionInputFrame {
270 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)]
294struct PointerRay {
296 origin: Vec3,
298 direction: Vec3,
300 pressed: bool,
302 scroll_dx: f32,
304 scroll_dy: f32,
306}
307
308#[derive(Debug, Clone, Copy)]
309struct PointerCandidate {
311 sample: OpenXrPointerSample,
313 ray_sample: OpenXrRaySample,
315 source: OpenXrInputSourceInfo,
317}
318
319pub struct OpenXrActionInput {
332 action_set: xr::ActionSet,
334 aim_action: xr::Action<xr::Posef>,
336 trigger_action: xr::Action<f32>,
338 scroll_x_action: xr::Action<f32>,
340 scroll_y_action: xr::Action<f32>,
342 right_path: xr::Path,
344 left_path: xr::Path,
346 right_aim_space: Option<xr::Space>,
348 left_aim_space: Option<xr::Space>,
350 right_hand_tracker: Option<xr::HandTracker>,
352 left_hand_tracker: Option<xr::HandTracker>,
354 options: OpenXrUiInputOptions,
356 hand_tracking_supported: bool,
358 hand_aim_supported: bool,
360 attached: bool,
362 locked_source_id: Option<u64>,
364 last_selected_source_id: Option<u64>,
366 last_poll: Option<Instant>,
368}
369
370impl OpenXrActionInput {
371 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 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 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 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 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 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 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 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 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 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 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
865fn 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
877fn 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
912fn 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
968fn 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
995fn 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
1008fn 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
1021fn 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
1048fn 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
1090fn 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
1142fn 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
1194fn 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
1232fn 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
1244fn 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
1257fn 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
1322fn 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
1375fn vec3_from_xr(v: xr::Vector3f) -> Vec3 {
1377 Vec3::new(v.x, v.y, v.z)
1378}
1379
1380fn 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
1385fn 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)]
1394mod tests {
1396 use super::*;
1397
1398 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}