From 7b09b5fbdf960b198dfb46ae9fbdee5d8aeeecfb Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 13:02:01 +0800 Subject: [PATCH] foundation: move apsis-math tests into tests directory --- crates/foundation/apsis-math/src/mat3.rs | 208 ------------------- crates/foundation/apsis-math/src/quat.rs | 222 --------------------- crates/foundation/apsis-math/src/vec3.rs | 170 ---------------- crates/foundation/apsis-math/tests/mat3.rs | 204 +++++++++++++++++++ crates/foundation/apsis-math/tests/quat.rs | 217 ++++++++++++++++++++ crates/foundation/apsis-math/tests/vec3.rs | 168 ++++++++++++++++ 6 files changed, 589 insertions(+), 600 deletions(-) create mode 100644 crates/foundation/apsis-math/tests/mat3.rs create mode 100644 crates/foundation/apsis-math/tests/quat.rs create mode 100644 crates/foundation/apsis-math/tests/vec3.rs diff --git a/crates/foundation/apsis-math/src/mat3.rs b/crates/foundation/apsis-math/src/mat3.rs index c9c17c7..74924b7 100644 --- a/crates/foundation/apsis-math/src/mat3.rs +++ b/crates/foundation/apsis-math/src/mat3.rs @@ -322,211 +322,3 @@ impl fmt::Display for Mat3 { ) } } - -#[cfg(test)] -mod tests { - use super::Mat3; - use crate::quat::Quat; - use crate::vec3::Vec3; - use core::f64::consts::FRAC_PI_2; - - const EPS: f64 = 1e-12; - - fn assert_mat_close(lhs: Mat3, rhs: Mat3) { - for row in 0..3 { - for column in 0..3 { - let (lhs, rhs) = ( - lhs.get(row, column).expect("indices are in range"), - rhs.get(row, column).expect("indices are in range"), - ); - assert!( - (lhs - rhs).abs() < EPS, - "at ({row}, {column}): {lhs} vs {rhs}" - ); - } - } - } - - #[test] - fn constructors_place_entries_in_the_right_slots() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - assert_eq!(m.get(0, 0), Some(1.0)); - assert_eq!(m.get(0, 1), Some(2.0)); - assert_eq!(m.get(0, 2), Some(3.0)); - assert_eq!(m.get(1, 0), Some(4.0)); - assert_eq!(m.get(2, 2), Some(9.0)); - assert_eq!(m.get(3, 0), None); - assert_eq!(m.get(0, 3), None); - - assert_eq!(m.row(1), Some(Vec3::new(4.0, 5.0, 6.0))); - assert_eq!(m.column(1), Some(Vec3::new(2.0, 5.0, 8.0))); - assert_eq!(m.row(3), None); - assert_eq!(m.column(3), None); - } - - #[test] - fn from_columns_matches_from_rows() { - let c0 = Vec3::new(1.0, 4.0, 7.0); - let c1 = Vec3::new(2.0, 5.0, 8.0); - let c2 = Vec3::new(3.0, 6.0, 9.0); - let expected = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - assert_eq!(Mat3::from_columns(c0, c1, c2), expected); - } - - #[test] - fn identity_is_the_multiplicative_unit() { - let m = Mat3::from_rotation_z(FRAC_PI_2); - assert_eq!(Mat3::IDENTITY * m, m); - assert_eq!(m * Mat3::IDENTITY, m); - assert_eq!(Mat3::IDENTITY.determinant(), 1.0); - assert_eq!(Mat3::default(), Mat3::ZERO); - } - - #[test] - fn zero_annihilates() { - assert_eq!(Mat3::ZERO * Vec3::new(1.0, 2.0, 3.0), Vec3::ZERO); - assert_eq!(Mat3::ZERO * Mat3::IDENTITY, Mat3::ZERO); - assert_eq!(Mat3::ZERO.determinant(), 0.0); - } - - #[test] - fn transpose_swaps_rows_and_columns() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - let t = m.transpose(); - assert_eq!(t.row(0), m.column(0)); - assert_eq!(t.row(1), m.column(1)); - assert_eq!(t.row(2), m.column(2)); - assert_eq!(t.transpose(), m); - } - - #[test] - fn determinant_matches_hand_computation() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 10.0]); - assert!((m.determinant() + 3.0).abs() < EPS); - assert_eq!( - Mat3::from_diagonal(Vec3::new(2.0, 3.0, 4.0)).determinant(), - 24.0 - ); - } - - #[test] - fn trace_sums_the_diagonal() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - assert_eq!(m.trace(), 15.0); - } - - #[test] - fn matrix_vector_product_uses_rows() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - let v = Vec3::new(1.0, 1.0, 1.0); - assert_eq!(m * v, Vec3::new(6.0, 15.0, 24.0)); - } - - #[test] - fn matrix_product_is_associative() { - let a = Mat3::from_rows([1.0, 2.0, 0.0], [0.0, 1.0, 3.0], [1.0, 0.0, 1.0]); - let b = Mat3::from_rows([0.0, 1.0, 2.0], [1.0, 0.0, 1.0], [2.0, 1.0, 0.0]); - let c = Mat3::from_rows([1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]); - assert_eq!((a * b) * c, a * (b * c)); - } - - #[test] - fn matrix_product_preserves_the_determinant_product() { - let a = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 10.0]); - let b = Mat3::from_rows([2.0, 0.0, 1.0], [1.0, 3.0, 2.0], [0.0, 1.0, 4.0]); - let expected = a.determinant() * b.determinant(); - assert!(((a * b).determinant() - expected).abs() < 1e-9); - } - - #[test] - fn scalar_product_scales_every_entry() { - let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); - let scaled = m * 2.0; - assert_eq!(scaled.get(0, 0), Some(2.0)); - assert_eq!(scaled.get(2, 2), Some(18.0)); - assert_eq!(2.0 * m, scaled); - } - - #[test] - fn rotation_matrices_satisfy_the_rotation_predicate() { - let m = Mat3::from_rotation_z(FRAC_PI_2); - assert!(m.is_rotation(1e-12)); - assert!((m.determinant() - 1.0).abs() < EPS); - assert_mat_close(m * m.transpose(), Mat3::IDENTITY); - } - - #[test] - fn is_rotation_rejects_non_rotations() { - assert!(!Mat3::ZERO.is_rotation(1e-9)); - assert!(!Mat3::from_diagonal(Vec3::splat(2.0)).is_rotation(1e-9)); - // A reflection has determinant minus one. - assert!(!Mat3::from_diagonal(Vec3::new(1.0, 1.0, -1.0)).is_rotation(1e-9)); - } - - #[test] - fn axis_angle_matches_the_quaternion_rotation() { - let axis = Vec3::new(0.5, -2.0, 1.0); - let m = Mat3::from_axis_angle(axis, 0.9).expect("non-zero axis"); - let q = Quat::from_axis_angle(axis, 0.9).expect("non-zero axis"); - let v = Vec3::new(1.0, -2.0, 3.0); - assert!((m * v - q.rotate(v)).norm() < EPS); - assert_eq!(Mat3::from_axis_angle(Vec3::ZERO, 1.0), None); - } - - #[test] - fn rotation_maps_axes_as_expected() { - let m = Mat3::from_rotation_z(FRAC_PI_2); - assert!((m * Vec3::X - Vec3::Y).norm() < EPS); - assert!((m * Vec3::Y + Vec3::X).norm() < EPS); - assert!((m * Vec3::Z - Vec3::Z).norm() < EPS); - } - - #[test] - fn per_axis_constructors_agree_with_the_axis_angle_form() { - let angle = 0.6; - let about_x = Mat3::from_rotation_x(angle); - assert_eq!( - about_x, - Mat3::from_axis_angle(Vec3::X, angle).expect("unit axis") - ); - assert_eq!(about_x * Vec3::X, Vec3::X); - - let about_y = Mat3::from_rotation_y(angle); - assert_eq!( - about_y, - Mat3::from_axis_angle(Vec3::Y, angle).expect("unit axis") - ); - assert_eq!(about_y * Vec3::Y, Vec3::Y); - - let about_z = Mat3::from_rotation_z(angle); - assert_eq!( - about_z, - Mat3::from_axis_angle(Vec3::Z, angle).expect("unit axis") - ); - assert_eq!(about_z * Vec3::Z, Vec3::Z); - assert!((about_z.determinant() - 1.0).abs() < EPS); - } - - #[test] - fn finiteness_is_reported() { - assert!(Mat3::IDENTITY.is_finite()); - let mut m = Mat3::IDENTITY; - m.m01 = f64::NAN; - assert!(!m.is_finite()); - } - - #[test] - fn display_uses_row_major_groups() { - let m = Mat3::from_diagonal(Vec3::new(1.0, 2.0, 3.0)); - assert_eq!(m.to_string(), "[1, 0, 0; 0, 2, 0; 0, 0, 3]"); - } - - #[test] - fn from_quat_is_consistent_with_quaternion_rotation() { - let q = Quat::from_axis_angle(Vec3::new(1.0, 1.0, 0.0), 1.3).expect("non-zero axis"); - let m = Mat3::from_quat(q); - let v = Vec3::new(2.0, -1.0, 0.5); - assert!((m * v - q.rotate(v)).norm() < EPS); - assert!(m.is_rotation(1e-12)); - } -} diff --git a/crates/foundation/apsis-math/src/quat.rs b/crates/foundation/apsis-math/src/quat.rs index 2b9ff8e..a7be057 100644 --- a/crates/foundation/apsis-math/src/quat.rs +++ b/crates/foundation/apsis-math/src/quat.rs @@ -272,225 +272,3 @@ impl fmt::Display for Quat { write!(f, "({}, {}, {}, {})", self.w, self.x, self.y, self.z) } } - -#[cfg(test)] -mod tests { - use super::Quat; - use crate::mat3::Mat3; - use crate::vec3::Vec3; - use core::f64::consts::{FRAC_PI_2, PI}; - - const EPS: f64 = 1e-12; - - fn assert_close(lhs: Vec3, rhs: Vec3) { - assert!((lhs - rhs).norm() < EPS, "left {lhs}, right {rhs}"); - } - - fn assert_quat_close(lhs: Quat, rhs: Quat) { - let difference = Quat::new(lhs.w - rhs.w, lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z); - assert!(difference.norm() < EPS, "left {lhs}, right {rhs}"); - } - - #[test] - fn identity_rotates_nothing() { - let v = Vec3::new(1.0, 2.0, 3.0); - assert_close(Quat::IDENTITY.rotate(v), v); - assert_eq!(Quat::IDENTITY.angle(), 0.0); - assert_eq!(Quat::IDENTITY.axis(), None); - } - - #[test] - fn axis_angle_round_trips() { - let axis = Vec3::new(1.0, 2.0, -2.0) - .normalize() - .expect("non-zero axis"); - let q = Quat::from_axis_angle(axis, 0.7).expect("non-zero axis"); - assert!((q.angle() - 0.7).abs() < EPS); - assert_close(q.axis().expect("rotation has an axis"), axis); - assert!(q.is_normalized()); - } - - #[test] - fn axis_angle_rejects_undefined_axes() { - assert_eq!(Quat::from_axis_angle(Vec3::ZERO, 1.0), None); - assert_eq!(Quat::from_axis_angle(Vec3::splat(f64::NAN), 1.0), None); - } - - #[test] - fn quarter_turn_about_z_maps_x_to_y() { - let q = Quat::from_rotation_z(FRAC_PI_2); - assert_close(q.rotate(Vec3::X), Vec3::Y); - assert_close(q.rotate(Vec3::Y), -Vec3::X); - assert_close(q.rotate(Vec3::Z), Vec3::Z); - } - - #[test] - fn quarter_turn_about_x_maps_y_to_z() { - let q = Quat::from_rotation_x(FRAC_PI_2); - assert_close(q.rotate(Vec3::Y), Vec3::Z); - assert_close(q.rotate(Vec3::Z), -Vec3::Y); - } - - #[test] - fn quarter_turn_about_y_maps_z_to_x() { - let q = Quat::from_rotation_y(FRAC_PI_2); - assert_close(q.rotate(Vec3::Z), Vec3::X); - assert_close(q.rotate(Vec3::X), -Vec3::Z); - } - - #[test] - fn rotation_preserves_length() { - let q = Quat::from_axis_angle(Vec3::new(1.0, -1.0, 0.5), 1.234).expect("non-zero axis"); - let v = Vec3::new(3.0, -4.0, 12.0); - assert!((q.rotate(v).norm() - v.norm()).abs() < EPS); - } - - #[test] - fn conjugate_undoes_the_rotation() { - let q = Quat::from_axis_angle(Vec3::new(0.0, 1.0, 1.0), 0.9).expect("non-zero axis"); - let v = Vec3::new(1.0, -2.0, 0.5); - assert_close(q.conjugate().rotate(q.rotate(v)), v); - } - - #[test] - fn inverse_undoes_the_rotation() { - let q = Quat::from_axis_angle(Vec3::new(-0.5, 1.0, 0.25), 2.0).expect("non-zero axis"); - let v = Vec3::new(2.0, 0.0, -1.0); - let inverse = q.inverse().expect("unit quaternion is invertible"); - assert_close(inverse.rotate(q.rotate(v)), v); - } - - #[test] - fn inverse_rejects_degenerate_quaternions() { - assert_eq!(Quat::new(0.0, 0.0, 0.0, 0.0).inverse(), None); - assert_eq!(Quat::new(f64::NAN, 0.0, 0.0, 0.0).inverse(), None); - } - - #[test] - fn multiplication_composes_right_to_left() { - let first = Quat::from_rotation_z(FRAC_PI_2); - let second = Quat::from_rotation_x(FRAC_PI_2); - let v = Vec3::new(1.0, 0.0, 0.0); - // `second * first` applies `first` first, then `second`. - assert_close((second * first).rotate(v), second.rotate(first.rotate(v))); - } - - #[test] - fn identity_is_the_multiplicative_unit() { - let q = Quat::from_rotation_y(0.4); - assert_quat_close(q * Quat::IDENTITY, q); - assert_quat_close(Quat::IDENTITY * q, q); - } - - #[test] - fn multiplication_matches_matrix_composition() { - let a = Quat::from_axis_angle(Vec3::new(1.0, 0.5, -0.25), 0.8).expect("non-zero axis"); - let b = Quat::from_axis_angle(Vec3::new(-0.5, 1.0, 0.75), 1.6).expect("non-zero axis"); - let composed = a * b; - let v = Vec3::new(1.0, 2.0, 3.0); - let via_matrices = Mat3::from_quat(a) * (Mat3::from_quat(b) * v); - assert_close(composed.rotate(v), via_matrices); - } - - #[test] - fn normalize_produces_unit_length() { - let q = Quat::new(2.0, 0.0, 0.0, 0.0); - let unit = q.normalize().expect("non-zero quaternion"); - assert!(unit.is_normalized()); - assert!((unit.w - 1.0).abs() < EPS); - } - - #[test] - fn normalize_rejects_degenerate_quaternions() { - assert_eq!(Quat::new(0.0, 0.0, 0.0, 0.0).normalize(), None); - assert_eq!(Quat::new(f64::NAN, 0.0, 0.0, 0.0).normalize(), None); - } - - #[test] - fn from_mat3_recovers_the_original_rotation() { - let q = Quat::from_axis_angle(Vec3::new(0.3, -0.4, 0.5), 1.1).expect("non-zero axis"); - let recovered = Quat::from_mat3(Mat3::from_quat(q)).expect("rotation matrix is accepted"); - // `q` and `-q` are the same rotation, so compare through their effect. - let v = Vec3::new(1.0, -2.0, 3.0); - assert_close(recovered.rotate(v), q.rotate(v)); - } - - #[test] - fn from_mat3_covers_every_branch() { - // These angles exercise the trace branch and each of the three - // largest-diagonal branches. - for (axis, angle) in [ - (Vec3::new(1.0, 0.0, 0.0), 0.2), - (Vec3::new(1.0, 0.0, 0.0), PI - 0.1), - (Vec3::new(0.0, 1.0, 0.0), PI - 0.1), - (Vec3::new(0.0, 0.0, 1.0), PI - 0.1), - ] { - let q = Quat::from_axis_angle(axis, angle).expect("non-zero axis"); - let recovered = - Quat::from_mat3(Mat3::from_quat(q)).expect("rotation matrix is accepted"); - let v = Vec3::new(0.5, 1.5, -2.5); - assert_close(recovered.rotate(v), q.rotate(v)); - } - } - - #[test] - fn from_mat3_rejects_non_rotations() { - let scaled = Mat3::from_diagonal(Vec3::splat(2.0)); - assert_eq!(Quat::from_mat3(scaled), None); - assert_eq!(Quat::from_mat3(Mat3::ZERO), None); - } - - #[test] - fn slerp_walks_the_short_arc_uniformly() { - let start = Quat::IDENTITY; - let end = Quat::from_rotation_z(PI); - let half = start.slerp(end, 0.5); - assert!((half.angle() - FRAC_PI_2).abs() < 1e-9); - assert_close(half.rotate(Vec3::X), Vec3::Y); - } - - #[test] - fn slerp_selects_the_short_arc_when_endpoints_are_negated() { - let start = Quat::IDENTITY; - let end = Quat::new(-1.0, 0.0, 0.0, 0.0); - // The two quaternions describe the same rotation, so any - // interpolation between them must stay at the identity. - let middle = start.slerp(end, 0.5); - assert_close(middle.rotate(Vec3::X), Vec3::X); - } - - #[test] - fn slerp_hits_both_endpoints() { - let start = Quat::from_rotation_x(0.3); - let end = Quat::from_rotation_y(1.2); - assert_quat_close(start.slerp(end, 0.0), start); - assert_quat_close(start.slerp(end, 1.0), end); - } - - #[test] - fn slerp_handles_nearly_parallel_inputs() { - let start = Quat::IDENTITY; - let end = Quat::from_rotation_z(1e-9); - let middle = start.slerp(end, 0.5); - assert!(middle.is_normalized()); - } - - #[test] - fn display_lists_the_components() { - assert_eq!(Quat::new(1.0, 0.0, 0.0, 0.0).to_string(), "(1, 0, 0, 0)"); - } - - #[test] - fn finiteness_is_reported() { - assert!(Quat::IDENTITY.is_finite()); - assert!(!Quat::new(f64::INFINITY, 0.0, 0.0, 0.0).is_finite()); - } - - #[test] - fn named_turns_behave_as_documented() { - assert_close(Quat::quarter_turn_x().rotate(Vec3::Y), Vec3::Z); - assert_close(Quat::half_turn_z().rotate(Vec3::X), -Vec3::X); - assert!(Quat::quarter_turn_x().is_normalized()); - assert!(Quat::half_turn_z().is_normalized()); - } -} diff --git a/crates/foundation/apsis-math/src/vec3.rs b/crates/foundation/apsis-math/src/vec3.rs index e596dcf..8b665e6 100644 --- a/crates/foundation/apsis-math/src/vec3.rs +++ b/crates/foundation/apsis-math/src/vec3.rs @@ -244,173 +244,3 @@ impl fmt::Display for Vec3 { write!(f, "({}, {}, {})", self.x, self.y, self.z) } } - -#[cfg(test)] -mod tests { - use super::Vec3; - - const EPS: f64 = 1e-12; - - fn assert_close(lhs: Vec3, rhs: Vec3) { - assert!((lhs - rhs).norm() < EPS, "left {lhs}, right {rhs}"); - } - - #[test] - fn constructors_set_the_components() { - assert_eq!( - Vec3::new(1.0, 2.0, 3.0), - Vec3 { - x: 1.0, - y: 2.0, - z: 3.0 - } - ); - assert_eq!(Vec3::splat(2.0), Vec3::new(2.0, 2.0, 2.0)); - assert_eq!(Vec3::default(), Vec3::ZERO); - } - - #[test] - fn get_selects_by_index_and_rejects_out_of_range() { - let v = Vec3::new(1.0, 2.0, 3.0); - assert_eq!(v.get(0), Some(1.0)); - assert_eq!(v.get(1), Some(2.0)); - assert_eq!(v.get(2), Some(3.0)); - assert_eq!(v.get(3), None); - } - - #[test] - fn dot_product_is_symmetric() { - let a = Vec3::new(1.0, 2.0, 3.0); - let b = Vec3::new(-4.0, 5.0, 0.5); - assert!((a.dot(b) - b.dot(a)).abs() < EPS); - assert!((a.dot(b) - 7.5).abs() < EPS); - } - - #[test] - fn cross_product_follows_the_right_hand_rule() { - assert_eq!(Vec3::X.cross(Vec3::Y), Vec3::Z); - assert_eq!(Vec3::Y.cross(Vec3::Z), Vec3::X); - assert_eq!(Vec3::Z.cross(Vec3::X), Vec3::Y); - // The cross product of parallel vectors vanishes. - assert_eq!(Vec3::X.cross(Vec3::X), Vec3::ZERO); - } - - #[test] - fn cross_product_is_orthogonal_to_its_inputs() { - let a = Vec3::new(1.0, -2.0, 3.5); - let b = Vec3::new(0.5, 4.0, -1.0); - let c = a.cross(b); - assert!(c.dot(a).abs() < EPS); - assert!(c.dot(b).abs() < EPS); - } - - #[test] - fn cross_product_is_antisymmetric() { - let a = Vec3::new(1.0, 2.0, 3.0); - let b = Vec3::new(4.0, 5.0, 6.0); - assert_eq!(a.cross(b), -b.cross(a)); - } - - #[test] - fn norm_matches_the_pythagorean_length() { - let v = Vec3::new(3.0, 4.0, 12.0); - assert!((v.norm_squared() - 169.0).abs() < EPS); - assert!((v.norm() - 13.0).abs() < EPS); - } - - #[test] - fn normalize_produces_a_unit_vector() { - let v = Vec3::new(-3.0, 0.0, 4.0); - let unit = v.normalize().expect("non-zero vector has a direction"); - assert!((unit.norm() - 1.0).abs() < EPS); - assert_close(unit, Vec3::new(-0.6, 0.0, 0.8)); - } - - #[test] - fn normalize_rejects_undefined_directions() { - assert_eq!(Vec3::ZERO.normalize(), None); - assert_eq!(Vec3::splat(f64::INFINITY).normalize(), None); - assert_eq!(Vec3::splat(f64::NAN).normalize(), None); - } - - #[test] - fn distance_measures_separation() { - let a = Vec3::new(1.0, 1.0, 1.0); - let b = Vec3::new(1.0, 1.0, 4.0); - assert!((a.distance(b) - 3.0).abs() < EPS); - assert!((a.distance_squared(b) - 9.0).abs() < EPS); - } - - #[test] - fn angle_between_uses_the_right_hand_geometry() { - let angle = Vec3::X - .angle_between(Vec3::Y) - .expect("both axes have unit length"); - assert!((angle - core::f64::consts::FRAC_PI_2).abs() < EPS); - let opposite = Vec3::X - .angle_between(-Vec3::X) - .expect("both vectors have unit length"); - assert!((opposite - core::f64::consts::PI).abs() < EPS); - let parallel = Vec3::X - .angle_between(Vec3::X) - .expect("both vectors have unit length"); - assert!(parallel.abs() < EPS); - } - - #[test] - fn angle_between_rejects_undefined_directions() { - assert_eq!(Vec3::ZERO.angle_between(Vec3::X), None); - assert_eq!(Vec3::X.angle_between(Vec3::ZERO), None); - } - - #[test] - fn lerp_hits_both_endpoints() { - let a = Vec3::new(0.0, 0.0, 0.0); - let b = Vec3::new(2.0, 4.0, 6.0); - assert_eq!(a.lerp(b, 0.0), a); - assert_eq!(a.lerp(b, 1.0), b); - assert_eq!(a.lerp(b, 0.5), Vec3::new(1.0, 2.0, 3.0)); - } - - #[test] - fn component_reductions_report_extremes() { - let v = Vec3::new(-2.0, 5.0, 1.0); - assert!((v.max_component() - 5.0).abs() < EPS); - assert!((v.min_component() + 2.0).abs() < EPS); - assert_close(v.abs(), Vec3::new(2.0, 5.0, 1.0)); - } - - #[test] - fn finiteness_is_reported() { - assert!(Vec3::new(1.0, 2.0, 3.0).is_finite()); - assert!(!Vec3::new(1.0, f64::NAN, 3.0).is_finite()); - assert!(!Vec3::splat(f64::INFINITY).is_finite()); - } - - #[test] - fn arithmetic_operators_match_component_wise_results() { - let a = Vec3::new(1.0, 2.0, 3.0); - let b = Vec3::new(4.0, 5.0, 6.0); - assert_eq!(a + b, Vec3::new(5.0, 7.0, 9.0)); - assert_eq!(b - a, Vec3::new(3.0, 3.0, 3.0)); - assert_eq!(-a, Vec3::new(-1.0, -2.0, -3.0)); - assert_eq!(a * 2.0, Vec3::new(2.0, 4.0, 6.0)); - assert_eq!(2.0 * a, a * 2.0); - assert_eq!(b / 2.0, Vec3::new(2.0, 2.5, 3.0)); - - let mut acc = a; - acc += b; - assert_eq!(acc, a + b); - acc -= b; - assert_eq!(acc, a); - acc *= 3.0; - assert_eq!(acc, a * 3.0); - acc /= 3.0; - assert_eq!(acc, a); - } - - #[test] - fn display_lists_the_components() { - assert_eq!(Vec3::new(1.0, 2.5, -3.0).to_string(), "(1, 2.5, -3)"); - } -} diff --git a/crates/foundation/apsis-math/tests/mat3.rs b/crates/foundation/apsis-math/tests/mat3.rs new file mode 100644 index 0000000..381f67d --- /dev/null +++ b/crates/foundation/apsis-math/tests/mat3.rs @@ -0,0 +1,204 @@ +//! Tests for three by three matrices. + +use apsis_math::{Mat3, Quat, Vec3}; +use core::f64::consts::FRAC_PI_2; + +const EPS: f64 = 1e-12; + +fn assert_mat_close(lhs: Mat3, rhs: Mat3) { + for row in 0..3 { + for column in 0..3 { + let (lhs, rhs) = ( + lhs.get(row, column).expect("indices are in range"), + rhs.get(row, column).expect("indices are in range"), + ); + assert!( + (lhs - rhs).abs() < EPS, + "at ({row}, {column}): {lhs} vs {rhs}" + ); + } + } +} + +#[test] +fn constructors_place_entries_in_the_right_slots() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + assert_eq!(m.get(0, 0), Some(1.0)); + assert_eq!(m.get(0, 1), Some(2.0)); + assert_eq!(m.get(0, 2), Some(3.0)); + assert_eq!(m.get(1, 0), Some(4.0)); + assert_eq!(m.get(2, 2), Some(9.0)); + assert_eq!(m.get(3, 0), None); + assert_eq!(m.get(0, 3), None); + + assert_eq!(m.row(1), Some(Vec3::new(4.0, 5.0, 6.0))); + assert_eq!(m.column(1), Some(Vec3::new(2.0, 5.0, 8.0))); + assert_eq!(m.row(3), None); + assert_eq!(m.column(3), None); +} + +#[test] +fn from_columns_matches_from_rows() { + let c0 = Vec3::new(1.0, 4.0, 7.0); + let c1 = Vec3::new(2.0, 5.0, 8.0); + let c2 = Vec3::new(3.0, 6.0, 9.0); + let expected = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + assert_eq!(Mat3::from_columns(c0, c1, c2), expected); +} + +#[test] +fn identity_is_the_multiplicative_unit() { + let m = Mat3::from_rotation_z(FRAC_PI_2); + assert_eq!(Mat3::IDENTITY * m, m); + assert_eq!(m * Mat3::IDENTITY, m); + assert_eq!(Mat3::IDENTITY.determinant(), 1.0); + assert_eq!(Mat3::default(), Mat3::ZERO); +} + +#[test] +fn zero_annihilates() { + assert_eq!(Mat3::ZERO * Vec3::new(1.0, 2.0, 3.0), Vec3::ZERO); + assert_eq!(Mat3::ZERO * Mat3::IDENTITY, Mat3::ZERO); + assert_eq!(Mat3::ZERO.determinant(), 0.0); +} + +#[test] +fn transpose_swaps_rows_and_columns() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + let t = m.transpose(); + assert_eq!(t.row(0), m.column(0)); + assert_eq!(t.row(1), m.column(1)); + assert_eq!(t.row(2), m.column(2)); + assert_eq!(t.transpose(), m); +} + +#[test] +fn determinant_matches_hand_computation() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 10.0]); + assert!((m.determinant() + 3.0).abs() < EPS); + assert_eq!( + Mat3::from_diagonal(Vec3::new(2.0, 3.0, 4.0)).determinant(), + 24.0 + ); +} + +#[test] +fn trace_sums_the_diagonal() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + assert_eq!(m.trace(), 15.0); +} + +#[test] +fn matrix_vector_product_uses_rows() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + let v = Vec3::new(1.0, 1.0, 1.0); + assert_eq!(m * v, Vec3::new(6.0, 15.0, 24.0)); +} + +#[test] +fn matrix_product_is_associative() { + let a = Mat3::from_rows([1.0, 2.0, 0.0], [0.0, 1.0, 3.0], [1.0, 0.0, 1.0]); + let b = Mat3::from_rows([0.0, 1.0, 2.0], [1.0, 0.0, 1.0], [2.0, 1.0, 0.0]); + let c = Mat3::from_rows([1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]); + assert_eq!((a * b) * c, a * (b * c)); +} + +#[test] +fn matrix_product_preserves_the_determinant_product() { + let a = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 10.0]); + let b = Mat3::from_rows([2.0, 0.0, 1.0], [1.0, 3.0, 2.0], [0.0, 1.0, 4.0]); + let expected = a.determinant() * b.determinant(); + assert!(((a * b).determinant() - expected).abs() < 1e-9); +} + +#[test] +fn scalar_product_scales_every_entry() { + let m = Mat3::from_rows([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]); + let scaled = m * 2.0; + assert_eq!(scaled.get(0, 0), Some(2.0)); + assert_eq!(scaled.get(2, 2), Some(18.0)); + assert_eq!(2.0 * m, scaled); +} + +#[test] +fn rotation_matrices_satisfy_the_rotation_predicate() { + let m = Mat3::from_rotation_z(FRAC_PI_2); + assert!(m.is_rotation(1e-12)); + assert!((m.determinant() - 1.0).abs() < EPS); + assert_mat_close(m * m.transpose(), Mat3::IDENTITY); +} + +#[test] +fn is_rotation_rejects_non_rotations() { + assert!(!Mat3::ZERO.is_rotation(1e-9)); + assert!(!Mat3::from_diagonal(Vec3::splat(2.0)).is_rotation(1e-9)); + // A reflection has determinant minus one. + assert!(!Mat3::from_diagonal(Vec3::new(1.0, 1.0, -1.0)).is_rotation(1e-9)); +} + +#[test] +fn axis_angle_matches_the_quaternion_rotation() { + let axis = Vec3::new(0.5, -2.0, 1.0); + let m = Mat3::from_axis_angle(axis, 0.9).expect("non-zero axis"); + let q = Quat::from_axis_angle(axis, 0.9).expect("non-zero axis"); + let v = Vec3::new(1.0, -2.0, 3.0); + assert!((m * v - q.rotate(v)).norm() < EPS); + assert_eq!(Mat3::from_axis_angle(Vec3::ZERO, 1.0), None); +} + +#[test] +fn rotation_maps_axes_as_expected() { + let m = Mat3::from_rotation_z(FRAC_PI_2); + assert!((m * Vec3::X - Vec3::Y).norm() < EPS); + assert!((m * Vec3::Y + Vec3::X).norm() < EPS); + assert!((m * Vec3::Z - Vec3::Z).norm() < EPS); +} + +#[test] +fn per_axis_constructors_agree_with_the_axis_angle_form() { + let angle = 0.6; + let about_x = Mat3::from_rotation_x(angle); + assert_eq!( + about_x, + Mat3::from_axis_angle(Vec3::X, angle).expect("unit axis") + ); + assert_eq!(about_x * Vec3::X, Vec3::X); + + let about_y = Mat3::from_rotation_y(angle); + assert_eq!( + about_y, + Mat3::from_axis_angle(Vec3::Y, angle).expect("unit axis") + ); + assert_eq!(about_y * Vec3::Y, Vec3::Y); + + let about_z = Mat3::from_rotation_z(angle); + assert_eq!( + about_z, + Mat3::from_axis_angle(Vec3::Z, angle).expect("unit axis") + ); + assert_eq!(about_z * Vec3::Z, Vec3::Z); + assert!((about_z.determinant() - 1.0).abs() < EPS); +} + +#[test] +fn finiteness_is_reported() { + assert!(Mat3::IDENTITY.is_finite()); + let mut m = Mat3::IDENTITY; + m.m01 = f64::NAN; + assert!(!m.is_finite()); +} + +#[test] +fn display_uses_row_major_groups() { + let m = Mat3::from_diagonal(Vec3::new(1.0, 2.0, 3.0)); + assert_eq!(m.to_string(), "[1, 0, 0; 0, 2, 0; 0, 0, 3]"); +} + +#[test] +fn from_quat_is_consistent_with_quaternion_rotation() { + let q = Quat::from_axis_angle(Vec3::new(1.0, 1.0, 0.0), 1.3).expect("non-zero axis"); + let m = Mat3::from_quat(q); + let v = Vec3::new(2.0, -1.0, 0.5); + assert!((m * v - q.rotate(v)).norm() < EPS); + assert!(m.is_rotation(1e-12)); +} diff --git a/crates/foundation/apsis-math/tests/quat.rs b/crates/foundation/apsis-math/tests/quat.rs new file mode 100644 index 0000000..1bbf16d --- /dev/null +++ b/crates/foundation/apsis-math/tests/quat.rs @@ -0,0 +1,217 @@ +//! Tests for unit quaternion rotations. + +use apsis_math::{Mat3, Quat, Vec3}; +use core::f64::consts::{FRAC_PI_2, PI}; + +const EPS: f64 = 1e-12; + +fn assert_close(lhs: Vec3, rhs: Vec3) { + assert!((lhs - rhs).norm() < EPS, "left {lhs}, right {rhs}"); +} + +fn assert_quat_close(lhs: Quat, rhs: Quat) { + let difference = Quat::new(lhs.w - rhs.w, lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z); + assert!(difference.norm() < EPS, "left {lhs}, right {rhs}"); +} + +#[test] +fn identity_rotates_nothing() { + let v = Vec3::new(1.0, 2.0, 3.0); + assert_close(Quat::IDENTITY.rotate(v), v); + assert_eq!(Quat::IDENTITY.angle(), 0.0); + assert_eq!(Quat::IDENTITY.axis(), None); +} + +#[test] +fn axis_angle_round_trips() { + let axis = Vec3::new(1.0, 2.0, -2.0) + .normalize() + .expect("non-zero axis"); + let q = Quat::from_axis_angle(axis, 0.7).expect("non-zero axis"); + assert!((q.angle() - 0.7).abs() < EPS); + assert_close(q.axis().expect("rotation has an axis"), axis); + assert!(q.is_normalized()); +} + +#[test] +fn axis_angle_rejects_undefined_axes() { + assert_eq!(Quat::from_axis_angle(Vec3::ZERO, 1.0), None); + assert_eq!(Quat::from_axis_angle(Vec3::splat(f64::NAN), 1.0), None); +} + +#[test] +fn quarter_turn_about_z_maps_x_to_y() { + let q = Quat::from_rotation_z(FRAC_PI_2); + assert_close(q.rotate(Vec3::X), Vec3::Y); + assert_close(q.rotate(Vec3::Y), -Vec3::X); + assert_close(q.rotate(Vec3::Z), Vec3::Z); +} + +#[test] +fn quarter_turn_about_x_maps_y_to_z() { + let q = Quat::from_rotation_x(FRAC_PI_2); + assert_close(q.rotate(Vec3::Y), Vec3::Z); + assert_close(q.rotate(Vec3::Z), -Vec3::Y); +} + +#[test] +fn quarter_turn_about_y_maps_z_to_x() { + let q = Quat::from_rotation_y(FRAC_PI_2); + assert_close(q.rotate(Vec3::Z), Vec3::X); + assert_close(q.rotate(Vec3::X), -Vec3::Z); +} + +#[test] +fn rotation_preserves_length() { + let q = Quat::from_axis_angle(Vec3::new(1.0, -1.0, 0.5), 1.234).expect("non-zero axis"); + let v = Vec3::new(3.0, -4.0, 12.0); + assert!((q.rotate(v).norm() - v.norm()).abs() < EPS); +} + +#[test] +fn conjugate_undoes_the_rotation() { + let q = Quat::from_axis_angle(Vec3::new(0.0, 1.0, 1.0), 0.9).expect("non-zero axis"); + let v = Vec3::new(1.0, -2.0, 0.5); + assert_close(q.conjugate().rotate(q.rotate(v)), v); +} + +#[test] +fn inverse_undoes_the_rotation() { + let q = Quat::from_axis_angle(Vec3::new(-0.5, 1.0, 0.25), 2.0).expect("non-zero axis"); + let v = Vec3::new(2.0, 0.0, -1.0); + let inverse = q.inverse().expect("unit quaternion is invertible"); + assert_close(inverse.rotate(q.rotate(v)), v); +} + +#[test] +fn inverse_rejects_degenerate_quaternions() { + assert_eq!(Quat::new(0.0, 0.0, 0.0, 0.0).inverse(), None); + assert_eq!(Quat::new(f64::NAN, 0.0, 0.0, 0.0).inverse(), None); +} + +#[test] +fn multiplication_composes_right_to_left() { + let first = Quat::from_rotation_z(FRAC_PI_2); + let second = Quat::from_rotation_x(FRAC_PI_2); + let v = Vec3::new(1.0, 0.0, 0.0); + // `second * first` applies `first` first, then `second`. + assert_close((second * first).rotate(v), second.rotate(first.rotate(v))); +} + +#[test] +fn identity_is_the_multiplicative_unit() { + let q = Quat::from_rotation_y(0.4); + assert_quat_close(q * Quat::IDENTITY, q); + assert_quat_close(Quat::IDENTITY * q, q); +} + +#[test] +fn multiplication_matches_matrix_composition() { + let a = Quat::from_axis_angle(Vec3::new(1.0, 0.5, -0.25), 0.8).expect("non-zero axis"); + let b = Quat::from_axis_angle(Vec3::new(-0.5, 1.0, 0.75), 1.6).expect("non-zero axis"); + let composed = a * b; + let v = Vec3::new(1.0, 2.0, 3.0); + let via_matrices = Mat3::from_quat(a) * (Mat3::from_quat(b) * v); + assert_close(composed.rotate(v), via_matrices); +} + +#[test] +fn normalize_produces_unit_length() { + let q = Quat::new(2.0, 0.0, 0.0, 0.0); + let unit = q.normalize().expect("non-zero quaternion"); + assert!(unit.is_normalized()); + assert!((unit.w - 1.0).abs() < EPS); +} + +#[test] +fn normalize_rejects_degenerate_quaternions() { + assert_eq!(Quat::new(0.0, 0.0, 0.0, 0.0).normalize(), None); + assert_eq!(Quat::new(f64::NAN, 0.0, 0.0, 0.0).normalize(), None); +} + +#[test] +fn from_mat3_recovers_the_original_rotation() { + let q = Quat::from_axis_angle(Vec3::new(0.3, -0.4, 0.5), 1.1).expect("non-zero axis"); + let recovered = Quat::from_mat3(Mat3::from_quat(q)).expect("rotation matrix is accepted"); + // `q` and `-q` are the same rotation, so compare through their effect. + let v = Vec3::new(1.0, -2.0, 3.0); + assert_close(recovered.rotate(v), q.rotate(v)); +} + +#[test] +fn from_mat3_covers_every_branch() { + // These angles exercise the trace branch and each of the three + // largest-diagonal branches. + for (axis, angle) in [ + (Vec3::new(1.0, 0.0, 0.0), 0.2), + (Vec3::new(1.0, 0.0, 0.0), PI - 0.1), + (Vec3::new(0.0, 1.0, 0.0), PI - 0.1), + (Vec3::new(0.0, 0.0, 1.0), PI - 0.1), + ] { + let q = Quat::from_axis_angle(axis, angle).expect("non-zero axis"); + let recovered = Quat::from_mat3(Mat3::from_quat(q)).expect("rotation matrix is accepted"); + let v = Vec3::new(0.5, 1.5, -2.5); + assert_close(recovered.rotate(v), q.rotate(v)); + } +} + +#[test] +fn from_mat3_rejects_non_rotations() { + let scaled = Mat3::from_diagonal(Vec3::splat(2.0)); + assert_eq!(Quat::from_mat3(scaled), None); + assert_eq!(Quat::from_mat3(Mat3::ZERO), None); +} + +#[test] +fn slerp_walks_the_short_arc_uniformly() { + let start = Quat::IDENTITY; + let end = Quat::from_rotation_z(PI); + let half = start.slerp(end, 0.5); + assert!((half.angle() - FRAC_PI_2).abs() < 1e-9); + assert_close(half.rotate(Vec3::X), Vec3::Y); +} + +#[test] +fn slerp_selects_the_short_arc_when_endpoints_are_negated() { + let start = Quat::IDENTITY; + let end = Quat::new(-1.0, 0.0, 0.0, 0.0); + // The two quaternions describe the same rotation, so any + // interpolation between them must stay at the identity. + let middle = start.slerp(end, 0.5); + assert_close(middle.rotate(Vec3::X), Vec3::X); +} + +#[test] +fn slerp_hits_both_endpoints() { + let start = Quat::from_rotation_x(0.3); + let end = Quat::from_rotation_y(1.2); + assert_quat_close(start.slerp(end, 0.0), start); + assert_quat_close(start.slerp(end, 1.0), end); +} + +#[test] +fn slerp_handles_nearly_parallel_inputs() { + let start = Quat::IDENTITY; + let end = Quat::from_rotation_z(1e-9); + let middle = start.slerp(end, 0.5); + assert!(middle.is_normalized()); +} + +#[test] +fn display_lists_the_components() { + assert_eq!(Quat::new(1.0, 0.0, 0.0, 0.0).to_string(), "(1, 0, 0, 0)"); +} + +#[test] +fn finiteness_is_reported() { + assert!(Quat::IDENTITY.is_finite()); + assert!(!Quat::new(f64::INFINITY, 0.0, 0.0, 0.0).is_finite()); +} + +#[test] +fn named_turns_behave_as_documented() { + assert_close(Quat::quarter_turn_x().rotate(Vec3::Y), Vec3::Z); + assert_close(Quat::half_turn_z().rotate(Vec3::X), -Vec3::X); + assert!(Quat::quarter_turn_x().is_normalized()); + assert!(Quat::half_turn_z().is_normalized()); +} diff --git a/crates/foundation/apsis-math/tests/vec3.rs b/crates/foundation/apsis-math/tests/vec3.rs new file mode 100644 index 0000000..34ec450 --- /dev/null +++ b/crates/foundation/apsis-math/tests/vec3.rs @@ -0,0 +1,168 @@ +//! Tests for three component vectors. + +use apsis_math::Vec3; + +const EPS: f64 = 1e-12; + +fn assert_close(lhs: Vec3, rhs: Vec3) { + assert!((lhs - rhs).norm() < EPS, "left {lhs}, right {rhs}"); +} + +#[test] +fn constructors_set_the_components() { + assert_eq!( + Vec3::new(1.0, 2.0, 3.0), + Vec3 { + x: 1.0, + y: 2.0, + z: 3.0 + } + ); + assert_eq!(Vec3::splat(2.0), Vec3::new(2.0, 2.0, 2.0)); + assert_eq!(Vec3::default(), Vec3::ZERO); +} + +#[test] +fn get_selects_by_index_and_rejects_out_of_range() { + let v = Vec3::new(1.0, 2.0, 3.0); + assert_eq!(v.get(0), Some(1.0)); + assert_eq!(v.get(1), Some(2.0)); + assert_eq!(v.get(2), Some(3.0)); + assert_eq!(v.get(3), None); +} + +#[test] +fn dot_product_is_symmetric() { + let a = Vec3::new(1.0, 2.0, 3.0); + let b = Vec3::new(-4.0, 5.0, 0.5); + assert!((a.dot(b) - b.dot(a)).abs() < EPS); + assert!((a.dot(b) - 7.5).abs() < EPS); +} + +#[test] +fn cross_product_follows_the_right_hand_rule() { + assert_eq!(Vec3::X.cross(Vec3::Y), Vec3::Z); + assert_eq!(Vec3::Y.cross(Vec3::Z), Vec3::X); + assert_eq!(Vec3::Z.cross(Vec3::X), Vec3::Y); + // The cross product of parallel vectors vanishes. + assert_eq!(Vec3::X.cross(Vec3::X), Vec3::ZERO); +} + +#[test] +fn cross_product_is_orthogonal_to_its_inputs() { + let a = Vec3::new(1.0, -2.0, 3.5); + let b = Vec3::new(0.5, 4.0, -1.0); + let c = a.cross(b); + assert!(c.dot(a).abs() < EPS); + assert!(c.dot(b).abs() < EPS); +} + +#[test] +fn cross_product_is_antisymmetric() { + let a = Vec3::new(1.0, 2.0, 3.0); + let b = Vec3::new(4.0, 5.0, 6.0); + assert_eq!(a.cross(b), -b.cross(a)); +} + +#[test] +fn norm_matches_the_pythagorean_length() { + let v = Vec3::new(3.0, 4.0, 12.0); + assert!((v.norm_squared() - 169.0).abs() < EPS); + assert!((v.norm() - 13.0).abs() < EPS); +} + +#[test] +fn normalize_produces_a_unit_vector() { + let v = Vec3::new(-3.0, 0.0, 4.0); + let unit = v.normalize().expect("non-zero vector has a direction"); + assert!((unit.norm() - 1.0).abs() < EPS); + assert_close(unit, Vec3::new(-0.6, 0.0, 0.8)); +} + +#[test] +fn normalize_rejects_undefined_directions() { + assert_eq!(Vec3::ZERO.normalize(), None); + assert_eq!(Vec3::splat(f64::INFINITY).normalize(), None); + assert_eq!(Vec3::splat(f64::NAN).normalize(), None); +} + +#[test] +fn distance_measures_separation() { + let a = Vec3::new(1.0, 1.0, 1.0); + let b = Vec3::new(1.0, 1.0, 4.0); + assert!((a.distance(b) - 3.0).abs() < EPS); + assert!((a.distance_squared(b) - 9.0).abs() < EPS); +} + +#[test] +fn angle_between_uses_the_right_hand_geometry() { + let angle = Vec3::X + .angle_between(Vec3::Y) + .expect("both axes have unit length"); + assert!((angle - core::f64::consts::FRAC_PI_2).abs() < EPS); + let opposite = Vec3::X + .angle_between(-Vec3::X) + .expect("both vectors have unit length"); + assert!((opposite - core::f64::consts::PI).abs() < EPS); + let parallel = Vec3::X + .angle_between(Vec3::X) + .expect("both vectors have unit length"); + assert!(parallel.abs() < EPS); +} + +#[test] +fn angle_between_rejects_undefined_directions() { + assert_eq!(Vec3::ZERO.angle_between(Vec3::X), None); + assert_eq!(Vec3::X.angle_between(Vec3::ZERO), None); +} + +#[test] +fn lerp_hits_both_endpoints() { + let a = Vec3::new(0.0, 0.0, 0.0); + let b = Vec3::new(2.0, 4.0, 6.0); + assert_eq!(a.lerp(b, 0.0), a); + assert_eq!(a.lerp(b, 1.0), b); + assert_eq!(a.lerp(b, 0.5), Vec3::new(1.0, 2.0, 3.0)); +} + +#[test] +fn component_reductions_report_extremes() { + let v = Vec3::new(-2.0, 5.0, 1.0); + assert!((v.max_component() - 5.0).abs() < EPS); + assert!((v.min_component() + 2.0).abs() < EPS); + assert_close(v.abs(), Vec3::new(2.0, 5.0, 1.0)); +} + +#[test] +fn finiteness_is_reported() { + assert!(Vec3::new(1.0, 2.0, 3.0).is_finite()); + assert!(!Vec3::new(1.0, f64::NAN, 3.0).is_finite()); + assert!(!Vec3::splat(f64::INFINITY).is_finite()); +} + +#[test] +fn arithmetic_operators_match_component_wise_results() { + let a = Vec3::new(1.0, 2.0, 3.0); + let b = Vec3::new(4.0, 5.0, 6.0); + assert_eq!(a + b, Vec3::new(5.0, 7.0, 9.0)); + assert_eq!(b - a, Vec3::new(3.0, 3.0, 3.0)); + assert_eq!(-a, Vec3::new(-1.0, -2.0, -3.0)); + assert_eq!(a * 2.0, Vec3::new(2.0, 4.0, 6.0)); + assert_eq!(2.0 * a, a * 2.0); + assert_eq!(b / 2.0, Vec3::new(2.0, 2.5, 3.0)); + + let mut acc = a; + acc += b; + assert_eq!(acc, a + b); + acc -= b; + assert_eq!(acc, a); + acc *= 3.0; + assert_eq!(acc, a * 3.0); + acc /= 3.0; + assert_eq!(acc, a); +} + +#[test] +fn display_lists_the_components() { + assert_eq!(Vec3::new(1.0, 2.5, -3.0).to_string(), "(1, 2.5, -3)"); +}