Skip to main content

oxiphysics_core/math/
geometry.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop)]
6use super::linear_algebra::*;
7#[cfg(test)]
8use super::types::*;
9
10/// Extract ZYX Euler angles (yaw, pitch, roll) from a rotation matrix.
11///
12/// Returns `(roll, pitch, yaw)` in radians, same convention as
13/// [`quat_to_euler`].
14///
15/// Assumes the matrix is a proper rotation (no scaling / reflection).
16#[allow(dead_code)]
17pub fn mat3_to_euler_zyx(m: &Mat3) -> (Real, Real, Real) {
18    let pitch = (-m[(2, 0)]).clamp(-1.0, 1.0).asin();
19    let cos_pitch = pitch.cos();
20    if cos_pitch.abs() < 1e-6 {
21        let roll = 0.0;
22        let yaw = m[(0, 1)].atan2(m[(1, 1)]);
23        (roll, pitch, yaw)
24    } else {
25        let roll = m[(2, 1)].atan2(m[(2, 2)]);
26        let yaw = m[(1, 0)].atan2(m[(0, 0)]);
27        (roll, pitch, yaw)
28    }
29}
30/// Build a rotation matrix from ZYX Euler angles (roll, pitch, yaw) in radians.
31///
32/// Equivalent to `Rz(yaw) * Ry(pitch) * Rx(roll)`.
33#[allow(dead_code)]
34pub fn mat3_from_euler_zyx(roll: Real, pitch: Real, yaw: Real) -> Mat3 {
35    let (cr, sr) = (roll.cos(), roll.sin());
36    let (cp, sp) = (pitch.cos(), pitch.sin());
37    let (cy, sy) = (yaw.cos(), yaw.sin());
38    Mat3::new(
39        cy * cp,
40        cy * sp * sr - sy * cr,
41        cy * sp * cr + sy * sr,
42        sy * cp,
43        sy * sp * sr + cy * cr,
44        sy * sp * cr - cy * sr,
45        -sp,
46        cp * sr,
47        cp * cr,
48    )
49}
50/// Cofactor matrix of a 3×3 matrix.
51///
52/// `C[i][j] = (-1)^{i+j} * M_{ij}` where `M_{ij}` is the (i,j) minor.
53#[allow(dead_code)]
54pub fn mat3_cofactor(m: &Mat3) -> Mat3 {
55    let c = |r: usize, c_idx: usize| -> Real {
56        let rows: [usize; 2] = match r {
57            0 => [1, 2],
58            1 => [0, 2],
59            _ => [0, 1],
60        };
61        let cols: [usize; 2] = match c_idx {
62            0 => [1, 2],
63            1 => [0, 2],
64            _ => [0, 1],
65        };
66        let det2 = m[(rows[0], cols[0])] * m[(rows[1], cols[1])]
67            - m[(rows[0], cols[1])] * m[(rows[1], cols[0])];
68        let sign = if (r + c_idx).is_multiple_of(2) {
69            1.0
70        } else {
71            -1.0
72        };
73        sign * det2
74    };
75    Mat3::new(
76        c(0, 0),
77        c(0, 1),
78        c(0, 2),
79        c(1, 0),
80        c(1, 1),
81        c(1, 2),
82        c(2, 0),
83        c(2, 1),
84        c(2, 2),
85    )
86}
87/// Adjugate of a nalgebra `Mat3` (transpose of the cofactor matrix).
88///
89/// `adj(M) = cofactor(M)^T = det(M) * M^{-1}` when M is invertible.
90#[allow(dead_code)]
91pub fn mat3_adjugate_na(m: &Mat3) -> Mat3 {
92    mat3_cofactor(m).transpose()
93}
94/// Build a unit quaternion that rotates `from` onto `to`.
95///
96/// Both `from` and `to` must be unit vectors.  Returns the identity for
97/// anti-parallel inputs (180° rotation around a perpendicular axis).
98#[allow(dead_code)]
99pub fn quat_from_two_vectors(from: &Vec3, to: &Vec3) -> Quat {
100    let dot = from.dot(to).clamp(-1.0, 1.0);
101    if dot > 1.0 - 1e-10 {
102        return Quat::identity();
103    }
104    if dot < -1.0 + 1e-10 {
105        let perp = if from.x.abs() < 0.9 {
106            Vec3::new(1.0, 0.0, 0.0).cross(from)
107        } else {
108            Vec3::new(0.0, 1.0, 0.0).cross(from)
109        };
110        let axis = Unit::new_normalize(perp);
111        return UnitQuaternion::from_axis_angle(&axis, std::f64::consts::PI);
112    }
113    let axis = from.cross(to);
114    let w = 1.0 + dot;
115    let len = (w * w + axis.norm_squared()).sqrt();
116    let [x, y, z] = [axis.x / len, axis.y / len, axis.z / len];
117    UnitQuaternion::new_normalize(nalgebra::Quaternion::new(w / len, x, y, z))
118}
119/// Quaternion logarithm of a `UnitQuaternion`.
120///
121/// Returns a pure quaternion `[θ*nx, θ*ny, θ*nz, 0]` where `θ*n̂` is the
122/// rotation axis-angle.
123#[allow(dead_code)]
124pub fn quat_log(q: &Quat) -> nalgebra::Quaternion<Real> {
125    let w = q.w.clamp(-1.0, 1.0);
126    let theta = w.acos();
127    let sin_t = theta.sin();
128    if sin_t.abs() < 1e-10 {
129        return nalgebra::Quaternion::new(0.0, 0.0, 0.0, 0.0);
130    }
131    let s = theta / sin_t;
132    nalgebra::Quaternion::new(0.0, q.i * s, q.j * s, q.k * s)
133}
134/// Quaternion exponential of a pure quaternion `v` (w component ignored).
135///
136/// Returns a unit quaternion `exp(v) = [sin(|v|)*v̂, cos(|v|)]`.
137#[allow(dead_code)]
138pub fn quat_exp(v: &nalgebra::Quaternion<Real>) -> Quat {
139    let norm = (v.i * v.i + v.j * v.j + v.k * v.k).sqrt();
140    if norm < 1e-15 {
141        return Quat::identity();
142    }
143    let s = norm.sin() / norm;
144    UnitQuaternion::new_normalize(nalgebra::Quaternion::new(
145        norm.cos(),
146        v.i * s,
147        v.j * s,
148        v.k * s,
149    ))
150}
151/// Ensure double-cover consistency: negate `q` if its w < 0 so that the
152/// canonical representative always has w ≥ 0.
153#[allow(dead_code)]
154pub fn quat_double_cover_fix(q: Quat) -> Quat {
155    if q.w < 0.0 {
156        UnitQuaternion::new_normalize(-q.into_inner())
157    } else {
158        q
159    }
160}
161/// Spherical cubic interpolation (SQUAD) between two `UnitQuaternion`s.
162///
163/// Interpolates between `q1` and `q2` at `t ∈ [0,1]` using the
164/// inner control quaternions `s1` and `s2` for C1 continuity.
165#[allow(dead_code)]
166pub fn quat_squad_na(q1: &Quat, q2: &Quat, s1: &Quat, s2: &Quat, t: Real) -> Quat {
167    let slerp_q = q1.slerp(q2, t);
168    let slerp_s = s1.slerp(s2, t);
169    slerp_q.slerp(&slerp_s, 2.0 * t * (1.0 - t))
170}
171#[cfg(test)]
172mod extended_math_tests {
173    use super::*;
174    use crate::Mat3;
175    use crate::Vec3;
176
177    use crate::math::mat3_from_axis_angle;
178
179    use crate::math::quat_from_axis_angle;
180
181    use crate::math::vec3_refract;
182
183    #[test]
184    fn test_vec3_project_onto_x_axis() {
185        let v = Vec3::new(3.0, 4.0, 5.0);
186        let x = Vec3::new(1.0, 0.0, 0.0);
187        let p = vec3_project_onto(&v, &x);
188        assert!((p.x - 3.0).abs() < 1e-12, "x={}", p.x);
189        assert!(p.y.abs() < 1e-12);
190        assert!(p.z.abs() < 1e-12);
191    }
192    #[test]
193    fn test_vec3_reject_from_perpendicular() {
194        let v = Vec3::new(3.0, 4.0, 0.0);
195        let x = Vec3::new(1.0, 0.0, 0.0);
196        let r = vec3_reject_from(&v, &x);
197        assert!(r.x.abs() < 1e-12, "x={}", r.x);
198        assert!((r.y - 4.0).abs() < 1e-12, "y={}", r.y);
199    }
200    #[test]
201    fn test_vec3_reflect_about_normal() {
202        let v = Vec3::new(1.0, -1.0, 0.0);
203        let n = Vec3::new(0.0, 1.0, 0.0);
204        let r = vec3_reflect_about(&v, &n);
205        assert!((r.x - 1.0).abs() < 1e-12);
206        assert!((r.y - 1.0).abs() < 1e-12);
207        assert!(r.z.abs() < 1e-12);
208    }
209    #[test]
210    fn test_vec3_refract_no_total_internal_reflection() {
211        let v = Vec3::new(0.0, -1.0, 0.0);
212        let n = Vec3::new(0.0, 1.0, 0.0);
213        let refr = vec3_refract(&v, &n, 1.0).expect("should not TIR");
214        assert!((refr.x).abs() < 1e-10);
215        assert!((refr.y + 1.0).abs() < 1e-10);
216    }
217    #[test]
218    fn test_vec3_refract_total_internal_reflection() {
219        let v = Vec3::new(0.9999, -0.0141, 0.0).normalize();
220        let n = Vec3::new(0.0, 1.0, 0.0);
221        let result = vec3_refract(&v, &n, 1.5);
222        if let Some(r) = result {
223            assert!(
224                (r.norm() - 1.0).abs() < 1e-8,
225                "refracted not unit: {}",
226                r.norm()
227            );
228        }
229    }
230    #[test]
231    fn test_vec3_angle_between_perpendicular() {
232        let a = Vec3::new(1.0, 0.0, 0.0);
233        let b = Vec3::new(0.0, 1.0, 0.0);
234        let angle = vec3_angle_between(&a, &b);
235        assert!(
236            (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-10,
237            "angle={}",
238            angle
239        );
240    }
241    #[test]
242    fn test_vec3_angle_between_parallel() {
243        let a = Vec3::new(1.0, 0.0, 0.0);
244        let angle = vec3_angle_between(&a, &a);
245        assert!(angle.abs() < 1e-10, "angle={}", angle);
246    }
247    #[test]
248    fn test_vec3_rotate_by_quat() {
249        let v = Vec3::new(1.0, 0.0, 0.0);
250        let q = quat_from_axis_angle(&Vec3::new(0.0, 0.0, 1.0), std::f64::consts::FRAC_PI_2);
251        let r = vec3_rotate_by_quat(&v, &q);
252        assert!(r.x.abs() < 1e-10, "x={}", r.x);
253        assert!((r.y - 1.0).abs() < 1e-10, "y={}", r.y);
254        assert!(r.z.abs() < 1e-10, "z={}", r.z);
255    }
256    #[test]
257    fn test_mat3_from_axis_angle_identity_at_zero() {
258        let m = mat3_from_axis_angle(&Vec3::new(0.0, 0.0, 1.0), 0.0);
259        let diff = (m - Mat3::identity()).norm();
260        assert!(diff < 1e-10, "diff={}", diff);
261    }
262    #[test]
263    fn test_mat3_from_axis_angle_90z() {
264        let m = mat3_from_axis_angle(&Vec3::new(0.0, 0.0, 1.0), std::f64::consts::FRAC_PI_2);
265        let v = m * Vec3::new(1.0, 0.0, 0.0);
266        assert!(v.x.abs() < 1e-10, "x={}", v.x);
267        assert!((v.y - 1.0).abs() < 1e-10, "y={}", v.y);
268        assert!(v.z.abs() < 1e-10, "z={}", v.z);
269    }
270    #[test]
271    fn test_mat3_euler_roundtrip() {
272        let roll = 0.4_f64;
273        let pitch = 0.2_f64;
274        let yaw = -0.3_f64;
275        let m = mat3_from_euler_zyx(roll, pitch, yaw);
276        let (r2, p2, y2) = mat3_to_euler_zyx(&m);
277        assert!((r2 - roll).abs() < 1e-8, "roll: {} vs {}", r2, roll);
278        assert!((p2 - pitch).abs() < 1e-8, "pitch: {} vs {}", p2, pitch);
279        assert!((y2 - yaw).abs() < 1e-8, "yaw: {} vs {}", y2, yaw);
280    }
281    #[test]
282    fn test_mat3_cofactor_identity() {
283        let m = Mat3::identity();
284        let c = mat3_cofactor(&m);
285        let diff = (c - Mat3::identity()).norm();
286        assert!(diff < 1e-10, "cofactor(I) should be I: diff={}", diff);
287    }
288    #[test]
289    fn test_mat3_adjugate_na_relation() {
290        let m = Mat3::new(1.0, 2.0, 0.0, 0.0, 1.0, 3.0, 0.0, 0.0, 1.0);
291        let adj = mat3_adjugate_na(&m);
292        let det = m.determinant();
293        let prod = m * adj;
294        let expected = Mat3::identity() * det;
295        let diff = (prod - expected).norm();
296        assert!(diff < 1e-8, "M*adj(M) diff={}", diff);
297    }
298    #[test]
299    fn test_quat_from_two_vectors_basic() {
300        let from = Vec3::new(1.0, 0.0, 0.0);
301        let to = Vec3::new(0.0, 1.0, 0.0);
302        let q = quat_from_two_vectors(&from, &to);
303        let rotated = q.transform_vector(&from);
304        assert!((rotated.x).abs() < 1e-8, "x={}", rotated.x);
305        assert!((rotated.y - 1.0).abs() < 1e-8, "y={}", rotated.y);
306    }
307    #[test]
308    fn test_quat_from_two_vectors_same_direction() {
309        let from = Vec3::new(0.0, 0.0, 1.0);
310        let q = quat_from_two_vectors(&from, &from);
311        let rotated = q.transform_vector(&from);
312        assert!((rotated - from).norm() < 1e-8);
313    }
314    #[test]
315    fn test_quat_log_exp_roundtrip_na() {
316        let q = quat_from_axis_angle(&Vec3::new(1.0, 0.0, 0.0), 1.2);
317        let log_q = quat_log(&q);
318        let exp_q = quat_exp(&log_q);
319        let diff = q.angle_to(&exp_q);
320        assert!(diff < 1e-8, "log/exp roundtrip angle diff={}", diff);
321    }
322    #[test]
323    fn test_quat_double_cover_fix_positive_w() {
324        let q = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.5);
325        let fixed = quat_double_cover_fix(q);
326        assert!(fixed.w >= 0.0, "w should be non-negative after fix");
327    }
328    #[test]
329    fn test_quat_double_cover_fix_negative_w() {
330        let q = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.5);
331        let neg = UnitQuaternion::new_normalize(-q.into_inner());
332        let fixed = quat_double_cover_fix(neg);
333        assert!(fixed.w >= 0.0, "w should be non-negative after fix");
334    }
335    #[test]
336    fn test_quat_squad_na_endpoints() {
337        let q1 = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.0);
338        let q2 = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 1.0);
339        let r0 = quat_squad_na(&q1, &q2, &q1, &q2, 0.0);
340        let r1 = quat_squad_na(&q1, &q2, &q1, &q2, 1.0);
341        assert!(r0.angle_to(&q1) < 1e-6, "t=0 should be q1");
342        assert!(r1.angle_to(&q2) < 1e-6, "t=1 should be q2");
343    }
344    #[test]
345    fn test_plane_intersect_ray_method() {
346        let plane = Plane::new(Vec3::new(0.0, 1.0, 0.0), 0.0);
347        let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
348        let t = plane.intersect_ray(&ray).expect("should intersect");
349        assert!((t - 2.0).abs() < 1e-10, "t={}", t);
350    }
351    #[test]
352    fn test_plane_intersect_ray_parallel() {
353        let plane = Plane::new(Vec3::new(0.0, 1.0, 0.0), 0.0);
354        let ray = Ray::new(Vec3::new(0.0, 1.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
355        assert!(plane.intersect_ray(&ray).is_none());
356    }
357    #[test]
358    fn test_frustum_contains_sphere_small() {
359        let vp = perspective(std::f64::consts::FRAC_PI_2, 1.0, 1.0, 100.0);
360        let frustum = Frustum::from_view_projection(&vp);
361        let _result = frustum.contains_sphere(&Vec3::new(0.0, 0.0, 0.0), 0.01);
362    }
363    #[test]
364    fn test_frustum_intersects_aabb_basic() {
365        let vp = perspective(std::f64::consts::FRAC_PI_2, 1.0, 1.0, 100.0);
366        let frustum = Frustum::from_view_projection(&vp);
367        let aabb_far = Aabb::new([1000.0, 1000.0, 1000.0], [1001.0, 1001.0, 1001.0]);
368        let _ = frustum.intersects_aabb(&aabb_far);
369    }
370    #[test]
371    fn test_frustum_extract_from_view_proj_same_as_constructor() {
372        let vp = perspective(std::f64::consts::FRAC_PI_4, 1.0, 0.5, 50.0);
373        let f1 = Frustum::from_view_projection(&vp);
374        let f2 = Frustum::extract_from_view_proj(&vp);
375        for (p1, p2) in f1.planes.iter().zip(f2.planes.iter()) {
376            assert!((p1.normal - p2.normal).norm() < 1e-10);
377            assert!((p1.distance - p2.distance).abs() < 1e-10);
378        }
379    }
380    #[test]
381    fn test_aabb_merge() {
382        let a = Aabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
383        let b = Aabb::new([-1.0, 2.0, -1.0], [0.5, 3.0, 2.0]);
384        let m = a.merge(&b);
385        assert!((m.min[0] - (-1.0)).abs() < 1e-12);
386        assert!((m.min[1] - 0.0).abs() < 1e-12);
387        assert!((m.max[1] - 3.0).abs() < 1e-12);
388    }
389    #[test]
390    fn test_aabb_expand_by() {
391        let a = Aabb::new([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]);
392        let e = a.expand_by(1.0);
393        for i in 0..3 {
394            assert!((e.min[i] - (-1.0)).abs() < 1e-12);
395            assert!((e.max[i] - 3.0).abs() < 1e-12);
396        }
397    }
398    #[test]
399    fn test_aabb_closest_point_inside() {
400        let aabb = Aabb::new([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
401        let p = [2.0, 2.0, 2.0];
402        let cp = aabb.closest_point(p);
403        for i in 0..3 {
404            assert!((cp[i] - 2.0).abs() < 1e-12);
405        }
406    }
407    #[test]
408    fn test_aabb_closest_point_outside() {
409        let aabb = Aabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
410        let p = [-1.0, 0.5, 2.0];
411        let cp = aabb.closest_point(p);
412        assert!((cp[0] - 0.0).abs() < 1e-12, "x={}", cp[0]);
413        assert!((cp[1] - 0.5).abs() < 1e-12, "y={}", cp[1]);
414        assert!((cp[2] - 1.0).abs() < 1e-12, "z={}", cp[2]);
415    }
416    #[test]
417    fn test_aabb_contains_point_strict() {
418        let aabb = Aabb::new([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]);
419        assert!(aabb.contains_point_strict([1.0, 1.0, 1.0]));
420        assert!(!aabb.contains_point_strict([0.0, 1.0, 1.0]));
421        assert!(!aabb.contains_point_strict([3.0, 1.0, 1.0]));
422    }
423    #[test]
424    fn test_aabb_contains_point_on_boundary() {
425        let aabb = Aabb::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
426        assert!(aabb.contains([0.0, 0.5, 0.5]));
427        assert!(aabb.contains([1.0, 0.5, 0.5]));
428    }
429    #[test]
430    fn test_aabb_ray_intersect_extended() {
431        let aabb = Aabb::new([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
432        let hit = aabb.intersect_ray([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
433        assert!(hit.is_some(), "should hit unit cube");
434        let (t0, t1) = hit.unwrap();
435        assert!((t0 - 4.0).abs() < 1e-10, "t0={}", t0);
436        assert!((t1 - 6.0).abs() < 1e-10, "t1={}", t1);
437    }
438}
439#[cfg(test)]
440mod new_math_tests {
441
442    use crate::Mat3;
443    use crate::Vec3;
444
445    use crate::math::cartesian_to_cylindrical;
446    use crate::math::cartesian_to_spherical;
447    use crate::math::cross_matrix;
448    use crate::math::cylindrical_to_cartesian;
449
450    use crate::math::gram_schmidt;
451
452    use crate::math::mat3_exp_skew;
453    use crate::math::mat3_from_axis_angle;
454
455    use crate::math::mat3_log_rotation;
456
457    use crate::math::quat_from_axis_angle;
458    use crate::math::quat_geodesic_distance;
459
460    use crate::math::quat_nlerp;
461    use crate::math::quat_squad_control;
462    use crate::math::rodrigues_rotate;
463
464    use crate::math::skew_symmetric;
465    use crate::math::spherical_to_cartesian;
466
467    use crate::math::vec3_outer_product;
468
469    #[test]
470    fn test_vec3_outer_product_basic() {
471        let a = Vec3::new(1.0, 2.0, 3.0);
472        let b = Vec3::new(4.0, 5.0, 6.0);
473        let op = vec3_outer_product(&a, &b);
474        assert!((op[(0, 0)] - 4.0).abs() < 1e-12);
475        assert!((op[(0, 1)] - 5.0).abs() < 1e-12);
476        assert!((op[(0, 2)] - 6.0).abs() < 1e-12);
477        assert!((op[(1, 0)] - 8.0).abs() < 1e-12);
478        assert!((op[(2, 2)] - 18.0).abs() < 1e-12);
479    }
480    #[test]
481    fn test_vec3_outer_product_unit_vectors() {
482        let e0 = Vec3::new(1.0, 0.0, 0.0);
483        let e1 = Vec3::new(0.0, 1.0, 0.0);
484        let op = vec3_outer_product(&e0, &e1);
485        assert!((op[(0, 1)] - 1.0).abs() < 1e-12);
486        assert!(op[(0, 0)].abs() < 1e-12);
487        assert!(op[(1, 0)].abs() < 1e-12);
488    }
489    #[test]
490    fn test_vec3_outer_product_trace() {
491        let a = Vec3::new(1.0, 2.0, 3.0);
492        let op = vec3_outer_product(&a, &a);
493        let trace = op.trace();
494        let norm_sq = a.norm_squared();
495        assert!(
496            (trace - norm_sq).abs() < 1e-10,
497            "trace={}, |a|²={}",
498            trace,
499            norm_sq
500        );
501    }
502    #[test]
503    fn test_cross_matrix_equals_skew_symmetric() {
504        let v = Vec3::new(2.0, -3.0, 1.0);
505        let cm = cross_matrix(&v);
506        let ss = skew_symmetric(&v);
507        let diff = (cm - ss).norm();
508        assert!(
509            diff < 1e-12,
510            "cross_matrix should equal skew_symmetric: diff={}",
511            diff
512        );
513    }
514    #[test]
515    fn test_cross_matrix_cross_product_equivalence() {
516        let a = Vec3::new(1.0, 2.0, 3.0);
517        let b = Vec3::new(-1.0, 0.5, 4.0);
518        let expected = a.cross(&b);
519        let actual = cross_matrix(&a) * b;
520        let diff = (expected - actual).norm();
521        assert!(
522            diff < 1e-10,
523            "cross matrix * b should equal a x b: diff={}",
524            diff
525        );
526    }
527    #[test]
528    fn test_gram_schmidt_orthonormality() {
529        let v0 = Vec3::new(1.0, 1.0, 0.0);
530        let v1 = Vec3::new(0.0, 1.0, 1.0);
531        let v2 = Vec3::new(1.0, 0.0, 1.0);
532        let (e0, e1, e2) = gram_schmidt(&v0, &v1, &v2).expect("should succeed");
533        assert!(
534            (e0.norm() - 1.0).abs() < 1e-10,
535            "e0 not unit: {}",
536            e0.norm()
537        );
538        assert!(
539            (e1.norm() - 1.0).abs() < 1e-10,
540            "e1 not unit: {}",
541            e1.norm()
542        );
543        assert!(
544            (e2.norm() - 1.0).abs() < 1e-10,
545            "e2 not unit: {}",
546            e2.norm()
547        );
548        assert!(e0.dot(&e1).abs() < 1e-10, "e0·e1={}", e0.dot(&e1));
549        assert!(e0.dot(&e2).abs() < 1e-10, "e0·e2={}", e0.dot(&e2));
550        assert!(e1.dot(&e2).abs() < 1e-10, "e1·e2={}", e1.dot(&e2));
551    }
552    #[test]
553    fn test_gram_schmidt_span_preserved() {
554        let v0 = Vec3::new(1.0, 0.0, 0.0);
555        let v1 = Vec3::new(1.0, 1.0, 0.0);
556        let v2 = Vec3::new(1.0, 1.0, 1.0);
557        let (e0, e1, e2) = gram_schmidt(&v0, &v1, &v2).expect("should succeed");
558        assert!((e0.x - 1.0).abs() < 1e-10);
559        assert!(e0.y.abs() < 1e-10);
560        assert!(e2.z.abs() > 0.99, "e2.z={}", e2.z);
561        let _ = e1;
562    }
563    #[test]
564    fn test_gram_schmidt_degenerate_returns_none() {
565        let v0 = Vec3::new(1.0, 0.0, 0.0);
566        let v1 = Vec3::new(2.0, 0.0, 0.0);
567        let v2 = Vec3::new(0.0, 1.0, 0.0);
568        let result = gram_schmidt(&v0, &v1, &v2);
569        assert!(result.is_none(), "collinear vectors should return None");
570    }
571    #[test]
572    fn test_cartesian_to_spherical_z_axis() {
573        let v = Vec3::new(0.0, 0.0, 2.0);
574        let (r, theta, _phi) = cartesian_to_spherical(&v);
575        assert!((r - 2.0).abs() < 1e-10, "r={}", r);
576        assert!(theta.abs() < 1e-10, "theta={}", theta);
577    }
578    #[test]
579    fn test_cartesian_to_spherical_x_axis() {
580        let v = Vec3::new(3.0, 0.0, 0.0);
581        let (r, theta, phi) = cartesian_to_spherical(&v);
582        assert!((r - 3.0).abs() < 1e-10, "r={}", r);
583        assert!(
584            (theta - std::f64::consts::FRAC_PI_2).abs() < 1e-10,
585            "theta={}",
586            theta
587        );
588        assert!(phi.abs() < 1e-10, "phi={}", phi);
589    }
590    #[test]
591    fn test_spherical_cartesian_roundtrip() {
592        let v = Vec3::new(1.0, 2.0, 3.0);
593        let (r, theta, phi) = cartesian_to_spherical(&v);
594        let v2 = spherical_to_cartesian(r, theta, phi);
595        let diff = (v - v2).norm();
596        assert!(diff < 1e-10, "roundtrip diff={}", diff);
597    }
598    #[test]
599    fn test_spherical_to_cartesian_unit_radius() {
600        let v = spherical_to_cartesian(1.0, std::f64::consts::FRAC_PI_2, 0.0);
601        assert!((v.x - 1.0).abs() < 1e-10);
602        assert!(v.y.abs() < 1e-10);
603        assert!(v.z.abs() < 1e-10);
604    }
605    #[test]
606    fn test_cylindrical_roundtrip() {
607        let v = Vec3::new(1.0, 2.0, 3.0);
608        let (rho, phi, z) = cartesian_to_cylindrical(&v);
609        let v2 = cylindrical_to_cartesian(rho, phi, z);
610        let diff = (v - v2).norm();
611        assert!(diff < 1e-10, "cylindrical roundtrip diff={}", diff);
612    }
613    #[test]
614    fn test_cylindrical_z_preserved() {
615        let v = Vec3::new(1.0, 1.0, 5.0);
616        let (_, _, z) = cartesian_to_cylindrical(&v);
617        assert!((z - 5.0).abs() < 1e-10, "z={}", z);
618    }
619    #[test]
620    fn test_rodrigues_rotate_90z() {
621        let v = Vec3::new(1.0, 0.0, 0.0);
622        let axis = Vec3::new(0.0, 0.0, 1.0);
623        let r = rodrigues_rotate(&v, &axis, std::f64::consts::FRAC_PI_2);
624        assert!(r.x.abs() < 1e-10, "x={}", r.x);
625        assert!((r.y - 1.0).abs() < 1e-10, "y={}", r.y);
626        assert!(r.z.abs() < 1e-10, "z={}", r.z);
627    }
628    #[test]
629    fn test_rodrigues_rotate_180() {
630        let v = Vec3::new(1.0, 0.0, 0.0);
631        let axis = Vec3::new(0.0, 1.0, 0.0);
632        let r = rodrigues_rotate(&v, &axis, std::f64::consts::PI);
633        assert!((r.x + 1.0).abs() < 1e-10, "x={}", r.x);
634        assert!(r.y.abs() < 1e-10, "y={}", r.y);
635        assert!(r.z.abs() < 1e-10, "z={}", r.z);
636    }
637    #[test]
638    fn test_rodrigues_rotate_zero_angle() {
639        let v = Vec3::new(1.0, 2.0, 3.0);
640        let axis = Vec3::new(0.0, 1.0, 0.0);
641        let r = rodrigues_rotate(&v, &axis, 0.0);
642        let diff = (r - v).norm();
643        assert!(
644            diff < 1e-10,
645            "zero rotation should leave vector unchanged: diff={}",
646            diff
647        );
648    }
649    #[test]
650    fn test_rodrigues_rotate_preserves_magnitude() {
651        let v = Vec3::new(1.0, 2.0, 3.0);
652        let axis = Vec3::new(0.0, 0.0, 1.0);
653        let r = rodrigues_rotate(&v, &axis, 1.234);
654        let diff = (r.norm() - v.norm()).abs();
655        assert!(
656            diff < 1e-10,
657            "rotation should preserve magnitude: diff={}",
658            diff
659        );
660    }
661    #[test]
662    fn test_mat3_exp_skew_zero() {
663        let omega = Vec3::zeros();
664        let r = mat3_exp_skew(&omega);
665        let diff = (r - Mat3::identity()).norm();
666        assert!(diff < 1e-10, "exp(0) should be identity: diff={}", diff);
667    }
668    #[test]
669    fn test_mat3_exp_skew_90z() {
670        let omega = Vec3::new(0.0, 0.0, std::f64::consts::FRAC_PI_2);
671        let r = mat3_exp_skew(&omega);
672        let v = r * Vec3::new(1.0, 0.0, 0.0);
673        assert!(v.x.abs() < 1e-10, "x={}", v.x);
674        assert!((v.y - 1.0).abs() < 1e-10, "y={}", v.y);
675    }
676    #[test]
677    fn test_mat3_exp_skew_is_rotation() {
678        let omega = Vec3::new(0.3, -0.5, 0.7);
679        let r = mat3_exp_skew(&omega);
680        let det = r.determinant();
681        let rrt = r * r.transpose();
682        let diff = (rrt - Mat3::identity()).norm();
683        assert!((det - 1.0).abs() < 1e-8, "det={}", det);
684        assert!(diff < 1e-8, "R R^T should be I: diff={}", diff);
685    }
686    #[test]
687    fn test_mat3_log_rotation_identity() {
688        let r = Mat3::identity();
689        let log = mat3_log_rotation(&r);
690        let diff = log.norm();
691        assert!(diff < 1e-10, "log(I) should be zero: norm={}", diff);
692    }
693    #[test]
694    fn test_mat3_exp_log_roundtrip() {
695        let omega = Vec3::new(0.2, 0.4, -0.3);
696        let r = mat3_exp_skew(&omega);
697        let log_r = mat3_log_rotation(&r);
698        let omega2 = Vec3::new(log_r[(2, 1)], log_r[(0, 2)], log_r[(1, 0)]);
699        let r2 = mat3_exp_skew(&omega2);
700        let diff = (r - r2).norm();
701        assert!(diff < 1e-7, "exp/log roundtrip diff={}", diff);
702    }
703    #[test]
704    fn test_quat_nlerp_at_endpoints() {
705        let a = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.0);
706        let b = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 1.0);
707        let q0 = quat_nlerp(&a, &b, 0.0);
708        let q1 = quat_nlerp(&a, &b, 1.0);
709        assert!(q0.angle_to(&a) < 1e-8, "nlerp(t=0) should be a");
710        assert!(q1.angle_to(&b) < 1e-8, "nlerp(t=1) should be b");
711    }
712    #[test]
713    fn test_quat_nlerp_is_unit() {
714        let a = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.3);
715        let b = quat_from_axis_angle(&Vec3::new(1.0, 0.0, 0.0), 0.9);
716        for i in 0..=10 {
717            let t = i as f64 / 10.0;
718            let q = quat_nlerp(&a, &b, t);
719            let norm = q.into_inner().norm();
720            assert!(
721                (norm - 1.0).abs() < 1e-10,
722                "nlerp result not unit at t={}: norm={}",
723                t,
724                norm
725            );
726        }
727    }
728    #[test]
729    fn test_quat_squad_control_produces_unit_quat() {
730        let q0 = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.0);
731        let q1 = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.5);
732        let q2 = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 1.0);
733        let s = quat_squad_control(&q0, &q1, &q2);
734        let norm = s.into_inner().norm();
735        assert!(
736            (norm - 1.0).abs() < 1e-8,
737            "squad control should be unit quat: norm={}",
738            norm
739        );
740    }
741    #[test]
742    fn test_quat_geodesic_distance_same() {
743        let q = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.7);
744        let d = quat_geodesic_distance(&q, &q);
745        assert!(
746            d.abs() < 1e-10,
747            "geodesic distance to self should be 0: d={}",
748            d
749        );
750    }
751    #[test]
752    fn test_quat_geodesic_distance_known() {
753        let a = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.0);
754        let b = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), std::f64::consts::FRAC_PI_2);
755        let d = quat_geodesic_distance(&a, &b);
756        assert!((d - std::f64::consts::FRAC_PI_2).abs() < 1e-8, "d={}", d);
757    }
758    #[test]
759    fn test_outer_product_rank_one() {
760        let a = Vec3::new(1.0, 2.0, 3.0);
761        let b = Vec3::new(1.0, -1.0, 2.0);
762        let op = vec3_outer_product(&a, &b);
763        let det = op.determinant();
764        assert!(
765            det.abs() < 1e-10,
766            "outer product should have det=0: det={}",
767            det
768        );
769    }
770    #[test]
771    fn test_cross_matrix_antisymmetric() {
772        let v = Vec3::new(1.0, -2.0, 3.0);
773        let cm = cross_matrix(&v);
774        let diff = (cm + cm.transpose()).norm();
775        assert!(
776            diff < 1e-10,
777            "cross matrix should be antisymmetric: diff={}",
778            diff
779        );
780    }
781    #[test]
782    fn test_cross_matrix_zero_diagonal() {
783        let v = Vec3::new(1.0, 2.0, 3.0);
784        let cm = cross_matrix(&v);
785        assert!(cm[(0, 0)].abs() < 1e-12);
786        assert!(cm[(1, 1)].abs() < 1e-12);
787        assert!(cm[(2, 2)].abs() < 1e-12);
788    }
789    #[test]
790    fn test_cartesian_to_spherical_origin() {
791        let v = Vec3::zeros();
792        let (r, theta, phi) = cartesian_to_spherical(&v);
793        assert!(r.abs() < 1e-10, "r should be 0 for origin");
794        assert!(theta.abs() < 1e-10);
795        assert!(phi.abs() < 1e-10);
796    }
797    #[test]
798    fn test_spherical_coordinate_r_is_norm() {
799        let v = Vec3::new(2.0, 3.0, 6.0);
800        let (r, _, _) = cartesian_to_spherical(&v);
801        assert!((r - v.norm()).abs() < 1e-10, "r should equal |v|");
802    }
803    #[test]
804    fn test_gram_schmidt_axis_aligned() {
805        let e0 = Vec3::new(1.0, 0.0, 0.0);
806        let e1 = Vec3::new(0.0, 1.0, 0.0);
807        let e2 = Vec3::new(0.0, 0.0, 1.0);
808        let (g0, g1, g2) = gram_schmidt(&e0, &e1, &e2).expect("axis-aligned should succeed");
809        assert!((g0.dot(&e0).abs() - 1.0).abs() < 1e-10);
810        assert!((g1.dot(&e1).abs() - 1.0).abs() < 1e-10);
811        assert!((g2.dot(&e2).abs() - 1.0).abs() < 1e-10);
812    }
813    #[test]
814    fn test_mat3_log_rotation_90z() {
815        let omega = Vec3::new(0.0, 0.0, std::f64::consts::FRAC_PI_2);
816        let r = mat3_exp_skew(&omega);
817        let log_r = mat3_log_rotation(&r);
818        let recovered_angle = log_r[(1, 0)];
819        assert!(
820            (recovered_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-8,
821            "recovered angle={}",
822            recovered_angle
823        );
824    }
825    #[test]
826    fn test_rodrigues_matches_mat3_from_axis_angle() {
827        let axis = Vec3::new(0.0, 1.0, 0.0);
828        let angle = 0.8_f64;
829        let v = Vec3::new(1.0, 0.5, -0.3);
830        let r1 = rodrigues_rotate(&v, &axis, angle);
831        let r2 = mat3_from_axis_angle(&axis, angle) * v;
832        let diff = (r1 - r2).norm();
833        assert!(
834            diff < 1e-10,
835            "rodrigues_rotate should match mat3_from_axis_angle: diff={}",
836            diff
837        );
838    }
839    #[test]
840    fn test_quat_nlerp_symmetric() {
841        let a = quat_from_axis_angle(&Vec3::new(1.0, 0.0, 0.0), 0.3);
842        let b = quat_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.6);
843        let q1 = quat_nlerp(&a, &b, 0.3);
844        let q2 = quat_nlerp(&b, &a, 0.7);
845        let d = quat_geodesic_distance(&q1, &q2);
846        assert!(d < 1e-8, "nlerp should be symmetric: angle diff={}", d);
847    }
848}
849/// Project vector `v` onto unit direction `onto_unit`.
850///
851/// `onto_unit` is assumed to be normalised; the caller is responsible for
852/// ensuring that is the case.
853#[allow(dead_code)]
854pub fn vec3_project_onto_unit(v: &Vec3, onto_unit: &Vec3) -> Vec3 {
855    onto_unit * v.dot(onto_unit)
856}
857/// Reject (orthogonal complement) of `v` with respect to unit direction `u`:
858/// `reject(v, u) = v - project(v, u)`.
859#[allow(dead_code)]
860pub fn vec3_reject(v: &Vec3, onto_unit: &Vec3) -> Vec3 {
861    v - vec3_project_onto_unit(v, onto_unit)
862}
863/// Reflect vector `v` about unit normal `n`: `v - 2*(v·n)*n`.
864#[allow(dead_code)]
865pub fn vec3_reflect_about_normal(v: &Vec3, n: &Vec3) -> Vec3 {
866    v - n * (2.0 * v.dot(n))
867}
868/// Refract vector `v` (unit incident) through a surface with unit normal `n`.
869///
870/// `eta_ratio = eta_i / eta_t`.  Returns `None` for total internal reflection.
871/// This variant uses the `eta_ratio` parameter name convention.
872#[allow(dead_code)]
873pub fn vec3_refract_ratio(v: &Vec3, n: &Vec3, eta_ratio: Real) -> Option<Vec3> {
874    let cos_i = -v.dot(n);
875    let sin2_t = eta_ratio * eta_ratio * (1.0 - cos_i * cos_i);
876    if sin2_t > 1.0 {
877        return None;
878    }
879    let cos_t = (1.0 - sin2_t).sqrt();
880    Some(v * eta_ratio + n * (eta_ratio * cos_i - cos_t))
881}
882/// Component-wise minimum of two `Vec3` values.
883#[allow(dead_code)]
884pub fn vec3_min(a: &Vec3, b: &Vec3) -> Vec3 {
885    Vec3::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z))
886}
887/// Component-wise maximum of two `Vec3` values.
888#[allow(dead_code)]
889pub fn vec3_max(a: &Vec3, b: &Vec3) -> Vec3 {
890    Vec3::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z))
891}
892/// Component-wise clamp of `v` to `[lo, hi]`.
893#[allow(dead_code)]
894pub fn vec3_clamp(v: &Vec3, lo: Real, hi: Real) -> Vec3 {
895    Vec3::new(v.x.clamp(lo, hi), v.y.clamp(lo, hi), v.z.clamp(lo, hi))
896}
897/// Absolute value of each component of `v`.
898#[allow(dead_code)]
899pub fn vec3_abs(v: &Vec3) -> Vec3 {
900    Vec3::new(v.x.abs(), v.y.abs(), v.z.abs())
901}
902/// Signed angle (in radians) from `a` to `b` measured about `axis`.
903///
904/// All three vectors should be unit vectors; `axis` must be perpendicular to
905/// both `a` and `b` for the result to be meaningful.
906#[allow(dead_code)]
907pub fn vec3_signed_angle(a: &Vec3, b: &Vec3, axis: &Vec3) -> Real {
908    let cross = a.cross(b);
909    let sin_angle = cross.dot(axis);
910    let cos_angle = a.dot(b);
911    sin_angle.atan2(cos_angle)
912}
913/// Build an orthonormal basis `(tangent, bitangent, normal)` given a surface
914/// normal `n` (must be unit length).
915///
916/// Uses the Frisvad / Duff–Burgess–Christensen (2017) numerically stable
917/// construction.
918#[allow(dead_code)]
919pub fn build_orthonormal_basis(n: &Vec3) -> (Vec3, Vec3, Vec3) {
920    let sign = if n.z >= 0.0 { 1.0 } else { -1.0 };
921    let a = -1.0 / (sign + n.z);
922    let b = n.x * n.y * a;
923    let t = Vec3::new(1.0 + sign * n.x * n.x * a, sign * b, -sign * n.x);
924    let bt = Vec3::new(b, sign + n.y * n.y * a, -n.y);
925    (t, bt, *n)
926}
927/// Decompose a `Vec3` into components parallel and perpendicular to `axis`
928/// (which must be a unit vector).
929///
930/// Returns `(parallel, perpendicular)` such that `parallel + perpendicular == v`.
931#[allow(dead_code)]
932pub fn vec3_decompose(v: &Vec3, axis: &Vec3) -> (Vec3, Vec3) {
933    let parallel = vec3_project_onto_unit(v, axis);
934    let perp = v - parallel;
935    (parallel, perp)
936}
937/// Scalar triple product `a · (b × c)`.
938#[allow(dead_code)]
939pub fn vec3_scalar_triple(a: &Vec3, b: &Vec3, c: &Vec3) -> Real {
940    a.dot(&b.cross(c))
941}
942/// Vector triple product `a × (b × c) = b*(a·c) - c*(a·b)`.
943#[allow(dead_code)]
944pub fn vec3_vector_triple(a: &Vec3, b: &Vec3, c: &Vec3) -> Vec3 {
945    b * a.dot(c) - c * a.dot(b)
946}
947/// Distance from point `p` to the line defined by `origin` and unit `dir`.
948#[allow(dead_code)]
949pub fn point_to_line_distance(p: &Vec3, origin: &Vec3, dir: &Vec3) -> Real {
950    let dp = p - origin;
951    vec3_reject(&dp, dir).norm()
952}
953/// Closest point on line (`origin` + t * `dir`) to point `p`.
954/// `dir` should be a unit vector.
955#[allow(dead_code)]
956pub fn closest_point_on_line(p: &Vec3, origin: &Vec3, dir: &Vec3) -> Vec3 {
957    let t = (p - origin).dot(dir);
958    origin + dir * t
959}
960/// Closest point on segment `a`–`b` to point `p`.
961#[allow(dead_code)]
962pub fn closest_point_on_segment(p: &Vec3, a: &Vec3, b: &Vec3) -> Vec3 {
963    let ab = b - a;
964    let ab_len2 = ab.norm_squared();
965    if ab_len2 < 1e-30 {
966        return *a;
967    }
968    let t = ((p - a).dot(&ab) / ab_len2).clamp(0.0, 1.0);
969    a + ab * t
970}
971/// Distance from point `p` to segment `a`–`b`.
972#[allow(dead_code)]
973pub fn point_to_segment_distance(p: &Vec3, a: &Vec3, b: &Vec3) -> Real {
974    (p - closest_point_on_segment(p, a, b)).norm()
975}
976/// Build a rotation matrix that takes unit vector `from` to unit vector `to`.
977///
978/// Uses Rodrigues' formula via the cross-product axis.
979/// For anti-parallel vectors it returns a 180° rotation about an arbitrary
980/// perpendicular axis.
981#[allow(dead_code)]
982pub fn mat3_rotation_from_to(from: &Vec3, to: &Vec3) -> Mat3 {
983    let dot = from.dot(to).clamp(-1.0, 1.0);
984    if (dot - 1.0).abs() < 1e-12 {
985        return Mat3::identity();
986    }
987    if (dot + 1.0).abs() < 1e-6 {
988        let perp = if from.x.abs() < 0.9 {
989            Vec3::new(0.0, -from.z, from.y).normalize()
990        } else {
991            Vec3::new(-from.z, 0.0, from.x).normalize()
992        };
993        return 2.0 * perp * perp.transpose() - Mat3::identity();
994    }
995    let axis = from.cross(to);
996    let axis_len = axis.norm();
997    if axis_len < 1e-10 {
998        let perp = if from.x.abs() < 0.9 {
999            Vec3::new(0.0, -from.z, from.y).normalize()
1000        } else {
1001            Vec3::new(-from.z, 0.0, from.x).normalize()
1002        };
1003        return 2.0 * perp * perp.transpose() - Mat3::identity();
1004    }
1005    let angle = dot.acos();
1006    mat3_from_axis_angle(&(axis / axis_len), angle)
1007}
1008/// Extract the rotation angle from a 3×3 rotation matrix.
1009///
1010/// Returns the angle in `[0, π]`.
1011#[allow(dead_code)]
1012pub fn mat3_rotation_angle(r: &Mat3) -> Real {
1013    let tr = r.trace().clamp(-1.0, 3.0);
1014    ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos()
1015}
1016/// Extract the rotation axis (unit vector) from a 3×3 rotation matrix.
1017///
1018/// Returns `None` for identity or 180° rotations where the axis is
1019/// ill-defined; otherwise returns the normalised axis.
1020#[allow(dead_code)]
1021pub fn mat3_rotation_axis(r: &Mat3) -> Option<Vec3> {
1022    let s = Vec3::new(
1023        r[(2, 1)] - r[(1, 2)],
1024        r[(0, 2)] - r[(2, 0)],
1025        r[(1, 0)] - r[(0, 1)],
1026    );
1027    let len = s.norm();
1028    if len < 1e-10 {
1029        return None;
1030    }
1031    Some(s / len)
1032}
1033/// Compose two rotation matrices: `r_total = r2 * r1` (first apply r1, then r2).
1034#[allow(dead_code)]
1035pub fn mat3_compose_rotations(r1: &Mat3, r2: &Mat3) -> Mat3 {
1036    r2 * r1
1037}
1038/// Interpolate between two rotation matrices using matrix slerp.
1039///
1040/// Computes `r1 * exp(t * log(r1^T * r2))`.
1041#[allow(dead_code)]
1042pub fn mat3_slerp(r1: &Mat3, r2: &Mat3, t: Real) -> Mat3 {
1043    let r_rel = r1.transpose() * r2;
1044    let log_r_rel = mat3_log_rotation(&r_rel);
1045    let scaled_log = log_r_rel * t;
1046    let omega = Vec3::new(scaled_log[(2, 1)], scaled_log[(0, 2)], scaled_log[(1, 0)]);
1047    r1 * mat3_exp_skew(&omega)
1048}
1049/// Compute the average of multiple rotation matrices using the iterative
1050/// geodesic mean (Moakher 2002).  `weights` must be non-negative and sum to 1.
1051#[allow(dead_code)]
1052pub fn mat3_weighted_mean(rotations: &[Mat3], weights: &[f64], max_iter: usize) -> Mat3 {
1053    assert_eq!(rotations.len(), weights.len());
1054    if rotations.is_empty() {
1055        return Mat3::identity();
1056    }
1057    let mut r_mean = rotations[0];
1058    for _ in 0..max_iter {
1059        let mut delta_sum = Mat3::zeros();
1060        for (r, &w) in rotations.iter().zip(weights.iter()) {
1061            let log = mat3_log_rotation(&(r_mean.transpose() * r));
1062            delta_sum += log * w;
1063        }
1064        if delta_sum.norm() < 1e-12 {
1065            break;
1066        }
1067        let omega = Vec3::new(delta_sum[(2, 1)], delta_sum[(0, 2)], delta_sum[(1, 0)]);
1068        r_mean *= mat3_exp_skew(&omega);
1069    }
1070    r_mean
1071}
1072/// Convert a unit quaternion `[x,y,z,w]` to its conjugate.
1073#[allow(dead_code)]
1074pub fn quat_arr_conjugate(q: [f64; 4]) -> [f64; 4] {
1075    [-q[0], -q[1], -q[2], q[3]]
1076}
1077/// Normalize a quaternion `[x,y,z,w]` to unit length.
1078#[allow(dead_code)]
1079pub fn quat_arr_normalize(q: [f64; 4]) -> [f64; 4] {
1080    let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
1081    if len < 1e-30 {
1082        return [0.0, 0.0, 0.0, 1.0];
1083    }
1084    [q[0] / len, q[1] / len, q[2] / len, q[3] / len]
1085}
1086/// Dot product of two quaternions (as 4-vectors).
1087#[allow(dead_code)]
1088pub fn quat_arr_dot(p: [f64; 4], q: [f64; 4]) -> f64 {
1089    p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]
1090}
1091/// Power of a unit quaternion: `q^t = exp(t * log(q))`.
1092#[allow(dead_code)]
1093pub fn quat_arr_pow(q: [f64; 4], t: f64) -> [f64; 4] {
1094    let log_q = quat_arr_log(q);
1095    let scaled = [log_q[0] * t, log_q[1] * t, log_q[2] * t, 0.0];
1096    quat_arr_exp(scaled)
1097}
1098/// Rotate a 3-vector by a unit quaternion `q = [x,y,z,w]` using
1099/// the sandwich product `q * v * q^*`.
1100#[allow(dead_code)]
1101pub fn quat_arr_rotate_vec3(q: [f64; 4], v: [f64; 3]) -> [f64; 3] {
1102    let [qx, qy, qz, qw] = q;
1103    let [vx, vy, vz] = v;
1104    let tx = 2.0 * (qy * vz - qz * vy);
1105    let ty = 2.0 * (qz * vx - qx * vz);
1106    let tz = 2.0 * (qx * vy - qy * vx);
1107    [
1108        vx + qw * tx + qy * tz - qz * ty,
1109        vy + qw * ty + qz * tx - qx * tz,
1110        vz + qw * tz + qx * ty - qy * tx,
1111    ]
1112}
1113/// Double-geodesic (SQUAD) interpolation at midpoint between two key frames.
1114///
1115/// Given quaternions `q_prev`, `q_curr`, `q_next`, compute the SQUAD inner
1116/// control point `s` such that the curve is C1 at `q_curr`.
1117///
1118/// `s = q_curr * exp(-0.25 * (log(conj(q_curr)*q_next) + log(conj(q_curr)*q_prev)))`
1119#[allow(dead_code)]
1120pub fn quat_arr_squad_control(q_prev: [f64; 4], q_curr: [f64; 4], q_next: [f64; 4]) -> [f64; 4] {
1121    let conj_curr = quat_arr_conjugate(q_curr);
1122    let log_a = quat_arr_log(quat_multiply(conj_curr, q_next));
1123    let log_b = quat_arr_log(quat_multiply(conj_curr, q_prev));
1124    let sum = [
1125        (log_a[0] + log_b[0]) * (-0.25),
1126        (log_a[1] + log_b[1]) * (-0.25),
1127        (log_a[2] + log_b[2]) * (-0.25),
1128        0.0,
1129    ];
1130    quat_multiply(q_curr, quat_arr_exp(sum))
1131}
1132/// Gram-Schmidt orthonormalization of up to `n` vectors stored in `vecs`.
1133///
1134/// Returns a new vector of orthonormal vectors.  Linearly-dependent vectors
1135/// produce a near-zero output and are replaced by a vector from the standard
1136/// basis so the output always spans the same subspace dimension.
1137#[allow(dead_code)]
1138pub fn gram_schmidt_vectors(vecs: &[Vec3]) -> Vec<Vec3> {
1139    let mut result: Vec<Vec3> = Vec::with_capacity(vecs.len());
1140    for v in vecs {
1141        let mut u = *v;
1142        for q in &result {
1143            u -= q * u.dot(q);
1144        }
1145        let len = u.norm();
1146        if len < 1e-10 {
1147            let fallbacks = [
1148                Vec3::new(1.0, 0.0, 0.0),
1149                Vec3::new(0.0, 1.0, 0.0),
1150                Vec3::new(0.0, 0.0, 1.0),
1151            ];
1152            for fb in &fallbacks {
1153                let mut candidate = *fb;
1154                for q in &result {
1155                    candidate -= q * candidate.dot(q);
1156                }
1157                if candidate.norm() > 1e-10 {
1158                    u = candidate.normalize();
1159                    break;
1160                }
1161            }
1162        } else {
1163            u /= len;
1164        }
1165        result.push(u);
1166    }
1167    result
1168}
1169/// Modified Gram-Schmidt applied to the columns of a `Mat3`.
1170///
1171/// Returns an orthonormal `Mat3` whose columns span the same space as the input
1172/// (assuming the input is full-rank).  Returns `None` if the matrix is rank-deficient.
1173#[allow(dead_code)]
1174pub fn mat3_gram_schmidt(m: &Mat3) -> Option<Mat3> {
1175    let c0 = Vec3::new(m[(0, 0)], m[(1, 0)], m[(2, 0)]);
1176    let c1 = Vec3::new(m[(0, 1)], m[(1, 1)], m[(2, 1)]);
1177    let c2 = Vec3::new(m[(0, 2)], m[(1, 2)], m[(2, 2)]);
1178    let (q0, q1, q2) = gram_schmidt(&c0, &c1, &c2)?;
1179    Some(Mat3::from_columns(&[q0, q1, q2]))
1180}
1181/// Project a `Vec3` onto a plane defined by its unit normal `n`.
1182///
1183/// Returns the vector with its component along `n` removed.
1184#[allow(dead_code)]
1185pub fn vec3_project_onto_plane(v: &Vec3, plane_normal: &Vec3) -> Vec3 {
1186    v - plane_normal * v.dot(plane_normal)
1187}
1188/// Compute the reflection of direction `d` about unit normal `n`.
1189///
1190/// Equivalent to `vec3_reflect_about_normal` but uses the physics
1191/// convention where `d` points *toward* the surface.
1192#[allow(dead_code)]
1193pub fn reflect_direction(d: &Vec3, n: &Vec3) -> Vec3 {
1194    d - n * (2.0 * d.dot(n))
1195}
1196/// Barycentric coordinates of point `p` with respect to triangle `(a, b, c)`.
1197///
1198/// Returns `(u, v, w)` such that `p = u*a + v*b + w*c` and `u+v+w = 1`.
1199/// Returns `None` if the triangle is degenerate.
1200#[allow(dead_code)]
1201pub fn barycentric_coords(p: &Vec3, a: &Vec3, b: &Vec3, c: &Vec3) -> Option<(Real, Real, Real)> {
1202    let ab = b - a;
1203    let ac = c - a;
1204    let ap = p - a;
1205    let normal = ab.cross(&ac);
1206    let denom = normal.norm_squared();
1207    if denom < 1e-20 {
1208        return None;
1209    }
1210    let v = normal.dot(&ap.cross(&ac)) / denom;
1211    let w = normal.dot(&ab.cross(&ap)) / denom;
1212    let u = 1.0 - v - w;
1213    Some((u, v, w))
1214}
1215/// Test whether point `p` lies inside triangle `(a, b, c)`.
1216#[allow(dead_code)]
1217pub fn point_in_triangle(p: &Vec3, a: &Vec3, b: &Vec3, c: &Vec3) -> bool {
1218    match barycentric_coords(p, a, b, c) {
1219        None => false,
1220        Some((u, v, w)) => u >= -1e-10 && v >= -1e-10 && w >= -1e-10,
1221    }
1222}
1223/// Cross product matrix `[a]×` such that `[a]× * b = a × b`.
1224///
1225/// Equivalent to `skew_symmetric` but named for clarity in the context of the
1226/// cross-product-as-linear-map interpretation.
1227#[allow(dead_code)]
1228pub fn cross_product_matrix(a: &Vec3) -> Mat3 {
1229    skew_symmetric(a)
1230}
1231/// Double cross product `a × (a × b)` expressed as a matrix times `b`:
1232/// the matrix is `-[a]ײ = a*aᵀ - (a·a)*I`.
1233#[allow(dead_code)]
1234pub fn double_cross_matrix(a: &Vec3) -> Mat3 {
1235    let aa = a.norm_squared();
1236    a * a.transpose() - Mat3::identity() * aa
1237}
1238/// Spin matrix for angular velocity `omega`: the time-derivative of a rotation
1239/// matrix R satisfies `Ṙ = omega_hat * R` where `omega_hat = skew(omega)`.
1240#[allow(dead_code)]
1241pub fn spin_matrix(omega: &Vec3) -> Mat3 {
1242    skew_symmetric(omega)
1243}
1244/// Runge–Kutta 4th-order integration of a quaternion ODE.
1245///
1246/// Given a unit quaternion `q` and angular velocity `omega` (body frame),
1247/// integrates `q̇ = 0.5 * q ⊗ [omega, 0]` forward by `dt`.
1248#[allow(dead_code)]
1249pub fn quat_integrate_rk4(q: &Quat, omega: &Vec3, dt: Real) -> Quat {
1250    let half_dt = dt * 0.5;
1251    let omega_quat = |q_in: &Quat| -> Quat {
1252        let ow = UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
1253            0.0, omega.x, omega.y, omega.z,
1254        ));
1255        UnitQuaternion::new_normalize(q_in.as_ref() * (ow.as_ref() * 0.5))
1256    };
1257    let k1 = omega_quat(q);
1258    let q2 = UnitQuaternion::new_normalize(q.as_ref() + k1.as_ref() * half_dt);
1259    let k2 = omega_quat(&q2);
1260    let q3 = UnitQuaternion::new_normalize(q.as_ref() + k2.as_ref() * half_dt);
1261    let k3 = omega_quat(&q3);
1262    let q4 = UnitQuaternion::new_normalize(q.as_ref() + k3.as_ref() * dt);
1263    let k4 = omega_quat(&q4);
1264    let sum = k1.as_ref() + k2.as_ref() * 2.0 + k3.as_ref() * 2.0 + k4.as_ref();
1265    UnitQuaternion::new_normalize(q.as_ref() + sum * (dt / 6.0))
1266}
1267/// Linear blending of two quaternions followed by renormalization (NLerp).
1268#[allow(dead_code)]
1269pub fn quat_blend(q1: &Quat, q2: &Quat, t: Real) -> Quat {
1270    q1.slerp(q2, t)
1271}
1272/// Convert a Cartesian 3-vector to cylindrical coordinates `(rho, phi, z)`.
1273///
1274/// `rho` ≥ 0, `phi ∈ (-π, π]`, `z` is unchanged.
1275#[allow(dead_code)]
1276pub fn vec3_to_cylindrical(v: &Vec3) -> (Real, Real, Real) {
1277    let rho = (v.x * v.x + v.y * v.y).sqrt();
1278    let phi = v.y.atan2(v.x);
1279    (rho, phi, v.z)
1280}
1281/// Convert cylindrical `(rho, phi, z)` to a Cartesian `Vec3`.
1282#[allow(dead_code)]
1283pub fn cylindrical_to_vec3(rho: Real, phi: Real, z: Real) -> Vec3 {
1284    Vec3::new(rho * phi.cos(), rho * phi.sin(), z)
1285}
1286/// Convert a Cartesian `Vec3` to spherical coordinates `(r, theta, phi)`.
1287///
1288/// `r` ≥ 0, `theta ∈ [0, π]` (polar/colatitude), `phi ∈ (-π, π]` (azimuth).
1289#[allow(dead_code)]
1290pub fn vec3_to_spherical(v: &Vec3) -> (Real, Real, Real) {
1291    let r = v.norm();
1292    if r < 1e-30 {
1293        return (0.0, 0.0, 0.0);
1294    }
1295    let theta = (v.z / r).clamp(-1.0, 1.0).acos();
1296    let phi = v.y.atan2(v.x);
1297    (r, theta, phi)
1298}
1299/// Convert spherical `(r, theta, phi)` to a Cartesian `Vec3`.
1300#[allow(dead_code)]
1301pub fn spherical_to_vec3(r: Real, theta: Real, phi: Real) -> Vec3 {
1302    Vec3::new(
1303        r * theta.sin() * phi.cos(),
1304        r * theta.sin() * phi.sin(),
1305        r * theta.cos(),
1306    )
1307}
1308/// Uniformly sample a direction on the unit hemisphere (z ≥ 0) given two
1309/// uniform random numbers `u1, u2 ∈ [0, 1)`.
1310///
1311/// Returns a unit vector `(x, y, z)` with `z ≥ 0`.
1312#[allow(dead_code)]
1313pub fn sample_hemisphere_uniform(u1: Real, u2: Real) -> Vec3 {
1314    let cos_theta = u1;
1315    let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
1316    let phi = 2.0 * std::f64::consts::PI * u2;
1317    Vec3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta)
1318}
1319/// Cosine-weighted sample on the unit hemisphere (z ≥ 0).
1320///
1321/// Returns a unit vector; the PDF is `cos(theta) / π`.
1322#[allow(dead_code)]
1323pub fn sample_hemisphere_cosine(u1: Real, u2: Real) -> Vec3 {
1324    let r = u1.sqrt();
1325    let phi = 2.0 * std::f64::consts::PI * u2;
1326    let x = r * phi.cos();
1327    let y = r * phi.sin();
1328    let z = (1.0 - u1).max(0.0).sqrt();
1329    Vec3::new(x, y, z)
1330}
1331/// Uniformly sample a point on the surface of a unit disk.
1332///
1333/// Uses Shirley's concentric-disk mapping for low distortion.
1334/// Returns `(x, y)` with `x² + y² ≤ 1`.
1335#[allow(dead_code)]
1336pub fn sample_unit_disk_concentric(u1: Real, u2: Real) -> (Real, Real) {
1337    let a = 2.0 * u1 - 1.0;
1338    let b = 2.0 * u2 - 1.0;
1339    if a == 0.0 && b == 0.0 {
1340        return (0.0, 0.0);
1341    }
1342    let (r, phi) = if a.abs() > b.abs() {
1343        (a, std::f64::consts::FRAC_PI_4 * (b / a))
1344    } else {
1345        (
1346            b,
1347            std::f64::consts::FRAC_PI_2 - std::f64::consts::FRAC_PI_4 * (a / b),
1348        )
1349    };
1350    (r * phi.cos(), r * phi.sin())
1351}
1352#[cfg(test)]
1353mod extended_math_tests_v2 {
1354
1355    use crate::Mat3;
1356    use crate::Vec3;
1357    use crate::math::barycentric_coords;
1358    use crate::math::build_orthonormal_basis;
1359
1360    use crate::math::cylindrical_to_vec3;
1361    use crate::math::double_cross_matrix;
1362
1363    use crate::math::gram_schmidt_vectors;
1364
1365    use crate::math::mat3_from_axis_angle;
1366    use crate::math::mat3_gram_schmidt;
1367
1368    use crate::math::mat3_rotation_angle;
1369    use crate::math::mat3_rotation_axis;
1370    use crate::math::mat3_rotation_from_to;
1371    use crate::math::mat3_slerp;
1372    use crate::math::mat3_weighted_mean;
1373    use crate::math::point_in_triangle;
1374    use crate::math::point_to_segment_distance;
1375    use crate::math::quat_arr_conjugate;
1376    use crate::math::quat_arr_dot;
1377    use crate::math::quat_arr_from_axis_angle;
1378    use crate::math::quat_arr_normalize;
1379    use crate::math::quat_arr_pow;
1380    use crate::math::quat_arr_rotate_vec3;
1381
1382    use crate::math::quat_multiply;
1383
1384    use crate::math::sample_hemisphere_cosine;
1385    use crate::math::sample_hemisphere_uniform;
1386    use crate::math::sample_unit_disk_concentric;
1387
1388    use crate::math::spherical_to_vec3;
1389    use crate::math::vec3_abs;
1390    use crate::math::vec3_clamp;
1391    use crate::math::vec3_decompose;
1392    use crate::math::vec3_max;
1393    use crate::math::vec3_min;
1394
1395    use crate::math::vec3_project_onto_unit;
1396    use crate::math::vec3_reflect_about_normal;
1397    use crate::math::vec3_refract;
1398    use crate::math::vec3_reject;
1399    use crate::math::vec3_scalar_triple;
1400    use crate::math::vec3_to_cylindrical;
1401    use crate::math::vec3_to_spherical;
1402    #[test]
1403    fn test_project_onto_unit_along_axis() {
1404        let v = Vec3::new(3.0, 4.0, 0.0);
1405        let u = Vec3::new(1.0, 0.0, 0.0);
1406        let proj = vec3_project_onto_unit(&v, &u);
1407        assert!((proj.x - 3.0).abs() < 1e-12);
1408        assert!(proj.y.abs() < 1e-12);
1409        assert!(proj.z.abs() < 1e-12);
1410    }
1411    #[test]
1412    fn test_reject_perpendicular_to_axis() {
1413        let v = Vec3::new(3.0, 4.0, 5.0);
1414        let u = Vec3::new(0.0, 0.0, 1.0);
1415        let rej = vec3_reject(&v, &u);
1416        assert!(rej.dot(&u).abs() < 1e-12, "reject should be perp to axis");
1417    }
1418    #[test]
1419    fn test_project_plus_reject_equals_v() {
1420        let v = Vec3::new(1.0, -2.0, 3.0);
1421        let u = Vec3::new(0.0, 1.0, 0.0);
1422        let proj = vec3_project_onto_unit(&v, &u);
1423        let rej = vec3_reject(&v, &u);
1424        let diff = (proj + rej - v).norm();
1425        assert!(diff < 1e-12, "proj + rej should equal v, diff={}", diff);
1426    }
1427    #[test]
1428    fn test_reflect_normal_preserves_magnitude() {
1429        let v = Vec3::new(1.0, -1.0, 0.0).normalize();
1430        let n = Vec3::new(0.0, 1.0, 0.0);
1431        let r = vec3_reflect_about_normal(&v, &n);
1432        assert!((r.norm() - v.norm()).abs() < 1e-12);
1433    }
1434    #[test]
1435    fn test_reflect_vertical_normal() {
1436        let v = Vec3::new(1.0, -1.0, 0.0);
1437        let n = Vec3::new(0.0, 1.0, 0.0);
1438        let r = vec3_reflect_about_normal(&v, &n);
1439        assert!((r.x - 1.0).abs() < 1e-12);
1440        assert!((r.y - 1.0).abs() < 1e-12);
1441        assert!(r.z.abs() < 1e-12);
1442    }
1443    #[test]
1444    fn test_refract_normal_incidence() {
1445        let v = Vec3::new(0.0, -1.0, 0.0);
1446        let n = Vec3::new(0.0, 1.0, 0.0);
1447        let t = vec3_refract(&v, &n, 1.0).unwrap();
1448        let diff = (t - v).norm();
1449        assert!(diff < 1e-10, "normal incidence same-medium: diff={}", diff);
1450    }
1451    #[test]
1452    fn test_refract_total_internal_reflection() {
1453        let v = Vec3::new(0.99, -0.14, 0.0).normalize();
1454        let n = Vec3::new(0.0, 1.0, 0.0);
1455        let result = vec3_refract(&v, &n, 1.5);
1456        let _ = result;
1457    }
1458    #[test]
1459    fn test_vec3_min_max() {
1460        let a = Vec3::new(1.0, 5.0, 3.0);
1461        let b = Vec3::new(2.0, 3.0, 4.0);
1462        let mn = vec3_min(&a, &b);
1463        let mx = vec3_max(&a, &b);
1464        assert!((mn.x - 1.0).abs() < 1e-12);
1465        assert!((mn.y - 3.0).abs() < 1e-12);
1466        assert!((mn.z - 3.0).abs() < 1e-12);
1467        assert!((mx.x - 2.0).abs() < 1e-12);
1468        assert!((mx.y - 5.0).abs() < 1e-12);
1469        assert!((mx.z - 4.0).abs() < 1e-12);
1470    }
1471    #[test]
1472    fn test_vec3_abs_all_negative() {
1473        let v = Vec3::new(-1.0, -2.0, -3.0);
1474        let a = vec3_abs(&v);
1475        assert!((a.x - 1.0).abs() < 1e-12);
1476        assert!((a.y - 2.0).abs() < 1e-12);
1477        assert!((a.z - 3.0).abs() < 1e-12);
1478    }
1479    #[test]
1480    fn test_vec3_clamp() {
1481        let v = Vec3::new(-5.0, 0.5, 10.0);
1482        let c = vec3_clamp(&v, 0.0, 1.0);
1483        assert!((c.x - 0.0).abs() < 1e-12);
1484        assert!((c.y - 0.5).abs() < 1e-12);
1485        assert!((c.z - 1.0).abs() < 1e-12);
1486    }
1487    #[test]
1488    fn test_build_orthonormal_basis_orthogonal() {
1489        let n = Vec3::new(0.0, 1.0, 0.0);
1490        let (t, bt, nn) = build_orthonormal_basis(&n);
1491        assert!(t.dot(&bt).abs() < 1e-10, "t·bt should be 0");
1492        assert!(t.dot(&nn).abs() < 1e-10, "t·n should be 0");
1493        assert!(bt.dot(&nn).abs() < 1e-10, "bt·n should be 0");
1494    }
1495    #[test]
1496    fn test_build_orthonormal_basis_unit_vectors() {
1497        let n = Vec3::new(1.0, 2.0, 3.0).normalize();
1498        let (t, bt, _) = build_orthonormal_basis(&n);
1499        assert!((t.norm() - 1.0).abs() < 1e-10, "t should be unit");
1500        assert!((bt.norm() - 1.0).abs() < 1e-10, "bt should be unit");
1501    }
1502    #[test]
1503    fn test_vec3_decompose_sums_to_original() {
1504        let v = Vec3::new(1.0, 2.0, 3.0);
1505        let axis = Vec3::new(0.0, 0.0, 1.0);
1506        let (par, perp) = vec3_decompose(&v, &axis);
1507        let diff = (par + perp - v).norm();
1508        assert!(diff < 1e-12, "parallel + perp should be v, diff={}", diff);
1509    }
1510    #[test]
1511    fn test_vec3_decompose_parallel_along_axis() {
1512        let v = Vec3::new(1.0, 2.0, 3.0);
1513        let axis = Vec3::new(0.0, 0.0, 1.0);
1514        let (par, _) = vec3_decompose(&v, &axis);
1515        assert!(par.x.abs() < 1e-12);
1516        assert!(par.y.abs() < 1e-12);
1517        assert!((par.z - 3.0).abs() < 1e-12);
1518    }
1519    #[test]
1520    fn test_scalar_triple_basis_vectors() {
1521        let e0 = Vec3::new(1.0, 0.0, 0.0);
1522        let e1 = Vec3::new(0.0, 1.0, 0.0);
1523        let e2 = Vec3::new(0.0, 0.0, 1.0);
1524        let triple = vec3_scalar_triple(&e0, &e1, &e2);
1525        assert!((triple - 1.0).abs() < 1e-12);
1526    }
1527    #[test]
1528    fn test_scalar_triple_antisymmetry() {
1529        let a = Vec3::new(1.0, 2.0, 3.0);
1530        let b = Vec3::new(4.0, 5.0, 6.0);
1531        let c = Vec3::new(7.0, 8.0, 9.0);
1532        let abc = vec3_scalar_triple(&a, &b, &c);
1533        let bca = vec3_scalar_triple(&b, &c, &a);
1534        assert!(
1535            (abc - bca).abs() < 1e-10,
1536            "cyclic permutation should be equal"
1537        );
1538    }
1539    #[test]
1540    fn test_point_to_segment_endpoint() {
1541        let a = Vec3::new(0.0, 0.0, 0.0);
1542        let b = Vec3::new(1.0, 0.0, 0.0);
1543        let p = Vec3::new(2.0, 0.0, 0.0);
1544        let d = point_to_segment_distance(&p, &a, &b);
1545        assert!((d - 1.0).abs() < 1e-12, "dist should be 1.0, got {}", d);
1546    }
1547    #[test]
1548    fn test_point_to_segment_perpendicular() {
1549        let a = Vec3::new(0.0, 0.0, 0.0);
1550        let b = Vec3::new(2.0, 0.0, 0.0);
1551        let p = Vec3::new(1.0, 3.0, 0.0);
1552        let d = point_to_segment_distance(&p, &a, &b);
1553        assert!((d - 3.0).abs() < 1e-12, "dist should be 3.0, got {}", d);
1554    }
1555    #[test]
1556    fn test_mat3_rotation_from_to_identity() {
1557        let v = Vec3::new(1.0, 0.0, 0.0);
1558        let r = mat3_rotation_from_to(&v, &v);
1559        let diff = (r - Mat3::identity()).norm();
1560        assert!(diff < 1e-10, "from_to same direction should be identity");
1561    }
1562    #[test]
1563    fn test_mat3_rotation_from_to_correct() {
1564        let from = Vec3::new(1.0, 0.0, 0.0);
1565        let to = Vec3::new(0.0, 1.0, 0.0);
1566        let r = mat3_rotation_from_to(&from, &to);
1567        let result = r * from;
1568        let diff = (result - to).norm();
1569        assert!(diff < 1e-10, "rotation_from_to mismatch: diff={}", diff);
1570    }
1571    #[test]
1572    fn test_mat3_rotation_from_to_antiparallel() {
1573        let from = Vec3::new(1.0, 0.0, 0.0);
1574        let to = Vec3::new(-1.0, 0.0, 0.0);
1575        let r = mat3_rotation_from_to(&from, &to);
1576        let result = r * from;
1577        let len = result.norm();
1578        assert!(
1579            (len - 1.0).abs() < 1e-8,
1580            "rotation preserves length: len={}",
1581            len
1582        );
1583    }
1584    #[test]
1585    fn test_mat3_slerp_at_endpoints() {
1586        let r1 = mat3_from_axis_angle(&Vec3::new(0.0, 0.0, 1.0), 0.3);
1587        let r2 = mat3_from_axis_angle(&Vec3::new(0.0, 0.0, 1.0), 0.9);
1588        let s0 = mat3_slerp(&r1, &r2, 0.0);
1589        let s1 = mat3_slerp(&r1, &r2, 1.0);
1590        assert!((s0 - r1).norm() < 1e-8, "slerp(t=0) should be r1");
1591        assert!((s1 - r2).norm() < 1e-8, "slerp(t=1) should be r2");
1592    }
1593    #[test]
1594    fn test_mat3_slerp_midpoint_is_rotation() {
1595        let r1 = mat3_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.0);
1596        let r2 = mat3_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 1.0);
1597        let mid = mat3_slerp(&r1, &r2, 0.5);
1598        let det = mid.determinant();
1599        let rrt = mid * mid.transpose();
1600        let diff = (rrt - Mat3::identity()).norm();
1601        assert!((det - 1.0).abs() < 1e-7, "slerp midpoint det={}", det);
1602        assert!(diff < 1e-7, "slerp midpoint R R^T diff={}", diff);
1603    }
1604    #[test]
1605    fn test_quat_arr_conjugate_product_is_identity() {
1606        let q = quat_arr_from_axis_angle([0.0, 1.0, 0.0], 0.5);
1607        let conj = quat_arr_conjugate(q);
1608        let prod = quat_multiply(q, conj);
1609        assert!(prod[0].abs() < 1e-12);
1610        assert!(prod[1].abs() < 1e-12);
1611        assert!(prod[2].abs() < 1e-12);
1612        assert!((prod[3] - 1.0).abs() < 1e-12);
1613    }
1614    #[test]
1615    fn test_quat_arr_normalize_unit_input() {
1616        let q = [0.0, 0.0, 0.0, 1.0];
1617        let n = quat_arr_normalize(q);
1618        assert!((n[3] - 1.0).abs() < 1e-12);
1619    }
1620    #[test]
1621    fn test_quat_arr_rotate_vec3_z90() {
1622        let q = quat_arr_from_axis_angle([0.0, 0.0, 1.0], std::f64::consts::FRAC_PI_2);
1623        let v = [1.0_f64, 0.0, 0.0];
1624        let r = quat_arr_rotate_vec3(q, v);
1625        assert!(r[0].abs() < 1e-10, "x={}", r[0]);
1626        assert!((r[1] - 1.0).abs() < 1e-10, "y={}", r[1]);
1627        assert!(r[2].abs() < 1e-10, "z={}", r[2]);
1628    }
1629    #[test]
1630    fn test_quat_arr_pow_half_is_sqrt() {
1631        let q = quat_arr_from_axis_angle([0.0, 1.0, 0.0], 0.8);
1632        let q_half = quat_arr_pow(q, 0.5);
1633        let q_reconstructed = quat_multiply(q_half, q_half);
1634        let q_norm = quat_arr_normalize(q_reconstructed);
1635        let dot = quat_arr_dot(q, q_norm).abs();
1636        assert!(dot > 0.9999, "q^0.5 * q^0.5 should equal q: dot={}", dot);
1637    }
1638    #[test]
1639    fn test_gram_schmidt_vectors_orthonormal() {
1640        let vecs = vec![
1641            Vec3::new(1.0, 1.0, 0.0),
1642            Vec3::new(1.0, 0.0, 1.0),
1643            Vec3::new(0.0, 1.0, 1.0),
1644        ];
1645        let qs = gram_schmidt_vectors(&vecs);
1646        assert_eq!(qs.len(), 3);
1647        for i in 0..3 {
1648            assert!((qs[i].norm() - 1.0).abs() < 1e-10, "q[{}] not unit", i);
1649            for j in (i + 1)..3 {
1650                let dot = qs[i].dot(&qs[j]);
1651                assert!(dot.abs() < 1e-10, "q[{}]·q[{}]={} != 0", i, j, dot);
1652            }
1653        }
1654    }
1655    #[test]
1656    fn test_mat3_gram_schmidt_gives_orthogonal_result() {
1657        let m = Mat3::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0);
1658        let q = mat3_gram_schmidt(&m).expect("should succeed for non-singular");
1659        let diff = (q * q.transpose() - Mat3::identity()).norm();
1660        assert!(
1661            diff < 1e-8,
1662            "gram-schmidt result not orthogonal: diff={}",
1663            diff
1664        );
1665    }
1666    #[test]
1667    fn test_barycentric_vertex_a() {
1668        let a = Vec3::new(0.0, 0.0, 0.0);
1669        let b = Vec3::new(1.0, 0.0, 0.0);
1670        let c = Vec3::new(0.0, 1.0, 0.0);
1671        let (u, v, w) = barycentric_coords(&a, &a, &b, &c).unwrap();
1672        assert!((u - 1.0).abs() < 1e-10);
1673        assert!(v.abs() < 1e-10);
1674        assert!(w.abs() < 1e-10);
1675    }
1676    #[test]
1677    fn test_barycentric_centroid() {
1678        let a = Vec3::new(0.0, 0.0, 0.0);
1679        let b = Vec3::new(3.0, 0.0, 0.0);
1680        let c = Vec3::new(0.0, 3.0, 0.0);
1681        let centroid = (a + b + c) / 3.0;
1682        let (u, v, w) = barycentric_coords(&centroid, &a, &b, &c).unwrap();
1683        let third = 1.0 / 3.0;
1684        assert!((u - third).abs() < 1e-10, "u={}", u);
1685        assert!((v - third).abs() < 1e-10, "v={}", v);
1686        assert!((w - third).abs() < 1e-10, "w={}", w);
1687    }
1688    #[test]
1689    fn test_point_in_triangle_inside() {
1690        let a = Vec3::new(0.0, 0.0, 0.0);
1691        let b = Vec3::new(1.0, 0.0, 0.0);
1692        let c = Vec3::new(0.0, 1.0, 0.0);
1693        let p = Vec3::new(0.2, 0.2, 0.0);
1694        assert!(point_in_triangle(&p, &a, &b, &c));
1695    }
1696    #[test]
1697    fn test_point_in_triangle_outside() {
1698        let a = Vec3::new(0.0, 0.0, 0.0);
1699        let b = Vec3::new(1.0, 0.0, 0.0);
1700        let c = Vec3::new(0.0, 1.0, 0.0);
1701        let p = Vec3::new(0.8, 0.8, 0.0);
1702        assert!(!point_in_triangle(&p, &a, &b, &c));
1703    }
1704    #[test]
1705    fn test_sample_hemisphere_uniform_unit_length() {
1706        let v = sample_hemisphere_uniform(0.5, 0.5);
1707        assert!((v.norm() - 1.0).abs() < 1e-12, "norm={}", v.norm());
1708    }
1709    #[test]
1710    fn test_sample_hemisphere_uniform_nonneg_z() {
1711        for i in 0..10 {
1712            let u1 = i as f64 / 10.0;
1713            let u2 = (i + 1) as f64 / 11.0;
1714            let v = sample_hemisphere_uniform(u1, u2);
1715            assert!(v.z >= -1e-12, "z={}", v.z);
1716        }
1717    }
1718    #[test]
1719    fn test_sample_hemisphere_cosine_unit_length() {
1720        let v = sample_hemisphere_cosine(0.3, 0.7);
1721        assert!((v.norm() - 1.0).abs() < 1e-12, "norm={}", v.norm());
1722    }
1723    #[test]
1724    fn test_sample_unit_disk_in_unit_circle() {
1725        for i in 0..10 {
1726            let u1 = i as f64 / 10.0;
1727            let u2 = (i + 3) as f64 / 13.0;
1728            let (x, y) = sample_unit_disk_concentric(u1, u2);
1729            assert!(
1730                x * x + y * y <= 1.0 + 1e-12,
1731                "outside unit circle: {},{}",
1732                x,
1733                y
1734            );
1735        }
1736    }
1737    #[test]
1738    fn test_mat3_rotation_angle_known() {
1739        let angle = std::f64::consts::FRAC_PI_3;
1740        let r = mat3_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), angle);
1741        let extracted = mat3_rotation_angle(&r);
1742        assert!((extracted - angle).abs() < 1e-8, "angle={}", extracted);
1743    }
1744    #[test]
1745    fn test_mat3_rotation_axis_known() {
1746        let r = mat3_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.5);
1747        let axis = mat3_rotation_axis(&r).expect("should have axis");
1748        assert!(axis.y.abs() > 0.99, "axis y={}", axis.y);
1749    }
1750    #[test]
1751    fn test_cylindrical_roundtrip_new() {
1752        let v = Vec3::new(2.0, 3.0, 4.0);
1753        let (rho, phi, z) = vec3_to_cylindrical(&v);
1754        let v2 = cylindrical_to_vec3(rho, phi, z);
1755        let diff = (v - v2).norm();
1756        assert!(diff < 1e-12, "cylindrical roundtrip diff={}", diff);
1757    }
1758    #[test]
1759    fn test_spherical_roundtrip_new() {
1760        let v = Vec3::new(1.0, 2.0, 2.0);
1761        let (r, theta, phi) = vec3_to_spherical(&v);
1762        let v2 = spherical_to_vec3(r, theta, phi);
1763        let diff = (v - v2).norm();
1764        assert!(diff < 1e-12, "spherical roundtrip diff={}", diff);
1765    }
1766    #[test]
1767    fn test_double_cross_matrix_bac_cab() {
1768        let a = Vec3::new(1.0, 2.0, 3.0);
1769        let b = Vec3::new(4.0, 5.0, 6.0);
1770        let dcm = double_cross_matrix(&a);
1771        let via_matrix = dcm * b;
1772        let direct = a * a.dot(&b) - b * a.norm_squared();
1773        let diff = (via_matrix - direct).norm();
1774        assert!(diff < 1e-10, "double cross matrix diff={}", diff);
1775    }
1776    #[test]
1777    fn test_mat3_weighted_mean_single_rotation() {
1778        let r = mat3_from_axis_angle(&Vec3::new(0.0, 1.0, 0.0), 0.7);
1779        let mean = mat3_weighted_mean(&[r], &[1.0], 20);
1780        let diff = (mean - r).norm();
1781        assert!(
1782            diff < 1e-6,
1783            "mean of single rotation should be itself: diff={}",
1784            diff
1785        );
1786    }
1787    #[test]
1788    fn test_mat3_weighted_mean_two_equal_rotations() {
1789        let r = mat3_from_axis_angle(&Vec3::new(1.0, 0.0, 0.0), 0.4);
1790        let mean = mat3_weighted_mean(&[r, r], &[0.5, 0.5], 20);
1791        let diff = (mean - r).norm();
1792        assert!(diff < 1e-6, "mean of same rotation: diff={}", diff);
1793    }
1794}