diff --git a/crates/lambda-rs/examples/reflective_room.rs b/crates/lambda-rs/examples/reflective_room.rs index 809a84ac..acc2171c 100644 --- a/crates/lambda-rs/examples/reflective_room.rs +++ b/crates/lambda-rs/examples/reflective_room.rs @@ -317,7 +317,8 @@ impl Component for ReflectiveRoomExample { lambda::math::matrix::identity_matrix(4, 4), [1.0, 0.0, 0.0], -self.camera_pitch_turns, - ); + ) + .expect("rotation axis must be a unit axis vector"); let view = rot_x.multiply(&compute_view_matrix(camera.position)); let projection = compute_perspective_projection( camera.field_of_view_in_turns, @@ -360,7 +361,8 @@ impl Component for ReflectiveRoomExample { model_floor, [1.0, 0.0, 0.0], self.floor_tilt_turns, - ); + ) + .expect("rotation axis must be a unit axis vector"); let mvp_floor = projection.multiply(&view).multiply(&model_floor); let viewport = ViewportBuilder::new().build(self.width, self.height); diff --git a/crates/lambda-rs/examples/textured_cube.rs b/crates/lambda-rs/examples/textured_cube.rs index cefec77c..8a4fc8a4 100644 --- a/crates/lambda-rs/examples/textured_cube.rs +++ b/crates/lambda-rs/examples/textured_cube.rs @@ -411,12 +411,14 @@ impl Component for TexturedCubeExample { model, [0.0, 1.0, 0.0], angle_y_turns, - ); + ) + .expect("rotation axis must be a unit axis vector"); model = lambda::math::matrix::rotate_matrix( model, [1.0, 0.0, 0.0], angle_x_turns, - ); + ) + .expect("rotation axis must be a unit axis vector"); let view = compute_view_matrix(camera.position); let projection = compute_perspective_projection( diff --git a/crates/lambda-rs/src/math/error.rs b/crates/lambda-rs/src/math/error.rs new file mode 100644 index 00000000..6c10720c --- /dev/null +++ b/crates/lambda-rs/src/math/error.rs @@ -0,0 +1,65 @@ +//! Math error types returned from fallible operations. + +use std::fmt; + +/// Errors returned by fallible math operations in `lambda-rs`. +#[derive(Debug, Clone, PartialEq)] +pub enum MathError { + /// Cross product requires exactly 3 dimensions. + CrossProductDimension { actual: usize }, + /// Cross product requires both vectors to have the same dimension. + MismatchedVectorDimensions { left: usize, right: usize }, + /// Rotation axis must be a unit axis vector (one of `[1,0,0]`, `[0,1,0]`, + /// `[0,0,1]`). A zero axis (`[0,0,0]`) is treated as "no rotation". + InvalidRotationAxis { axis: [f32; 3] }, + /// Rotation requires a 4x4 matrix. + InvalidRotationMatrixSize { rows: usize, cols: usize }, + /// Determinant requires a square matrix. + NonSquareMatrix { rows: usize, cols: usize }, + /// Determinant cannot be computed for an empty matrix. + EmptyMatrix, + /// Cannot normalize a zero-length vector. + ZeroLengthVector, +} + +impl fmt::Display for MathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MathError::CrossProductDimension { actual } => { + return write!(f, "Cross product requires 3D vectors, got {}D", actual); + } + MathError::MismatchedVectorDimensions { left, right } => { + return write!( + f, + "Vectors must have matching dimensions (left {}D, right {}D)", + left, right + ); + } + MathError::InvalidRotationAxis { axis } => { + return write!(f, "Rotation axis {:?} is not a unit axis vector", axis); + } + MathError::InvalidRotationMatrixSize { rows, cols } => { + return write!( + f, + "Rotation requires a 4x4 matrix, got {}x{}", + rows, cols + ); + } + MathError::NonSquareMatrix { rows, cols } => { + return write!( + f, + "Determinant requires square matrix, got {}x{}", + rows, cols + ); + } + MathError::EmptyMatrix => { + return write!(f, "Determinant requires a non-empty matrix"); + } + MathError::ZeroLengthVector => { + return write!(f, "Cannot normalize a zero-length vector"); + } + } + } +} + +impl std::error::Error for MathError {} diff --git a/crates/lambda-rs/src/math/matrix.rs b/crates/lambda-rs/src/math/matrix.rs index f8550d19..106cff81 100644 --- a/crates/lambda-rs/src/math/matrix.rs +++ b/crates/lambda-rs/src/math/matrix.rs @@ -3,6 +3,7 @@ use super::{ turns_to_radians, vector::Vector, + MathError, }; // -------------------------------- MATRIX ------------------------------------- @@ -17,7 +18,10 @@ pub trait Matrix { fn transpose(&self) -> Self; fn inverse(&self) -> Self; fn transform(&self, other: &V) -> V; - fn determinant(&self) -> f32; + /// Compute the determinant of the matrix. + /// + /// Returns an error when the matrix is empty or not square. + fn determinant(&self) -> Result; fn size(&self) -> (usize, usize); fn row(&self, row: usize) -> &V; fn at(&self, row: usize, column: usize) -> V::Scalar; @@ -84,70 +88,67 @@ pub fn translation_matrix< /// Rotates the input matrix by the given number of turns around the given axis. /// The axis must be a unit vector and the turns must be in the range [0, 1). /// The rotation is counter-clockwise when looking down the axis. +/// +/// Returns an error when the matrix is not 4x4, or when `axis_to_rotate` is not +/// a unit axis vector (`[1,0,0]`, `[0,1,0]`, `[0,0,1]`). A zero axis (`[0,0,0]`) +/// is treated as "no rotation". pub fn rotate_matrix< - InputVector: Vector, - ResultingVector: Vector, - OutputMatrix: Matrix + Default + Clone, + V: Vector, + MatrixLike: Matrix + Default + Clone, >( - matrix_to_rotate: OutputMatrix, - axis_to_rotate: InputVector, + matrix_to_rotate: MatrixLike, + axis_to_rotate: [f32; 3], angle_in_turns: f32, -) -> OutputMatrix { +) -> Result { let (rows, columns) = matrix_to_rotate.size(); - assert_eq!(rows, columns, "Matrix must be square"); - assert_eq!(rows, 4, "Matrix must be 4x4"); - assert_eq!( - axis_to_rotate.size(), - 3, - "Axis vector must have 3 elements (x, y, z)" - ); + if rows != columns { + return Err(MathError::NonSquareMatrix { + rows, + cols: columns, + }); + } + if rows != 4 { + return Err(MathError::InvalidRotationMatrixSize { + rows, + cols: columns, + }); + } let angle_in_radians = turns_to_radians(angle_in_turns); let cosine_of_angle = angle_in_radians.cos(); let sin_of_angle = angle_in_radians.sin(); - let _t = 1.0 - cosine_of_angle; - let x = axis_to_rotate.at(0); - let y = axis_to_rotate.at(1); - let z = axis_to_rotate.at(2); - - let mut rotation_matrix = OutputMatrix::default(); - - let rotation = match (x as u8, y as u8, z as u8) { - (0, 0, 0) => { - // No rotation - return matrix_to_rotate; - } - (0, 0, 1) => { - // Rotate around z-axis - [ - [cosine_of_angle, sin_of_angle, 0.0, 0.0], - [-sin_of_angle, cosine_of_angle, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - } - (0, 1, 0) => { - // Rotate around y-axis - [ - [cosine_of_angle, 0.0, -sin_of_angle, 0.0], - [0.0, 1.0, 0.0, 0.0], - [sin_of_angle, 0.0, cosine_of_angle, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - } - (1, 0, 0) => { - // Rotate around x-axis - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, cosine_of_angle, sin_of_angle, 0.0], - [0.0, -sin_of_angle, cosine_of_angle, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - } - _ => { - panic!("Axis must be a unit vector") - } + let mut rotation_matrix = MatrixLike::default(); + let [x, y, z] = axis_to_rotate; + + let rotation = if axis_to_rotate == [0.0, 0.0, 0.0] { + return Ok(matrix_to_rotate); + } else if axis_to_rotate == [0.0, 0.0, 1.0] { + // Rotate around z-axis + [ + [cosine_of_angle, sin_of_angle, 0.0, 0.0], + [-sin_of_angle, cosine_of_angle, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } else if axis_to_rotate == [0.0, 1.0, 0.0] { + // Rotate around y-axis + [ + [cosine_of_angle, 0.0, -sin_of_angle, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin_of_angle, 0.0, cosine_of_angle, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } else if axis_to_rotate == [1.0, 0.0, 0.0] { + // Rotate around x-axis + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, cosine_of_angle, sin_of_angle, 0.0], + [0.0, -sin_of_angle, cosine_of_angle, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + } else { + return Err(MathError::InvalidRotationAxis { axis: [x, y, z] }); }; for (i, row) in rotation.iter().enumerate().take(rows) { @@ -156,7 +157,7 @@ pub fn rotate_matrix< } } - return matrix_to_rotate.multiply(&rotation_matrix); + return Ok(matrix_to_rotate.multiply(&rotation_matrix)); } /// Creates a 4x4 perspective matrix given the fov in turns (unit between @@ -325,40 +326,47 @@ where } /// Computes the determinant of any square matrix using Laplace expansion. - fn determinant(&self) -> f32 { - let (width, height) = - (self.as_ref()[0].as_ref().len(), self.as_ref().len()); + fn determinant(&self) -> Result { + let rows = self.as_ref().len(); + if rows == 0 { + return Err(MathError::EmptyMatrix); + } + + let cols = self.as_ref()[0].as_ref().len(); + if cols == 0 { + return Err(MathError::EmptyMatrix); + } - if width != height { - panic!("Cannot compute determinant of non-square matrix"); + if cols != rows { + return Err(MathError::NonSquareMatrix { rows, cols }); } - return match height { - 1 => self.as_ref()[0].as_ref()[0], + return match rows { + 1 => Ok(self.as_ref()[0].as_ref()[0]), 2 => { let a = self.at(0, 0); let b = self.at(0, 1); let c = self.at(1, 0); let d = self.at(1, 1); - a * d - b * c + return Ok(a * d - b * c); } _ => { let mut result = 0.0; - for i in 0..height { - let mut submatrix: Vec> = Vec::with_capacity(height - 1); - for j in 1..height { + for i in 0..rows { + let mut submatrix: Vec> = Vec::with_capacity(rows - 1); + for j in 1..rows { let mut row = Vec::new(); - for k in 0..height { + for k in 0..rows { if k != i { row.push(self.at(j, k)); } } submatrix.push(row); } - result += - self.at(0, i) * submatrix.determinant() * (-1.0_f32).powi(i as i32); + let sub_determinant = submatrix.determinant()?; + result += self.at(0, i) * sub_determinant * (-1.0_f32).powi(i as i32); } - result + return Ok(result); } }; } @@ -397,6 +405,7 @@ mod tests { use crate::math::{ matrix::translation_matrix, turns_to_radians, + MathError, }; #[test] @@ -438,17 +447,17 @@ mod tests { #[test] fn square_matrix_determinant() { let m = [[3.0, 8.0], [4.0, 6.0]]; - assert_eq!(m.determinant(), -14.0); + assert_eq!(m.determinant(), Ok(-14.0)); let m2 = [[6.0, 1.0, 1.0], [4.0, -2.0, 5.0], [2.0, 8.0, 7.0]]; - assert_eq!(m2.determinant(), -306.0); + assert_eq!(m2.determinant(), Ok(-306.0)); } #[test] fn non_square_matrix_determinant() { let m = [[3.0, 8.0], [4.0, 6.0], [0.0, 1.0]]; - let result = std::panic::catch_unwind(|| m.determinant()); - assert!(result.is_err()); + let result = m.determinant(); + assert_eq!(result, Err(MathError::NonSquareMatrix { rows: 3, cols: 2 })); } #[test] @@ -503,7 +512,8 @@ mod tests { fn rotate_matrices() { // Test a zero turn rotation. let matrix: [[f32; 4]; 4] = filled_matrix(4, 4, 1.0); - let rotated_matrix = rotate_matrix(matrix, [0.0, 0.0, 1.0], 0.0); + let rotated_matrix = + rotate_matrix(matrix, [0.0, 0.0, 1.0], 0.0).expect("valid axis"); assert_eq!(rotated_matrix, matrix); // Test a 90 degree rotation. @@ -513,7 +523,8 @@ mod tests { [9.0, 10.0, 11.0, 12.0], [13.0, 14.0, 15.0, 16.0], ]; - let rotated = rotate_matrix(matrix, [0.0, 1.0, 0.0], 0.25); + let rotated = + rotate_matrix(matrix, [0.0, 1.0, 0.0], 0.25).expect("valid axis"); let expected = [ [3.0, 1.9999999, -1.0000001, 4.0], [7.0, 5.9999995, -5.0000005, 8.0], diff --git a/crates/lambda-rs/src/math/mod.rs b/crates/lambda-rs/src/math/mod.rs index 17804a1a..9691506b 100644 --- a/crates/lambda-rs/src/math/mod.rs +++ b/crates/lambda-rs/src/math/mod.rs @@ -1,8 +1,11 @@ //! Lambda Math Types and operations +pub mod error; pub mod matrix; pub mod vector; +pub use error::MathError; + /// Angle units used by conversion helpers and matrix transforms. /// /// Prefer `Angle::Turns` for ergonomic quarter/half rotations when building diff --git a/crates/lambda-rs/src/math/vector.rs b/crates/lambda-rs/src/math/vector.rs index 3d235b51..ef2f5baa 100644 --- a/crates/lambda-rs/src/math/vector.rs +++ b/crates/lambda-rs/src/math/vector.rs @@ -1,5 +1,7 @@ //! Vector math types and functions. +use super::MathError; + /// Generalized Vector operations that can be implemented by any vector like /// type. pub trait Vector { @@ -8,9 +10,20 @@ pub trait Vector { fn subtract(&self, other: &Self) -> Self; fn scale(&self, scalar: Self::Scalar) -> Self; fn dot(&self, other: &Self) -> Self::Scalar; - fn cross(&self, other: &Self) -> Self; + /// Cross product of two vectors. + /// + /// Returns an error when the vectors are not 3D, or when the vectors have + /// mismatched dimensions. + fn cross(&self, other: &Self) -> Result + where + Self: Sized; fn length(&self) -> Self::Scalar; - fn normalize(&self) -> Self; + /// Normalize the vector to unit length. + /// + /// Returns an error when the vector has zero length. + fn normalize(&self) -> Result + where + Self: Sized; fn size(&self) -> usize; fn at(&self, index: usize) -> Self::Scalar; fn update(&mut self, index: usize, value: Self::Scalar); @@ -64,30 +77,32 @@ where return result; } - /// Cross product of two 3D vectors. Panics if the vectors are not 3D. - fn cross(&self, other: &Self) -> Self { - assert_eq!( - self.as_ref().len(), - other.as_ref().len(), - "Vectors must be the same length" - ); + /// Cross product of two 3D vectors. + /// + /// Returns an error when either vector is not 3D, or when the vectors have + /// mismatched dimensions. + fn cross(&self, other: &Self) -> Result { + let left_size = self.as_ref().len(); + let right_size = other.as_ref().len(); + if left_size != right_size { + return Err(MathError::MismatchedVectorDimensions { + left: left_size, + right: right_size, + }); + } let mut result = Self::default(); let a = self.as_ref(); let b = other.as_ref(); - // TODO: This is only for 3D vectors - match a.len() { - 3 => { - result.as_mut()[0] = a[1] * b[2] - a[2] * b[1]; - result.as_mut()[1] = a[2] * b[0] - a[0] * b[2]; - result.as_mut()[2] = a[0] * b[1] - a[1] * b[0]; - } - _ => { - panic!("Cross product is only defined for 3 dimensional vectors.") - } + if a.len() != 3 { + return Err(MathError::CrossProductDimension { actual: a.len() }); } - return result; + + result.as_mut()[0] = a[1] * b[2] - a[2] * b[1]; + result.as_mut()[1] = a[2] * b[0] - a[0] * b[2]; + result.as_mut()[2] = a[0] * b[1] - a[1] * b[0]; + return Ok(result); } fn length(&self) -> Self::Scalar { @@ -98,16 +113,18 @@ where result.sqrt() } - fn normalize(&self) -> Self { - assert_ne!(self.length(), 0.0, "Cannot normalize a zero length vector"); + fn normalize(&self) -> Result { let mut result = Self::default(); let length = self.length(); + if length == 0.0 { + return Err(MathError::ZeroLengthVector); + } self.as_ref().iter().enumerate().for_each(|(i, a)| { result.as_mut()[i] = a / length; }); - return result; + return Ok(result); } fn scale(&self, scalar: Self::Scalar) -> Self { @@ -135,6 +152,7 @@ where #[cfg(test)] mod tests { use super::Vector; + use crate::math::MathError; #[test] fn adding_vectors() { @@ -184,7 +202,7 @@ mod tests { let b = [4.0, 5.0, 6.0]; let c = [-3.0, 6.0, -3.0]; - let result = a.cross(&b); + let result = a.cross(&b).expect("cross product inputs are 3D vectors"); assert_eq!(result, c); } @@ -193,8 +211,8 @@ mod tests { let a = [1.0, 2.0]; let b = [4.0, 5.0]; - let result = std::panic::catch_unwind(|| a.cross(&b)); - assert!(result.is_err()); + let result = a.cross(&b); + assert_eq!(result, Err(MathError::CrossProductDimension { actual: 2 })); } #[test] @@ -215,7 +233,7 @@ mod tests { fn normalize() { let a = [4.0, 3.0, 2.0]; let b = [0.74278135, 0.55708605, 0.37139067]; - let result = a.normalize(); + let result = a.normalize().expect("vector has non-zero length"); assert_eq!(result, b); } @@ -223,8 +241,8 @@ mod tests { fn normalize_fails_for_zero_length_vector() { let a = [0.0, 0.0, 0.0]; - let result = std::panic::catch_unwind(|| a.normalize()); - assert!(result.is_err()); + let result = a.normalize(); + assert_eq!(result, Err(MathError::ZeroLengthVector)); } #[test] diff --git a/crates/lambda-rs/src/render/scene_math.rs b/crates/lambda-rs/src/render/scene_math.rs index 54c994c0..e018a320 100644 --- a/crates/lambda-rs/src/render/scene_math.rs +++ b/crates/lambda-rs/src/render/scene_math.rs @@ -58,7 +58,8 @@ pub fn compute_model_matrix( ) -> [[f32; 4]; 4] { let mut model: [[f32; 4]; 4] = matrix::identity_matrix(4, 4); // Apply rotation first, then scaling via a diagonal matrix, and finally translation. - model = matrix::rotate_matrix(model, rotation_axis, angle_in_turns); + model = matrix::rotate_matrix(model, rotation_axis, angle_in_turns) + .expect("rotation axis must be a unit axis vector"); let scaled = scaling_matrix(uniform_scale); model = model.multiply(&scaled); @@ -245,7 +246,8 @@ mod tests { // Build the expected matrix explicitly: R, then S, then T. let mut expected: [[f32; 4]; 4] = m::identity_matrix(4, 4); - expected = m::rotate_matrix(expected, axis, angle_in_turns); + expected = m::rotate_matrix(expected, axis, angle_in_turns) + .expect("rotation axis must be a unit axis vector"); let s: [[f32; 4]; 4] = [ [scale, 0.0, 0.0, 0.0], diff --git a/docs/tutorials/reflective-room.md b/docs/tutorials/reflective-room.md index e85500bd..4acf8bc0 100644 --- a/docs/tutorials/reflective-room.md +++ b/docs/tutorials/reflective-room.md @@ -3,13 +3,13 @@ title: "Reflective Floor: Stencil‑Masked Planar Reflections" document_id: "reflective-room-tutorial-2025-11-17" status: "draft" created: "2025-11-17T00:00:00Z" -last_updated: "2026-01-16T00:00:00Z" -version: "0.4.3" +last_updated: "2026-01-19T00:00:00Z" +version: "0.4.4" engine_workspace_version: "2023.1.30" wgpu_version: "28.0.0" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "87aa423aca541823f271101e5bac390f5ca54c42" +repo_commit: "d0abc736e9d7308fdae80b2d0b568c4614f5a642" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "stencil", "depth", "msaa", "mirror", "3d", "immediates", "wgpu", "rust"] @@ -359,13 +359,15 @@ use lambda::render::scene_math::{compute_perspective_projection, compute_view_ma let camera = SimpleCamera { position: [0.0, 3.0, 4.0], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0 }; // View = R_x(-pitch) * T(-position) let pitch_turns = 0.10; // ~36 degrees downward -let rot_x = lambda::math::matrix::rotate_matrix(lambda::math::matrix::identity_matrix(4,4), [1.0,0.0,0.0], -pitch_turns); +let rot_x = lambda::math::matrix::rotate_matrix(lambda::math::matrix::identity_matrix(4,4), [1.0,0.0,0.0], -pitch_turns) + .expect("rotation axis must be a unit axis vector"); let view = rot_x.multiply(&compute_view_matrix(camera.position)); let projection = compute_perspective_projection(camera.field_of_view_in_turns, width.max(1), height.max(1), camera.near_clipping_plane, camera.far_clipping_plane); let angle_y = 0.12 * elapsed; let mut model = lambda::math::matrix::identity_matrix(4, 4); -model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y); +model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y) + .expect("rotation axis must be a unit axis vector"); model = model.multiply(&lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0])); let mvp = projection.multiply(&view).multiply(&model); diff --git a/docs/tutorials/textured-cube.md b/docs/tutorials/textured-cube.md index ce8ed154..adc8ed1e 100644 --- a/docs/tutorials/textured-cube.md +++ b/docs/tutorials/textured-cube.md @@ -3,13 +3,13 @@ title: "Textured Cube: 3D Immediates + 2D Sampling" document_id: "textured-cube-tutorial-2025-11-10" status: "draft" created: "2025-11-10T00:00:00Z" -last_updated: "2026-01-16T00:00:00Z" -version: "0.3.2" +last_updated: "2026-01-19T00:00:00Z" +version: "0.3.3" engine_workspace_version: "2023.1.30" wgpu_version: "28.0.0" shader_backend_default: "naga" winit_version: "0.29.10" -repo_commit: "9435ad1491b5930054117406abe08dd1c37f2102" +repo_commit: "d0abc736e9d7308fdae80b2d0b568c4614f5a642" owners: ["lambda-sh"] reviewers: ["engine", "rendering"] tags: ["tutorial", "graphics", "3d", "immediates", "textures", "samplers", "rust", "wgpu"] @@ -420,8 +420,10 @@ let angle_y_turns = 0.15 * self.elapsed; // yaw let angle_x_turns = 0.10 * self.elapsed; // pitch let mut model = lambda::math::matrix::identity_matrix(4, 4); -model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y_turns); -model = lambda::math::matrix::rotate_matrix(model, [1.0, 0.0, 0.0], angle_x_turns); +model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y_turns) + .expect("rotation axis must be a unit axis vector"); +model = lambda::math::matrix::rotate_matrix(model, [1.0, 0.0, 0.0], angle_x_turns) + .expect("rotation axis must be a unit axis vector"); let view = compute_view_matrix(camera.position); let projection = compute_perspective_projection(