ailloli_ui_openxr/openxr_runtime/
smoke.rs

1//! Built-in interactive OpenXR smoke scene and movable/resizable panel demo.
2
3use std::time::{Duration, Instant};
4
5use ailloli_ui_core::math::Scale;
6use ailloli_ui_core::{Color, FontId, Size, TextStyle};
7use ailloli_ui_runtime::app::RuntimeHandle;
8use ailloli_ui_runtime::component::{IntoView, IntoViewKeyExt, View};
9use ailloli_ui_runtime::input::ResizeEdge;
10use ailloli_ui_widgets::chrome::{
11    WindowAffordanceDragPhase, WindowAffordanceEvent, WindowAffordanceFrame, WindowAffordanceKind,
12};
13use ailloli_ui_widgets::controls::Button;
14use ailloli_ui_widgets::layout::{Column, Container, Row, ScrollView};
15use ailloli_ui_widgets::text::Text;
16use openxr as xr;
17
18use crate::math::Vec3;
19
20use super::composer::OpenXrQuadLayerOptions;
21use super::error::OpenXrRuntimeError;
22use super::external_host::{
23    OpenXrExternalUiHost, OpenXrExternalUiHostFrameParts, OpenXrExternalUiHostOptions,
24    OpenXrExternalUiHostRayOptions,
25};
26use super::input::{OpenXrPointerSelectionPolicy, OpenXrUiInputOptions};
27use super::panel::{
28    apply_facing_stable_grab, apply_pointer_depth_delta, logical_point_to_panel_local, rotate_vec3,
29    vec3_from_xr, OpenXrPanelFacingMode, OpenXrPanelFacingOptions, OpenXrPanelGrabState,
30};
31use super::ray_overlay::{OpenXrRayHitKind, OpenXrRayOverlayOptions, OpenXrRaySample};
32use super::session_loop::{OpenXrRuntime, OpenXrRuntimeOptions, ReferenceSpacePreference};
33
34/// Default smoke swapchain width in pixels.
35const SMOKE_PIXEL_WIDTH: u32 = 1024;
36/// Default smoke swapchain height in pixels.
37const SMOKE_PIXEL_HEIGHT: u32 = 576;
38/// Default smoke panel width in metres.
39const SMOKE_QUAD_WIDTH_M: f32 = 1.6;
40/// Default smoke panel height in metres.
41const SMOKE_QUAD_HEIGHT_M: f32 = 0.9;
42/// Fallback viewer-to-panel distance in metres.
43const DEFAULT_DISTANCE_M: f32 = 2.0;
44/// Fallback logical-to-physical scale.
45const DEFAULT_SCALE: f32 = 1.0;
46/// Minimum resizable panel width in metres.
47const SMOKE_SLATE_MIN_WIDTH_M: f32 = 0.45;
48/// Minimum resizable panel height in metres.
49const SMOKE_SLATE_MIN_HEIGHT_M: f32 = 0.25;
50/// Maximum resizable panel width in metres.
51const SMOKE_SLATE_MAX_WIDTH_M: f32 = 3.0;
52/// Maximum resizable panel height in metres.
53const SMOKE_SLATE_MAX_HEIGHT_M: f32 = 1.8;
54
55#[derive(Debug, Clone)]
56/// Runtime, input, physical panel, timeout, and demo settings for the smoke app.
57///
58/// Non-positive/non-finite distance and scale fall back to `2.0` metres and DPR
59/// `1.0`. Pixel axes are clamped to at least one. A timeout is active only when
60/// finite and strictly positive; `None` or invalid values mean no timeout.
61///
62/// # Examples
63///
64/// ```
65/// use ailloli_ui_openxr::OpenXrSmokeOptions;
66/// let options = OpenXrSmokeOptions::default();
67/// assert_eq!((options.distance_m, options.scale), (2.0, 1.0));
68/// assert_eq!(options.timeout_sec, None);
69/// ```
70pub struct OpenXrSmokeOptions {
71    /// Prefer the left controller when both controller rays are available.
72    pub prefer_left: bool,
73    /// Enable tracked-hand fallback when the runtime supports it.
74    pub hands: bool,
75    /// Initial panel distance in metres; invalid values fall back to `2.0`.
76    pub distance_m: f32,
77    /// Logical-to-physical UI scale; invalid values fall back to `1.0`.
78    pub scale: f32,
79    /// Optional positive finite automatic-exit delay in seconds.
80    pub timeout_sec: Option<f32>,
81    /// UI swapchain width in pixels; clamped to at least one.
82    pub pixel_width: u32,
83    /// UI swapchain height in pixels; clamped to at least one.
84    pub pixel_height: u32,
85    /// OpenXR application name, subject to native byte/NUL limits.
86    pub application_name: String,
87    /// OpenXR/Vulkan engine name, subject to native byte/NUL limits.
88    pub engine_name: String,
89    /// Whether the view exposes move and resize affordances.
90    pub affordance_demo: bool,
91    /// Panel-facing policy used by affordance moves.
92    pub panel_facing: OpenXrPanelFacingOptions,
93}
94
95impl Default for OpenXrSmokeOptions {
96    fn default() -> Self {
97        Self {
98            prefer_left: false,
99            hands: true,
100            distance_m: DEFAULT_DISTANCE_M,
101            scale: DEFAULT_SCALE,
102            timeout_sec: None,
103            pixel_width: SMOKE_PIXEL_WIDTH,
104            pixel_height: SMOKE_PIXEL_HEIGHT,
105            application_name: "ailloli_ui-smoke".to_string(),
106            engine_name: "ailloli_ui".to_string(),
107            affordance_demo: false,
108            panel_facing: OpenXrPanelFacingOptions::default(),
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114/// Terminal reason returned by a successful smoke run.
115///
116/// # Examples
117///
118/// ```
119/// use ailloli_ui_openxr::OpenXrSmokeExitReason;
120/// assert_ne!(OpenXrSmokeExitReason::Timeout, OpenXrSmokeExitReason::Shutdown);
121/// ```
122pub enum OpenXrSmokeExitReason {
123    /// The smoke UI dispatched its exit button action.
124    ExitAction,
125    /// The configured positive timeout elapsed.
126    Timeout,
127    /// The caller-provided shutdown predicate became true.
128    Shutdown,
129    /// OpenXR requested exit, loss pending, or instance loss.
130    XrExit,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134/// Successful completion report for the smoke host.
135///
136/// # Examples
137///
138/// ```
139/// use ailloli_ui_openxr::{OpenXrSmokeExitReason, OpenXrSmokeResult};
140/// let result = OpenXrSmokeResult { exit_reason: OpenXrSmokeExitReason::Shutdown };
141/// assert_eq!(result.exit_reason, OpenXrSmokeExitReason::Shutdown);
142/// ```
143pub struct OpenXrSmokeResult {
144    /// Condition that ended the loop.
145    pub exit_reason: OpenXrSmokeExitReason,
146}
147
148/// Initializes and runs the built-in OpenXR smoke application.
149///
150/// `shutdown` is polled once per outer loop iteration. Returning true yields a
151/// successful [`OpenXrSmokeExitReason::Shutdown`]. The function owns runtime/UI
152/// resources until the loop exits.
153///
154/// # Errors
155///
156/// Returns any OpenXR, Vulkan, swapchain, action, rendering, or submission error
157/// encountered during initialization or the loop.
158///
159/// # Examples
160///
161/// ```no_run
162/// use ailloli_ui_openxr::{run_openxr_smoke, OpenXrSmokeOptions};
163/// let result = run_openxr_smoke(OpenXrSmokeOptions::default(), || false)?;
164/// println!("{:?}", result.exit_reason);
165/// # Ok::<(), ailloli_ui_openxr::OpenXrRuntimeError>(())
166/// ```
167pub fn run_openxr_smoke(
168    options: OpenXrSmokeOptions,
169    shutdown: impl Fn() -> bool,
170) -> Result<OpenXrSmokeResult, OpenXrRuntimeError> {
171    let mut app = OpenXrSmokeApp::new(options)?;
172    app.main_loop(shutdown)
173}
174
175/// Owned runtime, external UI host, and diagnostic state for the smoke loop.
176struct OpenXrSmokeApp {
177    // Drop the UI host before `OpenXrRuntime`; its swapchains depend on the runtime session/device.
178    /// External UI host driving swapchain, input, composition, and ray overlay.
179    host: OpenXrExternalUiHost<SmokeAction>,
180    /// OpenXR instance, session, spaces, and frame-wait lifecycle.
181    runtime: OpenXrRuntime,
182    /// Mutable world-space slate pose, size, and active grab state.
183    slate: SmokeSlateState,
184    /// Runtime duration and initial layer/input configuration.
185    options: OpenXrSmokeOptions,
186    /// Monotonic start time used to enforce the requested smoke duration.
187    started_at: Instant,
188    /// Whether successful first-frame evidence has been logged.
189    first_frame_logged: bool,
190    /// Whether successful first-pointer evidence has been logged.
191    first_pointer_logged: bool,
192    /// Whether successful first-ray-overlay evidence has been logged.
193    first_ray_overlay_logged: bool,
194}
195
196impl OpenXrSmokeApp {
197    /// Initializes runtime, panel options, runtime tree, input, and swapchains.
198    ///
199    /// # Errors
200    ///
201    /// Propagates OpenXR instance/session/Vulkan initialization or external UI
202    /// host construction errors.
203    fn new(options: OpenXrSmokeOptions) -> Result<Self, OpenXrRuntimeError> {
204        let runtime = OpenXrRuntime::new(smoke_runtime_options(&options))?;
205        log::info!(
206            "Ailloli UI smoke OpenXR runtime initialized: {} {}",
207            runtime.xr.runtime_name,
208            runtime.xr.runtime_version
209        );
210
211        let initial_layer = smoke_layer_options(&options);
212        let host = OpenXrExternalUiHost::new(
213            &runtime.xr.instance,
214            &runtime.session,
215            runtime.external_vulkan_context(),
216            runtime.input_capabilities(),
217            RuntimeHandle::<SmokeAction>::new(),
218            smoke_view(options.clone()),
219            smoke_host_options(&options),
220        )?;
221        log::info!(
222            "Ailloli UI smoke external UI host created {}x{} distance_m={:.2} scale={:.2}",
223            smoke_pixel_width(&options),
224            smoke_pixel_height(&options),
225            smoke_distance_m(&options),
226            smoke_scale(&options)
227        );
228
229        Ok(Self {
230            host,
231            runtime,
232            slate: SmokeSlateState::new(initial_layer, options.panel_facing),
233            options,
234            started_at: Instant::now(),
235            first_frame_logged: false,
236            first_pointer_logged: false,
237            first_ray_overlay_logged: false,
238        })
239    }
240
241    /// Drives events, timing, input, panel affordances, rendering, and submission.
242    ///
243    /// # Errors
244    ///
245    /// Returns the matching OpenXR event/session/frame/input/render/composition
246    /// error encountered before normal exit. The loop returns a smoke result
247    /// only after a clean shutdown condition.
248    fn main_loop(
249        &mut self,
250        shutdown: impl Fn() -> bool,
251    ) -> Result<OpenXrSmokeResult, OpenXrRuntimeError> {
252        let mut event_storage = xr::EventDataBuffer::new();
253        let mut session_running = false;
254        let mut exit_reason = None;
255
256        log::info!("entering Ailloli UI Quest smoke frame loop");
257        while !shutdown() && exit_reason.is_none() {
258            while let Some(event) = self
259                .runtime
260                .xr
261                .instance
262                .poll_event(&mut event_storage)
263                .map_err(|result| OpenXrRuntimeError::PollEvent { result })?
264            {
265                use xr::Event::*;
266                match event {
267                    SessionStateChanged(event) => match event.state() {
268                        xr::SessionState::READY => {
269                            if !session_running {
270                                self.runtime
271                                    .session
272                                    .begin(xr::ViewConfigurationType::PRIMARY_STEREO)
273                                    .map_err(|result| OpenXrRuntimeError::BeginSession {
274                                        result,
275                                    })?;
276                                session_running = true;
277                            }
278                            log::info!("Ailloli UI smoke XR session READY");
279                        }
280                        xr::SessionState::STOPPING => {
281                            if session_running {
282                                self.runtime
283                                    .session
284                                    .end()
285                                    .map_err(|result| OpenXrRuntimeError::EndSession { result })?;
286                                session_running = false;
287                            }
288                            self.host.clear_input();
289                            log::info!("Ailloli UI smoke XR session STOPPING");
290                        }
291                        xr::SessionState::EXITING | xr::SessionState::LOSS_PENDING => {
292                            self.host.clear_input();
293                            log::info!(
294                                "Ailloli UI smoke XR session {:?} - leaving frame loop",
295                                event.state()
296                            );
297                            return Ok(OpenXrSmokeResult {
298                                exit_reason: OpenXrSmokeExitReason::XrExit,
299                            });
300                        }
301                        other => log::info!("Ailloli UI smoke XR session state {other:?}"),
302                    },
303                    InstanceLossPending(_) => {
304                        self.host.clear_input();
305                        return Ok(OpenXrSmokeResult {
306                            exit_reason: OpenXrSmokeExitReason::XrExit,
307                        });
308                    }
309                    EventsLost(event) => {
310                        log::warn!(
311                            "Ailloli UI smoke lost {} XR events",
312                            event.lost_event_count()
313                        )
314                    }
315                    InteractionProfileChanged(_) => {
316                        self.host.log_interaction_profiles(
317                            &self.runtime.xr.instance,
318                            &self.runtime.session,
319                        );
320                    }
321                    _ => {}
322                }
323            }
324
325            if !session_running {
326                std::thread::sleep(Duration::from_millis(16));
327                continue;
328            }
329
330            if let Some(timeout_sec) = smoke_timeout_sec(&self.options) {
331                if self.started_at.elapsed() >= Duration::from_secs_f32(timeout_sec) {
332                    log::info!(
333                        "ailloli_ui smoke action=timeout elapsed_sec={:.1}",
334                        self.started_at.elapsed().as_secs_f32()
335                    );
336                    return Ok(OpenXrSmokeResult {
337                        exit_reason: OpenXrSmokeExitReason::Timeout,
338                    });
339                }
340            }
341
342            let frame_state = self
343                .runtime
344                .frame_waiter
345                .wait()
346                .map_err(|result| OpenXrRuntimeError::FrameWait { result })?;
347            self.runtime
348                .frame_stream
349                .begin()
350                .map_err(|result| OpenXrRuntimeError::FrameBegin { result })?;
351
352            if !frame_state.should_render {
353                self.runtime
354                    .frame_stream
355                    .end(
356                        frame_state.predicted_display_time,
357                        self.runtime.blend_mode,
358                        &[],
359                    )
360                    .map_err(|result| OpenXrRuntimeError::FrameEnd { result })?;
361                continue;
362            }
363
364            let logical_size = self.host.logical_size();
365            let head_pos = self
366                .runtime
367                .locate_view_pose(frame_state.predicted_display_time)?
368                .map(|pose| vec3_from_xr(pose.position));
369            let frame = OpenXrExternalUiHostFrameParts::new(
370                &self.runtime.xr.instance,
371                &self.runtime.session,
372                &self.runtime.reference_space,
373                self.runtime.external_vulkan_context(),
374                frame_state.predicted_display_time,
375            )
376            .with_frame_time_ms(self.started_at.elapsed().as_millis());
377            let mut slate_changed = false;
378            {
379                let result = self.host.render_frame(frame)?;
380
381                if !self.first_frame_logged {
382                    self.first_frame_logged = true;
383                    log::info!(
384                        "ailloli_ui smoke frame rendered rects={} glyphs={} ignored={}",
385                        result.stats.rects_rendered,
386                        result.stats.glyphs_rendered,
387                        result.stats.commands_ignored
388                    );
389                }
390
391                if !self.first_pointer_logged {
392                    if let Some(source) = result.input.source {
393                        self.first_pointer_logged = true;
394                        log::info!(
395                            "ailloli_ui smoke pointer active source={:?} hand={:?} streaming_hit={:?}",
396                            source.source_kind,
397                            source.hand,
398                            result
399                                .input
400                                .ray_sample
401                                .map(|sample| sample.hit_kind)
402                                .unwrap_or(OpenXrRayHitKind::Miss)
403                        );
404                    }
405                }
406
407                if !self.first_ray_overlay_logged {
408                    if let (Some(sample), Some(source)) =
409                        (result.input.ray_sample, result.input.source)
410                    {
411                        self.first_ray_overlay_logged = true;
412                        log::info!(
413                            "ailloli_ui smoke ray overlay active source={:?} hand={:?} ui_hit={}",
414                            source.source_kind,
415                            source.hand,
416                            sample.hit_kind == OpenXrRayHitKind::Ui
417                        );
418                    }
419                }
420
421                for action in result.actions.iter().copied() {
422                    match action {
423                        SmokeAction::Primary => log::info!("ailloli_ui smoke action=primary"),
424                        SmokeAction::Secondary => log::info!("ailloli_ui smoke action=secondary"),
425                        SmokeAction::Affordance(event) => {
426                            let apply = self.slate.apply_affordance_event(
427                                event,
428                                logical_size,
429                                result.input.ray_sample,
430                                head_pos,
431                            );
432                            slate_changed |= apply.changed;
433                            log::info!(
434                                "ailloli_ui smoke action=affordance kind={:?} phase={:?} delta=({:.1},{:.1}) total_delta=({:.1},{:.1}) applied={} hmd_pose_seen={} grab_world_hit={} panel_facing_mode={:?} panel_facing_applied={} pitch_deg={:.2} pitch_clamped={} grab_point_stable={} fallback_delta={} depth_axis_seen={} depth_delta_m={:.4} depth_applied={} slate_pos=({:.3},{:.3},{:.3}) slate_size=({:.3},{:.3})",
435                                event.kind,
436                                event.phase,
437                                event.delta.x,
438                                event.delta.y,
439                                event.total_delta.x,
440                                event.total_delta.y,
441                                apply.changed,
442                                apply.hmd_pose_seen,
443                                apply.grab_world_hit,
444                                self.slate.facing.mode,
445                                apply.yaw_applied,
446                                apply.pitch_deg,
447                                apply.pitch_clamped,
448                                apply.grab_point_stable,
449                                apply.fallback_delta,
450                                apply.depth_axis_seen,
451                                apply.depth_delta_m,
452                                apply.depth_applied,
453                                self.slate.layer.pose.position.x,
454                                self.slate.layer.pose.position.y,
455                                self.slate.layer.pose.position.z,
456                                self.slate.layer.size.width,
457                                self.slate.layer.size.height,
458                            );
459                        }
460                        SmokeAction::Exit => {
461                            log::info!("ailloli_ui smoke action=exit");
462                            exit_reason = Some(OpenXrSmokeExitReason::ExitAction);
463                        }
464                    }
465                }
466
467                let layer_refs = result.layer_refs();
468                self.runtime
469                    .frame_stream
470                    .end(
471                        frame_state.predicted_display_time,
472                        self.runtime.blend_mode,
473                        &layer_refs,
474                    )
475                    .map_err(|result| OpenXrRuntimeError::FrameEnd { result })?;
476            }
477            if slate_changed {
478                self.host.set_layer_options(self.slate.layer);
479            }
480        }
481
482        log::info!("leaving Ailloli UI Quest smoke frame loop");
483        Ok(OpenXrSmokeResult {
484            exit_reason: exit_reason.unwrap_or(OpenXrSmokeExitReason::Shutdown),
485        })
486    }
487}
488
489#[derive(Debug, Clone, Copy)]
490/// Actions emitted by buttons and window affordances in the smoke tree.
491enum SmokeAction {
492    /// Primary diagnostic button.
493    Primary,
494    /// Secondary diagnostic button.
495    Secondary,
496    /// Move/resize event from the panel chrome.
497    Affordance(WindowAffordanceEvent),
498    /// Request a successful exit from the demo.
499    Exit,
500}
501
502#[derive(Debug, Clone, Copy, Default, PartialEq)]
503/// Detailed diagnostics returned after applying one affordance event.
504struct SmokeSlateApplyResult {
505    /// Whether applying this input changed slate pose or dimensions.
506    changed: bool,
507    /// Whether a valid HMD pose contributed to the update.
508    hmd_pose_seen: bool,
509    /// Whether grab initialization intersected the world-space slate.
510    grab_world_hit: bool,
511    /// Whether controller yaw changed the slate orientation.
512    yaw_applied: bool,
513    /// Whether the retained grab point remained stable during motion.
514    grab_point_stable: bool,
515    /// Whether translation fell back to controller delta without a world hit.
516    fallback_delta: bool,
517    /// Whether a usable depth-axis sample was observed.
518    depth_axis_seen: bool,
519    /// Applied depth translation in metres.
520    depth_delta_m: f32,
521    /// Whether that depth translation changed the slate pose.
522    depth_applied: bool,
523    /// Requested pitch in degrees before clamping.
524    pitch_deg: f32,
525    /// Whether pitch was clamped to the allowed range.
526    pitch_clamped: bool,
527}
528
529impl SmokeSlateApplyResult {
530    /// Returns a result whose only asserted condition is `changed`.
531    fn changed() -> Self {
532        Self {
533            changed: true,
534            ..Self::default()
535        }
536    }
537}
538
539#[derive(Clone, Copy)]
540/// Mutable physical panel geometry and active stable-grab state.
541struct SmokeSlateState {
542    /// Current quad pose, dimensions, and composition options.
543    layer: OpenXrQuadLayerOptions,
544    /// Minimum allowed quad width/height in metres.
545    min_size_m: xr::Extent2Df,
546    /// Maximum allowed quad width/height in metres.
547    max_size_m: xr::Extent2Df,
548    /// HMD-facing yaw/pitch constraint policy.
549    facing: OpenXrPanelFacingOptions,
550    /// Active controller grab state, or `None` between gestures.
551    active_grab: Option<OpenXrPanelGrabState>,
552}
553
554impl SmokeSlateState {
555    /// Creates bounded panel state and normalizes facing pitch bounds.
556    fn new(layer: OpenXrQuadLayerOptions, mut facing: OpenXrPanelFacingOptions) -> Self {
557        facing.normalize_pitch_bounds();
558        Self {
559            layer,
560            min_size_m: xr::Extent2Df {
561                width: SMOKE_SLATE_MIN_WIDTH_M,
562                height: SMOKE_SLATE_MIN_HEIGHT_M,
563            },
564            max_size_m: xr::Extent2Df {
565                width: SMOKE_SLATE_MAX_WIDTH_M,
566                height: SMOKE_SLATE_MAX_HEIGHT_M,
567            },
568            facing,
569            active_grab: None,
570        }
571    }
572
573    /// Dispatches move/resize phases and ignores non-geometric affordances.
574    fn apply_affordance_event(
575        &mut self,
576        event: WindowAffordanceEvent,
577        logical_size: Size,
578        ray_sample: Option<OpenXrRaySample>,
579        head_pos: Option<Vec3>,
580    ) -> SmokeSlateApplyResult {
581        match event.kind {
582            WindowAffordanceKind::Move => {
583                self.apply_move_affordance(event, logical_size, ray_sample, head_pos)
584            }
585            WindowAffordanceKind::ResizeEdge(edge) | WindowAffordanceKind::ResizeCorner(edge) => {
586                if event.phase == WindowAffordanceDragPhase::Drag {
587                    self.apply_resize(edge, event, logical_size);
588                    SmokeSlateApplyResult::changed()
589                } else {
590                    SmokeSlateApplyResult::default()
591                }
592            }
593            WindowAffordanceKind::Close
594            | WindowAffordanceKind::Minimize
595            | WindowAffordanceKind::Follow => SmokeSlateApplyResult::default(),
596        }
597    }
598
599    /// Applies stable world-ray drag when possible, otherwise logical delta move.
600    fn apply_move_affordance(
601        &mut self,
602        event: WindowAffordanceEvent,
603        logical_size: Size,
604        ray_sample: Option<OpenXrRaySample>,
605        head_pos: Option<Vec3>,
606    ) -> SmokeSlateApplyResult {
607        match event.phase {
608            WindowAffordanceDragPhase::Start => {
609                let grab = self.new_grab_state(event, logical_size, ray_sample);
610                let depth_axis_seen = grab.depth_axis.is_some();
611                self.active_grab = Some(grab);
612                SmokeSlateApplyResult {
613                    hmd_pose_seen: head_pos.is_some(),
614                    depth_axis_seen,
615                    ..SmokeSlateApplyResult::default()
616                }
617            }
618            WindowAffordanceDragPhase::Drag => {
619                if self.active_grab.is_none() {
620                    self.active_grab = Some(self.new_grab_state(event, logical_size, ray_sample));
621                }
622
623                let hmd_pose_seen = head_pos.is_some();
624                let valid_ray_sample = valid_ui_ray_sample(ray_sample);
625                let grab_world = ray_sample_to_grab_world(valid_ray_sample);
626                let grab_world_hit = grab_world.is_some();
627                let mut depth_axis_seen = self
628                    .active_grab
629                    .as_ref()
630                    .is_some_and(|grab| grab.depth_axis.is_some());
631
632                if matches!(
633                    self.facing.mode,
634                    OpenXrPanelFacingMode::FaceUserOnDrag
635                        | OpenXrPanelFacingMode::FaceUserAlways
636                        | OpenXrPanelFacingMode::FaceUserYawPitchOnDrag
637                ) {
638                    if let (Some(grab), Some(grab_world)) = (self.active_grab.as_mut(), grab_world)
639                    {
640                        if grab.depth_axis.is_none() {
641                            if let Some(sample) = valid_ray_sample {
642                                grab.set_pointer_depth(sample.origin, sample.direction);
643                                depth_axis_seen = grab.depth_axis.is_some();
644                            }
645                        }
646                        let depth = apply_pointer_depth_delta(
647                            grab_world,
648                            grab.depth_axis,
649                            grab.last_ray_origin,
650                            valid_ray_sample.map(|sample| sample.origin),
651                        );
652                        if let Some(sample) = valid_ray_sample {
653                            grab.last_ray_origin = Some(sample.origin);
654                        }
655                        let update = apply_facing_stable_grab(
656                            self.layer,
657                            grab,
658                            depth.adjusted_grab_world,
659                            head_pos,
660                            self.facing,
661                        );
662                        if update.hmd_pose_seen {
663                            self.layer = update.layer;
664                            return SmokeSlateApplyResult {
665                                changed: true,
666                                hmd_pose_seen: update.hmd_pose_seen,
667                                grab_world_hit,
668                                yaw_applied: update.yaw_applied,
669                                grab_point_stable: update.grab_point_stable,
670                                fallback_delta: false,
671                                depth_axis_seen,
672                                depth_delta_m: depth.depth_delta_m,
673                                depth_applied: depth.depth_applied,
674                                pitch_deg: update.pitch_deg,
675                                pitch_clamped: update.pitch_clamped,
676                            };
677                        }
678                    }
679                }
680
681                self.apply_move(event, logical_size);
682                SmokeSlateApplyResult {
683                    changed: true,
684                    hmd_pose_seen,
685                    grab_world_hit,
686                    fallback_delta: true,
687                    depth_axis_seen,
688                    ..SmokeSlateApplyResult::default()
689                }
690            }
691            WindowAffordanceDragPhase::End | WindowAffordanceDragPhase::Click => {
692                self.active_grab = None;
693                SmokeSlateApplyResult {
694                    hmd_pose_seen: head_pos.is_some(),
695                    ..SmokeSlateApplyResult::default()
696                }
697            }
698        }
699    }
700
701    /// Captures local grab point, initial rotation, and optional ray-depth axis.
702    fn new_grab_state(
703        &self,
704        event: WindowAffordanceEvent,
705        logical_size: Size,
706        ray_sample: Option<OpenXrRaySample>,
707    ) -> OpenXrPanelGrabState {
708        let local_grab_point_m =
709            logical_point_to_panel_local(event.position, logical_size, self.layer.size);
710        let mut grab = OpenXrPanelGrabState::new(local_grab_point_m, self.layer.pose.orientation);
711        if let Some(sample) = valid_ui_ray_sample(ray_sample) {
712            grab.set_pointer_depth(sample.origin, sample.direction);
713        }
714        grab
715    }
716
717    /// Converts a logical drag delta to panel-basis translation in metres.
718    fn apply_move(&mut self, event: WindowAffordanceEvent, logical_size: Size) {
719        let (dx_m, dy_m) = self.logical_delta_to_meters(event, logical_size);
720        let translation = layer_right(self.layer) * dx_m + layer_up(self.layer) * -dy_m;
721        translate_layer(&mut self.layer, translation);
722    }
723
724    /// Resizes from one edge/corner while preserving the opposite edge.
725    fn apply_resize(&mut self, edge: ResizeEdge, event: WindowAffordanceEvent, logical_size: Size) {
726        let (dx_m, dy_m) = self.logical_delta_to_meters(event, logical_size);
727        let old_width = self.layer.size.width.max(self.min_size_m.width);
728        let old_height = self.layer.size.height.max(self.min_size_m.height);
729        let right = layer_right(self.layer);
730        let up = layer_up(self.layer);
731
732        let mut shift_x = 0.0;
733        let mut shift_y = 0.0;
734        let mut new_width = old_width;
735        let mut new_height = old_height;
736
737        match edge {
738            ResizeEdge::E | ResizeEdge::NE | ResizeEdge::SE => {
739                let proposed = old_width + dx_m;
740                new_width = self.clamp_width(proposed);
741                shift_x = (new_width - old_width) * 0.5;
742            }
743            ResizeEdge::W | ResizeEdge::NW | ResizeEdge::SW => {
744                let proposed = old_width - dx_m;
745                new_width = self.clamp_width(proposed);
746                shift_x = (old_width - new_width) * 0.5;
747            }
748            ResizeEdge::N | ResizeEdge::S => {}
749        }
750
751        match edge {
752            ResizeEdge::N | ResizeEdge::NE | ResizeEdge::NW => {
753                let proposed = old_height - dy_m;
754                new_height = self.clamp_height(proposed);
755                shift_y = (new_height - old_height) * 0.5;
756            }
757            ResizeEdge::S | ResizeEdge::SE | ResizeEdge::SW => {
758                let proposed = old_height + dy_m;
759                new_height = self.clamp_height(proposed);
760                shift_y = (old_height - new_height) * 0.5;
761            }
762            ResizeEdge::E | ResizeEdge::W => {}
763        }
764
765        self.layer.size.width = new_width;
766        self.layer.size.height = new_height;
767        translate_layer(&mut self.layer, right * shift_x + up * shift_y);
768    }
769
770    /// Scales a logical delta by the current physical panel extent.
771    fn logical_delta_to_meters(
772        &self,
773        event: WindowAffordanceEvent,
774        logical_size: Size,
775    ) -> (f32, f32) {
776        let w = logical_size.w.max(1.0);
777        let h = logical_size.h.max(1.0);
778        (
779            event.delta.x / w * self.layer.size.width.max(self.min_size_m.width),
780            event.delta.y / h * self.layer.size.height.max(self.min_size_m.height),
781        )
782    }
783
784    /// Clamps width to the smoke panel's inclusive metre bounds.
785    fn clamp_width(&self, width: f32) -> f32 {
786        width.clamp(self.min_size_m.width, self.max_size_m.width)
787    }
788
789    /// Clamps height to the smoke panel's inclusive metre bounds.
790    fn clamp_height(&self, height: f32) -> f32 {
791        height.clamp(self.min_size_m.height, self.max_size_m.height)
792    }
793}
794
795/// Adds a world-space metre translation to the layer position.
796fn translate_layer(layer: &mut OpenXrQuadLayerOptions, translation: Vec3) {
797    layer.pose.position.x += translation.x;
798    layer.pose.position.y += translation.y;
799    layer.pose.position.z += translation.z;
800}
801
802/// Returns the normalized world-space right axis of a layer.
803fn layer_right(layer: OpenXrQuadLayerOptions) -> Vec3 {
804    rotate_vec3(layer.pose.orientation, Vec3::new(1.0, 0.0, 0.0))
805        .normalize_or(Vec3::new(1.0, 0.0, 0.0))
806}
807
808/// Returns the normalized world-space up axis of a layer.
809fn layer_up(layer: OpenXrQuadLayerOptions) -> Vec3 {
810    rotate_vec3(layer.pose.orientation, Vec3::new(0.0, 1.0, 0.0))
811        .normalize_or(Vec3::new(0.0, 1.0, 0.0))
812}
813
814/// Reconstructs a UI hit in world space from a valid ray sample.
815fn ray_sample_to_grab_world(sample: Option<OpenXrRaySample>) -> Option<Vec3> {
816    let sample = valid_ui_ray_sample(sample)?;
817    sample
818        .direction
819        .normalize()
820        .map(|direction| sample.origin + direction * sample.hit_distance)
821}
822
823/// Keeps finite UI-hit samples with a normalizable direction.
824fn valid_ui_ray_sample(sample: Option<OpenXrRaySample>) -> Option<OpenXrRaySample> {
825    let sample = sample?;
826    if sample.hit_kind == OpenXrRayHitKind::Ui
827        && sample.hit_distance.is_finite()
828        && sample.direction.normalize().is_some()
829    {
830        Some(sample)
831    } else {
832        None
833    }
834}
835
836/// Selects the default or affordance demo view.
837fn smoke_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
838    if options.affordance_demo {
839        smoke_affordance_view(options)
840    } else {
841        smoke_default_view(options)
842    }
843}
844
845/// Builds the static diagnostic/card/button smoke UI.
846fn smoke_default_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
847    let title = TextStyle::new(FontId::Ui, 34, Color::rgb(245, 248, 255));
848    let body = TextStyle::new(FontId::Ui, 17, Color::rgb(218, 226, 238));
849    let dim = TextStyle::new(FontId::Ui, 14, Color::rgb(157, 173, 196));
850    let accent = TextStyle::new(FontId::Ui, 16, Color::rgb(126, 231, 215));
851
852    Container::<SmokeAction>::new()
853        .fill()
854        .background(Color::rgb(10, 16, 28))
855        .padding(28.0)
856        .child(
857            Column::<SmokeAction>::new()
858                .fill()
859                .gap(14.0)
860                .child(Text::new("Ailloli UI Quest Smoke").style(title))
861                .child(
862                    Text::new(format!(
863                        "native APK mode | distance={:.2}m scale={:.2} pointer={} hands={}",
864                        smoke_distance_m(&options),
865                        smoke_scale(&options),
866                        if options.prefer_left { "left" } else { "right" },
867                        if options.hands { "enabled" } else { "disabled" }
868                    ))
869                    .style(dim),
870                )
871                .child(Text::new("Visual: quad centered, text readable, ray visible.").style(body))
872                .child(Text::new("Interaction: hover, trigger/pinch click, exit.").style(body))
873                .child(
874                    Row::<SmokeAction>::new()
875                        .gap(12.0)
876                        .child(
877                            Button::<SmokeAction>::with_label("Primary")
878                                .on_click(SmokeAction::Primary)
879                                .width(180.0),
880                        )
881                        .child(
882                            Button::<SmokeAction>::with_label("Secondary")
883                                .on_click(SmokeAction::Secondary)
884                                .width(180.0),
885                        )
886                        .child(
887                            Button::<SmokeAction>::with_label("Exit")
888                                .on_click(SmokeAction::Exit)
889                                .width(140.0),
890                        ),
891                )
892                .child(Text::new("Quest validation checklist").style(accent))
893                .child(
894                    Container::<SmokeAction>::new()
895                        .fill_width()
896                        .height(210.0)
897                        .background(Color::rgb(17, 25, 40))
898                        .padding(14.0)
899                        .child(
900                            ScrollView::<SmokeAction>::vertical().child(
901                                Column::<SmokeAction>::new()
902                                    .gap(10.0)
903                                    .child(
904                                        Text::new(
905                                            "1. APK launches without the PC streaming server",
906                                        )
907                                        .style(body),
908                                    )
909                                    .child(
910                                        Text::new("2. Quad is visible in the Quest headset")
911                                            .style(body),
912                                    )
913                                    .child(
914                                        Text::new(
915                                            "3. Text is readable, not mirrored, not upside down",
916                                        )
917                                        .style(body),
918                                    )
919                                    .child(
920                                        Text::new("4. Button hover/press colors are visible")
921                                            .style(body),
922                                    )
923                                    .child(
924                                        Text::new("5. Ray is gray off UI and green on the quad")
925                                            .style(body),
926                                    )
927                                    .child(
928                                        Text::new("6. Trigger or pinch logs action=primary")
929                                            .style(body),
930                                    )
931                                    .child(
932                                        Text::new("7. Exit button closes the smoke loop cleanly")
933                                            .style(body),
934                                    ),
935                            ),
936                        ),
937                )
938                .child(
939                    Text::new("Logcat tag: linux-vr | expected: ailloli_ui smoke frame rendered")
940                        .style(dim),
941                ),
942        )
943        .into_view()
944}
945
946/// Builds the movable/resizable window-affordance smoke UI.
947fn smoke_affordance_view(options: OpenXrSmokeOptions) -> View<SmokeAction> {
948    let title = TextStyle::new(FontId::Ui, 24, Color::rgb(245, 248, 255));
949    let body = TextStyle::new(FontId::Ui, 15, Color::rgb(218, 226, 238));
950    let dim = TextStyle::new(FontId::Ui, 13, Color::rgb(156, 173, 196));
951    let accent = TextStyle::new(FontId::Ui, 14, Color::rgb(126, 231, 215));
952
953    Container::<SmokeAction>::new()
954        .fill()
955        .background(Color::rgb(8, 13, 24))
956        .padding(36.0)
957        .child(
958            WindowAffordanceFrame::<SmokeAction>::new("XR Window Affordances")
959                .logical_window_id("window-affordances-smoke-window")
960                .width(860.0)
961                .height(430.0)
962                .on_affordance(|event| match event.kind {
963                    WindowAffordanceKind::Close => SmokeAction::Exit,
964                    _ => SmokeAction::Affordance(event),
965                })
966                .content(
967                    Container::<SmokeAction>::new()
968                        .fill()
969                        .background(Color::rgb(15, 23, 42))
970                        .padding(18.0)
971                        .child(
972                            Column::<SmokeAction>::new()
973                                .fill()
974                                .gap(12.0)
975                                .child(Text::new("Framework slate contract").style(title))
976                                .child(
977                                    Text::new(format!(
978                                        "distance={:.2}m scale={:.2} pointer={} hands={}",
979                                        smoke_distance_m(&options),
980                                        smoke_scale(&options),
981                                        if options.prefer_left { "left" } else { "right" },
982                                        if options.hands { "enabled" } else { "disabled" }
983                                    ))
984                                    .style(dim),
985                                )
986                                .child(Text::new("Move from the titlebar, resize from edges/corners, and use the chrome buttons without touching LinuxVR streaming.").style(body))
987                                .child(
988                                    Row::<SmokeAction>::new()
989                                        .gap(12.0)
990                                        .child(
991                                            Button::<SmokeAction>::with_label("Primary")
992                                                .on_click(SmokeAction::Primary)
993                                                .width(160.0),
994                                        )
995                                        .child(
996                                            Button::<SmokeAction>::with_label("Secondary")
997                                                .on_click(SmokeAction::Secondary)
998                                                .width(180.0),
999                                        )
1000                                        .child(
1001                                            Button::<SmokeAction>::with_label("Exit")
1002                                                .on_click(SmokeAction::Exit)
1003                                                .width(120.0),
1004                                        ),
1005                                )
1006                                .child(Text::new("Validation points").style(accent))
1007                                .child(
1008                                    Container::<SmokeAction>::new()
1009                                        .fill_width()
1010                                        .height(155.0)
1011                                        .background(Color::rgb(11, 18, 32))
1012                                        .padding(12.0)
1013                                        .child(
1014                                            ScrollView::<SmokeAction>::vertical().child(
1015                                                Column::<SmokeAction>::new()
1016                                                    .gap(8.0)
1017                                                    .child(Text::new("1. Rounded surface, border and shadow are visible in Vulkan").style(body))
1018                                                    .child(Text::new("2. Titlebar hover/drag emits affordance actions").style(body))
1019                                                    .child(Text::new("3. Resize handles react on edges and corners").style(body))
1020                                                    .child(Text::new("4. Inner buttons keep normal click handling").style(body))
1021                                                    .child(Text::new("5. Close chrome exits the smoke loop").style(body)),
1022                                            ),
1023                                        ),
1024                                ),
1025                        ),
1026                ),
1027        )
1028        .key("window-affordances-window-affordances-smoke")
1029        .into_view()
1030}
1031
1032/// Converts normalized smoke settings into external-host settings.
1033fn smoke_host_options(options: &OpenXrSmokeOptions) -> OpenXrExternalUiHostOptions {
1034    OpenXrExternalUiHostOptions {
1035        pixel_width: smoke_pixel_width(options),
1036        pixel_height: smoke_pixel_height(options),
1037        clear: Color::rgb(10, 16, 28),
1038        scale: Scale::new(smoke_scale(options)),
1039        layer: smoke_layer_options(options),
1040        renderer: Default::default(),
1041        input: smoke_input_options(options),
1042        ray: OpenXrExternalUiHostRayOptions {
1043            enabled: true,
1044            overlay: OpenXrRayOverlayOptions::default(),
1045        },
1046    }
1047}
1048
1049/// Builds the initial identity-facing layer at negative configured Z distance.
1050fn smoke_layer_options(options: &OpenXrSmokeOptions) -> OpenXrQuadLayerOptions {
1051    OpenXrQuadLayerOptions {
1052        pose: xr::Posef {
1053            orientation: xr::Quaternionf {
1054                x: 0.0,
1055                y: 0.0,
1056                z: 0.0,
1057                w: 1.0,
1058            },
1059            position: xr::Vector3f {
1060                x: 0.0,
1061                y: 0.0,
1062                z: -smoke_distance_m(options),
1063            },
1064        },
1065        size: xr::Extent2Df {
1066            width: SMOKE_QUAD_WIDTH_M,
1067            height: SMOKE_QUAD_HEIGHT_M,
1068        },
1069        eye_visibility: xr::EyeVisibility::BOTH,
1070        layer_flags: xr::CompositionLayerFlags::EMPTY,
1071    }
1072}
1073
1074/// Selects controller priority and hand enablement for smoke input.
1075fn smoke_input_options(options: &OpenXrSmokeOptions) -> OpenXrUiInputOptions {
1076    OpenXrUiInputOptions {
1077        controllers: true,
1078        hands: options.hands,
1079        pointer_selection: if options.prefer_left {
1080            OpenXrPointerSelectionPolicy::PreferLeftController
1081        } else {
1082            OpenXrPointerSelectionPolicy::PreferRightController
1083        },
1084        ..OpenXrUiInputOptions::default()
1085    }
1086}
1087
1088/// Copies names into local-then-stage runtime initialization settings.
1089fn smoke_runtime_options(options: &OpenXrSmokeOptions) -> OpenXrRuntimeOptions {
1090    OpenXrRuntimeOptions {
1091        application_name: options.application_name.clone(),
1092        engine_name: options.engine_name.clone(),
1093        reference_space: ReferenceSpacePreference::LocalThenStage,
1094    }
1095}
1096
1097/// Returns positive finite panel distance or the 2 metre fallback.
1098fn smoke_distance_m(options: &OpenXrSmokeOptions) -> f32 {
1099    positive_or_default(options.distance_m, DEFAULT_DISTANCE_M).abs()
1100}
1101
1102/// Returns positive finite DPR or the `1.0` fallback.
1103fn smoke_scale(options: &OpenXrSmokeOptions) -> f32 {
1104    positive_or_default(options.scale, DEFAULT_SCALE)
1105}
1106
1107/// Keeps only a positive finite timeout; invalid values become `None`.
1108fn smoke_timeout_sec(options: &OpenXrSmokeOptions) -> Option<f32> {
1109    options
1110        .timeout_sec
1111        .filter(|value| value.is_finite() && *value > 0.0)
1112}
1113
1114/// Returns requested pixel width clamped to at least one.
1115fn smoke_pixel_width(options: &OpenXrSmokeOptions) -> u32 {
1116    options.pixel_width.max(1)
1117}
1118
1119/// Returns requested pixel height clamped to at least one.
1120fn smoke_pixel_height(options: &OpenXrSmokeOptions) -> u32 {
1121    options.pixel_height.max(1)
1122}
1123
1124/// Keeps a positive finite value, otherwise returns its documented fallback.
1125fn positive_or_default(value: f32, default: f32) -> f32 {
1126    if value.is_finite() && value > 0.0 {
1127        value
1128    } else {
1129        default
1130    }
1131}
1132
1133#[cfg(test)]
1134/// Exercises normalization plus move, depth, facing, and resize behavior.
1135mod tests {
1136    use super::super::panel::panel_local_to_world;
1137    use super::*;
1138    use ailloli_ui_core::{Offset, Point};
1139
1140    /// Builds smoke options with explicit pointer-side and hand toggles.
1141    fn options(prefer_left: bool, hands: bool) -> OpenXrSmokeOptions {
1142        OpenXrSmokeOptions {
1143            prefer_left,
1144            hands,
1145            distance_m: 2.0,
1146            scale: 1.0,
1147            timeout_sec: Some(60.0),
1148            application_name: "linux-vr".to_string(),
1149            engine_name: "linux-vr".to_string(),
1150            ..OpenXrSmokeOptions::default()
1151        }
1152    }
1153
1154    #[test]
1155    fn smoke_options_default_match_quest_smoke_contract() {
1156        let options = OpenXrSmokeOptions::default();
1157        assert!(!options.prefer_left);
1158        assert!(options.hands);
1159        assert_eq!(options.distance_m, 2.0);
1160        assert_eq!(options.scale, 1.0);
1161        assert_eq!(options.pixel_width, 1024);
1162        assert_eq!(options.pixel_height, 576);
1163        assert_eq!(options.timeout_sec, None);
1164        assert!(!options.affordance_demo);
1165        assert_eq!(
1166            options.panel_facing.mode,
1167            OpenXrPanelFacingMode::FaceUserOnDrag
1168        );
1169        approx(options.panel_facing.pitch_min_rad.to_degrees(), -45.0);
1170        approx(options.panel_facing.pitch_max_rad.to_degrees(), 45.0);
1171    }
1172
1173    #[test]
1174    fn smoke_host_options_prefer_right_by_default() {
1175        let host = smoke_host_options(&options(false, true));
1176        assert_eq!(host.pixel_width, SMOKE_PIXEL_WIDTH);
1177        assert_eq!(host.pixel_height, SMOKE_PIXEL_HEIGHT);
1178        assert_eq!(host.scale.dpr, 1.0);
1179        assert_eq!(host.layer.pose.position.z, -2.0);
1180        assert_eq!(host.layer.size.width, SMOKE_QUAD_WIDTH_M);
1181        assert_eq!(host.layer.size.height, SMOKE_QUAD_HEIGHT_M);
1182        assert!(host.input.controllers);
1183        assert!(host.input.hands);
1184        assert!(host.ray.enabled);
1185        assert_eq!(
1186            host.input.pointer_selection,
1187            OpenXrPointerSelectionPolicy::PreferRightController
1188        );
1189    }
1190
1191    #[test]
1192    fn smoke_host_options_maps_left_and_no_hands() {
1193        let host = smoke_host_options(&options(true, false));
1194        assert!(host.input.controllers);
1195        assert!(!host.input.hands);
1196        assert_eq!(
1197            host.input.pointer_selection,
1198            OpenXrPointerSelectionPolicy::PreferLeftController
1199        );
1200    }
1201
1202    #[test]
1203    fn smoke_runtime_options_use_application_identity() {
1204        let runtime = smoke_runtime_options(&options(false, true));
1205        assert_eq!(runtime.application_name, "linux-vr");
1206        assert_eq!(runtime.engine_name, "linux-vr");
1207        assert_eq!(
1208            runtime.reference_space,
1209            ReferenceSpacePreference::LocalThenStage
1210        );
1211    }
1212
1213    /// Builds a drag-phase affordance event with matching position/deltas.
1214    fn drag_event(kind: WindowAffordanceKind, dx: f32, dy: f32) -> WindowAffordanceEvent {
1215        WindowAffordanceEvent {
1216            kind,
1217            phase: WindowAffordanceDragPhase::Drag,
1218            position: Point::new(dx, dy),
1219            delta: Offset::new(dx, dy),
1220            total_delta: Offset::new(dx, dy),
1221        }
1222    }
1223
1224    /// Builds a centered start-phase affordance fixture.
1225    fn start_event(kind: WindowAffordanceKind) -> WindowAffordanceEvent {
1226        WindowAffordanceEvent {
1227            kind,
1228            phase: WindowAffordanceDragPhase::Start,
1229            position: Point::new(512.0, 288.0),
1230            delta: Offset::new(100.0, 100.0),
1231            total_delta: Offset::new(100.0, 100.0),
1232        }
1233    }
1234
1235    /// Builds a centered end-phase affordance fixture with zero delta.
1236    fn end_event(kind: WindowAffordanceKind) -> WindowAffordanceEvent {
1237        WindowAffordanceEvent {
1238            kind,
1239            phase: WindowAffordanceDragPhase::End,
1240            position: Point::new(512.0, 288.0),
1241            delta: Offset::new(0.0, 0.0),
1242            total_delta: Offset::new(0.0, 0.0),
1243        }
1244    }
1245
1246    /// Returns a default layer/facing slate fixture.
1247    fn slate() -> SmokeSlateState {
1248        SmokeSlateState::new(
1249            smoke_layer_options(&OpenXrSmokeOptions::default()),
1250            OpenXrPanelFacingOptions::default(),
1251        )
1252    }
1253
1254    /// Returns a default layer with caller-selected facing options.
1255    fn slate_with_facing(facing: OpenXrPanelFacingOptions) -> SmokeSlateState {
1256        SmokeSlateState::new(smoke_layer_options(&OpenXrSmokeOptions::default()), facing)
1257    }
1258
1259    /// Returns the default pixel extent as DPR-one logical size.
1260    fn logical_size() -> Size {
1261        Size::new(SMOKE_PIXEL_WIDTH as f32, SMOKE_PIXEL_HEIGHT as f32)
1262    }
1263
1264    /// Applies one affordance without ray or HMD inputs.
1265    fn apply_delta(
1266        slate: &mut SmokeSlateState,
1267        event: WindowAffordanceEvent,
1268    ) -> SmokeSlateApplyResult {
1269        slate.apply_affordance_event(event, logical_size(), None, None)
1270    }
1271
1272    /// Builds a UI-hit ray fixture in world metres.
1273    fn ray_sample(origin: Vec3, direction: Vec3, distance: f32) -> OpenXrRaySample {
1274        OpenXrRaySample::new(origin, direction, OpenXrRayHitKind::Ui, distance)
1275    }
1276
1277    /// Asserts two floats differ by less than `1e-5`.
1278    fn approx(a: f32, b: f32) {
1279        assert!(
1280            (a - b).abs() < 1e-5,
1281            "expected {a:?} to be approximately {b:?}"
1282        );
1283    }
1284
1285    /// Applies [`approx`] to all three vector components.
1286    fn approx_vec(a: Vec3, b: Vec3) {
1287        approx(a.x, b.x);
1288        approx(a.y, b.y);
1289        approx(a.z, b.z);
1290    }
1291
1292    #[test]
1293    fn smoke_slate_move_right_increases_x() {
1294        let mut slate = slate();
1295        let applied = apply_delta(
1296            &mut slate,
1297            drag_event(WindowAffordanceKind::Move, 128.0, 0.0),
1298        );
1299        assert!(applied.changed);
1300        assert!(applied.fallback_delta);
1301        assert!(slate.layer.pose.position.x > 0.0);
1302        assert_eq!(slate.layer.pose.position.y, 0.0);
1303        assert_eq!(slate.layer.size.width, SMOKE_QUAD_WIDTH_M);
1304    }
1305
1306    #[test]
1307    fn smoke_slate_move_down_decreases_y() {
1308        let mut slate = slate();
1309        let applied = apply_delta(
1310            &mut slate,
1311            drag_event(WindowAffordanceKind::Move, 0.0, 128.0),
1312        );
1313        assert!(applied.changed);
1314        assert!(applied.fallback_delta);
1315        assert!(slate.layer.pose.position.y < 0.0);
1316        assert_eq!(slate.layer.pose.position.x, 0.0);
1317    }
1318
1319    #[test]
1320    fn smoke_slate_move_start_initializes_grab_without_pose_change() {
1321        let mut slate = slate();
1322        let before = slate.layer;
1323        let applied = slate.apply_affordance_event(
1324            start_event(WindowAffordanceKind::Move),
1325            logical_size(),
1326            None,
1327            Some(Vec3::new(0.0, 1.5, 0.0)),
1328        );
1329        assert!(!applied.changed);
1330        assert!(applied.hmd_pose_seen);
1331        assert!(slate.active_grab.is_some());
1332        assert_eq!(slate.layer.pose.position.x, before.pose.position.x);
1333        assert_eq!(slate.layer.pose.position.y, before.pose.position.y);
1334        assert_eq!(slate.layer.pose.position.z, before.pose.position.z);
1335        assert_eq!(slate.layer.size.width, before.size.width);
1336        assert_eq!(slate.layer.size.height, before.size.height);
1337    }
1338
1339    #[test]
1340    fn smoke_slate_move_start_with_ray_initializes_depth_state() {
1341        let mut slate = slate();
1342        let origin = Vec3::new(0.0, 0.0, 0.0);
1343        let direction = Vec3::new(0.0, 0.0, -1.0);
1344        let applied = slate.apply_affordance_event(
1345            start_event(WindowAffordanceKind::Move),
1346            logical_size(),
1347            Some(ray_sample(origin, direction, 2.0)),
1348            Some(Vec3::new(0.0, 1.5, 0.0)),
1349        );
1350        assert!(!applied.changed);
1351        assert!(applied.depth_axis_seen);
1352        let grab = slate.active_grab.unwrap();
1353        approx_vec(grab.depth_axis.unwrap(), direction);
1354        approx_vec(grab.last_ray_origin.unwrap(), origin);
1355    }
1356
1357    #[test]
1358    fn smoke_slate_move_drag_faces_hmd_and_keeps_grab_point_stable() {
1359        let mut slate = slate();
1360        slate.apply_affordance_event(
1361            start_event(WindowAffordanceKind::Move),
1362            logical_size(),
1363            None,
1364            None,
1365        );
1366
1367        let grab_world = Vec3::new(0.65, 0.1, -1.35);
1368        let origin = Vec3::new(0.0, 0.0, 0.0);
1369        let direction = grab_world - origin;
1370        let head_pos = Vec3::new(1.6, 0.2, 0.1);
1371        let applied = slate.apply_affordance_event(
1372            drag_event(WindowAffordanceKind::Move, 40.0, -20.0),
1373            logical_size(),
1374            Some(ray_sample(origin, direction, direction.len())),
1375            Some(head_pos),
1376        );
1377
1378        assert!(applied.changed);
1379        assert!(applied.hmd_pose_seen);
1380        assert!(applied.grab_world_hit);
1381        assert!(applied.yaw_applied);
1382        assert!(applied.grab_point_stable);
1383        assert!(!applied.fallback_delta);
1384
1385        let grab = slate.active_grab.unwrap();
1386        approx_vec(
1387            panel_local_to_world(slate.layer, grab.local_grab_point_m),
1388            grab_world,
1389        );
1390        assert!(slate.layer.pose.orientation.y.abs() > 0.001);
1391        approx(slate.layer.pose.orientation.x, 0.0);
1392        approx(slate.layer.pose.orientation.z, 0.0);
1393    }
1394
1395    #[test]
1396    fn smoke_slate_move_drag_yaw_pitch_faces_hmd_above_and_keeps_grab_point_stable() {
1397        let mut slate = slate_with_facing(OpenXrPanelFacingOptions::new(
1398            OpenXrPanelFacingMode::FaceUserYawPitchOnDrag,
1399            -45.0_f32.to_radians(),
1400            45.0_f32.to_radians(),
1401        ));
1402        slate.apply_affordance_event(
1403            start_event(WindowAffordanceKind::Move),
1404            logical_size(),
1405            None,
1406            None,
1407        );
1408
1409        let grab_world = Vec3::new(0.65, 0.1, -1.35);
1410        let origin = Vec3::new(0.0, 0.0, 0.0);
1411        let direction = grab_world - origin;
1412        let head_pos = Vec3::new(1.6, 1.4, 0.1);
1413        let applied = slate.apply_affordance_event(
1414            drag_event(WindowAffordanceKind::Move, 40.0, -20.0),
1415            logical_size(),
1416            Some(ray_sample(origin, direction, direction.len())),
1417            Some(head_pos),
1418        );
1419
1420        assert!(applied.changed);
1421        assert!(applied.hmd_pose_seen);
1422        assert!(applied.grab_world_hit);
1423        assert!(applied.yaw_applied);
1424        assert!(applied.grab_point_stable);
1425        assert!(applied.pitch_deg > 0.0);
1426        assert!(!applied.fallback_delta);
1427
1428        let grab = slate.active_grab.unwrap();
1429        approx_vec(
1430            panel_local_to_world(slate.layer, grab.local_grab_point_m),
1431            grab_world,
1432        );
1433        assert!(slate.layer.pose.orientation.x.abs() > 0.001);
1434        let right = rotate_vec3(slate.layer.pose.orientation, Vec3::new(1.0, 0.0, 0.0));
1435        approx(right.y, 0.0);
1436    }
1437
1438    #[test]
1439    fn smoke_slate_move_drag_forward_origin_pushes_panel_deeper() {
1440        let mut slate = slate();
1441        let start_origin = Vec3::new(0.0, 0.0, 0.0);
1442        let direction = Vec3::new(0.0, 0.0, -1.0);
1443        slate.apply_affordance_event(
1444            start_event(WindowAffordanceKind::Move),
1445            logical_size(),
1446            Some(ray_sample(start_origin, direction, 2.0)),
1447            None,
1448        );
1449
1450        let applied = slate.apply_affordance_event(
1451            drag_event(WindowAffordanceKind::Move, 0.0, 0.0),
1452            logical_size(),
1453            Some(ray_sample(Vec3::new(0.0, 0.0, -0.25), direction, 1.75)),
1454            Some(Vec3::new(0.0, 0.2, 0.0)),
1455        );
1456
1457        assert!(applied.changed);
1458        assert!(applied.depth_axis_seen);
1459        assert!(applied.depth_applied);
1460        approx(applied.depth_delta_m, 0.25);
1461        approx(slate.layer.pose.position.z, -2.25);
1462        approx_vec(
1463            slate.active_grab.unwrap().last_ray_origin.unwrap(),
1464            Vec3::new(0.0, 0.0, -0.25),
1465        );
1466    }
1467
1468    #[test]
1469    fn smoke_slate_move_drag_backward_origin_pulls_panel_closer() {
1470        let mut slate = slate();
1471        let start_origin = Vec3::new(0.0, 0.0, 0.0);
1472        let direction = Vec3::new(0.0, 0.0, -1.0);
1473        slate.apply_affordance_event(
1474            start_event(WindowAffordanceKind::Move),
1475            logical_size(),
1476            Some(ray_sample(start_origin, direction, 2.0)),
1477            None,
1478        );
1479
1480        let applied = slate.apply_affordance_event(
1481            drag_event(WindowAffordanceKind::Move, 0.0, 0.0),
1482            logical_size(),
1483            Some(ray_sample(Vec3::new(0.0, 0.0, 0.25), direction, 2.25)),
1484            Some(Vec3::new(0.0, 0.2, 0.0)),
1485        );
1486
1487        assert!(applied.changed);
1488        assert!(applied.depth_axis_seen);
1489        assert!(applied.depth_applied);
1490        approx(applied.depth_delta_m, -0.25);
1491        approx(slate.layer.pose.position.z, -1.75);
1492    }
1493
1494    #[test]
1495    fn smoke_slate_move_drag_without_hmd_or_ray_falls_back_to_delta() {
1496        let mut slate = slate();
1497        slate.apply_affordance_event(
1498            start_event(WindowAffordanceKind::Move),
1499            logical_size(),
1500            None,
1501            None,
1502        );
1503        let applied = slate.apply_affordance_event(
1504            drag_event(WindowAffordanceKind::Move, 128.0, 0.0),
1505            logical_size(),
1506            None,
1507            None,
1508        );
1509        assert!(applied.changed);
1510        assert!(!applied.hmd_pose_seen);
1511        assert!(!applied.grab_world_hit);
1512        assert!(!applied.yaw_applied);
1513        assert!(applied.fallback_delta);
1514        assert!(!applied.depth_applied);
1515        assert!(slate.layer.pose.position.x > 0.0);
1516    }
1517
1518    #[test]
1519    fn smoke_slate_move_end_clears_active_grab() {
1520        let mut slate = slate();
1521        slate.apply_affordance_event(
1522            start_event(WindowAffordanceKind::Move),
1523            logical_size(),
1524            None,
1525            None,
1526        );
1527        assert!(slate.active_grab.is_some());
1528        let applied = slate.apply_affordance_event(
1529            end_event(WindowAffordanceKind::Move),
1530            logical_size(),
1531            None,
1532            None,
1533        );
1534        assert!(!applied.changed);
1535        assert!(slate.active_grab.is_none());
1536    }
1537
1538    #[test]
1539    fn smoke_slate_resize_east_grows_width_and_moves_right() {
1540        let mut slate = slate();
1541        let applied = apply_delta(
1542            &mut slate,
1543            drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E), 128.0, 0.0),
1544        );
1545        assert!(applied.changed);
1546        assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1547        assert!(slate.layer.pose.position.x > 0.0);
1548        assert_eq!(slate.layer.size.height, SMOKE_QUAD_HEIGHT_M);
1549    }
1550
1551    #[test]
1552    fn smoke_slate_resize_west_keeps_east_edge_stable() {
1553        let mut slate = slate();
1554        let old_east = slate.layer.pose.position.x + slate.layer.size.width * 0.5;
1555        let applied = apply_delta(
1556            &mut slate,
1557            drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::W), -128.0, 0.0),
1558        );
1559        assert!(applied.changed);
1560        let new_east = slate.layer.pose.position.x + slate.layer.size.width * 0.5;
1561        assert!((new_east - old_east).abs() < 1e-5);
1562        assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1563        assert!(slate.layer.pose.position.x < 0.0);
1564    }
1565
1566    #[test]
1567    fn smoke_slate_resize_north_and_south_use_screen_y_direction() {
1568        let mut north = slate();
1569        apply_delta(
1570            &mut north,
1571            drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::N), 0.0, -128.0),
1572        );
1573        assert!(north.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1574        assert!(north.layer.pose.position.y > 0.0);
1575
1576        let mut south = slate();
1577        apply_delta(
1578            &mut south,
1579            drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::S), 0.0, 128.0),
1580        );
1581        assert!(south.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1582        assert!(south.layer.pose.position.y < 0.0);
1583    }
1584
1585    #[test]
1586    fn smoke_slate_resize_corner_combines_width_and_height() {
1587        let mut slate = slate();
1588        let applied = apply_delta(
1589            &mut slate,
1590            drag_event(
1591                WindowAffordanceKind::ResizeCorner(ResizeEdge::NE),
1592                128.0,
1593                -128.0,
1594            ),
1595        );
1596        assert!(applied.changed);
1597        assert!(slate.layer.size.width > SMOKE_QUAD_WIDTH_M);
1598        assert!(slate.layer.size.height > SMOKE_QUAD_HEIGHT_M);
1599        assert!(slate.layer.pose.position.x > 0.0);
1600        assert!(slate.layer.pose.position.y > 0.0);
1601    }
1602
1603    #[test]
1604    fn smoke_slate_resize_does_not_force_billboard_rotation() {
1605        let mut slate = slate();
1606        let before = slate.layer.pose.orientation;
1607        let applied = apply_delta(
1608            &mut slate,
1609            drag_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E), 128.0, 0.0),
1610        );
1611        assert!(applied.changed);
1612        assert_eq!(slate.layer.pose.orientation, before);
1613        assert!(slate.active_grab.is_none());
1614        assert!(!applied.depth_axis_seen);
1615        assert!(!applied.depth_applied);
1616    }
1617
1618    #[test]
1619    fn smoke_slate_resize_clamps_min_and_max() {
1620        let mut min = slate();
1621        apply_delta(
1622            &mut min,
1623            drag_event(
1624                WindowAffordanceKind::ResizeCorner(ResizeEdge::SE),
1625                -100_000.0,
1626                -100_000.0,
1627            ),
1628        );
1629        assert_eq!(min.layer.size.width, SMOKE_SLATE_MIN_WIDTH_M);
1630        assert_eq!(min.layer.size.height, SMOKE_SLATE_MIN_HEIGHT_M);
1631
1632        let mut max = slate();
1633        apply_delta(
1634            &mut max,
1635            drag_event(
1636                WindowAffordanceKind::ResizeCorner(ResizeEdge::SE),
1637                100_000.0,
1638                100_000.0,
1639            ),
1640        );
1641        assert_eq!(max.layer.size.width, SMOKE_SLATE_MAX_WIDTH_M);
1642        assert_eq!(max.layer.size.height, SMOKE_SLATE_MAX_HEIGHT_M);
1643    }
1644
1645    #[test]
1646    fn smoke_slate_start_event_does_not_change_pose_or_size() {
1647        let mut slate = slate();
1648        let before = slate.layer;
1649        let applied = apply_delta(
1650            &mut slate,
1651            start_event(WindowAffordanceKind::ResizeEdge(ResizeEdge::E)),
1652        );
1653        assert!(!applied.changed);
1654        assert_eq!(slate.layer.pose.position.x, before.pose.position.x);
1655        assert_eq!(slate.layer.pose.position.y, before.pose.position.y);
1656        assert_eq!(slate.layer.size.width, before.size.width);
1657        assert_eq!(slate.layer.size.height, before.size.height);
1658    }
1659}