ailloli_ui_openxr/
math.rs

1//! Small math helpers used by XR input mapping.
2//!
3//! These types are intentionally lightweight and local to the crate so the XR host
4//! can avoid adding a full vector math dependency.
5
6use std::ops::{Add, AddAssign, Div, Mul, Sub};
7
8use ailloli_ui_core::Point;
9
10#[derive(Debug, Clone, Copy, Default, PartialEq)]
11/// Three-dimensional single-precision vector used by the XR geometry helpers.
12///
13/// Coordinates use the caller's space and units; runtime modules conventionally
14/// use metres for world-space vectors.
15///
16/// # Examples
17///
18/// ```
19/// use ailloli_ui_openxr::math::Vec3;
20///
21/// let vector = Vec3::new(1.0, 2.0, 3.0);
22/// assert_eq!((vector.x, vector.y, vector.z), (1.0, 2.0, 3.0));
23/// ```
24pub struct Vec3 {
25    /// X component.
26    pub x: f32,
27    /// Y component.
28    pub y: f32,
29    /// Z component.
30    pub z: f32,
31}
32
33impl Vec3 {
34    /// Creates a vector from its three components.
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use ailloli_ui_openxr::math::Vec3;
40    /// assert_eq!(Vec3::new(4.0, 5.0, 6.0).z, 6.0);
41    /// ```
42    pub const fn new(x: f32, y: f32, z: f32) -> Self {
43        Self { x, y, z }
44    }
45
46    /// Returns the scalar dot product.
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use ailloli_ui_openxr::math::Vec3;
52    /// assert_eq!(Vec3::new(1.0, 2.0, 3.0).dot(Vec3::new(4.0, 5.0, 6.0)), 32.0);
53    /// ```
54    pub fn dot(self, rhs: Self) -> f32 {
55        self.x * rhs.x + self.y * rhs.y + self.z * rhs.z
56    }
57
58    /// Returns the right-handed cross product.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use ailloli_ui_openxr::math::Vec3;
64    /// assert_eq!(Vec3::new(1.0, 0.0, 0.0).cross(Vec3::new(0.0, 1.0, 0.0)), Vec3::new(0.0, 0.0, 1.0));
65    /// ```
66    pub fn cross(self, rhs: Self) -> Self {
67        Self {
68            x: self.y * rhs.z - self.z * rhs.y,
69            y: self.z * rhs.x - self.x * rhs.z,
70            z: self.x * rhs.y - self.y * rhs.x,
71        }
72    }
73
74    /// Returns the squared Euclidean length without a square root.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use ailloli_ui_openxr::math::Vec3;
80    /// assert_eq!(Vec3::new(2.0, 3.0, 6.0).len_sq(), 49.0);
81    /// ```
82    pub fn len_sq(self) -> f32 {
83        self.dot(self)
84    }
85
86    /// Returns the Euclidean length.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use ailloli_ui_openxr::math::Vec3;
92    /// assert_eq!(Vec3::new(3.0, 4.0, 0.0).len(), 5.0);
93    /// ```
94    pub fn len(self) -> f32 {
95        self.len_sq().sqrt()
96    }
97
98    /// Normalizes the vector, returning `fallback` only for exact zero length.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use ailloli_ui_openxr::math::Vec3;
104    /// let fallback = Vec3::new(0.0, 0.0, -1.0);
105    /// assert_eq!(Vec3::default().normalize_or(fallback), fallback);
106    /// ```
107    pub fn normalize_or(self, fallback: Self) -> Self {
108        let length = self.len();
109        if length > 0.0 {
110            self / length
111        } else {
112            fallback
113        }
114    }
115
116    /// Returns a unit vector, or `None` when the length is at most `1e-6`.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use ailloli_ui_openxr::math::Vec3;
122    /// assert_eq!(Vec3::default().normalize(), None);
123    /// assert_eq!(Vec3::new(0.0, 3.0, 0.0).normalize(), Some(Vec3::new(0.0, 1.0, 0.0)));
124    /// ```
125    pub fn normalize(self) -> Option<Self> {
126        let length = self.len();
127        if length > 1e-6 {
128            Some(self / length)
129        } else {
130            None
131        }
132    }
133
134    /// Multiplies every component by `s`.
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use ailloli_ui_openxr::math::Vec3;
140    /// assert_eq!(Vec3::new(1.0, -2.0, 3.0).scale(2.0), Vec3::new(2.0, -4.0, 6.0));
141    /// ```
142    pub fn scale(self, s: f32) -> Self {
143        Self {
144            x: self.x * s,
145            y: self.y * s,
146            z: self.z * s,
147        }
148    }
149}
150
151impl Add for Vec3 {
152    type Output = Self;
153    fn add(self, rhs: Self) -> Self {
154        Self {
155            x: self.x + rhs.x,
156            y: self.y + rhs.y,
157            z: self.z + rhs.z,
158        }
159    }
160}
161
162impl AddAssign for Vec3 {
163    fn add_assign(&mut self, rhs: Self) {
164        self.x += rhs.x;
165        self.y += rhs.y;
166        self.z += rhs.z;
167    }
168}
169
170impl Sub for Vec3 {
171    type Output = Self;
172    fn sub(self, rhs: Self) -> Self {
173        Self {
174            x: self.x - rhs.x,
175            y: self.y - rhs.y,
176            z: self.z - rhs.z,
177        }
178    }
179}
180
181impl Div<f32> for Vec3 {
182    type Output = Self;
183    fn div(self, rhs: f32) -> Self {
184        Self {
185            x: self.x / rhs,
186            y: self.y / rhs,
187            z: self.z / rhs,
188        }
189    }
190}
191
192impl Mul<f32> for Vec3 {
193    type Output = Self;
194    fn mul(self, rhs: f32) -> Self {
195        Self {
196            x: self.x * rhs,
197            y: self.y * rhs,
198            z: self.z * rhs,
199        }
200    }
201}
202
203impl Mul<Vec3> for f32 {
204    type Output = Vec3;
205    fn mul(self, rhs: Vec3) -> Vec3 {
206        rhs * self
207    }
208}
209
210/// Single hit result for ray-vs-quad intersection.
211///
212/// # Examples
213///
214/// ```
215/// use ailloli_ui_openxr::math::{QuadHit, Vec3};
216/// let hit = QuadHit { position: Vec3::default(), t: 1.0, u: 0.5, v: 0.5 };
217/// assert_eq!((hit.u, hit.v), (0.5, 0.5));
218/// ```
219#[derive(Debug, Clone, Copy)]
220pub struct QuadHit {
221    /// Intersection point in world space.
222    pub position: Vec3,
223    /// Signed distance from ray origin along direction (`t` in `origin + t*dir`).
224    pub t: f32,
225    /// Horizontal local coordinate in `[0..1]` for the quad.
226    pub u: f32,
227    /// Vertical local coordinate in `[0..1]` for the quad.
228    pub v: f32,
229}
230
231/// Axis-aligned quad represented with local basis vectors.
232///
233/// `right`, `up`, and `normal` are expected to be normalized and mutually
234/// orthogonal. Half extents use the same units as `center`, normally metres.
235///
236/// # Examples
237///
238/// ```
239/// use ailloli_ui_openxr::math::{RayQuad, Vec3};
240/// let quad = RayQuad::new(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 0.5, 0.25);
241/// assert_eq!((quad.half_width, quad.half_height), (0.5, 0.25));
242/// ```
243#[derive(Debug, Clone, Copy)]
244pub struct RayQuad {
245    /// Quad center in world space.
246    pub center: Vec3,
247    /// Front-facing unit normal.
248    pub normal: Vec3,
249    /// Local positive-X unit vector.
250    pub right: Vec3,
251    /// Local positive-Y unit vector.
252    pub up: Vec3,
253    /// Horizontal half extent, normally in metres.
254    pub half_width: f32,
255    /// Vertical half extent, normally in metres.
256    pub half_height: f32,
257}
258
259impl RayQuad {
260    /// Creates a quad without normalizing its basis or clamping its extents.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use ailloli_ui_openxr::math::{RayQuad, Vec3};
266    /// let quad = RayQuad::new(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 1.0, 0.5);
267    /// assert_eq!(quad.center, Vec3::default());
268    /// ```
269    pub const fn new(
270        center: Vec3,
271        normal: Vec3,
272        right: Vec3,
273        up: Vec3,
274        half_width: f32,
275        half_height: f32,
276    ) -> Self {
277        Self {
278            center,
279            normal,
280            right,
281            up,
282            half_width,
283            half_height,
284        }
285    }
286
287    /// Intersects a ray against the finite quad.
288    ///
289    /// The direction is normalized, with `(0, 0, -1)` used for an exact zero
290    /// vector. Parallel rays, intersections behind the origin, and points beyond
291    /// the inclusive quad edges return `None`. Returned UV coordinates are in
292    /// `[0, 1]`.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use ailloli_ui_openxr::math::{RayQuad, Vec3};
298    /// let quad = RayQuad::new(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 0.5, 0.5);
299    /// let hit = quad.intersect(Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0)).unwrap();
300    /// assert_eq!((hit.u, hit.v), (0.5, 0.5));
301    /// ```
302    pub fn intersect(&self, origin: Vec3, direction: Vec3) -> Option<QuadHit> {
303        let dir = direction.normalize_or(Vec3::new(0.0, 0.0, -1.0));
304        let denom = dir.dot(self.normal);
305        if denom.abs() < 1e-6 {
306            return None;
307        }
308
309        let t = (self.center - origin).dot(self.normal) / denom;
310        if t < 0.0 {
311            return None;
312        }
313
314        let hit = origin + dir * t;
315        let rel = hit - self.center;
316        let u = rel.dot(self.right) / self.half_width.max(f32::MIN_POSITIVE);
317        let v = rel.dot(self.up) / self.half_height.max(f32::MIN_POSITIVE);
318
319        if u.abs() <= 1.0 && v.abs() <= 1.0 {
320            Some(QuadHit {
321                position: hit,
322                t,
323                u: (u + 1.0) * 0.5,
324                v: (v + 1.0) * 0.5,
325            })
326        } else {
327            None
328        }
329    }
330}
331
332/// Converts quad UV into top-left-origin logical UI coordinates.
333///
334/// Both axes are clamped to the inclusive logical bounds and V is inverted.
335///
336/// # Examples
337///
338/// ```
339/// use ailloli_ui_openxr::math::uv_to_logical;
340/// let point = uv_to_logical(0.25, 0.75, 800.0, 600.0);
341/// assert_eq!((point.x, point.y), (200.0, 150.0));
342/// ```
343pub fn uv_to_logical(u: f32, v: f32, logical_width: f32, logical_height: f32) -> Point {
344    let x = (u * logical_width).clamp(0.0, logical_width);
345    let y = ((1.0 - v) * logical_height).clamp(0.0, logical_height);
346    Point::new(x, y)
347}
348
349#[cfg(test)]
350/// Covers centered and edge intersections, UV inversion, and misses.
351mod tests {
352    use super::*;
353
354    #[test]
355    fn ray_hits_quad_center() {
356        let quad = RayQuad::new(
357            Vec3::new(0.0, 0.0, 0.0),
358            Vec3::new(0.0, 0.0, 1.0),
359            Vec3::new(1.0, 0.0, 0.0),
360            Vec3::new(0.0, 1.0, 0.0),
361            0.5,
362            0.5,
363        );
364        let hit = quad.intersect(Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0));
365        assert!(hit.is_some());
366        let hit = hit.unwrap();
367        assert!((hit.u - 0.5).abs() < 1e-6);
368        assert!((hit.v - 0.5).abs() < 1e-6);
369    }
370
371    #[test]
372    fn ray_hits_quad_edges_with_visual_y_mapping() {
373        let quad = RayQuad::new(
374            Vec3::new(0.0, 0.0, 0.0),
375            Vec3::new(0.0, 0.0, 1.0),
376            Vec3::new(1.0, 0.0, 0.0),
377            Vec3::new(0.0, 1.0, 0.0),
378            0.5,
379            0.5,
380        );
381        let top_left = quad
382            .intersect(Vec3::new(-0.5, 0.5, -1.0), Vec3::new(0.0, 0.0, 1.0))
383            .unwrap();
384        let point = uv_to_logical(top_left.u, top_left.v, 100.0, 50.0);
385        assert!((point.x - 0.0).abs() < 1e-6);
386        assert!((point.y - 0.0).abs() < 1e-6);
387
388        let bottom_right = quad
389            .intersect(Vec3::new(0.5, -0.5, -1.0), Vec3::new(0.0, 0.0, 1.0))
390            .unwrap();
391        let point = uv_to_logical(bottom_right.u, bottom_right.v, 100.0, 50.0);
392        assert!((point.x - 100.0).abs() < 1e-6);
393        assert!((point.y - 50.0).abs() < 1e-6);
394    }
395
396    #[test]
397    fn ray_misses_quad_bounds() {
398        let quad = RayQuad::new(
399            Vec3::new(0.0, 0.0, 0.0),
400            Vec3::new(0.0, 0.0, 1.0),
401            Vec3::new(1.0, 0.0, 0.0),
402            Vec3::new(0.0, 1.0, 0.0),
403            0.5,
404            0.5,
405        );
406        let hit = quad.intersect(Vec3::new(2.0, 2.0, -1.0), Vec3::new(0.0, 0.0, 1.0));
407        assert!(hit.is_none());
408    }
409}