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}