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}