ailloli_ui_openxr/openxr_runtime/
ui_layer.rs

1//! Headless Ailloli runtime/layout/paint orchestration over Vulkan frame targets.
2
3use ailloli_ui_core::event::Modifiers;
4use ailloli_ui_core::geometry::Constraints;
5use ailloli_ui_core::math::Scale;
6use ailloli_ui_core::{Color, Point, Size};
7use ailloli_ui_render_vulkan::{
8    VulkanFrameTarget, VulkanRenderContext, VulkanRenderer, VulkanRendererOptions,
9    VulkanRendererStats,
10};
11use ailloli_ui_runtime::app::{Runtime, RuntimeHandle};
12use ailloli_ui_runtime::component::IntoView;
13use ailloli_ui_runtime::input::InputRouter;
14use ailloli_ui_text::TextSystem;
15use ash::vk;
16use openxr as xr;
17
18use crate::input::{
19    OpenXrInputMapper, OpenXrPointerFrame, OpenXrPointerHit, OpenXrPointerSample, PointHit,
20};
21use crate::math::{uv_to_logical, RayQuad, Vec3};
22
23use super::composer::{OpenXrQuadLayerOptions, OpenXrUiFrameLoopOptions};
24use super::error::OpenXrRuntimeError;
25use super::vulkan::OpenXrVulkanContext;
26
27#[derive(Clone, Copy)]
28/// Physical extent, scale, clear color, and renderer settings for one UI layer.
29///
30/// # Examples
31///
32/// ```
33/// use ailloli_ui_openxr::OpenXrUiLayerOptions;
34/// let options = OpenXrUiLayerOptions::default();
35/// assert_eq!((options.pixel_width, options.pixel_height, options.scale.dpr), (1024, 576, 1.0));
36/// ```
37pub struct OpenXrUiLayerOptions {
38    /// Render-target width in physical pixels.
39    pub pixel_width: u32,
40    /// Render-target height in physical pixels.
41    pub pixel_height: u32,
42    /// Color used to clear the Vulkan frame target.
43    pub clear: Color,
44    /// Logical-to-physical scale; DPR is clamped to `0.0001` for size division.
45    pub scale: Scale,
46    /// Vulkan renderer resource and batching configuration.
47    pub renderer: VulkanRendererOptions,
48}
49
50impl Default for OpenXrUiLayerOptions {
51    fn default() -> Self {
52        Self {
53            pixel_width: 1024,
54            pixel_height: 576,
55            clear: Color::f32(0.05, 0.20, 0.80, 1.0),
56            scale: Scale::new(1.0),
57            renderer: VulkanRendererOptions::default(),
58        }
59    }
60}
61
62impl From<OpenXrUiFrameLoopOptions> for OpenXrUiLayerOptions {
63    fn from(options: OpenXrUiFrameLoopOptions) -> Self {
64        Self {
65            pixel_width: options.pixel_width,
66            pixel_height: options.pixel_height,
67            clear: options.clear,
68            scale: options.scale,
69            renderer: options.renderer,
70        }
71    }
72}
73
74#[derive(Clone, Copy)]
75/// Borrowed Vulkan handles needed to render or upload in an external XR host.
76///
77/// Handles must all belong to the same live device and queue family. Memory
78/// properties are optional for rendering but required by staging allocations
79/// such as the ray overlay.
80///
81/// # Examples
82///
83/// ```no_run
84/// use ailloli_ui_openxr::OpenXrExternalVulkanContext;
85/// fn queue_family(context: OpenXrExternalVulkanContext<'_>) -> u32 { context.queue_family_index }
86/// ```
87pub struct OpenXrExternalVulkanContext<'a> {
88    /// Live Vulkan logical device.
89    pub device: &'a ash::Device,
90    /// Graphics queue used for rendering and synchronous uploads.
91    pub queue: vk::Queue,
92    /// Family index owning `queue` and `command_pool`.
93    pub queue_family_index: u32,
94    /// Command pool from which one-time and renderer buffers are allocated.
95    pub command_pool: vk::CommandPool,
96    /// Physical-device memory properties, or `None` when staging is unavailable.
97    pub memory_properties: Option<&'a vk::PhysicalDeviceMemoryProperties>,
98}
99
100impl<'a> OpenXrExternalVulkanContext<'a> {
101    /// Creates a render-only context without memory properties.
102    ///
103    /// # Examples
104    ///
105    /// ```no_run
106    /// use ailloli_ui_openxr::OpenXrExternalVulkanContext;
107    /// fn make(device: &ash::Device, queue: ash::vk::Queue, pool: ash::vk::CommandPool) -> OpenXrExternalVulkanContext<'_> {
108    ///     OpenXrExternalVulkanContext::new(device, queue, 0, pool)
109    /// }
110    /// ```
111    pub fn new(
112        device: &'a ash::Device,
113        queue: vk::Queue,
114        queue_family_index: u32,
115        command_pool: vk::CommandPool,
116    ) -> Self {
117        Self {
118            device,
119            queue,
120            queue_family_index,
121            command_pool,
122            memory_properties: None,
123        }
124    }
125
126    /// Creates a context that also supports host-visible staging allocations.
127    ///
128    /// # Examples
129    ///
130    /// ```no_run
131    /// use ailloli_ui_openxr::OpenXrExternalVulkanContext;
132    /// fn make<'a>(device: &'a ash::Device, queue: ash::vk::Queue, pool: ash::vk::CommandPool, memory: &'a ash::vk::PhysicalDeviceMemoryProperties) -> OpenXrExternalVulkanContext<'a> {
133    ///     OpenXrExternalVulkanContext::with_memory_properties(device, queue, 0, pool, memory)
134    /// }
135    /// ```
136    pub fn with_memory_properties(
137        device: &'a ash::Device,
138        queue: vk::Queue,
139        queue_family_index: u32,
140        command_pool: vk::CommandPool,
141        memory_properties: &'a vk::PhysicalDeviceMemoryProperties,
142    ) -> Self {
143        Self {
144            device,
145            queue,
146            queue_family_index,
147            command_pool,
148            memory_properties: Some(memory_properties),
149        }
150    }
151
152    /// Converts this copyable host context to the renderer's borrowed context.
153    ///
154    /// Optional memory properties are preserved.
155    ///
156    /// # Examples
157    ///
158    /// ```no_run
159    /// use ailloli_ui_openxr::OpenXrExternalVulkanContext;
160    /// use ailloli_ui_render_vulkan::VulkanRenderContext;
161    /// fn convert<'a>(context: OpenXrExternalVulkanContext<'a>) -> VulkanRenderContext<'a> { context.render_context() }
162    /// ```
163    pub fn render_context(self) -> VulkanRenderContext<'a> {
164        match self.memory_properties {
165            Some(memory_properties) => VulkanRenderContext::with_memory_properties(
166                self.device,
167                self.queue,
168                self.queue_family_index,
169                self.command_pool,
170                memory_properties,
171            ),
172            None => VulkanRenderContext::new(
173                self.device,
174                self.queue,
175                self.queue_family_index,
176                self.command_pool,
177            ),
178        }
179    }
180}
181
182impl<'a> From<&'a OpenXrVulkanContext> for OpenXrExternalVulkanContext<'a> {
183    fn from(context: &'a OpenXrVulkanContext) -> Self {
184        Self::with_memory_properties(
185            &context.vk_device,
186            context.queue,
187            context.queue_family_index,
188            context.command_pool,
189            &context.memory_properties,
190        )
191    }
192}
193
194/// Vulkan render target and optional UI input for one layer render.
195///
196/// Pointer input is omitted with `None`; frame time defaults to zero milliseconds.
197///
198/// # Examples
199///
200/// ```no_run
201/// use ailloli_ui_openxr::OpenXrExternalHostFrame;
202/// fn elapsed(frame: &OpenXrExternalHostFrame<'_>) -> u128 { frame.frame_time_ms }
203/// ```
204pub struct OpenXrExternalHostFrame<'a> {
205    /// Vulkan device/queue context for this target.
206    pub context: OpenXrExternalVulkanContext<'a>,
207    /// Acquired Vulkan image and layout contract.
208    pub target: VulkanFrameTarget,
209    /// Optional complete pointer frame to route before painting.
210    pub pointer_frame: Option<&'a OpenXrPointerFrame>,
211    /// Runtime animation/paint time in milliseconds.
212    pub frame_time_ms: u128,
213}
214
215impl<'a> OpenXrExternalHostFrame<'a> {
216    /// Creates a frame without pointer input at time zero.
217    ///
218    /// # Examples
219    ///
220    /// ```no_run
221    /// use ailloli_ui_openxr::{OpenXrExternalHostFrame, OpenXrExternalVulkanContext};
222    /// use ailloli_ui_render_vulkan::VulkanFrameTarget;
223    /// fn make<'a>(context: OpenXrExternalVulkanContext<'a>, target: VulkanFrameTarget) -> OpenXrExternalHostFrame<'a> { OpenXrExternalHostFrame::new(context, target) }
224    /// ```
225    pub fn new(context: OpenXrExternalVulkanContext<'a>, target: VulkanFrameTarget) -> Self {
226        Self {
227            context,
228            target,
229            pointer_frame: None,
230            frame_time_ms: 0,
231        }
232    }
233
234    /// Replaces the optional pointer frame.
235    ///
236    /// `None` means no new events; it does not clear retained input state.
237    ///
238    /// # Examples
239    ///
240    /// ```no_run
241    /// use ailloli_ui_openxr::{OpenXrExternalHostFrame, OpenXrPointerFrame};
242    /// fn input<'a>(frame: OpenXrExternalHostFrame<'a>, pointers: &'a OpenXrPointerFrame) -> OpenXrExternalHostFrame<'a> { frame.with_pointer_frame(Some(pointers)) }
243    /// ```
244    pub fn with_pointer_frame(mut self, pointer_frame: Option<&'a OpenXrPointerFrame>) -> Self {
245        self.pointer_frame = pointer_frame;
246        self
247    }
248
249    /// Replaces runtime frame time in milliseconds.
250    ///
251    /// # Examples
252    ///
253    /// ```no_run
254    /// use ailloli_ui_openxr::OpenXrExternalHostFrame;
255    /// fn timed<'a>(frame: OpenXrExternalHostFrame<'a>) -> OpenXrExternalHostFrame<'a> { frame.with_frame_time_ms(16) }
256    /// ```
257    pub fn with_frame_time_ms(mut self, frame_time_ms: u128) -> Self {
258        self.frame_time_ms = frame_time_ms;
259        self
260    }
261}
262
263/// Headless Ailloli runtime and lazy Vulkan renderer for an OpenXR UI layer.
264///
265/// The renderer is allocated on the first frame, while runtime reconciliation is
266/// performed during construction. Actions accumulate until [`Self::take_actions`].
267///
268/// # Examples
269///
270/// ```no_run
271/// use ailloli_ui_openxr::OpenXrUiLayer;
272/// fn clear<A: 'static>(layer: &mut OpenXrUiLayer<A>) { layer.clear_input(); }
273/// ```
274pub struct OpenXrUiLayer<A> {
275    /// Provider-neutral retained runtime and action queue.
276    runtime: Runtime<A>,
277    /// Font discovery, shaping, and prepared-layout cache.
278    text_system: TextSystem,
279    /// Vulkan renderer initialized lazily from the external host context.
280    renderer: Option<VulkanRenderer>,
281    /// Per-window focus, capture, hover, and event routing state.
282    input_router: InputRouter,
283    /// OpenXR source-state mapper producing provider-neutral pointer events.
284    input_mapper: OpenXrInputMapper,
285    /// Logical window identity, dimensions, scale, and action-drain limits.
286    options: OpenXrUiLayerOptions,
287}
288
289impl<A: 'static> OpenXrUiLayer<A> {
290    /// Reconciles the root view and prepares a lazy renderer.
291    ///
292    /// # Errors
293    ///
294    /// The current constructor performs no fallible Vulkan work, but returns the
295    /// runtime error type so initialization can remain uniform with future setup.
296    ///
297    /// # Examples
298    ///
299    /// ```no_run
300    /// use ailloli_ui_openxr::{OpenXrRuntimeError, OpenXrUiLayer, OpenXrUiLayerOptions};
301    /// use ailloli_ui_runtime::{app::RuntimeHandle, component::IntoView};
302    /// fn create<A: 'static>(handle: RuntimeHandle<A>, root: impl IntoView<A>) -> Result<OpenXrUiLayer<A>, OpenXrRuntimeError> { OpenXrUiLayer::new(handle, root, OpenXrUiLayerOptions::default()) }
303    /// ```
304    pub fn new(
305        runtime_handle: RuntimeHandle<A>,
306        root: impl IntoView<A>,
307        options: OpenXrUiLayerOptions,
308    ) -> Result<Self, OpenXrRuntimeError> {
309        let mut runtime = Runtime::new(runtime_handle);
310        runtime.reconcile(root);
311        Ok(Self {
312            runtime,
313            text_system: TextSystem::new(),
314            renderer: None,
315            input_router: InputRouter::default(),
316            input_mapper: OpenXrInputMapper::new(),
317            options,
318        })
319    }
320
321    /// Lays out, routes optional input, paints, and renders one Vulkan frame.
322    ///
323    /// Layout is tight to [`Self::logical_size`]. The first call allocates the
324    /// renderer. Pointer events are routed before paint; `None` retains existing
325    /// pointer/focus state. Text face blobs are refreshed before rendering.
326    ///
327    /// # Errors
328    ///
329    /// Returns Vulkan renderer initialization or frame-render errors.
330    ///
331    /// # Panics
332    ///
333    /// Panics only if successful renderer initialization fails to populate the
334    /// layer's internal renderer slot.
335    ///
336    /// # Examples
337    ///
338    /// ```no_run
339    /// use ailloli_ui_openxr::{OpenXrExternalHostFrame, OpenXrRuntimeError, OpenXrUiLayer};
340    /// use ailloli_ui_render_vulkan::VulkanRendererStats;
341    /// fn render<A: 'static>(layer: &mut OpenXrUiLayer<A>, frame: OpenXrExternalHostFrame<'_>) -> Result<VulkanRendererStats, OpenXrRuntimeError> { layer.layout_paint_render(frame) }
342    /// ```
343    pub fn layout_paint_render(
344        &mut self,
345        frame: OpenXrExternalHostFrame<'_>,
346    ) -> Result<VulkanRendererStats, OpenXrRuntimeError> {
347        let render_context = frame.context.render_context();
348        if self.renderer.is_none() {
349            self.renderer = Some(
350                VulkanRenderer::new(&render_context, self.options.renderer)
351                    .map_err(|source| OpenXrRuntimeError::RenderVulkan { source })?,
352            );
353        }
354
355        let logical_size = self.logical_size();
356        self.runtime.layout(
357            Constraints::tight(logical_size.w, logical_size.h),
358            self.options.scale,
359            &mut self.text_system,
360        );
361
362        if let Some(pointer_frame) = frame.pointer_frame {
363            self.route_input_frame(pointer_frame);
364        }
365
366        let scene = self.runtime.paint_with_input(
367            &mut self.text_system,
368            self.input_router.snapshot(),
369            frame.frame_time_ms,
370        );
371        let renderer = self.renderer.as_mut().expect("renderer initialized");
372        renderer.set_text_face_blobs(self.text_system.face_blobs_snapshot());
373        renderer
374            .render_scene(
375                &render_context,
376                self.options.clear,
377                &scene,
378                self.options.scale,
379                &frame.target,
380            )
381            .map_err(|source| OpenXrRuntimeError::RenderVulkan { source })?;
382
383        Ok(renderer.stats())
384    }
385
386    /// Cancels captured pointer gestures, then clears mapper and keyboard focus.
387    ///
388    /// # Examples
389    ///
390    /// ```no_run
391    /// use ailloli_ui_openxr::OpenXrUiLayer;
392    /// fn clear<A: 'static>(layer: &mut OpenXrUiLayer<A>) { layer.clear_input(); }
393    /// ```
394    pub fn clear_input(&mut self) {
395        self.input_router
396            .cancel_pointer_state(&self.runtime.tree, self.runtime.runtime.clone());
397        self.input_router.clear_focus();
398        self.input_mapper.clear();
399    }
400
401    /// Drains and returns actions queued by the reconciled runtime.
402    ///
403    /// An empty vector means no action was dispatched since the previous drain.
404    ///
405    /// # Examples
406    ///
407    /// ```no_run
408    /// use ailloli_ui_openxr::OpenXrUiLayer;
409    /// fn actions<A: 'static>(layer: &OpenXrUiLayer<A>) -> Vec<A> { layer.take_actions() }
410    /// ```
411    pub fn take_actions(&self) -> Vec<A> {
412        self.runtime.runtime.take_actions()
413    }
414
415    /// Returns the latest renderer counters or zeroed defaults before first frame.
416    ///
417    /// # Examples
418    ///
419    /// ```no_run
420    /// use ailloli_ui_openxr::OpenXrUiLayer;
421    /// use ailloli_ui_render_vulkan::VulkanRendererStats;
422    /// fn stats<A: 'static>(layer: &OpenXrUiLayer<A>) -> VulkanRendererStats { layer.renderer_stats() }
423    /// ```
424    pub fn renderer_stats(&self) -> VulkanRendererStats {
425        self.renderer
426            .as_ref()
427            .map(VulkanRenderer::stats)
428            .unwrap_or_default()
429    }
430
431    /// Returns physical pixels divided by DPR as logical dimensions.
432    ///
433    /// DPR is clamped to at least `0.0001`; pixel axes are otherwise unmodified,
434    /// so zero pixels produce zero logical extent.
435    ///
436    /// # Examples
437    ///
438    /// ```no_run
439    /// use ailloli_ui_core::Size;
440    /// use ailloli_ui_openxr::OpenXrUiLayer;
441    /// fn size<A: 'static>(layer: &OpenXrUiLayer<A>) -> Size { layer.logical_size() }
442    /// ```
443    pub fn logical_size(&self) -> Size {
444        let dpr = self.options.scale.dpr.max(0.0001);
445        Size::new(
446            self.options.pixel_width as f32 / dpr,
447            self.options.pixel_height as f32 / dpr,
448        )
449    }
450
451    /// Converts and routes pointer transitions in sample order.
452    fn route_input_frame(&mut self, input_frame: &OpenXrPointerFrame) {
453        let events = self
454            .input_mapper
455            .map_frame_to_events(input_frame, Modifiers::default());
456        for event in events {
457            self.input_router
458                .route_event(&self.runtime.tree, self.runtime.runtime.clone(), &event);
459        }
460    }
461}
462
463/// Stateless world-ray mapper for one OpenXR quad layer.
464///
465/// # Examples
466///
467/// ```
468/// use ailloli_ui_core::Size;
469/// use ailloli_ui_openxr::{OpenXrQuadLayerOptions, OpenXrQuadPointerMapper};
470/// use ailloli_ui_openxr::math::Vec3;
471/// let point = OpenXrQuadPointerMapper::ray_to_logical_point(Vec3::default(), Vec3::new(0.0, 0.0, -1.0), &OpenXrQuadLayerOptions::default(), Size::new(1024.0, 576.0));
472/// assert_eq!(point.map(|p| (p.x, p.y)), Some((512.0, 288.0)));
473/// ```
474pub struct OpenXrQuadPointerMapper;
475
476impl OpenXrQuadPointerMapper {
477    /// Intersects a world ray and returns a top-left-origin logical point.
478    ///
479    /// Returns `None` for parallel rays, hits behind the origin, or points beyond
480    /// the finite quad. Layer axes are derived from its quaternion and its physical
481    /// width/height are clamped to at least one millimetre before halving.
482    ///
483    /// # Examples
484    ///
485    /// ```
486    /// use ailloli_ui_core::Size;
487    /// use ailloli_ui_openxr::{OpenXrQuadLayerOptions, OpenXrQuadPointerMapper};
488    /// use ailloli_ui_openxr::math::Vec3;
489    /// let point = OpenXrQuadPointerMapper::ray_to_logical_point(Vec3::default(), Vec3::new(0.0, 0.0, -1.0), &OpenXrQuadLayerOptions::default(), Size::new(100.0, 50.0)).unwrap();
490    /// assert_eq!((point.x, point.y), (50.0, 25.0));
491    /// ```
492    pub fn ray_to_logical_point(
493        ray_origin: Vec3,
494        ray_direction: Vec3,
495        layer: &OpenXrQuadLayerOptions,
496        logical_size: Size,
497    ) -> Option<Point> {
498        let quad = ray_quad_from_layer(*layer);
499        quad.intersect(ray_origin, ray_direction)
500            .map(|hit| uv_to_logical(hit.u, hit.v, logical_size.w, logical_size.h))
501    }
502
503    /// Creates a hit/miss pointer sample from a world ray.
504    ///
505    /// Hit depth is deliberately `None`; this mapper preserves logical routing
506    /// only. Source ID and pressed state are preserved verbatim; scroll is zero.
507    ///
508    /// # Examples
509    ///
510    /// ```
511    /// use ailloli_ui_core::Size;
512    /// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrQuadLayerOptions, OpenXrQuadPointerMapper};
513    /// use ailloli_ui_openxr::math::Vec3;
514    /// let sample = OpenXrQuadPointerMapper::sample_from_ray(7, Vec3::default(), Vec3::new(0.0, 0.0, -1.0), true, &OpenXrQuadLayerOptions::default(), Size::new(100.0, 50.0));
515    /// assert!(matches!(sample.hit, OpenXrPointerHit::Hit(_)) && sample.trigger_pressed);
516    /// ```
517    pub fn sample_from_ray(
518        source_id: u64,
519        ray_origin: Vec3,
520        ray_direction: Vec3,
521        trigger_pressed: bool,
522        layer: &OpenXrQuadLayerOptions,
523        logical_size: Size,
524    ) -> OpenXrPointerSample {
525        let hit = Self::ray_to_logical_point(ray_origin, ray_direction, layer, logical_size)
526            .map(|point| OpenXrPointerHit::Hit(PointHit::new(point, None)))
527            .unwrap_or(OpenXrPointerHit::Miss);
528        OpenXrPointerSample::new(source_id, hit, trigger_pressed)
529    }
530
531    /// Creates an unpressed miss sample with zero scroll.
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrQuadPointerMapper};
537    /// let sample = OpenXrQuadPointerMapper::miss_sample(9);
538    /// assert_eq!(sample.hit, OpenXrPointerHit::Miss);
539    /// assert!(!sample.trigger_pressed);
540    /// ```
541    pub fn miss_sample(source_id: u64) -> OpenXrPointerSample {
542        OpenXrPointerSample::new(source_id, OpenXrPointerHit::Miss, false)
543    }
544}
545
546/// Derives a normalized finite quad from layer pose and clamped dimensions.
547fn ray_quad_from_layer(layer: OpenXrQuadLayerOptions) -> RayQuad {
548    let orientation = layer.pose.orientation;
549    RayQuad::new(
550        vec3_from_xr(layer.pose.position),
551        rotate_vec3(orientation, Vec3::new(0.0, 0.0, 1.0)).normalize_or(Vec3::new(0.0, 0.0, 1.0)),
552        rotate_vec3(orientation, Vec3::new(1.0, 0.0, 0.0)).normalize_or(Vec3::new(1.0, 0.0, 0.0)),
553        rotate_vec3(orientation, Vec3::new(0.0, 1.0, 0.0)).normalize_or(Vec3::new(0.0, 1.0, 0.0)),
554        layer.size.width.max(0.001) * 0.5,
555        layer.size.height.max(0.001) * 0.5,
556    )
557}
558
559/// Copies OpenXR vector components into the lightweight vector type.
560fn vec3_from_xr(v: xr::Vector3f) -> Vec3 {
561    Vec3::new(v.x, v.y, v.z)
562}
563
564/// Rotates `v` by an assumed-normalized OpenXR quaternion.
565fn rotate_vec3(q: xr::Quaternionf, v: Vec3) -> Vec3 {
566    let q_vec = Vec3::new(q.x, q.y, q.z);
567    let uv = q_vec.cross(v);
568    let uuv = q_vec.cross(uv);
569    v + (uv * q.w + uuv) * 2.0
570}
571
572#[cfg(test)]
573/// Verifies defaults plus center, top-left, and outside ray mappings.
574mod tests {
575    use super::*;
576
577    /// Returns the canonical identity-facing layer fixture.
578    fn default_layer() -> OpenXrQuadLayerOptions {
579        OpenXrQuadLayerOptions::default()
580    }
581
582    #[test]
583    fn ui_layer_options_default_matches_smoke_contract() {
584        let options = OpenXrUiLayerOptions::default();
585        assert_eq!(options.pixel_width, 1024);
586        assert_eq!(options.pixel_height, 576);
587        assert_eq!(options.clear.to_array(), [0.05, 0.20, 0.80, 1.0]);
588        assert_eq!(options.scale.dpr, 1.0);
589    }
590
591    #[test]
592    fn quad_pointer_mapper_hits_center() {
593        let point = OpenXrQuadPointerMapper::ray_to_logical_point(
594            Vec3::new(0.0, 0.0, 0.0),
595            Vec3::new(0.0, 0.0, -1.0),
596            &default_layer(),
597            Size::new(1024.0, 576.0),
598        )
599        .expect("center hit");
600        assert!((point.x - 512.0).abs() < 1e-4);
601        assert!((point.y - 288.0).abs() < 1e-4);
602    }
603
604    #[test]
605    fn quad_pointer_mapper_uses_visual_top_left_origin() {
606        let point = OpenXrQuadPointerMapper::ray_to_logical_point(
607            Vec3::new(-0.8, 0.45, 0.0),
608            Vec3::new(0.0, 0.0, -1.0),
609            &default_layer(),
610            Size::new(1024.0, 576.0),
611        )
612        .expect("top-left hit");
613        assert!(point.x <= 1e-4, "x={}", point.x);
614        assert!(point.y <= 1e-4, "y={}", point.y);
615    }
616
617    #[test]
618    fn quad_pointer_mapper_misses_outside_quad() {
619        assert!(OpenXrQuadPointerMapper::ray_to_logical_point(
620            Vec3::new(2.0, 0.0, 0.0),
621            Vec3::new(0.0, 0.0, -1.0),
622            &default_layer(),
623            Size::new(1024.0, 576.0),
624        )
625        .is_none());
626    }
627}