From 32fcab02c7ec1884b45996586627dbcb11a05acc Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:25:09 +0100 Subject: [PATCH 1/8] =?UTF-8?q?feat(math):=204D=20primitive=20catalog=20?= =?UTF-8?q?=E2=80=94=20regular=20polytopes,=20curved=20shapes,=20Mesh4D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mesh4D: general tetrahedral mesh (merge, weld via spatial hash, transform baking, validation, Gram-determinant cell volumes, face-pairing / watertightness check) - primitives::polytopes: pentachoron (5-cell), hexadecachoron (16-cell), icositetrachoron (24-cell, 96 tets via octahedral cell splitting), hexacosichoron (600-cell: 120 binary-icosahedral-group vertices, 600 cells found as 4-cliques of the edge graph) - primitives::curved: hypersphere (refined 16-cell boundary, O(h²) convergence to 2π²r³), spherinder, cubinder, duocylinder — all built on Dompierre lowest-index prism splitting with shared vertex pools for crack-free seams - fix(tesseract): emit only the 48 boundary tetrahedra instead of all 84 Kuhn tets — the 36 internal membranes wasted ~43% of slice work and rendered as spurious walls from inside; boundary is now watertight - every primitive pinned by structure, watertightness, and closed-form boundary-volume tests (+37 tests) --- crates/rust4d_math/src/lib.rs | 10 + crates/rust4d_math/src/mesh4d.rs | 481 ++++++++++++++++++ crates/rust4d_math/src/primitives/curved.rs | 462 +++++++++++++++++ crates/rust4d_math/src/primitives/extrude.rs | 205 ++++++++ crates/rust4d_math/src/primitives/mod.rs | 43 ++ .../rust4d_math/src/primitives/polytopes.rs | 465 +++++++++++++++++ crates/rust4d_math/src/tesseract.rs | 65 ++- 7 files changed, 1723 insertions(+), 8 deletions(-) create mode 100644 crates/rust4d_math/src/mesh4d.rs create mode 100644 crates/rust4d_math/src/primitives/curved.rs create mode 100644 crates/rust4d_math/src/primitives/extrude.rs create mode 100644 crates/rust4d_math/src/primitives/mod.rs create mode 100644 crates/rust4d_math/src/primitives/polytopes.rs diff --git a/crates/rust4d_math/src/lib.rs b/crates/rust4d_math/src/lib.rs index 82e46fd..ef518ae 100644 --- a/crates/rust4d_math/src/lib.rs +++ b/crates/rust4d_math/src/lib.rs @@ -12,18 +12,28 @@ //! //! - [`ConvexShape4D`] - Trait for 4D shapes that can be sliced //! - [`Tetrahedron`] - A 3-simplex defined by vertex indices +//! - [`Mesh4D`] - General tetrahedral mesh (merge, weld, validate, measure) //! - [`Tesseract4D`] - A 4D hypercube //! - [`Hyperplane4D`] - A floor/ground plane in 4D +//! +//! ## Primitive Catalog +//! +//! The [`primitives`] module constructs the regular 4-polytopes (5-cell, +//! 16-cell, 24-cell, 600-cell) and the curved shapes (hypersphere, +//! spherinder, cubinder, duocylinder) as watertight boundary meshes. mod vec4; mod rotor4; pub mod mat4; +pub mod mesh4d; +pub mod primitives; pub mod ray; pub mod shape; pub mod tesseract; pub mod hyperplane; pub mod interpolation; +pub use mesh4d::{Mesh4D, MeshError}; pub use vec4::Vec4; pub use rotor4::{Rotor4, RotationPlane}; pub use mat4::Mat4; diff --git a/crates/rust4d_math/src/mesh4d.rs b/crates/rust4d_math/src/mesh4d.rs new file mode 100644 index 0000000..64cd56e --- /dev/null +++ b/crates/rust4d_math/src/mesh4d.rs @@ -0,0 +1,481 @@ +//! General-purpose 4D tetrahedral mesh +//! +//! [`Mesh4D`] is the engine's universal geometry container: a list of 4D +//! vertices plus a list of [`Tetrahedron`] cells indexing into them. All +//! procedural primitives (see [`crate::primitives`]) produce a `Mesh4D`, +//! and anything that implements [`ConvexShape4D`] can be converted into one. +//! +//! # Why tetrahedra? +//! +//! The renderer visualizes 4D objects by slicing their **boundary** — a +//! closed 3-manifold embedded in 4D — with the camera's hyperplane. Just as +//! 3D renderers triangulate surfaces, Rust4D *tetrahedralizes* boundary +//! volumes. Slicing one tetrahedron with a hyperplane yields 1–2 triangles +//! (see the marching-tetrahedra tables in `rust4d_render`), so a closed +//! tetrahedral boundary mesh slices to a closed triangle surface. +//! +//! # Construction utilities +//! +//! - [`Mesh4D::merge`] — append another mesh (indices re-based automatically) +//! - [`Mesh4D::weld`] — deduplicate vertices within an epsilon (essential +//! after subdivision, where shared midpoints are generated repeatedly) +//! - [`Mesh4D::transformed`] / [`Mesh4D::translated`] / [`Mesh4D::scaled`] — +//! bake transforms into vertex data +//! - [`Mesh4D::validate`] — index bounds + degenerate-cell detection +//! - [`Mesh4D::surface_volume`] — total 3-volume of all cells, measured with +//! Gram determinants (correct for any embedding in 4D); used heavily by +//! the primitive test suites to pin constructions against closed forms + +use crate::{ConvexShape4D, Mat4, Tetrahedron, Vec4, mat4}; + +/// A general 4D tetrahedral mesh: vertices + tetrahedral cells. +/// +/// See the [module documentation](self) for the role this type plays in the +/// engine and the utilities it provides. +#[derive(Clone, Debug, Default)] +pub struct Mesh4D { + vertices: Vec, + tetrahedra: Vec, +} + +/// Errors reported by [`Mesh4D::validate`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MeshError { + /// A tetrahedron references a vertex index outside the vertex array. + IndexOutOfBounds { + /// Index of the offending tetrahedron in the cell list + tet: usize, + /// The out-of-bounds vertex index + index: usize, + }, + /// A tetrahedron uses the same vertex index more than once. + DegenerateCell { + /// Index of the offending tetrahedron in the cell list + tet: usize, + }, +} + +impl std::fmt::Display for MeshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeshError::IndexOutOfBounds { tet, index } => { + write!(f, "tetrahedron {tet} references out-of-bounds vertex {index}") + } + MeshError::DegenerateCell { tet } => { + write!(f, "tetrahedron {tet} has repeated vertex indices") + } + } + } +} + +impl std::error::Error for MeshError {} + +impl Mesh4D { + /// Create an empty mesh. + pub fn new() -> Self { + Self::default() + } + + /// Create a mesh from raw parts. + /// + /// Use [`Mesh4D::validate`] afterwards if the data comes from an + /// untrusted source (e.g. a file). + pub fn from_parts(vertices: Vec, tetrahedra: Vec) -> Self { + Self { vertices, tetrahedra } + } + + /// Create an empty mesh with pre-allocated capacity. + pub fn with_capacity(vertices: usize, tetrahedra: usize) -> Self { + Self { + vertices: Vec::with_capacity(vertices), + tetrahedra: Vec::with_capacity(tetrahedra), + } + } + + /// Number of vertices. + pub fn vertex_count(&self) -> usize { + self.vertices.len() + } + + /// Number of tetrahedral cells. + pub fn tetrahedron_count(&self) -> usize { + self.tetrahedra.len() + } + + /// Append a vertex, returning its index. + pub fn push_vertex(&mut self, v: Vec4) -> usize { + self.vertices.push(v); + self.vertices.len() - 1 + } + + /// Append a tetrahedral cell from four vertex indices. + pub fn push_tetrahedron(&mut self, indices: [usize; 4]) { + self.tetrahedra.push(Tetrahedron::new(indices)); + } + + /// Append all geometry from `other`, re-basing its indices. + pub fn merge(&mut self, other: &Mesh4D) { + let base = self.vertices.len(); + self.vertices.extend_from_slice(&other.vertices); + self.tetrahedra.extend(other.tetrahedra.iter().map(|t| { + Tetrahedron::new([ + t.indices[0] + base, + t.indices[1] + base, + t.indices[2] + base, + t.indices[3] + base, + ]) + })); + } + + /// Return a copy with every vertex transformed by `rotation` (a `Mat4`) + /// then offset by `translation`. + pub fn transformed(&self, rotation: &Mat4, translation: Vec4) -> Self { + Self { + vertices: self + .vertices + .iter() + .map(|v| mat4::transform(*rotation, *v) + translation) + .collect(), + tetrahedra: self.tetrahedra.clone(), + } + } + + /// Return a copy with every vertex offset by `delta`. + pub fn translated(&self, delta: Vec4) -> Self { + Self { + vertices: self.vertices.iter().map(|v| *v + delta).collect(), + tetrahedra: self.tetrahedra.clone(), + } + } + + /// Return a copy with every vertex scaled uniformly about the origin. + pub fn scaled(&self, factor: f32) -> Self { + Self { + vertices: self.vertices.iter().map(|v| *v * factor).collect(), + tetrahedra: self.tetrahedra.clone(), + } + } + + /// Deduplicate vertices that lie within `epsilon` of each other, + /// rewriting cell indices to the surviving vertex. + /// + /// Subdivision algorithms generate shared midpoints once per parent + /// cell; welding restores connectivity so the mesh slices without + /// cracks and uploads fewer vertices. Uses a quantized spatial hash: + /// O(n) for meshes without pathological epsilon-chains. + pub fn weld(&mut self, epsilon: f32) { + use std::collections::HashMap; + + let inv = 1.0 / epsilon.max(1e-12); + let quantize = |v: &Vec4| -> (i64, i64, i64, i64) { + ( + (v.x * inv).round() as i64, + (v.y * inv).round() as i64, + (v.z * inv).round() as i64, + (v.w * inv).round() as i64, + ) + }; + + let mut buckets: HashMap<(i64, i64, i64, i64), Vec> = HashMap::new(); + let mut remap: Vec = Vec::with_capacity(self.vertices.len()); + let mut kept: Vec = Vec::with_capacity(self.vertices.len()); + + 'outer: for v in &self.vertices { + let key = quantize(v); + // Check this bucket and all 80 neighbors for an existing match. + // (Neighbor check handles points straddling a quantization edge.) + for dx in -1..=1_i64 { + for dy in -1..=1_i64 { + for dz in -1..=1_i64 { + for dw in -1..=1_i64 { + let k = (key.0 + dx, key.1 + dy, key.2 + dz, key.3 + dw); + if let Some(candidates) = buckets.get(&k) { + for &ci in candidates { + if (kept[ci] - *v).length() <= epsilon { + remap.push(ci); + continue 'outer; + } + } + } + } + } + } + } + let idx = kept.len(); + kept.push(*v); + buckets.entry(key).or_default().push(idx); + remap.push(idx); + } + + for tet in &mut self.tetrahedra { + for i in &mut tet.indices { + *i = remap[*i]; + } + } + self.vertices = kept; + } + + /// Check index bounds and reject cells with repeated vertices. + pub fn validate(&self) -> Result<(), MeshError> { + let n = self.vertices.len(); + for (ti, tet) in self.tetrahedra.iter().enumerate() { + for &i in &tet.indices { + if i >= n { + return Err(MeshError::IndexOutOfBounds { tet: ti, index: i }); + } + } + let c = tet.canonical(); + if c[0] == c[1] || c[1] == c[2] || c[2] == c[3] { + return Err(MeshError::DegenerateCell { tet: ti }); + } + } + Ok(()) + } + + /// Axis-aligned bounding box as `(min, max)`. + /// + /// Returns `None` for an empty mesh. + pub fn bounding_box(&self) -> Option<(Vec4, Vec4)> { + let first = *self.vertices.first()?; + let mut min = first; + let mut max = first; + for v in &self.vertices[1..] { + min = Vec4::new(min.x.min(v.x), min.y.min(v.y), min.z.min(v.z), min.w.min(v.w)); + max = Vec4::new(max.x.max(v.x), max.y.max(v.y), max.z.max(v.z), max.w.max(v.w)); + } + Some((min, max)) + } + + /// Radius of the smallest origin-centered ball containing all vertices. + pub fn bounding_radius(&self) -> f32 { + self.vertices + .iter() + .map(|v| v.length()) + .fold(0.0, f32::max) + } + + /// 3-volume of a single cell. + /// + /// The cell is a tetrahedron embedded in 4D, so the usual + /// `det/6` formula doesn't apply directly. Instead we use the Gram + /// determinant of its edge vectors, which measures k-volume in any + /// embedding dimension: + /// + /// ```text + /// V = sqrt(det(G)) / 3! where G[i][j] = e_i · e_j + /// ``` + pub fn cell_volume(&self, tet: usize) -> f32 { + let t = &self.tetrahedra[tet]; + let p0 = self.vertices[t.indices[0]]; + let e = [ + self.vertices[t.indices[1]] - p0, + self.vertices[t.indices[2]] - p0, + self.vertices[t.indices[3]] - p0, + ]; + let mut g = [[0.0f64; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + g[i][j] = e[i].dot(e[j]) as f64; + } + } + let det = g[0][0] * (g[1][1] * g[2][2] - g[1][2] * g[2][1]) + - g[0][1] * (g[1][0] * g[2][2] - g[1][2] * g[2][0]) + + g[0][2] * (g[1][0] * g[2][1] - g[1][1] * g[2][0]); + (det.max(0.0).sqrt() / 6.0) as f32 + } + + /// Count triangular faces by occurrence: returns `(paired, unpaired)`. + /// + /// Each tetrahedron has four triangular faces. In a mesh representing a + /// **closed** boundary 3-manifold (the boundary of a 4D solid), every + /// face must be shared by exactly two cells. Faces are compared by + /// canonical (sorted) vertex indices, so run [`Mesh4D::weld`] first if + /// the mesh was built from unshared vertices. + pub fn face_pairing(&self) -> (usize, usize) { + use std::collections::HashMap; + let mut counts: HashMap<[usize; 3], usize> = HashMap::new(); + for tet in &self.tetrahedra { + let c = tet.canonical(); + for skip in 0..4 { + let mut face = [0usize; 3]; + let mut k = 0; + for (i, &v) in c.iter().enumerate() { + if i != skip { + face[k] = v; + k += 1; + } + } + *counts.entry(face).or_insert(0) += 1; + } + } + let mut paired = 0; + let mut unpaired = 0; + for &n in counts.values() { + if n == 2 { + paired += 1; + } else { + unpaired += 1; + } + } + (paired, unpaired) + } + + /// True if every triangular face is shared by exactly two cells — i.e. + /// the mesh is a closed 3-manifold with no cracks, gaps, T-junctions, + /// or duplicated cells. + /// + /// This is the strongest cheap structural test we have; every primitive + /// in [`crate::primitives`] is pinned watertight by its test suite. + pub fn is_watertight(&self) -> bool { + !self.tetrahedra.is_empty() && self.face_pairing().1 == 0 + } + + /// Total 3-volume of all cells — the "surface volume" of a boundary + /// mesh, analogous to surface *area* in 3D. + /// + /// Primitive tests compare this against closed-form values to prove + /// constructions cover the boundary exactly once (no gaps, no overlaps). + pub fn surface_volume(&self) -> f32 { + (0..self.tetrahedra.len()) + .map(|i| self.cell_volume(i) as f64) + .sum::() as f32 + } +} + +impl ConvexShape4D for Mesh4D { + fn vertices(&self) -> &[Vec4] { + &self.vertices + } + + fn tetrahedra(&self) -> &[Tetrahedron] { + &self.tetrahedra + } +} + +impl From<&S> for Mesh4D { + fn from(shape: &S) -> Self { + Self { + vertices: shape.vertices().to_vec(), + tetrahedra: shape.tetrahedra().to_vec(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unit_tet() -> Mesh4D { + Mesh4D::from_parts( + vec![ + Vec4::new(0.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 1.0, 0.0, 0.0), + Vec4::new(0.0, 0.0, 1.0, 0.0), + ], + vec![Tetrahedron::new([0, 1, 2, 3])], + ) + } + + #[test] + fn test_cell_volume_unit_tet() { + // Right-corner tetrahedron with unit legs: V = 1/6 + let m = unit_tet(); + assert!((m.cell_volume(0) - 1.0 / 6.0).abs() < 1e-6); + } + + #[test] + fn test_cell_volume_invariant_under_rotation_into_w() { + // Gram-determinant volume must not change when the cell is rotated + // out of the XYZ subspace into W. + let m = unit_tet(); + let rot = crate::mat4::plane_rotation(0.7, 2, 3); // ZW rotation + let r = m.transformed(&rot, Vec4::new(5.0, -3.0, 2.0, 1.0)); + assert!((r.cell_volume(0) - 1.0 / 6.0).abs() < 1e-6); + } + + #[test] + fn test_merge_rebases_indices() { + let mut a = unit_tet(); + let b = unit_tet().translated(Vec4::new(10.0, 0.0, 0.0, 0.0)); + a.merge(&b); + assert_eq!(a.vertex_count(), 8); + assert_eq!(a.tetrahedron_count(), 2); + assert_eq!(a.tetrahedra()[1].indices, [4, 5, 6, 7]); + assert!(a.validate().is_ok()); + } + + #[test] + fn test_weld_merges_coincident_vertices() { + let mut a = unit_tet(); + let b = unit_tet(); // exact duplicate + a.merge(&b); + assert_eq!(a.vertex_count(), 8); + a.weld(1e-5); + assert_eq!(a.vertex_count(), 4); + assert!(a.validate().is_ok()); + // Both cells now reference the same 4 vertices + assert_eq!(a.tetrahedra()[0].canonical(), a.tetrahedra()[1].canonical()); + } + + #[test] + fn test_weld_keeps_distinct_vertices() { + let mut m = unit_tet(); + m.weld(1e-5); + assert_eq!(m.vertex_count(), 4); + } + + #[test] + fn test_validate_catches_out_of_bounds() { + let m = Mesh4D::from_parts( + vec![Vec4::ZERO], + vec![Tetrahedron::new([0, 0, 0, 7])], + ); + assert!(matches!( + m.validate(), + Err(MeshError::IndexOutOfBounds { index: 7, .. }) + )); + } + + #[test] + fn test_validate_catches_degenerate() { + let m = Mesh4D::from_parts( + vec![Vec4::ZERO; 4], + vec![Tetrahedron::new([0, 1, 2, 2])], + ); + assert!(matches!(m.validate(), Err(MeshError::DegenerateCell { tet: 0 }))); + } + + #[test] + fn test_bounding_box_and_radius() { + let m = unit_tet().translated(Vec4::new(0.0, 0.0, 0.0, 2.0)); + let (min, max) = m.bounding_box().unwrap(); + assert_eq!(min.w, 2.0); + assert_eq!(max.x, 1.0); + assert!(m.bounding_radius() >= 2.0); + } + + #[test] + fn test_tesseract_boundary_is_watertight() { + // The tesseract's 8 cubic cells × 6 tets form a closed 3-manifold. + let tess = crate::Tesseract4D::new(2.0); + let m: Mesh4D = (&tess as &dyn ConvexShape4D).into(); + assert!(m.is_watertight(), "tesseract boundary should be watertight"); + } + + #[test] + fn test_single_tet_is_not_watertight() { + let m = unit_tet(); + assert!(!m.is_watertight()); + let (paired, unpaired) = m.face_pairing(); + assert_eq!((paired, unpaired), (0, 4)); + } + + #[test] + fn test_from_convex_shape() { + let tess = crate::Tesseract4D::new(2.0); + let m: Mesh4D = (&tess as &dyn ConvexShape4D).into(); + assert_eq!(m.vertex_count(), 16); + assert!(m.validate().is_ok()); + } +} diff --git a/crates/rust4d_math/src/primitives/curved.rs b/crates/rust4d_math/src/primitives/curved.rs new file mode 100644 index 0000000..2a9e550 --- /dev/null +++ b/crates/rust4d_math/src/primitives/curved.rs @@ -0,0 +1,462 @@ +//! Curved 4D primitives: hypersphere, spherinder, cubinder, duocylinder +//! +//! Unlike the [polytopes](super::polytopes), these shapes approximate curved +//! boundaries, so each takes resolution parameters. They are the 4D +//! analogues of the sphere and cylinder family: +//! +//! | Function | Shape | Boundary structure | +//! |----------|-------|--------------------| +//! | [`hypersphere`] | solid 4-ball | S³, refined from the 16-cell | +//! | [`spherinder`] | ball × segment | two 3-ball caps + S² × segment tube | +//! | [`cubinder`] | disk × square | S¹ × square + disk × 4 edges | +//! | [`duocylinder`] | disk × disk | **two interlocked solid-torus pieces** meeting at a Clifford torus | +//! +//! All construction goes through a shared vertex pool keyed by logical +//! coordinates, so adjacent pieces reference identical global indices and +//! the [lowest-index prism rule](super::extrude) produces matching diagonals +//! at every seam — the test suites pin each mesh watertight. + +use super::extrude::{split_prism, TET_EDGES, TET_SUBDIVISION}; +use crate::{ConvexShape4D, Mesh4D, Tetrahedron, Vec4}; +use std::collections::HashMap; + +/// Solid 4-ball of the given `radius`: its boundary 3-sphere (S³, the +/// *glome*) tetrahedralized by recursive refinement of the +/// [16-cell](super::polytopes::hexadecachoron)'s 16 boundary cells. +/// +/// Each subdivision level splits every tetrahedron into 8 and reprojects +/// vertices onto the sphere, so level *d* yields `16·8^d` cells: +/// +/// | `subdivisions` | cells | character | +/// |----------------|-------|-----------| +/// | 0 | 16 | the bare 16-cell | +/// | 1 | 128 | visibly round | +/// | 2 | 1 024 | smooth (default scene quality) | +/// | 3 | 8 192 | hero-object quality | +/// +/// `subdivisions` is clamped to 4 (65 536 cells) to keep accidental +/// `u32`-ish blowups out of the GPU buffers. The cross-section of a +/// hypersphere is the engine's "hello world" of 4D intuition: a sphere that +/// grows and shrinks as the slice plane sweeps through it. +pub fn hypersphere(radius: f32, subdivisions: u32) -> Mesh4D { + let subdivisions = subdivisions.min(4); + let mut mesh = super::polytopes::hexadecachoron(radius); + + for _ in 0..subdivisions { + let mut refined = Mesh4D::with_capacity( + mesh.vertex_count() * 4, + mesh.tetrahedron_count() * 8, + ); + for tet in mesh.tetrahedra() { + // Gather the 10 points of the subdivision (4 corners + 6 edge + // midpoints reprojected to the sphere). Midpoints are computed + // identically from both sides of a shared edge (IEEE addition + // is commutative), so the final weld restores connectivity. + let corners = tet.indices.map(|i| mesh.vertices()[i]); + let mut points = [Vec4::ZERO; 10]; + points[..4].copy_from_slice(&corners); + for (k, (a, b)) in TET_EDGES.iter().enumerate() { + let mid = (corners[*a] + corners[*b]) * 0.5; + points[4 + k] = mid.normalized() * radius; + } + let base = refined.vertex_count(); + for p in points { + refined.push_vertex(p); + } + for child in TET_SUBDIVISION { + refined.push_tetrahedron(child.map(|i| base + i)); + } + } + refined.weld(radius * 1e-6); + mesh = refined; + } + mesh +} + +/// **Spherinder**: a solid 3-ball of `radius` extruded along W over +/// `[-half_height, +half_height]` — the most literal 4D analogue of the +/// cylinder ("a ball, dragged through the 4th dimension"). +/// +/// Boundary pieces: +/// - two flat **caps**: solid 3-balls at `w = ±half_height` (icosphere +/// surface fanned to a center vertex), +/// - the **tube**: S² × segment, every icosphere surface triangle extruded +/// into a prism. +/// +/// `subdivisions` controls the icosphere refinement (0 → 20 triangles, +/// each level ×4; default scenes use 2 → 320 triangles, 1 600 cells total). +/// +/// Sliced face-on it looks like a ball; sliced along W it is a ball that +/// pops into existence, stays *constant-sized* for `2·half_height` of +/// travel, then vanishes — the signature difference from a hypersphere. +pub fn spherinder(radius: f32, half_height: f32, subdivisions: u32) -> Mesh4D { + let (sphere_verts, sphere_tris) = icosphere(radius, subdivisions.min(5)); + let n = sphere_verts.len(); + + let mut mesh = Mesh4D::with_capacity(2 * n + 2, sphere_tris.len() * 5); + + // Vertex layout: [0..n) bottom shell (w = -h), [n..2n) top shell, + // 2n bottom center, 2n+1 top center. + for w in [-half_height, half_height] { + for v in &sphere_verts { + mesh.push_vertex(Vec4::new(v[0], v[1], v[2], w)); + } + } + let bottom_center = mesh.push_vertex(Vec4::new(0.0, 0.0, 0.0, -half_height)); + let top_center = mesh.push_vertex(Vec4::new(0.0, 0.0, 0.0, half_height)); + + let mut tets: Vec = Vec::with_capacity(sphere_tris.len() * 5); + for &[a, b, c] in &sphere_tris { + // Caps: fan every surface triangle to the cap center. + tets.push(Tetrahedron::new([bottom_center, a, b, c])); + tets.push(Tetrahedron::new([top_center, n + a, n + b, n + c])); + // Tube: prism between the two shells. + split_prism([a, b, c, n + a, n + b, n + c], &mut tets); + } + + for t in tets { + mesh.push_tetrahedron(t.indices); + } + mesh +} + +/// **Cubinder**: a disk of `radius` (XY plane) × a square of half-extent +/// `half_size` (ZW plane) — the "other" 4D cylinder, with a curved direction +/// pair and a flat direction pair. +/// +/// Boundary pieces: +/// - **shell**: S¹ × square (the curved side), +/// - **plates**: disk × each of the square's 4 edges. +/// +/// `segments` is the circle resolution (≥ 3, default-quality scenes use 24). +/// Sliced at w = 0 it is a cylinder; rotate it in ZW and the cross-section +/// stays a cylinder of changing height — flat directions don't foreshorten +/// the disk. +pub fn cubinder(radius: f32, half_size: f32, segments: u32) -> Mesh4D { + let segments = segments.max(3) as usize; + let mut pool = VertexPool::new(); + + // Logical coordinates: ring index 0..segments on the circle (or CENTER), + // corner index 0..4 on the square (z,w) = (±s, ±s). + const CENTER: usize = usize::MAX; + let square = [ + (-half_size, -half_size), + (half_size, -half_size), + (half_size, half_size), + (-half_size, half_size), + ]; + let circle_point = |i: usize| -> (f32, f32) { + let theta = (i % segments) as f32 / segments as f32 * std::f32::consts::TAU; + (radius * theta.cos(), radius * theta.sin()) + }; + let vert = |pool: &mut VertexPool, ring: usize, corner: usize| -> usize { + let key = (if ring == CENTER { u32::MAX } else { (ring % segments) as u32 }, corner as u32); + let (z, w) = square[corner]; + let (x, y) = if ring == CENTER { (0.0, 0.0) } else { circle_point(ring) }; + pool.get(key, Vec4::new(x, y, z, w)) + }; + + let mut tets: Vec = Vec::new(); + + // Shell: S¹ × square. Split the square into triangles (0,1,2) and + // (0,2,3); each circle segment × triangle is a prism. + for i in 0..segments { + for tri in [[0usize, 1, 2], [0, 2, 3]] { + let bottom = tri.map(|c| vert(&mut pool, i, c)); + let top = tri.map(|c| vert(&mut pool, i + 1, c)); + split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + } + } + + // Plates: disk × each square edge. The disk is a fan of `segments` + // triangles (center, i, i+1); each fanned triangle × edge is a prism. + for edge in 0..4usize { + let (c0, c1) = (edge, (edge + 1) % 4); + for i in 0..segments { + let bottom = [ + vert(&mut pool, CENTER, c0), + vert(&mut pool, i, c0), + vert(&mut pool, i + 1, c0), + ]; + let top = [ + vert(&mut pool, CENTER, c1), + vert(&mut pool, i, c1), + vert(&mut pool, i + 1, c1), + ]; + split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + } + } + + let mut mesh = Mesh4D::from_parts(pool.vertices, Vec::new()); + for t in tets { + mesh.push_tetrahedron(t.indices); + } + mesh +} + +/// **Duocylinder**: the product of two disks, D²(`r1`) in XY × D²(`r2`) in +/// ZW — the most alien object in the catalog. Its boundary is exactly two +/// pieces, each a solid torus (S¹ × D²), glued along the **Clifford torus** +/// S¹ × S¹ where they meet. It has no edges and no vertices: just two +/// smooth curved 3-faces. +/// +/// `segments1`/`segments2` set the resolution of the two circles (≥ 3). +/// Rolling a duocylinder on its XY ridge while watching the 3D slice is one +/// of the great "feel the 4th dimension" demos. +pub fn duocylinder(r1: f32, r2: f32, segments1: u32, segments2: u32) -> Mesh4D { + let n1 = segments1.max(3) as usize; + let n2 = segments2.max(3) as usize; + let mut pool = VertexPool::new(); + + // Logical coordinates (i, j): i on circle 1 (or AXIS1 = its center), + // j on circle 2 (or AXIS2). The Clifford torus is the (i, j) grid; + // each solid-torus piece fans toward its own axis circle. + const AXIS: u32 = u32::MAX; + let vert = |pool: &mut VertexPool, i: usize, j: usize, on_axis_1: bool, on_axis_2: bool| -> usize { + let ki = if on_axis_1 { AXIS } else { (i % n1) as u32 }; + let kj = if on_axis_2 { AXIS } else { (j % n2) as u32 }; + let (x, y) = if on_axis_1 { + (0.0, 0.0) + } else { + let a = (i % n1) as f32 / n1 as f32 * std::f32::consts::TAU; + (r1 * a.cos(), r1 * a.sin()) + }; + let (z, w) = if on_axis_2 { + (0.0, 0.0) + } else { + let b = (j % n2) as f32 / n2 as f32 * std::f32::consts::TAU; + (r2 * b.cos(), r2 * b.sin()) + }; + pool.get((ki, kj), Vec4::new(x, y, z, w)) + }; + + let mut tets: Vec = Vec::new(); + + // Piece 1: S¹(r1) × D²(r2). Disk 2 fans (axis2, j, j+1); extrude each + // fan triangle along circle 1. + for i in 0..n1 { + for j in 0..n2 { + let bottom = [ + vert(&mut pool, i, 0, false, true), + vert(&mut pool, i, j, false, false), + vert(&mut pool, i, j + 1, false, false), + ]; + let top = [ + vert(&mut pool, i + 1, 0, false, true), + vert(&mut pool, i + 1, j, false, false), + vert(&mut pool, i + 1, j + 1, false, false), + ]; + split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + } + } + + // Piece 2: D²(r1) × S¹(r2) — the mirror construction. + for j in 0..n2 { + for i in 0..n1 { + let bottom = [ + vert(&mut pool, 0, j, true, false), + vert(&mut pool, i, j, false, false), + vert(&mut pool, i + 1, j, false, false), + ]; + let top = [ + vert(&mut pool, 0, j + 1, true, false), + vert(&mut pool, i, j + 1, false, false), + vert(&mut pool, i + 1, j + 1, false, false), + ]; + split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + } + } + + let mut mesh = Mesh4D::from_parts(pool.vertices, Vec::new()); + for t in tets { + mesh.push_tetrahedron(t.indices); + } + mesh +} + +// ============================================================================ +// Internals +// ============================================================================ + +/// Vertex pool keyed by logical coordinates, so every piece of a composite +/// boundary references identical global indices for shared points (the +/// precondition for crack-free [`split_prism`] seams). +struct VertexPool { + vertices: Vec, + index: HashMap<(u32, u32), usize>, +} + +impl VertexPool { + fn new() -> Self { + Self { vertices: Vec::new(), index: HashMap::new() } + } + + fn get(&mut self, key: (u32, u32), position: Vec4) -> usize { + *self.index.entry(key).or_insert_with(|| { + self.vertices.push(position); + self.vertices.len() - 1 + }) + } +} + +/// Icosphere in the XYZ subspace: returns `(vertices, triangles)` with all +/// vertices at `radius` from the origin. `subdivisions = 0` is the bare +/// icosahedron (12 vertices, 20 faces); each level quadruples the faces. +fn icosphere(radius: f32, subdivisions: u32) -> (Vec<[f32; 3]>, Vec<[usize; 3]>) { + let phi = (1.0 + 5.0f32.sqrt()) / 2.0; + let mut verts: Vec<[f32; 3]> = vec![ + [-1.0, phi, 0.0], [1.0, phi, 0.0], [-1.0, -phi, 0.0], [1.0, -phi, 0.0], + [0.0, -1.0, phi], [0.0, 1.0, phi], [0.0, -1.0, -phi], [0.0, 1.0, -phi], + [phi, 0.0, -1.0], [phi, 0.0, 1.0], [-phi, 0.0, -1.0], [-phi, 0.0, 1.0], + ]; + let mut tris: Vec<[usize; 3]> = vec![ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + + let project = |v: [f32; 3]| -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / len * radius, v[1] / len * radius, v[2] / len * radius] + }; + for v in &mut verts { + *v = project(*v); + } + + for _ in 0..subdivisions { + let mut midpoint_cache: HashMap<(usize, usize), usize> = HashMap::new(); + let mut next_tris = Vec::with_capacity(tris.len() * 4); + for [a, b, c] in tris { + let mut mid = |i: usize, j: usize, verts: &mut Vec<[f32; 3]>| -> usize { + let key = (i.min(j), i.max(j)); + *midpoint_cache.entry(key).or_insert_with(|| { + let m = [ + (verts[i][0] + verts[j][0]) * 0.5, + (verts[i][1] + verts[j][1]) * 0.5, + (verts[i][2] + verts[j][2]) * 0.5, + ]; + verts.push(project(m)); + verts.len() - 1 + }) + }; + let ab = mid(a, b, &mut verts); + let bc = mid(b, c, &mut verts); + let ca = mid(c, a, &mut verts); + next_tris.push([a, ab, ca]); + next_tris.push([b, bc, ab]); + next_tris.push([c, ca, bc]); + next_tris.push([ab, bc, ca]); + } + tris = next_tris; + } + + (verts, tris) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::PI; + + #[test] + fn test_hypersphere_structure() { + for (d, cells) in [(0u32, 16usize), (1, 128), (2, 1024)] { + let m = hypersphere(1.0, d); + m.validate().unwrap(); + assert_eq!(m.tetrahedron_count(), cells, "subdivision {d}"); + assert!(m.is_watertight(), "hypersphere d={d} must be watertight"); + for v in m.vertices() { + assert!((v.length() - 1.0).abs() < 1e-5, "all vertices on S³"); + } + } + } + + #[test] + fn test_hypersphere_volume_converges_to_glome() { + // Boundary 3-volume of S³(r) is 2π²r³. The inscribed approximation + // approaches it from below as subdivision increases. + let exact = 2.0 * PI * PI; + let v1 = hypersphere(1.0, 1).surface_volume() as f64; + let v2 = hypersphere(1.0, 2).surface_volume() as f64; + let v3 = hypersphere(1.0, 3).surface_volume() as f64; + assert!(v1 < v2 && v2 < v3 && v3 < exact, "monotone from below: {v1} {v2} {v3} {exact}"); + assert!((exact - v3) / exact < 0.05, "d=3 within 5% of 2π²: {v3} vs {exact}"); + // Each subdivision halves edge length; an O(h²) scheme must shrink + // the error by ~4× per level. Pin convergence *order*, not just size. + let ratio = (exact - v2) / (exact - v3); + assert!(ratio > 3.0, "expected quadratic convergence, error ratio {ratio}"); + } + + #[test] + fn test_hypersphere_subdivision_clamp() { + // Levels above 4 are clamped — same mesh as 4. + assert_eq!(hypersphere(1.0, 9).tetrahedron_count(), 16 * 8usize.pow(4)); + } + + #[test] + fn test_spherinder_structure() { + let m = spherinder(1.0, 0.75, 1); + m.validate().unwrap(); + // 80 surface triangles → 2 caps × 80 + 80 prisms × 3 = 400 cells + assert_eq!(m.tetrahedron_count(), 400); + assert!(m.is_watertight(), "spherinder must be watertight"); + } + + #[test] + fn test_spherinder_volume() { + // Caps: 2 · (4/3)πr³. Tube: 4πr² · 2h. Inscribed → slightly below. + let (r, h) = (1.0f64, 0.75f64); + let exact = 2.0 * (4.0 / 3.0) * PI * r.powi(3) + 4.0 * PI * r * r * (2.0 * h); + let measured = spherinder(r as f32, h as f32, 3).surface_volume() as f64; + assert!(measured < exact, "inscribed mesh underestimates"); + assert!((exact - measured) / exact < 0.02, "{measured} vs {exact}"); + } + + #[test] + fn test_cubinder_structure() { + let m = cubinder(1.0, 0.5, 8); + m.validate().unwrap(); + // Shell: 8 segments × 2 square-tris × 3. Plates: 4 edges × 8 fans × 3. + assert_eq!(m.tetrahedron_count(), 8 * 2 * 3 + 4 * 8 * 3); + assert!(m.is_watertight(), "cubinder must be watertight"); + } + + #[test] + fn test_cubinder_volume() { + // Shell: 2πr·(2s)². Plates: πr²·(8s). Circle inscribed → below exact. + let (r, s) = (1.0f64, 0.5f64); + let exact = 2.0 * PI * r * (2.0 * s) * (2.0 * s) + PI * r * r * 8.0 * s; + let measured = cubinder(r as f32, s as f32, 64).surface_volume() as f64; + assert!(measured < exact); + assert!((exact - measured) / exact < 0.01, "{measured} vs {exact}"); + } + + #[test] + fn test_duocylinder_structure() { + let m = duocylinder(1.0, 0.8, 8, 6); + m.validate().unwrap(); + // Piece 1: 8·6 prisms; piece 2: 6·8 prisms; ×3 tets. + assert_eq!(m.tetrahedron_count(), 2 * 8 * 6 * 3); + assert!(m.is_watertight(), "duocylinder must be watertight"); + } + + #[test] + fn test_duocylinder_volume() { + // Piece 1: 2πr1 · πr2². Piece 2: πr1² · 2πr2. + let (r1, r2) = (1.0f64, 0.8f64); + let exact = 2.0 * PI * r1 * PI * r2 * r2 + PI * r1 * r1 * 2.0 * PI * r2; + let measured = duocylinder(r1 as f32, r2 as f32, 48, 48).surface_volume() as f64; + assert!(measured < exact); + assert!((exact - measured) / exact < 0.01, "{measured} vs {exact}"); + } + + #[test] + fn test_curved_shapes_bounded() { + // Bounding radius sanity: every shape fits its analytic bound. + assert!(hypersphere(2.0, 1).bounding_radius() <= 2.0 + 1e-4); + assert!(spherinder(1.0, 0.5, 1).bounding_radius() <= (1.0f32 + 0.25).sqrt() + 1e-4); + assert!(cubinder(1.0, 0.5, 12).bounding_radius() <= (1.0f32 + 0.5).sqrt() + 1e-4); + assert!(duocylinder(1.0, 1.0, 12, 12).bounding_radius() <= 2.0f32.sqrt() + 1e-4); + } +} diff --git a/crates/rust4d_math/src/primitives/extrude.rs b/crates/rust4d_math/src/primitives/extrude.rs new file mode 100644 index 0000000..02fe1c3 --- /dev/null +++ b/crates/rust4d_math/src/primitives/extrude.rs @@ -0,0 +1,205 @@ +//! Tetrahedralization helpers: prism splitting and simplex subdivision +//! +//! The curved primitives (spherinder, cubinder, duocylinder) are built from +//! **triangular prisms**: a 2D triangle extruded along a 1D segment is a +//! 3-cell embedded in 4D, and every product-shaped boundary piece +//! (circle × square, disk × edge, circle × disk, …) decomposes into such +//! prisms. Each prism then splits into 3 tetrahedra. +//! +//! # The crack problem +//! +//! Two prisms that share a quadrilateral face must split that quad along the +//! **same diagonal**, or the slice shader will produce hairline cracks and +//! T-junctions at the seam. We use the classic *lowest-global-index* rule +//! (Dompierre, Labbé, Vallet & Camarero, *How to Subdivide Pyramids, Prisms +//! and Hexahedra into Tetrahedra*, IMR 1999): every quad face is split by +//! the diagonal emanating from its smallest global vertex index. Because the +//! rule depends only on the four shared indices, both neighbors always +//! agree — across pieces, seams, and wrap-arounds — as long as shared +//! vertices share global indices (build from a common vertex pool; don't +//! rely on post-hoc welding, which renumbers *after* splitting decisions). +//! +//! Every primitive's test suite verifies the result with +//! [`Mesh4D::is_watertight`](crate::Mesh4D::is_watertight). + +use crate::Tetrahedron; + +/// Split a triangular prism into 3 tetrahedra using the lowest-global-index +/// rule, appending them to `out`. +/// +/// `prism` lists global vertex indices as `[b0, b1, b2, t0, t1, t2]` where +/// `t_i` is the extruded copy of `b_i` (the three quads are `b0 b1 t1 t0`, +/// `b1 b2 t2 t1`, `b2 b0 t0 t2`). +/// +/// All six indices must be distinct; adjacent prisms sharing a quad face are +/// guaranteed matching diagonals on it. +pub fn split_prism(prism: [usize; 6], out: &mut Vec) { + debug_assert!( + { + let mut s = prism; + s.sort_unstable(); + s.windows(2).all(|w| w[0] != w[1]) + }, + "split_prism requires distinct vertex indices, got {prism:?}" + ); + + // Bring the globally smallest index to local position 0 using the + // prism's symmetries (cyclic rotation of both triangles, and the + // bottom/top flip), which preserve the pairing structure. + let min_pos = (0..6).min_by_key(|&i| prism[i]).unwrap(); + let v: [usize; 6] = match min_pos { + 0 => prism, + 1 => [prism[1], prism[2], prism[0], prism[4], prism[5], prism[3]], + 2 => [prism[2], prism[0], prism[1], prism[5], prism[3], prism[4]], + 3 => [prism[3], prism[4], prism[5], prism[0], prism[1], prism[2]], + 4 => [prism[4], prism[5], prism[3], prism[1], prism[2], prism[0]], + _ => [prism[5], prism[3], prism[4], prism[2], prism[0], prism[1]], + }; + + // v[0] is now the global minimum. The two quads incident to v[0] + // (v0 v1 v4 v3 and v0 v2 v5 v3) take diagonals v0–v4 and v0–v5 by the + // rule. The remaining quad (v1 v2 v5 v4) takes the diagonal from its + // own smallest index: v1–v5 if min is v1 or v5, else v2–v4. + let m = v[1].min(v[2]).min(v[4]).min(v[5]); + if m == v[1] || m == v[5] { + out.push(Tetrahedron::new([v[0], v[1], v[2], v[5]])); + out.push(Tetrahedron::new([v[0], v[1], v[5], v[4]])); + out.push(Tetrahedron::new([v[0], v[4], v[5], v[3]])); + } else { + out.push(Tetrahedron::new([v[0], v[1], v[2], v[4]])); + out.push(Tetrahedron::new([v[0], v[4], v[2], v[5]])); + out.push(Tetrahedron::new([v[0], v[4], v[5], v[3]])); + } +} + +/// The 8-way subdivision of a tetrahedron, expressed over its 4 corners and +/// 6 edge midpoints. +/// +/// Index convention for the returned cells: `0..4` are the corners +/// `v0..v3`; `4..10` are the midpoints of edges +/// `(0,1), (0,2), (0,3), (1,2), (1,3), (2,3)` in that order. +/// +/// The construction: cutting off the 4 corner tetrahedra leaves a central +/// octahedron with vertices at the 6 midpoints, which is split into 4 +/// tetrahedra around its `m01–m23` diagonal. Used by the hypersphere to +/// refine the 16-cell boundary toward S³. +pub const TET_SUBDIVISION: [[usize; 4]; 8] = [ + // Corner cells + [0, 4, 5, 6], // v0, m01, m02, m03 + [1, 4, 7, 8], // v1, m01, m12, m13 + [2, 5, 7, 9], // v2, m02, m12, m23 + [3, 6, 8, 9], // v3, m03, m13, m23 + // Central octahedron around diagonal m01(4) – m23(9). + // Equator cycle: m02(5) – m03(6) – m13(8) – m12(7). + [4, 9, 5, 6], + [4, 9, 6, 8], + [4, 9, 8, 7], + [4, 9, 7, 5], +]; + +/// The midpoint edge list matching [`TET_SUBDIVISION`]'s index convention. +pub const TET_EDGES: [(usize, usize); 6] = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Mesh4D, Vec4}; + + /// Build a prism mesh from explicit points and split it. + fn prism_mesh(points: [Vec4; 6], order: [usize; 6]) -> Mesh4D { + let mut tets = Vec::new(); + split_prism(order, &mut tets); + Mesh4D::from_parts(points.to_vec(), tets) + } + + fn unit_prism_points() -> [Vec4; 6] { + // Right triangle (legs 1) extruded by height 1 along Y. + [ + Vec4::new(0.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 0.0, 1.0, 0.0), + Vec4::new(0.0, 1.0, 0.0, 0.0), + Vec4::new(1.0, 1.0, 0.0, 0.0), + Vec4::new(0.0, 1.0, 1.0, 0.0), + ] + } + + #[test] + fn test_split_prism_volume() { + // Prism volume = triangle area (1/2) × height (1) = 1/2, + // regardless of which permutation the indices arrive in. + for order in [ + [0usize, 1, 2, 3, 4, 5], + [1, 2, 0, 4, 5, 3], + [3, 4, 5, 0, 1, 2], + [5, 3, 4, 2, 0, 1], + ] { + let m = prism_mesh(unit_prism_points(), order); + assert_eq!(m.tetrahedron_count(), 3); + m.validate().unwrap(); + assert!( + (m.surface_volume() - 0.5).abs() < 1e-6, + "order {order:?}: volume {} != 0.5", + m.surface_volume() + ); + } + } + + #[test] + fn test_split_prism_in_w_plane() { + // Same prism rotated into the ZW plane — Gram volumes must agree. + let rot = crate::mat4::plane_rotation(1.1, 2, 3); + let pts = unit_prism_points().map(|p| crate::mat4::transform(rot, p)); + let m = prism_mesh(pts, [0, 1, 2, 3, 4, 5]); + assert!((m.surface_volume() - 0.5).abs() < 1e-5); + } + + #[test] + fn test_adjacent_prisms_share_diagonals() { + // Two prisms stacked along Y share the middle triangle; the combined + // tet complex must pair every internal face exactly twice. The shared + // *quad* faces (sides) get diagonals from the index rule — if the rule + // were inconsistent, faces would appear unpaired. + let mut points = Vec::new(); + for y in 0..3 { + points.push(Vec4::new(0.0, y as f32, 0.0, 0.0)); + points.push(Vec4::new(1.0, y as f32, 0.0, 0.0)); + points.push(Vec4::new(0.0, y as f32, 1.0, 0.0)); + } + let mut tets = Vec::new(); + split_prism([0, 1, 2, 3, 4, 5], &mut tets); + split_prism([3, 4, 5, 6, 7, 8], &mut tets); + let m = Mesh4D::from_parts(points, tets); + m.validate().unwrap(); + assert!((m.surface_volume() - 1.0).abs() < 1e-6); + // 2 prisms × 3 tets × 4 faces = 24 face slots. The shared triangle + // (3,4,5) plane contains 2 triangles (the quad rule splits it the + // same way from both sides) → internal pairings exist. + let (paired, _unpaired) = m.face_pairing(); + assert!(paired >= 2, "stacked prisms must share their interface faces"); + } + + #[test] + fn test_tet_subdivision_volume() { + // Subdividing a tetrahedron into 8 must conserve volume exactly. + let corners = [ + Vec4::new(0.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 1.0, 0.0, 0.0), + Vec4::new(0.0, 0.0, 1.0, 0.0), + ]; + let mut points: Vec = corners.to_vec(); + for (a, b) in TET_EDGES { + points.push((corners[a] + corners[b]) * 0.5); + } + let tets = TET_SUBDIVISION.map(Tetrahedron::new).to_vec(); + let m = Mesh4D::from_parts(points, tets); + m.validate().unwrap(); + assert_eq!(m.tetrahedron_count(), 8); + assert!( + (m.surface_volume() - 1.0 / 6.0).abs() < 1e-6, + "children must tile the parent: {} != 1/6", + m.surface_volume() + ); + } +} diff --git a/crates/rust4d_math/src/primitives/mod.rs b/crates/rust4d_math/src/primitives/mod.rs new file mode 100644 index 0000000..5aec367 --- /dev/null +++ b/crates/rust4d_math/src/primitives/mod.rs @@ -0,0 +1,43 @@ +//! Procedural 4D primitive shapes +//! +//! This module is the engine's shape catalog. Every function returns a +//! [`Mesh4D`](crate::Mesh4D) containing a **tetrahedralized boundary** — +//! a closed 3-manifold ready for the slice pipeline (see `docs/shapes.md` +//! in the repository for the full catalog with pictures and the underlying +//! math). +//! +//! # Regular polytopes ([`polytopes`]) +//! +//! - [`pentachoron`] — 5-cell, the 4D tetrahedron +//! - [`hexadecachoron`] — 16-cell, the 4D octahedron +//! - [`icositetrachoron`] — 24-cell, 4D's unique extra regular solid +//! - [`hexacosichoron`] — 600-cell, the 4D icosahedron (600 cells, built +//! from the binary icosahedral group) +//! +//! (The tesseract predates this module and lives at [`crate::Tesseract4D`].) +//! +//! # Curved shapes ([`curved`]) +//! +//! - [`hypersphere`] — solid 4-ball bounded by S³ +//! - [`spherinder`] — ball × segment +//! - [`cubinder`] — disk × square +//! - [`duocylinder`] — disk × disk, bounded by two solid tori +//! +//! # Quality guarantees +//! +//! Every primitive is pinned by tests on three properties: +//! +//! 1. **Structure** — exact vertex/cell counts, +//! 2. **Watertightness** — every triangular face shared by exactly two +//! cells ([`Mesh4D::is_watertight`](crate::Mesh4D::is_watertight)), so +//! slices never show cracks or T-junctions, +//! 3. **Measure** — total boundary 3-volume matches the closed-form value +//! (exactly for polytopes, convergent from below for curved shapes). + +pub mod curved; +pub mod extrude; +pub mod polytopes; + +pub use curved::{cubinder, duocylinder, hypersphere, spherinder}; +pub use extrude::split_prism; +pub use polytopes::{hexacosichoron, hexadecachoron, icositetrachoron, pentachoron}; diff --git a/crates/rust4d_math/src/primitives/polytopes.rs b/crates/rust4d_math/src/primitives/polytopes.rs new file mode 100644 index 0000000..0e41c25 --- /dev/null +++ b/crates/rust4d_math/src/primitives/polytopes.rs @@ -0,0 +1,465 @@ +//! The regular convex 4-polytopes (boundary tetrahedralizations) +//! +//! Four-dimensional space has **six** regular convex polytopes — one more +//! than 3D's five Platonic solids. This module constructs four of them +//! (the tesseract lives in [`crate::tesseract`]; the 120-cell's dodecahedral +//! cells make it a poor fit for real-time slicing and it is omitted): +//! +//! | Function | Polytope | Cells | Our tetrahedra | +//! |----------|----------|-------|----------------| +//! | [`pentachoron`] | 5-cell (4-simplex) | 5 tetrahedra | 5 | +//! | [`hexadecachoron`] | 16-cell (4-orthoplex) | 16 tetrahedra | 16 | +//! | [`icositetrachoron`] | 24-cell | 24 octahedra | 96 (4 per cell) | +//! | [`hexacosichoron`] | 600-cell | 600 tetrahedra | 600 | +//! +//! All are centered at the origin and parameterized by **circumradius** +//! (every vertex lies at that distance from the center), which is the +//! natural size measure for objects you slice: it bounds how far the +//! cross-section can reach. +//! +//! Each construction is pinned by tests on cell count, watertightness +//! (every triangular face shared by exactly two cells), and total boundary +//! 3-volume against the closed-form value. + +use crate::{Mesh4D, Tetrahedron, Vec4}; + +/// The golden ratio φ = (1 + √5)/2, the structural constant of the 600-cell. +const PHI: f64 = 1.618_033_988_749_895; + +/// Regular **5-cell** (4-simplex, pentachoron): 5 vertices, 5 tetrahedral +/// cells — the 4D analogue of the tetrahedron, and the simplest possible +/// closed 4D solid. +/// +/// Its boundary is every 4-subset of the 5 vertices. Vertices are placed at +/// a standard regular-simplex configuration and scaled to `circumradius`. +pub fn pentachoron(circumradius: f32) -> Mesh4D { + // Four vertices of a regular tetrahedron at w = -1/√5, apex at w = 4/√5: + // all pairwise distances 2√2, centroid at the origin, circumradius √(1+1+1+1/5) = ... uniform. + let s5 = 5.0f64.sqrt(); + let raw = [ + [1.0, 1.0, 1.0, -1.0 / s5], + [1.0, -1.0, -1.0, -1.0 / s5], + [-1.0, 1.0, -1.0, -1.0 / s5], + [-1.0, -1.0, 1.0, -1.0 / s5], + [0.0, 0.0, 0.0, 4.0 / s5], + ]; + // |v0| = sqrt(3 + 1/5) = sqrt(16/5) = 4/√5 — same for all five. + let scale = circumradius as f64 / (4.0 / s5); + let vertices: Vec = raw + .iter() + .map(|v| { + Vec4::new( + (v[0] * scale) as f32, + (v[1] * scale) as f32, + (v[2] * scale) as f32, + (v[3] * scale) as f32, + ) + }) + .collect(); + + let tetrahedra = (0..5) + .map(|skip| { + let mut idx = [0usize; 4]; + let mut k = 0; + for i in 0..5 { + if i != skip { + idx[k] = i; + k += 1; + } + } + Tetrahedron::new(idx) + }) + .collect(); + + Mesh4D::from_parts(vertices, tetrahedra) +} + +/// Regular **16-cell** (4-orthoplex, hexadecachoron): 8 vertices at +/// `±circumradius` along each axis, 16 tetrahedral cells — the 4D analogue +/// of the octahedron, and the dual of the tesseract. +/// +/// Each cell pairs one vertex from each axis: cell *(s₀,s₁,s₂,s₃)* = +/// *(s₀r·e₀, s₁r·e₁, s₂r·e₂, s₃r·e₃)* for the 16 sign combinations. This is +/// also the base mesh the [hypersphere](crate::primitives::hypersphere) +/// refines. +pub fn hexadecachoron(circumradius: f32) -> Mesh4D { + let r = circumradius; + let mut vertices = Vec::with_capacity(8); + for axis in 0..4 { + for sign in [1.0f32, -1.0] { + let mut v = [0.0f32; 4]; + v[axis] = sign * r; + vertices.push(Vec4::new(v[0], v[1], v[2], v[3])); + } + } + // vertices[2*axis] = +e_axis, vertices[2*axis + 1] = -e_axis + let mut tetrahedra = Vec::with_capacity(16); + for signs in 0..16u32 { + let idx = [ + (signs & 1) as usize, + 2 + ((signs >> 1) & 1) as usize, + 4 + ((signs >> 2) & 1) as usize, + 6 + ((signs >> 3) & 1) as usize, + ]; + tetrahedra.push(Tetrahedron::new(idx)); + } + Mesh4D::from_parts(vertices, tetrahedra) +} + +/// Regular **24-cell** (icositetrachoron): 24 vertices, 24 octahedral cells. +/// +/// The 24-cell is the one regular polytope with **no analogue in any other +/// dimension** — 4D's special snowflake. It is self-dual, tiles 4D space, +/// and its vertices are the unit Hurwitz quaternions. +/// +/// Vertices: all permutations of *(±1, ±1, 0, 0)*, scaled to `circumradius` +/// (natural circumradius √2). Each octahedral cell is found as the set of +/// six vertices extremal along a cell-center direction (the 24 directions of +/// the dual 24-cell), then split into 4 tetrahedra around a main diagonal — +/// 96 tetrahedra total. +pub fn icositetrachoron(circumradius: f32) -> Mesh4D { + let scale = circumradius / 2.0f32.sqrt(); + + // 24 vertices: permutations of (±1, ±1, 0, 0). + let mut vertices: Vec = Vec::with_capacity(24); + for i in 0..3 { + for j in (i + 1)..4 { + for si in [1.0f32, -1.0] { + for sj in [1.0f32, -1.0] { + let mut v = [0.0f32; 4]; + v[i] = si; + v[j] = sj; + vertices.push(Vec4::new(v[0], v[1], v[2], v[3]) * scale); + } + } + } + } + + // 24 cell-center directions: the dual 24-cell's vertices. + let mut directions: Vec = Vec::with_capacity(24); + for axis in 0..4 { + for sign in [1.0f32, -1.0] { + let mut v = [0.0f32; 4]; + v[axis] = sign; + directions.push(Vec4::new(v[0], v[1], v[2], v[3])); + } + } + for bits in 0..16u32 { + let s = |b: u32| if (bits >> b) & 1 == 0 { 0.5f32 } else { -0.5 }; + directions.push(Vec4::new(s(0), s(1), s(2), s(3))); + } + + let mut tetrahedra = Vec::with_capacity(96); + for dir in &directions { + // The 6 vertices extremal along `dir` form one octahedral cell. + let mut scored: Vec<(usize, f32)> = vertices + .iter() + .enumerate() + .map(|(i, v)| (i, v.dot(*dir))) + .collect(); + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + let cell: Vec = scored[..6].iter().map(|(i, _)| *i).collect(); + debug_assert!( + scored[5].1 - scored[6].1 > 1e-4, + "cell selection must be unambiguous" + ); + + // Cell centroid; opposite vertex pairs satisfy a + b = 2c. + let centroid = cell + .iter() + .fold(Vec4::ZERO, |acc, &i| acc + vertices[i]) + * (1.0 / 6.0); + + let antipode = |a: usize| -> usize { + *cell + .iter() + .find(|&&b| { + b != a && (vertices[a] + vertices[b] - centroid * 2.0).length() < 1e-4 * circumradius.max(1.0) + }) + .expect("octahedral cell vertex must have an antipode") + }; + + // Axis pair (a, a'), equator cycle e0 → e1 → e0' → e1'. + let a = cell[0]; + let a2 = antipode(a); + let equator: Vec = cell.iter().copied().filter(|&v| v != a && v != a2).collect(); + let e0 = equator[0]; + let e0p = antipode(e0); + let e1 = *equator.iter().find(|&&v| v != e0 && v != e0p).unwrap(); + let e1p = antipode(e1); + + for (p, q) in [(e0, e1), (e1, e0p), (e0p, e1p), (e1p, e0)] { + tetrahedra.push(Tetrahedron::new([a, a2, p, q])); + } + } + + Mesh4D::from_parts(vertices, tetrahedra) +} + +/// Regular **600-cell** (hexacosichoron): 120 vertices, 600 tetrahedral +/// cells — the 4D analogue of the icosahedron and the most intricate object +/// this engine ships. +/// +/// The 120 vertices are exactly the elements of the **binary icosahedral +/// group** 2I ⊂ unit quaternions: +/// +/// - 8 unit-axis points: permutations of *(±1, 0, 0, 0)* +/// - 16 half-points: *(±½, ±½, ±½, ±½)* +/// - 96 golden points: **even** permutations of *(±φ/2, ±½, ±1/(2φ), 0)* +/// +/// Every vertex has exactly 12 nearest neighbors at distance `r/φ` (its +/// vertex figure is an icosahedron), and the 600 cells are precisely the +/// 4-cliques of the nearest-neighbor graph, which we enumerate directly. +/// The cell count doubles as a proof of correctness: only the true edge +/// length yields exactly 600 maximal 4-cliques. +pub fn hexacosichoron(circumradius: f32) -> Mesh4D { + // --- vertices (f64 for clean adjacency thresholds) --- + let mut raw: Vec<[f64; 4]> = Vec::with_capacity(120); + + for axis in 0..4 { + for sign in [1.0f64, -1.0] { + let mut v = [0.0f64; 4]; + v[axis] = sign; + raw.push(v); + } + } + for bits in 0..16u32 { + let s = |b: u32| if (bits >> b) & 1 == 0 { 0.5f64 } else { -0.5 }; + raw.push([s(0), s(1), s(2), s(3)]); + } + // Even permutations of (φ/2, 1/2, 1/(2φ), 0) with independent signs on + // the three nonzero entries. + let base = [PHI / 2.0, 0.5, 1.0 / (2.0 * PHI), 0.0]; + for perm in EVEN_PERMUTATIONS_4 { + for bits in 0..8u32 { + let mut v = [0.0f64; 4]; + let mut sign_slot = 0; + for (dst, &src) in perm.iter().enumerate() { + let mag = base[src]; + if mag != 0.0 { + let s = if (bits >> sign_slot) & 1 == 0 { 1.0 } else { -1.0 }; + v[dst] = s * mag; + sign_slot += 1; + } else { + v[dst] = 0.0; + } + } + raw.push(v); + } + } + debug_assert_eq!(raw.len(), 120); + + // --- adjacency: edge length is exactly 1/φ for unit circumradius --- + let edge = 1.0 / PHI; + let edge2_lo = (edge * edge) * 0.999; + let edge2_hi = (edge * edge) * 1.001; + let dist2 = |a: &[f64; 4], b: &[f64; 4]| -> f64 { + (0..4).map(|k| (a[k] - b[k]) * (a[k] - b[k])).sum() + }; + + let n = raw.len(); + let mut neighbors: Vec> = vec![Vec::with_capacity(12); n]; + for i in 0..n { + for j in (i + 1)..n { + let d2 = dist2(&raw[i], &raw[j]); + if d2 > edge2_lo && d2 < edge2_hi { + neighbors[i].push(j); + neighbors[j].push(i); + } + } + } + + // --- cells: 4-cliques of the edge graph, enumerated with i = Vec::with_capacity(600); + for i in 0..n { + let ni = &neighbors[i]; + for (a_pos, &j) in ni.iter().enumerate() { + if j < i { + continue; + } + for &k in &ni[(a_pos + 1)..] { + if k < j || !neighbors[j].contains(&k) { + continue; + } + for &l in ni { + if l > k && neighbors[j].contains(&l) && neighbors[k].contains(&l) { + tetrahedra.push(Tetrahedron::new([i, j, k, l])); + } + } + } + } + } + + let vertices = raw + .iter() + .map(|v| { + Vec4::new( + (v[0] * circumradius as f64) as f32, + (v[1] * circumradius as f64) as f32, + (v[2] * circumradius as f64) as f32, + (v[3] * circumradius as f64) as f32, + ) + }) + .collect(); + + Mesh4D::from_parts(vertices, tetrahedra) +} + +/// The 12 even permutations of `[0, 1, 2, 3]`, used to generate the +/// 600-cell's 96 golden vertices (odd permutations would produce the wrong +/// chirality and break the group structure). +const EVEN_PERMUTATIONS_4: [[usize; 4]; 12] = [ + [0, 1, 2, 3], + [0, 2, 3, 1], + [0, 3, 1, 2], + [1, 0, 3, 2], + [1, 2, 0, 3], + [1, 3, 2, 0], + [2, 0, 1, 3], + [2, 1, 3, 0], + [2, 3, 0, 1], + [3, 0, 2, 1], + [3, 1, 0, 2], + [3, 2, 1, 0], +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConvexShape4D; + + /// Volume of a regular tetrahedron with edge `a`: a³ / (6√2). + fn regular_tet_volume(a: f64) -> f64 { + a.powi(3) / (6.0 * 2.0f64.sqrt()) + } + + #[test] + fn test_pentachoron_structure() { + let m = pentachoron(1.0); + m.validate().unwrap(); + assert_eq!(m.vertex_count(), 5); + assert_eq!(m.tetrahedron_count(), 5); + assert!(m.is_watertight()); + // All vertices on the circumsphere + for v in m.vertices() { + assert!((v.length() - 1.0).abs() < 1e-5); + } + } + + #[test] + fn test_pentachoron_volume() { + // Circumradius 1 → edge 2√2 · (√5/4) = √(5/2) · ... measure edge + // directly and compare against 5 regular tets. + let m = pentachoron(2.0); + let edge = (m.vertices()[0] - m.vertices()[1]).length() as f64; + let expected = 5.0 * regular_tet_volume(edge); + assert!( + ((m.surface_volume() as f64) - expected).abs() / expected < 1e-4, + "5-cell boundary volume {} != {expected}", + m.surface_volume() + ); + } + + #[test] + fn test_hexadecachoron_structure() { + let m = hexadecachoron(1.5); + m.validate().unwrap(); + assert_eq!(m.vertex_count(), 8); + assert_eq!(m.tetrahedron_count(), 16); + assert!(m.is_watertight()); + for v in m.vertices() { + assert!((v.length() - 1.5).abs() < 1e-5); + } + } + + #[test] + fn test_hexadecachoron_volume() { + // Closed form: 16 r³ / 3 for circumradius r. + let r = 1.5f64; + let m = hexadecachoron(r as f32); + let expected = 16.0 * r.powi(3) / 3.0; + assert!( + ((m.surface_volume() as f64) - expected).abs() / expected < 1e-4, + "16-cell boundary volume {} != {expected}", + m.surface_volume() + ); + } + + #[test] + fn test_icositetrachoron_structure() { + let m = icositetrachoron(2.0f32.sqrt()); + m.validate().unwrap(); + assert_eq!(m.vertex_count(), 24); + assert_eq!(m.tetrahedron_count(), 96); + assert!(m.is_watertight(), "24-cell boundary must be watertight"); + for v in m.vertices() { + assert!((v.length() - 2.0f32.sqrt()).abs() < 1e-5); + } + } + + #[test] + fn test_icositetrachoron_volume() { + // 24 octahedra of edge a = r (for circumradius r = √2·scale, the + // cell edge equals the scaled edge √2·s where s = r/√2 → a = r/√2·√2 + // ... measure the edge directly instead of deriving it.) + let m = icositetrachoron(2.0f32.sqrt()); + // Octahedron volume: √2/3 · a³. Cell edge: distance between two + // vertices sharing a cell — e.g. (1,1,0,0) and (1,0,1,0): √2. + let a = 2.0f64.sqrt(); + let expected = 24.0 * (2.0f64.sqrt() / 3.0) * a.powi(3); + assert!( + ((m.surface_volume() as f64) - expected).abs() / expected < 1e-3, + "24-cell boundary volume {} != {expected}", + m.surface_volume() + ); + } + + #[test] + fn test_hexacosichoron_structure() { + let m = hexacosichoron(1.0); + m.validate().unwrap(); + assert_eq!(m.vertex_count(), 120, "binary icosahedral group has order 120"); + assert_eq!(m.tetrahedron_count(), 600, "the 600-cell must have 600 cells"); + assert!(m.is_watertight(), "600-cell boundary must be watertight"); + for v in m.vertices() { + assert!((v.length() - 1.0).abs() < 1e-5); + } + } + + #[test] + fn test_hexacosichoron_volume() { + // 600 regular tetrahedra of edge 1/φ (circumradius 1). + let m = hexacosichoron(1.0); + let expected = 600.0 * regular_tet_volume(1.0 / PHI); + assert!( + ((m.surface_volume() as f64) - expected).abs() / expected < 1e-3, + "600-cell boundary volume {} != {expected}", + m.surface_volume() + ); + } + + #[test] + fn test_hexacosichoron_vertex_figure() { + // Every vertex of the 600-cell has exactly 20 incident cells and + // 12 neighbors (its vertex figure is an icosahedron). + let m = hexacosichoron(1.0); + let mut incident = vec![0usize; m.vertex_count()]; + for t in m.tetrahedra() { + for &i in &t.indices { + incident[i] += 1; + } + } + assert!(incident.iter().all(|&c| c == 20), "each vertex must touch 20 cells"); + } + + #[test] + fn test_polytopes_scale_with_circumradius() { + for ctor in [pentachoron, hexadecachoron, icositetrachoron, hexacosichoron] { + let small = ctor(1.0); + let big = ctor(2.0); + // Boundary 3-volume scales with r³ + let ratio = big.surface_volume() / small.surface_volume(); + assert!((ratio - 8.0).abs() < 1e-2, "volume must scale as r³, got {ratio}"); + } + } +} diff --git a/crates/rust4d_math/src/tesseract.rs b/crates/rust4d_math/src/tesseract.rs index 6659576..bd11f75 100644 --- a/crates/rust4d_math/src/tesseract.rs +++ b/crates/rust4d_math/src/tesseract.rs @@ -81,11 +81,24 @@ impl Tesseract4D { ] } - /// Compute the tetrahedra decomposition using Kuhn triangulation + /// Compute the boundary tetrahedra using Kuhn triangulation /// - /// The Kuhn triangulation decomposes the hypercube into 24 5-cells (simplices), - /// each defined by a permutation of dimensions. We then decompose each 5-cell - /// into 5 tetrahedra by omitting each vertex in turn. + /// The Kuhn triangulation decomposes the solid hypercube into 24 5-cells + /// (one per permutation of the four axes). Taking every tetrahedral face + /// of every 5-cell yields 84 distinct tetrahedra — but only the ones + /// lying on the tesseract's **boundary** (its eight cubic facets) matter + /// for slicing; the rest are internal membranes that waste GPU work and + /// would render as spurious interior walls if the camera entered the + /// shape. + /// + /// A tetrahedron lies on a facet exactly when its four vertices agree on + /// one coordinate — with bitmask vertex indices, when all four indices + /// share a bit value. Filtering leaves the 48 boundary tetrahedra + /// (8 cubic facets × 6 Kuhn tetrahedra), and because each facet's + /// triangulation is induced by the same solid Kuhn triangulation, + /// adjacent facets agree on shared face diagonals: the result is a + /// closed, watertight 3-manifold (pinned by `Mesh4D::is_watertight` + /// tests). fn compute_tetrahedra() -> Vec { // Generate all permutations of [0, 1, 2, 3] for Kuhn triangulation let permutations = [ @@ -130,7 +143,14 @@ impl Tesseract4D { canonical.sort(); if seen.insert(canonical) { - tetrahedra.push(Tetrahedron::new(tet_verts)); + // Keep only boundary tetrahedra: all four vertex-index + // bitmasks agree on at least one axis bit. + let all_and = tet_verts.iter().fold(0b1111, |acc, &v| acc & v); + let all_or = tet_verts.iter().fold(0, |acc, &v| acc | v); + let on_boundary = all_and != 0 || all_or != 0b1111; + if on_boundary { + tetrahedra.push(Tetrahedron::new(tet_verts)); + } } } } @@ -162,9 +182,38 @@ mod tests { #[test] fn test_tesseract_tetrahedron_count() { let t = Tesseract4D::new(2.0); - // Should have some reasonable number of tetrahedra - assert!(!t.tetrahedra().is_empty()); - assert!(t.tetrahedra().len() <= 120); // Max: 24 * 5 before deduplication + // Exactly the boundary: 8 cubic facets × 6 Kuhn tetrahedra. + assert_eq!(t.tetrahedra().len(), 48); + } + + #[test] + fn test_tesseract_tetrahedra_lie_on_facets() { + // Every tetrahedron's four vertices must share a fixed coordinate + // (±h on some axis) — i.e. lie on one of the 8 cubic facets. + let t = Tesseract4D::new(2.0); + for tet in t.tetrahedra() { + let verts = tet.indices.map(|i| t.vertices()[i]); + let on_facet = (0..4).any(|axis| { + let c = |v: &Vec4| match axis { + 0 => v.x, + 1 => v.y, + 2 => v.z, + _ => v.w, + }; + verts.iter().all(|v| c(v) == c(&verts[0])) + }); + assert!(on_facet, "tet {:?} is not on a facet", tet.indices); + } + } + + #[test] + fn test_tesseract_boundary_volume() { + // Boundary 3-volume of a tesseract with side s: 8 cubic cells of + // volume s³. + use crate::Mesh4D; + let t = Tesseract4D::new(2.0); + let m: Mesh4D = (&t as &dyn crate::ConvexShape4D).into(); + assert!((m.surface_volume() - 8.0 * 8.0).abs() < 1e-4); } #[test] From 89f62fb213f238bc2e859c6ceccde382679f73df Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:30:01 +0100 Subject: [PATCH 2/8] feat(core): scene support for all primitive shapes - ShapeTemplate variants: Hypersphere, Pentachoron, Hexadecachoron, Icositetrachoron, Hexacosichoron, Spherinder, Cubinder, Duocylinder (resolution fields default via serde so scene files stay terse) - bounding_radius() closed-form per variant; ColliderHint maps round shapes to sphere colliders, others to conservative AABBs - scene instantiation uses collider hints instead of hardcoded tesseract-only half-extents - RON round-trip + defaults + collider hint tests (+4 tests) --- crates/rust4d_core/src/lib.rs | 2 +- crates/rust4d_core/src/scene.rs | 21 +-- crates/rust4d_core/src/shapes.rs | 239 ++++++++++++++++++++++++++++++- 3 files changed, 250 insertions(+), 12 deletions(-) diff --git a/crates/rust4d_core/src/lib.rs b/crates/rust4d_core/src/lib.rs index a017bf7..2ed7d97 100644 --- a/crates/rust4d_core/src/lib.rs +++ b/crates/rust4d_core/src/lib.rs @@ -28,7 +28,7 @@ pub use transform::Transform4D; pub use entity::{Material, ShapeRef, DirtyFlags, EntityTemplate}; pub use world::{World, HierarchyError}; pub use components::{Name, Tags, PhysicsBody, Parent, Children}; -pub use shapes::ShapeTemplate; +pub use shapes::{ColliderHint, ShapeTemplate}; pub use scene::{Scene, SceneLoadError, SceneSaveError, SceneError, ActiveScene}; pub use scene_manager::SceneManager; pub use asset_error::AssetError; diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 598a25c..5a7958d 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -274,16 +274,17 @@ impl ActiveScene { entity_template.transform.position.w, ); - // Get half-extent from shape - let half_extent = match &entity_template.shape { - ShapeTemplate::Tesseract { size } => size / 2.0, - ShapeTemplate::Hyperplane { .. } => 1.0, // shouldn't be dynamic, but fallback - }; - - let body = RigidBody4D::new_aabb( - position, - Vec4::new(half_extent, half_extent, half_extent, half_extent), - ) + // Choose a collider from the shape's hint: spheres for + // round shapes, conservative AABBs for everything else. + let body = match entity_template.shape.collider_hint() { + crate::shapes::ColliderHint::Sphere { radius } => { + RigidBody4D::new_sphere(position, radius) + } + crate::shapes::ColliderHint::Aabb { half_extent } => RigidBody4D::new_aabb( + position, + Vec4::new(half_extent, half_extent, half_extent, half_extent), + ), + } .with_body_type(BodyType::Dynamic) .with_mass(10.0) .with_material(PhysicsMaterial::WOOD); diff --git a/crates/rust4d_core/src/shapes.rs b/crates/rust4d_core/src/shapes.rs index 4b9e26d..6803d60 100644 --- a/crates/rust4d_core/src/shapes.rs +++ b/crates/rust4d_core/src/shapes.rs @@ -8,7 +8,7 @@ //! The entity transform is used to position them in world space. use serde::{Serialize, Deserialize}; -use rust4d_math::{Tesseract4D, Hyperplane4D, ConvexShape4D}; +use rust4d_math::{Tesseract4D, Hyperplane4D, ConvexShape4D, primitives}; /// Serializable shape template /// @@ -44,6 +44,75 @@ pub enum ShapeTemplate { /// Y thickness (bottom at y=0 in local space) thickness: f32, }, + /// Solid 4-ball bounded by a 3-sphere (S³) + /// + /// Its cross-section is a sphere that grows and shrinks as the slice + /// plane sweeps through — the canonical 4D demo object. + Hypersphere { + /// Radius of the 4-ball + radius: f32, + /// Boundary refinement level (0–4; cells = 16·8ˢ) + #[serde(default = "default_sphere_subdivisions")] + subdivisions: u32, + }, + /// Regular 5-cell (4-simplex) — the 4D tetrahedron + Pentachoron { + /// Circumradius (vertex distance from center) + circumradius: f32, + }, + /// Regular 16-cell (4-orthoplex) — the 4D octahedron + Hexadecachoron { + /// Circumradius (vertex distance from center) + circumradius: f32, + }, + /// Regular 24-cell — 4D's unique extra regular polytope + Icositetrachoron { + /// Circumradius (vertex distance from center) + circumradius: f32, + }, + /// Regular 600-cell — the 4D icosahedron (600 tetrahedral cells) + Hexacosichoron { + /// Circumradius (vertex distance from center) + circumradius: f32, + }, + /// Ball × W-segment — the 4D cylinder with a spherical cross-section + Spherinder { + /// Radius of the 3-ball + radius: f32, + /// Half-length of the W extrusion + half_height: f32, + /// Icosphere refinement level (0–5) + #[serde(default = "default_sphere_subdivisions")] + subdivisions: u32, + }, + /// Disk (XY) × square (ZW) — the 4D cylinder with a flat direction pair + Cubinder { + /// Disk radius + radius: f32, + /// Square half-extent in Z and W + half_size: f32, + /// Circle resolution (≥ 3) + #[serde(default = "default_segments")] + segments: u32, + }, + /// Disk × disk — boundary is two solid tori meeting at a Clifford torus + Duocylinder { + /// Radius of the XY disk + radius_xy: f32, + /// Radius of the ZW disk + radius_zw: f32, + /// Circle resolution for both circles (≥ 3) + #[serde(default = "default_segments")] + segments: u32, + }, +} + +fn default_sphere_subdivisions() -> u32 { + 2 +} + +fn default_segments() -> u32 { + 24 } impl ShapeTemplate { @@ -60,6 +129,73 @@ impl ShapeTemplate { // The visual mesh is created at y=0 (local space) and positioned by entity transform. Box::new(Hyperplane4D::new(*size, *subdivisions as usize, *cell_size, *thickness)) } + ShapeTemplate::Hypersphere { radius, subdivisions } => { + Box::new(primitives::hypersphere(*radius, *subdivisions)) + } + ShapeTemplate::Pentachoron { circumradius } => { + Box::new(primitives::pentachoron(*circumradius)) + } + ShapeTemplate::Hexadecachoron { circumradius } => { + Box::new(primitives::hexadecachoron(*circumradius)) + } + ShapeTemplate::Icositetrachoron { circumradius } => { + Box::new(primitives::icositetrachoron(*circumradius)) + } + ShapeTemplate::Hexacosichoron { circumradius } => { + Box::new(primitives::hexacosichoron(*circumradius)) + } + ShapeTemplate::Spherinder { radius, half_height, subdivisions } => { + Box::new(primitives::spherinder(*radius, *half_height, *subdivisions)) + } + ShapeTemplate::Cubinder { radius, half_size, segments } => { + Box::new(primitives::cubinder(*radius, *half_size, *segments)) + } + ShapeTemplate::Duocylinder { radius_xy, radius_zw, segments } => { + Box::new(primitives::duocylinder(*radius_xy, *radius_zw, *segments, *segments)) + } + } + } + + /// Radius of the smallest origin-centered 4-ball containing the shape. + /// + /// Used for physics collider sizing and slice-range culling. Closed-form + /// per variant — no mesh construction required. + pub fn bounding_radius(&self) -> f32 { + match self { + ShapeTemplate::Tesseract { size } => size * 0.5 * 2.0, // half-diagonal = (s/2)·√4 + ShapeTemplate::Hyperplane { size, cell_size, thickness, .. } => { + (2.0 * size * size + cell_size * cell_size + thickness * thickness).sqrt() + } + ShapeTemplate::Hypersphere { radius, .. } => *radius, + ShapeTemplate::Pentachoron { circumradius } + | ShapeTemplate::Hexadecachoron { circumradius } + | ShapeTemplate::Icositetrachoron { circumradius } + | ShapeTemplate::Hexacosichoron { circumradius } => *circumradius, + ShapeTemplate::Spherinder { radius, half_height, .. } => { + (radius * radius + half_height * half_height).sqrt() + } + ShapeTemplate::Cubinder { radius, half_size, .. } => { + (radius * radius + 2.0 * half_size * half_size).sqrt() + } + ShapeTemplate::Duocylinder { radius_xy, radius_zw, .. } => { + (radius_xy * radius_xy + radius_zw * radius_zw).sqrt() + } + } + } + + /// Preferred physics collider for this shape: `(is_sphere, radius_or_half_extent)`. + /// + /// Round shapes (hypersphere, 600-cell — which is within 2% of its + /// circumsphere) map to sphere colliders; everything else gets a + /// conservative AABB from [`Self::bounding_radius`]. + pub fn collider_hint(&self) -> ColliderHint { + match self { + ShapeTemplate::Hypersphere { radius, .. } => ColliderHint::Sphere { radius: *radius }, + ShapeTemplate::Hexacosichoron { circumradius } => { + ColliderHint::Sphere { radius: *circumradius } + } + ShapeTemplate::Tesseract { size } => ColliderHint::Aabb { half_extent: size * 0.5 }, + other => ColliderHint::Aabb { half_extent: other.bounding_radius() * std::f32::consts::FRAC_1_SQRT_2 }, } } @@ -76,6 +212,41 @@ impl ShapeTemplate { pub fn hyperplane(y: f32, size: f32, subdivisions: u32, cell_size: f32, thickness: f32) -> Self { ShapeTemplate::Hyperplane { y, size, subdivisions, cell_size, thickness } } + + /// Create a hypersphere template at default quality + pub fn hypersphere(radius: f32) -> Self { + ShapeTemplate::Hypersphere { radius, subdivisions: 2 } + } + + /// Create a spherinder template at default quality + pub fn spherinder(radius: f32, half_height: f32) -> Self { + ShapeTemplate::Spherinder { radius, half_height, subdivisions: 2 } + } + + /// Create a cubinder template at default quality + pub fn cubinder(radius: f32, half_size: f32) -> Self { + ShapeTemplate::Cubinder { radius, half_size, segments: 24 } + } + + /// Create a duocylinder template at default quality + pub fn duocylinder(radius_xy: f32, radius_zw: f32) -> Self { + ShapeTemplate::Duocylinder { radius_xy, radius_zw, segments: 24 } + } +} + +/// Physics collider suggestion derived from a [`ShapeTemplate`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ColliderHint { + /// Use a sphere collider of this radius + Sphere { + /// Sphere radius + radius: f32, + }, + /// Use an axis-aligned box collider with this uniform half-extent + Aabb { + /// Half-extent on every axis + half_extent: f32, + }, } #[cfg(test)] @@ -97,6 +268,72 @@ mod tests { assert_eq!(shape.vertex_count(), 4 * 16); } + #[test] + fn test_all_primitive_templates_create_valid_shapes() { + let templates = [ + (ShapeTemplate::hypersphere(1.0), 1024), + (ShapeTemplate::Pentachoron { circumradius: 1.0 }, 5), + (ShapeTemplate::Hexadecachoron { circumradius: 1.0 }, 16), + (ShapeTemplate::Icositetrachoron { circumradius: 1.0 }, 96), + (ShapeTemplate::Hexacosichoron { circumradius: 1.0 }, 600), + (ShapeTemplate::spherinder(1.0, 0.5), 1600), + (ShapeTemplate::cubinder(1.0, 0.5), 24 * 2 * 3 + 4 * 24 * 3), + (ShapeTemplate::duocylinder(1.0, 1.0), 2 * 24 * 24 * 3), + ]; + for (template, expected_tets) in templates { + let shape = template.create_shape(); + assert_eq!( + shape.tetrahedron_count(), + expected_tets, + "template {template:?}" + ); + } + } + + #[test] + fn test_primitive_template_ron_round_trip() { + let originals = vec![ + ShapeTemplate::hypersphere(1.5), + ShapeTemplate::Hexacosichoron { circumradius: 0.8 }, + ShapeTemplate::spherinder(1.0, 0.75), + ShapeTemplate::duocylinder(1.2, 0.9), + ]; + for original in originals { + let text = ron::to_string(&original).unwrap(); + let parsed: ShapeTemplate = ron::from_str(&text).unwrap(); + assert_eq!(format!("{original:?}"), format!("{parsed:?}")); + } + } + + #[test] + fn test_ron_defaults_for_resolution_fields() { + // Scene files may omit resolution fields; they get sane defaults. + let s: ShapeTemplate = ron::from_str("(type: \"Hypersphere\", radius: 2.0)").unwrap(); + match s { + ShapeTemplate::Hypersphere { radius, subdivisions } => { + assert_eq!(radius, 2.0); + assert_eq!(subdivisions, 2); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_collider_hints() { + assert_eq!( + ShapeTemplate::hypersphere(1.5).collider_hint(), + ColliderHint::Sphere { radius: 1.5 } + ); + assert_eq!( + ShapeTemplate::tesseract(2.0).collider_hint(), + ColliderHint::Aabb { half_extent: 1.0 } + ); + match ShapeTemplate::duocylinder(1.0, 1.0).collider_hint() { + ColliderHint::Aabb { half_extent } => assert!(half_extent > 0.9 && half_extent < 1.1), + other => panic!("expected AABB, got {other:?}"), + } + } + #[test] fn test_tesseract_serialization() { let template = ShapeTemplate::tesseract(2.5); From 8ac81fe362d12a6ec93f573e77661a6c993ba777 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:41:12 +0100 Subject: [PATCH 3/8] feat(examples): gallery scene and headless primitive showcase - scenes/gallery.ron: floor plus all nine primitive exhibits (tesseract, hypersphere, 5-cell, 16-cell, 24-cell, 600-cell, spherinder, cubinder, duocylinder), with a dynamic hypersphere to exercise collider hints - examples/shape_showcase: offscreen GPU renderer for every primitive across three slice offsets and three orientations; writes PPM frames and logs triangle counts, using the real slice + render pipelines - tests/gallery_scene: parses and instantiates the gallery through SceneManager, verifies all primitive variants are present, and builds renderable geometry Verification: - cargo run --example shape_showcase -- .scratchpad/captures-gallery produced 81 captures, zero zero-triangle frames - contact-sheet.png visually checked from central slices - cargo clippy --workspace --all-targets -- -D warnings - RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps - cargo test --workspace (976 passed) --- examples/shape_showcase.rs | 435 +++++++++++++++++++++++++++++++++++++ scenes/gallery.ron | 131 +++++++++++ tests/gallery_scene.rs | 71 ++++++ 3 files changed, 637 insertions(+) create mode 100644 examples/shape_showcase.rs create mode 100644 scenes/gallery.ron create mode 100644 tests/gallery_scene.rs diff --git a/examples/shape_showcase.rs b/examples/shape_showcase.rs new file mode 100644 index 0000000..7a03767 --- /dev/null +++ b/examples/shape_showcase.rs @@ -0,0 +1,435 @@ +//! Headless visual showcase for the Rust4D primitive catalog. +//! +//! This is both a demo generator and a regression tool. It renders each +//! primitive through the real GPU slice + render pipelines into offscreen +//! textures and writes PPM frames, no window needed. +//! +//! Usage: +//! +//! ```bash +//! cargo run --example shape_showcase .scratchpad/captures-gallery +//! magick .scratchpad/captures-gallery/duocylinder_mid_identity.ppm duocylinder.png +//! ``` +//! +//! What to look for: +//! - hypersphere: sphere grows/shrinks across W offsets +//! - spherinder: sphere remains roughly constant through its W tube +//! - duocylinder: a torus-like slice at W=0 +//! - no cracks or hairline T-junctions (would indicate a broken primitive +//! tetrahedralization despite the CPU watertightness tests) + +use rust4d_core::{Material, ShapeTemplate, Transform4D}; +use rust4d_render::camera4d::Camera4D; +use rust4d_render::pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, +}; +use rust4d_render::RenderableGeometry; + +use std::path::{Path, PathBuf}; + +const WIDTH: u32 = 900; +const HEIGHT: u32 = 700; +const MAX_TRIANGLES: usize = 1_200_000; + +struct ShowcaseShape { + name: &'static str, + template: ShapeTemplate, + color: [f32; 4], + radius: f32, +} + +impl ShowcaseShape { + fn geometry(&self) -> RenderableGeometry { + let shape = self.template.create_shape(); + let mut geom = RenderableGeometry::new(); + let material = Material { base_color: self.color }; + geom.add_components_with_color( + &Transform4D::identity(), + shape.as_ref(), + &material, + &|_v, material| material.base_color, + ); + geom + } +} + +struct HeadlessGpu { + device: wgpu::Device, + queue: wgpu::Queue, + slice_pipeline: SlicePipeline, + render_pipeline: RenderPipeline, + color_texture: wgpu::Texture, + readback_buffer: wgpu::Buffer, + padded_bytes_per_row: u32, +} + +impl HeadlessGpu { + fn new() -> Self { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + compatible_surface: None, + force_fallback_adapter: false, + })) + .expect("no GPU adapter available"); + + let info = adapter.get_info(); + println!("[GPU] adapter: {} ({:?}, {:?})", info.name, info.device_type, info.backend); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("Shape Showcase Headless Device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + }, + None, + )) + .expect("failed to create device"); + + let slice_pipeline = SlicePipeline::new(&device, MAX_TRIANGLES); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let mut render_pipeline = RenderPipeline::new(&device, format); + render_pipeline.ensure_depth_texture(&device, WIDTH, HEIGHT); + + let color_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Shape Showcase Color"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let unpadded = WIDTH * 4; + let padded_bytes_per_row = unpadded.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Shape Showcase Readback"), + size: (padded_bytes_per_row * HEIGHT) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Self { + device, + queue, + slice_pipeline, + render_pipeline, + color_texture, + readback_buffer, + padded_bytes_per_row, + } + } + + fn upload_geometry(&mut self, geometry: &RenderableGeometry) { + self.slice_pipeline + .upload_tetrahedra(&self.device, &geometry.vertices, &geometry.tetrahedra); + println!( + "[GPU] uploaded {} vertices, {} tetrahedra", + geometry.vertex_count(), + geometry.tetrahedron_count() + ); + } + + fn capture(&mut self, camera: &Camera4D, tetrahedron_count: u32, path: &Path) -> u32 { + let pos = camera.position; + let slice_params = SliceParams { + slice_w: camera.get_slice_w(), + tetrahedron_count, + _padding: [0.0; 2], + camera_matrix: camera.rotation_matrix(), + camera_position: [pos.x, pos.y, pos.z, pos.w], + }; + self.slice_pipeline.update_params(&self.queue, &slice_params); + + let projection_matrix = perspective_matrix( + 40.0_f32.to_radians(), + WIDTH as f32 / HEIGHT as f32, + 0.1, + 100.0, + ); + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + self.render_pipeline.update_uniforms( + &self.queue, + &RenderUniforms { + view_matrix: identity, + projection_matrix, + light_dir: [0.4, 0.9, 0.35], + _padding: 0.0, + ambient_strength: 0.35, + diffuse_strength: 0.75, + w_color_strength: 0.25, + w_range: 2.5, + }, + ); + + let view = self + .color_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Shape Showcase Encoder"), + }); + + self.slice_pipeline.reset_counter(&self.queue); + self.slice_pipeline.run_slice_pass(&mut encoder); + self.render_pipeline + .prepare_indirect_draw(&mut encoder, self.slice_pipeline.counter_buffer()); + self.render_pipeline.render( + &mut encoder, + &view, + self.slice_pipeline.output_buffer(), + wgpu::Color { + r: 0.035, + g: 0.035, + b: 0.055, + a: 1.0, + }, + ); + + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.color_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &self.readback_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.padded_bytes_per_row), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = self.readback_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); + self.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().expect("readback map failed"); + + { + let data = slice.get_mapped_range(); + write_ppm(path, &data, WIDTH, HEIGHT, self.padded_bytes_per_row); + } + self.readback_buffer.unmap(); + + let vertex_count = self.read_counter(); + let tris = vertex_count / 3; + println!( + "[CAPTURE] {} slice={:.3} triangles={tris}", + path.file_name().unwrap().to_string_lossy(), + camera.get_slice_w(), + ); + tris + } + + fn read_counter(&self) -> u32 { + let staging = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Shape Showcase Counter Staging"), + size: 4, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + encoder.copy_buffer_to_buffer(self.slice_pipeline.counter_buffer(), 0, &staging, 0, 4); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); + self.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().expect("counter map failed"); + let count = { + let data = slice.get_mapped_range(); + u32::from_le_bytes(data[..4].try_into().unwrap()) + }; + staging.unmap(); + count + } +} + +fn write_ppm(path: &Path, data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) { + let mut out = Vec::with_capacity((width * height * 3) as usize + 32); + out.extend_from_slice(format!("P6\n{} {}\n255\n", width, height).as_bytes()); + for y in 0..height { + let row_start = (y * padded_bytes_per_row) as usize; + for x in 0..width { + let px = row_start + (x * 4) as usize; + out.extend_from_slice(&data[px..px + 3]); + } + } + std::fs::write(path, out).expect("failed to write ppm"); +} + +fn shapes() -> Vec { + vec![ + ShowcaseShape { + name: "tesseract", + template: ShapeTemplate::tesseract(2.2), + color: [1.0, 0.82, 0.32, 1.0], + radius: 1.9, + }, + ShowcaseShape { + name: "hypersphere", + template: ShapeTemplate::Hypersphere { radius: 1.25, subdivisions: 2 }, + color: [0.35, 0.70, 1.0, 1.0], + radius: 1.25, + }, + ShowcaseShape { + name: "pentachoron", + template: ShapeTemplate::Pentachoron { circumradius: 1.4 }, + color: [1.0, 0.38, 0.34, 1.0], + radius: 1.4, + }, + ShowcaseShape { + name: "hexadecachoron", + template: ShapeTemplate::Hexadecachoron { circumradius: 1.35 }, + color: [0.55, 1.0, 0.48, 1.0], + radius: 1.35, + }, + ShowcaseShape { + name: "icositetrachoron", + template: ShapeTemplate::Icositetrachoron { circumradius: 1.5 }, + color: [0.72, 0.45, 1.0, 1.0], + radius: 1.5, + }, + ShowcaseShape { + name: "hexacosichoron", + template: ShapeTemplate::Hexacosichoron { circumradius: 1.2 }, + color: [1.0, 0.55, 0.88, 1.0], + radius: 1.2, + }, + ShowcaseShape { + name: "spherinder", + template: ShapeTemplate::Spherinder { radius: 1.05, half_height: 1.25, subdivisions: 2 }, + color: [0.28, 0.95, 0.88, 1.0], + radius: 1.65, + }, + ShowcaseShape { + name: "cubinder", + template: ShapeTemplate::Cubinder { radius: 1.05, half_size: 0.9, segments: 32 }, + color: [0.95, 0.72, 0.24, 1.0], + radius: 1.65, + }, + ShowcaseShape { + name: "duocylinder", + template: ShapeTemplate::Duocylinder { radius_xy: 1.0, radius_zw: 1.0, segments: 32 }, + color: [0.40, 0.65, 1.0, 1.0], + radius: 1.45, + }, + ] +} + +fn camera_for(angle: &str, slice_offset: f32) -> Camera4D { + let mut camera = Camera4D::new(); + camera.slice_offset = slice_offset; + match angle { + "xw" => camera.rotate_xw(0.45), + "zw" => camera.rotate_w(0.45), + _ => {} + } + // Keep the object at the origin centered in the slice plane for every + // orientation. Leaving the camera at world Z=5 after a ZW rotation makes + // the plane miss small objects entirely; positioning along `-forward` + // preserves the usual identity-camera view distance while maintaining + // dot(ana, origin - camera.position) == 0. + camera.position = -camera.forward() * 5.0; + camera +} + +fn main() { + env_logger::init(); + + let out_dir = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".scratchpad/captures-gallery")); + std::fs::create_dir_all(&out_dir).expect("create output dir"); + + let mut gpu = HeadlessGpu::new(); + let mut total_captures = 0usize; + let mut zero_captures = 0usize; + + for shape in shapes() { + println!("\n[SHAPE] {}", shape.name); + let geom = shape.geometry(); + let tetrahedron_count = geom.tetrahedron_count() as u32; + gpu.upload_geometry(&geom); + + for angle in ["identity", "xw", "zw"] { + for (label, slice_offset) in [ + ("minus", -0.20 * shape.radius), + ("mid", 0.0), + ("plus", 0.20 * shape.radius), + ] { + let camera = camera_for(angle, slice_offset); + let path = out_dir.join(format!("{}_{}_{}.ppm", shape.name, label, angle)); + let tris = gpu.capture(&camera, tetrahedron_count, &path); + total_captures += 1; + if tris == 0 { + zero_captures += 1; + eprintln!( + "[WARN] zero triangles for {} {label} {angle}; inspect if intentional", + shape.name + ); + } + } + } + } + + println!( + "\n[SUMMARY] {total_captures} captures written to {}; zero-triangle captures: {zero_captures}", + out_dir.display() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_showcase_shapes_create_geometry() { + for shape in shapes() { + let geom = shape.geometry(); + assert!(geom.vertex_count() > 0, "{} has no vertices", shape.name); + assert!(geom.tetrahedron_count() > 0, "{} has no tetrahedra", shape.name); + } + } + + #[test] + fn test_showcase_gpu_types_still_match() { + // Keeps this example honest if the pipeline types change. + assert_eq!(std::mem::size_of::(), 32); + assert_eq!(std::mem::size_of::(), 16); + } +} diff --git a/scenes/gallery.ron b/scenes/gallery.ron new file mode 100644 index 0000000..f5b1b2c --- /dev/null +++ b/scenes/gallery.ron @@ -0,0 +1,131 @@ +// Rust4D Shape Gallery +// +// Demonstrates the engine's full primitive catalog: tesseract, the four +// regular polytopes implemented by the primitive library (5-cell, 16-cell, +// 24-cell, 600-cell), and the curved 4D shapes (hypersphere, spherinder, +// cubinder, duocylinder). The hypersphere is dynamic to verify collider +// creation; the rest are static visual exhibits. +Scene( + name: "Shape Gallery", + entities: [ + EntityTemplate( + name: Some("floor"), + tags: ["static"], + transform: Transform4D( + position: Vec4(x: 0.0, y: -2.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate( + type: "Hyperplane", + y: -2.0, + size: 22.0, + subdivisions: 22, + cell_size: 8.0, + thickness: 0.001, + ), + material: Material(base_color: (0.36, 0.36, 0.40, 1.0)), + ), + EntityTemplate( + name: Some("tesseract"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: -16.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate(type: "Tesseract", size: 2.2), + material: Material(base_color: (1.0, 0.82, 0.32, 1.0)), + ), + EntityTemplate( + name: Some("hypersphere"), + tags: ["dynamic", "gallery"], + transform: Transform4D( + position: Vec4(x: -12.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate(type: "Hypersphere", radius: 1.25, subdivisions: 2), + material: Material(base_color: (0.35, 0.70, 1.0, 1.0)), + ), + EntityTemplate( + name: Some("pentachoron"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: -8.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.2, + ), + shape: ShapeTemplate(type: "Pentachoron", circumradius: 1.3), + material: Material(base_color: (1.0, 0.38, 0.34, 1.0)), + ), + EntityTemplate( + name: Some("hexadecachoron"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: -4.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.2, + ), + shape: ShapeTemplate(type: "Hexadecachoron", circumradius: 1.35), + material: Material(base_color: (0.55, 1.0, 0.48, 1.0)), + ), + EntityTemplate( + name: Some("icositetrachoron"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: 0.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.1, + ), + shape: ShapeTemplate(type: "Icositetrachoron", circumradius: 1.5), + material: Material(base_color: (0.72, 0.45, 1.0, 1.0)), + ), + EntityTemplate( + name: Some("hexacosichoron"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: 4.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.25, + ), + shape: ShapeTemplate(type: "Hexacosichoron", circumradius: 1.2), + material: Material(base_color: (1.0, 0.55, 0.88, 1.0)), + ), + EntityTemplate( + name: Some("spherinder"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: 8.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate(type: "Spherinder", radius: 1.05, half_height: 1.25, subdivisions: 2), + material: Material(base_color: (0.28, 0.95, 0.88, 1.0)), + ), + EntityTemplate( + name: Some("cubinder"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: 12.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate(type: "Cubinder", radius: 1.05, half_size: 0.9, segments: 32), + material: Material(base_color: (0.95, 0.72, 0.24, 1.0)), + ), + EntityTemplate( + name: Some("duocylinder"), + tags: ["static", "gallery"], + transform: Transform4D( + position: Vec4(x: 16.0, y: 0.0, z: 0.0, w: 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), + scale: 1.0, + ), + shape: ShapeTemplate(type: "Duocylinder", radius_xy: 1.0, radius_zw: 1.0, segments: 32), + material: Material(base_color: (0.40, 0.65, 1.0, 1.0)), + ), + ], + gravity: Some(-20.0), + player_spawn: Some((0.0, 1.5, 18.0, 0.0)), +) diff --git a/tests/gallery_scene.rs b/tests/gallery_scene.rs new file mode 100644 index 0000000..6f2b7b2 --- /dev/null +++ b/tests/gallery_scene.rs @@ -0,0 +1,71 @@ +//! Regression tests for `scenes/gallery.ron`. +//! +//! The gallery is both user-facing content and the canonical scene-file +//! exercise for every 4D primitive variant. If a new `ShapeTemplate` breaks +//! serde, collider hints, or renderable geometry collection, this test fails +//! before someone discovers it in the windowed app. + +use rust4d::systems::build_geometry; +use rust4d_core::{SceneManager, ShapeTemplate}; +use rust4d_physics::PhysicsConfig; + +#[test] +fn gallery_scene_loads_instantiates_and_builds_geometry() { + let mut manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); + let name = manager + .load_scene("scenes/gallery.ron") + .expect("gallery scene should parse"); + + let template = manager + .get_template(&name) + .expect("gallery template should be registered"); + assert_eq!(template.name, "Shape Gallery"); + assert_eq!(template.entities.len(), 10, "floor + 9 exhibit shapes"); + + let variants: Vec<&'static str> = template + .entities + .iter() + .map(|e| match &e.shape { + ShapeTemplate::Tesseract { .. } => "Tesseract", + ShapeTemplate::Hyperplane { .. } => "Hyperplane", + ShapeTemplate::Hypersphere { .. } => "Hypersphere", + ShapeTemplate::Pentachoron { .. } => "Pentachoron", + ShapeTemplate::Hexadecachoron { .. } => "Hexadecachoron", + ShapeTemplate::Icositetrachoron { .. } => "Icositetrachoron", + ShapeTemplate::Hexacosichoron { .. } => "Hexacosichoron", + ShapeTemplate::Spherinder { .. } => "Spherinder", + ShapeTemplate::Cubinder { .. } => "Cubinder", + ShapeTemplate::Duocylinder { .. } => "Duocylinder", + }) + .collect(); + + for expected in [ + "Tesseract", + "Hypersphere", + "Pentachoron", + "Hexadecachoron", + "Icositetrachoron", + "Hexacosichoron", + "Spherinder", + "Cubinder", + "Duocylinder", + ] { + assert!(variants.contains(&expected), "gallery missing {expected}"); + } + + manager.instantiate(&name).expect("gallery should instantiate"); + manager.push_scene(&name).expect("gallery should become active"); + + let world = manager.active_world().expect("active gallery world"); + let geometry = build_geometry(world); + + assert!(geometry.vertex_count() > 2_000, "gallery should contain substantial geometry"); + assert!(geometry.tetrahedron_count() > 8_000, "gallery should upload all primitive meshes"); + + let active = manager.active_scene().unwrap(); + assert_eq!(active.world.entity_count(), 10); + assert!( + active.world.physics().is_some(), + "gallery should create a physics world because the hypersphere is dynamic" + ); +} From 4acc41f38619fc44a894fe196a34a13646bc0da2 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:46:00 +0100 Subject: [PATCH 4/8] feat(render): professional two-sided Blinn-Phong lighting - RenderUniforms v2: camera position, specular parameters, fog, and up to four point lights (336-byte layout pinned against WGSL) - render.wgsl: two-sided normal handling for arbitrary slice triangle winding, directional + point light Blinn-Phong, W-color blending, and exponential distance fog - render pipeline disables back-face culling, matching the slice shader's non-stable winding - all examples and render system inherit new uniform defaults Verification: - cargo run --example shape_showcase -- .scratchpad/captures-gallery-lit produced 81 captures, zero zero-triangle frames; contact sheet visually checked - cargo clippy --workspace --all-targets -- -D warnings - RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps - cargo test --workspace (977 passed) --- .../src/pipeline/render_pipeline.rs | 5 +- crates/rust4d_render/src/pipeline/types.rs | 79 ++++++++-- crates/rust4d_render/src/shaders/render.wgsl | 136 ++++++++++++------ examples/01_hello_tesseract.rs | 1 + examples/02_multiple_shapes.rs | 1 + examples/03_physics_demo.rs | 1 + examples/04_camera_exploration.rs | 1 + examples/headless_protocol.rs | 1 + examples/ron_preview.rs | 1 + examples/shape_showcase.rs | 1 + src/systems/render.rs | 1 + 11 files changed, 175 insertions(+), 53 deletions(-) diff --git a/crates/rust4d_render/src/pipeline/render_pipeline.rs b/crates/rust4d_render/src/pipeline/render_pipeline.rs index 71200fd..c44b69e 100644 --- a/crates/rust4d_render/src/pipeline/render_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/render_pipeline.rs @@ -94,7 +94,10 @@ impl RenderPipeline { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: Some(wgpu::Face::Back), + // Slice-generated triangle winding is not stable across all + // marching-tetrahedra cases, so shading is deliberately + // two-sided and culling must stay disabled. + cull_mode: None, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, diff --git a/crates/rust4d_render/src/pipeline/types.rs b/crates/rust4d_render/src/pipeline/types.rs index 2fd8df9..bd807cf 100644 --- a/crates/rust4d_render/src/pipeline/types.rs +++ b/crates/rust4d_render/src/pipeline/types.rs @@ -127,8 +127,36 @@ impl Default for SliceParams { } } -/// Render uniforms for the 3D rendering pass -/// Layout: 160 bytes total (must match render.wgsl RenderUniforms) +/// A point light for the 3D cross-section render pass. +/// +/// Layout: 32 bytes, matching WGSL `PointLight`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct PointLightUniform { + /// XYZ position in camera/world 3-space plus attenuation radius. + pub position_radius: [f32; 4], + /// RGB color plus scalar intensity. + pub color_intensity: [f32; 4], +} + +impl PointLightUniform { + /// Construct a point light from position, radius, color, and intensity. + pub fn new(position: [f32; 3], radius: f32, color: [f32; 3], intensity: f32) -> Self { + Self { + position_radius: [position[0], position[1], position[2], radius], + color_intensity: [color[0], color[1], color[2], intensity], + } + } +} + +impl Default for PointLightUniform { + fn default() -> Self { + Self::new([0.0, 2.0, 2.0], 8.0, [1.0, 1.0, 1.0], 0.0) + } +} + +/// Render uniforms for the 3D rendering pass. +/// Layout: 336 bytes total (must match render.wgsl RenderUniforms). #[repr(C)] #[derive(Clone, Copy, Debug, Pod, Zeroable)] pub struct RenderUniforms { @@ -136,14 +164,27 @@ pub struct RenderUniforms { pub view_matrix: [[f32; 4]; 4], /// Projection matrix (64 bytes) pub projection_matrix: [[f32; 4]; 4], - /// Light direction (normalized) + padding (16 bytes) + /// Directional light direction (normalized) + padding (16 bytes) pub light_dir: [f32; 3], pub _padding: f32, + /// Camera position for specular highlights (16 bytes) + pub camera_pos: [f32; 3], + pub _padding_camera: f32, /// Lighting parameters (16 bytes) pub ambient_strength: f32, pub diffuse_strength: f32, + pub specular_strength: f32, + pub specular_power: f32, + /// W-color and fog parameters (16 bytes) pub w_color_strength: f32, pub w_range: f32, + pub fog_density: f32, + pub point_light_count: f32, + /// Fog color (16 bytes) + pub fog_color: [f32; 3], + pub _padding_fog: f32, + /// Up to four point lights (128 bytes) + pub point_lights: [PointLightUniform; 4], } impl Default for RenderUniforms { @@ -163,10 +204,24 @@ impl Default for RenderUniforms { ], light_dir: [0.5, 1.0, 0.3], _padding: 0.0, - ambient_strength: 0.3, - diffuse_strength: 0.7, + camera_pos: [0.0, 0.0, 0.0], + _padding_camera: 0.0, + ambient_strength: 0.28, + diffuse_strength: 0.72, + specular_strength: 0.35, + specular_power: 48.0, w_color_strength: 0.5, w_range: 2.0, + fog_density: 0.018, + point_light_count: 1.0, + fog_color: [0.035, 0.035, 0.055], + _padding_fog: 0.0, + point_lights: [ + PointLightUniform::new([2.5, 3.0, 4.0], 10.0, [1.0, 0.86, 0.65], 0.65), + PointLightUniform::default(), + PointLightUniform::default(), + PointLightUniform::default(), + ], } } } @@ -215,11 +270,18 @@ mod tests { assert_eq!(size_of::(), 96); } + #[test] + fn test_point_light_uniform_size() { + // position+radius vec4 + color+intensity vec4 = 8 floats = 32 bytes + assert_eq!(size_of::(), 32); + } + #[test] fn test_render_uniforms_size() { - // 16 floats view_matrix + 16 floats projection_matrix + 3 floats light_dir + 1 padding - // + 4 floats (ambient, diffuse, w_color, w_range) = 40 floats = 160 bytes - assert_eq!(size_of::(), 160); + // 16 floats view + 16 projection + 4 directional-light slot + + // 4 camera slot + 4 lighting params + 4 w/fog params + 4 fog slot + + // 4 point lights × 8 floats = 84 floats = 336 bytes + assert_eq!(size_of::(), 336); } #[test] @@ -228,6 +290,7 @@ mod tests { assert_eq!(std::mem::align_of::(), 4); assert_eq!(std::mem::align_of::(), 4); assert_eq!(std::mem::align_of::(), 4); + assert_eq!(std::mem::align_of::(), 4); assert_eq!(std::mem::align_of::(), 4); } } diff --git a/crates/rust4d_render/src/shaders/render.wgsl b/crates/rust4d_render/src/shaders/render.wgsl index 5a9fc08..66545dd 100644 --- a/crates/rust4d_render/src/shaders/render.wgsl +++ b/crates/rust4d_render/src/shaders/render.wgsl @@ -1,12 +1,13 @@ // 4D Cross-Section Render Shader // // This shader renders the 3D triangles produced by the slice compute shader. -// It applies view/projection transformation and W-depth based coloring. +// It applies view/projection transformation, W-depth coloring, two-sided +// Blinn-Phong lighting, point lights, and distance fog. // -// Features: -// - W-depth visualization: red (+W) to blue (-W) gradient -// - Basic diffuse lighting -// - Vertex color blending +// Important: slice-generated triangle winding is not stable across all 4D +// tetrahedron cases, so fragment shading must be two-sided. Back-face culling +// is disabled in the Rust render pipeline and normals are flipped toward the +// camera before lighting. // ============================================================================ // Data Structures @@ -29,16 +30,31 @@ struct VertexOutput { @location(3) w_depth: f32, } -/// Render uniforms +/// Point light, layout-matched with Rust `PointLightUniform`. +struct PointLight { + position_radius: vec4, // xyz position, w radius + color_intensity: vec4, // rgb color, w intensity +} + +/// Render uniforms, layout-matched with Rust `RenderUniforms` (336 bytes). struct RenderUniforms { view_matrix: mat4x4, projection_matrix: mat4x4, light_direction: vec3, _pad0: f32, + camera_position: vec3, + _pad_camera: f32, ambient_strength: f32, diffuse_strength: f32, - w_color_strength: f32, // How much W-depth affects color (0-1) - w_range: f32, // Range of W values for normalization + specular_strength: f32, + specular_power: f32, + w_color_strength: f32, + w_range: f32, + fog_density: f32, + point_light_count: f32, + fog_color: vec3, + _pad_fog: f32, + point_lights: array, } // ============================================================================ @@ -55,16 +71,12 @@ struct RenderUniforms { fn vs_main(input: VertexInput) -> VertexOutput { var output: VertexOutput; - // Transform to clip space let world_pos = vec4(input.position, 1.0); let view_pos = uniforms.view_matrix * world_pos; output.clip_position = uniforms.projection_matrix * view_pos; - // Pass through world position and normal for lighting output.world_position = input.position; output.world_normal = input.normal; - - // Pass through color and W-depth output.vertex_color = input.color; output.w_depth = input.w_depth; @@ -75,56 +87,92 @@ fn vs_main(input: VertexInput) -> VertexOutput { // Fragment Shader // ============================================================================ -/// Map W-depth to a color gradient +/// Map W-depth to a color gradient. /// Positive W (towards the 4th dimension) = warm colors (red/orange) -/// Negative W (away from 4th dimension) = cool colors (blue/cyan) +/// Negative W (away from the 4th dimension) = cool colors (blue/cyan) /// Zero W = neutral (based on vertex color) fn w_depth_to_color(w: f32, w_range: f32) -> vec3 { - // Normalize W to [-1, 1] range - let w_normalized = clamp(w / w_range, -1.0, 1.0); - - // Create a gradient from blue (-W) through white (0) to red (+W) - // Using a smooth interpolation for visual appeal - let t = w_normalized * 0.5 + 0.5; // Map to [0, 1] + let w_normalized = clamp(w / max(w_range, 0.0001), -1.0, 1.0); + let t = w_normalized * 0.5 + 0.5; - // Cool (blue) to warm (red) gradient - let cool_color = vec3(0.2, 0.4, 0.9); // Blue - let neutral_color = vec3(0.8, 0.8, 0.8); // Light gray - let warm_color = vec3(0.9, 0.3, 0.2); // Red + let cool_color = vec3(0.2, 0.4, 0.9); + let neutral_color = vec3(0.8, 0.8, 0.8); + let warm_color = vec3(0.9, 0.3, 0.2); - // Two-part interpolation: blue -> neutral -> red if (t < 0.5) { return mix(cool_color, neutral_color, t * 2.0); - } else { - return mix(neutral_color, warm_color, (t - 0.5) * 2.0); } + return mix(neutral_color, warm_color, (t - 0.5) * 2.0); } -@fragment -fn fs_main(input: VertexOutput) -> @location(0) vec4 { - // Normalize the interpolated normal - let normal = normalize(input.world_normal); +fn safe_normalize(v: vec3, fallback: vec3) -> vec3 { + let len = length(v); + if (len < 0.00001) { + return fallback; + } + return v / len; +} - // Normalize light direction - let light_dir = normalize(uniforms.light_direction); +fn blinn_phong( + normal: vec3, + view_dir: vec3, + light_dir: vec3, + light_color: vec3, + light_intensity: f32, +) -> vec3 { + let ndotl = max(dot(normal, light_dir), 0.0); + let half_dir = safe_normalize(light_dir + view_dir, normal); + let spec = pow(max(dot(normal, half_dir), 0.0), max(uniforms.specular_power, 1.0)); + return light_color * light_intensity * ( + ndotl * uniforms.diffuse_strength + spec * uniforms.specular_strength + ); +} - // Calculate diffuse lighting (Lambert) - let n_dot_l = max(dot(normal, light_dir), 0.0); - let diffuse = n_dot_l * uniforms.diffuse_strength; +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let base_normal = safe_normalize(input.world_normal, vec3(0.0, 0.0, 1.0)); + let view_dir = safe_normalize(uniforms.camera_position - input.world_position, vec3(0.0, 0.0, 1.0)); - // Total light contribution - let light = uniforms.ambient_strength + diffuse; + // Two-sided shading: flip normals that face away from the camera. + var normal = base_normal; + if (dot(normal, view_dir) < 0.0) { + normal = -normal; + } - // Get W-depth based color let w_color = w_depth_to_color(input.w_depth, uniforms.w_range); - - // Blend vertex color with W-depth color let base_color = input.vertex_color.rgb; let blended_color = mix(base_color, w_color, uniforms.w_color_strength); - // Apply lighting - let final_color = blended_color * light; + var light = vec3(uniforms.ambient_strength); + + // Directional light. `light_direction` points from the surface toward the light. + let dir_light = safe_normalize(uniforms.light_direction, vec3(0.3, 0.8, 0.4)); + light += blinn_phong(normal, view_dir, dir_light, vec3(1.0, 0.96, 0.88), 1.0); + + // Up to four point lights with smooth quadratic attenuation to zero at radius. + for (var i: u32 = 0u; i < 4u; i = i + 1u) { + if (f32(i) < uniforms.point_light_count) { + let pl = uniforms.point_lights[i]; + let to_light = pl.position_radius.xyz - input.world_position; + let dist = length(to_light); + let radius = max(pl.position_radius.w, 0.0001); + let attenuation = pow(clamp(1.0 - dist / radius, 0.0, 1.0), 2.0); + let light_dir = safe_normalize(to_light, dir_light); + light += blinn_phong( + normal, + view_dir, + light_dir, + pl.color_intensity.rgb, + pl.color_intensity.w * attenuation, + ); + } + } + + var final_color = blended_color * light; + + let distance_to_camera = length(uniforms.camera_position - input.world_position); + let fog_amount = clamp(1.0 - exp(-distance_to_camera * uniforms.fog_density), 0.0, 1.0); + final_color = mix(final_color, uniforms.fog_color, fog_amount); - // Output with original alpha return vec4(final_color, input.vertex_color.a); } diff --git a/examples/01_hello_tesseract.rs b/examples/01_hello_tesseract.rs index 4c87903..89528d4 100644 --- a/examples/01_hello_tesseract.rs +++ b/examples/01_hello_tesseract.rs @@ -148,6 +148,7 @@ impl ApplicationHandler for App { diffuse_strength: 0.7, w_color_strength: 0.5, w_range: 2.0, + ..RenderUniforms::default() }; rp.update_uniforms(&ctx.queue, &render_uniforms); diff --git a/examples/02_multiple_shapes.rs b/examples/02_multiple_shapes.rs index 629047c..0af37fb 100644 --- a/examples/02_multiple_shapes.rs +++ b/examples/02_multiple_shapes.rs @@ -219,6 +219,7 @@ impl ApplicationHandler for App { diffuse_strength: 0.7, w_color_strength: 0.5, w_range: 2.0, + ..RenderUniforms::default() }; rp.update_uniforms(&ctx.queue, &render_uniforms); diff --git a/examples/03_physics_demo.rs b/examples/03_physics_demo.rs index dbcbc30..e71a290 100644 --- a/examples/03_physics_demo.rs +++ b/examples/03_physics_demo.rs @@ -265,6 +265,7 @@ impl ApplicationHandler for App { diffuse_strength: 0.7, w_color_strength: 0.5, w_range: 2.0, + ..RenderUniforms::default() }; rp.update_uniforms(&ctx.queue, &render_uniforms); diff --git a/examples/04_camera_exploration.rs b/examples/04_camera_exploration.rs index 723cefe..5605a1c 100644 --- a/examples/04_camera_exploration.rs +++ b/examples/04_camera_exploration.rs @@ -352,6 +352,7 @@ impl ApplicationHandler for App { diffuse_strength: 0.7, w_color_strength: 0.5, w_range: 2.0, + ..RenderUniforms::default() }; rp.update_uniforms(&ctx.queue, &render_uniforms); diff --git a/examples/headless_protocol.rs b/examples/headless_protocol.rs index 7d16c43..3921637 100644 --- a/examples/headless_protocol.rs +++ b/examples/headless_protocol.rs @@ -180,6 +180,7 @@ impl HeadlessGpu { diffuse_strength: 0.7, w_color_strength: 0.3, w_range: 2.0, + ..RenderUniforms::default() }, ); diff --git a/examples/ron_preview.rs b/examples/ron_preview.rs index 2c4de4d..7f31735 100644 --- a/examples/ron_preview.rs +++ b/examples/ron_preview.rs @@ -507,6 +507,7 @@ impl ApplicationHandler for PreviewApp { diffuse_strength: 0.7, w_color_strength: 0.5, w_range: 2.0, + ..RenderUniforms::default() }; rp.update_uniforms(&ctx.queue, &render_uniforms); diff --git a/examples/shape_showcase.rs b/examples/shape_showcase.rs index 7a03767..06342e2 100644 --- a/examples/shape_showcase.rs +++ b/examples/shape_showcase.rs @@ -177,6 +177,7 @@ impl HeadlessGpu { diffuse_strength: 0.75, w_color_strength: 0.25, w_range: 2.5, + ..RenderUniforms::default() }, ); diff --git a/src/systems/render.rs b/src/systems/render.rs index 0b500df..66f23e8 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -149,6 +149,7 @@ impl RenderSystem { diffuse_strength: self.render_config.diffuse_strength, w_color_strength: self.render_config.w_color_strength, w_range: self.render_config.w_range, + ..RenderUniforms::default() }; self.render_pipeline .update_uniforms(&self.context.queue, &render_uniforms); From ed968b961eb575d3eb5ad0045ceaffc36961009c Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:47:54 +0100 Subject: [PATCH 5/8] style: rustfmt workspace Run cargo fmt --all so CI can enforce formatting consistently across the workspace. Mechanical formatting only. --- crates/rust4d_audio/src/lib.rs | 34 +- crates/rust4d_audio/src/spatial.rs | 9 +- crates/rust4d_core/src/asset_cache.rs | 11 +- crates/rust4d_core/src/asset_error.rs | 2 +- crates/rust4d_core/src/components.rs | 2 +- crates/rust4d_core/src/entity.rs | 34 +- crates/rust4d_core/src/lib.rs | 38 +- crates/rust4d_core/src/scene.rs | 85 ++- crates/rust4d_core/src/scene_loader.rs | 2 +- crates/rust4d_core/src/scene_manager.rs | 62 +- crates/rust4d_core/src/scene_transition.rs | 13 +- crates/rust4d_core/src/scene_validator.rs | 41 +- crates/rust4d_core/src/shapes.rs | 153 +++-- crates/rust4d_core/src/transform.rs | 17 +- crates/rust4d_core/src/world.rs | 211 ++++-- .../rust4d_core/tests/physics_integration.rs | 197 ++++-- .../rust4d_game/src/character_controller.rs | 40 +- crates/rust4d_game/src/events.rs | 29 +- crates/rust4d_game/src/lib.rs | 2 +- crates/rust4d_game/src/scene_helpers.rs | 53 +- crates/rust4d_game/src/tween.rs | 12 +- crates/rust4d_input/src/camera_controller.rs | 72 +- crates/rust4d_input/src/lib.rs | 2 +- crates/rust4d_math/src/hyperplane.rs | 47 +- crates/rust4d_math/src/interpolation.rs | 46 +- crates/rust4d_math/src/lib.rs | 18 +- crates/rust4d_math/src/mat4.rs | 184 ++++-- crates/rust4d_math/src/mesh4d.rs | 46 +- crates/rust4d_math/src/primitives/curved.rs | 139 ++-- crates/rust4d_math/src/primitives/extrude.rs | 13 +- .../rust4d_math/src/primitives/polytopes.rs | 55 +- crates/rust4d_math/src/rotor4.rs | 282 +++++--- crates/rust4d_math/src/tesseract.rs | 75 ++- crates/rust4d_math/src/vec4.rs | 103 ++- .../tests/interpolation_external.rs | 2 +- crates/rust4d_physics/src/body.rs | 117 ++-- crates/rust4d_physics/src/collision.rs | 60 +- crates/rust4d_physics/src/lib.rs | 9 +- crates/rust4d_physics/src/raycast.rs | 54 +- crates/rust4d_physics/src/shapes.rs | 5 +- crates/rust4d_physics/src/world.rs | 620 +++++++++++------- crates/rust4d_physics/tests/edge_cases.rs | 70 +- crates/rust4d_render/src/camera4d.rs | 229 +++++-- .../rust4d_render/src/egui_overlay/context.rs | 108 +-- crates/rust4d_render/src/egui_overlay/mod.rs | 2 +- .../src/egui_overlay/renderer.rs | 4 +- crates/rust4d_render/src/lib.rs | 22 +- crates/rust4d_render/src/particle/emitter.rs | 9 +- crates/rust4d_render/src/particle/mod.rs | 4 +- crates/rust4d_render/src/particle/system.rs | 6 +- crates/rust4d_render/src/particle/types.rs | 6 +- .../src/pipeline/lookup_tables.rs | 39 +- crates/rust4d_render/src/pipeline/mod.rs | 16 +- .../src/pipeline/render_pipeline.rs | 40 +- .../src/pipeline/slice_pipeline.rs | 46 +- crates/rust4d_render/src/renderable.rs | 89 ++- crates/rust4d_render/src/sprite/batch.rs | 18 +- crates/rust4d_render/src/sprite/mod.rs | 4 +- crates/rust4d_render/src/sprite/types.rs | 17 +- crates/rust4d_scripting/src/bindings/audio.rs | 23 +- crates/rust4d_scripting/src/bindings/ecs.rs | 21 +- crates/rust4d_scripting/src/bindings/hud.rs | 91 +-- crates/rust4d_scripting/src/bindings/input.rs | 175 ++++- crates/rust4d_scripting/src/bindings/math.rs | 10 +- .../rust4d_scripting/src/bindings/physics.rs | 55 +- crates/rust4d_scripting/src/error.rs | 9 +- crates/rust4d_scripting/src/lib.rs | 21 +- crates/rust4d_scripting/src/lifecycle.rs | 11 +- crates/rust4d_scripting/src/loader.rs | 2 +- crates/rust4d_scripting/src/vm.rs | 36 +- .../rust4d_scripting/tests/sandbox_escape.rs | 17 +- examples/01_hello_tesseract.rs | 46 +- examples/02_multiple_shapes.rs | 90 ++- examples/03_physics_demo.rs | 122 ++-- examples/04_camera_exploration.rs | 105 ++- examples/05_audio_demo.rs | 5 +- examples/headless_protocol.rs | 15 +- examples/ron_preview.rs | 18 +- examples/shape_showcase.rs | 53 +- src/config.rs | 7 +- src/input/input_mapper.rs | 3 +- src/input/mod.rs | 2 +- src/main.rs | 92 ++- src/systems/render.rs | 29 +- src/systems/simulation.rs | 62 +- src/systems/window.rs | 10 +- tests/gallery_scene.rs | 18 +- tests/game_integration.rs | 30 +- tests/slice_invariant.rs | 8 +- 89 files changed, 3249 insertions(+), 1662 deletions(-) diff --git a/crates/rust4d_audio/src/lib.rs b/crates/rust4d_audio/src/lib.rs index 0e913c1..c27612a 100644 --- a/crates/rust4d_audio/src/lib.rs +++ b/crates/rust4d_audio/src/lib.rs @@ -91,7 +91,9 @@ impl AudioEngine4D { /// Create a new audio engine pub fn new() -> Result { let manager = AudioManager::::new(AudioManagerSettings::default()) - .map_err(|e: kira::manager::backend::cpal::Error| AudioError::ManagerInit(e.to_string()))?; + .map_err(|e: kira::manager::backend::cpal::Error| { + AudioError::ManagerInit(e.to_string()) + })?; let mut engine = Self { manager, @@ -151,11 +153,10 @@ impl AudioEngine4D { /// Returns `AudioError::SoundIdOverflow` if the maximum number of sounds /// (2^64) has been reached. In practice this is unreachable. pub fn load_sound(&mut self, path: &str) -> Result { - let sound_data = StaticSoundData::from_file(path) - .map_err(|e| AudioError::LoadSound { - path: path.to_string(), - message: e.to_string(), - })?; + let sound_data = StaticSoundData::from_file(path).map_err(|e| AudioError::LoadSound { + path: path.to_string(), + message: e.to_string(), + })?; let id = self.next_sound_id; self.next_sound_id = self @@ -183,15 +184,13 @@ impl AudioEngine4D { let settings = StaticSoundSettings::new().output_destination(track); let sound_with_settings = sound_data.with_settings(settings); - let handle = self.manager + let handle = self + .manager .play(sound_with_settings) .map_err(|e| AudioError::PlaySound(e.to_string()))?; // Track the sound handle for stop_all/stop_bus support - self.active_sounds - .entry(bus) - .or_default() - .push(handle); + self.active_sounds.entry(bus).or_default().push(handle); Ok(()) } @@ -232,15 +231,13 @@ impl AudioEngine4D { let sound_with_settings = sound_data.with_settings(settings); - let handle = self.manager + let handle = self + .manager .play(sound_with_settings) .map_err(|e| AudioError::PlaySound(e.to_string()))?; // Track the sound handle for stop_all/stop_bus support - self.active_sounds - .entry(bus) - .or_default() - .push(handle); + self.active_sounds.entry(bus).or_default().push(handle); log::trace!( "Playing spatial sound at {:?}, volume: {:.2}, panning: {:.2}", @@ -266,7 +263,10 @@ impl AudioEngine4D { /// Set the volume of a specific bus pub fn set_bus_volume(&mut self, bus: AudioBus, volume: f32) { if let Some(track) = self.bus_tracks.get_mut(&bus) { - track.set_volume(Volume::Amplitude(volume.clamp(0.0, 1.0) as f64), Tween::default()); + track.set_volume( + Volume::Amplitude(volume.clamp(0.0, 1.0) as f64), + Tween::default(), + ); log::debug!("Set {:?} bus volume to {:.2}", bus, volume); } } diff --git a/crates/rust4d_audio/src/spatial.rs b/crates/rust4d_audio/src/spatial.rs index 84b8c6b..fdc957e 100644 --- a/crates/rust4d_audio/src/spatial.rs +++ b/crates/rust4d_audio/src/spatial.rs @@ -135,8 +135,7 @@ mod tests { #[test] fn test_attenuation_within_min_distance() { let listener = Vec4::ZERO; - let config = SpatialConfig::new(Vec4::new(0.5, 0.0, 0.0, 0.0)) - .with_min_distance(1.0); + let config = SpatialConfig::new(Vec4::new(0.5, 0.0, 0.0, 0.0)).with_min_distance(1.0); let attenuation = calculate_attenuation(listener, &config); assert!((attenuation - 1.0).abs() < 0.0001); } @@ -144,8 +143,7 @@ mod tests { #[test] fn test_attenuation_at_max_distance() { let listener = Vec4::ZERO; - let config = SpatialConfig::new(Vec4::new(50.0, 0.0, 0.0, 0.0)) - .with_max_distance(50.0); + let config = SpatialConfig::new(Vec4::new(50.0, 0.0, 0.0, 0.0)).with_max_distance(50.0); let attenuation = calculate_attenuation(listener, &config); assert!(attenuation < 0.0001); } @@ -153,8 +151,7 @@ mod tests { #[test] fn test_attenuation_beyond_max_distance() { let listener = Vec4::ZERO; - let config = SpatialConfig::new(Vec4::new(100.0, 0.0, 0.0, 0.0)) - .with_max_distance(50.0); + let config = SpatialConfig::new(Vec4::new(100.0, 0.0, 0.0, 0.0)).with_max_distance(50.0); let attenuation = calculate_attenuation(listener, &config); assert_eq!(attenuation, 0.0); } diff --git a/crates/rust4d_core/src/asset_cache.rs b/crates/rust4d_core/src/asset_cache.rs index 4cfa5f3..acb5ee3 100644 --- a/crates/rust4d_core/src/asset_cache.rs +++ b/crates/rust4d_core/src/asset_cache.rs @@ -151,10 +151,7 @@ impl AssetCache { // Check if already cached (deduplication by path) if let Some(&id) = self.path_index.get(&path) { - return Ok(AssetHandle { - id, - path, - }); + return Ok(AssetHandle { id, path }); } // Load from file @@ -280,11 +277,7 @@ impl AssetCache { log::info!("Hot-reloaded asset: {}", path.display()); } Err(err) => { - log::warn!( - "Failed to hot-reload asset {}: {}", - path.display(), - err - ); + log::warn!("Failed to hot-reload asset {}: {}", path.display(), err); } } } diff --git a/crates/rust4d_core/src/asset_error.rs b/crates/rust4d_core/src/asset_error.rs index 81923ba..976807c 100644 --- a/crates/rust4d_core/src/asset_error.rs +++ b/crates/rust4d_core/src/asset_error.rs @@ -2,8 +2,8 @@ //! //! Provides error handling for asset loading, caching, and hot-reload operations. -use std::io; use std::fmt; +use std::io; /// Error type for asset operations #[derive(Debug)] diff --git a/crates/rust4d_core/src/components.rs b/crates/rust4d_core/src/components.rs index f95f1b8..9f6141e 100644 --- a/crates/rust4d_core/src/components.rs +++ b/crates/rust4d_core/src/components.rs @@ -6,8 +6,8 @@ //! Existing types like `Transform4D`, `Material`, `ShapeRef`, and `DirtyFlags` //! are also used as ECS components directly (they satisfy Send + Sync + 'static). -use std::collections::HashSet; use rust4d_physics::BodyKey; +use std::collections::HashSet; // === Name Component === diff --git a/crates/rust4d_core/src/entity.rs b/crates/rust4d_core/src/entity.rs index 8a38086..93843e1 100644 --- a/crates/rust4d_core/src/entity.rs +++ b/crates/rust4d_core/src/entity.rs @@ -4,13 +4,13 @@ //! The Entity struct has been removed -- entities are now spawned directly as //! ECS component tuples via `world.spawn(...)` or `EntityTemplate::spawn_in(...)`. -use std::collections::HashSet; -use std::sync::Arc; +use crate::shapes::ShapeTemplate; +use crate::Transform4D; use bitflags::bitflags; use rust4d_math::ConvexShape4D; -use serde::{Serialize, Deserialize}; -use crate::Transform4D; -use crate::shapes::ShapeTemplate; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::sync::Arc; bitflags! { /// Flags indicating which parts of an entity have changed and need updating @@ -63,19 +63,29 @@ impl Material { } /// White material - pub const WHITE: Self = Self { base_color: [1.0, 1.0, 1.0, 1.0] }; + pub const WHITE: Self = Self { + base_color: [1.0, 1.0, 1.0, 1.0], + }; /// Gray material - pub const GRAY: Self = Self { base_color: [0.5, 0.5, 0.5, 1.0] }; + pub const GRAY: Self = Self { + base_color: [0.5, 0.5, 0.5, 1.0], + }; /// Red material - pub const RED: Self = Self { base_color: [1.0, 0.0, 0.0, 1.0] }; + pub const RED: Self = Self { + base_color: [1.0, 0.0, 0.0, 1.0], + }; /// Green material - pub const GREEN: Self = Self { base_color: [0.0, 1.0, 0.0, 1.0] }; + pub const GREEN: Self = Self { + base_color: [0.0, 1.0, 0.0, 1.0], + }; /// Blue material - pub const BLUE: Self = Self { base_color: [0.0, 0.0, 1.0, 1.0] }; + pub const BLUE: Self = Self { + base_color: [0.0, 0.0, 1.0, 1.0], + }; } /// Reference to a shape - either shared (Arc) or owned (Box) @@ -259,7 +269,9 @@ mod tests { ShapeTemplate::tesseract(2.0), Transform4D::from_position(Vec4::new(1.0, 2.0, 3.0, 4.0)), Material::RED, - ).with_name("my_cube").with_tag("dynamic"); + ) + .with_name("my_cube") + .with_tag("dynamic"); let mut world = crate::World::new(); let entity = template.spawn_in(&mut world); diff --git a/crates/rust4d_core/src/lib.rs b/crates/rust4d_core/src/lib.rs index 2ed7d97..b27e47b 100644 --- a/crates/rust4d_core/src/lib.rs +++ b/crates/rust4d_core/src/lib.rs @@ -11,38 +11,38 @@ //! - [`EntityTemplate`] - Serializable entity template //! - [`Scene`] - Loadable/saveable scene containing entities -mod transform; -mod entity; -mod world; +mod asset_cache; +mod asset_error; mod components; -mod shapes; +mod entity; mod scene; +mod scene_loader; mod scene_manager; -mod asset_error; -mod asset_cache; mod scene_transition; -mod scene_loader; mod scene_validator; +mod shapes; +mod transform; +mod world; -pub use transform::Transform4D; -pub use entity::{Material, ShapeRef, DirtyFlags, EntityTemplate}; -pub use world::{World, HierarchyError}; -pub use components::{Name, Tags, PhysicsBody, Parent, Children}; -pub use shapes::{ColliderHint, ShapeTemplate}; -pub use scene::{Scene, SceneLoadError, SceneSaveError, SceneError, ActiveScene}; -pub use scene_manager::SceneManager; +pub use asset_cache::{Asset, AssetCache, AssetHandle, AssetId}; pub use asset_error::AssetError; -pub use asset_cache::{AssetId, AssetHandle, Asset, AssetCache}; -pub use scene_transition::{SceneTransition, TransitionEffect, SlideDirection}; -pub use scene_loader::{SceneLoader, LoadResult}; +pub use components::{Children, Name, Parent, PhysicsBody, Tags}; +pub use entity::{DirtyFlags, EntityTemplate, Material, ShapeRef}; +pub use scene::{ActiveScene, Scene, SceneError, SceneLoadError, SceneSaveError}; +pub use scene_loader::{LoadResult, SceneLoader}; +pub use scene_manager::SceneManager; +pub use scene_transition::{SceneTransition, SlideDirection, TransitionEffect}; pub use scene_validator::{SceneValidator, ValidationError}; +pub use shapes::{ColliderHint, ShapeTemplate}; +pub use transform::Transform4D; +pub use world::{HierarchyError, World}; /// Type alias for entity handles (hecs::Entity) pub type EntityId = hecs::Entity; // Re-export commonly used types from rust4d_math for convenience -pub use rust4d_math::{Vec4, Rotor4, RotationPlane, ConvexShape4D, Tetrahedron}; -pub use rust4d_math::{Tesseract4D, Hyperplane4D}; +pub use rust4d_math::{ConvexShape4D, RotationPlane, Rotor4, Tetrahedron, Vec4}; +pub use rust4d_math::{Hyperplane4D, Tesseract4D}; // Re-export physics types for convenient access through rust4d_core pub use rust4d_physics::{BodyKey, PhysicsConfig, PhysicsWorld, RigidBody4D, StaticCollider}; diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 5a7958d..c85417e 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -3,17 +3,17 @@ //! Provides Scene struct for loading/saving scenes from RON files. //! Scenes contain entity templates, physics settings, and player spawn info. -use serde::{Serialize, Deserialize}; -use std::path::Path; +use serde::{Deserialize, Serialize}; use std::fs; use std::io; +use std::path::Path; +use crate::components::PhysicsBody; use crate::entity::EntityTemplate; use crate::shapes::ShapeTemplate; -use crate::components::PhysicsBody; use crate::World; use rust4d_math::Vec4; -use rust4d_physics::{PhysicsConfig, RigidBody4D, StaticCollider, BodyType, PhysicsMaterial}; +use rust4d_physics::{BodyType, PhysicsConfig, PhysicsMaterial, RigidBody4D, StaticCollider}; /// A serializable scene containing entity templates /// @@ -48,8 +48,12 @@ impl Scene { pub fn load>(path: P) -> Result { let contents = fs::read_to_string(path.as_ref())?; let scene: Scene = ron::from_str(&contents)?; - log::debug!("Loaded scene '{}' with gravity={:?}, player_spawn={:?}", - scene.name, scene.gravity, scene.player_spawn); + log::debug!( + "Loaded scene '{}' with gravity={:?}, player_spawn={:?}", + scene.name, + scene.gravity, + scene.player_spawn + ); Ok(scene) } @@ -229,11 +233,18 @@ impl ActiveScene { /// Player body creation is NOT handled here -- use /// `rust4d_game::scene_helpers::create_player_body()` at the application layer. pub fn from_template(template: &Scene, physics_config: Option) -> Self { - log::debug!("from_template: physics_config={:?}, template.gravity={:?}", physics_config, template.gravity); + log::debug!( + "from_template: physics_config={:?}, template.gravity={:?}", + physics_config, + template.gravity + ); // Create world with physics let mut world = if let Some(config) = physics_config { - log::debug!("Using provided physics_config with gravity={}", config.gravity); + log::debug!( + "Using provided physics_config with gravity={}", + config.gravity + ); World::new().with_physics(config) } else if let Some(gravity) = template.gravity { log::debug!("Using template gravity={}", gravity); @@ -254,7 +265,14 @@ impl ActiveScene { if let Some(physics) = world.physics_mut() { if is_static { // Create bounded static collider for floor/walls (objects can fall off edges) - if let ShapeTemplate::Hyperplane { y, size, cell_size, thickness, .. } = &entity_template.shape { + if let ShapeTemplate::Hyperplane { + y, + size, + cell_size, + thickness, + .. + } = &entity_template.shape + { log::debug!("Adding bounded floor collider: y={}, size={}, cell_size={}, thickness={}", y, size, cell_size, thickness); physics.add_static_collider(StaticCollider::floor_bounded( @@ -295,7 +313,9 @@ impl ActiveScene { let entity_handle = entity_template.spawn_in(&mut world); if let Some(bk) = body_key { - let _ = world.ecs_mut_unchecked().insert_one(entity_handle, PhysicsBody(bk)); + let _ = world + .ecs_mut_unchecked() + .insert_one(entity_handle, PhysicsBody(bk)); } } @@ -342,8 +362,8 @@ impl ActiveScene { #[cfg(test)] mod tests { use super::*; - use crate::{Transform4D, Material}; use crate::shapes::ShapeTemplate; + use crate::{Material, Transform4D}; use rust4d_math::Vec4; #[test] @@ -389,7 +409,9 @@ mod tests { ShapeTemplate::tesseract(2.0), Transform4D::from_position(Vec4::new(1.0, 0.0, 0.0, 0.0)), Material::RED, - ).with_name("test_cube").with_tag("dynamic"); + ) + .with_name("test_cube") + .with_tag("dynamic"); scene.add_entity(entity); @@ -464,7 +486,13 @@ Scene( assert_eq!(scene.entities[0].name, Some("floor".to_string())); assert_eq!(scene.entities[0].tags, vec!["static"]); match &scene.entities[0].shape { - ShapeTemplate::Hyperplane { y, size, subdivisions, cell_size, thickness } => { + ShapeTemplate::Hyperplane { + y, + size, + subdivisions, + cell_size, + thickness, + } => { assert_eq!(*y, -2.0); assert_eq!(*size, 10.0); assert_eq!(*subdivisions, 10); @@ -491,7 +519,9 @@ Scene( ShapeTemplate::tesseract(2.0), Transform4D::from_position(Vec4::new(1.0, 2.0, 3.0, 4.0)), Material::RED, - ).with_name("my_cube").with_tag("dynamic"); + ) + .with_name("my_cube") + .with_tag("dynamic"); let mut world = crate::World::new(); let entity = template.spawn_in(&mut world); @@ -556,16 +586,14 @@ Scene( #[test] fn test_active_scene_with_physics() { - let scene = ActiveScene::new("Physics Scene") - .with_physics(PhysicsConfig::new(-20.0)); + let scene = ActiveScene::new("Physics Scene").with_physics(PhysicsConfig::new(-20.0)); assert!(scene.world.physics().is_some()); assert_eq!(scene.world.physics().unwrap().config.gravity, -20.0); } #[test] fn test_active_scene_with_player_spawn() { - let scene = ActiveScene::new("Spawn Scene") - .with_player_spawn([1.0, 2.0, 3.0, 4.0]); + let scene = ActiveScene::new("Spawn Scene").with_player_spawn([1.0, 2.0, 3.0, 4.0]); assert_eq!(scene.player_spawn, Some([1.0, 2.0, 3.0, 4.0])); } @@ -576,11 +604,14 @@ Scene( .with_gravity(-15.0) .with_player_spawn(0.0, 1.0, 5.0, 0.0); - template.add_entity(EntityTemplate::new( - ShapeTemplate::tesseract(2.0), - Transform4D::from_position(Vec4::new(1.0, 0.0, 0.0, 0.0)), - Material::RED, - ).with_name("cube")); + template.add_entity( + EntityTemplate::new( + ShapeTemplate::tesseract(2.0), + Transform4D::from_position(Vec4::new(1.0, 0.0, 0.0, 0.0)), + Material::RED, + ) + .with_name("cube"), + ); // Instantiate from template let active = ActiveScene::from_template(&template, None); @@ -604,10 +635,7 @@ Scene( let template = Scene::new("Template").with_gravity(-10.0); // Override physics config - let active = ActiveScene::from_template( - &template, - Some(PhysicsConfig::new(-30.0)), - ); + let active = ActiveScene::from_template(&template, Some(PhysicsConfig::new(-30.0))); // Should use overridden config, not template gravity assert_eq!(active.world.physics().unwrap().config.gravity, -30.0); @@ -615,8 +643,7 @@ Scene( #[test] fn test_active_scene_update() { - let mut scene = ActiveScene::new("Update Test") - .with_physics(PhysicsConfig::new(-20.0)); + let mut scene = ActiveScene::new("Update Test").with_physics(PhysicsConfig::new(-20.0)); // Just verify update doesn't panic scene.update(0.016); diff --git a/crates/rust4d_core/src/scene_loader.rs b/crates/rust4d_core/src/scene_loader.rs index a2fed63..f1719f3 100644 --- a/crates/rust4d_core/src/scene_loader.rs +++ b/crates/rust4d_core/src/scene_loader.rs @@ -5,7 +5,7 @@ //! and returns results via a channel, enabling non-blocking scene loading. use std::path::PathBuf; -use std::sync::mpsc::{channel, Sender, Receiver, TryRecvError}; +use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError}; use std::thread; use crate::scene::{Scene, SceneError}; diff --git a/crates/rust4d_core/src/scene_manager.rs b/crates/rust4d_core/src/scene_manager.rs index d858756..bacaf74 100644 --- a/crates/rust4d_core/src/scene_manager.rs +++ b/crates/rust4d_core/src/scene_manager.rs @@ -21,12 +21,12 @@ //! manager.update(dt); //! ``` -use std::collections::HashMap; -use crate::{Scene, World}; -use crate::scene::{SceneError, ActiveScene}; -use crate::scene_transition::{SceneTransition, TransitionEffect}; +use crate::scene::{ActiveScene, SceneError}; use crate::scene_loader::SceneLoader; +use crate::scene_transition::{SceneTransition, TransitionEffect}; +use crate::{Scene, World}; use rust4d_physics::PhysicsConfig; +use std::collections::HashMap; /// Manages multiple scenes with a stack for overlays /// @@ -114,7 +114,9 @@ impl SceneManager { /// The instantiated scene is stored but not automatically made active. /// Use `push_scene` to make it the current scene. pub fn instantiate(&mut self, template_name: &str) -> Result<(), SceneError> { - let template = self.templates.get(template_name) + let template = self + .templates + .get(template_name) .ok_or_else(|| SceneError::NotLoaded(template_name.to_string()))?; let active = ActiveScene::from_template(template, self.default_physics.clone()); @@ -161,7 +163,8 @@ impl SceneManager { /// Get a reference to the currently active scene (top of stack) pub fn active_scene(&self) -> Option<&ActiveScene> { - self.active_stack.last() + self.active_stack + .last() .and_then(|name| self.scenes.get(name)) } @@ -236,15 +239,9 @@ impl SceneManager { return Err(SceneError::NotLoaded(name.to_string())); } - let from = self.active_scene_name() - .unwrap_or("") - .to_string(); + let from = self.active_scene_name().unwrap_or("").to_string(); - self.transition = Some(SceneTransition::new( - from, - name.to_string(), - effect, - )); + self.transition = Some(SceneTransition::new(from, name.to_string(), effect)); Ok(()) } @@ -346,7 +343,7 @@ impl SceneManager { #[cfg(test)] mod tests { use super::*; - use crate::{ShapeRef, Material, DirtyFlags, Transform4D, Name}; + use crate::{DirtyFlags, Material, Name, ShapeRef, Transform4D}; use rust4d_math::Tesseract4D; fn spawn_test_entity(world: &mut World) -> hecs::Entity { @@ -368,8 +365,7 @@ mod tests { #[test] fn test_with_physics() { - let manager = SceneManager::new() - .with_physics(PhysicsConfig::new(-20.0)); + let manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); assert!(manager.default_physics.is_some()); assert_eq!(manager.default_physics.unwrap().gravity, -20.0); } @@ -551,12 +547,10 @@ mod tests { #[test] fn test_update() { - let mut manager = SceneManager::new() - .with_physics(PhysicsConfig::new(-20.0)); + let mut manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); // Create scene with physics - let scene = ActiveScene::new("Test") - .with_physics(PhysicsConfig::new(-20.0)); + let scene = ActiveScene::new("Test").with_physics(PhysicsConfig::new(-20.0)); manager.register_active_scene("test", scene); manager.push_scene("test").unwrap(); @@ -569,8 +563,7 @@ mod tests { let mut manager = SceneManager::new(); // Create and register a template - let template = Scene::new("My Template") - .with_gravity(-15.0); + let template = Scene::new("My Template").with_gravity(-15.0); manager.register_template(template); // Should be able to retrieve it @@ -636,10 +629,7 @@ mod tests { fn test_switch_with_transition_not_loaded() { let mut manager = SceneManager::new(); - let result = manager.switch_to_with_transition( - "nonexistent", - TransitionEffect::Instant, - ); + let result = manager.switch_to_with_transition("nonexistent", TransitionEffect::Instant); assert!(result.is_err()); match result { Err(SceneError::NotLoaded(name)) => assert_eq!(name, "nonexistent"), @@ -657,7 +647,9 @@ mod tests { manager.push_scene("scene1").unwrap(); // Instant transition should complete in one update - manager.switch_to_with_transition("scene2", TransitionEffect::Instant).unwrap(); + manager + .switch_to_with_transition("scene2", TransitionEffect::Instant) + .unwrap(); let completed = manager.update_transition(); assert!(completed); assert!(!manager.is_transitioning()); @@ -676,12 +668,14 @@ mod tests { assert!(!manager.is_transitioning()); // Start transition - manager.switch_to_with_transition( - "test", - TransitionEffect::Fade { - duration: std::time::Duration::from_secs(10), - }, - ).unwrap(); + manager + .switch_to_with_transition( + "test", + TransitionEffect::Fade { + duration: std::time::Duration::from_secs(10), + }, + ) + .unwrap(); assert!(manager.is_transitioning()); } diff --git a/crates/rust4d_core/src/scene_transition.rs b/crates/rust4d_core/src/scene_transition.rs index a6d92f6..ac34e91 100644 --- a/crates/rust4d_core/src/scene_transition.rs +++ b/crates/rust4d_core/src/scene_transition.rs @@ -171,11 +171,8 @@ mod tests { #[test] fn test_instant_alpha_is_one() { - let transition = SceneTransition::new( - "a".to_string(), - "b".to_string(), - TransitionEffect::Instant, - ); + let transition = + SceneTransition::new("a".to_string(), "b".to_string(), TransitionEffect::Instant); assert_eq!(transition.alpha(), 1.0); } @@ -283,7 +280,11 @@ mod tests { // Right after creation, progress should be near 0 let initial_progress = transition.progress(); - assert!(initial_progress <= 0.1, "Initial progress too high: {}", initial_progress); + assert!( + initial_progress <= 0.1, + "Initial progress too high: {}", + initial_progress + ); // After updating with some time passed, progress should have increased std::thread::sleep(Duration::from_millis(50)); diff --git a/crates/rust4d_core/src/scene_validator.rs b/crates/rust4d_core/src/scene_validator.rs index 1d80974..ff22158 100644 --- a/crates/rust4d_core/src/scene_validator.rs +++ b/crates/rust4d_core/src/scene_validator.rs @@ -127,7 +127,7 @@ mod tests { use super::*; use crate::entity::EntityTemplate; use crate::shapes::ShapeTemplate; - use crate::{Transform4D, Material}; + use crate::{Material, Transform4D}; fn make_valid_scene() -> Scene { let mut scene = Scene::new("Valid Scene") @@ -220,7 +220,9 @@ mod tests { let errors = SceneValidator::validate(&scene); assert!( - !errors.iter().any(|e| matches!(e, ValidationError::UnreasonableGravity(_))), + !errors + .iter() + .any(|e| matches!(e, ValidationError::UnreasonableGravity(_))), "Did not expect gravity error, got: {:?}", errors ); @@ -228,8 +230,7 @@ mod tests { #[test] fn test_extreme_spawn_position_detected() { - let mut scene = Scene::new("Far Away") - .with_player_spawn(99999.0, 0.0, 0.0, 0.0); + let mut scene = Scene::new("Far Away").with_player_spawn(99999.0, 0.0, 0.0, 0.0); scene.add_entity(EntityTemplate::new( ShapeTemplate::tesseract(1.0), Transform4D::identity(), @@ -238,7 +239,9 @@ mod tests { let errors = SceneValidator::validate(&scene); assert!( - errors.contains(&ValidationError::ExtremeSpawnPosition([99999.0, 0.0, 0.0, 0.0])), + errors.contains(&ValidationError::ExtremeSpawnPosition([ + 99999.0, 0.0, 0.0, 0.0 + ])), "Expected ExtremeSpawnPosition, got: {:?}", errors ); @@ -249,7 +252,9 @@ mod tests { let scene = make_valid_scene(); let errors = SceneValidator::validate(&scene); assert!( - !errors.iter().any(|e| matches!(e, ValidationError::ExtremeSpawnPosition(_))), + !errors + .iter() + .any(|e| matches!(e, ValidationError::ExtremeSpawnPosition(_))), "Did not expect spawn error, got: {:?}", errors ); @@ -272,10 +277,17 @@ mod tests { .with_player_spawn(50000.0, 0.0, 0.0, 0.0); let errors = SceneValidator::validate(&scene); - assert!(errors.len() >= 3, "Expected at least 3 errors, got {}: {:?}", errors.len(), errors); + assert!( + errors.len() >= 3, + "Expected at least 3 errors, got {}: {:?}", + errors.len(), + errors + ); assert!(errors.contains(&ValidationError::EmptyScene)); assert!(errors.contains(&ValidationError::UnreasonableGravity(-9999.0))); - assert!(errors.contains(&ValidationError::ExtremeSpawnPosition([50000.0, 0.0, 0.0, 0.0]))); + assert!(errors.contains(&ValidationError::ExtremeSpawnPosition([ + 50000.0, 0.0, 0.0, 0.0 + ]))); } #[test] @@ -295,7 +307,9 @@ mod tests { let errors = SceneValidator::validate(&scene); assert!( - !errors.iter().any(|e| matches!(e, ValidationError::DuplicateName(_))), + !errors + .iter() + .any(|e| matches!(e, ValidationError::DuplicateName(_))), "Unnamed entities should not trigger duplicate name: {:?}", errors ); @@ -316,9 +330,10 @@ mod tests { "Entity 'bar' has no shape" ); assert!(format!("{}", ValidationError::UnreasonableGravity(-5000.0)).contains("-5000")); - assert!( - format!("{}", ValidationError::ExtremeSpawnPosition([1.0, 2.0, 3.0, 4.0])) - .contains("1, 2, 3, 4") - ); + assert!(format!( + "{}", + ValidationError::ExtremeSpawnPosition([1.0, 2.0, 3.0, 4.0]) + ) + .contains("1, 2, 3, 4")); } } diff --git a/crates/rust4d_core/src/shapes.rs b/crates/rust4d_core/src/shapes.rs index 6803d60..1e9df0d 100644 --- a/crates/rust4d_core/src/shapes.rs +++ b/crates/rust4d_core/src/shapes.rs @@ -7,8 +7,8 @@ //! All shapes are created in **local space** (centered at origin or with bottom at y=0). //! The entity transform is used to position them in world space. -use serde::{Serialize, Deserialize}; -use rust4d_math::{Tesseract4D, Hyperplane4D, ConvexShape4D, primitives}; +use rust4d_math::{primitives, ConvexShape4D, Hyperplane4D, Tesseract4D}; +use serde::{Deserialize, Serialize}; /// Serializable shape template /// @@ -121,17 +121,27 @@ impl ShapeTemplate { /// Shapes are created in local space. The entity transform positions them in world space. pub fn create_shape(&self) -> Box { match self { - ShapeTemplate::Tesseract { size } => { - Box::new(Tesseract4D::new(*size)) - } - ShapeTemplate::Hyperplane { size, subdivisions, cell_size, thickness, .. } => { + ShapeTemplate::Tesseract { size } => Box::new(Tesseract4D::new(*size)), + ShapeTemplate::Hyperplane { + size, + subdivisions, + cell_size, + thickness, + .. + } => { // Note: `y` is not passed to the shape constructor - it's used for physics only. // The visual mesh is created at y=0 (local space) and positioned by entity transform. - Box::new(Hyperplane4D::new(*size, *subdivisions as usize, *cell_size, *thickness)) - } - ShapeTemplate::Hypersphere { radius, subdivisions } => { - Box::new(primitives::hypersphere(*radius, *subdivisions)) + Box::new(Hyperplane4D::new( + *size, + *subdivisions as usize, + *cell_size, + *thickness, + )) } + ShapeTemplate::Hypersphere { + radius, + subdivisions, + } => Box::new(primitives::hypersphere(*radius, *subdivisions)), ShapeTemplate::Pentachoron { circumradius } => { Box::new(primitives::pentachoron(*circumradius)) } @@ -144,15 +154,23 @@ impl ShapeTemplate { ShapeTemplate::Hexacosichoron { circumradius } => { Box::new(primitives::hexacosichoron(*circumradius)) } - ShapeTemplate::Spherinder { radius, half_height, subdivisions } => { - Box::new(primitives::spherinder(*radius, *half_height, *subdivisions)) - } - ShapeTemplate::Cubinder { radius, half_size, segments } => { - Box::new(primitives::cubinder(*radius, *half_size, *segments)) - } - ShapeTemplate::Duocylinder { radius_xy, radius_zw, segments } => { - Box::new(primitives::duocylinder(*radius_xy, *radius_zw, *segments, *segments)) - } + ShapeTemplate::Spherinder { + radius, + half_height, + subdivisions, + } => Box::new(primitives::spherinder(*radius, *half_height, *subdivisions)), + ShapeTemplate::Cubinder { + radius, + half_size, + segments, + } => Box::new(primitives::cubinder(*radius, *half_size, *segments)), + ShapeTemplate::Duocylinder { + radius_xy, + radius_zw, + segments, + } => Box::new(primitives::duocylinder( + *radius_xy, *radius_zw, *segments, *segments, + )), } } @@ -163,23 +181,30 @@ impl ShapeTemplate { pub fn bounding_radius(&self) -> f32 { match self { ShapeTemplate::Tesseract { size } => size * 0.5 * 2.0, // half-diagonal = (s/2)·√4 - ShapeTemplate::Hyperplane { size, cell_size, thickness, .. } => { - (2.0 * size * size + cell_size * cell_size + thickness * thickness).sqrt() - } + ShapeTemplate::Hyperplane { + size, + cell_size, + thickness, + .. + } => (2.0 * size * size + cell_size * cell_size + thickness * thickness).sqrt(), ShapeTemplate::Hypersphere { radius, .. } => *radius, ShapeTemplate::Pentachoron { circumradius } | ShapeTemplate::Hexadecachoron { circumradius } | ShapeTemplate::Icositetrachoron { circumradius } | ShapeTemplate::Hexacosichoron { circumradius } => *circumradius, - ShapeTemplate::Spherinder { radius, half_height, .. } => { - (radius * radius + half_height * half_height).sqrt() - } - ShapeTemplate::Cubinder { radius, half_size, .. } => { - (radius * radius + 2.0 * half_size * half_size).sqrt() - } - ShapeTemplate::Duocylinder { radius_xy, radius_zw, .. } => { - (radius_xy * radius_xy + radius_zw * radius_zw).sqrt() - } + ShapeTemplate::Spherinder { + radius, + half_height, + .. + } => (radius * radius + half_height * half_height).sqrt(), + ShapeTemplate::Cubinder { + radius, half_size, .. + } => (radius * radius + 2.0 * half_size * half_size).sqrt(), + ShapeTemplate::Duocylinder { + radius_xy, + radius_zw, + .. + } => (radius_xy * radius_xy + radius_zw * radius_zw).sqrt(), } } @@ -191,11 +216,15 @@ impl ShapeTemplate { pub fn collider_hint(&self) -> ColliderHint { match self { ShapeTemplate::Hypersphere { radius, .. } => ColliderHint::Sphere { radius: *radius }, - ShapeTemplate::Hexacosichoron { circumradius } => { - ColliderHint::Sphere { radius: *circumradius } - } - ShapeTemplate::Tesseract { size } => ColliderHint::Aabb { half_extent: size * 0.5 }, - other => ColliderHint::Aabb { half_extent: other.bounding_radius() * std::f32::consts::FRAC_1_SQRT_2 }, + ShapeTemplate::Hexacosichoron { circumradius } => ColliderHint::Sphere { + radius: *circumradius, + }, + ShapeTemplate::Tesseract { size } => ColliderHint::Aabb { + half_extent: size * 0.5, + }, + other => ColliderHint::Aabb { + half_extent: other.bounding_radius() * std::f32::consts::FRAC_1_SQRT_2, + }, } } @@ -209,28 +238,55 @@ impl ShapeTemplate { /// The `y` parameter specifies the Y-level for the physics collider. /// The visual mesh is created in local space (y=0) and should be positioned /// using the entity transform. - pub fn hyperplane(y: f32, size: f32, subdivisions: u32, cell_size: f32, thickness: f32) -> Self { - ShapeTemplate::Hyperplane { y, size, subdivisions, cell_size, thickness } + pub fn hyperplane( + y: f32, + size: f32, + subdivisions: u32, + cell_size: f32, + thickness: f32, + ) -> Self { + ShapeTemplate::Hyperplane { + y, + size, + subdivisions, + cell_size, + thickness, + } } /// Create a hypersphere template at default quality pub fn hypersphere(radius: f32) -> Self { - ShapeTemplate::Hypersphere { radius, subdivisions: 2 } + ShapeTemplate::Hypersphere { + radius, + subdivisions: 2, + } } /// Create a spherinder template at default quality pub fn spherinder(radius: f32, half_height: f32) -> Self { - ShapeTemplate::Spherinder { radius, half_height, subdivisions: 2 } + ShapeTemplate::Spherinder { + radius, + half_height, + subdivisions: 2, + } } /// Create a cubinder template at default quality pub fn cubinder(radius: f32, half_size: f32) -> Self { - ShapeTemplate::Cubinder { radius, half_size, segments: 24 } + ShapeTemplate::Cubinder { + radius, + half_size, + segments: 24, + } } /// Create a duocylinder template at default quality pub fn duocylinder(radius_xy: f32, radius_zw: f32) -> Self { - ShapeTemplate::Duocylinder { radius_xy, radius_zw, segments: 24 } + ShapeTemplate::Duocylinder { + radius_xy, + radius_zw, + segments: 24, + } } } @@ -310,7 +366,10 @@ mod tests { // Scene files may omit resolution fields; they get sane defaults. let s: ShapeTemplate = ron::from_str("(type: \"Hypersphere\", radius: 2.0)").unwrap(); match s { - ShapeTemplate::Hypersphere { radius, subdivisions } => { + ShapeTemplate::Hypersphere { + radius, + subdivisions, + } => { assert_eq!(radius, 2.0); assert_eq!(subdivisions, 2); } @@ -353,7 +412,13 @@ mod tests { let deserialized: ShapeTemplate = ron::from_str(&serialized).unwrap(); match deserialized { - ShapeTemplate::Hyperplane { y, size, subdivisions, cell_size, thickness } => { + ShapeTemplate::Hyperplane { + y, + size, + subdivisions, + cell_size, + thickness, + } => { assert_eq!(y, -2.0); assert_eq!(size, 4.0); assert_eq!(subdivisions, 4); diff --git a/crates/rust4d_core/src/transform.rs b/crates/rust4d_core/src/transform.rs index 57b81a3..4e26b7a 100644 --- a/crates/rust4d_core/src/transform.rs +++ b/crates/rust4d_core/src/transform.rs @@ -2,8 +2,8 @@ //! //! A Transform4D represents the position, rotation, and scale of an entity in 4D space. -use rust4d_math::{Vec4, Rotor4}; -use serde::{Serialize, Deserialize}; +use rust4d_math::{Rotor4, Vec4}; +use serde::{Deserialize, Serialize}; /// A 4D transform with position, rotation, and uniform scale #[derive(Clone, Copy, Debug, Serialize, Deserialize)] @@ -172,7 +172,11 @@ mod tests { let t = Transform4D::from_position_rotation(Vec4::ZERO, rotor); let p = Vec4::X; let transformed = t.transform_point(p); - assert!(vec_approx_eq(transformed, Vec4::Y), "Expected Y, got {:?}", transformed); + assert!( + vec_approx_eq(transformed, Vec4::Y), + "Expected Y, got {:?}", + transformed + ); } #[test] @@ -187,8 +191,11 @@ mod tests { // X * 2 = (2, 0, 0, 0), rotated 90° in XY = (0, 2, 0, 0), + (10, 0, 0, 0) = (10, 2, 0, 0) let p = Vec4::X; let transformed = t.transform_point(p); - assert!(vec_approx_eq(transformed, Vec4::new(10.0, 2.0, 0.0, 0.0)), - "Expected (10, 2, 0, 0), got {:?}", transformed); + assert!( + vec_approx_eq(transformed, Vec4::new(10.0, 2.0, 0.0, 0.0)), + "Expected (10, 2, 0, 0), got {:?}", + transformed + ); } #[test] diff --git a/crates/rust4d_core/src/world.rs b/crates/rust4d_core/src/world.rs index 48bda11..8c1d326 100644 --- a/crates/rust4d_core/src/world.rs +++ b/crates/rust4d_core/src/world.rs @@ -4,11 +4,11 @@ //! It provides side-tables for name lookups, tag indexing, and physics integration, //! and stores hierarchy as Parent/Children components. -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fmt; use crate::components::*; use crate::{DirtyFlags, Transform4D}; use rust4d_physics::{PhysicsConfig, PhysicsWorld}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; /// Error type for hierarchy operations #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,7 +113,10 @@ impl World { let name_str = name.0.clone(); drop(name); if let Some(_old) = self.name_index.insert(name_str.clone(), entity) { - log::warn!("Name '{}' already exists in world; overwriting index entry", name_str); + log::warn!( + "Name '{}' already exists in world; overwriting index entry", + name_str + ); } } @@ -193,7 +196,10 @@ impl World { if let Some(ref mut physics) = self.physics_world { let result = physics.remove_body(body_key); if result.is_none() { - log::warn!("Physics body {:?} not found during entity despawn cleanup", body_key); + log::warn!( + "Physics body {:?} not found during entity despawn cleanup", + body_key + ); } } } @@ -207,7 +213,9 @@ impl World { } // Orphan all children (remove Parent component, add them to roots) - let child_list: Vec = self.ecs.get::<&Children>(entity) + let child_list: Vec = self + .ecs + .get::<&Children>(entity) .ok() .map(|c| c.0.clone()) .unwrap_or_default(); @@ -253,7 +261,11 @@ impl World { /// /// Returns `Some(())` on success, `None` if the entity doesn't exist /// or doesn't have a Name component. - pub fn rename_entity(&mut self, entity: hecs::Entity, new_name: impl Into) -> Option<()> { + pub fn rename_entity( + &mut self, + entity: hecs::Entity, + new_name: impl Into, + ) -> Option<()> { // Remove old name from index let old_name = self.ecs.get::<&Name>(entity).ok().map(|n| n.0.clone())?; self.name_index.remove(&old_name); @@ -267,7 +279,10 @@ impl World { // Add new name to index if let Some(_old) = self.name_index.insert(new_name.clone(), entity) { - log::warn!("Name '{}' already exists in world; overwriting index entry", new_name); + log::warn!( + "Name '{}' already exists in world; overwriting index entry", + new_name + ); } Some(()) @@ -289,7 +304,8 @@ impl World { /// /// Uses the tag index for O(1) lookup instead of scanning. pub fn get_by_tag(&self, tag: &str) -> Vec { - self.tag_index.get(tag) + self.tag_index + .get(tag) .map(|set| set.iter().copied().collect()) .unwrap_or_default() } @@ -335,7 +351,8 @@ impl World { // Sync entity transforms from their physics bodies if let Some(ref physics) = self.physics_world { for (_entity, (transform, body, dirty)) in - self.ecs.query_mut::<(&mut Transform4D, &PhysicsBody, &mut DirtyFlags)>() + self.ecs + .query_mut::<(&mut Transform4D, &PhysicsBody, &mut DirtyFlags)>() { if let Some(phys_body) = physics.get_body(body.0) { if transform.position != phys_body.position { @@ -375,7 +392,8 @@ impl World { /// Get an entity's children pub fn children_of(&self, entity: hecs::Entity) -> Vec { - self.ecs.get::<&Children>(entity) + self.ecs + .get::<&Children>(entity) .ok() .map(|c| c.0.clone()) .unwrap_or_default() @@ -383,7 +401,8 @@ impl World { /// Check if an entity has any children pub fn has_children(&self, entity: hecs::Entity) -> bool { - self.ecs.get::<&Children>(entity) + self.ecs + .get::<&Children>(entity) .ok() .is_some_and(|c| !c.0.is_empty()) } @@ -399,7 +418,11 @@ impl World { /// that parent (reparenting). Returns an error if either entity does not /// exist, if the relationship would create a cycle, or if the child is /// already a child of the specified parent. - pub fn add_child(&mut self, parent: hecs::Entity, child: hecs::Entity) -> Result<(), HierarchyError> { + pub fn add_child( + &mut self, + parent: hecs::Entity, + child: hecs::Entity, + ) -> Result<(), HierarchyError> { // Validate both entities exist if !self.ecs.contains(parent) || !self.ecs.contains(child) { return Err(HierarchyError::InvalidEntity); @@ -488,7 +511,10 @@ impl World { while let Ok(parent) = self.ecs.get::<&Parent>(current) { depth += 1; if depth > MAX_DEPTH { - log::error!("world_transform: depth limit ({}) exceeded for entity, possible cycle", MAX_DEPTH); + log::error!( + "world_transform: depth limit ({}) exceeded for entity, possible cycle", + MAX_DEPTH + ); break; } let parent_entity = parent.0; @@ -631,7 +657,6 @@ impl World { } } - // Check all Children components for stale/non-existent references for (entity, children) in self.ecs.query::<&Children>().iter() { for &child in &children.0 { @@ -698,7 +723,9 @@ impl World { // PhysicsBody requires Transform4D for (entity, _body) in self.ecs.query::<&PhysicsBody>().iter() { if self.ecs.get::<&Transform4D>(entity).is_err() { - let name = self.ecs.get::<&Name>(entity) + let name = self + .ecs + .get::<&Name>(entity) .map(|n| format!(" (\"{}\")", n.0)) .unwrap_or_default(); warnings.push(format!( @@ -712,7 +739,9 @@ impl World { for (entity, children) in self.ecs.query::<&Children>().iter() { for &child in &children.0 { if self.ecs.contains(child) && self.ecs.get::<&Parent>(child).is_err() { - let name = self.ecs.get::<&Name>(entity) + let name = self + .ecs + .get::<&Name>(entity) .map(|n| format!(" (\"{}\")", n.0)) .unwrap_or_default(); warnings.push(format!( @@ -726,7 +755,9 @@ impl World { // Parent component but no Transform4D (hierarchy nodes usually need transforms) for (entity, _parent) in self.ecs.query::<&Parent>().iter() { if self.ecs.get::<&Transform4D>(entity).is_err() { - let name = self.ecs.get::<&Name>(entity) + let name = self + .ecs + .get::<&Name>(entity) .map(|n| format!(" (\"{}\")", n.0)) .unwrap_or_default(); warnings.push(format!( @@ -779,7 +810,8 @@ mod tests { fn spawn_tagged_entity(world: &mut World, name: &str, tags: &[&str]) -> hecs::Entity { let tesseract = Tesseract4D::new(2.0); - let tag_set: std::collections::HashSet = tags.iter().map(|t| t.to_string()).collect(); + let tag_set: std::collections::HashSet = + tags.iter().map(|t| t.to_string()).collect(); world.spawn(( ShapeRef::shared(tesseract), Transform4D::identity(), @@ -823,7 +855,10 @@ mod tests { let handle = spawn_test_entity(&mut world); { - let mut material = world.ecs_mut_unchecked().get::<&mut Material>(handle).unwrap(); + let mut material = world + .ecs_mut_unchecked() + .get::<&mut Material>(handle) + .unwrap(); *material = Material::RED; } @@ -901,8 +936,8 @@ mod tests { #[test] fn test_world_with_physics() { - use rust4d_physics::RigidBody4D; use rust4d_math::Vec4; + use rust4d_physics::RigidBody4D; // Create a world with physics enabled (no gravity for predictable test) let config = PhysicsConfig::new(0.0); @@ -938,15 +973,18 @@ mod tests { // Entity transform should now reflect the physics body position let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); - assert!((transform.position.x - 10.0).abs() < 0.2, - "Expected ~10.0, got {}", transform.position.x); + assert!( + (transform.position.x - 10.0).abs() < 0.2, + "Expected ~10.0, got {}", + transform.position.x + ); assert!((transform.position.y - 5.0).abs() < 0.001); } #[test] fn test_physics_sync_with_gravity() { - use rust4d_physics::RigidBody4D; use rust4d_math::Vec4; + use rust4d_physics::RigidBody4D; // Create a world with gravity (default config) let mut world = World::new().with_physics(PhysicsConfig::default()); @@ -1127,13 +1165,19 @@ mod tests { // Manually mark one as dirty { - let mut dirty = world.ecs_mut_unchecked().get::<&mut DirtyFlags>(key1).unwrap(); + let mut dirty = world + .ecs_mut_unchecked() + .get::<&mut DirtyFlags>(key1) + .unwrap(); *dirty |= DirtyFlags::TRANSFORM; } // Note: dirty_count won't track this since we used ecs_mut_unchecked // Count dirty entities via query - let dirty_count = world.ecs().query::<&DirtyFlags>().iter() + let dirty_count = world + .ecs() + .query::<&DirtyFlags>() + .iter() .filter(|(_, d)| !d.is_empty()) .count(); assert_eq!(dirty_count, 1); @@ -1141,8 +1185,8 @@ mod tests { #[test] fn test_physics_sync_marks_dirty() { - use rust4d_physics::RigidBody4D; use rust4d_math::Vec4; + use rust4d_physics::RigidBody4D; // Create a world with physics enabled (no gravity for predictable test) let config = PhysicsConfig::new(0.0); @@ -1178,8 +1222,8 @@ mod tests { #[test] fn test_physics_sync_no_change_not_dirty() { - use rust4d_physics::RigidBody4D; use rust4d_math::Vec4; + use rust4d_physics::RigidBody4D; // Create a world with physics (no gravity, no velocity = no movement) let config = PhysicsConfig::new(0.0); @@ -1259,16 +1303,10 @@ mod tests { assert!(world.add_child(a, b).is_ok()); // B -> A would create a cycle - assert_eq!( - world.add_child(b, a), - Err(HierarchyError::CyclicHierarchy) - ); + assert_eq!(world.add_child(b, a), Err(HierarchyError::CyclicHierarchy)); // Self-parenting should also be rejected - assert_eq!( - world.add_child(a, a), - Err(HierarchyError::CyclicHierarchy) - ); + assert_eq!(world.add_child(a, a), Err(HierarchyError::CyclicHierarchy)); } #[test] @@ -1283,10 +1321,7 @@ mod tests { assert!(world.add_child(b, c).is_ok()); // C -> A would create a cycle (A is ancestor of C) - assert_eq!( - world.add_child(c, a), - Err(HierarchyError::CyclicHierarchy) - ); + assert_eq!(world.add_child(c, a), Err(HierarchyError::CyclicHierarchy)); } #[test] @@ -1346,10 +1381,16 @@ mod tests { world.add_child(parent, child).unwrap(); let wt = world.world_transform(child).unwrap(); - assert!((wt.position.x - 11.0).abs() < 0.001, - "Expected x=11.0, got {}", wt.position.x); - assert!((wt.position.y - 2.0).abs() < 0.001, - "Expected y=2.0, got {}", wt.position.y); + assert!( + (wt.position.x - 11.0).abs() < 0.001, + "Expected x=11.0, got {}", + wt.position.x + ); + assert!( + (wt.position.y - 2.0).abs() < 0.001, + "Expected y=2.0, got {}", + wt.position.y + ); } #[test] @@ -1358,7 +1399,8 @@ mod tests { // Parent with scale 2 at origin let tesseract = Tesseract4D::new(2.0); - let mut parent_transform = Transform4D::from_position(rust4d_math::Vec4::new(0.0, 0.0, 0.0, 0.0)); + let mut parent_transform = + Transform4D::from_position(rust4d_math::Vec4::new(0.0, 0.0, 0.0, 0.0)); parent_transform.scale = 2.0; let parent = world.spawn(( ShapeRef::shared(tesseract), @@ -1373,8 +1415,11 @@ mod tests { world.add_child(parent, child).unwrap(); let wt = world.world_transform(child).unwrap(); - assert!((wt.position.x - 2.0).abs() < 0.001, - "Expected x=2.0, got {}", wt.position.x); + assert!( + (wt.position.x - 2.0).abs() < 0.001, + "Expected x=2.0, got {}", + wt.position.x + ); } #[test] @@ -1484,9 +1529,9 @@ mod tests { world.add_child(a, b).unwrap(); world.add_child(b, c).unwrap(); - assert!(world.is_ancestor(a, b)); // A is ancestor of B - assert!(world.is_ancestor(a, c)); // A is ancestor of C (transitive) - assert!(world.is_ancestor(b, c)); // B is ancestor of C + assert!(world.is_ancestor(a, b)); // A is ancestor of B + assert!(world.is_ancestor(a, c)); // A is ancestor of C (transitive) + assert!(world.is_ancestor(b, c)); // B is ancestor of C assert!(!world.is_ancestor(c, a)); // C is NOT ancestor of A assert!(!world.is_ancestor(a, a)); // Not ancestor of self assert!(!world.is_ancestor(a, d)); // D is unrelated @@ -1579,8 +1624,11 @@ mod tests { world.add_child(parent, child).unwrap(); let wt = world.world_transform(child).unwrap(); - assert!((wt.position.x - 16.0).abs() < 0.001, - "Expected x=16.0, got {}", wt.position.x); + assert!( + (wt.position.x - 16.0).abs() < 0.001, + "Expected x=16.0, got {}", + wt.position.x + ); } #[test] @@ -1788,7 +1836,7 @@ mod tests { #[test] fn test_despawn_cleans_up_physics_body() { - use rust4d_physics::{PhysicsMaterial, BodyType}; + use rust4d_physics::{BodyType, PhysicsMaterial}; let config = crate::PhysicsConfig::new(-9.81); let mut world = World::new().with_physics(config); @@ -1799,9 +1847,10 @@ mod tests { let body = crate::RigidBody4D::new_aabb( rust4d_math::Vec4::new(0.0, 5.0, 0.0, 0.0), rust4d_math::Vec4::new(0.5, 0.5, 0.5, 0.5), - ).with_body_type(BodyType::Dynamic) - .with_mass(1.0) - .with_material(PhysicsMaterial::RUBBER); + ) + .with_body_type(BodyType::Dynamic) + .with_mass(1.0) + .with_material(PhysicsMaterial::RUBBER); physics.add_body(body) }; @@ -1831,10 +1880,15 @@ mod tests { // Manually set Parent to nonexistent entity (bypassing add_child) let fake_parent = hecs::Entity::DANGLING; - let _ = world.ecs_mut_unchecked().insert_one(child, Parent(fake_parent)); + let _ = world + .ecs_mut_unchecked() + .insert_one(child, Parent(fake_parent)); let issues = world.validate_hierarchy(); - assert!(!issues.is_empty(), "Should detect parent pointing to nonexistent entity"); + assert!( + !issues.is_empty(), + "Should detect parent pointing to nonexistent entity" + ); } #[test] @@ -1847,7 +1901,10 @@ mod tests { let _ = world.ecs_mut_unchecked().insert_one(child, Parent(parent)); let issues = world.validate_hierarchy(); - assert!(!issues.is_empty(), "Should detect child not listed in parent's Children"); + assert!( + !issues.is_empty(), + "Should detect child not listed in parent's Children" + ); } #[test] @@ -1865,7 +1922,8 @@ mod tests { let issues = world.validate_hierarchy(); assert!( issues.iter().any(|i| i.contains("non-existent")), - "Should detect stale child reference; got: {:?}", issues + "Should detect stale child reference; got: {:?}", + issues ); } @@ -1880,12 +1938,15 @@ mod tests { world.add_child(parent_a, child).unwrap(); // Now manually add child to parent_b's Children without updating child's Parent - let _ = world.ecs_mut_unchecked().insert_one(parent_b, Children(vec![child])); + let _ = world + .ecs_mut_unchecked() + .insert_one(parent_b, Children(vec![child])); let issues = world.validate_hierarchy(); assert!( issues.iter().any(|i| i.contains("child's Parent")), - "Should detect child-parent mismatch; got: {:?}", issues + "Should detect child-parent mismatch; got: {:?}", + issues ); } @@ -1893,14 +1954,17 @@ mod tests { fn test_validate_component_schemas_physics_no_transform() { let mut world = World::new(); // Spawn entity with only PhysicsBody, no Transform4D - let _entity = world.ecs_mut_unchecked().spawn(( - PhysicsBody(crate::BodyKey::default()), - )); + let _entity = world + .ecs_mut_unchecked() + .spawn((PhysicsBody(crate::BodyKey::default()),)); let warnings = world.validate_component_schemas(); assert!( - warnings.iter().any(|w| w.contains("PhysicsBody but no Transform4D")), - "Should detect PhysicsBody without Transform4D; got: {:?}", warnings + warnings + .iter() + .any(|w| w.contains("PhysicsBody but no Transform4D")), + "Should detect PhysicsBody without Transform4D; got: {:?}", + warnings ); } @@ -1911,12 +1975,17 @@ mod tests { let child = spawn_test_entity(&mut world); // Manually add child to parent's Children without setting Parent on child - let _ = world.ecs_mut_unchecked().insert_one(parent, Children(vec![child])); + let _ = world + .ecs_mut_unchecked() + .insert_one(parent, Children(vec![child])); let warnings = world.validate_component_schemas(); assert!( - warnings.iter().any(|w| w.contains("lacks a Parent component")), - "Should detect child without Parent; got: {:?}", warnings + warnings + .iter() + .any(|w| w.contains("lacks a Parent component")), + "Should detect child without Parent; got: {:?}", + warnings ); } @@ -1930,8 +1999,8 @@ mod tests { let warnings = world.validate_component_schemas(); assert!( warnings.is_empty(), - "Valid world should have no schema warnings; got: {:?}", warnings + "Valid world should have no schema warnings; got: {:?}", + warnings ); } - } diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 0973441..a5ae0a5 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -7,13 +7,13 @@ //! 4. Dirty flags trigger geometry rebuild use rust4d_core::{ - ActiveScene, Scene, World, EntityTemplate, Transform4D, Material, ShapeRef, - ShapeTemplate, DirtyFlags, PhysicsBody, Name, + ActiveScene, DirtyFlags, EntityTemplate, Material, Name, PhysicsBody, Scene, ShapeRef, + ShapeTemplate, Transform4D, World, }; +use rust4d_math::{Tesseract4D, Vec4}; use rust4d_physics::{ - PhysicsConfig, PhysicsWorld, RigidBody4D, BodyType, StaticCollider, PhysicsMaterial, + BodyType, PhysicsConfig, PhysicsMaterial, PhysicsWorld, RigidBody4D, StaticCollider, }; -use rust4d_math::{Vec4, Tesseract4D}; // ==================== Scene Loading Tests ==================== @@ -30,14 +30,16 @@ fn test_scene_dynamic_entity_has_physics_body() { Material::WHITE, ) .with_name("tesseract") - .with_tag("dynamic") + .with_tag("dynamic"), ); // Instantiate the scene let active = ActiveScene::from_template(&scene, None); // Get the entity - let entity_handle = active.world.get_by_name("tesseract") + let entity_handle = active + .world + .get_by_name("tesseract") .expect("Tesseract entity should exist"); // Verify physics body was created @@ -50,13 +52,12 @@ fn test_scene_dynamic_entity_has_physics_body() { // Verify the body exists in the physics world let physics = active.world.physics().expect("World should have physics"); let body_key = body_comp.unwrap().0; - let body = physics.get_body(body_key).expect("Physics body should exist"); + let body = physics + .get_body(body_key) + .expect("Physics body should exist"); // Verify body type is Dynamic - assert!( - !body.is_static(), - "Body should not be static" - ); + assert!(!body.is_static(), "Body should not be static"); assert!( body.affected_by_gravity(), "Dynamic body should be affected by gravity" @@ -75,7 +76,7 @@ fn test_scene_static_floor_has_collider() { Material::GRAY, ) .with_name("floor") - .with_tag("static") + .with_tag("static"), ); let active = ActiveScene::from_template(&scene, None); @@ -140,16 +141,15 @@ fn test_dynamic_body_lands_on_floor() { // Body should be near the floor (radius 0.5, floor at 0, so center at ~0.5) assert!( body.position.y < 1.0, - "Body should have fallen. Y={}", body.position.y + "Body should have fallen. Y={}", + body.position.y ); assert!( body.position.y > -1.0, - "Body should be above floor. Y={}", body.position.y - ); - assert!( - body.grounded, - "Body should be grounded after settling" + "Body should be above floor. Y={}", + body.position.y ); + assert!(body.grounded, "Body should be grounded after settling"); } /// Test bounded floor collision (the specific bug scenario) @@ -159,17 +159,17 @@ fn test_aabb_body_lands_on_bounded_floor() { // Add a bounded floor at y=-2 (matching default.ron) physics.add_static_collider(StaticCollider::floor_bounded( - -2.0, // y (surface level) - 10.0, // half_size_xz - 5.0, // half_size_w - 5.0, // thickness (minimum) + -2.0, // y (surface level) + 10.0, // half_size_xz + 5.0, // half_size_w + 5.0, // thickness (minimum) PhysicsMaterial::CONCRETE, )); // Add an AABB body at y=0 with half_extent=1 (matching tesseract in default.ron) let body = RigidBody4D::new_aabb( - Vec4::new(0.0, 0.0, 0.0, 0.0), // position - Vec4::new(1.0, 1.0, 1.0, 1.0), // half_extents + Vec4::new(0.0, 0.0, 0.0, 0.0), // position + Vec4::new(1.0, 1.0, 1.0, 1.0), // half_extents ) .with_body_type(BodyType::Dynamic) .with_mass(10.0) @@ -190,18 +190,22 @@ fn test_aabb_body_lands_on_bounded_floor() { // Body should have fallen from y=0 assert!( body.position.y < initial_y, - "Body should have fallen. Initial: {}, Final: {}", initial_y, body.position.y + "Body should have fallen. Initial: {}, Final: {}", + initial_y, + body.position.y ); // Body center should be at approximately y=-1 (bottom at y=-2, floor surface at y=-2) // With half_extent.y=1, center at y=-1 means bottom is at y=-2 (floor surface) assert!( body.position.y > -2.0, - "Body should be above floor. Y={}", body.position.y + "Body should be above floor. Y={}", + body.position.y ); assert!( body.position.y < 0.0, - "Body should be below starting position. Y={}", body.position.y + "Body should be below starting position. Y={}", + body.position.y ); // Body should be grounded @@ -245,7 +249,8 @@ fn test_entity_transform_syncs_from_physics() { let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); assert!( transform.position.y < 10.0, - "Entity should have moved. Y={}", transform.position.y + "Entity should have moved. Y={}", + transform.position.y ); // Entity should be marked dirty @@ -273,7 +278,7 @@ fn test_scene_dynamic_entity_falls_to_floor() { Material::GRAY, ) .with_name("floor") - .with_tag("static") + .with_tag("static"), ); // Add tesseract at y=0 @@ -284,7 +289,7 @@ fn test_scene_dynamic_entity_falls_to_floor() { Material::WHITE, ) .with_name("tesseract") - .with_tag("dynamic") + .with_tag("dynamic"), ); // Instantiate scene @@ -292,7 +297,13 @@ fn test_scene_dynamic_entity_falls_to_floor() { // Get initial tesseract position let entity_handle = active.world.get_by_name("tesseract").unwrap(); - let initial_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; + let initial_y = active + .world + .ecs() + .get::<&Transform4D>(entity_handle) + .unwrap() + .position + .y; // Simulate 2 seconds (120 frames at 60fps) for _ in 0..120 { @@ -301,32 +312,43 @@ fn test_scene_dynamic_entity_falls_to_floor() { // Get final position let entity_handle = active.world.get_by_name("tesseract").unwrap(); - let final_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; + let final_y = active + .world + .ecs() + .get::<&Transform4D>(entity_handle) + .unwrap() + .position + .y; // Tesseract should have fallen assert!( final_y < initial_y, - "Tesseract should have fallen. Initial: {}, Final: {}", initial_y, final_y + "Tesseract should have fallen. Initial: {}, Final: {}", + initial_y, + final_y ); // Tesseract should be near the floor (center at ~-1, bottom at -2) assert!( final_y > -2.0, - "Tesseract should be above floor surface. Y={}", final_y + "Tesseract should be above floor surface. Y={}", + final_y ); assert!( final_y < 0.0, - "Tesseract should be below starting position. Y={}", final_y + "Tesseract should be below starting position. Y={}", + final_y ); // Verify physics body is grounded let physics = active.world.physics().unwrap(); - let body_comp = active.world.ecs().get::<&PhysicsBody>(entity_handle).unwrap(); + let body_comp = active + .world + .ecs() + .get::<&PhysicsBody>(entity_handle) + .unwrap(); let body = physics.get_body(body_comp.0).unwrap(); - assert!( - body.grounded, - "Tesseract physics body should be grounded" - ); + assert!(body.grounded, "Tesseract physics body should be grounded"); } /// Test with actual scene file (requires scenes/default.ron to exist) @@ -343,15 +365,27 @@ fn test_load_default_scene_file() { let mut active = ActiveScene::from_template(&scene, None); // Verify tesseract entity exists and has physics body - let entity_handle = active.world.get_by_name("tesseract") + let entity_handle = active + .world + .get_by_name("tesseract") .expect("Tesseract entity should exist in default scene"); assert!( - active.world.ecs().get::<&PhysicsBody>(entity_handle).is_ok(), + active + .world + .ecs() + .get::<&PhysicsBody>(entity_handle) + .is_ok(), "Tesseract should have physics body" ); - let initial_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; + let initial_y = active + .world + .ecs() + .get::<&Transform4D>(entity_handle) + .unwrap() + .position + .y; // Simulate 2 seconds for _ in 0..120 { @@ -360,12 +394,20 @@ fn test_load_default_scene_file() { // Get final position let entity_handle = active.world.get_by_name("tesseract").unwrap(); - let final_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; + let final_y = active + .world + .ecs() + .get::<&Transform4D>(entity_handle) + .unwrap() + .position + .y; // Tesseract should have fallen assert!( final_y < initial_y, - "Tesseract should have fallen from {} to near floor. Final: {}", initial_y, final_y + "Tesseract should have fallen from {} to near floor. Final: {}", + initial_y, + final_y ); } @@ -378,7 +420,11 @@ fn test_kinematic_body_falls_off_w_edge() { // Add bounded floor: W extends from -5 to +5 physics.add_static_collider(StaticCollider::floor_bounded( - -2.0, 10.0, 5.0, 5.0, PhysicsMaterial::CONCRETE, + -2.0, + 10.0, + 5.0, + 5.0, + PhysicsMaterial::CONCRETE, )); // Add kinematic sphere at center, resting on floor, with gravity enabled @@ -393,7 +439,10 @@ fn test_kinematic_body_falls_off_w_edge() { } // Body should be grounded - assert!(physics.body_is_grounded(key), "Body should be grounded at center"); + assert!( + physics.body_is_grounded(key), + "Body should be grounded at center" + ); let start_y = physics.body_position(key).unwrap().y; // Move body to W=6 (outside floor's W bounds of -5 to +5) @@ -403,10 +452,17 @@ fn test_kinematic_body_falls_off_w_edge() { } let pos = physics.body_position(key).unwrap(); - assert!(pos.w > 5.0, "Body should have moved off W edge. W={}", pos.w); + assert!( + pos.w > 5.0, + "Body should have moved off W edge. W={}", + pos.w + ); - assert!(!physics.body_is_grounded(key), - "Body should NOT be grounded when off W edge. W={}", pos.w); + assert!( + !physics.body_is_grounded(key), + "Body should NOT be grounded when off W edge. W={}", + pos.w + ); // Continue stepping - body should fall for _ in 0..60 { @@ -415,8 +471,12 @@ fn test_kinematic_body_falls_off_w_edge() { } let final_pos = physics.body_position(key).unwrap(); - assert!(final_pos.y < start_y, - "Body should fall when off W edge. Start Y={}, Final Y={}", start_y, final_pos.y); + assert!( + final_pos.y < start_y, + "Body should fall when off W edge. Start Y={}, Final Y={}", + start_y, + final_pos.y + ); } /// Print detailed state for debugging @@ -426,16 +486,17 @@ fn test_physics_step_trace() { // Add bounded floor at y=-2 physics.add_static_collider(StaticCollider::floor_bounded( - -2.0, 10.0, 5.0, 5.0, PhysicsMaterial::CONCRETE, + -2.0, + 10.0, + 5.0, + 5.0, + PhysicsMaterial::CONCRETE, )); // Add AABB body at y=0 - let body = RigidBody4D::new_aabb( - Vec4::new(0.0, 0.0, 0.0, 0.0), - Vec4::new(1.0, 1.0, 1.0, 1.0), - ) - .with_body_type(BodyType::Dynamic) - .with_mass(10.0); + let body = RigidBody4D::new_aabb(Vec4::new(0.0, 0.0, 0.0, 0.0), Vec4::new(1.0, 1.0, 1.0, 1.0)) + .with_body_type(BodyType::Dynamic) + .with_mass(10.0); let key = physics.add_body(body); @@ -453,8 +514,10 @@ fn test_physics_step_trace() { } let body = physics.get_body(key).unwrap(); - println!("Final: pos.y={:.4}, vel.y={:.4}, grounded={}", - body.position.y, body.velocity.y, body.grounded); + println!( + "Final: pos.y={:.4}, vel.y={:.4}, grounded={}", + body.position.y, body.velocity.y, body.grounded + ); // Should be falling assert!(body.position.y < 0.0, "Body should have fallen"); @@ -489,7 +552,8 @@ fn test_remove_entity_cleans_up_physics_body() { "Physics body should exist before entity removal" ); assert_eq!( - world.physics().unwrap().body_count(), 1, + world.physics().unwrap().body_count(), + 1, "Should have exactly 1 physics body" ); @@ -503,7 +567,8 @@ fn test_remove_entity_cleans_up_physics_body() { "Physics body should be removed when entity is removed" ); assert_eq!( - world.physics().unwrap().body_count(), 0, + world.physics().unwrap().body_count(), + 0, "Should have 0 physics bodies after entity removal" ); } @@ -547,6 +612,8 @@ fn test_remove_entity_world_without_physics() { // Remove should work fine let removed = world.despawn(entity_key); - assert!(removed, "Entity should be removed even without physics world"); + assert!( + removed, + "Entity should be removed even without physics world" + ); } - diff --git a/crates/rust4d_game/src/character_controller.rs b/crates/rust4d_game/src/character_controller.rs index 09c6649..d4454c6 100644 --- a/crates/rust4d_game/src/character_controller.rs +++ b/crates/rust4d_game/src/character_controller.rs @@ -91,7 +91,7 @@ impl CharacterController4D { #[cfg(test)] mod tests { use super::*; - use rust4d_physics::{PhysicsConfig, RigidBody4D, BodyType, PhysicsMaterial, StaticCollider}; + use rust4d_physics::{BodyType, PhysicsConfig, PhysicsMaterial, RigidBody4D, StaticCollider}; /// Create a test physics world with a floor at y=0 fn test_world_with_floor() -> PhysicsWorld { @@ -185,8 +185,16 @@ mod tests { physics.step(1.0); let pos = physics.body_position(body_key).unwrap(); - assert!((pos.x - 2.0).abs() < 0.01, "Expected x=2.0 (move_speed), got {}", pos.x); - assert!((pos.w - 5.0).abs() < 0.01, "Expected w=5.0 (w_move_speed), got {}", pos.w); + assert!( + (pos.x - 2.0).abs() < 0.01, + "Expected x=2.0 (move_speed), got {}", + pos.x + ); + assert!( + (pos.w - 5.0).abs() < 0.01, + "Expected w=5.0 (w_move_speed), got {}", + pos.w + ); } #[test] @@ -196,9 +204,8 @@ mod tests { // orthogonal after speed scaling, for ANY speed configuration. // World-axis anisotropic scaling violated this. let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); - let body_key = physics.add_body( - RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_body_type(BodyType::Kinematic), - ); + let body_key = physics + .add_body(RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_body_type(BodyType::Kinematic)); let config = CharacterConfig { move_speed: 3.0, w_move_speed: 2.0, // anisotropic, like config/default.toml @@ -235,12 +242,18 @@ mod tests { // Step to get grounded physics.step(0.016); - assert!(controller.is_grounded(&physics), "Should be grounded after step near floor"); + assert!( + controller.is_grounded(&physics), + "Should be grounded after step near floor" + ); // Jump should succeed let jumped = controller.jump(&mut physics); assert!(jumped, "Jump should succeed when grounded"); - assert!(!controller.is_grounded(&physics), "Should not be grounded after jump"); + assert!( + !controller.is_grounded(&physics), + "Should not be grounded after jump" + ); } #[test] @@ -251,7 +264,10 @@ mod tests { let controller = CharacterController4D::new(body_key, CharacterConfig::default()); - assert!(!controller.is_grounded(&physics), "Should not be grounded in air"); + assert!( + !controller.is_grounded(&physics), + "Should not be grounded in air" + ); let jumped = controller.jump(&mut physics); assert!(!jumped, "Jump should fail when airborne"); @@ -351,7 +367,11 @@ mod tests { let controller = CharacterController4D::new( body_key, - CharacterConfig { move_speed: 5.0, w_move_speed: 5.0, jump_velocity: 8.0 }, + CharacterConfig { + move_speed: 5.0, + w_move_speed: 5.0, + jump_velocity: 8.0, + }, ); // First apply some movement diff --git a/crates/rust4d_game/src/events.rs b/crates/rust4d_game/src/events.rs index 8a348b4..550494e 100644 --- a/crates/rust4d_game/src/events.rs +++ b/crates/rust4d_game/src/events.rs @@ -69,7 +69,10 @@ impl EventBus { handler(event); } }); - self.handlers.entry(type_id).or_default().push((id, wrapper)); + self.handlers + .entry(type_id) + .or_default() + .push((id, wrapper)); HandlerId(type_id, id) } @@ -250,7 +253,11 @@ mod tests { assert_eq!(bus.queued_count(), 0); bus.dispatch(); - assert_eq!(count.load(Ordering::Relaxed), 0, "No events should have been dispatched after clear"); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "No events should have been dispatched after clear" + ); } #[test] @@ -317,7 +324,11 @@ mod tests { // Events should not be delivered bus.emit(DamageEvent { amount: 10 }); bus.dispatch(); - assert_eq!(count.load(Ordering::Relaxed), 0, "Removed handler should not be called"); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "Removed handler should not be called" + ); } #[test] @@ -355,8 +366,16 @@ mod tests { bus.emit(DamageEvent { amount: 10 }); bus.dispatch(); - assert_eq!(count1.load(Ordering::Relaxed), 0, "Removed handler should not fire"); - assert_eq!(count2.load(Ordering::Relaxed), 1, "Remaining handler should fire"); + assert_eq!( + count1.load(Ordering::Relaxed), + 0, + "Removed handler should not fire" + ); + assert_eq!( + count2.load(Ordering::Relaxed), + 1, + "Remaining handler should fire" + ); } #[test] diff --git a/crates/rust4d_game/src/lib.rs b/crates/rust4d_game/src/lib.rs index 8f91149..20ba34a 100644 --- a/crates/rust4d_game/src/lib.rs +++ b/crates/rust4d_game/src/lib.rs @@ -13,7 +13,7 @@ pub mod fsm; pub mod scene_helpers; pub mod tween; -pub use character_controller::{CharacterController4D, CharacterConfig}; +pub use character_controller::{CharacterConfig, CharacterController4D}; pub use events::{EventBus, HandlerId}; pub use fsm::StateMachine; pub use tween::{EasingFunction, Tween, TweenId, TweenManager, TweenState}; diff --git a/crates/rust4d_game/src/scene_helpers.rs b/crates/rust4d_game/src/scene_helpers.rs index f514f7d..a6f077c 100644 --- a/crates/rust4d_game/src/scene_helpers.rs +++ b/crates/rust4d_game/src/scene_helpers.rs @@ -12,7 +12,9 @@ use rust4d_physics::{BodyKey, BodyType, PhysicsMaterial, PhysicsWorld, RigidBody /// /// Returns the player spawn position as a `Vec4`, or `None` if no spawn is defined. pub fn find_player_spawn(scene: &Scene) -> Option { - scene.player_spawn.map(|s| Vec4::new(s[0], s[1], s[2], s[3])) + scene + .player_spawn + .map(|s| Vec4::new(s[0], s[1], s[2], s[3])) } /// Create a player body in the physics world @@ -29,11 +31,7 @@ pub fn find_player_spawn(scene: &Scene) -> Option { /// /// # Returns /// The `BodyKey` for the newly created player body. -pub fn create_player_body( - physics: &mut PhysicsWorld, - position: Vec4, - radius: f32, -) -> BodyKey { +pub fn create_player_body(physics: &mut PhysicsWorld, position: Vec4, radius: f32) -> BodyKey { let body = RigidBody4D::new_sphere(position, radius) .with_body_type(BodyType::Kinematic) .with_gravity(true) // Kinematic but needs gravity for jumping/falling @@ -49,8 +47,7 @@ mod tests { #[test] fn test_find_player_spawn_some() { - let scene = Scene::new("Test") - .with_player_spawn(1.0, 2.0, 3.0, 4.0); + let scene = Scene::new("Test").with_player_spawn(1.0, 2.0, 3.0, 4.0); let spawn = find_player_spawn(&scene); assert!(spawn.is_some()); @@ -112,8 +109,14 @@ mod tests { physics.step(0.1); let body = physics.get_body(key).unwrap(); - assert!(body.position.y < 10.0, "Player body should fall with gravity"); - assert!(body.velocity.y < 0.0, "Player body should have downward velocity"); + assert!( + body.position.y < 10.0, + "Player body should fall with gravity" + ); + assert!( + body.velocity.y < 0.0, + "Player body should have downward velocity" + ); } #[test] @@ -129,8 +132,7 @@ mod tests { #[test] fn test_find_player_spawn_with_zeros() { - let scene = Scene::new("Test") - .with_player_spawn(0.0, 0.0, 0.0, 0.0); + let scene = Scene::new("Test").with_player_spawn(0.0, 0.0, 0.0, 0.0); let spawn = find_player_spawn(&scene); assert!(spawn.is_some()); @@ -141,22 +143,28 @@ mod tests { /// T5: create_player_body + CharacterController4D round-trip #[test] fn test_create_player_body_with_character_controller_round_trip() { - use crate::{CharacterController4D, CharacterConfig}; + use crate::{CharacterConfig, CharacterController4D}; use rust4d_physics::StaticCollider; let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); - physics.add_static_collider(StaticCollider::floor(0.0, rust4d_physics::PhysicsMaterial::CONCRETE)); + physics.add_static_collider(StaticCollider::floor( + 0.0, + rust4d_physics::PhysicsMaterial::CONCRETE, + )); // Create player body via scene_helpers let spawn = Vec4::new(0.0, 1.0, 0.0, 0.0); let key = create_player_body(&mut physics, spawn, 0.5); // Wrap in CharacterController4D - let controller = CharacterController4D::new(key, CharacterConfig { - move_speed: 5.0, - w_move_speed: 5.0, - jump_velocity: 10.0, - }); + let controller = CharacterController4D::new( + key, + CharacterConfig { + move_speed: 5.0, + w_move_speed: 5.0, + jump_velocity: 10.0, + }, + ); // Verify initial position matches spawn let pos = controller.position(&physics).expect("Body should exist"); @@ -167,11 +175,14 @@ mod tests { physics.step(0.016); // Position should have changed - let pos_after = controller.position(&physics).expect("Body should still exist"); + let pos_after = controller + .position(&physics) + .expect("Body should still exist"); assert!( (pos_after.x - spawn.x).abs() > 0.01, "X position should change after movement. Before: {}, After: {}", - spawn.x, pos_after.x + spawn.x, + pos_after.x ); // Step physics until grounded diff --git a/crates/rust4d_game/src/tween.rs b/crates/rust4d_game/src/tween.rs index d7bbca9..f3e2512 100644 --- a/crates/rust4d_game/src/tween.rs +++ b/crates/rust4d_game/src/tween.rs @@ -114,7 +114,9 @@ impl EasingFunction { Some(Self::EaseInOutQuad) } "ease_in_cubic" | "easeincubic" | "in_cubic" | "incubic" => Some(Self::EaseInCubic), - "ease_out_cubic" | "easeoutcubic" | "out_cubic" | "outcubic" => Some(Self::EaseOutCubic), + "ease_out_cubic" | "easeoutcubic" | "out_cubic" | "outcubic" => { + Some(Self::EaseOutCubic) + } "ease_in_out_cubic" | "easeinoutcubic" | "in_out_cubic" | "inoutcubic" => { Some(Self::EaseInOutCubic) } @@ -824,13 +826,7 @@ mod tests { for _ in 0..5 { let entity = world.spawn((rust4d_core::Transform4D::identity(),)); - manager.tween_position( - entity, - Vec4::ZERO, - Vec4::X, - 1.0, - EasingFunction::Linear, - ); + manager.tween_position(entity, Vec4::ZERO, Vec4::X, 1.0, EasingFunction::Linear); } assert_eq!(manager.active_count(), 5); diff --git a/crates/rust4d_input/src/camera_controller.rs b/crates/rust4d_input/src/camera_controller.rs index d7a1d47..a18134c 100644 --- a/crates/rust4d_input/src/camera_controller.rs +++ b/crates/rust4d_input/src/camera_controller.rs @@ -21,15 +21,15 @@ pub struct CameraController { right: bool, up: bool, down: bool, - ana: bool, // Q - move toward +W (ana) - kata: bool, // E - move toward -W (kata) + ana: bool, // Q - move toward +W (ana) + kata: bool, // E - move toward -W (kata) // Jump state (for physics-based movement) jump_pressed: bool, // Mouse state mouse_pressed: bool, - w_rotation_mode: bool, // Right-click held + w_rotation_mode: bool, // Right-click held pending_yaw: f32, pending_pitch: f32, @@ -42,7 +42,7 @@ pub struct CameraController { pub w_move_speed: f32, pub mouse_sensitivity: f32, pub w_rotation_sensitivity: f32, - pub smoothing_half_life: f32, // Exponential smoothing half-life in seconds + pub smoothing_half_life: f32, // Exponential smoothing half-life in seconds pub smoothing_enabled: bool, } @@ -76,10 +76,10 @@ impl CameraController { move_speed: 3.0, w_move_speed: 2.0, - mouse_sensitivity: 0.002, // Standard FPS sensitivity + mouse_sensitivity: 0.002, // Standard FPS sensitivity w_rotation_sensitivity: 0.005, - smoothing_half_life: 0.05, // 50ms half-life when enabled - smoothing_enabled: false, // Disabled by default for responsive FPS feel + smoothing_half_life: 0.05, // 50ms half-life when enabled + smoothing_enabled: false, // Disabled by default for responsive FPS feel } } @@ -88,12 +88,30 @@ impl CameraController { let pressed = state == ElementState::Pressed; match key { - KeyCode::KeyW => { self.forward = pressed; true } - KeyCode::KeyS => { self.backward = pressed; true } - KeyCode::KeyA => { self.left = pressed; true } - KeyCode::KeyD => { self.right = pressed; true } - KeyCode::KeyQ => { self.ana = pressed; true } - KeyCode::KeyE => { self.kata = pressed; true } + KeyCode::KeyW => { + self.forward = pressed; + true + } + KeyCode::KeyS => { + self.backward = pressed; + true + } + KeyCode::KeyA => { + self.left = pressed; + true + } + KeyCode::KeyD => { + self.right = pressed; + true + } + KeyCode::KeyQ => { + self.ana = pressed; + true + } + KeyCode::KeyE => { + self.kata = pressed; + true + } KeyCode::Space => { self.up = pressed; // Also track jump for physics mode @@ -102,7 +120,10 @@ impl CameraController { } true } - KeyCode::ShiftLeft | KeyCode::ShiftRight => { self.down = pressed; true } + KeyCode::ShiftLeft | KeyCode::ShiftRight => { + self.down = pressed; + true + } _ => false, } } @@ -132,7 +153,12 @@ impl CameraController { /// /// When `cursor_captured` is true, free look is enabled (no click required). /// Returns the camera position for debug display. - pub fn update(&mut self, camera: &mut C, dt: f32, cursor_captured: bool) -> Vec4 { + pub fn update( + &mut self, + camera: &mut C, + dt: f32, + cursor_captured: bool, + ) -> Vec4 { // Calculate movement deltas let fwd = (self.forward as i32 - self.backward as i32) as f32; let rgt = (self.right as i32 - self.left as i32) as f32; @@ -149,8 +175,10 @@ impl CameraController { // Exponential smoothing: new = old * factor + input * (1 - factor) // factor = 2^(-dt / half_life), so smaller half_life = faster response let smooth_factor = 2.0f32.powf(-dt / self.smoothing_half_life); - self.smooth_yaw = self.smooth_yaw * smooth_factor + self.pending_yaw * (1.0 - smooth_factor); - self.smooth_pitch = self.smooth_pitch * smooth_factor + self.pending_pitch * (1.0 - smooth_factor); + self.smooth_yaw = + self.smooth_yaw * smooth_factor + self.pending_yaw * (1.0 - smooth_factor); + self.smooth_pitch = + self.smooth_pitch * smooth_factor + self.pending_pitch * (1.0 - smooth_factor); (self.smooth_yaw, self.smooth_pitch) } else { // No smoothing - use raw input @@ -187,8 +215,14 @@ impl CameraController { /// Check if any movement keys are pressed pub fn is_moving(&self) -> bool { - self.forward || self.backward || self.left || self.right - || self.up || self.down || self.ana || self.kata + self.forward + || self.backward + || self.left + || self.right + || self.up + || self.down + || self.ana + || self.kata } /// Toggle input smoothing on/off diff --git a/crates/rust4d_input/src/lib.rs b/crates/rust4d_input/src/lib.rs index 9aeb38a..d01fdd8 100644 --- a/crates/rust4d_input/src/lib.rs +++ b/crates/rust4d_input/src/lib.rs @@ -5,4 +5,4 @@ mod camera_controller; -pub use camera_controller::{CameraController, CameraControl}; +pub use camera_controller::{CameraControl, CameraController}; diff --git a/crates/rust4d_math/src/hyperplane.rs b/crates/rust4d_math/src/hyperplane.rs index 2ff6cab..204d6c3 100644 --- a/crates/rust4d_math/src/hyperplane.rs +++ b/crates/rust4d_math/src/hyperplane.rs @@ -11,7 +11,10 @@ //! We model it as a grid of "pillars" - each pillar is a rectangular prism //! extending in W, decomposed into tetrahedra. -use crate::{Vec4, shape::{ConvexShape4D, Tetrahedron}}; +use crate::{ + shape::{ConvexShape4D, Tetrahedron}, + Vec4, +}; use std::collections::HashSet; /// A hyperplane floor in local space - pure geometry without colors @@ -46,12 +49,7 @@ impl Hyperplane4D { /// * `grid_size` - Number of cells along each axis /// * `w_extent` - Half-extent in W dimension (for slicing visibility) /// * `thickness` - Y thickness (bottom at y=0, top at y=thickness) - pub fn new( - size: f32, - grid_size: usize, - w_extent: f32, - thickness: f32, - ) -> Self { + pub fn new(size: f32, grid_size: usize, w_extent: f32, thickness: f32) -> Self { let mut vertices = Vec::new(); let mut tetrahedra = Vec::new(); @@ -144,10 +142,30 @@ impl Hyperplane4D { /// Decompose a single cell (mini-tesseract) into tetrahedra using Kuhn triangulation fn decompose_cell_to_tetrahedra(base_idx: usize) -> Vec { let permutations = [ - [0, 1, 2, 3], [0, 1, 3, 2], [0, 2, 1, 3], [0, 2, 3, 1], [0, 3, 1, 2], [0, 3, 2, 1], - [1, 0, 2, 3], [1, 0, 3, 2], [1, 2, 0, 3], [1, 2, 3, 0], [1, 3, 0, 2], [1, 3, 2, 0], - [2, 0, 1, 3], [2, 0, 3, 1], [2, 1, 0, 3], [2, 1, 3, 0], [2, 3, 0, 1], [2, 3, 1, 0], - [3, 0, 1, 2], [3, 0, 2, 1], [3, 1, 0, 2], [3, 1, 2, 0], [3, 2, 0, 1], [3, 2, 1, 0], + [0, 1, 2, 3], + [0, 1, 3, 2], + [0, 2, 1, 3], + [0, 2, 3, 1], + [0, 3, 1, 2], + [0, 3, 2, 1], + [1, 0, 2, 3], + [1, 0, 3, 2], + [1, 2, 0, 3], + [1, 2, 3, 0], + [1, 3, 0, 2], + [1, 3, 2, 0], + [2, 0, 1, 3], + [2, 0, 3, 1], + [2, 1, 0, 3], + [2, 1, 3, 0], + [2, 3, 0, 1], + [2, 3, 1, 0], + [3, 0, 1, 2], + [3, 0, 2, 1], + [3, 1, 0, 2], + [3, 1, 2, 0], + [3, 2, 0, 1], + [3, 2, 1, 0], ]; let mut simplices = Vec::with_capacity(24); @@ -218,8 +236,11 @@ mod tests { // Check that all vertices are in local space: y=0 to y=thickness for v in plane.vertices() { - assert!(v.y >= 0.0 && v.y <= 0.1, - "Vertex Y should be between 0 and thickness, got {}", v.y); + assert!( + v.y >= 0.0 && v.y <= 0.1, + "Vertex Y should be between 0 and thickness, got {}", + v.y + ); } } diff --git a/crates/rust4d_math/src/interpolation.rs b/crates/rust4d_math/src/interpolation.rs index a85ca81..eeeac42 100644 --- a/crates/rust4d_math/src/interpolation.rs +++ b/crates/rust4d_math/src/interpolation.rs @@ -3,7 +3,7 @@ //! This module provides the [`Interpolatable`] trait for types that support //! linear interpolation (lerp), which is foundational for animation and tweening. -use crate::{Vec4, Rotor4}; +use crate::{Rotor4, Vec4}; /// Trait for types that can be linearly interpolated /// @@ -88,7 +88,9 @@ impl Interpolatable for Rotor4 { // If dot is negative, negate one rotor to take the shorter path let (b_s, b_xy, b_xz, b_xw, b_yz, b_yw, b_zw, b_p) = if dot < 0.0 { dot = -dot; - (-b.s, -b.b_xy, -b.b_xz, -b.b_xw, -b.b_yz, -b.b_yw, -b.b_zw, -b.p) + ( + -b.s, -b.b_xy, -b.b_xz, -b.b_xw, -b.b_yz, -b.b_yw, -b.b_zw, -b.p, + ) } else { (b.s, b.b_xy, b.b_xz, b.b_xw, b.b_yz, b.b_yw, b.b_zw, b.p) }; @@ -131,15 +133,16 @@ impl Interpolatable for Rotor4 { b_yw: scale_a * a.b_yw + scale_b * b_yw, b_zw: scale_a * a.b_zw + scale_b * b_zw, p: scale_a * a.p + scale_b * b_p, - }.normalize() + } + .normalize() } } #[cfg(test)] mod tests { use super::*; - use std::f32::consts::PI; use crate::RotationPlane; + use std::f32::consts::PI; const EPSILON: f32 = 0.001; @@ -203,7 +206,12 @@ mod tests { let v = Vec4::X; let expected = b.rotate(v); let actual = at_one.rotate(v); - assert!(vec_approx_eq(actual, expected), "expected {:?}, got {:?}", expected, actual); + assert!( + vec_approx_eq(actual, expected), + "expected {:?}, got {:?}", + expected, + actual + ); } #[test] @@ -219,8 +227,18 @@ mod tests { // 45 degrees in XY should put X at (cos45, sin45, 0, 0) let expected_x = (PI / 4.0).cos(); let expected_y = (PI / 4.0).sin(); - assert!(approx_eq(rotated.x, expected_x), "x: {} vs {}", rotated.x, expected_x); - assert!(approx_eq(rotated.y, expected_y), "y: {} vs {}", rotated.y, expected_y); + assert!( + approx_eq(rotated.x, expected_x), + "x: {} vs {}", + rotated.x, + expected_x + ); + assert!( + approx_eq(rotated.y, expected_y), + "y: {} vs {}", + rotated.y, + expected_y + ); } #[test] @@ -237,8 +255,18 @@ mod tests { // Should be at -45 degrees, not 135 degrees let expected_x = (PI / 4.0).cos(); let expected_y = -(PI / 4.0).sin(); - assert!(approx_eq(rotated.x, expected_x), "x: {} vs {}", rotated.x, expected_x); - assert!(approx_eq(rotated.y, expected_y), "y: {} vs {}", rotated.y, expected_y); + assert!( + approx_eq(rotated.x, expected_x), + "x: {} vs {}", + rotated.x, + expected_x + ); + assert!( + approx_eq(rotated.y, expected_y), + "y: {} vs {}", + rotated.y, + expected_y + ); } #[test] diff --git a/crates/rust4d_math/src/lib.rs b/crates/rust4d_math/src/lib.rs index ef518ae..76be56d 100644 --- a/crates/rust4d_math/src/lib.rs +++ b/crates/rust4d_math/src/lib.rs @@ -22,23 +22,23 @@ //! 16-cell, 24-cell, 600-cell) and the curved shapes (hypersphere, //! spherinder, cubinder, duocylinder) as watertight boundary meshes. -mod vec4; -mod rotor4; +pub mod hyperplane; +pub mod interpolation; pub mod mat4; pub mod mesh4d; pub mod primitives; pub mod ray; +mod rotor4; pub mod shape; pub mod tesseract; -pub mod hyperplane; -pub mod interpolation; +mod vec4; -pub use mesh4d::{Mesh4D, MeshError}; -pub use vec4::Vec4; -pub use rotor4::{Rotor4, RotationPlane}; +pub use hyperplane::Hyperplane4D; +pub use interpolation::Interpolatable; pub use mat4::Mat4; +pub use mesh4d::{Mesh4D, MeshError}; pub use ray::Ray4D; +pub use rotor4::{RotationPlane, Rotor4}; pub use shape::{ConvexShape4D, Tetrahedron}; pub use tesseract::Tesseract4D; -pub use hyperplane::Hyperplane4D; -pub use interpolation::Interpolatable; +pub use vec4::Vec4; diff --git a/crates/rust4d_math/src/mat4.rs b/crates/rust4d_math/src/mat4.rs index d60144a..f782a0c 100644 --- a/crates/rust4d_math/src/mat4.rs +++ b/crates/rust4d_math/src/mat4.rs @@ -241,7 +241,6 @@ pub fn scale(s: Vec4) -> Mat4 { /// 2. Remove mutual projections between columns #[allow(clippy::needless_range_loop)] // index pairs (i, j) into mt are clearest here pub fn ortho_iterate(mut m: Mat4) -> Mat4 { - // Normalize columns for i in 0..4 { let col = get_column(m, i); @@ -358,27 +357,36 @@ mod tests { // Y should go to Z let y = Vec4::new(0.0, 1.0, 0.0, 0.0); let result = transform(m, y); - assert!(vec_approx_eq(result, Vec4::new(0.0, 0.0, 1.0, 0.0)), - "Y should become Z, got {:?}", result); + assert!( + vec_approx_eq(result, Vec4::new(0.0, 0.0, 1.0, 0.0)), + "Y should become Z, got {:?}", + result + ); // Z should go to -Y let z = Vec4::new(0.0, 0.0, 1.0, 0.0); let result = transform(m, z); - assert!(vec_approx_eq(result, Vec4::new(0.0, -1.0, 0.0, 0.0)), - "Z should become -Y, got {:?}", result); + assert!( + vec_approx_eq(result, Vec4::new(0.0, -1.0, 0.0, 0.0)), + "Z should become -Y, got {:?}", + result + ); // X should be unchanged let x = Vec4::new(1.0, 0.0, 0.0, 0.0); let result = transform(m, x); - assert!(vec_approx_eq(result, x), - "X should be unchanged, got {:?}", result); + assert!( + vec_approx_eq(result, x), + "X should be unchanged, got {:?}", + result + ); } #[test] fn test_skip_y_preserves_y_axis() { - use std::f32::consts::FRAC_PI_4; - use crate::Rotor4; use crate::RotationPlane; + use crate::Rotor4; + use std::f32::consts::FRAC_PI_4; // Create a 3D rotation (using YZ plane which affects Y and Z) let r = Rotor4::from_plane_angle(RotationPlane::YZ, FRAC_PI_4); @@ -391,15 +399,18 @@ mod tests { let y = Vec4::new(0.0, 1.0, 0.0, 0.0); let result = transform(skip_m, y); - assert!(vec_approx_eq(result, y), - "Y axis should be preserved after skip_y, got {:?}", result); + assert!( + vec_approx_eq(result, y), + "Y axis should be preserved after skip_y, got {:?}", + result + ); } #[test] fn test_skip_y_remaps_rotation() { - use std::f32::consts::FRAC_PI_2; - use crate::Rotor4; use crate::RotationPlane; + use crate::Rotor4; + use std::f32::consts::FRAC_PI_2; // Create a 90° rotation in XY plane (affects X and Y) let r = Rotor4::from_plane_angle(RotationPlane::XY, FRAC_PI_2); @@ -408,29 +419,38 @@ mod tests { // Original: X→Y, Y→-X let x = Vec4::new(1.0, 0.0, 0.0, 0.0); let original_result = transform(m, x); - assert!(vec_approx_eq(original_result, Vec4::new(0.0, 1.0, 0.0, 0.0)), - "Original: X should become Y, got {:?}", original_result); + assert!( + vec_approx_eq(original_result, Vec4::new(0.0, 1.0, 0.0, 0.0)), + "Original: X should become Y, got {:?}", + original_result + ); // After SkipY: rotation is now in XZ plane (indices 0,2) let skip_m = skip_y(m); // X should now go to Z (not Y) let result = transform(skip_m, x); - assert!(vec_approx_eq(result, Vec4::new(0.0, 0.0, 1.0, 0.0)), - "After skip_y: X should become Z, got {:?}", result); + assert!( + vec_approx_eq(result, Vec4::new(0.0, 0.0, 1.0, 0.0)), + "After skip_y: X should become Z, got {:?}", + result + ); // Y should be unchanged let y = Vec4::new(0.0, 1.0, 0.0, 0.0); let result = transform(skip_m, y); - assert!(vec_approx_eq(result, y), - "After skip_y: Y should be unchanged, got {:?}", result); + assert!( + vec_approx_eq(result, y), + "After skip_y: Y should be unchanged, got {:?}", + result + ); } #[test] fn test_skip_y_xz_becomes_xw() { - use std::f32::consts::FRAC_PI_2; - use crate::Rotor4; use crate::RotationPlane; + use crate::Rotor4; + use std::f32::consts::FRAC_PI_2; // Create a 90° rotation in XZ plane let r = Rotor4::from_plane_angle(RotationPlane::XZ, FRAC_PI_2); @@ -443,8 +463,11 @@ mod tests { // X should go to W let x = Vec4::new(1.0, 0.0, 0.0, 0.0); let result = transform(skip_m, x); - assert!(vec_approx_eq(result, Vec4::new(0.0, 0.0, 0.0, 1.0)), - "After skip_y(XZ rotation): X should become W, got {:?}", result); + assert!( + vec_approx_eq(result, Vec4::new(0.0, 0.0, 0.0, 1.0)), + "After skip_y(XZ rotation): X should become W, got {:?}", + result + ); } #[test] @@ -471,8 +494,12 @@ mod tests { let result1 = transform(composed, v); let result2 = transform(r90, v); - assert!(vec_approx_eq(result1, result2), - "Composed: {:?}, Direct: {:?}", result1, result2); + assert!( + vec_approx_eq(result1, result2), + "Composed: {:?}, Direct: {:?}", + result1, + result2 + ); } #[test] @@ -480,8 +507,10 @@ mod tests { let m = plane_rotation(0.5, 1, 2); let col0 = get_column(m, 0); - assert!(vec_approx_eq(col0, Vec4::new(1.0, 0.0, 0.0, 0.0)), - "Column 0 should be X axis for YZ rotation"); + assert!( + vec_approx_eq(col0, Vec4::new(1.0, 0.0, 0.0, 0.0)), + "Column 0 should be X axis for YZ rotation" + ); } #[test] @@ -548,8 +577,10 @@ mod tests { let result = ortho_iterate(m); // Should still be approximately equal - assert!(mat_approx_eq(m, result), - "ortho_iterate should preserve orthogonal matrices"); + assert!( + mat_approx_eq(m, result), + "ortho_iterate should preserve orthogonal matrices" + ); } #[test] @@ -565,7 +596,11 @@ mod tests { let col0 = get_column(result, 0); let col1 = get_column(result, 1); let dot = col0.dot(col1); - assert!(dot.abs() < 0.01, "Columns should be orthogonal after ortho_iterate, dot = {}", dot); + assert!( + dot.abs() < 0.01, + "Columns should be orthogonal after ortho_iterate, dot = {}", + dot + ); // Column lengths should be near 1 assert!((col0.length() - 1.0).abs() < 0.01); @@ -580,8 +615,11 @@ mod tests { // Should rotate X to Y let result = transform(m, from); - assert!(vec_approx_eq(result, to), - "from_to_rotation(X, Y) * X should equal Y, got {:?}", result); + assert!( + vec_approx_eq(result, to), + "from_to_rotation(X, Y) * X should equal Y, got {:?}", + result + ); } #[test] @@ -593,7 +631,11 @@ mod tests { // Result should rotate `from` to `to` let result = transform(m, from); let dot = result.dot(to); - assert!(dot > 0.99, "from_to_rotation should rotate from to to, dot = {}", dot); + assert!( + dot > 0.99, + "from_to_rotation should rotate from to to, dot = {}", + dot + ); } #[test] @@ -603,7 +645,11 @@ mod tests { // Should be close to identity (no rotation needed) let result = transform(m, v); - assert!(vec_approx_eq(result, v), "Identity rotation failed: {:?}", result); + assert!( + vec_approx_eq(result, v), + "Identity rotation failed: {:?}", + result + ); } #[test] @@ -618,8 +664,13 @@ mod tests { for (from, to) in &cases { let m = from_to_rotation(*from, *to); let result = transform(m, *from); - assert!(vec_approx_eq(result, *to), - "180° rotation failed: from={:?} to={:?}, got {:?}", from, to, result); + assert!( + vec_approx_eq(result, *to), + "180° rotation failed: from={:?} to={:?}, got {:?}", + from, + to, + result + ); } } @@ -640,21 +691,37 @@ mod tests { let to = -from; let m = from_to_rotation(from, to); let result = transform(m, from); - assert!(vec_approx_eq(result, to), - "180° diagonal rotation failed: got {:?}, expected {:?}", result, to); + assert!( + vec_approx_eq(result, to), + "180° diagonal rotation failed: got {:?}, expected {:?}", + result, + to + ); } /// Compute determinant of a 4x4 matrix using cofactor expansion fn determinant(m: Mat4) -> f32 { - let a = m[0][0]; let b = m[1][0]; let c = m[2][0]; let d = m[3][0]; - let e = m[0][1]; let f = m[1][1]; let g = m[2][1]; let h = m[3][1]; - let i = m[0][2]; let j = m[1][2]; let k = m[2][2]; let l = m[3][2]; - let mm = m[0][3]; let n = m[1][3]; let o = m[2][3]; let p = m[3][3]; - - a * (f*(k*p - l*o) - g*(j*p - l*n) + h*(j*o - k*n)) - -b * (e*(k*p - l*o) - g*(i*p - l*mm) + h*(i*o - k*mm)) - +c * (e*(j*p - l*n) - f*(i*p - l*mm) + h*(i*n - j*mm)) - -d * (e*(j*o - k*n) - f*(i*o - k*mm) + g*(i*n - j*mm)) + let a = m[0][0]; + let b = m[1][0]; + let c = m[2][0]; + let d = m[3][0]; + let e = m[0][1]; + let f = m[1][1]; + let g = m[2][1]; + let h = m[3][1]; + let i = m[0][2]; + let j = m[1][2]; + let k = m[2][2]; + let l = m[3][2]; + let mm = m[0][3]; + let n = m[1][3]; + let o = m[2][3]; + let p = m[3][3]; + + a * (f * (k * p - l * o) - g * (j * p - l * n) + h * (j * o - k * n)) + - b * (e * (k * p - l * o) - g * (i * p - l * mm) + h * (i * o - k * mm)) + + c * (e * (j * p - l * n) - f * (i * p - l * mm) + h * (i * n - j * mm)) + - d * (e * (j * o - k * n) - f * (i * o - k * mm) + g * (i * n - j * mm)) } #[test] @@ -662,16 +729,23 @@ mod tests { // T1 from review: verify det = +1 (proper rotation, not reflection) let cases: Vec<(Vec4, Vec4)> = vec![ (Vec4::X, Vec4::Y), - (Vec4::X, -Vec4::X), // anti-parallel + (Vec4::X, -Vec4::X), // anti-parallel (Vec4::Y, -Vec4::Y), - (Vec4::new(1.0, 1.0, 1.0, 1.0).normalized(), - Vec4::new(-1.0, -1.0, -1.0, -1.0).normalized()), + ( + Vec4::new(1.0, 1.0, 1.0, 1.0).normalized(), + Vec4::new(-1.0, -1.0, -1.0, -1.0).normalized(), + ), ]; for (from, to) in &cases { let m = from_to_rotation(*from, *to); let det = determinant(m); - assert!((det - 1.0).abs() < 0.01, - "from_to_rotation({:?}, {:?}) should have det=1, got {}", from, to, det); + assert!( + (det - 1.0).abs() < 0.01, + "from_to_rotation({:?}, {:?}) should have det=1, got {}", + from, + to, + det + ); } } @@ -682,7 +756,9 @@ mod tests { set_column(&mut m, 2, Vec4::new(0.0, 0.0, 1e-12, 0.0)); // Near-zero column let result = ortho_iterate(m); // Should return identity rather than a partially normalized matrix - assert!(mat_approx_eq(result, IDENTITY), - "Degenerate ortho_iterate should return identity"); + assert!( + mat_approx_eq(result, IDENTITY), + "Degenerate ortho_iterate should return identity" + ); } } diff --git a/crates/rust4d_math/src/mesh4d.rs b/crates/rust4d_math/src/mesh4d.rs index 64cd56e..decd8ba 100644 --- a/crates/rust4d_math/src/mesh4d.rs +++ b/crates/rust4d_math/src/mesh4d.rs @@ -26,7 +26,7 @@ //! Gram determinants (correct for any embedding in 4D); used heavily by //! the primitive test suites to pin constructions against closed forms -use crate::{ConvexShape4D, Mat4, Tetrahedron, Vec4, mat4}; +use crate::{mat4, ConvexShape4D, Mat4, Tetrahedron, Vec4}; /// A general 4D tetrahedral mesh: vertices + tetrahedral cells. /// @@ -59,7 +59,10 @@ impl std::fmt::Display for MeshError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { MeshError::IndexOutOfBounds { tet, index } => { - write!(f, "tetrahedron {tet} references out-of-bounds vertex {index}") + write!( + f, + "tetrahedron {tet} references out-of-bounds vertex {index}" + ) } MeshError::DegenerateCell { tet } => { write!(f, "tetrahedron {tet} has repeated vertex indices") @@ -81,7 +84,10 @@ impl Mesh4D { /// Use [`Mesh4D::validate`] afterwards if the data comes from an /// untrusted source (e.g. a file). pub fn from_parts(vertices: Vec, tetrahedra: Vec) -> Self { - Self { vertices, tetrahedra } + Self { + vertices, + tetrahedra, + } } /// Create an empty mesh with pre-allocated capacity. @@ -240,18 +246,25 @@ impl Mesh4D { let mut min = first; let mut max = first; for v in &self.vertices[1..] { - min = Vec4::new(min.x.min(v.x), min.y.min(v.y), min.z.min(v.z), min.w.min(v.w)); - max = Vec4::new(max.x.max(v.x), max.y.max(v.y), max.z.max(v.z), max.w.max(v.w)); + min = Vec4::new( + min.x.min(v.x), + min.y.min(v.y), + min.z.min(v.z), + min.w.min(v.w), + ); + max = Vec4::new( + max.x.max(v.x), + max.y.max(v.y), + max.z.max(v.z), + max.w.max(v.w), + ); } Some((min, max)) } /// Radius of the smallest origin-centered ball containing all vertices. pub fn bounding_radius(&self) -> f32 { - self.vertices - .iter() - .map(|v| v.length()) - .fold(0.0, f32::max) + self.vertices.iter().map(|v| v.length()).fold(0.0, f32::max) } /// 3-volume of a single cell. @@ -427,10 +440,7 @@ mod tests { #[test] fn test_validate_catches_out_of_bounds() { - let m = Mesh4D::from_parts( - vec![Vec4::ZERO], - vec![Tetrahedron::new([0, 0, 0, 7])], - ); + let m = Mesh4D::from_parts(vec![Vec4::ZERO], vec![Tetrahedron::new([0, 0, 0, 7])]); assert!(matches!( m.validate(), Err(MeshError::IndexOutOfBounds { index: 7, .. }) @@ -439,11 +449,11 @@ mod tests { #[test] fn test_validate_catches_degenerate() { - let m = Mesh4D::from_parts( - vec![Vec4::ZERO; 4], - vec![Tetrahedron::new([0, 1, 2, 2])], - ); - assert!(matches!(m.validate(), Err(MeshError::DegenerateCell { tet: 0 }))); + let m = Mesh4D::from_parts(vec![Vec4::ZERO; 4], vec![Tetrahedron::new([0, 1, 2, 2])]); + assert!(matches!( + m.validate(), + Err(MeshError::DegenerateCell { tet: 0 }) + )); } #[test] diff --git a/crates/rust4d_math/src/primitives/curved.rs b/crates/rust4d_math/src/primitives/curved.rs index 2a9e550..b8f8696 100644 --- a/crates/rust4d_math/src/primitives/curved.rs +++ b/crates/rust4d_math/src/primitives/curved.rs @@ -43,10 +43,8 @@ pub fn hypersphere(radius: f32, subdivisions: u32) -> Mesh4D { let mut mesh = super::polytopes::hexadecachoron(radius); for _ in 0..subdivisions { - let mut refined = Mesh4D::with_capacity( - mesh.vertex_count() * 4, - mesh.tetrahedron_count() * 8, - ); + let mut refined = + Mesh4D::with_capacity(mesh.vertex_count() * 4, mesh.tetrahedron_count() * 8); for tet in mesh.tetrahedra() { // Gather the 10 points of the subdivision (4 corners + 6 edge // midpoints reprojected to the sphere). Midpoints are computed @@ -150,9 +148,20 @@ pub fn cubinder(radius: f32, half_size: f32, segments: u32) -> Mesh4D { (radius * theta.cos(), radius * theta.sin()) }; let vert = |pool: &mut VertexPool, ring: usize, corner: usize| -> usize { - let key = (if ring == CENTER { u32::MAX } else { (ring % segments) as u32 }, corner as u32); + let key = ( + if ring == CENTER { + u32::MAX + } else { + (ring % segments) as u32 + }, + corner as u32, + ); let (z, w) = square[corner]; - let (x, y) = if ring == CENTER { (0.0, 0.0) } else { circle_point(ring) }; + let (x, y) = if ring == CENTER { + (0.0, 0.0) + } else { + circle_point(ring) + }; pool.get(key, Vec4::new(x, y, z, w)) }; @@ -164,7 +173,10 @@ pub fn cubinder(radius: f32, half_size: f32, segments: u32) -> Mesh4D { for tri in [[0usize, 1, 2], [0, 2, 3]] { let bottom = tri.map(|c| vert(&mut pool, i, c)); let top = tri.map(|c| vert(&mut pool, i + 1, c)); - split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + split_prism( + [bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], + &mut tets, + ); } } @@ -183,7 +195,10 @@ pub fn cubinder(radius: f32, half_size: f32, segments: u32) -> Mesh4D { vert(&mut pool, i, c1), vert(&mut pool, i + 1, c1), ]; - split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + split_prism( + [bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], + &mut tets, + ); } } @@ -212,23 +227,24 @@ pub fn duocylinder(r1: f32, r2: f32, segments1: u32, segments2: u32) -> Mesh4D { // j on circle 2 (or AXIS2). The Clifford torus is the (i, j) grid; // each solid-torus piece fans toward its own axis circle. const AXIS: u32 = u32::MAX; - let vert = |pool: &mut VertexPool, i: usize, j: usize, on_axis_1: bool, on_axis_2: bool| -> usize { - let ki = if on_axis_1 { AXIS } else { (i % n1) as u32 }; - let kj = if on_axis_2 { AXIS } else { (j % n2) as u32 }; - let (x, y) = if on_axis_1 { - (0.0, 0.0) - } else { - let a = (i % n1) as f32 / n1 as f32 * std::f32::consts::TAU; - (r1 * a.cos(), r1 * a.sin()) - }; - let (z, w) = if on_axis_2 { - (0.0, 0.0) - } else { - let b = (j % n2) as f32 / n2 as f32 * std::f32::consts::TAU; - (r2 * b.cos(), r2 * b.sin()) + let vert = + |pool: &mut VertexPool, i: usize, j: usize, on_axis_1: bool, on_axis_2: bool| -> usize { + let ki = if on_axis_1 { AXIS } else { (i % n1) as u32 }; + let kj = if on_axis_2 { AXIS } else { (j % n2) as u32 }; + let (x, y) = if on_axis_1 { + (0.0, 0.0) + } else { + let a = (i % n1) as f32 / n1 as f32 * std::f32::consts::TAU; + (r1 * a.cos(), r1 * a.sin()) + }; + let (z, w) = if on_axis_2 { + (0.0, 0.0) + } else { + let b = (j % n2) as f32 / n2 as f32 * std::f32::consts::TAU; + (r2 * b.cos(), r2 * b.sin()) + }; + pool.get((ki, kj), Vec4::new(x, y, z, w)) }; - pool.get((ki, kj), Vec4::new(x, y, z, w)) - }; let mut tets: Vec = Vec::new(); @@ -246,7 +262,10 @@ pub fn duocylinder(r1: f32, r2: f32, segments1: u32, segments2: u32) -> Mesh4D { vert(&mut pool, i + 1, j, false, false), vert(&mut pool, i + 1, j + 1, false, false), ]; - split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + split_prism( + [bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], + &mut tets, + ); } } @@ -263,7 +282,10 @@ pub fn duocylinder(r1: f32, r2: f32, segments1: u32, segments2: u32) -> Mesh4D { vert(&mut pool, i, j + 1, false, false), vert(&mut pool, i + 1, j + 1, false, false), ]; - split_prism([bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], &mut tets); + split_prism( + [bottom[0], bottom[1], bottom[2], top[0], top[1], top[2]], + &mut tets, + ); } } @@ -288,7 +310,10 @@ struct VertexPool { impl VertexPool { fn new() -> Self { - Self { vertices: Vec::new(), index: HashMap::new() } + Self { + vertices: Vec::new(), + index: HashMap::new(), + } } fn get(&mut self, key: (u32, u32), position: Vec4) -> usize { @@ -305,20 +330,49 @@ impl VertexPool { fn icosphere(radius: f32, subdivisions: u32) -> (Vec<[f32; 3]>, Vec<[usize; 3]>) { let phi = (1.0 + 5.0f32.sqrt()) / 2.0; let mut verts: Vec<[f32; 3]> = vec![ - [-1.0, phi, 0.0], [1.0, phi, 0.0], [-1.0, -phi, 0.0], [1.0, -phi, 0.0], - [0.0, -1.0, phi], [0.0, 1.0, phi], [0.0, -1.0, -phi], [0.0, 1.0, -phi], - [phi, 0.0, -1.0], [phi, 0.0, 1.0], [-phi, 0.0, -1.0], [-phi, 0.0, 1.0], + [-1.0, phi, 0.0], + [1.0, phi, 0.0], + [-1.0, -phi, 0.0], + [1.0, -phi, 0.0], + [0.0, -1.0, phi], + [0.0, 1.0, phi], + [0.0, -1.0, -phi], + [0.0, 1.0, -phi], + [phi, 0.0, -1.0], + [phi, 0.0, 1.0], + [-phi, 0.0, -1.0], + [-phi, 0.0, 1.0], ]; let mut tris: Vec<[usize; 3]> = vec![ - [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], - [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], - [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], - [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + [0, 11, 5], + [0, 5, 1], + [0, 1, 7], + [0, 7, 10], + [0, 10, 11], + [1, 5, 9], + [5, 11, 4], + [11, 10, 2], + [10, 7, 6], + [7, 1, 8], + [3, 9, 4], + [3, 4, 2], + [3, 2, 6], + [3, 6, 8], + [3, 8, 9], + [4, 9, 5], + [2, 4, 11], + [6, 2, 10], + [8, 6, 7], + [9, 8, 1], ]; let project = |v: [f32; 3]| -> [f32; 3] { let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - [v[0] / len * radius, v[1] / len * radius, v[2] / len * radius] + [ + v[0] / len * radius, + v[1] / len * radius, + v[2] / len * radius, + ] }; for v in &mut verts { *v = project(*v); @@ -380,12 +434,21 @@ mod tests { let v1 = hypersphere(1.0, 1).surface_volume() as f64; let v2 = hypersphere(1.0, 2).surface_volume() as f64; let v3 = hypersphere(1.0, 3).surface_volume() as f64; - assert!(v1 < v2 && v2 < v3 && v3 < exact, "monotone from below: {v1} {v2} {v3} {exact}"); - assert!((exact - v3) / exact < 0.05, "d=3 within 5% of 2π²: {v3} vs {exact}"); + assert!( + v1 < v2 && v2 < v3 && v3 < exact, + "monotone from below: {v1} {v2} {v3} {exact}" + ); + assert!( + (exact - v3) / exact < 0.05, + "d=3 within 5% of 2π²: {v3} vs {exact}" + ); // Each subdivision halves edge length; an O(h²) scheme must shrink // the error by ~4× per level. Pin convergence *order*, not just size. let ratio = (exact - v2) / (exact - v3); - assert!(ratio > 3.0, "expected quadratic convergence, error ratio {ratio}"); + assert!( + ratio > 3.0, + "expected quadratic convergence, error ratio {ratio}" + ); } #[test] diff --git a/crates/rust4d_math/src/primitives/extrude.rs b/crates/rust4d_math/src/primitives/extrude.rs index 02fe1c3..58bf836 100644 --- a/crates/rust4d_math/src/primitives/extrude.rs +++ b/crates/rust4d_math/src/primitives/extrude.rs @@ -85,10 +85,10 @@ pub fn split_prism(prism: [usize; 6], out: &mut Vec) { /// refine the 16-cell boundary toward S³. pub const TET_SUBDIVISION: [[usize; 4]; 8] = [ // Corner cells - [0, 4, 5, 6], // v0, m01, m02, m03 - [1, 4, 7, 8], // v1, m01, m12, m13 - [2, 5, 7, 9], // v2, m02, m12, m23 - [3, 6, 8, 9], // v3, m03, m13, m23 + [0, 4, 5, 6], // v0, m01, m02, m03 + [1, 4, 7, 8], // v1, m01, m12, m13 + [2, 5, 7, 9], // v2, m02, m12, m23 + [3, 6, 8, 9], // v3, m03, m13, m23 // Central octahedron around diagonal m01(4) – m23(9). // Equator cycle: m02(5) – m03(6) – m13(8) – m12(7). [4, 9, 5, 6], @@ -176,7 +176,10 @@ mod tests { // (3,4,5) plane contains 2 triangles (the quad rule splits it the // same way from both sides) → internal pairings exist. let (paired, _unpaired) = m.face_pairing(); - assert!(paired >= 2, "stacked prisms must share their interface faces"); + assert!( + paired >= 2, + "stacked prisms must share their interface faces" + ); } #[test] diff --git a/crates/rust4d_math/src/primitives/polytopes.rs b/crates/rust4d_math/src/primitives/polytopes.rs index 0e41c25..2ae29b4 100644 --- a/crates/rust4d_math/src/primitives/polytopes.rs +++ b/crates/rust4d_math/src/primitives/polytopes.rs @@ -165,16 +165,15 @@ pub fn icositetrachoron(circumradius: f32) -> Mesh4D { ); // Cell centroid; opposite vertex pairs satisfy a + b = 2c. - let centroid = cell - .iter() - .fold(Vec4::ZERO, |acc, &i| acc + vertices[i]) - * (1.0 / 6.0); + let centroid = cell.iter().fold(Vec4::ZERO, |acc, &i| acc + vertices[i]) * (1.0 / 6.0); let antipode = |a: usize| -> usize { *cell .iter() .find(|&&b| { - b != a && (vertices[a] + vertices[b] - centroid * 2.0).length() < 1e-4 * circumradius.max(1.0) + b != a + && (vertices[a] + vertices[b] - centroid * 2.0).length() + < 1e-4 * circumradius.max(1.0) }) .expect("octahedral cell vertex must have an antipode") }; @@ -182,7 +181,11 @@ pub fn icositetrachoron(circumradius: f32) -> Mesh4D { // Axis pair (a, a'), equator cycle e0 → e1 → e0' → e1'. let a = cell[0]; let a2 = antipode(a); - let equator: Vec = cell.iter().copied().filter(|&v| v != a && v != a2).collect(); + let equator: Vec = cell + .iter() + .copied() + .filter(|&v| v != a && v != a2) + .collect(); let e0 = equator[0]; let e0p = antipode(e0); let e1 = *equator.iter().find(|&&v| v != e0 && v != e0p).unwrap(); @@ -237,7 +240,11 @@ pub fn hexacosichoron(circumradius: f32) -> Mesh4D { for (dst, &src) in perm.iter().enumerate() { let mag = base[src]; if mag != 0.0 { - let s = if (bits >> sign_slot) & 1 == 0 { 1.0 } else { -1.0 }; + let s = if (bits >> sign_slot) & 1 == 0 { + 1.0 + } else { + -1.0 + }; v[dst] = s * mag; sign_slot += 1; } else { @@ -253,9 +260,8 @@ pub fn hexacosichoron(circumradius: f32) -> Mesh4D { let edge = 1.0 / PHI; let edge2_lo = (edge * edge) * 0.999; let edge2_hi = (edge * edge) * 1.001; - let dist2 = |a: &[f64; 4], b: &[f64; 4]| -> f64 { - (0..4).map(|k| (a[k] - b[k]) * (a[k] - b[k])).sum() - }; + let dist2 = + |a: &[f64; 4], b: &[f64; 4]| -> f64 { (0..4).map(|k| (a[k] - b[k]) * (a[k] - b[k])).sum() }; let n = raw.len(); let mut neighbors: Vec> = vec![Vec::with_capacity(12); n]; @@ -418,8 +424,16 @@ mod tests { fn test_hexacosichoron_structure() { let m = hexacosichoron(1.0); m.validate().unwrap(); - assert_eq!(m.vertex_count(), 120, "binary icosahedral group has order 120"); - assert_eq!(m.tetrahedron_count(), 600, "the 600-cell must have 600 cells"); + assert_eq!( + m.vertex_count(), + 120, + "binary icosahedral group has order 120" + ); + assert_eq!( + m.tetrahedron_count(), + 600, + "the 600-cell must have 600 cells" + ); assert!(m.is_watertight(), "600-cell boundary must be watertight"); for v in m.vertices() { assert!((v.length() - 1.0).abs() < 1e-5); @@ -449,17 +463,28 @@ mod tests { incident[i] += 1; } } - assert!(incident.iter().all(|&c| c == 20), "each vertex must touch 20 cells"); + assert!( + incident.iter().all(|&c| c == 20), + "each vertex must touch 20 cells" + ); } #[test] fn test_polytopes_scale_with_circumradius() { - for ctor in [pentachoron, hexadecachoron, icositetrachoron, hexacosichoron] { + for ctor in [ + pentachoron, + hexadecachoron, + icositetrachoron, + hexacosichoron, + ] { let small = ctor(1.0); let big = ctor(2.0); // Boundary 3-volume scales with r³ let ratio = big.surface_volume() / small.surface_volume(); - assert!((ratio - 8.0).abs() < 1e-2, "volume must scale as r³, got {ratio}"); + assert!( + (ratio - 8.0).abs() < 1e-2, + "volume must scale as r³, got {ratio}" + ); } } } diff --git a/crates/rust4d_math/src/rotor4.rs b/crates/rust4d_math/src/rotor4.rs index 6220976..f4857c6 100644 --- a/crates/rust4d_math/src/rotor4.rs +++ b/crates/rust4d_math/src/rotor4.rs @@ -8,9 +8,9 @@ //! - 6 bivectors (one for each plane) //! - 1 pseudoscalar (4-vector) -use bytemuck::{Pod, Zeroable}; -use serde::{Serialize, Deserialize}; use crate::Vec4; +use bytemuck::{Pod, Zeroable}; +use serde::{Deserialize, Serialize}; /// The 6 rotation planes in 4D space #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -141,8 +141,8 @@ impl Rotor4 { let b_zw = a.z * b.w - a.w * b.z; // Magnitude of the bivector - let mag_sq = b_xy * b_xy + b_xz * b_xz + b_xw * b_xw - + b_yz * b_yz + b_yw * b_yw + b_zw * b_zw; + let mag_sq = + b_xy * b_xy + b_xz * b_xz + b_xw * b_xw + b_yz * b_yz + b_yw * b_yw + b_zw * b_zw; if mag_sq < 1e-10 { // Vectors are parallel, no rotation plane @@ -276,25 +276,25 @@ impl Rotor4 { let new_x = rv_e1 * s + rv_e2 * b12 + rv_e3 * b13 + rv_e4 * b14 // from e_i * e_1i + rv_e123 * b23 + rv_e124 * b24 + rv_e134 * b34 // from e_1jk * e_jk - - rv_e234 * p; // from e_234 * e_1234 = -e_1 + - rv_e234 * p; // from e_234 * e_1234 = -e_1 // e2 coefficient: let new_y = rv_e2 * s - rv_e1 * b12 + rv_e3 * b23 + rv_e4 * b24 // from e_i * e_2i - rv_e123 * b13 - rv_e124 * b14 + rv_e234 * b34 // from e_2jk * e_jk - + rv_e134 * p; // from e_134 * e_1234 = e_2 + + rv_e134 * p; // from e_134 * e_1234 = e_2 // e3 coefficient: let new_z = rv_e3 * s - rv_e1 * b13 - rv_e2 * b23 + rv_e4 * b34 // from e_i * e_3i + rv_e123 * b12 - rv_e134 * b14 - rv_e234 * b24 // from e_3jk * e_jk - - rv_e124 * p; // from e_124 * e_1234 = -e_3 + - rv_e124 * p; // from e_124 * e_1234 = -e_3 // e4 coefficient: let new_w = rv_e4 * s - rv_e1 * b14 - rv_e2 * b24 - rv_e3 * b34 // from e_i * e_4i + rv_e124 * b12 + rv_e134 * b13 + rv_e234 * b23 // from e_4jk * e_jk - + rv_e123 * p; // from e_123 * e_1234 = e_4 + + rv_e123 * p; // from e_123 * e_1234 = e_4 Vec4::new(new_x, new_y, new_z, new_w) } @@ -319,46 +319,53 @@ impl Rotor4 { + a.p * b.p; // XY bivector - let b_xy = a.s * b.b_xy + a.b_xy * b.s - - a.b_xz * b.b_yz + a.b_yz * b.b_xz - - a.b_xw * b.b_yw + a.b_yw * b.b_xw - - a.b_zw * b.p - a.p * b.b_zw; + let b_xy = a.s * b.b_xy + a.b_xy * b.s - a.b_xz * b.b_yz + a.b_yz * b.b_xz + - a.b_xw * b.b_yw + + a.b_yw * b.b_xw + - a.b_zw * b.p + - a.p * b.b_zw; // XZ bivector - let b_xz = a.s * b.b_xz + a.b_xz * b.s - + a.b_xy * b.b_yz - a.b_yz * b.b_xy - - a.b_xw * b.b_zw + a.b_zw * b.b_xw - + a.b_yw * b.p + a.p * b.b_yw; + let b_xz = + a.s * b.b_xz + a.b_xz * b.s + a.b_xy * b.b_yz - a.b_yz * b.b_xy - a.b_xw * b.b_zw + + a.b_zw * b.b_xw + + a.b_yw * b.p + + a.p * b.b_yw; // XW bivector - let b_xw = a.s * b.b_xw + a.b_xw * b.s - + a.b_xy * b.b_yw - a.b_yw * b.b_xy - + a.b_xz * b.b_zw - a.b_zw * b.b_xz - - a.b_yz * b.p - a.p * b.b_yz; + let b_xw = a.s * b.b_xw + a.b_xw * b.s + a.b_xy * b.b_yw - a.b_yw * b.b_xy + + a.b_xz * b.b_zw + - a.b_zw * b.b_xz + - a.b_yz * b.p + - a.p * b.b_yz; // YZ bivector - let b_yz = a.s * b.b_yz + a.b_yz * b.s - - a.b_xy * b.b_xz + a.b_xz * b.b_xy - - a.b_yw * b.b_zw + a.b_zw * b.b_yw - - a.b_xw * b.p - a.p * b.b_xw; + let b_yz = a.s * b.b_yz + a.b_yz * b.s - a.b_xy * b.b_xz + a.b_xz * b.b_xy + - a.b_yw * b.b_zw + + a.b_zw * b.b_yw + - a.b_xw * b.p + - a.p * b.b_xw; // YW bivector - let b_yw = a.s * b.b_yw + a.b_yw * b.s - - a.b_xy * b.b_xw + a.b_xw * b.b_xy - + a.b_yz * b.b_zw - a.b_zw * b.b_yz - + a.b_xz * b.p + a.p * b.b_xz; + let b_yw = + a.s * b.b_yw + a.b_yw * b.s - a.b_xy * b.b_xw + a.b_xw * b.b_xy + a.b_yz * b.b_zw + - a.b_zw * b.b_yz + + a.b_xz * b.p + + a.p * b.b_xz; // ZW bivector - let b_zw = a.s * b.b_zw + a.b_zw * b.s - - a.b_xz * b.b_xw + a.b_xw * b.b_xz - - a.b_yz * b.b_yw + a.b_yw * b.b_yz - - a.b_xy * b.p - a.p * b.b_xy; + let b_zw = a.s * b.b_zw + a.b_zw * b.s - a.b_xz * b.b_xw + a.b_xw * b.b_xz + - a.b_yz * b.b_yw + + a.b_yw * b.b_yz + - a.b_xy * b.p + - a.p * b.b_xy; // Pseudoscalar - let p = a.s * b.p + a.p * b.s - + a.b_xy * b.b_zw + a.b_zw * b.b_xy - - a.b_xz * b.b_yw - a.b_yw * b.b_xz - + a.b_xw * b.b_yz + a.b_yz * b.b_xw; + let p = a.s * b.p + a.p * b.s + a.b_xy * b.b_zw + a.b_zw * b.b_xy + - a.b_xz * b.b_yw + - a.b_yw * b.b_xz + + a.b_xw * b.b_yz + + a.b_yz * b.b_xw; Self { s, @@ -421,12 +428,20 @@ mod tests { // Rotating X by 90° in XY plane should give Y let v = Vec4::X; let rotated = r.rotate(v); - assert!(vec_approx_eq(rotated, Vec4::Y), "Expected Y, got {:?}", rotated); + assert!( + vec_approx_eq(rotated, Vec4::Y), + "Expected Y, got {:?}", + rotated + ); // Rotating Y by 90° in XY plane should give -X let v = Vec4::Y; let rotated = r.rotate(v); - assert!(vec_approx_eq(rotated, -Vec4::X), "Expected -X, got {:?}", rotated); + assert!( + vec_approx_eq(rotated, -Vec4::X), + "Expected -X, got {:?}", + rotated + ); } #[test] @@ -436,7 +451,11 @@ mod tests { // Rotating X by 90° in XZ plane should give Z let v = Vec4::X; let rotated = r.rotate(v); - assert!(vec_approx_eq(rotated, Vec4::Z), "Expected Z, got {:?}", rotated); + assert!( + vec_approx_eq(rotated, Vec4::Z), + "Expected Z, got {:?}", + rotated + ); } #[test] @@ -446,7 +465,11 @@ mod tests { // Rotating Z by 90° in ZW plane should give W let v = Vec4::Z; let rotated = r.rotate(v); - assert!(vec_approx_eq(rotated, Vec4::W), "Expected W, got {:?}", rotated); + assert!( + vec_approx_eq(rotated, Vec4::W), + "Expected W, got {:?}", + rotated + ); } #[test] @@ -474,7 +497,11 @@ mod tests { let composed = r.compose(&r_inv); // Should be close to identity - assert!(approx_eq(composed.normalize().s, 1.0), "Expected identity, got {:?}", composed); + assert!( + approx_eq(composed.normalize().s, 1.0), + "Expected identity, got {:?}", + composed + ); } #[test] @@ -485,7 +512,11 @@ mod tests { let v = Vec4::new(1.0, 2.0, 3.0, 4.0); let rotated = composed.normalize().rotate(v); - assert!(vec_approx_eq(v, rotated), "Expected original, got {:?}", rotated); + assert!( + vec_approx_eq(v, rotated), + "Expected original, got {:?}", + rotated + ); } #[test] @@ -505,10 +536,30 @@ mod tests { let m = r.to_matrix(); // Should be identity matrix - assert!(approx_eq(m[0][0], 1.0) && approx_eq(m[0][1], 0.0) && approx_eq(m[0][2], 0.0) && approx_eq(m[0][3], 0.0)); - assert!(approx_eq(m[1][0], 0.0) && approx_eq(m[1][1], 1.0) && approx_eq(m[1][2], 0.0) && approx_eq(m[1][3], 0.0)); - assert!(approx_eq(m[2][0], 0.0) && approx_eq(m[2][1], 0.0) && approx_eq(m[2][2], 1.0) && approx_eq(m[2][3], 0.0)); - assert!(approx_eq(m[3][0], 0.0) && approx_eq(m[3][1], 0.0) && approx_eq(m[3][2], 0.0) && approx_eq(m[3][3], 1.0)); + assert!( + approx_eq(m[0][0], 1.0) + && approx_eq(m[0][1], 0.0) + && approx_eq(m[0][2], 0.0) + && approx_eq(m[0][3], 0.0) + ); + assert!( + approx_eq(m[1][0], 0.0) + && approx_eq(m[1][1], 1.0) + && approx_eq(m[1][2], 0.0) + && approx_eq(m[1][3], 0.0) + ); + assert!( + approx_eq(m[2][0], 0.0) + && approx_eq(m[2][1], 0.0) + && approx_eq(m[2][2], 1.0) + && approx_eq(m[2][3], 0.0) + ); + assert!( + approx_eq(m[3][0], 0.0) + && approx_eq(m[3][1], 0.0) + && approx_eq(m[3][2], 0.0) + && approx_eq(m[3][3], 1.0) + ); } #[test] @@ -518,15 +569,27 @@ mod tests { // X should be unchanged let rotated_x = r.rotate(Vec4::X); - assert!(vec_approx_eq(rotated_x, Vec4::X), "X should be unchanged, got {:?}", rotated_x); + assert!( + vec_approx_eq(rotated_x, Vec4::X), + "X should be unchanged, got {:?}", + rotated_x + ); // Y should go to Z let rotated_y = r.rotate(Vec4::Y); - assert!(vec_approx_eq(rotated_y, Vec4::Z), "Y should become Z, got {:?}", rotated_y); + assert!( + vec_approx_eq(rotated_y, Vec4::Z), + "Y should become Z, got {:?}", + rotated_y + ); // Z should go to -Y let rotated_z = r.rotate(Vec4::Z); - assert!(vec_approx_eq(rotated_z, -Vec4::Y), "Z should become -Y, got {:?}", rotated_z); + assert!( + vec_approx_eq(rotated_z, -Vec4::Y), + "Z should become -Y, got {:?}", + rotated_z + ); } #[test] @@ -543,10 +606,26 @@ mod tests { let w = composed.rotate(Vec4::W); // Check lengths are preserved - assert!(approx_eq(x.length(), 1.0), "X length not preserved: {}", x.length()); - assert!(approx_eq(y.length(), 1.0), "Y length not preserved: {}", y.length()); - assert!(approx_eq(z.length(), 1.0), "Z length not preserved: {}", z.length()); - assert!(approx_eq(w.length(), 1.0), "W length not preserved: {}", w.length()); + assert!( + approx_eq(x.length(), 1.0), + "X length not preserved: {}", + x.length() + ); + assert!( + approx_eq(y.length(), 1.0), + "Y length not preserved: {}", + y.length() + ); + assert!( + approx_eq(z.length(), 1.0), + "Z length not preserved: {}", + z.length() + ); + assert!( + approx_eq(w.length(), 1.0), + "W length not preserved: {}", + w.length() + ); // Check orthogonality (dot products should be 0) assert!(approx_eq(x.dot(y), 0.0), "X.Y not orthogonal: {}", x.dot(y)); @@ -565,17 +644,28 @@ mod tests { let r_roll_w = Rotor4::from_plane_angle(RotationPlane::ZW, 0.2); let r_roll_xw = Rotor4::from_plane_angle(RotationPlane::XW, 0.1); - let composed = r_roll_xw.compose(&r_roll_w.compose(&r_pitch.compose(&r_yaw))).normalize(); + let composed = r_roll_xw + .compose(&r_roll_w.compose(&r_pitch.compose(&r_yaw))) + .normalize(); // Verify it's still a unit rotor - assert!(approx_eq(composed.magnitude(), 1.0), "Composed rotor not unit: {}", composed.magnitude()); + assert!( + approx_eq(composed.magnitude(), 1.0), + "Composed rotor not unit: {}", + composed.magnitude() + ); // Verify rotation preserves lengths (use slightly larger epsilon for accumulated error) let v = Vec4::new(1.0, 2.0, 3.0, 4.0); let rotated = composed.rotate(v); let length_error = (v.length() - rotated.length()).abs(); - assert!(length_error < 0.001, - "Length not preserved: {} vs {} (error: {})", v.length(), rotated.length(), length_error); + assert!( + length_error < 0.001, + "Length not preserved: {} vs {} (error: {})", + v.length(), + rotated.length(), + length_error + ); } #[test] @@ -599,8 +689,12 @@ mod tests { m[0][3] * v.x + m[1][3] * v.y + m[2][3] * v.z + m[3][3] * v.w, ); - assert!(vec_approx_eq(rotated_rotor, rotated_matrix), - "Rotor and matrix give different results: {:?} vs {:?}", rotated_rotor, rotated_matrix); + assert!( + vec_approx_eq(rotated_rotor, rotated_matrix), + "Rotor and matrix give different results: {:?} vs {:?}", + rotated_rotor, + rotated_matrix + ); } #[test] @@ -616,12 +710,22 @@ mod tests { // Check that column vectors are orthonormal for i in 0..4 { let col_i = Vec4::new(m[i][0], m[i][1], m[i][2], m[i][3]); - assert!(approx_eq(col_i.length(), 1.0), "Column {} not unit length", i); + assert!( + approx_eq(col_i.length(), 1.0), + "Column {} not unit length", + i + ); - for j in (i+1)..4 { + for j in (i + 1)..4 { let col_j = Vec4::new(m[j][0], m[j][1], m[j][2], m[j][3]); let dot = col_i.dot(col_j); - assert!(approx_eq(dot, 0.0), "Columns {} and {} not orthogonal: dot = {}", i, j, dot); + assert!( + approx_eq(dot, 0.0), + "Columns {} and {} not orthogonal: dot = {}", + i, + j, + dot + ); } } } @@ -640,8 +744,12 @@ mod tests { let rotated_composed = composed.rotate(v); let rotated_expected = expected.rotate(v); - assert!(vec_approx_eq(rotated_composed, rotated_expected), - "Same-plane composition failed: {:?} vs {:?}", rotated_composed, rotated_expected); + assert!( + vec_approx_eq(rotated_composed, rotated_expected), + "Same-plane composition failed: {:?} vs {:?}", + rotated_composed, + rotated_expected + ); } #[test] @@ -660,8 +768,12 @@ mod tests { let composed = r_yz.compose(&r_xz); let result = composed.rotate(v); - assert!(vec_approx_eq(step2, result), - "Sequential {:?} vs composed {:?}", step2, result); + assert!( + vec_approx_eq(step2, result), + "Sequential {:?} vs composed {:?}", + step2, + result + ); } #[test] @@ -673,13 +785,18 @@ mod tests { println!("r_xz: s={}, b_xz={}", r_xz.s, r_xz.b_xz); println!("r_yz: s={}, b_yz={}", r_yz.s, r_yz.b_yz); - println!("composed: s={}, b_xy={}, b_xz={}, b_yz={}, p={}", - composed.s, composed.b_xy, composed.b_xz, composed.b_yz, composed.p); + println!( + "composed: s={}, b_xy={}, b_xz={}, b_yz={}, p={}", + composed.s, composed.b_xy, composed.b_xz, composed.b_yz, composed.p + ); println!("composed magnitude: {}", composed.magnitude()); // The composed rotor should be a unit rotor - assert!(approx_eq(composed.magnitude(), 1.0), - "Composed rotor not unit: {}", composed.magnitude()); + assert!( + approx_eq(composed.magnitude(), 1.0), + "Composed rotor not unit: {}", + composed.magnitude() + ); } #[test] @@ -695,10 +812,22 @@ mod tests { // Print the rotation matrix let m = composed.to_matrix(); println!("Rotation matrix for composed rotor:"); - println!("[{:6.3} {:6.3} {:6.3} {:6.3}]", m[0][0], m[0][1], m[0][2], m[0][3]); - println!("[{:6.3} {:6.3} {:6.3} {:6.3}]", m[1][0], m[1][1], m[1][2], m[1][3]); - println!("[{:6.3} {:6.3} {:6.3} {:6.3}]", m[2][0], m[2][1], m[2][2], m[2][3]); - println!("[{:6.3} {:6.3} {:6.3} {:6.3}]", m[3][0], m[3][1], m[3][2], m[3][3]); + println!( + "[{:6.3} {:6.3} {:6.3} {:6.3}]", + m[0][0], m[0][1], m[0][2], m[0][3] + ); + println!( + "[{:6.3} {:6.3} {:6.3} {:6.3}]", + m[1][0], m[1][1], m[1][2], m[1][3] + ); + println!( + "[{:6.3} {:6.3} {:6.3} {:6.3}]", + m[2][0], m[2][1], m[2][2], m[2][3] + ); + println!( + "[{:6.3} {:6.3} {:6.3} {:6.3}]", + m[3][0], m[3][1], m[3][2], m[3][3] + ); // The correct matrix should have: // Column 0 (what X maps to): (0, -1, 0, 0) based on GA calculation @@ -710,6 +839,9 @@ mod tests { println!("X via composed: {:?}", x_via_composed); // The first column of the matrix tells us where X goes - println!("Matrix column 0: ({}, {}, {}, {})", m[0][0], m[1][0], m[2][0], m[3][0]); + println!( + "Matrix column 0: ({}, {}, {}, {})", + m[0][0], m[1][0], m[2][0], m[3][0] + ); } } diff --git a/crates/rust4d_math/src/tesseract.rs b/crates/rust4d_math/src/tesseract.rs index bd11f75..0f7d08a 100644 --- a/crates/rust4d_math/src/tesseract.rs +++ b/crates/rust4d_math/src/tesseract.rs @@ -5,7 +5,10 @@ //! //! For cross-section rendering, we decompose it into tetrahedra (3-simplices). -use crate::{Vec4, shape::{ConvexShape4D, Tetrahedron}}; +use crate::{ + shape::{ConvexShape4D, Tetrahedron}, + Vec4, +}; use std::collections::HashSet; /// A tesseract (4D hypercube) - pure geometry without colors @@ -31,21 +34,21 @@ impl Tesseract4D { // Using binary counting: vertex i has coordinates based on bits of i let vertices = [ Vec4::new(-h, -h, -h, -h), // 0 = 0b0000 - Vec4::new( h, -h, -h, -h), // 1 = 0b0001 - Vec4::new(-h, h, -h, -h), // 2 = 0b0010 - Vec4::new( h, h, -h, -h), // 3 = 0b0011 - Vec4::new(-h, -h, h, -h), // 4 = 0b0100 - Vec4::new( h, -h, h, -h), // 5 = 0b0101 - Vec4::new(-h, h, h, -h), // 6 = 0b0110 - Vec4::new( h, h, h, -h), // 7 = 0b0111 - Vec4::new(-h, -h, -h, h), // 8 = 0b1000 - Vec4::new( h, -h, -h, h), // 9 = 0b1001 - Vec4::new(-h, h, -h, h), // 10 = 0b1010 - Vec4::new( h, h, -h, h), // 11 = 0b1011 - Vec4::new(-h, -h, h, h), // 12 = 0b1100 - Vec4::new( h, -h, h, h), // 13 = 0b1101 - Vec4::new(-h, h, h, h), // 14 = 0b1110 - Vec4::new( h, h, h, h), // 15 = 0b1111 + Vec4::new(h, -h, -h, -h), // 1 = 0b0001 + Vec4::new(-h, h, -h, -h), // 2 = 0b0010 + Vec4::new(h, h, -h, -h), // 3 = 0b0011 + Vec4::new(-h, -h, h, -h), // 4 = 0b0100 + Vec4::new(h, -h, h, -h), // 5 = 0b0101 + Vec4::new(-h, h, h, -h), // 6 = 0b0110 + Vec4::new(h, h, h, -h), // 7 = 0b0111 + Vec4::new(-h, -h, -h, h), // 8 = 0b1000 + Vec4::new(h, -h, -h, h), // 9 = 0b1001 + Vec4::new(-h, h, -h, h), // 10 = 0b1010 + Vec4::new(h, h, -h, h), // 11 = 0b1011 + Vec4::new(-h, -h, h, h), // 12 = 0b1100 + Vec4::new(h, -h, h, h), // 13 = 0b1101 + Vec4::new(-h, h, h, h), // 14 = 0b1110 + Vec4::new(h, h, h, h), // 15 = 0b1111 ]; // Compute tetrahedra decomposition using Kuhn triangulation @@ -102,10 +105,30 @@ impl Tesseract4D { fn compute_tetrahedra() -> Vec { // Generate all permutations of [0, 1, 2, 3] for Kuhn triangulation let permutations = [ - [0, 1, 2, 3], [0, 1, 3, 2], [0, 2, 1, 3], [0, 2, 3, 1], [0, 3, 1, 2], [0, 3, 2, 1], - [1, 0, 2, 3], [1, 0, 3, 2], [1, 2, 0, 3], [1, 2, 3, 0], [1, 3, 0, 2], [1, 3, 2, 0], - [2, 0, 1, 3], [2, 0, 3, 1], [2, 1, 0, 3], [2, 1, 3, 0], [2, 3, 0, 1], [2, 3, 1, 0], - [3, 0, 1, 2], [3, 0, 2, 1], [3, 1, 0, 2], [3, 1, 2, 0], [3, 2, 0, 1], [3, 2, 1, 0], + [0, 1, 2, 3], + [0, 1, 3, 2], + [0, 2, 1, 3], + [0, 2, 3, 1], + [0, 3, 1, 2], + [0, 3, 2, 1], + [1, 0, 2, 3], + [1, 0, 3, 2], + [1, 2, 0, 3], + [1, 2, 3, 0], + [1, 3, 0, 2], + [1, 3, 2, 0], + [2, 0, 1, 3], + [2, 0, 3, 1], + [2, 1, 0, 3], + [2, 1, 3, 0], + [2, 3, 0, 1], + [2, 3, 1, 0], + [3, 0, 1, 2], + [3, 0, 2, 1], + [3, 1, 0, 2], + [3, 1, 2, 0], + [3, 2, 0, 1], + [3, 2, 1, 0], ]; // Generate 5-cells from permutations @@ -260,7 +283,7 @@ mod tests { let mut tet_edges: HashSet<(usize, usize)> = HashSet::new(); for tet in t.tetrahedra() { for i in 0..4 { - for j in (i+1)..4 { + for j in (i + 1)..4 { let (v0, v1) = if tet.indices[i] < tet.indices[j] { (tet.indices[i], tet.indices[j]) } else { @@ -273,11 +296,15 @@ mod tests { // Check that all tesseract edges are covered for i in 0usize..16 { - for j in (i+1)..16 { + for j in (i + 1)..16 { if (i ^ j).count_ones() == 1 { // This is a tesseract edge - assert!(tet_edges.contains(&(i, j)), - "Tesseract edge ({}, {}) not in any tetrahedron", i, j); + assert!( + tet_edges.contains(&(i, j)), + "Tesseract edge ({}, {}) not in any tetrahedron", + i, + j + ); } } } diff --git a/crates/rust4d_math/src/vec4.rs b/crates/rust4d_math/src/vec4.rs index 0343f7d..d378a79 100644 --- a/crates/rust4d_math/src/vec4.rs +++ b/crates/rust4d_math/src/vec4.rs @@ -1,7 +1,7 @@ //! 4D Vector type use bytemuck::{Pod, Zeroable}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// 4D Vector with x, y, z, w components /// The w component represents the 4th spatial dimension (ana/kata) @@ -15,11 +15,36 @@ pub struct Vec4 { } impl Vec4 { - pub const ZERO: Self = Self { x: 0.0, y: 0.0, z: 0.0, w: 0.0 }; - pub const X: Self = Self { x: 1.0, y: 0.0, z: 0.0, w: 0.0 }; - pub const Y: Self = Self { x: 0.0, y: 1.0, z: 0.0, w: 0.0 }; - pub const Z: Self = Self { x: 0.0, y: 0.0, z: 1.0, w: 0.0 }; - pub const W: Self = Self { x: 0.0, y: 0.0, z: 0.0, w: 1.0 }; + pub const ZERO: Self = Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 0.0, + }; + pub const X: Self = Self { + x: 1.0, + y: 0.0, + z: 0.0, + w: 0.0, + }; + pub const Y: Self = Self { + x: 0.0, + y: 1.0, + z: 0.0, + w: 0.0, + }; + pub const Z: Self = Self { + x: 0.0, + y: 0.0, + z: 1.0, + w: 0.0, + }; + pub const W: Self = Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }; /// Create a new Vec4 #[inline] @@ -275,12 +300,7 @@ impl std::ops::Mul for f32 { type Output = Vec4; #[inline] fn mul(self, vec: Vec4) -> Vec4 { - Vec4::new( - self * vec.x, - self * vec.y, - self * vec.z, - self * vec.w, - ) + Vec4::new(self * vec.x, self * vec.y, self * vec.z, self * vec.w) } } @@ -511,14 +531,22 @@ mod tests { let x = Vec4::X; let y = Vec4::Y; let angle = x.angle_to(y); - assert!((angle - FRAC_PI_2).abs() < 0.0001, "Orthogonal vectors should have π/2 angle, got {}", angle); + assert!( + (angle - FRAC_PI_2).abs() < 0.0001, + "Orthogonal vectors should have π/2 angle, got {}", + angle + ); } #[test] fn test_angle_to_same_direction() { let v = Vec4::new(1.0, 2.0, 3.0, 4.0); let angle = v.angle_to(v); - assert!(angle.abs() < 0.0001, "Same direction should have 0 angle, got {}", angle); + assert!( + angle.abs() < 0.0001, + "Same direction should have 0 angle, got {}", + angle + ); } #[test] @@ -527,7 +555,11 @@ mod tests { let v = Vec4::X; let neg_v = -v; let angle = v.angle_to(neg_v); - assert!((angle - PI).abs() < 0.0001, "Opposite vectors should have π angle, got {}", angle); + assert!( + (angle - PI).abs() < 0.0001, + "Opposite vectors should have π angle, got {}", + angle + ); } #[test] @@ -537,7 +569,10 @@ mod tests { // Angle between X and Y is π/2. If we allow more than that, should reach target let result = from.rotate_towards(to, 2.0); let dot = result.dot(to); - assert!(dot > 0.99, "rotate_towards should reach target when max_radians is large enough"); + assert!( + dot > 0.99, + "rotate_towards should reach target when max_radians is large enough" + ); } #[test] @@ -548,8 +583,12 @@ mod tests { // Rotate by π/4, should be halfway between X and Y let result = from.rotate_towards(to, FRAC_PI_4); let angle_from_start = from.angle_to(result); - assert!((angle_from_start - FRAC_PI_4).abs() < 0.01, - "Should rotate by max_radians, rotated {} instead of {}", angle_from_start, FRAC_PI_4); + assert!( + (angle_from_start - FRAC_PI_4).abs() < 0.01, + "Should rotate by max_radians, rotated {} instead of {}", + angle_from_start, + FRAC_PI_4 + ); } #[test] @@ -558,7 +597,11 @@ mod tests { let to = Vec4::new(0.0, 1.0, 1.0, 1.0).normalized(); let result = from.rotate_towards(to, 0.5); let len = result.length(); - assert!((len - 1.0).abs() < 0.01, "rotate_towards should preserve length, got {}", len); + assert!( + (len - 1.0).abs() < 0.01, + "rotate_towards should preserve length, got {}", + len + ); } #[test] @@ -569,12 +612,18 @@ mod tests { let to = -Vec4::X; let result = from.rotate_towards(to, FRAC_PI_4); let len = result.length(); - assert!((len - 1.0).abs() < 0.01, - "rotate_towards anti-parallel should produce unit vector, got length {}", len); + assert!( + (len - 1.0).abs() < 0.01, + "rotate_towards anti-parallel should produce unit vector, got length {}", + len + ); // Should have rotated 45° from X toward -X let angle = from.angle_to(result); - assert!((angle - FRAC_PI_4).abs() < 0.01, - "Should rotate by max_radians, got {}", angle); + assert!( + (angle - FRAC_PI_4).abs() < 0.01, + "Should rotate by max_radians, got {}", + angle + ); } #[test] @@ -585,8 +634,12 @@ mod tests { for axis in &axes { let result = axis.rotate_towards(-*axis, FRAC_PI_4); let len = result.length(); - assert!((len - 1.0).abs() < 0.01, - "Anti-parallel rotate_towards failed for {:?}, length = {}", axis, len); + assert!( + (len - 1.0).abs() < 0.01, + "Anti-parallel rotate_towards failed for {:?}, length = {}", + axis, + len + ); } } } diff --git a/crates/rust4d_math/tests/interpolation_external.rs b/crates/rust4d_math/tests/interpolation_external.rs index ab9610f..23b79bc 100644 --- a/crates/rust4d_math/tests/interpolation_external.rs +++ b/crates/rust4d_math/tests/interpolation_external.rs @@ -4,7 +4,7 @@ //! by code outside the rust4d_math crate, which is the typical use case for the //! tween system in rust4d_game. -use rust4d_math::{Interpolatable, Rotor4, RotationPlane, Vec4}; +use rust4d_math::{Interpolatable, RotationPlane, Rotor4, Vec4}; use std::f32::consts::PI; const EPSILON: f32 = 0.001; diff --git a/crates/rust4d_physics/src/body.rs b/crates/rust4d_physics/src/body.rs index 0287475..9d95423 100644 --- a/crates/rust4d_physics/src/body.rs +++ b/crates/rust4d_physics/src/body.rs @@ -398,8 +398,7 @@ mod tests { #[test] fn test_with_material() { - let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) - .with_material(PhysicsMaterial::RUBBER); + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0).with_material(PhysicsMaterial::RUBBER); assert_eq!(body.material, PhysicsMaterial::RUBBER); } @@ -448,8 +447,7 @@ mod tests { #[test] fn test_with_filter() { use crate::collision::CollisionLayer; - let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) - .with_filter(CollisionFilter::player()); + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0).with_filter(CollisionFilter::player()); assert_eq!(body.filter.layer, CollisionLayer::PLAYER); } @@ -457,8 +455,7 @@ mod tests { #[test] fn test_with_layer() { use crate::collision::CollisionLayer; - let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) - .with_layer(CollisionLayer::ENEMY); + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0).with_layer(CollisionLayer::ENEMY); assert_eq!(body.filter.layer, CollisionLayer::ENEMY); } @@ -495,10 +492,10 @@ mod tests { fn test_floor_bounded_creates_aabb() { use crate::shapes::Collider; let collider = StaticCollider::floor_bounded( - 0.0, // y: floor surface at y=0 - 10.0, // half_size_xz - 5.0, // half_size_w - 1.0, // thickness (clamped to minimum 5.0) + 0.0, // y: floor surface at y=0 + 10.0, // half_size_xz + 5.0, // half_size_w + 1.0, // thickness (clamped to minimum 5.0) PhysicsMaterial::CONCRETE, ); @@ -529,10 +526,10 @@ mod tests { use crate::shapes::Collider; // Thin thickness is clamped to minimum 5.0 let collider = StaticCollider::floor_bounded( - 5.0, // y: floor surface at y=5 - 1.0, // half_size_xz - 1.0, // half_size_w - 0.01, // thickness (clamped to 5.0) + 5.0, // y: floor surface at y=5 + 1.0, // half_size_xz + 1.0, // half_size_w + 0.01, // thickness (clamped to 5.0) PhysicsMaterial::RUBBER, ); @@ -552,10 +549,10 @@ mod tests { use crate::shapes::Collider; // Can specify larger thickness let collider = StaticCollider::floor_bounded( - 0.0, // y: floor surface at y=0 - 10.0, // half_size_xz - 5.0, // half_size_w - 20.0, // thickness (larger than minimum) + 0.0, // y: floor surface at y=0 + 10.0, // half_size_xz + 5.0, // half_size_w + 20.0, // thickness (larger than minimum) PhysicsMaterial::CONCRETE, ); @@ -570,8 +567,8 @@ mod tests { #[test] fn test_floor_bounded_collision_with_sphere() { - use crate::shapes::{Collider, Sphere4D}; use crate::collision::sphere_vs_aabb; + use crate::shapes::{Collider, Sphere4D}; // Values from default.ron scene let collider = StaticCollider::floor_bounded( @@ -589,41 +586,54 @@ mod tests { // Verify floor bounds assert_eq!(aabb.max.y, -2.0, "Floor top should be at y=-2"); - assert_eq!(aabb.min.y, -7.0, "Floor bottom should extend 5 units down (minimum)"); + assert_eq!( + aabb.min.y, -7.0, + "Floor bottom should extend 5 units down (minimum)" + ); // Player spawn at (0, 0, 5, 0) with radius 0.5 let player_radius = 0.5; // Player at spawn position (above floor) - should NOT collide let player_above = Sphere4D::new(Vec4::new(0.0, 0.0, 5.0, 0.0), player_radius); - assert!(sphere_vs_aabb(&player_above, aabb).is_none(), "Player at spawn should not collide"); + assert!( + sphere_vs_aabb(&player_above, aabb).is_none(), + "Player at spawn should not collide" + ); // Player fallen to slightly penetrating floor (center at y = -2 + 0.5 - 0.1 = -1.6) // This is 0.1 units below the tangent point - let player_penetrating_slight = Sphere4D::new(Vec4::new(0.0, -1.6, 5.0, 0.0), player_radius); + let player_penetrating_slight = + Sphere4D::new(Vec4::new(0.0, -1.6, 5.0, 0.0), player_radius); let contact = sphere_vs_aabb(&player_penetrating_slight, aabb); assert!(contact.is_some(), "Player penetrating floor should collide"); // Player outside X/Z bounds - should NOT collide (can fall off edge) let player_off_edge_xz = Sphere4D::new(Vec4::new(15.0, -1.6, 5.0, 0.0), player_radius); - assert!(sphere_vs_aabb(&player_off_edge_xz, aabb).is_none(), "Player off X edge should not collide"); + assert!( + sphere_vs_aabb(&player_off_edge_xz, aabb).is_none(), + "Player off X edge should not collide" + ); // Player outside W bounds - should NOT collide (can fall off W edge) // Floor W extent is -5 to +5, so W=10 is outside let player_off_edge_w = Sphere4D::new(Vec4::new(0.0, -1.6, 5.0, 10.0), player_radius); - assert!(sphere_vs_aabb(&player_off_edge_w, aabb).is_none(), "Player off W edge should not collide"); + assert!( + sphere_vs_aabb(&player_off_edge_w, aabb).is_none(), + "Player off W edge should not collide" + ); } #[test] fn test_floor_bounded_4d_edges() { - use crate::shapes::{Collider, Sphere4D}; use crate::collision::sphere_vs_aabb; + use crate::shapes::{Collider, Sphere4D}; let collider = StaticCollider::floor_bounded( - -2.0, // y: floor surface - 10.0, // half_size_xz (X/Z from -10 to +10) - 5.0, // half_size_w (W from -5 to +5) - 5.0, // thickness + -2.0, // y: floor surface + 10.0, // half_size_xz (X/Z from -10 to +10) + 5.0, // half_size_w (W from -5 to +5) + 5.0, // thickness PhysicsMaterial::CONCRETE, ); @@ -637,23 +647,38 @@ mod tests { // On floor at center - SHOULD collide let on_floor = Sphere4D::new(Vec4::new(0.0, y_on_floor, 0.0, 0.0), radius); - assert!(sphere_vs_aabb(&on_floor, aabb).is_some(), "Center should collide"); + assert!( + sphere_vs_aabb(&on_floor, aabb).is_some(), + "Center should collide" + ); // On floor at W=-4 (inside W bounds) - SHOULD collide let inside_w = Sphere4D::new(Vec4::new(0.0, y_on_floor, 0.0, -4.0), radius); - assert!(sphere_vs_aabb(&inside_w, aabb).is_some(), "Inside W bounds should collide"); + assert!( + sphere_vs_aabb(&inside_w, aabb).is_some(), + "Inside W bounds should collide" + ); // Off floor at W=6 (outside W bounds) - should NOT collide let outside_w_pos = Sphere4D::new(Vec4::new(0.0, y_on_floor, 0.0, 6.0), radius); - assert!(sphere_vs_aabb(&outside_w_pos, aabb).is_none(), "Outside +W should not collide"); + assert!( + sphere_vs_aabb(&outside_w_pos, aabb).is_none(), + "Outside +W should not collide" + ); // Off floor at W=-6 (outside W bounds) - should NOT collide let outside_w_neg = Sphere4D::new(Vec4::new(0.0, y_on_floor, 0.0, -6.0), radius); - assert!(sphere_vs_aabb(&outside_w_neg, aabb).is_none(), "Outside -W should not collide"); + assert!( + sphere_vs_aabb(&outside_w_neg, aabb).is_none(), + "Outside -W should not collide" + ); // Off floor at X=12 - should NOT collide let outside_x = Sphere4D::new(Vec4::new(12.0, y_on_floor, 0.0, 0.0), radius); - assert!(sphere_vs_aabb(&outside_x, aabb).is_none(), "Outside X should not collide"); + assert!( + sphere_vs_aabb(&outside_x, aabb).is_none(), + "Outside X should not collide" + ); } // ===== is_position_over Tests ===== @@ -661,10 +686,10 @@ mod tests { #[test] fn test_is_position_over_aabb_inside() { let floor = StaticCollider::floor_bounded( - 0.0, // y - 10.0, // half_size_xz (X/Z: -10 to +10) - 5.0, // half_size_w (W: -5 to +5) - 5.0, // thickness + 0.0, // y + 10.0, // half_size_xz (X/Z: -10 to +10) + 5.0, // half_size_w (W: -5 to +5) + 5.0, // thickness PhysicsMaterial::CONCRETE, ); @@ -681,10 +706,10 @@ mod tests { #[test] fn test_is_position_over_aabb_outside() { let floor = StaticCollider::floor_bounded( - 0.0, // y - 10.0, // half_size_xz (X/Z: -10 to +10) - 5.0, // half_size_w (W: -5 to +5) - 5.0, // thickness + 0.0, // y + 10.0, // half_size_xz (X/Z: -10 to +10) + 5.0, // half_size_w (W: -5 to +5) + 5.0, // thickness PhysicsMaterial::CONCRETE, ); @@ -717,10 +742,10 @@ mod tests { #[test] fn test_is_position_over_ignores_y() { let floor = StaticCollider::floor_bounded( - 0.0, // y: floor at y=0 - 10.0, // half_size_xz - 5.0, // half_size_w - 5.0, // thickness (floor AABB: y from -5 to 0) + 0.0, // y: floor at y=0 + 10.0, // half_size_xz + 5.0, // half_size_w + 5.0, // thickness (floor AABB: y from -5 to 0) PhysicsMaterial::CONCRETE, ); diff --git a/crates/rust4d_physics/src/collision.rs b/crates/rust4d_physics/src/collision.rs index a786a9d..4a74069 100644 --- a/crates/rust4d_physics/src/collision.rs +++ b/crates/rust4d_physics/src/collision.rs @@ -434,7 +434,10 @@ mod tests { #[test] fn test_aabb_vs_plane_above() { - let aabb = AABB4D::from_center_half_extents(Vec4::new(0.0, 2.0, 0.0, 0.0), Vec4::new(0.5, 0.5, 0.5, 0.5)); + let aabb = AABB4D::from_center_half_extents( + Vec4::new(0.0, 2.0, 0.0, 0.0), + Vec4::new(0.5, 0.5, 0.5, 0.5), + ); let plane = Plane4D::floor(0.0); // AABB is above plane (lowest point at y=1.5) @@ -443,7 +446,10 @@ mod tests { #[test] fn test_aabb_vs_plane_colliding() { - let aabb = AABB4D::from_center_half_extents(Vec4::new(0.0, 0.25, 0.0, 0.0), Vec4::new(0.5, 0.5, 0.5, 0.5)); + let aabb = AABB4D::from_center_half_extents( + Vec4::new(0.0, 0.25, 0.0, 0.0), + Vec4::new(0.5, 0.5, 0.5, 0.5), + ); let plane = Plane4D::floor(0.0); // AABB lowest point at y=-0.25, floor at y=0 @@ -475,7 +481,10 @@ mod tests { #[test] fn test_aabb_vs_aabb_no_collision() { let a = AABB4D::from_center_half_extents(Vec4::ZERO, Vec4::new(0.5, 0.5, 0.5, 0.5)); - let b = AABB4D::from_center_half_extents(Vec4::new(5.0, 0.0, 0.0, 0.0), Vec4::new(0.5, 0.5, 0.5, 0.5)); + let b = AABB4D::from_center_half_extents( + Vec4::new(5.0, 0.0, 0.0, 0.0), + Vec4::new(0.5, 0.5, 0.5, 0.5), + ); assert!(aabb_vs_aabb(&a, &b).is_none()); } @@ -483,7 +492,10 @@ mod tests { #[test] fn test_aabb_vs_aabb_colliding() { let a = AABB4D::from_center_half_extents(Vec4::ZERO, Vec4::new(1.0, 1.0, 1.0, 1.0)); - let b = AABB4D::from_center_half_extents(Vec4::new(1.5, 0.0, 0.0, 0.0), Vec4::new(1.0, 1.0, 1.0, 1.0)); + let b = AABB4D::from_center_half_extents( + Vec4::new(1.5, 0.0, 0.0, 0.0), + Vec4::new(1.0, 1.0, 1.0, 1.0), + ); // Overlap on x-axis: a.max.x=1.0, b.min.x=0.5, overlap=0.5 let contact = aabb_vs_aabb(&a, &b).expect("Should collide"); @@ -625,21 +637,23 @@ mod tests { assert_eq!(floor.min.y, -7.0, "Floor bottom should be at y=-7"); // Tesseract at starting position (y=0), half_extent=1 - let tesseract_start = AABB4D::from_center_half_extents( - Vec4::ZERO, - Vec4::new(1.0, 1.0, 1.0, 1.0), - ); + let tesseract_start = + AABB4D::from_center_half_extents(Vec4::ZERO, Vec4::new(1.0, 1.0, 1.0, 1.0)); // Tesseract bottom at y=-1, floor top at y=-2 → no collision - assert!(aabb_vs_aabb(&tesseract_start, &floor).is_none(), - "Tesseract at y=0 should not collide with floor at y=-2"); + assert!( + aabb_vs_aabb(&tesseract_start, &floor).is_none(), + "Tesseract at y=0 should not collide with floor at y=-2" + ); // Tesseract fallen to y=-0.9 (bottom at y=-1.9, still above floor at y=-2) let tesseract_almost = AABB4D::from_center_half_extents( Vec4::new(0.0, -0.9, 0.0, 0.0), Vec4::new(1.0, 1.0, 1.0, 1.0), ); - assert!(aabb_vs_aabb(&tesseract_almost, &floor).is_none(), - "Tesseract at y=-0.9 should not collide (bottom at -1.9, floor top at -2)"); + assert!( + aabb_vs_aabb(&tesseract_almost, &floor).is_none(), + "Tesseract at y=-0.9 should not collide (bottom at -1.9, floor top at -2)" + ); // Tesseract fallen to y=-1.1 (bottom at y=-2.1, penetrating floor) let tesseract_touching = AABB4D::from_center_half_extents( @@ -647,9 +661,15 @@ mod tests { Vec4::new(1.0, 1.0, 1.0, 1.0), ); let contact = aabb_vs_aabb(&tesseract_touching, &floor); - assert!(contact.is_some(), "Tesseract at y=-1.1 should collide with floor"); + assert!( + contact.is_some(), + "Tesseract at y=-1.1 should collide with floor" + ); let contact = contact.unwrap(); - assert!(contact.penetration > 0.0, "Should have positive penetration"); + assert!( + contact.penetration > 0.0, + "Should have positive penetration" + ); // Tesseract at rest position y=-1 (bottom at y=-2, exactly on floor) // At exact boundary - behavior is undefined (floating point edge case) @@ -662,8 +682,10 @@ mod tests { Vec4::new(0.0, -1.001, 0.0, 0.0), Vec4::new(1.0, 1.0, 1.0, 1.0), ); - assert!(aabb_vs_aabb(&tesseract_slightly_in, &floor).is_some(), - "Tesseract slightly below resting position should collide"); + assert!( + aabb_vs_aabb(&tesseract_slightly_in, &floor).is_some(), + "Tesseract slightly below resting position should collide" + ); } #[test] @@ -674,7 +696,9 @@ mod tests { let b = Sphere4D::new(Vec4::ZERO, 1.0); let contact = sphere_vs_sphere(&a, &b).expect("Coincident spheres should return a contact"); assert_eq!(contact.normal, Vec4::Y, "Fallback normal should be Y-axis"); - assert!((contact.penetration - 2.0).abs() < f32::EPSILON, - "Penetration should equal radius_a + radius_b"); + assert!( + (contact.penetration - 2.0).abs() < f32::EPSILON, + "Penetration should equal radius_a + radius_b" + ); } } diff --git a/crates/rust4d_physics/src/lib.rs b/crates/rust4d_physics/src/lib.rs index d3de7c4..86951f7 100644 --- a/crates/rust4d_physics/src/lib.rs +++ b/crates/rust4d_physics/src/lib.rs @@ -52,9 +52,12 @@ pub mod world; // Re-export commonly used types (see module doc for rationale) pub use body::{BodyKey, BodyType, RigidBody4D, StaticCollider}; -pub use collision::{aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, sphere_vs_sphere, CollisionEvent, CollisionEventKind, CollisionFilter, CollisionLayer, Contact}; +pub use collision::{ + aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, sphere_vs_sphere, CollisionEvent, + CollisionEventKind, CollisionFilter, CollisionLayer, Contact, +}; pub use material::PhysicsMaterial; +pub use raycast::{ray_vs_aabb, ray_vs_collider, ray_vs_plane, ray_vs_sphere, RayHit}; pub use shapes::{Collider, Plane4D, Sphere4D, AABB4D}; -pub use raycast::{RayHit, ray_vs_sphere, ray_vs_aabb, ray_vs_plane, ray_vs_collider}; -pub use spatial::{SpatialQueryResult, AreaEffectHit}; +pub use spatial::{AreaEffectHit, SpatialQueryResult}; pub use world::{PhysicsConfig, PhysicsWorld, RayTarget, WorldRayHit}; diff --git a/crates/rust4d_physics/src/raycast.rs b/crates/rust4d_physics/src/raycast.rs index 2dcce6c..e4e1529 100644 --- a/crates/rust4d_physics/src/raycast.rs +++ b/crates/rust4d_physics/src/raycast.rs @@ -2,8 +2,8 @@ //! //! Provides ray intersection tests against spheres, AABBs, and planes. +use crate::shapes::{Collider, Plane4D, Sphere4D, AABB4D}; use rust4d_math::{Ray4D, Vec4}; -use crate::shapes::{Collider, Sphere4D, AABB4D, Plane4D}; /// Information about a ray hit #[derive(Clone, Copy, Debug)] @@ -71,7 +71,12 @@ pub fn ray_vs_aabb(ray: &Ray4D, aabb: &AABB4D) -> Option { // Check each axis using the slab method let origin = [ray.origin.x, ray.origin.y, ray.origin.z, ray.origin.w]; - let dir = [ray.direction.x, ray.direction.y, ray.direction.z, ray.direction.w]; + let dir = [ + ray.direction.x, + ray.direction.y, + ray.direction.z, + ray.direction.w, + ]; let mins = [aabb.min.x, aabb.min.y, aabb.min.z, aabb.min.w]; let maxs = [aabb.max.x, aabb.max.y, aabb.max.z, aabb.max.w]; @@ -132,10 +137,34 @@ pub fn ray_vs_aabb(ray: &Ray4D, aabb: &AABB4D) -> Option { } } match best_axis { - 0 => normal.x = if exit_origin[0] > (mins[0] + maxs[0]) * 0.5 { 1.0 } else { -1.0 }, - 1 => normal.y = if exit_origin[1] > (mins[1] + maxs[1]) * 0.5 { 1.0 } else { -1.0 }, - 2 => normal.z = if exit_origin[2] > (mins[2] + maxs[2]) * 0.5 { 1.0 } else { -1.0 }, - 3 => normal.w = if exit_origin[3] > (mins[3] + maxs[3]) * 0.5 { 1.0 } else { -1.0 }, + 0 => { + normal.x = if exit_origin[0] > (mins[0] + maxs[0]) * 0.5 { + 1.0 + } else { + -1.0 + } + } + 1 => { + normal.y = if exit_origin[1] > (mins[1] + maxs[1]) * 0.5 { + 1.0 + } else { + -1.0 + } + } + 2 => { + normal.z = if exit_origin[2] > (mins[2] + maxs[2]) * 0.5 { + 1.0 + } else { + -1.0 + } + } + 3 => { + normal.w = if exit_origin[3] > (mins[3] + maxs[3]) * 0.5 { + 1.0 + } else { + -1.0 + } + } _ => unreachable!(), } return Some(RayHit { @@ -181,7 +210,11 @@ pub fn ray_vs_plane(ray: &Ray4D, plane: &Plane4D) -> Option { let point = ray.point_at(t); // Normal always faces toward the ray origin side - let normal = if denom < 0.0 { plane.normal } else { -plane.normal }; + let normal = if denom < 0.0 { + plane.normal + } else { + -plane.normal + }; Some(RayHit { distance: t, @@ -295,8 +328,11 @@ mod tests { let hit = ray_vs_aabb(&ray, &aabb).expect("Should hit exit"); // Should hit exit face at x=0.5 - assert!((hit.point.x - 0.5).abs() < 0.01, - "Exit point x should be 0.5, got {}", hit.point.x); + assert!( + (hit.point.x - 0.5).abs() < 0.01, + "Exit point x should be 0.5, got {}", + hit.point.x + ); assert!(hit.distance > 0.0, "Distance should be positive"); } diff --git a/crates/rust4d_physics/src/shapes.rs b/crates/rust4d_physics/src/shapes.rs index 9731c94..c47d191 100644 --- a/crates/rust4d_physics/src/shapes.rs +++ b/crates/rust4d_physics/src/shapes.rs @@ -214,7 +214,10 @@ mod tests { #[test] fn test_aabb_from_center_half_extents() { - let aabb = AABB4D::from_center_half_extents(Vec4::new(1.0, 2.0, 3.0, 4.0), Vec4::new(0.5, 0.5, 0.5, 0.5)); + let aabb = AABB4D::from_center_half_extents( + Vec4::new(1.0, 2.0, 3.0, 4.0), + Vec4::new(0.5, 0.5, 0.5, 0.5), + ); assert_eq!(aabb.min, Vec4::new(0.5, 1.5, 2.5, 3.5)); assert_eq!(aabb.max, Vec4::new(1.5, 2.5, 3.5, 4.5)); assert_eq!(aabb.center(), Vec4::new(1.0, 2.0, 3.0, 4.0)); diff --git a/crates/rust4d_physics/src/world.rs b/crates/rust4d_physics/src/world.rs index 566018b..5f5e6a2 100644 --- a/crates/rust4d_physics/src/world.rs +++ b/crates/rust4d_physics/src/world.rs @@ -1,14 +1,17 @@ //! Physics world and simulation use crate::body::{BodyKey, RigidBody4D, StaticCollider}; -use crate::collision::{aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, sphere_vs_sphere, CollisionEvent, CollisionEventKind, CollisionLayer, Contact}; +use crate::collision::{ + aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, sphere_vs_sphere, CollisionEvent, + CollisionEventKind, CollisionLayer, Contact, +}; use crate::raycast::{ray_vs_collider, RayHit}; use crate::shapes::Collider; use rust4d_math::{Ray4D, Vec4}; use slotmap::SlotMap; use std::collections::HashSet; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// Configuration for the physics simulation #[derive(Clone, Debug, Serialize, Deserialize)] @@ -208,7 +211,12 @@ impl PhysicsWorld { /// /// Returns all hits within max_distance whose collision layer matches /// layer_mask, sorted by distance (nearest first). - pub fn raycast(&self, ray: &Ray4D, max_distance: f32, layer_mask: CollisionLayer) -> Vec { + pub fn raycast( + &self, + ray: &Ray4D, + max_distance: f32, + layer_mask: CollisionLayer, + ) -> Vec { let mut hits = Vec::new(); // Check all bodies @@ -218,7 +226,10 @@ impl PhysicsWorld { } if let Some(hit) = ray_vs_collider(ray, &body.collider) { if hit.distance <= max_distance { - hits.push(WorldRayHit { hit, target: RayTarget::Body(key) }); + hits.push(WorldRayHit { + hit, + target: RayTarget::Body(key), + }); } } } @@ -230,13 +241,21 @@ impl PhysicsWorld { } if let Some(hit) = ray_vs_collider(ray, &static_col.collider) { if hit.distance <= max_distance { - hits.push(WorldRayHit { hit, target: RayTarget::Static(index) }); + hits.push(WorldRayHit { + hit, + target: RayTarget::Static(index), + }); } } } // Sort by distance (nearest first) - hits.sort_by(|a, b| a.hit.distance.partial_cmp(&b.hit.distance).unwrap_or(std::cmp::Ordering::Equal)); + hits.sort_by(|a, b| { + a.hit + .distance + .partial_cmp(&b.hit.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); hits } @@ -244,7 +263,12 @@ impl PhysicsWorld { /// /// More efficient than `raycast()` for the common case — performs a /// single pass without allocating or sorting. - pub fn raycast_nearest(&self, ray: &Ray4D, max_distance: f32, layer_mask: CollisionLayer) -> Option { + pub fn raycast_nearest( + &self, + ray: &Ray4D, + max_distance: f32, + layer_mask: CollisionLayer, + ) -> Option { let mut nearest: Option = None; let mut nearest_dist = max_distance; @@ -255,7 +279,10 @@ impl PhysicsWorld { if let Some(hit) = ray_vs_collider(ray, &body.collider) { if hit.distance <= nearest_dist { nearest_dist = hit.distance; - nearest = Some(WorldRayHit { hit, target: RayTarget::Body(key) }); + nearest = Some(WorldRayHit { + hit, + target: RayTarget::Body(key), + }); } } } @@ -267,7 +294,10 @@ impl PhysicsWorld { if let Some(hit) = ray_vs_collider(ray, &static_col.collider) { if hit.distance <= nearest_dist { nearest_dist = hit.distance; - nearest = Some(WorldRayHit { hit, target: RayTarget::Static(index) }); + nearest = Some(WorldRayHit { + hit, + target: RayTarget::Static(index), + }); } } } @@ -520,12 +550,7 @@ impl PhysicsWorld { /// // Enemy spots player! /// } /// ``` - pub fn line_of_sight( - &self, - from: Vec4, - to: Vec4, - blocking_layers: CollisionLayer, - ) -> bool { + pub fn line_of_sight(&self, from: Vec4, to: Vec4, blocking_layers: CollisionLayer) -> bool { let delta = to - from; let max_distance = delta.length(); @@ -626,20 +651,17 @@ impl PhysicsWorld { } /// Check for collision between a body collider and a static collider - fn check_static_collision(body_collider: &Collider, static_collider: &Collider) -> Option { + fn check_static_collision( + body_collider: &Collider, + static_collider: &Collider, + ) -> Option { match (body_collider, static_collider) { // Body sphere vs static plane - (Collider::Sphere(sphere), Collider::Plane(plane)) => { - sphere_vs_plane(sphere, plane) - } + (Collider::Sphere(sphere), Collider::Plane(plane)) => sphere_vs_plane(sphere, plane), // Body AABB vs static plane - (Collider::AABB(aabb), Collider::Plane(plane)) => { - aabb_vs_plane(aabb, plane) - } + (Collider::AABB(aabb), Collider::Plane(plane)) => aabb_vs_plane(aabb, plane), // Body sphere vs static AABB - (Collider::Sphere(sphere), Collider::AABB(aabb)) => { - sphere_vs_aabb(sphere, aabb) - } + (Collider::Sphere(sphere), Collider::AABB(aabb)) => sphere_vs_aabb(sphere, aabb), // Body AABB vs static AABB (Collider::AABB(body_aabb), Collider::AABB(static_aabb)) => { aabb_vs_aabb(body_aabb, static_aabb) @@ -694,7 +716,10 @@ impl PhysicsWorld { if contact.is_colliding() { // Record collision event self.collision_events.push(CollisionEvent { - kind: CollisionEventKind::BodyVsStatic { body: key, static_index: static_idx }, + kind: CollisionEventKind::BodyVsStatic { + body: key, + static_index: static_idx, + }, contact: Some(contact), }); @@ -720,20 +745,20 @@ impl PhysicsWorld { body.velocity -= normal_velocity * (1.0 + combined.restitution); // Apply friction to horizontal (tangent) velocity - let tangent_velocity = body.velocity - contact.normal * body.velocity.dot(contact.normal); + let tangent_velocity = + body.velocity - contact.normal * body.velocity.dot(contact.normal); let tangent_speed = tangent_velocity.length(); if tangent_speed > 0.0001 { let friction_factor = 1.0 - combined.friction; body.velocity = contact.normal * body.velocity.dot(contact.normal) - + tangent_velocity * friction_factor; + + tangent_velocity * friction_factor; } } } } } } - } /// Resolve collisions between bodies @@ -752,7 +777,14 @@ impl PhysicsWorld { let (collider_a, collider_b, is_static_a, is_static_b, filter_a, filter_b) = { let body_a = &self.bodies[key_a]; let body_b = &self.bodies[key_b]; - (body_a.collider, body_b.collider, body_a.is_static(), body_b.is_static(), body_a.filter, body_b.filter) + ( + body_a.collider, + body_b.collider, + body_a.is_static(), + body_b.is_static(), + body_a.filter, + body_b.filter, + ) }; // Skip if both bodies are static @@ -768,9 +800,7 @@ impl PhysicsWorld { // Check for collision based on collider types // The contact normal convention: points FROM body A TOWARD body B let contact = match (&collider_a, &collider_b) { - (Collider::Sphere(a), Collider::Sphere(b)) => { - sphere_vs_sphere(a, b) - } + (Collider::Sphere(a), Collider::Sphere(b)) => sphere_vs_sphere(a, b), (Collider::Sphere(sphere), Collider::AABB(aabb)) => { // sphere_vs_aabb returns normal pointing from AABB toward sphere // We want normal from A (sphere) toward B (AABB), so flip it @@ -799,10 +829,19 @@ impl PhysicsWorld { if let Some(contact) = contact { if contact.is_colliding() { self.collision_events.push(CollisionEvent { - kind: CollisionEventKind::BodyVsBody { body_a: key_a, body_b: key_b }, + kind: CollisionEventKind::BodyVsBody { + body_a: key_a, + body_b: key_b, + }, contact: Some(contact), }); - self.resolve_body_pair_collision(key_a, key_b, &contact, is_static_a, is_static_b); + self.resolve_body_pair_collision( + key_a, + key_b, + &contact, + is_static_a, + is_static_b, + ); } } } @@ -864,7 +903,9 @@ impl PhysicsWorld { } // Combine materials from both bodies - let combined = self.bodies[key_a].material.combine(&self.bodies[key_b].material); + let combined = self.bodies[key_a] + .material + .combine(&self.bodies[key_b].material); // Velocity response rules: // - Static bodies: no velocity (implicit) @@ -881,12 +922,14 @@ impl PhysicsWorld { self.bodies[key_a].velocity -= normal_velocity * (1.0 + combined.restitution); // Apply friction to tangent velocity - let tangent_velocity = self.bodies[key_a].velocity - (-contact.normal) * self.bodies[key_a].velocity.dot(-contact.normal); + let tangent_velocity = self.bodies[key_a].velocity + - (-contact.normal) * self.bodies[key_a].velocity.dot(-contact.normal); let tangent_speed = tangent_velocity.length(); if tangent_speed > 0.0001 { let friction_factor = 1.0 - combined.friction; - self.bodies[key_a].velocity = (-contact.normal) * self.bodies[key_a].velocity.dot(-contact.normal) - + tangent_velocity * friction_factor; + self.bodies[key_a].velocity = (-contact.normal) + * self.bodies[key_a].velocity.dot(-contact.normal) + + tangent_velocity * friction_factor; } } } @@ -898,12 +941,14 @@ impl PhysicsWorld { self.bodies[key_b].velocity -= normal_velocity * (1.0 + combined.restitution); // Apply friction to tangent velocity - let tangent_velocity = self.bodies[key_b].velocity - contact.normal * self.bodies[key_b].velocity.dot(contact.normal); + let tangent_velocity = self.bodies[key_b].velocity + - contact.normal * self.bodies[key_b].velocity.dot(contact.normal); let tangent_speed = tangent_velocity.length(); if tangent_speed > 0.0001 { let friction_factor = 1.0 - combined.friction; - self.bodies[key_b].velocity = contact.normal * self.bodies[key_b].velocity.dot(contact.normal) - + tangent_velocity * friction_factor; + self.bodies[key_b].velocity = contact.normal + * self.bodies[key_b].velocity.dot(contact.normal) + + tangent_velocity * friction_factor; } } } @@ -941,12 +986,18 @@ impl PhysicsWorld { if self.active_triggers.contains(&(key, static_idx)) { self.collision_events.push(CollisionEvent { - kind: CollisionEventKind::TriggerStay { body: key, trigger_index: static_idx }, + kind: CollisionEventKind::TriggerStay { + body: key, + trigger_index: static_idx, + }, contact: Some(contact), }); } else { self.collision_events.push(CollisionEvent { - kind: CollisionEventKind::TriggerEnter { body: key, trigger_index: static_idx }, + kind: CollisionEventKind::TriggerEnter { + body: key, + trigger_index: static_idx, + }, contact: Some(contact), }); } @@ -958,12 +1009,16 @@ impl PhysicsWorld { // Emit exit events for pairs that are no longer overlapping for &(body_key, trigger_idx) in &self.active_triggers { if !current_overlaps.contains(&(body_key, trigger_idx)) - && self.bodies.contains_key(body_key) { - self.collision_events.push(CollisionEvent { - kind: CollisionEventKind::TriggerExit { body: body_key, trigger_index: trigger_idx }, - contact: None, - }); - } + && self.bodies.contains_key(body_key) + { + self.collision_events.push(CollisionEvent { + kind: CollisionEventKind::TriggerExit { + body: body_key, + trigger_index: trigger_idx, + }, + contact: None, + }); + } } self.active_triggers = current_overlaps; @@ -994,7 +1049,11 @@ mod tests { } /// Helper to create a world with a floor at the given Y position - fn world_with_floor(gravity: f32, floor_y: f32, floor_material: PhysicsMaterial) -> PhysicsWorld { + fn world_with_floor( + gravity: f32, + floor_y: f32, + floor_material: PhysicsMaterial, + ) -> PhysicsWorld { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(gravity)); world.add_static_collider(StaticCollider::floor(floor_y, floor_material)); world @@ -1109,8 +1168,7 @@ mod tests { fn test_floor_collision() { let mut world = world_with_floor(-20.0, 0.0, PhysicsMaterial::CONCRETE); // Sphere starting below the floor (partially penetrating) - let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.3, 0.0, 0.0), 0.5) - .with_gravity(false); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.3, 0.0, 0.0), 0.5).with_gravity(false); let handle = world.add_body(body); world.step(0.016); @@ -1225,8 +1283,7 @@ mod tests { #[test] fn test_gravity_disabled_body() { let mut world = PhysicsWorld::new(); - let body = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5) - .with_gravity(false); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5).with_gravity(false); let handle = world.add_body(body); world.step(1.0); @@ -1253,8 +1310,14 @@ mod tests { let body = world.get_body(handle).unwrap(); // Horizontal velocity should be reduced by friction // Rubber has friction 0.9, so velocity should be significantly reduced - assert!(body.velocity.x < 10.0, "Friction should slow horizontal movement"); - assert!(body.velocity.x < 5.0, "High friction should reduce velocity significantly"); + assert!( + body.velocity.x < 10.0, + "Friction should slow horizontal movement" + ); + assert!( + body.velocity.x < 5.0, + "High friction should reduce velocity significantly" + ); } #[test] @@ -1287,7 +1350,7 @@ mod tests { // Add a wall world.add_static_collider(StaticCollider::plane( - Vec4::new(1.0, 0.0, 0.0, 0.0), // Normal pointing +X + Vec4::new(1.0, 0.0, 0.0, 0.0), // Normal pointing +X 0.0, PhysicsMaterial::METAL, )); @@ -1319,8 +1382,11 @@ mod tests { // Ball should still be between 0 and 10 let ball = world.bodies.values().next().unwrap(); - assert!(ball.position.y >= 0.0 && ball.position.y <= 10.0, - "Ball should be between floor and ceiling, got y={}", ball.position.y); + assert!( + ball.position.y >= 0.0 && ball.position.y <= 10.0, + "Ball should be between floor and ceiling, got y={}", + ball.position.y + ); } // ====== Generic Body Method Tests ====== @@ -1466,7 +1532,10 @@ mod tests { let body = world.get_body(handle).unwrap(); // Body should have moved down (no floor collision) - assert!(body.position.y < 0.5, "Body should fall through trigger zone"); + assert!( + body.position.y < 0.5, + "Body should fall through trigger zone" + ); } #[test] @@ -1672,10 +1741,10 @@ mod tests { // Create world with bounded floor (W bounds: -2 to +2) let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( - 0.0, // y: floor surface at y=0 - 10.0, // half_size_xz (X/Z from -10 to +10) - 2.0, // half_size_w (W from -2 to +2) - 5.0, // thickness + 0.0, // y: floor surface at y=0 + 10.0, // half_size_xz (X/Z from -10 to +10) + 2.0, // half_size_w (W from -2 to +2) + 5.0, // thickness PhysicsMaterial::CONCRETE, )); @@ -1727,10 +1796,10 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( - 0.0, // floor at y=0 - 10.0, // half_size_xz - 2.0, // half_size_w (W: -2 to +2) - 5.0, // thickness + 0.0, // floor at y=0 + 10.0, // half_size_xz + 2.0, // half_size_w (W: -2 to +2) + 5.0, // thickness PhysicsMaterial::CONCRETE, )); @@ -1779,10 +1848,10 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( - 0.0, // floor at y=0 - 10.0, // half_size_xz - 2.0, // half_size_w (W: -2 to +2) - 5.0, // thickness (floor bottom at y=-5) + 0.0, // floor at y=0 + 10.0, // half_size_xz + 2.0, // half_size_w (W: -2 to +2) + 5.0, // thickness (floor bottom at y=-5) PhysicsMaterial::CONCRETE, )); @@ -1816,10 +1885,10 @@ mod tests { // Make sure the edge falling fix doesn't break normal jumping let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( - 0.0, // floor at y=0 - 10.0, // half_size_xz - 10.0, // half_size_w (large, body stays over floor) - 5.0, // thickness + 0.0, // floor at y=0 + 10.0, // half_size_xz + 10.0, // half_size_w (large, body stays over floor) + 5.0, // thickness PhysicsMaterial::CONCRETE, )); @@ -1835,7 +1904,10 @@ mod tests { // Jump world.body_jump(key, 8.0); - assert!(!world.body_is_grounded(key), "Body should be airborne after jump"); + assert!( + !world.body_is_grounded(key), + "Body should be airborne after jump" + ); // Let physics run - body should go up then land back on floor for _ in 0..100 { @@ -1863,10 +1935,10 @@ mod tests { // Kinematic body in center of floor should work normally let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( - 0.0, // floor at y=0 - 10.0, // half_size_xz - 10.0, // half_size_w - 5.0, // thickness + 0.0, // floor at y=0 + 10.0, // half_size_xz + 10.0, // half_size_w + 5.0, // thickness PhysicsMaterial::CONCRETE, )); @@ -1882,7 +1954,10 @@ mod tests { } // Body should be grounded on floor - assert!(world.body_is_grounded(key), "Body should be grounded on floor center"); + assert!( + world.body_is_grounded(key), + "Body should be grounded on floor center" + ); let final_y = world.body_position(key).unwrap().y; assert!( @@ -1926,7 +2001,9 @@ mod tests { assert!( diff < 0.01, "Fixed timestep should produce consistent results. A: {}, B: {}, diff: {}", - pos_a.y, pos_b.y, diff + pos_a.y, + pos_b.y, + diff ); } @@ -1979,12 +2056,18 @@ mod tests { let body = world.get_body(key).unwrap(); // 1 step at 1/60s with velocity 60 = 1.0 unit moved - assert!((body.position.x - 1.0).abs() < 0.01, - "Should have moved 1 unit after 1 fixed step, got {}", body.position.x); + assert!( + (body.position.x - 1.0).abs() < 0.01, + "Should have moved 1 unit after 1 fixed step, got {}", + body.position.x + ); // Remainder should be ~8.33ms - assert!(world.accumulator > 0.008 && world.accumulator < 0.009, - "Accumulator should carry remainder: {}", world.accumulator); + assert!( + world.accumulator > 0.008 && world.accumulator < 0.009, + "Accumulator should carry remainder: {}", + world.accumulator + ); } #[test] @@ -1998,8 +2081,11 @@ mod tests { // Update with half a fixed step world.update(0.5 / 60.0); let alpha = world.interpolation_alpha(); - assert!((alpha - 0.5).abs() < 0.01, - "Alpha should be ~0.5, got {}", alpha); + assert!( + (alpha - 0.5).abs() < 0.01, + "Alpha should be ~0.5, got {}", + alpha + ); } #[test] @@ -2166,7 +2252,10 @@ mod tests { assert!(!events.is_empty(), "First drain should have events"); let events = world.drain_collision_events(); - assert!(events.is_empty(), "Second drain without step should be empty"); + assert!( + events.is_empty(), + "Second drain without step should be empty" + ); } #[test] @@ -2174,16 +2263,16 @@ mod tests { let mut world = world_with_floor(0.0, 0.0, PhysicsMaterial::CONCRETE); // Sphere penetrating the floor (center at y=0.3, radius=0.5, floor at y=0) - let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.3, 0.0, 0.0), 0.5) - .with_gravity(false); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.3, 0.0, 0.0), 0.5).with_gravity(false); let key = world.add_body(body); world.step(0.016); let events = world.drain_collision_events(); - let static_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, CollisionEventKind::BodyVsStatic { .. }) - }).collect(); + let static_events: Vec<_> = events + .iter() + .filter(|e| matches!(e.kind, CollisionEventKind::BodyVsStatic { .. })) + .collect(); assert_eq!(static_events.len(), 1); if let CollisionEventKind::BodyVsStatic { body, static_index } = static_events[0].kind { @@ -2198,19 +2287,16 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Two overlapping spheres (centers 0.8 apart, combined radii 1.0) - let key_a = world.add_body( - RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) - ); - let key_b = world.add_body( - RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5) - ); + let key_a = world.add_body(RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5)); + let key_b = world.add_body(RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5)); world.step(0.016); let events = world.drain_collision_events(); - let body_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, CollisionEventKind::BodyVsBody { .. }) - }).collect(); + let body_events: Vec<_> = events + .iter() + .filter(|e| matches!(e.kind, CollisionEventKind::BodyVsBody { .. })) + .collect(); assert_eq!(body_events.len(), 1); if let CollisionEventKind::BodyVsBody { body_a, body_b } = body_events[0].kind { @@ -2231,25 +2317,35 @@ mod tests { Vec4::ZERO, Vec4::new(2.0, 2.0, 2.0, 2.0), PhysicsMaterial::CONCRETE, - ).with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + ) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); world.add_static_collider(trigger); // Player body inside the trigger zone let key = world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::player()) - .with_gravity(false) + .with_gravity(false), ); world.step(0.016); let events = world.drain_collision_events(); - let enter_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, CollisionEventKind::TriggerEnter { .. }) - }).collect(); + let enter_events: Vec<_> = events + .iter() + .filter(|e| matches!(e.kind, CollisionEventKind::TriggerEnter { .. })) + .collect(); - assert_eq!(enter_events.len(), 1, "Should have exactly one TriggerEnter"); - if let CollisionEventKind::TriggerEnter { body, trigger_index } = enter_events[0].kind { + assert_eq!( + enter_events.len(), + 1, + "Should have exactly one TriggerEnter" + ); + if let CollisionEventKind::TriggerEnter { + body, + trigger_index, + } = enter_events[0].kind + { assert_eq!(body, key); assert_eq!(trigger_index, 0); } @@ -2266,29 +2362,37 @@ mod tests { Vec4::ZERO, Vec4::new(2.0, 2.0, 2.0, 2.0), PhysicsMaterial::CONCRETE, - ).with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + ) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); world.add_static_collider(trigger); world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::player()) - .with_gravity(false) + .with_gravity(false), ); // First step: Enter world.step(0.016); let events = world.drain_collision_events(); - assert!(events.iter().any(|e| matches!(e.kind, CollisionEventKind::TriggerEnter { .. }))); + assert!(events + .iter() + .any(|e| matches!(e.kind, CollisionEventKind::TriggerEnter { .. }))); // Second step: Stay (body hasn't moved) world.step(0.016); let events = world.drain_collision_events(); - let stay_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, CollisionEventKind::TriggerStay { .. }) - }).collect(); + let stay_events: Vec<_> = events + .iter() + .filter(|e| matches!(e.kind, CollisionEventKind::TriggerStay { .. })) + .collect(); - assert_eq!(stay_events.len(), 1, "Should have TriggerStay on second step"); + assert_eq!( + stay_events.len(), + 1, + "Should have TriggerStay on second step" + ); } #[test] @@ -2302,38 +2406,54 @@ mod tests { Vec4::ZERO, Vec4::new(1.0, 1.0, 1.0, 1.0), PhysicsMaterial::CONCRETE, - ).with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + ) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); world.add_static_collider(trigger); // Body starts inside trigger let key = world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::player()) - .with_gravity(false) + .with_gravity(false), ); // First step: Enter world.step(0.016); let events = world.drain_collision_events(); - assert!(events.iter().any(|e| matches!(e.kind, CollisionEventKind::TriggerEnter { .. })), - "Should have TriggerEnter on first step"); + assert!( + events + .iter() + .any(|e| matches!(e.kind, CollisionEventKind::TriggerEnter { .. })), + "Should have TriggerEnter on first step" + ); // Teleport body far outside trigger - world.get_body_mut(key).unwrap().set_position(Vec4::new(10.0, 0.0, 0.0, 0.0)); + world + .get_body_mut(key) + .unwrap() + .set_position(Vec4::new(10.0, 0.0, 0.0, 0.0)); // Second step: should detect exit world.step(0.016); let events = world.drain_collision_events(); - let exit_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, CollisionEventKind::TriggerExit { .. }) - }).collect(); + let exit_events: Vec<_> = events + .iter() + .filter(|e| matches!(e.kind, CollisionEventKind::TriggerExit { .. })) + .collect(); - assert_eq!(exit_events.len(), 1, "Should have TriggerExit after body leaves"); + assert_eq!( + exit_events.len(), + 1, + "Should have TriggerExit after body leaves" + ); if let CollisionEventKind::TriggerExit { body, .. } = exit_events[0].kind { assert_eq!(body, key); } - assert!(exit_events[0].contact.is_none(), "TriggerExit should have no contact"); + assert!( + exit_events[0].contact.is_none(), + "TriggerExit should have no contact" + ); } #[test] @@ -2347,28 +2467,36 @@ mod tests { Vec4::ZERO, Vec4::new(2.0, 2.0, 2.0, 2.0), PhysicsMaterial::CONCRETE, - ).with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + ) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); world.add_static_collider(trigger); // Enemy body inside trigger (ENEMY layer, not PLAYER) world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::enemy()) - .with_gravity(false) + .with_gravity(false), ); world.step(0.016); let events = world.drain_collision_events(); - let trigger_events: Vec<_> = events.iter().filter(|e| { - matches!(e.kind, - CollisionEventKind::TriggerEnter { .. } | - CollisionEventKind::TriggerStay { .. } | - CollisionEventKind::TriggerExit { .. } - ) - }).collect(); + let trigger_events: Vec<_> = events + .iter() + .filter(|e| { + matches!( + e.kind, + CollisionEventKind::TriggerEnter { .. } + | CollisionEventKind::TriggerStay { .. } + | CollisionEventKind::TriggerExit { .. } + ) + }) + .collect(); - assert!(trigger_events.is_empty(), "Trigger should not detect enemy bodies"); + assert!( + trigger_events.is_empty(), + "Trigger should not detect enemy bodies" + ); } #[test] @@ -2382,7 +2510,8 @@ mod tests { Vec4::ZERO, Vec4::new(2.0, 2.0, 2.0, 2.0), PhysicsMaterial::CONCRETE, - ).with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + ) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); world.add_static_collider(trigger); // Player moving through the trigger should not be stopped @@ -2390,15 +2519,18 @@ mod tests { RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::player()) .with_velocity(Vec4::new(10.0, 0.0, 0.0, 0.0)) - .with_gravity(false) + .with_gravity(false), ); world.step(0.1); let body = world.get_body(key).unwrap(); // Body should have moved freely through trigger (velocity * dt = 1.0) - assert!((body.position.x - 1.0).abs() < 0.01, - "Body should pass through trigger without physical response. Got x={}", body.position.x); + assert!( + (body.position.x - 1.0).abs() < 0.01, + "Body should pass through trigger without physical response. Got x={}", + body.position.x + ); } #[test] @@ -2407,9 +2539,7 @@ mod tests { // generating a BodyVsStatic event every step let mut world = world_with_floor(-20.0, 0.0, PhysicsMaterial::CONCRETE); - world.add_body( - RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) - ); + world.add_body(RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5)); // Run multiple steps without draining world.step(0.016); @@ -2418,7 +2548,11 @@ mod tests { let events = world.drain_collision_events(); // Should have accumulated events from all 3 steps (gravity recollides every step) - assert!(events.len() >= 3, "Events should accumulate across steps, got {}", events.len()); + assert!( + events.len() >= 3, + "Events should accumulate across steps, got {}", + events.len() + ); } // ===== Spatial Query Tests ===== @@ -2428,24 +2562,20 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Add body at origin - let key = world.add_body( - RigidBody4D::new_sphere(Vec4::ZERO, 0.5) - .with_gravity(false) - ); + let key = world.add_body(RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_gravity(false)); // Query with center at (5,0,0,0), radius 10 - should find the body - let results = world.query_sphere( - Vec4::new(5.0, 0.0, 0.0, 0.0), - 10.0, - CollisionLayer::ALL, - ); + let results = world.query_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 10.0, CollisionLayer::ALL); assert_eq!(results.len(), 1, "Should find one body in range"); match results[0].target { RayTarget::Body(found_key) => assert_eq!(found_key, key), _ => panic!("Expected Body target"), } - assert!((results[0].distance - 5.0).abs() < 0.001, "Distance should be 5.0"); + assert!( + (results[0].distance - 5.0).abs() < 0.001, + "Distance should be 5.0" + ); } #[test] @@ -2453,17 +2583,10 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Add body at origin - world.add_body( - RigidBody4D::new_sphere(Vec4::ZERO, 0.5) - .with_gravity(false) - ); + world.add_body(RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_gravity(false)); // Query with center at (20,0,0,0), radius 5 - too far away - let results = world.query_sphere( - Vec4::new(20.0, 0.0, 0.0, 0.0), - 5.0, - CollisionLayer::ALL, - ); + let results = world.query_sphere(Vec4::new(20.0, 0.0, 0.0, 0.0), 5.0, CollisionLayer::ALL); assert!(results.is_empty(), "Should not find body out of range"); } @@ -2478,17 +2601,16 @@ mod tests { world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::enemy()) - .with_gravity(false) + .with_gravity(false), ); // Query with PLAYER layer mask - should not find the ENEMY body - let results = world.query_sphere( - Vec4::ZERO, - 10.0, - CollisionLayer::PLAYER, - ); + let results = world.query_sphere(Vec4::ZERO, 10.0, CollisionLayer::PLAYER); - assert!(results.is_empty(), "Should not find body with non-matching layer"); + assert!( + results.is_empty(), + "Should not find body with non-matching layer" + ); } #[test] @@ -2501,15 +2623,11 @@ mod tests { world.add_body( RigidBody4D::new_sphere(Vec4::ZERO, 0.5) .with_filter(CollisionFilter::enemy()) - .with_gravity(false) + .with_gravity(false), ); // Query with ENEMY layer mask - should find the body - let results = world.query_sphere( - Vec4::ZERO, - 10.0, - CollisionLayer::ENEMY, - ); + let results = world.query_sphere(Vec4::ZERO, 10.0, CollisionLayer::ENEMY); assert_eq!(results.len(), 1, "Should find body with matching layer"); } @@ -2520,28 +2638,30 @@ mod tests { // Add bodies at different distances world.add_body( - RigidBody4D::new_sphere(Vec4::new(10.0, 0.0, 0.0, 0.0), 0.5) - .with_gravity(false) + RigidBody4D::new_sphere(Vec4::new(10.0, 0.0, 0.0, 0.0), 0.5).with_gravity(false), ); world.add_body( - RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5) - .with_gravity(false) + RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5).with_gravity(false), ); world.add_body( - RigidBody4D::new_sphere(Vec4::new(15.0, 0.0, 0.0, 0.0), 0.5) - .with_gravity(false) + RigidBody4D::new_sphere(Vec4::new(15.0, 0.0, 0.0, 0.0), 0.5).with_gravity(false), ); - let results = world.query_sphere( - Vec4::ZERO, - 20.0, - CollisionLayer::ALL, - ); + let results = world.query_sphere(Vec4::ZERO, 20.0, CollisionLayer::ALL); assert_eq!(results.len(), 3); - assert!((results[0].distance - 5.0).abs() < 0.001, "Nearest should be at distance 5"); - assert!((results[1].distance - 10.0).abs() < 0.001, "Second should be at distance 10"); - assert!((results[2].distance - 15.0).abs() < 0.001, "Farthest should be at distance 15"); + assert!( + (results[0].distance - 5.0).abs() < 0.001, + "Nearest should be at distance 5" + ); + assert!( + (results[1].distance - 10.0).abs() < 0.001, + "Second should be at distance 10" + ); + assert!( + (results[2].distance - 15.0).abs() < 0.001, + "Farthest should be at distance 15" + ); } #[test] @@ -2550,23 +2670,24 @@ mod tests { // Add body at distance 5 from origin world.add_body( - RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5) - .with_gravity(false) + RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5).with_gravity(false), ); // Query with radius 10, with_falloff = true - let results = world.query_area_effect( - Vec4::ZERO, - 10.0, - CollisionLayer::ALL, - true, - ); + let results = world.query_area_effect(Vec4::ZERO, 10.0, CollisionLayer::ALL, true); assert_eq!(results.len(), 1); // Falloff = 1.0 - (5.0 / 10.0) = 0.5 - assert!((results[0].falloff - 0.5).abs() < 0.001, "Falloff should be 0.5, got {}", results[0].falloff); + assert!( + (results[0].falloff - 0.5).abs() < 0.001, + "Falloff should be 0.5, got {}", + results[0].falloff + ); // Direction should point from origin toward body (positive X) - assert!(results[0].direction.x > 0.9, "Direction should be positive X"); + assert!( + results[0].direction.x > 0.9, + "Direction should be positive X" + ); } #[test] @@ -2575,21 +2696,18 @@ mod tests { // Add body at distance 5 from origin world.add_body( - RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5) - .with_gravity(false) + RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 0.5).with_gravity(false), ); // Query with radius 10, with_falloff = false - let results = world.query_area_effect( - Vec4::ZERO, - 10.0, - CollisionLayer::ALL, - false, - ); + let results = world.query_area_effect(Vec4::ZERO, 10.0, CollisionLayer::ALL, false); assert_eq!(results.len(), 1); // Falloff should be 1.0 when with_falloff is false - assert!((results[0].falloff - 1.0).abs() < 0.001, "Falloff should be 1.0 without falloff"); + assert!( + (results[0].falloff - 1.0).abs() < 0.001, + "Falloff should be 1.0 without falloff" + ); } #[test] @@ -2597,23 +2715,22 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Add body at exact center - world.add_body( - RigidBody4D::new_sphere(Vec4::ZERO, 0.5) - .with_gravity(false) - ); + world.add_body(RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_gravity(false)); - let results = world.query_area_effect( - Vec4::ZERO, - 10.0, - CollisionLayer::ALL, - true, - ); + let results = world.query_area_effect(Vec4::ZERO, 10.0, CollisionLayer::ALL, true); assert_eq!(results.len(), 1); // Falloff should be 1.0 at center - assert!((results[0].falloff - 1.0).abs() < 0.001, "Falloff at center should be 1.0"); + assert!( + (results[0].falloff - 1.0).abs() < 0.001, + "Falloff at center should be 1.0" + ); // Direction should be Y (fallback for zero distance) - assert_eq!(results[0].direction, Vec4::Y, "Direction should be Y at zero distance"); + assert_eq!( + results[0].direction, + Vec4::Y, + "Direction should be Y at zero distance" + ); } #[test] @@ -2635,13 +2752,11 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Add a wall between the two points - world.add_static_collider( - StaticCollider::aabb( - Vec4::new(5.0, 0.0, 0.0, 0.0), - Vec4::new(1.0, 10.0, 10.0, 10.0), - PhysicsMaterial::CONCRETE, - ) - ); + world.add_static_collider(StaticCollider::aabb( + Vec4::new(5.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 10.0, 10.0, 10.0), + PhysicsMaterial::CONCRETE, + )); let blocked = world.line_of_sight( Vec4::ZERO, @@ -2649,7 +2764,10 @@ mod tests { CollisionLayer::STATIC, ); - assert!(!blocked, "Line of sight should be blocked by static collider"); + assert!( + !blocked, + "Line of sight should be blocked by static collider" + ); } #[test] @@ -2662,7 +2780,7 @@ mod tests { world.add_body( RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 1.0) .with_filter(CollisionFilter::enemy()) - .with_gravity(false) + .with_gravity(false), ); // Check line of sight blocking only STATIC layer @@ -2672,7 +2790,10 @@ mod tests { CollisionLayer::STATIC, ); - assert!(clear, "Line of sight should be clear when obstacle is on different layer"); + assert!( + clear, + "Line of sight should be clear when obstacle is on different layer" + ); } #[test] @@ -2685,7 +2806,7 @@ mod tests { world.add_body( RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 1.0) .with_filter(CollisionFilter::enemy()) - .with_gravity(false) + .with_gravity(false), ); // Check line of sight blocking ENEMY layer @@ -2703,11 +2824,7 @@ mod tests { let world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Same start and end point - let clear = world.line_of_sight( - Vec4::ZERO, - Vec4::ZERO, - CollisionLayer::ALL, - ); + let clear = world.line_of_sight(Vec4::ZERO, Vec4::ZERO, CollisionLayer::ALL); assert!(clear, "Line of sight to same point should be clear"); } @@ -2717,25 +2834,22 @@ mod tests { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Add a static collider - world.add_static_collider( - StaticCollider::aabb( - Vec4::new(3.0, 0.0, 0.0, 0.0), - Vec4::new(1.0, 1.0, 1.0, 1.0), - PhysicsMaterial::CONCRETE, - ) - ); + world.add_static_collider(StaticCollider::aabb( + Vec4::new(3.0, 0.0, 0.0, 0.0), + Vec4::new(1.0, 1.0, 1.0, 1.0), + PhysicsMaterial::CONCRETE, + )); - let results = world.query_sphere( - Vec4::ZERO, - 10.0, - CollisionLayer::STATIC, - ); + let results = world.query_sphere(Vec4::ZERO, 10.0, CollisionLayer::STATIC); assert_eq!(results.len(), 1, "Should find static collider"); match results[0].target { RayTarget::Static(idx) => assert_eq!(idx, 0), _ => panic!("Expected Static target"), } - assert!((results[0].distance - 3.0).abs() < 0.001, "Distance should be 3.0"); + assert!( + (results[0].distance - 3.0).abs() < 0.001, + "Distance should be 3.0" + ); } } diff --git a/crates/rust4d_physics/tests/edge_cases.rs b/crates/rust4d_physics/tests/edge_cases.rs index 81bc692..4661b3e 100644 --- a/crates/rust4d_physics/tests/edge_cases.rs +++ b/crates/rust4d_physics/tests/edge_cases.rs @@ -94,7 +94,10 @@ fn stale_key_does_not_alias_new_body() { let key2 = world.add_body(RigidBody4D::new_sphere(Vec4::new(2.0, 0.0, 0.0, 0.0), 0.5)); // key1 is stale; key2 is valid - assert!(world.get_body(key1).is_none(), "Stale key must not resolve to new body"); + assert!( + world.get_body(key1).is_none(), + "Stale key must not resolve to new body" + ); assert!(world.get_body(key2).is_some()); assert_ne!(key1, key2); } @@ -234,10 +237,7 @@ fn zero_movement_then_step_no_drift() { #[test] fn raycast_very_large_max_distance() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); - let key = world.add_body(RigidBody4D::new_sphere( - Vec4::new(5.0, 0.0, 0.0, 0.0), - 1.0, - )); + let key = world.add_body(RigidBody4D::new_sphere(Vec4::new(5.0, 0.0, 0.0, 0.0), 1.0)); let ray = Ray4D::new(Vec4::ZERO, Vec4::X); let hits = world.raycast(&ray, 1e12, CollisionLayer::ALL); @@ -257,20 +257,14 @@ fn raycast_very_large_max_distance() { #[test] fn raycast_zero_max_distance() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); - world.add_body(RigidBody4D::new_sphere( - Vec4::new(0.5, 0.0, 0.0, 0.0), - 1.0, - )); + world.add_body(RigidBody4D::new_sphere(Vec4::new(0.5, 0.0, 0.0, 0.0), 1.0)); let ray = Ray4D::new(Vec4::ZERO, Vec4::X); // The ray origin is inside the sphere, so the exit point distance is > 0. // With max_distance = 0, it should miss because hit.distance > 0. let hits = world.raycast(&ray, 0.0, CollisionLayer::ALL); // Even if origin is inside, exit point distance > 0, so with max_distance=0 nothing should match - assert!( - hits.is_empty(), - "Zero max_distance should yield no hits" - ); + assert!(hits.is_empty(), "Zero max_distance should yield no hits"); } /// Ray originating inside a sphere should hit the exit point. @@ -355,10 +349,7 @@ fn raycast_very_small_sphere_off_axis_misses() { fn raycast_nearest_large_max_distance() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); - let near = world.add_body(RigidBody4D::new_sphere( - Vec4::new(3.0, 0.0, 0.0, 0.0), - 0.5, - )); + let near = world.add_body(RigidBody4D::new_sphere(Vec4::new(3.0, 0.0, 0.0, 0.0), 0.5)); world.add_body(RigidBody4D::new_sphere( Vec4::new(100.0, 0.0, 0.0, 0.0), 0.5, @@ -384,10 +375,7 @@ fn raycast_hits_body_and_static_sorted() { world.add_static_collider(StaticCollider::floor(0.0, PhysicsMaterial::CONCRETE)); // Body sphere at (0, 5, 0, 0) -- above floor - world.add_body(RigidBody4D::new_sphere( - Vec4::new(0.0, 5.0, 0.0, 0.0), - 1.0, - )); + world.add_body(RigidBody4D::new_sphere(Vec4::new(0.0, 5.0, 0.0, 0.0), 1.0)); // Ray shooting downward from high above let ray = Ray4D::new(Vec4::new(0.0, 20.0, 0.0, 0.0), -Vec4::Y); @@ -410,10 +398,8 @@ fn raycast_hits_body_and_static_sorted() { fn zero_mass_body_collision_no_panic() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); - let body_a = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) - .with_mass(0.0); - let body_b = RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5) - .with_mass(0.0); + let body_a = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5).with_mass(0.0); + let body_b = RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5).with_mass(0.0); world.add_body(body_a); world.add_body(body_b); @@ -443,7 +429,10 @@ fn zero_mass_vs_normal_mass_no_panic() { // Neither body should have NaN position let pos_zero = world.body_position(key_zero).unwrap(); let pos_normal = world.body_position(key_normal).unwrap(); - assert!(!pos_zero.x.is_nan(), "Zero-mass body should not produce NaN"); + assert!( + !pos_zero.x.is_nan(), + "Zero-mass body should not produce NaN" + ); assert!( !pos_normal.x.is_nan(), "Normal body should not produce NaN from zero-mass collision" @@ -498,10 +487,7 @@ fn multiple_triggers_on_same_body() { let indices: Vec = enter_events .iter() .map(|e| { - if let CollisionEventKind::TriggerEnter { - trigger_index, .. - } = e.kind - { + if let CollisionEventKind::TriggerEnter { trigger_index, .. } = e.kind { trigger_index } else { unreachable!() @@ -642,18 +628,9 @@ fn collision_events_report_correct_keys() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // Three non-overlapping bodies -- only first two overlap - let key_a = world.add_body(RigidBody4D::new_sphere( - Vec4::new(0.0, 0.0, 0.0, 0.0), - 0.5, - )); - let key_b = world.add_body(RigidBody4D::new_sphere( - Vec4::new(0.8, 0.0, 0.0, 0.0), - 0.5, - )); - let key_c = world.add_body(RigidBody4D::new_sphere( - Vec4::new(10.0, 0.0, 0.0, 0.0), - 0.5, - )); + let key_a = world.add_body(RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5)); + let key_b = world.add_body(RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5)); + let key_c = world.add_body(RigidBody4D::new_sphere(Vec4::new(10.0, 0.0, 0.0, 0.0), 0.5)); world.step(0.016); let events = world.drain_collision_events(); @@ -684,10 +661,7 @@ fn removed_body_during_simulation_no_panic() { let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); let key_a = world.add_body(RigidBody4D::new_sphere(Vec4::ZERO, 0.5)); - let key_b = world.add_body(RigidBody4D::new_sphere( - Vec4::new(0.8, 0.0, 0.0, 0.0), - 0.5, - )); + let key_b = world.add_body(RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5)); // Step once to establish collision world.step(0.016); @@ -748,7 +722,9 @@ fn raycast_empty_world() { let ray = Ray4D::new(Vec4::ZERO, Vec4::X); let hits = world.raycast(&ray, 100.0, CollisionLayer::ALL); assert!(hits.is_empty()); - assert!(world.raycast_nearest(&ray, 100.0, CollisionLayer::ALL).is_none()); + assert!(world + .raycast_nearest(&ray, 100.0, CollisionLayer::ALL) + .is_none()); } /// A dynamic body with gravity disabled colliding with another should still produce events. diff --git a/crates/rust4d_render/src/camera4d.rs b/crates/rust4d_render/src/camera4d.rs index df9913c..e87bbe9 100644 --- a/crates/rust4d_render/src/camera4d.rs +++ b/crates/rust4d_render/src/camera4d.rs @@ -9,8 +9,8 @@ //! This design ensures intuitive movement behavior: walking forward stays //! horizontal regardless of 4D rotation state. -use rust4d_math::{Vec4, Rotor4, RotationPlane, mat4}; use rust4d_input::CameraControl; +use rust4d_math::{mat4, RotationPlane, Rotor4, Vec4}; /// 4D Camera using Engine4D-style architecture /// @@ -157,7 +157,8 @@ impl Camera4D { /// /// This matches Engine4D's `accel = camMatrix * accel` fn move_camera(&mut self, forward: f32, right: f32, up: f32, ana: f32) { - if forward.abs() < 0.0001 && right.abs() < 0.0001 && up.abs() < 0.0001 && ana.abs() < 0.0001 { + if forward.abs() < 0.0001 && right.abs() < 0.0001 && up.abs() < 0.0001 && ana.abs() < 0.0001 + { return; } @@ -318,7 +319,11 @@ mod tests { // Up should still be purely +Y (or close to it) let up = cam.up(); - assert!(up.y > 0.99, "Up should still be +Y after 4D rotation, got {:?}", up); + assert!( + up.y > 0.99, + "Up should still be +Y after 4D rotation, got {:?}", + up + ); assert!(up.x.abs() < EPSILON, "Up.x should be ~0, got {}", up.x); assert!(up.z.abs() < EPSILON, "Up.z should be ~0, got {}", up.z); assert!(up.w.abs() < EPSILON, "Up.w should be ~0, got {}", up.w); @@ -335,10 +340,18 @@ mod tests { let fwd = cam.forward(); // Up should be tilted (Y component < 1) - assert!(up.y < 0.95, "Up should be tilted after pitch, got up.y={}", up.y); + assert!( + up.y < 0.95, + "Up should be tilted after pitch, got up.y={}", + up.y + ); // Forward should point up (positive Y) - assert!(fwd.y > 0.5, "Forward should point up after pitch, got fwd.y={}", fwd.y); + assert!( + fwd.y > 0.5, + "Forward should point up after pitch, got fwd.y={}", + fwd.y + ); } #[test] @@ -355,7 +368,11 @@ mod tests { println!("Forward after 90° yaw: {:?}", fwd); // Y should still be 0 (yaw doesn't affect pitch) - assert!(fwd.y.abs() < EPSILON, "Forward.y should be ~0 after pure yaw, got {}", fwd.y); + assert!( + fwd.y.abs() < EPSILON, + "Forward.y should be ~0 after pure yaw, got {}", + fwd.y + ); } #[test] @@ -372,8 +389,11 @@ mod tests { cam.move_local_xz(1.0, 0.0); // Y should be unchanged (movement stays horizontal!) - assert!(cam.position.y.abs() < EPSILON, - "Forward movement should not affect Y after 4D rotation, got Y={}", cam.position.y); + assert!( + cam.position.y.abs() < EPSILON, + "Forward movement should not affect Y after 4D rotation, got Y={}", + cam.position.y + ); } #[test] @@ -388,8 +408,11 @@ mod tests { cam.move_local_xz(1.0, 0.0); // Y should be positive (moving up because we're pitched up) - assert!(cam.position.y > 0.5, - "Forward movement should affect Y when pitched, got Y={}", cam.position.y); + assert!( + cam.position.y > 0.5, + "Forward movement should affect Y when pitched, got Y={}", + cam.position.y + ); } #[test] @@ -407,8 +430,16 @@ mod tests { let fwd = cam.forward(); let up = cam.up(); - assert!(approx_eq(fwd.z, -1.0), "Forward should be -Z after reset, got {:?}", fwd); - assert!(approx_eq(up.y, 1.0), "Up should be +Y after reset, got {:?}", up); + assert!( + approx_eq(fwd.z, -1.0), + "Forward should be -Z after reset, got {:?}", + fwd + ); + assert!( + approx_eq(up.y, 1.0), + "Up should be +Y after reset, got {:?}", + up + ); } #[test] @@ -419,8 +450,11 @@ mod tests { cam.rotate_3d(0.0, 10.0); // Pitch should be clamped to ~89° (default pitch limit) - assert!(cam.pitch.abs() <= Camera4D::DEFAULT_PITCH_LIMIT + 0.001, - "Pitch should be clamped, got {}", cam.pitch); + assert!( + cam.pitch.abs() <= Camera4D::DEFAULT_PITCH_LIMIT + 0.001, + "Pitch should be clamped, got {}", + cam.pitch + ); } #[test] @@ -438,17 +472,39 @@ mod tests { let ana = cam.ana(); // Check vectors are unit length - assert!((fwd.length() - 1.0).abs() < EPSILON, "Forward not unit: {}", fwd.length()); - assert!((right.length() - 1.0).abs() < EPSILON, "Right not unit: {}", right.length()); - assert!((up.length() - 1.0).abs() < EPSILON, "Up not unit: {}", up.length()); - assert!((ana.length() - 1.0).abs() < EPSILON, "Ana not unit: {}", ana.length()); + assert!( + (fwd.length() - 1.0).abs() < EPSILON, + "Forward not unit: {}", + fwd.length() + ); + assert!( + (right.length() - 1.0).abs() < EPSILON, + "Right not unit: {}", + right.length() + ); + assert!( + (up.length() - 1.0).abs() < EPSILON, + "Up not unit: {}", + up.length() + ); + assert!( + (ana.length() - 1.0).abs() < EPSILON, + "Ana not unit: {}", + ana.length() + ); // Check orthogonality - assert!(fwd.dot(right).abs() < EPSILON, "Fwd not orthogonal to Right"); + assert!( + fwd.dot(right).abs() < EPSILON, + "Fwd not orthogonal to Right" + ); assert!(fwd.dot(up).abs() < EPSILON, "Fwd not orthogonal to Up"); assert!(fwd.dot(ana).abs() < EPSILON, "Fwd not orthogonal to Ana"); assert!(right.dot(up).abs() < EPSILON, "Right not orthogonal to Up"); - assert!(right.dot(ana).abs() < EPSILON, "Right not orthogonal to Ana"); + assert!( + right.dot(ana).abs() < EPSILON, + "Right not orthogonal to Ana" + ); assert!(up.dot(ana).abs() < EPSILON, "Up not orthogonal to Ana"); } @@ -465,7 +521,11 @@ mod tests { // Up should still be +Y (4D rotation + yaw both preserve Y) let up = cam.up(); - assert!(up.y > 0.99, "Up should be +Y after 4D rotation + yaw, got {:?}", up); + assert!( + up.y > 0.99, + "Up should be +Y after 4D rotation + yaw, got {:?}", + up + ); } #[test] @@ -473,20 +533,26 @@ mod tests { let mut cam = Camera4D::new(); // Apply multiple 4D rotations - cam.rotate_w(FRAC_PI_2); // Look into W + cam.rotate_w(FRAC_PI_2); // Look into W cam.rotate_xw(FRAC_PI_4); // Tilt in XW // Y axis should still be preserved let up = cam.up(); - assert!(up.y > 0.99, "Up should be +Y after combined 4D rotations, got {:?}", up); + assert!( + up.y > 0.99, + "Up should be +Y after combined 4D rotations, got {:?}", + up + ); // But forward should be in a different direction let fwd = cam.forward(); println!("Forward after combined 4D rotations: {:?}", fwd); // Forward should have W component (looking into 4D) - assert!(fwd.w.abs() > 0.1 || fwd.z.abs() > 0.1, - "Forward should be affected by 4D rotation"); + assert!( + fwd.w.abs() > 0.1 || fwd.z.abs() > 0.1, + "Forward should be affected by 4D rotation" + ); } #[test] @@ -496,7 +562,10 @@ mod tests { // Without any rotation, W movement should go in +W cam.move_w(1.0); - assert!(cam.position.w > 0.9, "W movement should go in +W by default"); + assert!( + cam.position.w > 0.9, + "W movement should go in +W by default" + ); // Reset cam.reset(); @@ -508,8 +577,11 @@ mod tests { // W axis is now rotated, so movement goes in a different direction // But Y should still be unchanged - assert!(cam.position.y.abs() < EPSILON, - "W movement should not affect Y, got Y={}", cam.position.y); + assert!( + cam.position.y.abs() < EPSILON, + "W movement should not affect Y, got Y={}", + cam.position.y + ); } #[test] @@ -518,34 +590,56 @@ mod tests { // Initial ana() should point in +W direction let ana_before = cam.ana(); - eprintln!("ana_before: ({:.4}, {:.4}, {:.4}, {:.4})", - ana_before.x, ana_before.y, ana_before.z, ana_before.w); - assert!(ana_before.w > 0.9, - "Initial ana should be ~(0,0,0,1), got W={}", ana_before.w); - assert!(ana_before.x.abs() < 0.1, - "Initial ana X should be ~0, got {}", ana_before.x); + eprintln!( + "ana_before: ({:.4}, {:.4}, {:.4}, {:.4})", + ana_before.x, ana_before.y, ana_before.z, ana_before.w + ); + assert!( + ana_before.w > 0.9, + "Initial ana should be ~(0,0,0,1), got W={}", + ana_before.w + ); + assert!( + ana_before.x.abs() < 0.1, + "Initial ana X should be ~0, got {}", + ana_before.x + ); // After 90° rotation in the ZW plane (via rotate_w), ana should change cam.rotate_w(FRAC_PI_2); let ana_after = cam.ana(); - eprintln!("ana_after: ({:.4}, {:.4}, {:.4}, {:.4})", - ana_after.x, ana_after.y, ana_after.z, ana_after.w); + eprintln!( + "ana_after: ({:.4}, {:.4}, {:.4}, {:.4})", + ana_after.x, ana_after.y, ana_after.z, ana_after.w + ); // After 90° ZW rotation, the W axis should point along Z - assert!(ana_after.w.abs() < 0.1, - "After 90° rotation, W component should be ~0, got {}", ana_after.w); - assert!(ana_after.z.abs() > 0.9, - "After 90° rotation, Z component should be ~±1, got {}", ana_after.z); + assert!( + ana_after.w.abs() < 0.1, + "After 90° rotation, W component should be ~0, got {}", + ana_after.w + ); + assert!( + ana_after.z.abs() > 0.9, + "After 90° rotation, Z component should be ~±1, got {}", + ana_after.z + ); // And forward (-Z) should now look into the 4th dimension let fwd = cam.forward(); - assert!(fwd.w.abs() > 0.9, - "After 90° ZW rotation, forward should point along W, got {:?}", fwd); + assert!( + fwd.w.abs() > 0.9, + "After 90° ZW rotation, forward should point along W, got {:?}", + fwd + ); // Y should never be affected by rotate_w (that's the point of SkipY) - assert!(ana_after.y.abs() < 0.1, - "Y should never be affected by rotate_w, got {}", ana_after.y); + assert!( + ana_after.y.abs() < 0.1, + "Y should never be affected by rotate_w, got {}", + ana_after.y + ); } #[test] @@ -561,33 +655,54 @@ mod tests { // === Before any rotation === let ana = cam.ana(); let ana_xzw = project_ana(ana); - eprintln!("Before rotation: ana=({:.2},{:.2},{:.2},{:.2}) projected=({:.2},{:.2},{:.2},{:.2})", - ana.x, ana.y, ana.z, ana.w, ana_xzw.x, ana_xzw.y, ana_xzw.z, ana_xzw.w); + eprintln!( + "Before rotation: ana=({:.2},{:.2},{:.2},{:.2}) projected=({:.2},{:.2},{:.2},{:.2})", + ana.x, ana.y, ana.z, ana.w, ana_xzw.x, ana_xzw.y, ana_xzw.z, ana_xzw.w + ); // W movement should go in +W direction assert!(ana_xzw.w > 0.9, "Before rotation, W movement should be +W"); - assert!(ana_xzw.x.abs() < 0.1, "Before rotation, X component should be ~0"); + assert!( + ana_xzw.x.abs() < 0.1, + "Before rotation, X component should be ~0" + ); // === After 90° rotation === cam.rotate_w(FRAC_PI_2); let ana = cam.ana(); let ana_xzw = project_ana(ana); - eprintln!("After 90° rotation: ana=({:.2},{:.2},{:.2},{:.2}) projected=({:.2},{:.2},{:.2},{:.2})", - ana.x, ana.y, ana.z, ana.w, ana_xzw.x, ana_xzw.y, ana_xzw.z, ana_xzw.w); + eprintln!( + "After 90° rotation: ana=({:.2},{:.2},{:.2},{:.2}) projected=({:.2},{:.2},{:.2},{:.2})", + ana.x, ana.y, ana.z, ana.w, ana_xzw.x, ana_xzw.y, ana_xzw.z, ana_xzw.w + ); // W movement should now go in +Z or -Z direction (ZW rotation) - assert!(ana_xzw.w.abs() < 0.1, "After 90° rotation, W movement should NOT go in W direction"); - assert!(ana_xzw.z.abs() > 0.9, "After 90° rotation, W movement should go in Z direction"); + assert!( + ana_xzw.w.abs() < 0.1, + "After 90° rotation, W movement should NOT go in W direction" + ); + assert!( + ana_xzw.z.abs() > 0.9, + "After 90° rotation, W movement should go in Z direction" + ); // Verify: pressing Q after rotation affects Z, not W let w_input = 1.0; let move_from_w = ana_xzw * w_input; - eprintln!("Movement from Q key: ({:.2},{:.2},{:.2},{:.2})", - move_from_w.x, move_from_w.y, move_from_w.z, move_from_w.w); + eprintln!( + "Movement from Q key: ({:.2},{:.2},{:.2},{:.2})", + move_from_w.x, move_from_w.y, move_from_w.z, move_from_w.w + ); - assert!(move_from_w.z.abs() > 0.9, "Q key should affect Z position after rotation"); - assert!(move_from_w.w.abs() < 0.1, "Q key should NOT affect W position after rotation"); + assert!( + move_from_w.z.abs() > 0.9, + "Q key should affect Z position after rotation" + ); + assert!( + move_from_w.w.abs() < 0.1, + "Q key should NOT affect W position after rotation" + ); } #[test] @@ -615,8 +730,8 @@ mod tests { assert!( (slice_w_after - slice_w_before).abs() < EPSILON, "slice_w changed from {} to {} during movement! This would cause morphing.", - slice_w_before, slice_w_after + slice_w_before, + slice_w_after ); } - } diff --git a/crates/rust4d_render/src/egui_overlay/context.rs b/crates/rust4d_render/src/egui_overlay/context.rs index b199175..0923313 100644 --- a/crates/rust4d_render/src/egui_overlay/context.rs +++ b/crates/rust4d_render/src/egui_overlay/context.rs @@ -56,12 +56,21 @@ impl<'a> HudContext<'a> { let pos = Pos2::new(pos[0], pos[1]); let color = rgba_to_color32(color); - Area::new(egui::Id::new(("hud_text", pos.x as i32, pos.y as i32, text))) - .order(Order::Foreground) - .fixed_pos(pos) - .show(self.ctx, |ui| { - ui.label(RichText::new(text).font(FontId::proportional(size)).color(color)); - }); + Area::new(egui::Id::new(( + "hud_text", + pos.x as i32, + pos.y as i32, + text, + ))) + .order(Order::Foreground) + .fixed_pos(pos) + .show(self.ctx, |ui| { + ui.label( + RichText::new(text) + .font(FontId::proportional(size)) + .color(color), + ); + }); } /// Draw text centered at screen position @@ -76,13 +85,22 @@ impl<'a> HudContext<'a> { let pos = Pos2::new(pos[0], pos[1]); let color = rgba_to_color32(color); - Area::new(egui::Id::new(("hud_text_centered", pos.x as i32, pos.y as i32, text))) - .order(Order::Foreground) - .anchor(Align2::CENTER_CENTER, Vec2::ZERO) - .fixed_pos(pos) - .show(self.ctx, |ui| { - ui.label(RichText::new(text).font(FontId::proportional(size)).color(color)); - }); + Area::new(egui::Id::new(( + "hud_text_centered", + pos.x as i32, + pos.y as i32, + text, + ))) + .order(Order::Foreground) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO) + .fixed_pos(pos) + .show(self.ctx, |ui| { + ui.label( + RichText::new(text) + .font(FontId::proportional(size)) + .color(color), + ); + }); } /// Draw a filled rectangle @@ -116,14 +134,23 @@ impl<'a> HudContext<'a> { pub fn rect_outline(&self, pos: [f32; 2], size: [f32; 2], color: [f32; 4], stroke_width: f32) { let color = rgba_to_color32(color); - Area::new(egui::Id::new(("hud_rect_outline", pos[0] as i32, pos[1] as i32))) - .order(Order::Foreground) - .fixed_pos(Pos2::new(pos[0], pos[1])) - .show(self.ctx, |ui| { - let (response, painter) = - ui.allocate_painter(Vec2::new(size[0], size[1]), Sense::hover()); - painter.rect_stroke(response.rect, CornerRadius::ZERO, Stroke::new(stroke_width, color), StrokeKind::Outside); - }); + Area::new(egui::Id::new(( + "hud_rect_outline", + pos[0] as i32, + pos[1] as i32, + ))) + .order(Order::Foreground) + .fixed_pos(Pos2::new(pos[0], pos[1])) + .show(self.ctx, |ui| { + let (response, painter) = + ui.allocate_painter(Vec2::new(size[0], size[1]), Sense::hover()); + painter.rect_stroke( + response.rect, + CornerRadius::ZERO, + Stroke::new(stroke_width, color), + StrokeKind::Outside, + ); + }); } /// Draw a progress bar @@ -147,22 +174,26 @@ impl<'a> HudContext<'a> { let bg_color = rgba_to_color32(bg_color); let fill_color = rgba_to_color32(fill_color); - Area::new(egui::Id::new(("hud_progress", pos[0] as i32, pos[1] as i32))) - .order(Order::Foreground) - .fixed_pos(Pos2::new(pos[0], pos[1])) - .show(self.ctx, |ui| { - let (response, painter) = - ui.allocate_painter(Vec2::new(size[0], size[1]), Sense::hover()); - let rect = response.rect; + Area::new(egui::Id::new(( + "hud_progress", + pos[0] as i32, + pos[1] as i32, + ))) + .order(Order::Foreground) + .fixed_pos(Pos2::new(pos[0], pos[1])) + .show(self.ctx, |ui| { + let (response, painter) = + ui.allocate_painter(Vec2::new(size[0], size[1]), Sense::hover()); + let rect = response.rect; - // Draw background - painter.rect_filled(rect, CornerRadius::ZERO, bg_color); + // Draw background + painter.rect_filled(rect, CornerRadius::ZERO, bg_color); - // Draw fill - let fill_width = rect.width() * progress; - let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height())); - painter.rect_filled(fill_rect, CornerRadius::ZERO, fill_color); - }); + // Draw fill + let fill_width = rect.width() * progress; + let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height())); + painter.rect_filled(fill_rect, CornerRadius::ZERO, fill_color); + }); } /// Flash the screen (for damage, pickups, etc) @@ -180,8 +211,7 @@ impl<'a> HudContext<'a> { .order(Order::Background) // Behind other HUD elements but over scene .fixed_pos(Pos2::ZERO) .show(self.ctx, |ui| { - let (_response, painter) = - ui.allocate_painter(screen.size(), Sense::hover()); + let (_response, painter) = ui.allocate_painter(screen.size(), Sense::hover()); painter.rect_filled(screen, CornerRadius::ZERO, color); }); } @@ -276,7 +306,9 @@ mod tests { assert!( (original[i] - back[i]).abs() < 0.02, "Channel {}: original={}, back={}", - i, original[i], back[i] + i, + original[i], + back[i] ); } } diff --git a/crates/rust4d_render/src/egui_overlay/mod.rs b/crates/rust4d_render/src/egui_overlay/mod.rs index fb757fd..0a95d3c 100644 --- a/crates/rust4d_render/src/egui_overlay/mod.rs +++ b/crates/rust4d_render/src/egui_overlay/mod.rs @@ -90,7 +90,7 @@ mod context; mod renderer; -pub use context::{HudContext, color32_to_rgba, rgba_to_color32}; +pub use context::{color32_to_rgba, rgba_to_color32, HudContext}; pub use renderer::EguiRenderer; // Re-export key egui types for convenience diff --git a/crates/rust4d_render/src/egui_overlay/renderer.rs b/crates/rust4d_render/src/egui_overlay/renderer.rs index 27c6867..34329fd 100644 --- a/crates/rust4d_render/src/egui_overlay/renderer.rs +++ b/crates/rust4d_render/src/egui_overlay/renderer.rs @@ -91,7 +91,9 @@ impl EguiRenderer { } // Tessellate shapes into primitives - let clipped_primitives = self.ctx.tessellate(full_output.shapes, full_output.pixels_per_point); + let clipped_primitives = self + .ctx + .tessellate(full_output.shapes, full_output.pixels_per_point); // Update buffers self.renderer.update_buffers( diff --git a/crates/rust4d_render/src/lib.rs b/crates/rust4d_render/src/lib.rs index f2eb4f8..417acd7 100644 --- a/crates/rust4d_render/src/lib.rs +++ b/crates/rust4d_render/src/lib.rs @@ -19,27 +19,29 @@ //! Shape geometry is defined in `rust4d_math`. This crate re-exports the shapes //! for convenience, but you can also import them directly from `rust4d_math`. -pub mod context; pub mod camera4d; +pub mod context; +pub mod egui_overlay; +pub mod particle; pub mod pipeline; pub mod renderable; pub mod sprite; -pub mod particle; -pub mod egui_overlay; // Re-export core types for convenience -pub use rust4d_core::{World, Transform4D, Material, ShapeRef, DirtyFlags, Tags}; -pub use rust4d_core::{ConvexShape4D, Tetrahedron, Tesseract4D, Hyperplane4D}; -pub use rust4d_core::{Vec4, Rotor4, RotationPlane}; +pub use rust4d_core::{ConvexShape4D, Hyperplane4D, Tesseract4D, Tetrahedron}; +pub use rust4d_core::{DirtyFlags, Material, ShapeRef, Tags, Transform4D, World}; +pub use rust4d_core::{RotationPlane, Rotor4, Vec4}; // Re-export renderable for easy access -pub use renderable::{RenderableGeometry, CheckerboardGeometry, position_gradient_color}; +pub use renderable::{position_gradient_color, CheckerboardGeometry, RenderableGeometry}; // Re-export sprite types for easy access -pub use sprite::{Sprite, SpriteSheet, SpriteBatch, WFadeConfig}; +pub use sprite::{Sprite, SpriteBatch, SpriteSheet, WFadeConfig}; // Re-export particle types for easy access -pub use particle::{Particle, BlendMode, BurstConfig, EmitterConfig, ParticleEmitter, ParticleSystem}; +pub use particle::{ + BlendMode, BurstConfig, EmitterConfig, Particle, ParticleEmitter, ParticleSystem, +}; // Re-export egui overlay types for easy access -pub use egui_overlay::{EguiRenderer, HudContext, ScreenDescriptor, RawInput}; +pub use egui_overlay::{EguiRenderer, HudContext, RawInput, ScreenDescriptor}; diff --git a/crates/rust4d_render/src/particle/emitter.rs b/crates/rust4d_render/src/particle/emitter.rs index e0cf486..ecdf6ad 100644 --- a/crates/rust4d_render/src/particle/emitter.rs +++ b/crates/rust4d_render/src/particle/emitter.rs @@ -1,9 +1,9 @@ //! Particle emitter for continuous particle emission -use rand::{Rng, SeedableRng}; +use super::types::{BurstConfig, EmitterConfig, Particle}; use rand::rngs::SmallRng; +use rand::{Rng, SeedableRng}; use rust4d_math::Vec4; -use super::types::{Particle, EmitterConfig, BurstConfig}; /// A continuous particle emitter #[derive(Clone, Debug)] @@ -74,7 +74,8 @@ impl ParticleEmitter { self.accumulator -= particles_to_spawn as f32; // Spawn the particles - let mut particles = Vec::with_capacity((particles_to_spawn * self.config.burst.count) as usize); + let mut particles = + Vec::with_capacity((particles_to_spawn * self.config.burst.count) as usize); for _ in 0..particles_to_spawn { particles.extend(self.spawn_burst_internal(&self.config.burst.clone())); } @@ -214,7 +215,7 @@ pub fn spawn_burst(position: Vec4, config: &BurstConfig, seed: u64) -> Vec assert_eq!(tri_count, 0), @@ -257,7 +266,9 @@ mod tests { assert_eq!( TETRA_EDGE_TABLE[i].count_ones(), TETRA_EDGE_TABLE[15 - i].count_ones(), - "Cases {} and {} should have same edge count", i, 15 - i + "Cases {} and {} should have same edge count", + i, + 15 - i ); } } @@ -272,9 +283,13 @@ mod tests { for i in 0..6 { let idx = TETRA_TRI_TABLE[case_idx][i]; if idx >= 0 { - assert!(idx < num_edges, + assert!( + idx < num_edges, "Case {}: triangle index {} out of range (only {} edges)", - case_idx, idx, num_edges); + case_idx, + idx, + num_edges + ); } } } diff --git a/crates/rust4d_render/src/pipeline/mod.rs b/crates/rust4d_render/src/pipeline/mod.rs index ba17d10..8c0816d 100644 --- a/crates/rust4d_render/src/pipeline/mod.rs +++ b/crates/rust4d_render/src/pipeline/mod.rs @@ -4,22 +4,24 @@ //! 4D cross-section rendering. pub mod lookup_tables; -pub mod types; -pub mod slice_pipeline; pub mod render_pipeline; +pub mod slice_pipeline; +pub mod types; // Re-export lookup tables (tetrahedra tables only) pub use lookup_tables::{ - TETRA_EDGES, TETRA_EDGE_TABLE, TETRA_TRI_TABLE, TETRA_TRI_COUNT, - tetra_edge_count, tetra_crossed_edges, + tetra_crossed_edges, tetra_edge_count, TETRA_EDGES, TETRA_EDGE_TABLE, TETRA_TRI_COUNT, + TETRA_TRI_TABLE, }; // Re-export types pub use types::{ - Vertex4D, Vertex3D, SliceParams, RenderUniforms, - AtomicCounter, GpuTetrahedron, MAX_OUTPUT_TRIANGLES, TRIANGLE_VERTEX_COUNT, + AtomicCounter, GpuTetrahedron, RenderUniforms, SliceParams, Vertex3D, Vertex4D, + MAX_OUTPUT_TRIANGLES, TRIANGLE_VERTEX_COUNT, }; // Re-export pipelines +pub use render_pipeline::{ + look_at_matrix, mat4_mul, perspective_matrix, DrawIndirectArgs, RenderPipeline, +}; pub use slice_pipeline::SlicePipeline; -pub use render_pipeline::{RenderPipeline, DrawIndirectArgs, perspective_matrix, look_at_matrix, mat4_mul}; diff --git a/crates/rust4d_render/src/pipeline/render_pipeline.rs b/crates/rust4d_render/src/pipeline/render_pipeline.rs index c44b69e..5127afa 100644 --- a/crates/rust4d_render/src/pipeline/render_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/render_pipeline.rs @@ -129,12 +129,10 @@ impl RenderPipeline { let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Render Bind Group"), layout: &bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: uniform_buffer.as_entire_binding(), - }, - ], + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], }); // Create indirect draw buffer @@ -238,11 +236,13 @@ impl RenderPipeline { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); - self.depth_texture = Some(depth_texture.create_view(&wgpu::TextureViewDescriptor::default())); + self.depth_texture = + Some(depth_texture.create_view(&wgpu::TextureViewDescriptor::default())); self.depth_size = (width, height); } } @@ -257,7 +257,10 @@ impl RenderPipeline { vertex_buffer: &wgpu::Buffer, clear_color: wgpu::Color, ) { - let depth_view = self.depth_texture.as_ref().expect("Depth texture not created. Call ensure_depth_texture first."); + let depth_view = self + .depth_texture + .as_ref() + .expect("Depth texture not created. Call ensure_depth_texture first."); let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Render Pass"), @@ -313,11 +316,7 @@ pub fn perspective_matrix(fov_y: f32, aspect: f32, near: f32, far: f32) -> [[f32 /// Helper to create a look-at view matrix pub fn look_at_matrix(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [[f32; 4]; 4] { - let f = normalize([ - target[0] - eye[0], - target[1] - eye[1], - target[2] - eye[2], - ]); + let f = normalize([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]); let s = normalize(cross(f, up)); let u = cross(s, f); @@ -334,7 +333,8 @@ pub fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] { let mut result = [[0.0f32; 4]; 4]; for i in 0..4 { for j in 0..4 { - result[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + a[i][3] * b[3][j]; + result[i][j] = + a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + a[i][3] * b[3][j]; } } result @@ -399,8 +399,14 @@ mod tests { // Near plane (z = -near) must map to depth 0, far plane to depth 1. let d_near = project(proj, [0.0, 0.0, -near])[2]; let d_far = project(proj, [0.0, 0.0, -far])[2]; - assert!(d_near.abs() < 1e-5, "near plane depth should be 0, got {d_near}"); - assert!((d_far - 1.0).abs() < 1e-5, "far plane depth should be 1, got {d_far}"); + assert!( + d_near.abs() < 1e-5, + "near plane depth should be 0, got {d_near}" + ); + assert!( + (d_far - 1.0).abs() < 1e-5, + "far plane depth should be 1, got {d_far}" + ); // Every depth between near and far must stay inside [0, 1] — the // OpenGL convention put anything closer than √(near·far) below 0, diff --git a/crates/rust4d_render/src/pipeline/slice_pipeline.rs b/crates/rust4d_render/src/pipeline/slice_pipeline.rs index 4cffc9d..2074ebb 100644 --- a/crates/rust4d_render/src/pipeline/slice_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/slice_pipeline.rs @@ -6,8 +6,7 @@ use wgpu::util::DeviceExt; use super::types::{ - SliceParams, Vertex3D, Vertex4D, GpuTetrahedron, AtomicCounter, - TRIANGLE_VERTEX_COUNT, + AtomicCounter, GpuTetrahedron, SliceParams, Vertex3D, Vertex4D, TRIANGLE_VERTEX_COUNT, }; /// Compute pipeline for slicing 4D geometry @@ -143,11 +142,14 @@ impl SlicePipeline { }); // Create output buffer sized by max_triangles parameter - let output_size = (max_triangles * TRIANGLE_VERTEX_COUNT * std::mem::size_of::()) as u64; + let output_size = + (max_triangles * TRIANGLE_VERTEX_COUNT * std::mem::size_of::()) as u64; let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Slice Output Buffer"), size: output_size, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_SRC, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::VERTEX + | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); @@ -155,7 +157,10 @@ impl SlicePipeline { let counter_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Slice Counter Buffer"), size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::INDIRECT, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::INDIRECT, mapped_at_creation: false, }); @@ -181,22 +186,31 @@ impl SlicePipeline { } /// Upload tetrahedra and vertices to the GPU - pub fn upload_tetrahedra(&mut self, device: &wgpu::Device, vertices: &[Vertex4D], tetrahedra: &[GpuTetrahedron]) { + pub fn upload_tetrahedra( + &mut self, + device: &wgpu::Device, + vertices: &[Vertex4D], + tetrahedra: &[GpuTetrahedron], + ) { self.tetra_count = tetrahedra.len() as u32; // Create vertex buffer - self.vertex_buffer = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Vertex Buffer"), - contents: bytemuck::cast_slice(vertices), - usage: wgpu::BufferUsages::STORAGE, - })); + self.vertex_buffer = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Vertex Buffer"), + contents: bytemuck::cast_slice(vertices), + usage: wgpu::BufferUsages::STORAGE, + }), + ); // Create tetrahedra buffer - self.tetra_buffer = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Tetrahedra Buffer"), - contents: bytemuck::cast_slice(tetrahedra), - usage: wgpu::BufferUsages::STORAGE, - })); + self.tetra_buffer = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Tetrahedra Buffer"), + contents: bytemuck::cast_slice(tetrahedra), + usage: wgpu::BufferUsages::STORAGE, + }), + ); // Recreate bind group self.bind_group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor { diff --git a/crates/rust4d_render/src/renderable.rs b/crates/rust4d_render/src/renderable.rs index f966122..fd59671 100644 --- a/crates/rust4d_render/src/renderable.rs +++ b/crates/rust4d_render/src/renderable.rs @@ -3,9 +3,9 @@ //! This module converts the abstract shape data from rust4d_core into //! GPU-compatible vertex and tetrahedra buffers. -use rust4d_core::{World, Material, Transform4D, ShapeRef}; +use crate::pipeline::{GpuTetrahedron, Vertex4D}; +use rust4d_core::{Material, ShapeRef, Transform4D, World}; use rust4d_math::Vec4; -use crate::pipeline::{Vertex4D, GpuTetrahedron}; /// GPU-ready geometry collected from entities /// @@ -46,9 +46,16 @@ impl RenderableGeometry { /// /// Iterates all entities with Transform4D, ShapeRef, and Material components /// using ECS queries. - pub fn from_world_with_color(world: &World, color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4]) -> Self { + pub fn from_world_with_color( + world: &World, + color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4], + ) -> Self { let mut result = Self::new(); - for (_entity, (transform, shape, material)) in world.ecs().query::<(&Transform4D, &ShapeRef, &Material)>().iter() { + for (_entity, (transform, shape, material)) in world + .ecs() + .query::<(&Transform4D, &ShapeRef, &Material)>() + .iter() + { result.add_components_with_color(transform, shape.as_shape(), material, color_fn); } result @@ -142,7 +149,11 @@ pub struct CheckerboardGeometry { impl CheckerboardGeometry { /// Create a new checkerboard with the given colors and cell size pub fn new(color_a: [f32; 4], color_b: [f32; 4], cell_size: f32) -> Self { - Self { color_a, color_b, cell_size } + Self { + color_a, + color_b, + cell_size, + } } /// Get the color for a vertex based on its XZ position @@ -159,16 +170,14 @@ impl CheckerboardGeometry { /// Create a color function that applies checkerboard pattern pub fn color_fn(&self) -> impl Fn(&Vec4, &Material) -> [f32; 4] + '_ { - move |vertex, _material| { - self.color_for_position(vertex.x, vertex.z) - } + move |vertex, _material| self.color_for_position(vertex.x, vertex.z) } } #[cfg(test)] mod tests { use super::*; - use rust4d_core::{ShapeRef, Tesseract4D, Transform4D, DirtyFlags}; + use rust4d_core::{DirtyFlags, ShapeRef, Tesseract4D, Transform4D}; fn spawn_test_entity(world: &mut World) -> rust4d_core::hecs::Entity { let tesseract = Tesseract4D::new(2.0); @@ -195,7 +204,12 @@ mod tests { let transform = Transform4D::identity(); let mut geom = RenderableGeometry::new(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); assert_eq!(geom.vertex_count(), 16); // Tesseract has 16 vertices assert!(geom.tetrahedron_count() > 0); @@ -225,10 +239,20 @@ mod tests { let material = Material::from_rgb(1.0, 0.5, 0.25); let transform = Transform4D::identity(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); assert_eq!(geom.vertex_count(), 16); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); assert_eq!(geom.vertex_count(), 32); } @@ -240,7 +264,12 @@ mod tests { let transform = Transform4D::identity(); let mut geom = RenderableGeometry::new(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); assert!(geom.vertex_count() > 0); geom.clear(); @@ -286,12 +315,20 @@ mod tests { let transform = Transform4D::from_position(Vec4::new(10.0, 0.0, 0.0, 0.0)); let mut geom = RenderableGeometry::new(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); // All vertices should be offset by 10 in x for v in &geom.vertices { - assert!(v.position[0] >= 9.0 && v.position[0] <= 11.0, - "Vertex x should be around 10, got {}", v.position[0]); + assert!( + v.position[0] >= 9.0 && v.position[0] <= 11.0, + "Vertex x should be around 10, got {}", + v.position[0] + ); } } @@ -303,14 +340,26 @@ mod tests { let material = Material::from_rgb(1.0, 0.5, 0.25); let transform = Transform4D::identity(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); let first_entity_verts = geom.vertex_count(); - geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); + geom.add_components_with_color( + &transform, + shape_ref.as_shape(), + &material, + &default_color_fn, + ); // Second entity's tetrahedra should have indices >= first_entity_verts let second_tet = geom.tetrahedra.last().unwrap(); - assert!(second_tet.v0 >= first_entity_verts as u32, - "Second entity's tetrahedra should have offset indices"); + assert!( + second_tet.v0 >= first_entity_verts as u32, + "Second entity's tetrahedra should have offset indices" + ); } } diff --git a/crates/rust4d_render/src/sprite/batch.rs b/crates/rust4d_render/src/sprite/batch.rs index d571571..1ea1c58 100644 --- a/crates/rust4d_render/src/sprite/batch.rs +++ b/crates/rust4d_render/src/sprite/batch.rs @@ -3,9 +3,9 @@ //! Collects sprites during a frame and provides sorted output for rendering //! with proper transparency handling. -use std::collections::HashMap; -use rust4d_math::Vec4; use super::types::{Sprite, SpriteSheet}; +use rust4d_math::Vec4; +use std::collections::HashMap; /// Batch renderer for sprites /// @@ -93,7 +93,9 @@ impl SpriteBatch { let dist_a = a.distance_3d(camera_pos); let dist_b = b.distance_3d(camera_pos); // Sort by distance descending (far sprites first) - dist_b.partial_cmp(&dist_a).unwrap_or(std::cmp::Ordering::Equal) + dist_b + .partial_cmp(&dist_a) + .unwrap_or(std::cmp::Ordering::Equal) }); sorted } @@ -103,10 +105,12 @@ impl SpriteBatch { /// Returns sprites that have non-zero alpha after W-fade, /// sorted back to front. pub fn get_visible_sorted(&self, camera_pos: Vec4, current_w: f32) -> Vec<(&Sprite, f32)> { - let mut visible: Vec<(&Sprite, f32)> = self.sprites + let mut visible: Vec<(&Sprite, f32)> = self + .sprites .iter() .filter_map(|sprite| { - let w_fade = Self::calculate_w_fade(sprite.position.w, current_w, sprite.w_fade_range); + let w_fade = + Self::calculate_w_fade(sprite.position.w, current_w, sprite.w_fade_range); let final_alpha = sprite.color_tint[3] * w_fade; if final_alpha > 0.001 { Some((sprite, final_alpha)) @@ -119,7 +123,9 @@ impl SpriteBatch { visible.sort_by(|a, b| { let dist_a = a.0.distance_3d(camera_pos); let dist_b = b.0.distance_3d(camera_pos); - dist_b.partial_cmp(&dist_a).unwrap_or(std::cmp::Ordering::Equal) + dist_b + .partial_cmp(&dist_a) + .unwrap_or(std::cmp::Ordering::Equal) }); visible diff --git a/crates/rust4d_render/src/sprite/mod.rs b/crates/rust4d_render/src/sprite/mod.rs index f50a279..9359c16 100644 --- a/crates/rust4d_render/src/sprite/mod.rs +++ b/crates/rust4d_render/src/sprite/mod.rs @@ -68,9 +68,9 @@ //! distance from the camera (W dimension is ignored for depth sorting since //! billboards are rendered at their XYZ projection). -pub mod types; pub mod batch; +pub mod types; // Re-export main types for convenience -pub use types::{Sprite, SpriteSheet, WFadeConfig}; pub use batch::SpriteBatch; +pub use types::{Sprite, SpriteSheet, WFadeConfig}; diff --git a/crates/rust4d_render/src/sprite/types.rs b/crates/rust4d_render/src/sprite/types.rs index 49d71af..c98ef7a 100644 --- a/crates/rust4d_render/src/sprite/types.rs +++ b/crates/rust4d_render/src/sprite/types.rs @@ -28,7 +28,13 @@ pub struct SpriteSheet { impl SpriteSheet { /// Create a new sprite sheet with the given parameters - pub fn new(name: impl Into, frame_width: u32, frame_height: u32, columns: u32, rows: u32) -> Self { + pub fn new( + name: impl Into, + frame_width: u32, + frame_height: u32, + columns: u32, + rows: u32, + ) -> Self { Self { name: name.into(), frame_width, @@ -114,7 +120,7 @@ impl Sprite { sheet_name: sheet_name.into(), frame: 0, size: [1.0, 1.0], - w_fade_range: 2.0, // Default fade range of 2 world units + w_fade_range: 2.0, // Default fade range of 2 world units color_tint: [1.0, 1.0, 1.0, 1.0], // White (no tint) } } @@ -179,7 +185,8 @@ impl WFadeConfig { debug_assert!( fade_end > fade_start, "WFadeConfig: fade_end ({}) must be greater than fade_start ({})", - fade_end, fade_start + fade_end, + fade_start ); Self { current_w, @@ -241,8 +248,8 @@ mod tests { // First frame (top-left) let uvs = sheet.frame_uvs(0); - assert_eq!(uvs[0], 0.0); // u_min - assert_eq!(uvs[1], 0.0); // v_min + assert_eq!(uvs[0], 0.0); // u_min + assert_eq!(uvs[1], 0.0); // v_min assert_eq!(uvs[2], 0.25); // u_max assert_eq!(uvs[3], 0.25); // v_max diff --git a/crates/rust4d_scripting/src/bindings/audio.rs b/crates/rust4d_scripting/src/bindings/audio.rs index f8078b9..b7d19a4 100644 --- a/crates/rust4d_scripting/src/bindings/audio.rs +++ b/crates/rust4d_scripting/src/bindings/audio.rs @@ -186,7 +186,11 @@ pub fn register(lua: &Lua) -> LuaResult<()> { let bus_name = validate_bus_name(&bus)?; // STUB: Log warning // Real implementation would call engine.play(&handle, bus) - log::trace!("[audio] play(handle={}, bus='{}') - stub", handle.id, bus_name); + log::trace!( + "[audio] play(handle={}, bus='{}') - stub", + handle.id, + bus_name + ); Ok(()) })?, )?; @@ -203,7 +207,11 @@ pub fn register(lua: &Lua) -> LuaResult<()> { "play_oneshot", lua.create_function(|_, (handle, bus): (LuaSoundHandle, String)| { let bus_name = validate_bus_name(&bus)?; - log::trace!("[audio] play_oneshot(handle={}, bus='{}') - stub", handle.id, bus_name); + log::trace!( + "[audio] play_oneshot(handle={}, bus='{}') - stub", + handle.id, + bus_name + ); Ok(()) })?, )?; @@ -289,7 +297,11 @@ pub fn register(lua: &Lua) -> LuaResult<()> { ); } - log::trace!("[audio] set_volume(bus='{}', volume={:.2}) - stub", bus_name, clamped); + log::trace!( + "[audio] set_volume(bus='{}', volume={:.2}) - stub", + bus_name, + clamped + ); Ok(()) })?, )?; @@ -332,7 +344,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { lua.create_function(|_, pos: LuaVec4| { log::trace!( "[audio] update_listener(pos=({:.2}, {:.2}, {:.2}, {:.2})) - stub", - pos.0.x, pos.0.y, pos.0.z, pos.0.w + pos.0.x, + pos.0.y, + pos.0.z, + pos.0.w ); Ok(()) })?, diff --git a/crates/rust4d_scripting/src/bindings/ecs.rs b/crates/rust4d_scripting/src/bindings/ecs.rs index 681551f..f81f805 100644 --- a/crates/rust4d_scripting/src/bindings/ecs.rs +++ b/crates/rust4d_scripting/src/bindings/ecs.rs @@ -163,18 +163,15 @@ pub fn register(lua: &Lua) -> LuaResult<()> { "query", lua.create_function(|lua, component: String| { if !ECS_WARNED.swap(true, Ordering::Relaxed) { - log::warn!( - "[ecs] hecs::World not connected - entity operations are stubs." - ); + log::warn!("[ecs] hecs::World not connected - entity operations are stubs."); } log::trace!("[ecs] query called for component: {}", component); // Return an empty iterator function // Real implementation would iterate over hecs::World query results - let empty_iter = lua.create_function(|_, ()| -> LuaResult> { - Ok(None) - })?; + let empty_iter = + lua.create_function(|_, ()| -> LuaResult> { Ok(None) })?; Ok(empty_iter) })?, )?; @@ -186,9 +183,7 @@ pub fn register(lua: &Lua) -> LuaResult<()> { "find_by_name", lua.create_function(|_, name: String| { if !ECS_WARNED.swap(true, Ordering::Relaxed) { - log::warn!( - "[ecs] hecs::World not connected - entity operations are stubs." - ); + log::warn!("[ecs] hecs::World not connected - entity operations are stubs."); } log::trace!("[ecs] find_by_name called: {}", name); // Real implementation would query World for entity with matching Name component @@ -240,7 +235,10 @@ mod tests { #[test] fn test_world_table_exists() { let lua = create_lua_with_ecs(); - let world: LuaTable = lua.globals().get("world").expect("world table should exist"); + let world: LuaTable = lua + .globals() + .get("world") + .expect("world table should exist"); assert!(world.contains_key("spawn").unwrap()); assert!(world.contains_key("query").unwrap()); assert!(world.contains_key("find_by_name").unwrap()); @@ -423,7 +421,8 @@ mod tests { let generation: u64 = 1; let id: u64 = 42; let bits = (generation << 32) | id; - let entity = LuaEntity(Entity::from_bits(bits).expect("should create entity from valid bits")); + let entity = + LuaEntity(Entity::from_bits(bits).expect("should create entity from valid bits")); // Entity created with id 42 should have id 42 assert_eq!(entity.0.id(), 42); // to_bits returns a non-zero value diff --git a/crates/rust4d_scripting/src/bindings/hud.rs b/crates/rust4d_scripting/src/bindings/hud.rs index 796c2ef..3643860 100644 --- a/crates/rust4d_scripting/src/bindings/hud.rs +++ b/crates/rust4d_scripting/src/bindings/hud.rs @@ -124,22 +124,24 @@ pub fn register(lua: &Lua) -> LuaResult<()> { // - color: RGBA color table {r, g, b, a} or {1, 2, 3, 4} hud_table.set( "text", - lua.create_function(|_, (x, y, text, size, color): (f32, f32, String, f32, LuaTable)| { - let color = table_to_color(&color)?; - // STUB: Log at trace level (LOW-6 - called every frame) - // Real implementation would: - // 1. Get HudContext from lua.app_data() - // 2. Call hud.text([x, y], &text, size, color) - log::trace!( - "[hud] text at ({}, {}): '{}' size={} color={:?}", - x, - y, - text, - size, - color - ); - Ok(()) - })?, + lua.create_function( + |_, (x, y, text, size, color): (f32, f32, String, f32, LuaTable)| { + let color = table_to_color(&color)?; + // STUB: Log at trace level (LOW-6 - called every frame) + // Real implementation would: + // 1. Get HudContext from lua.app_data() + // 2. Call hud.text([x, y], &text, size, color) + log::trace!( + "[hud] text at ({}, {}): '{}' size={} color={:?}", + x, + y, + text, + size, + color + ); + Ok(()) + }, + )?, )?; // hud.text_centered(x, y, text, size, color) @@ -154,18 +156,20 @@ pub fn register(lua: &Lua) -> LuaResult<()> { // - color: RGBA color table {r, g, b, a} or {1, 2, 3, 4} hud_table.set( "text_centered", - lua.create_function(|_, (x, y, text, size, color): (f32, f32, String, f32, LuaTable)| { - let color = table_to_color(&color)?; - log::trace!( - "[hud] text_centered at ({}, {}): '{}' size={} color={:?}", - x, - y, - text, - size, - color - ); - Ok(()) - })?, + lua.create_function( + |_, (x, y, text, size, color): (f32, f32, String, f32, LuaTable)| { + let color = table_to_color(&color)?; + log::trace!( + "[hud] text_centered at ({}, {}): '{}' size={} color={:?}", + x, + y, + text, + size, + color + ); + Ok(()) + }, + )?, )?; // hud.rect(x, y, width, height, color) @@ -365,7 +369,11 @@ fn table_to_color(table: &LuaTable) -> LuaResult<[f32; 4]> { log::warn!( "[hud] Color table using named format but missing component(s): {:?}. \ Typo? Got r={}, g={}, b={}, a={}. Missing components default to 0.0.", - missing, r, g, b, a + missing, + r, + g, + b, + a ); } @@ -393,10 +401,7 @@ mod tests { #[test] fn test_hud_table_exists() { let lua = create_lua_with_hud(); - let hud: LuaTable = lua - .globals() - .get("hud") - .expect("hud table should exist"); + let hud: LuaTable = lua.globals().get("hud").expect("hud table should exist"); assert!(hud.contains_key("text").unwrap()); assert!(hud.contains_key("text_centered").unwrap()); assert!(hud.contains_key("rect").unwrap()); @@ -538,7 +543,10 @@ mod tests { // No alpha specified let color = table_to_color(&table).unwrap(); - assert!((color[3] - 1.0).abs() < 0.001, "Default alpha should be 1.0"); + assert!( + (color[3] - 1.0).abs() < 0.001, + "Default alpha should be 1.0" + ); } #[test] @@ -550,9 +558,18 @@ mod tests { let color = table_to_color(&table).unwrap(); assert!((color[0] - 1.0).abs() < 0.001); - assert!((color[1] - 0.0).abs() < 0.001, "Missing g should default to 0.0"); - assert!((color[2] - 0.0).abs() < 0.001, "Missing b should default to 0.0"); - assert!((color[3] - 1.0).abs() < 0.001, "Missing a should default to 1.0"); + assert!( + (color[1] - 0.0).abs() < 0.001, + "Missing g should default to 0.0" + ); + assert!( + (color[2] - 0.0).abs() < 0.001, + "Missing b should default to 0.0" + ); + assert!( + (color[3] - 1.0).abs() < 0.001, + "Missing a should default to 1.0" + ); } #[test] diff --git a/crates/rust4d_scripting/src/bindings/input.rs b/crates/rust4d_scripting/src/bindings/input.rs index 7c6d667..6a62f89 100644 --- a/crates/rust4d_scripting/src/bindings/input.rs +++ b/crates/rust4d_scripting/src/bindings/input.rs @@ -55,36 +55,134 @@ static INPUT_WARNED: AtomicBool = AtomicBool::new(false); /// These correspond to common keyboard keys that would be handled by winit/gilrs. const VALID_KEY_NAMES: &[&str] = &[ // Letters - "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", - "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", // Numbers - "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", // Function keys - "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", // Modifiers - "Shift", "LShift", "RShift", - "Control", "LControl", "RControl", "Ctrl", "LCtrl", "RCtrl", - "Alt", "LAlt", "RAlt", - "Super", "LSuper", "RSuper", "Win", "LWin", "RWin", + "Shift", + "LShift", + "RShift", + "Control", + "LControl", + "RControl", + "Ctrl", + "LCtrl", + "RCtrl", + "Alt", + "LAlt", + "RAlt", + "Super", + "LSuper", + "RSuper", + "Win", + "LWin", + "RWin", // Special keys - "Space", "Enter", "Return", "Escape", "Esc", - "Tab", "Backspace", "Delete", "Insert", - "Home", "End", "PageUp", "PageDown", - "Up", "Down", "Left", "Right", + "Space", + "Enter", + "Return", + "Escape", + "Esc", + "Tab", + "Backspace", + "Delete", + "Insert", + "Home", + "End", + "PageUp", + "PageDown", + "Up", + "Down", + "Left", + "Right", // Punctuation and symbols - "Minus", "Plus", "Equals", - "LeftBracket", "RightBracket", "LBracket", "RBracket", - "Backslash", "Semicolon", "Quote", "Apostrophe", - "Comma", "Period", "Slash", - "Grave", "Backtick", "Tilde", + "Minus", + "Plus", + "Equals", + "LeftBracket", + "RightBracket", + "LBracket", + "RBracket", + "Backslash", + "Semicolon", + "Quote", + "Apostrophe", + "Comma", + "Period", + "Slash", + "Grave", + "Backtick", + "Tilde", // Numpad - "Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4", - "Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9", - "NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", - "NumpadEnter", "NumpadDecimal", + "Numpad0", + "Numpad1", + "Numpad2", + "Numpad3", + "Numpad4", + "Numpad5", + "Numpad6", + "Numpad7", + "Numpad8", + "Numpad9", + "NumpadAdd", + "NumpadSubtract", + "NumpadMultiply", + "NumpadDivide", + "NumpadEnter", + "NumpadDecimal", // Other - "CapsLock", "NumLock", "ScrollLock", - "PrintScreen", "Pause", + "CapsLock", + "NumLock", + "ScrollLock", + "PrintScreen", + "Pause", ]; /// Validate a key name and return it normalized (uppercase for letters). @@ -194,7 +292,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { } // STUB: Return false - log::trace!("[input] is_key_just_pressed('{}') - stub returning false", key); + log::trace!( + "[input] is_key_just_pressed('{}') - stub returning false", + key + ); Ok(false) })?, )?; @@ -220,7 +321,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { // STUB: Return false // Action names are user-defined, so we don't validate them - log::trace!("[input] is_action_pressed('{}') - stub returning false", action); + log::trace!( + "[input] is_action_pressed('{}') - stub returning false", + action + ); Ok(false) })?, )?; @@ -244,7 +348,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { } // STUB: Return false - log::trace!("[input] is_action_just_pressed('{}') - stub returning false", action); + log::trace!( + "[input] is_action_just_pressed('{}') - stub returning false", + action + ); Ok(false) })?, )?; @@ -274,7 +381,11 @@ pub fn register(lua: &Lua) -> LuaResult<()> { } // STUB: Return 0.0 - log::trace!("[input] get_axis('{}', '{}') - stub returning 0.0", positive, negative); + log::trace!( + "[input] get_axis('{}', '{}') - stub returning 0.0", + positive, + negative + ); Ok(0.0f32) })?, )?; @@ -343,7 +454,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { ); } - log::trace!("[input] is_mouse_button_pressed('{}') - stub returning false", button); + log::trace!( + "[input] is_mouse_button_pressed('{}') - stub returning false", + button + ); Ok(false) })?, )?; @@ -368,7 +482,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { ); } - log::trace!("[input] is_mouse_button_just_pressed('{}') - stub returning false", button); + log::trace!( + "[input] is_mouse_button_just_pressed('{}') - stub returning false", + button + ); Ok(false) })?, )?; diff --git a/crates/rust4d_scripting/src/bindings/math.rs b/crates/rust4d_scripting/src/bindings/math.rs index 73fa0d5..a3299aa 100644 --- a/crates/rust4d_scripting/src/bindings/math.rs +++ b/crates/rust4d_scripting/src/bindings/math.rs @@ -28,7 +28,7 @@ //! This module is owned by Agent D2 (Math/Physics Bindings). use mlua::prelude::*; -use rust4d_math::{Rotor4, RotationPlane, Vec4}; +use rust4d_math::{RotationPlane, Rotor4, Vec4}; // Re-export Transform4D binding if rust4d_core is available // For now, we'll implement Transform4D bindings directly using the math crate @@ -78,9 +78,7 @@ impl LuaUserData for LuaVec4 { methods.add_method("length_squared", |_, this, ()| Ok(this.0.length_squared())); // Normalize to unit length - methods.add_method("normalized", |_, this, ()| { - Ok(LuaVec4(this.0.normalized())) - }); + methods.add_method("normalized", |_, this, ()| Ok(LuaVec4(this.0.normalized()))); // Distance between two vectors methods.add_method("distance", |_, this, other: LuaVec4| { @@ -218,9 +216,7 @@ impl LuaUserData for LuaRotor4 { methods.add_method("reverse", |_, this, ()| Ok(LuaRotor4(this.0.reverse()))); // Normalize the rotor - methods.add_method("normalize", |_, this, ()| { - Ok(LuaRotor4(this.0.normalize())) - }); + methods.add_method("normalize", |_, this, ()| Ok(LuaRotor4(this.0.normalize()))); // Get magnitude methods.add_method("magnitude", |_, this, ()| Ok(this.0.magnitude())); diff --git a/crates/rust4d_scripting/src/bindings/physics.rs b/crates/rust4d_scripting/src/bindings/physics.rs index 1cb5bbc..0a0bacd 100644 --- a/crates/rust4d_scripting/src/bindings/physics.rs +++ b/crates/rust4d_scripting/src/bindings/physics.rs @@ -262,34 +262,36 @@ pub fn register(lua: &Lua) -> LuaResult<()> { // When physics is not connected, returns a table with `_stub = true` field. physics_table.set( "query_sphere", - lua.create_function( - |lua, (center, radius, layers): (LuaVec4, f32, LuaValue)| { - let layer_names = parse_layers(layers)?; + lua.create_function(|lua, (center, radius, layers): (LuaVec4, f32, LuaValue)| { + let layer_names = parse_layers(layers)?; - if !PHYSICS_WARNED.swap(true, Ordering::Relaxed) { - log::warn!( - "[physics] PhysicsWorld not connected - all physics queries will return \ + if !PHYSICS_WARNED.swap(true, Ordering::Relaxed) { + log::warn!( + "[physics] PhysicsWorld not connected - all physics queries will return \ empty/stub results." - ); - } - - log::trace!( - "[physics] query_sphere at ({:.2}, {:.2}, {:.2}, {:.2}) radius={:.2} layers={:?}", - center.0.x, center.0.y, center.0.z, center.0.w, - radius, layer_names ); + } - // STUB: Return empty array with _stub marker (MEDIUM-7) - // Real implementation would: - // 1. Get PhysicsWorld from lua.app_data() - // 2. Iterate all bodies and check distance from center - // 3. Filter by layer - // 4. Return matches with position and distance - let results = lua.create_table()?; - results.set("_stub", true)?; - Ok(results) - }, - )?, + log::trace!( + "[physics] query_sphere at ({:.2}, {:.2}, {:.2}, {:.2}) radius={:.2} layers={:?}", + center.0.x, + center.0.y, + center.0.z, + center.0.w, + radius, + layer_names + ); + + // STUB: Return empty array with _stub marker (MEDIUM-7) + // Real implementation would: + // 1. Get PhysicsWorld from lua.app_data() + // 2. Iterate all bodies and check distance from center + // 3. Filter by layer + // 4. Return matches with position and distance + let results = lua.create_table()?; + results.set("_stub", true)?; + Ok(results) + })?, )?; // physics.query_area_effect(center, radius, layers, with_falloff) -> array of results @@ -389,7 +391,10 @@ pub fn register(lua: &Lua) -> LuaResult<()> { lua.create_function(|lua, ()| { // Try to get PhysicsConfig from app_data let gravity_y = if let Some(config) = lua.app_data_ref::() { - log::trace!("[physics] gravity() - returning configured value: {}", config.gravity); + log::trace!( + "[physics] gravity() - returning configured value: {}", + config.gravity + ); config.gravity } else { // No config set - use default and warn once diff --git a/crates/rust4d_scripting/src/error.rs b/crates/rust4d_scripting/src/error.rs index 869cad7..9847909 100644 --- a/crates/rust4d_scripting/src/error.rs +++ b/crates/rust4d_scripting/src/error.rs @@ -22,10 +22,7 @@ pub enum ScriptError { /// File watcher error (hot-reload) WatcherError(String), /// Module reload failed (old version continues running) - ModuleReloadError { - path: String, - error: mlua::Error, - }, + ModuleReloadError { path: String, error: mlua::Error }, } impl ScriptError { @@ -72,7 +69,9 @@ impl std::error::Error for ScriptError { Self::IoError(_, e) => Some(e), Self::LuaError(e) => Some(e), Self::RuntimeError { error, .. } => Some(error), - Self::ReloadError { source: Some(e), .. } => Some(e.as_ref()), + Self::ReloadError { + source: Some(e), .. + } => Some(e.as_ref()), Self::ModuleReloadError { error, .. } => Some(error), _ => None, } diff --git a/crates/rust4d_scripting/src/lib.rs b/crates/rust4d_scripting/src/lib.rs index e75194f..222738c 100644 --- a/crates/rust4d_scripting/src/lib.rs +++ b/crates/rust4d_scripting/src/lib.rs @@ -317,25 +317,13 @@ mod tests { engine.call_shutdown().unwrap(); // Verify the lifecycle log - let count: i64 = engine - .lua() - .load("return #lifecycle_log") - .eval() - .unwrap(); + let count: i64 = engine.lua().load("return #lifecycle_log").eval().unwrap(); assert_eq!(count, 4); - let first: String = engine - .lua() - .load("return lifecycle_log[1]") - .eval() - .unwrap(); + let first: String = engine.lua().load("return lifecycle_log[1]").eval().unwrap(); assert_eq!(first, "init"); - let last: String = engine - .lua() - .load("return lifecycle_log[4]") - .eval() - .unwrap(); + let last: String = engine.lua().load("return lifecycle_log[4]").eval().unwrap(); assert_eq!(last, "shutdown"); } @@ -381,8 +369,7 @@ mod tests { let (dir, config) = create_game_dir(); // Create a module - let mut module_file = - std::fs::File::create(dir.path().join("utils.lua")).unwrap(); + let mut module_file = std::fs::File::create(dir.path().join("utils.lua")).unwrap(); writeln!(module_file, "local M = {{}}").unwrap(); writeln!(module_file, "function M.double(x) return x * 2 end").unwrap(); writeln!(module_file, "return M").unwrap(); diff --git a/crates/rust4d_scripting/src/lifecycle.rs b/crates/rust4d_scripting/src/lifecycle.rs index 605c118..43d9df1 100644 --- a/crates/rust4d_scripting/src/lifecycle.rs +++ b/crates/rust4d_scripting/src/lifecycle.rs @@ -1,7 +1,7 @@ //! Game loop lifecycle callback dispatch -use mlua::prelude::*; use crate::error::ScriptError; +use mlua::prelude::*; /// Check if a callback function exists without calling it pub fn has_callback(lua: &Lua, callback_name: &str) -> bool { @@ -21,10 +21,11 @@ pub fn call_lifecycle(lua: &Lua, name: &str, args: impl IntoLuaMulti) -> Result< match globals.get::(name) { Ok(LuaValue::Function(func)) => { - func.call::<()>(args).map_err(|e| ScriptError::RuntimeError { - callback: name.to_string(), - error: e, - })?; + func.call::<()>(args) + .map_err(|e| ScriptError::RuntimeError { + callback: name.to_string(), + error: e, + })?; Ok(()) } Ok(LuaValue::Nil) => { diff --git a/crates/rust4d_scripting/src/loader.rs b/crates/rust4d_scripting/src/loader.rs index a648402..4319697 100644 --- a/crates/rust4d_scripting/src/loader.rs +++ b/crates/rust4d_scripting/src/loader.rs @@ -1,7 +1,7 @@ //! Script loading and require() resolution -use mlua::prelude::*; use crate::error::ScriptError; +use mlua::prelude::*; /// Load the game's main.lua entry point and execute it. /// diff --git a/crates/rust4d_scripting/src/vm.rs b/crates/rust4d_scripting/src/vm.rs index 67b85a9..ac60d29 100644 --- a/crates/rust4d_scripting/src/vm.rs +++ b/crates/rust4d_scripting/src/vm.rs @@ -1,7 +1,7 @@ //! Lua VM initialization and configuration -use mlua::prelude::*; use crate::error::ScriptError; +use mlua::prelude::*; /// Configuration for the scripting engine #[derive(Debug, Clone)] @@ -59,14 +59,18 @@ pub fn create_lua_vm(config: &ScriptConfig) -> Result { // Clear cpath to prevent loading native C modules from system paths package.set("cpath", "").map_err(ScriptError::LuaError)?; // Remove loadlib which can load arbitrary shared libraries - package.set("loadlib", LuaNil).map_err(ScriptError::LuaError)?; + package + .set("loadlib", LuaNil) + .map_err(ScriptError::LuaError)?; } // Remove dangerous standard library modules for sandboxing let globals = lua.globals(); globals.set("os", LuaNil).map_err(ScriptError::LuaError)?; globals.set("io", LuaNil).map_err(ScriptError::LuaError)?; - globals.set("debug", LuaNil).map_err(ScriptError::LuaError)?; + globals + .set("debug", LuaNil) + .map_err(ScriptError::LuaError)?; globals .set("loadfile", LuaNil) .map_err(ScriptError::LuaError)?; @@ -84,12 +88,10 @@ pub fn create_lua_vm(config: &ScriptConfig) -> Result { LuaValue::Boolean(b) => b.to_string(), LuaValue::Integer(n) => n.to_string(), LuaValue::Number(n) => n.to_string(), - LuaValue::String(s) => { - match s.to_str() { - Ok(s) => s.to_string(), - Err(_) => "".to_string(), - } - } + LuaValue::String(s) => match s.to_str() { + Ok(s) => s.to_string(), + Err(_) => "".to_string(), + }, other => format!("{:?}", other), }) .collect(); @@ -117,9 +119,10 @@ pub fn create_lua_vm(config: &ScriptConfig) -> Result { move |_lua, _debug| { let prev = count_clone.fetch_add(1000, std::sync::atomic::Ordering::Relaxed); if prev + 1000 > limit { - return Err(mlua::Error::RuntimeError( - format!("instruction limit exceeded ({})", limit), - )); + return Err(mlua::Error::RuntimeError(format!( + "instruction limit exceeded ({})", + limit + ))); } Ok(mlua::VmState::Continue) }, @@ -190,9 +193,7 @@ mod tests { let config = ScriptConfig::default(); let lua = create_lua_vm(&config).unwrap(); // Should not panic even though log isn't fully initialized - lua.load(r#"print("hello", 42, true, nil)"#) - .exec() - .unwrap(); + lua.load(r#"print("hello", 42, true, nil)"#).exec().unwrap(); } #[test] @@ -202,10 +203,7 @@ mod tests { ..Default::default() }; let lua = create_lua_vm(&config).unwrap(); - let path: String = lua - .load("return package.path") - .eval() - .unwrap(); + let path: String = lua.load("return package.path").eval().unwrap(); assert!(path.contains("/tmp/test_scripts/?.lua")); } diff --git a/crates/rust4d_scripting/tests/sandbox_escape.rs b/crates/rust4d_scripting/tests/sandbox_escape.rs index a30e299..1c63cb8 100644 --- a/crates/rust4d_scripting/tests/sandbox_escape.rs +++ b/crates/rust4d_scripting/tests/sandbox_escape.rs @@ -4,8 +4,8 @@ //! a forbidden action. The test passes if the script errors (or the //! forbidden global is nil). -use rust4d_scripting::ScriptConfig; use rust4d_scripting::vm::create_lua_vm; +use rust4d_scripting::ScriptConfig; /// Helper: create a sandboxed Lua VM with default config. fn sandboxed_vm() -> mlua::Lua { @@ -182,10 +182,7 @@ fn sandbox_blocks_require_c_module() { // Attempting to require a C module should fail because cpath is empty // and loadlib is removed let result = lua.load(r#"require("socket")"#).exec(); - assert!( - result.is_err(), - "require('socket') should fail in sandbox" - ); + assert!(result.is_err(), "require('socket') should fail in sandbox"); } // --------------------------------------------------------------------------- @@ -229,10 +226,7 @@ fn sandbox_blocks_rawget_global_restore() { .load(r#"return rawget(_G, "os")"#) .eval() .unwrap_or(mlua::Value::Nil); - assert!( - result == mlua::Value::Nil, - "rawget(_G, 'os') should be nil" - ); + assert!(result == mlua::Value::Nil, "rawget(_G, 'os') should be nil"); } #[test] @@ -242,10 +236,7 @@ fn sandbox_blocks_rawget_io() { .load(r#"return rawget(_G, "io")"#) .eval() .unwrap_or(mlua::Value::Nil); - assert!( - result == mlua::Value::Nil, - "rawget(_G, 'io') should be nil" - ); + assert!(result == mlua::Value::Nil, "rawget(_G, 'io') should be nil"); } #[test] diff --git a/examples/01_hello_tesseract.rs b/examples/01_hello_tesseract.rs index 89528d4..b81aeb6 100644 --- a/examples/01_hello_tesseract.rs +++ b/examples/01_hello_tesseract.rs @@ -19,14 +19,17 @@ use winit::{ window::{Window, WindowId}, }; -use rust4d_core::{Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World}; +use rust4d_core::{DirtyFlags, Material, ShapeRef, Tesseract4D, Transform4D, World}; +use rust4d_math::Vec4; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, - pipeline::{perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, MAX_OUTPUT_TRIANGLES}, + pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, + MAX_OUTPUT_TRIANGLES, + }, RenderableGeometry, }; -use rust4d_math::Vec4; /// Application state struct App { @@ -88,7 +91,8 @@ impl ApplicationHandler for App { // Initialize rendering let render_context = pollster::block_on(RenderContext::new(window.clone())); - let mut slice_pipeline = SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); + let mut slice_pipeline = + SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); let mut render_pipeline = RenderPipeline::new(&render_context.device, render_context.config.format); @@ -140,8 +144,18 @@ impl ApplicationHandler for App { sp.update_params(&ctx.queue, &slice_params); let render_uniforms = RenderUniforms { - view_matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], - projection_matrix: perspective_matrix(std::f32::consts::FRAC_PI_4, ctx.aspect_ratio(), 0.1, 100.0), + view_matrix: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + projection_matrix: perspective_matrix( + std::f32::consts::FRAC_PI_4, + ctx.aspect_ratio(), + 0.1, + 100.0, + ), light_dir: [0.5, 1.0, 0.3], _padding: 0.0, ambient_strength: 0.3, @@ -156,13 +170,27 @@ impl ApplicationHandler for App { Ok(o) => o, Err(_) => return, }; - let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = ctx.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); sp.reset_counter(&ctx.queue); sp.run_slice_pass(&mut encoder); rp.prepare_indirect_draw(&mut encoder, sp.counter_buffer()); - rp.render(&mut encoder, &view, sp.output_buffer(), wgpu::Color { r: 0.02, g: 0.02, b: 0.08, a: 1.0 }); + rp.render( + &mut encoder, + &view, + sp.output_buffer(), + wgpu::Color { + r: 0.02, + g: 0.02, + b: 0.08, + a: 1.0, + }, + ); ctx.queue.submit(std::iter::once(encoder.finish())); output.present(); diff --git a/examples/02_multiple_shapes.rs b/examples/02_multiple_shapes.rs index 0af37fb..95b9e34 100644 --- a/examples/02_multiple_shapes.rs +++ b/examples/02_multiple_shapes.rs @@ -19,14 +19,17 @@ use winit::{ window::{Window, WindowId}, }; -use rust4d_core::{Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World}; +use rust4d_core::{DirtyFlags, Material, ShapeRef, Tesseract4D, Transform4D, World}; +use rust4d_math::Vec4; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, - pipeline::{perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, MAX_OUTPUT_TRIANGLES}, + pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, + MAX_OUTPUT_TRIANGLES, + }, RenderableGeometry, }; -use rust4d_math::Vec4; /// Movement state struct Movement { @@ -36,15 +39,21 @@ struct Movement { right: bool, up: bool, down: bool, - ana: bool, // Q - move toward +W - kata: bool, // E - move toward -W + ana: bool, // Q - move toward +W + kata: bool, // E - move toward -W } impl Movement { fn new() -> Self { Self { - forward: false, backward: false, left: false, right: false, - up: false, down: false, ana: false, kata: false, + forward: false, + backward: false, + left: false, + right: false, + up: false, + down: false, + ana: false, + kata: false, } } } @@ -69,11 +78,26 @@ impl App { // Create multiple tesseracts with different colors and positions let positions_and_colors = [ - (Vec4::new(0.0, 0.0, 0.0, 0.0), Material::from_rgb(0.9, 0.3, 0.2)), // Red at origin - (Vec4::new(4.0, 0.0, 0.0, 0.0), Material::from_rgb(0.2, 0.9, 0.3)), // Green to the right - (Vec4::new(-4.0, 0.0, 0.0, 0.0), Material::from_rgb(0.2, 0.3, 0.9)), // Blue to the left - (Vec4::new(0.0, 4.0, 0.0, 0.0), Material::from_rgb(0.9, 0.9, 0.2)), // Yellow above - (Vec4::new(0.0, 0.0, 0.0, 4.0), Material::from_rgb(0.9, 0.2, 0.9)), // Magenta in +W + ( + Vec4::new(0.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.9, 0.3, 0.2), + ), // Red at origin + ( + Vec4::new(4.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.9, 0.3), + ), // Green to the right + ( + Vec4::new(-4.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.3, 0.9), + ), // Blue to the left + ( + Vec4::new(0.0, 4.0, 0.0, 0.0), + Material::from_rgb(0.9, 0.9, 0.2), + ), // Yellow above + ( + Vec4::new(0.0, 0.0, 0.0, 4.0), + Material::from_rgb(0.9, 0.2, 0.9), + ), // Magenta in +W ]; for (position, material) in positions_and_colors { @@ -120,7 +144,8 @@ impl ApplicationHandler for App { ); let render_context = pollster::block_on(RenderContext::new(window.clone())); - let mut slice_pipeline = SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); + let mut slice_pipeline = + SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); let mut render_pipeline = RenderPipeline::new(&render_context.device, render_context.config.format); @@ -187,7 +212,8 @@ impl ApplicationHandler for App { let up = (self.movement.up as i32 - self.movement.down as i32) as f32; let w = (self.movement.ana as i32 - self.movement.kata as i32) as f32; - self.camera.move_local_xz(forward * speed * dt, right * speed * dt); + self.camera + .move_local_xz(forward * speed * dt, right * speed * dt); self.camera.move_y(up * speed * dt); self.camera.move_w(w * speed * dt); @@ -208,10 +234,17 @@ impl ApplicationHandler for App { sp.update_params(&ctx.queue, &slice_params); let render_uniforms = RenderUniforms { - view_matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + view_matrix: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], projection_matrix: perspective_matrix( - std::f32::consts::FRAC_PI_4, ctx.aspect_ratio(), 0.1, 100.0, + std::f32::consts::FRAC_PI_4, + ctx.aspect_ratio(), + 0.1, + 100.0, ), light_dir: [0.5, 1.0, 0.3], _padding: 0.0, @@ -227,16 +260,27 @@ impl ApplicationHandler for App { Ok(o) => o, Err(_) => return, }; - let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = ctx.device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: None }, - ); + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); sp.reset_counter(&ctx.queue); sp.run_slice_pass(&mut encoder); rp.prepare_indirect_draw(&mut encoder, sp.counter_buffer()); - rp.render(&mut encoder, &view, sp.output_buffer(), - wgpu::Color { r: 0.02, g: 0.02, b: 0.08, a: 1.0 }); + rp.render( + &mut encoder, + &view, + sp.output_buffer(), + wgpu::Color { + r: 0.02, + g: 0.02, + b: 0.08, + a: 1.0, + }, + ); ctx.queue.submit(std::iter::once(encoder.finish())); output.present(); diff --git a/examples/03_physics_demo.rs b/examples/03_physics_demo.rs index e71a290..7c95e65 100644 --- a/examples/03_physics_demo.rs +++ b/examples/03_physics_demo.rs @@ -20,19 +20,22 @@ use winit::{ window::{Window, WindowId}, }; +use rust4d_core::hecs; use rust4d_core::{ - Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World, Tags, Name, - PhysicsConfig, PhysicsBody, RigidBody4D, StaticCollider, Hyperplane4D, + DirtyFlags, Hyperplane4D, Material, Name, PhysicsBody, PhysicsConfig, RigidBody4D, ShapeRef, + StaticCollider, Tags, Tesseract4D, Transform4D, World, }; +use rust4d_math::Vec4; +use rust4d_physics::{BodyType, PhysicsMaterial}; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, - pipeline::{perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, MAX_OUTPUT_TRIANGLES}, - RenderableGeometry, CheckerboardGeometry, position_gradient_color, + pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, + MAX_OUTPUT_TRIANGLES, + }, + position_gradient_color, CheckerboardGeometry, RenderableGeometry, }; -use rust4d_math::Vec4; -use rust4d_physics::{BodyType, PhysicsMaterial}; -use rust4d_core::hecs; /// Application state struct App { @@ -72,11 +75,26 @@ impl App { // Add falling tesseracts at different heights let spawn_positions = [ - (Vec4::new(0.0, 5.0, 0.0, 0.0), Material::from_rgb(0.9, 0.3, 0.2)), - (Vec4::new(3.0, 8.0, 0.0, 0.0), Material::from_rgb(0.2, 0.9, 0.3)), - (Vec4::new(-3.0, 11.0, 0.0, 0.0), Material::from_rgb(0.2, 0.3, 0.9)), - (Vec4::new(0.0, 14.0, 2.0, 0.0), Material::from_rgb(0.9, 0.9, 0.2)), - (Vec4::new(1.5, 17.0, -1.5, 0.0), Material::from_rgb(0.9, 0.2, 0.9)), + ( + Vec4::new(0.0, 5.0, 0.0, 0.0), + Material::from_rgb(0.9, 0.3, 0.2), + ), + ( + Vec4::new(3.0, 8.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.9, 0.3), + ), + ( + Vec4::new(-3.0, 11.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.3, 0.9), + ), + ( + Vec4::new(0.0, 14.0, 2.0, 0.0), + Material::from_rgb(0.9, 0.9, 0.2), + ), + ( + Vec4::new(1.5, 17.0, -1.5, 0.0), + Material::from_rgb(0.9, 0.2, 0.9), + ), ]; for (i, (position, material)) in spawn_positions.iter().enumerate() { @@ -134,22 +152,29 @@ impl App { fn build_geometry(world: &World) -> RenderableGeometry { let mut geometry = RenderableGeometry::new(); - let checkerboard = CheckerboardGeometry::new( - [0.3, 0.3, 0.35, 1.0], - [0.6, 0.6, 0.65, 1.0], - 2.0, - ); + let checkerboard = + CheckerboardGeometry::new([0.3, 0.3, 0.35, 1.0], [0.6, 0.6, 0.65, 1.0], 2.0); - for (_entity, (transform, shape, material, tags)) in - world.ecs().query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>().iter() + for (_entity, (transform, shape, material, tags)) in world + .ecs() + .query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>() + .iter() { let is_dynamic = tags.map(|t| t.has("dynamic")).unwrap_or(false); if is_dynamic { - geometry.add_components_with_color(transform, shape.as_shape(), material, &position_gradient_color); + geometry.add_components_with_color( + transform, + shape.as_shape(), + material, + &position_gradient_color, + ); } else { - geometry.add_components_with_color(transform, shape.as_shape(), material, &|v, _m| { - checkerboard.color_for_position(v.x, v.z) - }); + geometry.add_components_with_color( + transform, + shape.as_shape(), + material, + &|v, _m| checkerboard.color_for_position(v.x, v.z), + ); } } @@ -171,7 +196,8 @@ impl ApplicationHandler for App { ); let render_context = pollster::block_on(RenderContext::new(window.clone())); - let mut slice_pipeline = SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); + let mut slice_pipeline = + SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); let mut render_pipeline = RenderPipeline::new(&render_context.device, render_context.config.format); @@ -198,12 +224,11 @@ impl ApplicationHandler for App { match event { WindowEvent::CloseRequested => event_loop.exit(), - WindowEvent::KeyboardInput { event, .. } - if event.state == ElementState::Pressed => { - if let PhysicalKey::Code(KeyCode::Escape) = event.physical_key { - event_loop.exit(); - } + WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { + if let PhysicalKey::Code(KeyCode::Escape) = event.physical_key { + event_loop.exit(); } + } WindowEvent::Resized(size) => { if let Some(ctx) = &mut self.render_context { @@ -226,7 +251,8 @@ impl ApplicationHandler for App { if self.world.has_dirty_entities() { self.geometry = Self::build_geometry(&self.world); - if let (Some(sp), Some(ctx)) = (&mut self.slice_pipeline, &self.render_context) { + if let (Some(sp), Some(ctx)) = (&mut self.slice_pipeline, &self.render_context) + { sp.upload_tetrahedra( &ctx.device, &self.geometry.vertices, @@ -254,10 +280,17 @@ impl ApplicationHandler for App { sp.update_params(&ctx.queue, &slice_params); let render_uniforms = RenderUniforms { - view_matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]], + view_matrix: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], projection_matrix: perspective_matrix( - std::f32::consts::FRAC_PI_4, ctx.aspect_ratio(), 0.1, 100.0, + std::f32::consts::FRAC_PI_4, + ctx.aspect_ratio(), + 0.1, + 100.0, ), light_dir: [0.5, 1.0, 0.3], _padding: 0.0, @@ -273,16 +306,27 @@ impl ApplicationHandler for App { Ok(o) => o, Err(_) => return, }; - let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = ctx.device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: None }, - ); + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); sp.reset_counter(&ctx.queue); sp.run_slice_pass(&mut encoder); rp.prepare_indirect_draw(&mut encoder, sp.counter_buffer()); - rp.render(&mut encoder, &view, sp.output_buffer(), - wgpu::Color { r: 0.02, g: 0.02, b: 0.08, a: 1.0 }); + rp.render( + &mut encoder, + &view, + sp.output_buffer(), + wgpu::Color { + r: 0.02, + g: 0.02, + b: 0.08, + a: 1.0, + }, + ); ctx.queue.submit(std::iter::once(encoder.finish())); output.present(); diff --git a/examples/04_camera_exploration.rs b/examples/04_camera_exploration.rs index 5605a1c..afa14da 100644 --- a/examples/04_camera_exploration.rs +++ b/examples/04_camera_exploration.rs @@ -34,17 +34,19 @@ use winit::{ }; use rust4d_core::{ - Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World, Tags, Name, - Hyperplane4D, + DirtyFlags, Hyperplane4D, Material, Name, ShapeRef, Tags, Tesseract4D, Transform4D, World, }; +use rust4d_input::CameraController; +use rust4d_math::Vec4; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, - pipeline::{perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, MAX_OUTPUT_TRIANGLES}, - RenderableGeometry, CheckerboardGeometry, position_gradient_color, + pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, + MAX_OUTPUT_TRIANGLES, + }, + position_gradient_color, CheckerboardGeometry, RenderableGeometry, }; -use rust4d_math::Vec4; -use rust4d_input::CameraController; /// Application state with full camera controller integration struct App { @@ -81,15 +83,47 @@ impl App { // Each one at a different location in 4D space let tesseracts = [ // XYZ positions (W=0) - visible immediately - (Vec4::new(0.0, 0.0, 0.0, 0.0), Material::from_rgb(0.9, 0.4, 0.2), "origin"), - (Vec4::new(5.0, 0.0, 0.0, 0.0), Material::from_rgb(0.2, 0.9, 0.3), "right"), - (Vec4::new(-5.0, 0.0, 0.0, 0.0), Material::from_rgb(0.2, 0.3, 0.9), "left"), - (Vec4::new(0.0, 3.0, 0.0, 0.0), Material::from_rgb(0.9, 0.9, 0.2), "above"), - (Vec4::new(0.0, 0.0, -5.0, 0.0), Material::from_rgb(0.2, 0.9, 0.9), "forward"), + ( + Vec4::new(0.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.9, 0.4, 0.2), + "origin", + ), + ( + Vec4::new(5.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.9, 0.3), + "right", + ), + ( + Vec4::new(-5.0, 0.0, 0.0, 0.0), + Material::from_rgb(0.2, 0.3, 0.9), + "left", + ), + ( + Vec4::new(0.0, 3.0, 0.0, 0.0), + Material::from_rgb(0.9, 0.9, 0.2), + "above", + ), + ( + Vec4::new(0.0, 0.0, -5.0, 0.0), + Material::from_rgb(0.2, 0.9, 0.9), + "forward", + ), // Tesseracts at different W positions (use Q/E to find them!) - (Vec4::new(3.0, 0.0, 3.0, 2.0), Material::from_rgb(0.9, 0.2, 0.9), "w+2"), - (Vec4::new(-3.0, 0.0, 3.0, -2.0), Material::from_rgb(0.6, 0.3, 0.9), "w-2"), - (Vec4::new(0.0, 1.0, -3.0, 4.0), Material::from_rgb(0.3, 0.6, 0.9), "w+4"), + ( + Vec4::new(3.0, 0.0, 3.0, 2.0), + Material::from_rgb(0.9, 0.2, 0.9), + "w+2", + ), + ( + Vec4::new(-3.0, 0.0, 3.0, -2.0), + Material::from_rgb(0.6, 0.3, 0.9), + "w-2", + ), + ( + Vec4::new(0.0, 1.0, -3.0, 4.0), + Material::from_rgb(0.3, 0.6, 0.9), + "w+4", + ), ]; for (position, material, name) in tesseracts { @@ -136,22 +170,29 @@ impl App { fn build_geometry(world: &World) -> RenderableGeometry { let mut geometry = RenderableGeometry::new(); - let checkerboard = CheckerboardGeometry::new( - [0.25, 0.25, 0.30, 1.0], - [0.55, 0.55, 0.60, 1.0], - 2.0, - ); + let checkerboard = + CheckerboardGeometry::new([0.25, 0.25, 0.30, 1.0], [0.55, 0.55, 0.60, 1.0], 2.0); - for (_entity, (transform, shape, material, tags)) in - world.ecs().query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>().iter() + for (_entity, (transform, shape, material, tags)) in world + .ecs() + .query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>() + .iter() { let is_dynamic = tags.map(|t| t.has("dynamic")).unwrap_or(false); if is_dynamic { - geometry.add_components_with_color(transform, shape.as_shape(), material, &position_gradient_color); + geometry.add_components_with_color( + transform, + shape.as_shape(), + material, + &position_gradient_color, + ); } else { - geometry.add_components_with_color(transform, shape.as_shape(), material, &|v, _m| { - checkerboard.color_for_position(v.x, v.z) - }); + geometry.add_components_with_color( + transform, + shape.as_shape(), + material, + &|v, _m| checkerboard.color_for_position(v.x, v.z), + ); } } @@ -161,7 +202,8 @@ impl App { /// Capture cursor for FPS-style controls fn capture_cursor(&mut self) { if let Some(window) = &self.window { - let grab_result = window.set_cursor_grab(CursorGrabMode::Locked) + let grab_result = window + .set_cursor_grab(CursorGrabMode::Locked) .or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined)); if grab_result.is_ok() { @@ -195,7 +237,8 @@ impl ApplicationHandler for App { ); let render_context = pollster::block_on(RenderContext::new(window.clone())); - let mut slice_pipeline = SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); + let mut slice_pipeline = + SlicePipeline::new(&render_context.device, MAX_OUTPUT_TRIANGLES); let mut render_pipeline = RenderPipeline::new(&render_context.device, render_context.config.format); @@ -275,7 +318,10 @@ impl ApplicationHandler for App { WindowEvent::MouseInput { state, button, .. } => { // Click to capture cursor - if state == ElementState::Pressed && button == MouseButton::Left && !self.cursor_captured { + if state == ElementState::Pressed + && button == MouseButton::Left + && !self.cursor_captured + { self.capture_cursor(); } self.controller.process_mouse_button(button, state); @@ -297,7 +343,8 @@ impl ApplicationHandler for App { self.last_frame = now; // Update camera via controller - self.controller.update(&mut self.camera, dt, self.cursor_captured); + self.controller + .update(&mut self.camera, dt, self.cursor_captured); // Update window title with position info if let Some(window) = &self.window { diff --git a/examples/05_audio_demo.rs b/examples/05_audio_demo.rs index a1e814b..dd2a387 100644 --- a/examples/05_audio_demo.rs +++ b/examples/05_audio_demo.rs @@ -179,6 +179,9 @@ fn main() { println!(" - with_min_distance(f32) -> Full volume within this range"); println!(" - with_max_distance(f32) -> Silent beyond this range"); - println!("\nCurrent listener position: {:?}", engine.listener_position()); + println!( + "\nCurrent listener position: {:?}", + engine.listener_position() + ); println!("\nAudio demo complete!"); } diff --git a/examples/headless_protocol.rs b/examples/headless_protocol.rs index 3921637..4b73009 100644 --- a/examples/headless_protocol.rs +++ b/examples/headless_protocol.rs @@ -70,7 +70,10 @@ impl HeadlessGpu { .expect("no GPU adapter available"); let info = adapter.get_info(); - println!("[GPU] adapter: {} ({:?}, {:?})", info.name, info.device_type, info.backend); + println!( + "[GPU] adapter: {} ({:?}, {:?})", + info.name, info.device_type, info.backend + ); let (device, queue) = pollster::block_on(adapter.request_device( &wgpu::DeviceDescriptor { @@ -140,12 +143,7 @@ impl HeadlessGpu { } /// Render one frame exactly like `RenderSystem::render_frame` and save it. - fn capture( - &mut self, - camera: &Camera4D, - tetrahedron_count: u32, - path: &Path, - ) -> u32 { + fn capture(&mut self, camera: &Camera4D, tetrahedron_count: u32, path: &Path) -> u32 { let pos = camera.position; let camera_matrix = camera.rotation_matrix(); let slice_params = SliceParams { @@ -155,7 +153,8 @@ impl HeadlessGpu { camera_matrix, camera_position: [pos.x, pos.y, pos.z, pos.w], }; - self.slice_pipeline.update_params(&self.queue, &slice_params); + self.slice_pipeline + .update_params(&self.queue, &slice_params); let proj = perspective_matrix( 45.0_f32.to_radians(), diff --git a/examples/ron_preview.rs b/examples/ron_preview.rs index 7f31735..3c43ff5 100644 --- a/examples/ron_preview.rs +++ b/examples/ron_preview.rs @@ -35,7 +35,9 @@ use winit::{ window::{CursorGrabMode, Window, WindowId}, }; -use rust4d_core::{Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World}; +use rust4d_core::{DirtyFlags, Material, ShapeRef, Tesseract4D, Transform4D, World}; +use rust4d_input::CameraController; +use rust4d_math::Vec4; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, @@ -43,10 +45,8 @@ use rust4d_render::{ perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, MAX_OUTPUT_TRIANGLES, }, - RenderableGeometry, position_gradient_color, + position_gradient_color, RenderableGeometry, }; -use rust4d_math::Vec4; -use rust4d_input::CameraController; // ============================================================================ // RON Asset Types @@ -229,7 +229,9 @@ impl PreviewApp { PreviewMesh::Hypersphere { segments: _ } => { // TODO: Implement proper hypersphere when available in rust4d_math // For now, use a tesseract as placeholder - println!("Note: Hypersphere not yet implemented, using tesseract as placeholder"); + println!( + "Note: Hypersphere not yet implemented, using tesseract as placeholder" + ); Box::new(Tesseract4D::new(1.0)) } }; @@ -304,7 +306,11 @@ impl PreviewApp { /// Upload geometry to GPU fn upload_geometry(&mut self) { if let (Some(ctx), Some(sp)) = (&self.render_context, &mut self.slice_pipeline) { - sp.upload_tetrahedra(&ctx.device, &self.geometry.vertices, &self.geometry.tetrahedra); + sp.upload_tetrahedra( + &ctx.device, + &self.geometry.vertices, + &self.geometry.tetrahedra, + ); } } diff --git a/examples/shape_showcase.rs b/examples/shape_showcase.rs index 06342e2..3c2121e 100644 --- a/examples/shape_showcase.rs +++ b/examples/shape_showcase.rs @@ -42,7 +42,9 @@ impl ShowcaseShape { fn geometry(&self) -> RenderableGeometry { let shape = self.template.create_shape(); let mut geom = RenderableGeometry::new(); - let material = Material { base_color: self.color }; + let material = Material { + base_color: self.color, + }; geom.add_components_with_color( &Transform4D::identity(), shape.as_ref(), @@ -78,7 +80,10 @@ impl HeadlessGpu { .expect("no GPU adapter available"); let info = adapter.get_info(); - println!("[GPU] adapter: {} ({:?}, {:?})", info.name, info.device_type, info.backend); + println!( + "[GPU] adapter: {} ({:?}, {:?})", + info.name, info.device_type, info.backend + ); let (device, queue) = pollster::block_on(adapter.request_device( &wgpu::DeviceDescriptor { @@ -134,8 +139,11 @@ impl HeadlessGpu { } fn upload_geometry(&mut self, geometry: &RenderableGeometry) { - self.slice_pipeline - .upload_tetrahedra(&self.device, &geometry.vertices, &geometry.tetrahedra); + self.slice_pipeline.upload_tetrahedra( + &self.device, + &geometry.vertices, + &geometry.tetrahedra, + ); println!( "[GPU] uploaded {} vertices, {} tetrahedra", geometry.vertex_count(), @@ -152,7 +160,8 @@ impl HeadlessGpu { camera_matrix: camera.rotation_matrix(), camera_position: [pos.x, pos.y, pos.z, pos.w], }; - self.slice_pipeline.update_params(&self.queue, &slice_params); + self.slice_pipeline + .update_params(&self.queue, &slice_params); let projection_matrix = perspective_matrix( 40.0_f32.to_radians(), @@ -302,7 +311,10 @@ fn shapes() -> Vec { }, ShowcaseShape { name: "hypersphere", - template: ShapeTemplate::Hypersphere { radius: 1.25, subdivisions: 2 }, + template: ShapeTemplate::Hypersphere { + radius: 1.25, + subdivisions: 2, + }, color: [0.35, 0.70, 1.0, 1.0], radius: 1.25, }, @@ -332,19 +344,31 @@ fn shapes() -> Vec { }, ShowcaseShape { name: "spherinder", - template: ShapeTemplate::Spherinder { radius: 1.05, half_height: 1.25, subdivisions: 2 }, + template: ShapeTemplate::Spherinder { + radius: 1.05, + half_height: 1.25, + subdivisions: 2, + }, color: [0.28, 0.95, 0.88, 1.0], radius: 1.65, }, ShowcaseShape { name: "cubinder", - template: ShapeTemplate::Cubinder { radius: 1.05, half_size: 0.9, segments: 32 }, + template: ShapeTemplate::Cubinder { + radius: 1.05, + half_size: 0.9, + segments: 32, + }, color: [0.95, 0.72, 0.24, 1.0], radius: 1.65, }, ShowcaseShape { name: "duocylinder", - template: ShapeTemplate::Duocylinder { radius_xy: 1.0, radius_zw: 1.0, segments: 32 }, + template: ShapeTemplate::Duocylinder { + radius_xy: 1.0, + radius_zw: 1.0, + segments: 32, + }, color: [0.40, 0.65, 1.0, 1.0], radius: 1.45, }, @@ -423,7 +447,11 @@ mod tests { for shape in shapes() { let geom = shape.geometry(); assert!(geom.vertex_count() > 0, "{} has no vertices", shape.name); - assert!(geom.tetrahedron_count() > 0, "{} has no tetrahedra", shape.name); + assert!( + geom.tetrahedron_count() > 0, + "{} has no tetrahedra", + shape.name + ); } } @@ -431,6 +459,9 @@ mod tests { fn test_showcase_gpu_types_still_match() { // Keeps this example honest if the pipeline types change. assert_eq!(std::mem::size_of::(), 32); - assert_eq!(std::mem::size_of::(), 16); + assert_eq!( + std::mem::size_of::(), + 16 + ); } } diff --git a/src/config.rs b/src/config.rs index ef8cf46..eab4264 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,8 +5,11 @@ //! 2. `config/user.toml` (gitignored, user overrides) //! 3. Environment variables (`R4D_SECTION__KEY`) -use figment::{Figment, providers::{Format, Toml, Env}}; -use serde::{Serialize, Deserialize}; +use figment::{ + providers::{Env, Format, Toml}, + Figment, +}; +use serde::{Deserialize, Serialize}; use std::path::Path; // Re-export PhysicsConfig from the physics crate for convenience diff --git a/src/input/input_mapper.rs b/src/input/input_mapper.rs index 23c7989..cb43eab 100644 --- a/src/input/input_mapper.rs +++ b/src/input/input_mapper.rs @@ -107,8 +107,7 @@ mod tests { #[test] fn test_key_release_ignored() { - let action = - InputMapper::map_keyboard(KeyCode::Escape, ElementState::Released, true); + let action = InputMapper::map_keyboard(KeyCode::Escape, ElementState::Released, true); assert_eq!(action, None); } diff --git a/src/input/mod.rs b/src/input/mod.rs index 8411da8..e866883 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4,4 +4,4 @@ mod input_mapper; -pub use input_mapper::{InputMapper, InputAction}; +pub use input_mapper::{InputAction, InputMapper}; diff --git a/src/main.rs b/src/main.rs index 29b6625..2f6c40b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,6 @@ //! //! A 4D rendering engine that displays 3D cross-sections of 4D geometry. - - use winit::{ application::ApplicationHandler, event::{DeviceEvent, DeviceId, WindowEvent}, @@ -12,14 +10,14 @@ use winit::{ window::WindowId, }; -use rust4d::input::{InputMapper, InputAction}; +use rust4d::input::{InputAction, InputMapper}; use rust4d::systems::{build_geometry, RenderError, RenderSystem, SimulationSystem, WindowSystem}; use rust4d_core::SceneManager; -use rust4d_game::{CharacterController4D, CharacterConfig, scene_helpers}; -use rust4d_render::{camera4d::Camera4D, RenderableGeometry}; +use rust4d_game::{scene_helpers, CharacterConfig, CharacterController4D}; use rust4d_input::CameraController; use rust4d_math::Vec4; +use rust4d_render::{camera4d::Camera4D, RenderableGeometry}; use rust4d::config::AppConfig; @@ -53,19 +51,22 @@ impl App { // Create scene manager and load scene from file // Pass physics config from TOML to the physics engine - let mut scene_manager = SceneManager::new() - .with_physics(config.physics.to_physics_config()); + let mut scene_manager = + SceneManager::new().with_physics(config.physics.to_physics_config()); // Load scene from configured path - let scene_name = scene_manager.load_scene(&config.scene.path) + let scene_name = scene_manager + .load_scene(&config.scene.path) .unwrap_or_else(|e| { panic!("Failed to load scene '{}': {}", config.scene.path, e); }); // Instantiate and activate the scene - scene_manager.instantiate(&scene_name) + scene_manager + .instantiate(&scene_name) .unwrap_or_else(|e| panic!("Failed to instantiate scene: {}", e)); - scene_manager.push_scene(&scene_name) + scene_manager + .push_scene(&scene_name) .unwrap_or_else(|e| panic!("Failed to push scene: {}", e)); // Create player body from scene spawn point using scene_helpers @@ -85,24 +86,35 @@ impl App { } // Get player start from scene's player_spawn - let player_start = scene_manager.active_scene() + let player_start = scene_manager + .active_scene() .and_then(|s| s.player_spawn) .map(|spawn| Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3])) - .unwrap_or_else(|| Vec4::new( - config.camera.start_position[0], - config.camera.start_position[1], - config.camera.start_position[2], - config.camera.start_position[3], - )); + .unwrap_or_else(|| { + Vec4::new( + config.camera.start_position[0], + config.camera.start_position[1], + config.camera.start_position[2], + config.camera.start_position[3], + ) + }); // Build GPU geometry from the world let geometry = build_geometry(scene_manager.active_world().unwrap()); - log::info!("Loaded scene '{}' with {} entities", + log::info!( + "Loaded scene '{}' with {} entities", scene_name, - scene_manager.active_world().map(|w| w.entity_count()).unwrap_or(0)); - log::info!("Total geometry: {} vertices, {} tetrahedra", - geometry.vertex_count(), geometry.tetrahedron_count()); + scene_manager + .active_world() + .map(|w| w.entity_count()) + .unwrap_or(0) + ); + log::info!( + "Total geometry: {} vertices, {} tetrahedra", + geometry.vertex_count(), + geometry.tetrahedron_count() + ); // Set camera with configured pitch limit and player start position let mut camera = Camera4D::with_pitch_limit(config.camera.pitch_limit.to_radians()); @@ -122,11 +134,14 @@ impl App { .active_scene() .and_then(|s| s.player_body_key) .map(|key| { - CharacterController4D::new(key, CharacterConfig { - move_speed: config.input.move_speed, - w_move_speed: config.input.w_move_speed, - jump_velocity: config.physics.jump_velocity, - }) + CharacterController4D::new( + key, + CharacterConfig { + move_speed: config.input.move_speed, + w_move_speed: config.input.w_move_speed, + jump_velocity: config.physics.jump_velocity, + }, + ) }); Self { @@ -141,7 +156,6 @@ impl App { simulation: SimulationSystem::new(), } } - } impl ApplicationHandler for App { @@ -182,11 +196,15 @@ impl ApplicationHandler for App { WindowEvent::KeyboardInput { event, .. } => { if let PhysicalKey::Code(key) = event.physical_key { // Map to action via InputMapper - let cursor_captured = self.window_system.as_ref() + let cursor_captured = self + .window_system + .as_ref() .map(|ws| ws.is_cursor_captured()) .unwrap_or(false); - if let Some(action) = InputMapper::map_keyboard(key, event.state, cursor_captured) { + if let Some(action) = + InputMapper::map_keyboard(key, event.state, cursor_captured) + { match action { InputAction::ToggleCursor => { if let Some(ws) = &mut self.window_system { @@ -207,7 +225,10 @@ impl ApplicationHandler for App { } InputAction::ToggleSmoothing => { let enabled = self.controller.toggle_smoothing(); - log::info!("Input smoothing: {}", if enabled { "ON" } else { "OFF" }); + log::info!( + "Input smoothing: {}", + if enabled { "ON" } else { "OFF" } + ); } } return; @@ -220,11 +241,14 @@ impl ApplicationHandler for App { WindowEvent::MouseInput { state, button, .. } => { // Map to action via InputMapper - let cursor_captured = self.window_system.as_ref() + let cursor_captured = self + .window_system + .as_ref() .map(|ws| ws.is_cursor_captured()) .unwrap_or(false); - if let Some(action) = InputMapper::map_mouse_button(button, state, cursor_captured) { + if let Some(action) = InputMapper::map_mouse_button(button, state, cursor_captured) + { if action == InputAction::ToggleCursor { if let Some(ws) = &mut self.window_system { ws.capture_cursor(); @@ -245,7 +269,9 @@ impl ApplicationHandler for App { WindowEvent::RedrawRequested => { // Run simulation - let cursor_captured = self.window_system.as_ref() + let cursor_captured = self + .window_system + .as_ref() .map(|ws| ws.is_cursor_captured()) .unwrap_or(false); diff --git a/src/systems/render.rs b/src/systems/render.rs index 66f23e8..655d2dc 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -5,15 +5,15 @@ //! - Slice and render pipelines //! - Frame rendering -use std::sync::Arc; -use winit::window::Window; +use crate::config::{CameraConfig, RenderingConfig}; use rust4d_render::{ - context::RenderContext, camera4d::Camera4D, + context::RenderContext, pipeline::{perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline}, RenderableGeometry, }; -use crate::config::{CameraConfig, RenderingConfig}; +use std::sync::Arc; +use winit::window::Window; /// Render error types #[derive(Debug)] @@ -57,10 +57,8 @@ impl RenderSystem { ) -> Self { let context = pollster::block_on(RenderContext::with_vsync(window, vsync)); - let slice_pipeline = SlicePipeline::new( - &context.device, - render_config.max_triangles as usize, - ); + let slice_pipeline = + SlicePipeline::new(&context.device, render_config.max_triangles as usize); let mut render_pipeline = RenderPipeline::new(&context.device, context.config.format); @@ -84,7 +82,8 @@ impl RenderSystem { pub fn resize(&mut self, width: u32, height: u32) { self.context .resize(winit::dpi::PhysicalSize::new(width, height)); - self.render_pipeline.ensure_depth_texture(&self.context.device, width, height); + self.render_pipeline + .ensure_depth_texture(&self.context.device, width, height); } /// Upload geometry to GPU @@ -167,12 +166,12 @@ impl RenderSystem { .create_view(&wgpu::TextureViewDescriptor::default()); // Create command encoder - let mut encoder = self - .context - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Render Encoder"), - }); + let mut encoder = + self.context + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }); // Reset counter and run compute pass self.slice_pipeline.reset_counter(&self.context.queue); diff --git a/src/systems/simulation.rs b/src/systems/simulation.rs index 0f2e19a..70a8c14 100644 --- a/src/systems/simulation.rs +++ b/src/systems/simulation.rs @@ -6,12 +6,12 @@ //! - Physics stepping //! - Camera synchronization -use std::time::Instant; use rust4d_core::SceneManager; use rust4d_game::CharacterController4D; use rust4d_input::CameraController; use rust4d_math::Vec4; use rust4d_render::camera4d::Camera4D; +use std::time::Instant; /// Result of a simulation update pub struct SimulationResult { @@ -65,7 +65,14 @@ impl SimulationSystem { let dt = raw_dt.min(0.25); self.last_frame = now; - self.update_with_dt(dt, scene_manager, camera, controller, character, cursor_captured) + self.update_with_dt( + dt, + scene_manager, + camera, + controller, + character, + cursor_captured, + ) } /// Run one simulation frame with an explicit delta time. @@ -86,8 +93,16 @@ impl SimulationSystem { let w_input = controller.get_w_input(); // Guard against NaN/infinity from broken input state - let forward_input = if forward_input.is_finite() { forward_input } else { 0.0 }; - let right_input = if right_input.is_finite() { right_input } else { 0.0 }; + let forward_input = if forward_input.is_finite() { + forward_input + } else { + 0.0 + }; + let right_input = if right_input.is_finite() { + right_input + } else { + 0.0 + }; let w_input = if w_input.is_finite() { w_input } else { 0.0 }; // 3. Calculate movement direction in world space using camera orientation @@ -113,8 +128,7 @@ impl SimulationSystem { // Project to XZW hyperplane (zero out Y for horizontal movement) let forward_xzw = Vec4::new(camera_forward.x, 0.0, camera_forward.z, camera_forward.w).normalized(); - let right_xzw = - Vec4::new(camera_right.x, 0.0, camera_right.z, camera_right.w).normalized(); + let right_xzw = Vec4::new(camera_right.x, 0.0, camera_right.z, camera_right.w).normalized(); let ana_xzw = Vec4::new(camera_ana.x, 0.0, camera_ana.z, camera_ana.w).normalized(); // Combine WASD slice movement; clamp to unit length to prevent faster @@ -132,19 +146,23 @@ impl SimulationSystem { // 4. Apply movement to player via character controller // The controller owns the speeds and scales each component uniformly - if let (Some(character), Some(physics)) = (character, scene_manager - .active_world_mut() - .and_then(|w| w.physics_mut())) - { + if let (Some(character), Some(physics)) = ( + character, + scene_manager + .active_world_mut() + .and_then(|w| w.physics_mut()), + ) { character.apply_movement(physics, slice_dir, ana_dir); } // 5. Handle jump via character controller if controller.consume_jump() { - if let (Some(character), Some(physics)) = (character, scene_manager - .active_world_mut() - .and_then(|w| w.physics_mut())) - { + if let (Some(character), Some(physics)) = ( + character, + scene_manager + .active_world_mut() + .and_then(|w| w.physics_mut()), + ) { character.jump(physics); } } @@ -162,10 +180,10 @@ impl SimulationSystem { // This sets the camera to the physics-authoritative position BEFORE the // controller runs, so controller.update() in step 9 computes rotation // deltas from the correct starting position. - if let (Some(character), Some(physics)) = (character, scene_manager - .active_world() - .and_then(|w| w.physics())) - { + if let (Some(character), Some(physics)) = ( + character, + scene_manager.active_world().and_then(|w| w.physics()), + ) { if let Some(pos) = character.position(physics) { camera.position = pos; } @@ -178,10 +196,10 @@ impl SimulationSystem { // controller.update() in step 9 applies both rotation AND movement. We want // the rotation (mouse look) but not the movement (physics owns position). // Re-syncing here overwrites any position drift the controller introduced. - if let (Some(character), Some(physics)) = (character, scene_manager - .active_world() - .and_then(|w| w.physics())) - { + if let (Some(character), Some(physics)) = ( + character, + scene_manager.active_world().and_then(|w| w.physics()), + ) { if let Some(pos) = character.position(physics) { camera.position = pos; } diff --git a/src/systems/window.rs b/src/systems/window.rs index 948e885..610a8b4 100644 --- a/src/systems/window.rs +++ b/src/systems/window.rs @@ -2,12 +2,12 @@ //! //! Handles window creation, cursor capture/release, fullscreen toggle, and title updates. +use crate::config::WindowConfig; use std::sync::Arc; use winit::{ event_loop::ActiveEventLoop, window::{CursorGrabMode, Fullscreen, Window}, }; -use crate::config::WindowConfig; /// Manages the application window and cursor state pub struct WindowSystem { @@ -24,10 +24,7 @@ impl WindowSystem { ) -> Result { let mut attrs = Window::default_attributes() .with_title(&config.title) - .with_inner_size(winit::dpi::LogicalSize::new( - config.width, - config.height, - )); + .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height)); if config.fullscreen { attrs = attrs.with_fullscreen(Some(Fullscreen::Borderless(None))); @@ -58,7 +55,8 @@ impl WindowSystem { /// Capture cursor for FPS-style controls pub fn capture_cursor(&mut self) -> bool { - let grab_result = self.window + let grab_result = self + .window .set_cursor_grab(CursorGrabMode::Locked) .or_else(|_| self.window.set_cursor_grab(CursorGrabMode::Confined)); diff --git a/tests/gallery_scene.rs b/tests/gallery_scene.rs index 6f2b7b2..39ef45b 100644 --- a/tests/gallery_scene.rs +++ b/tests/gallery_scene.rs @@ -53,14 +53,24 @@ fn gallery_scene_loads_instantiates_and_builds_geometry() { assert!(variants.contains(&expected), "gallery missing {expected}"); } - manager.instantiate(&name).expect("gallery should instantiate"); - manager.push_scene(&name).expect("gallery should become active"); + manager + .instantiate(&name) + .expect("gallery should instantiate"); + manager + .push_scene(&name) + .expect("gallery should become active"); let world = manager.active_world().expect("active gallery world"); let geometry = build_geometry(world); - assert!(geometry.vertex_count() > 2_000, "gallery should contain substantial geometry"); - assert!(geometry.tetrahedron_count() > 8_000, "gallery should upload all primitive meshes"); + assert!( + geometry.vertex_count() > 2_000, + "gallery should contain substantial geometry" + ); + assert!( + geometry.tetrahedron_count() > 8_000, + "gallery should upload all primitive meshes" + ); let active = manager.active_scene().unwrap(); assert_eq!(active.world.entity_count(), 10); diff --git a/tests/game_integration.rs b/tests/game_integration.rs index 82735fa..81db1c2 100644 --- a/tests/game_integration.rs +++ b/tests/game_integration.rs @@ -3,10 +3,8 @@ //! These tests verify the integration between rust4d_game and rust4d_core, //! specifically the CharacterController4D working with ActiveScene and scene_helpers. -use rust4d_core::{ - ActiveScene, Scene, EntityTemplate, Transform4D, Material, ShapeTemplate, -}; -use rust4d_game::{CharacterController4D, CharacterConfig, scene_helpers}; +use rust4d_core::{ActiveScene, EntityTemplate, Material, Scene, ShapeTemplate, Transform4D}; +use rust4d_game::{scene_helpers, CharacterConfig, CharacterController4D}; use rust4d_math::Vec4; /// T1: Integration test between CharacterController4D and ActiveScene @@ -27,7 +25,7 @@ fn test_character_controller_with_active_scene() { Material::GRAY, ) .with_name("floor") - .with_tag("static") + .with_tag("static"), ); // Instantiate scene (player body creation is at the app layer) @@ -36,22 +34,30 @@ fn test_character_controller_with_active_scene() { // Create player body using scene_helpers (the single source of truth) let spawn_pos = Vec4::new(0.0, 1.0, 5.0, 0.0); let player_key = { - let physics = active.world.physics_mut().expect("Scene should have physics"); + let physics = active + .world + .physics_mut() + .expect("Scene should have physics"); scene_helpers::create_player_body(physics, spawn_pos, 0.5) }; active.player_body_key = Some(player_key); // Create CharacterController4D from the player body key - let controller = CharacterController4D::new(player_key, CharacterConfig { - move_speed: 5.0, - w_move_speed: 5.0, - jump_velocity: 10.0, - }); + let controller = CharacterController4D::new( + player_key, + CharacterConfig { + move_speed: 5.0, + w_move_speed: 5.0, + jump_velocity: 10.0, + }, + ); // Verify initial position { let physics = active.world.physics().unwrap(); - let pos = controller.position(physics).expect("Player body should exist"); + let pos = controller + .position(physics) + .expect("Player body should exist"); assert_eq!(pos, spawn_pos, "Initial position should match spawn"); } diff --git a/tests/slice_invariant.rs b/tests/slice_invariant.rs index 0cf4c2b..038c35a 100644 --- a/tests/slice_invariant.rs +++ b/tests/slice_invariant.rs @@ -89,7 +89,10 @@ impl TestRig { }, ) }); - assert!(character.is_some(), "default scene should produce a player body"); + assert!( + character.is_some(), + "default scene should produce a player body" + ); Self { scene_manager, @@ -131,7 +134,8 @@ impl TestRig { } fn release(&mut self, key: KeyCode) { - self.controller.process_keyboard(key, ElementState::Released); + self.controller + .process_keyboard(key, ElementState::Released); } fn debug_state(&self, label: &str) { From 82affaaaa61ea6fea24078dece959ea93c87e602 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:49:14 +0100 Subject: [PATCH 6/8] ci: add production gate and project workflow skills - GitHub Actions CI: fmt, clippy -D warnings, rustdoc -D warnings, workspace tests - 4d-geometry skill: Mesh4D/watertightness/prism-splitting workflow - headless-visual-verification skill: shape_showcase + invariant capture workflow - production-readiness skill: final PR/release checklist --- .agents/skills/4d-geometry/SKILL.md | 52 ++++++++++++++++ .../headless-visual-verification/SKILL.md | 57 ++++++++++++++++++ .agents/skills/production-readiness/SKILL.md | 59 +++++++++++++++++++ .github/workflows/ci.yml | 54 +++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 .agents/skills/4d-geometry/SKILL.md create mode 100644 .agents/skills/headless-visual-verification/SKILL.md create mode 100644 .agents/skills/production-readiness/SKILL.md create mode 100644 .github/workflows/ci.yml diff --git a/.agents/skills/4d-geometry/SKILL.md b/.agents/skills/4d-geometry/SKILL.md new file mode 100644 index 0000000..7b37927 --- /dev/null +++ b/.agents/skills/4d-geometry/SKILL.md @@ -0,0 +1,52 @@ +--- +name: 4d-geometry +description: Build or modify Rust4D primitives and tetrahedral meshes. Use when touching rust4d_math::Mesh4D, primitives, Tesseract4D, ShapeTemplate geometry variants, or any code that creates 4D tetrahedra for slicing. +--- + +# 4D Geometry Workflow + +Rust4D renders **3D slices of 4D boundary meshes**. The renderer wants a closed +3-manifold embedded in 4D, decomposed into tetrahedra. If a primitive is not +structurally watertight, the GPU will show cracks, T-junctions, missing faces, +or interior membranes when sliced. + +## Mandatory checks for every primitive + +1. Construct as `Mesh4D` where possible. +2. Run `mesh.validate()` — catches out-of-bounds and repeated cell indices. +3. Run `mesh.is_watertight()` — every triangular face must be shared by exactly + two tetrahedra. +4. Pin expected cell counts in tests. +5. Pin total boundary 3-volume using `mesh.surface_volume()`: + - exact for regular polytopes, + - convergent-from-below for curved approximations. +6. Render with `cargo run --example shape_showcase .scratchpad/captures-gallery` + and inspect captures. + +## Seam rule + +For composite curved shapes, shared vertices must share **global indices before +splitting prisms**. Use the `VertexPool` pattern from `primitives/curved.rs`. +Do not rely on post-hoc welding to fix seams: welding after splitting does not +fix mismatched quad diagonals. + +Use `primitives::extrude::split_prism`, which applies the Dompierre +lowest-global-index rule so neighboring prisms choose the same diagonals on +shared quad faces. + +## Tesseract warning + +The tesseract must emit only its 48 boundary tetrahedra. The old 84-tet Kuhn +surface included 36 internal membranes, wasting slice work and rendering +spurious interior walls. Any future tesseract edit must keep: + +```rust +assert_eq!(Tesseract4D::new(2.0).tetrahedra().len(), 48); +assert!(Mesh4D::from(&tess as &dyn ConvexShape4D).is_watertight()); +``` + +## Related docs + +- `docs/4d-math.md` — slicing and matrix conventions +- `docs/shapes.md` — shape catalog (when updated) +- `.agents/skills/headless-visual-verification` — GPU capture workflow diff --git a/.agents/skills/headless-visual-verification/SKILL.md b/.agents/skills/headless-visual-verification/SKILL.md new file mode 100644 index 0000000..2637fc0 --- /dev/null +++ b/.agents/skills/headless-visual-verification/SKILL.md @@ -0,0 +1,57 @@ +--- +name: headless-visual-verification +description: Run autonomous visual checks for Rust4D rendering changes. Use after touching WGSL shaders, RenderUniforms, camera projection, slice pipeline, primitives, or examples that affect rendering. +--- + +# Headless Visual Verification + +Willow does not do manual rendering checks for this project. If a change affects +visual output, verify it yourself with offscreen GPU captures. + +## Slice invariant protocol + +Use for camera/movement/physics/slice correctness: + +```bash +nix develop --command cargo run --example headless_protocol .scratchpad/captures +``` + +Read the `[STATE] slice_w(...)` logs. During WASD phases, slice W must remain +constant. Convert frames with ImageMagick if needed. + +## Primitive showcase protocol + +Use for geometry, shader, material, and lighting work: + +```bash +nix develop --command cargo run --example shape_showcase .scratchpad/captures-gallery +``` + +Expected: +- 81 captures (9 primitives × 3 slice offsets × 3 orientations) +- zero zero-triangle captures +- no cracks/T-junctions/hairline seams +- central slices of every primitive visibly distinct + +Make a quick contact sheet: + +```bash +mkdir -p .scratchpad/captures-gallery/png +for f in .scratchpad/captures-gallery/*_mid_identity.ppm; do + magick "$f" ".scratchpad/captures-gallery/png/$(basename "${f%.ppm}").png" +done +magick .scratchpad/captures-gallery/png/tesseract_mid_identity.png \ + .scratchpad/captures-gallery/png/hypersphere_mid_identity.png \ + .scratchpad/captures-gallery/png/pentachoron_mid_identity.png +append row1.png +# Repeat rows, then `magick row1.png row2.png row3.png -append contact-sheet.png` +``` + +Use `+append`/`-append`; ImageMagick `montage` may need fonts unavailable in +the nix shell. + +## GPU warning + +The current wgpu/naga stack may emit Vulkan validation warning +`VUID-StandaloneSpirv-MemorySemantics-10871` for `OpAtomicIAdd` relaxed +semantics. This is harmless for now and tracked as a wgpu upgrade backlog item. +Do not confuse it with a rendering failure. diff --git a/.agents/skills/production-readiness/SKILL.md b/.agents/skills/production-readiness/SKILL.md new file mode 100644 index 0000000..97002df --- /dev/null +++ b/.agents/skills/production-readiness/SKILL.md @@ -0,0 +1,59 @@ +--- +name: production-readiness +description: Final quality gate for Rust4D feature branches. Use before PRs, releases, or after broad engine changes. Covers formatting, tests, clippy, rustdoc, visual captures, docs, and scratchpad handoff. +--- + +# Production Readiness Gate + +Run this before opening a PR or calling a feature branch done. + +## Code gate + +```bash +nix develop --command cargo fmt --all -- --check +nix develop --command cargo clippy --workspace --all-targets -- -D warnings +nix develop --command bash -c 'RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps' +nix develop --command cargo test --workspace +nix develop --command cargo test --test slice_invariant +``` + +No warnings. No ignored failures unless already documented. + +## Visual gate + +For rendering-affecting work: + +```bash +nix develop --command cargo run --example shape_showcase .scratchpad/captures-gallery +nix develop --command cargo run --example headless_protocol .scratchpad/captures +``` + +Inspect at least one contact sheet or representative PNGs. Do not ask Willow to +verify visually. + +## Docs gate + +- README feature list updated +- `docs/README.md` links any new doc +- `docs/developer-guide.md` updated for workflows/architecture changes +- `docs/4d-math.md` updated for math/convention changes +- `docs/shapes.md` updated for primitive changes +- `.agents/skills/*` updated if a workflow changed +- Rustdoc has no broken intra-doc links (`RUSTDOCFLAGS=-Dwarnings` catches this) + +## Scratchpad gate + +- Update the active plan with completed waves and next steps +- Update `scratchpad/board.md` with correct `# ` column names +- Write a short report if ending a substantial session +- Commit and push scratchpad separately from repo code + +## PR gate + +PR body should include: +- Summary +- Why it matters / root cause if a fix +- Verification commands and visual evidence +- Known follow-ups + +Mention exact test counts and capture counts where relevant. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ccd08dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, feature/**] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + RUSTDOCFLAGS: -Dwarnings + +jobs: + test: + name: clippy, docs, tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libasound2-dev \ + libudev-dev \ + pkg-config \ + libwayland-dev \ + libxkbcommon-dev + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Documentation + run: cargo doc --workspace --no-deps + + - name: Tests + run: cargo test --workspace From 9486b07a54e65d9ad4bd8022766551a90f07dfb3 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:54:35 +0100 Subject: [PATCH 7/8] feat(input scripting): semantic camera actions and hecs entity handle round-trips - rust4d_input::ActionMap + CameraAction: default WASD/QE/Space/Shift bindings are now data instead of hardcoded controller matches - CameraController consumes semantic actions, supports custom maps via with_action_map/action_map_mut, and keeps legacy process_keyboard behavior - Lua ECS binding exposes entity_from_bits(bits) and entity:equals(other), with LuaEntity helpers wrapping real hecs::Entity bit patterns even in disconnected stub mode Verification: - cargo test -p rust4d_input -p rust4d_scripting - cargo clippy --workspace --all-targets -- -D warnings - RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps - cargo test --workspace (984 passed) --- crates/rust4d_input/src/action_map.rs | 151 +++++++++++++++++++ crates/rust4d_input/src/camera_controller.rs | 101 ++++++++----- crates/rust4d_input/src/lib.rs | 2 + crates/rust4d_scripting/src/bindings/ecs.rs | 94 +++++++++--- 4 files changed, 293 insertions(+), 55 deletions(-) create mode 100644 crates/rust4d_input/src/action_map.rs diff --git a/crates/rust4d_input/src/action_map.rs b/crates/rust4d_input/src/action_map.rs new file mode 100644 index 0000000..a88b5e0 --- /dev/null +++ b/crates/rust4d_input/src/action_map.rs @@ -0,0 +1,151 @@ +//! Semantic input actions and key bindings. +//! +//! `CameraController` should not know that "forward" means the W key or that +//! "ana" means Q. Those are bindings, not behavior. [`ActionMap`] keeps the +//! default Rust4D controls while allowing examples, config files, and future +//! UI editors to rebind movement without touching camera math. + +use winit::keyboard::KeyCode; + +/// High-level camera/controller actions. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CameraAction { + /// Move forward in the current slice plane + MoveForward, + /// Move backward in the current slice plane + MoveBackward, + /// Strafe left in the current slice plane + MoveLeft, + /// Strafe right in the current slice plane + MoveRight, + /// Move upward along world/camera Y + MoveUp, + /// Move downward along world/camera Y + MoveDown, + /// Move ana (+W in camera-local 4D space) + MoveAna, + /// Move kata (-W in camera-local 4D space) + MoveKata, +} + +/// Key binding table for camera actions. +/// +/// Multiple keys may trigger the same action, and one key may intentionally +/// trigger multiple actions (e.g. Space currently means both upward movement +/// and a one-shot jump in physics mode, handled by `CameraController`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionMap { + bindings: Vec<(KeyCode, CameraAction)>, +} + +impl Default for ActionMap { + fn default() -> Self { + Self { + bindings: vec![ + (KeyCode::KeyW, CameraAction::MoveForward), + (KeyCode::KeyS, CameraAction::MoveBackward), + (KeyCode::KeyA, CameraAction::MoveLeft), + (KeyCode::KeyD, CameraAction::MoveRight), + (KeyCode::Space, CameraAction::MoveUp), + (KeyCode::ShiftLeft, CameraAction::MoveDown), + (KeyCode::ShiftRight, CameraAction::MoveDown), + (KeyCode::KeyQ, CameraAction::MoveAna), + (KeyCode::KeyE, CameraAction::MoveKata), + ], + } + } +} + +impl ActionMap { + /// Create the default Rust4D camera bindings. + pub fn new() -> Self { + Self::default() + } + + /// Create an empty action map. + pub fn empty() -> Self { + Self { + bindings: Vec::new(), + } + } + + /// Bind `key` to `action`, keeping any existing bindings. + pub fn bind(&mut self, key: KeyCode, action: CameraAction) { + if !self.bindings.contains(&(key, action)) { + self.bindings.push((key, action)); + } + } + + /// Remove every action bound to `key`. + pub fn unbind_key(&mut self, key: KeyCode) { + self.bindings.retain(|(bound_key, _)| *bound_key != key); + } + + /// Remove this exact key/action binding. + pub fn unbind(&mut self, key: KeyCode, action: CameraAction) { + self.bindings + .retain(|(bound_key, bound_action)| *bound_key != key || *bound_action != action); + } + + /// Return the actions bound to `key` in insertion order. + pub fn actions_for_key(&self, key: KeyCode) -> impl Iterator + '_ { + self.bindings + .iter() + .filter_map(move |(bound_key, action)| (*bound_key == key).then_some(*action)) + } + + /// True if `key` is bound to any action. + pub fn handles_key(&self, key: KeyCode) -> bool { + self.bindings.iter().any(|(bound_key, _)| *bound_key == key) + } + + /// Read-only binding list, useful for UI/debug display. + pub fn bindings(&self) -> &[(KeyCode, CameraAction)] { + &self.bindings + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_bindings_match_legacy_controls() { + let map = ActionMap::default(); + assert_eq!( + map.actions_for_key(KeyCode::KeyW).collect::>(), + vec![CameraAction::MoveForward] + ); + assert_eq!( + map.actions_for_key(KeyCode::KeyQ).collect::>(), + vec![CameraAction::MoveAna] + ); + assert_eq!( + map.actions_for_key(KeyCode::ShiftRight).collect::>(), + vec![CameraAction::MoveDown] + ); + } + + #[test] + fn bind_and_unbind_key() { + let mut map = ActionMap::empty(); + map.bind(KeyCode::ArrowUp, CameraAction::MoveForward); + map.bind(KeyCode::ArrowUp, CameraAction::MoveForward); + assert_eq!(map.bindings().len(), 1, "duplicate bindings are ignored"); + assert!(map.handles_key(KeyCode::ArrowUp)); + map.unbind_key(KeyCode::ArrowUp); + assert!(!map.handles_key(KeyCode::ArrowUp)); + } + + #[test] + fn one_key_can_drive_multiple_actions() { + let mut map = ActionMap::empty(); + map.bind(KeyCode::KeyR, CameraAction::MoveForward); + map.bind(KeyCode::KeyR, CameraAction::MoveAna); + let actions: Vec<_> = map.actions_for_key(KeyCode::KeyR).collect(); + assert_eq!( + actions, + vec![CameraAction::MoveForward, CameraAction::MoveAna] + ); + } +} diff --git a/crates/rust4d_input/src/camera_controller.rs b/crates/rust4d_input/src/camera_controller.rs index a18134c..e5307da 100644 --- a/crates/rust4d_input/src/camera_controller.rs +++ b/crates/rust4d_input/src/camera_controller.rs @@ -8,6 +8,7 @@ //! - Mouse drag: 3D camera rotation //! - Right-click + drag: W-axis rotation +use crate::{ActionMap, CameraAction}; use rust4d_math::Vec4; use winit::event::{ElementState, MouseButton}; use winit::keyboard::KeyCode; @@ -37,6 +38,9 @@ pub struct CameraController { smooth_yaw: f32, smooth_pitch: f32, + // Semantic key bindings + action_map: ActionMap, + // Configuration pub move_speed: f32, pub w_move_speed: f32, @@ -80,54 +84,55 @@ impl CameraController { w_rotation_sensitivity: 0.005, smoothing_half_life: 0.05, // 50ms half-life when enabled smoothing_enabled: false, // Disabled by default for responsive FPS feel + action_map: ActionMap::default(), } } - /// Process keyboard input - pub fn process_keyboard(&mut self, key: KeyCode, state: ElementState) -> bool { - let pressed = state == ElementState::Pressed; + /// Replace the key/action binding map. + pub fn with_action_map(mut self, action_map: ActionMap) -> Self { + self.action_map = action_map; + self + } - match key { - KeyCode::KeyW => { - self.forward = pressed; - true - } - KeyCode::KeyS => { - self.backward = pressed; - true - } - KeyCode::KeyA => { - self.left = pressed; - true - } - KeyCode::KeyD => { - self.right = pressed; - true - } - KeyCode::KeyQ => { - self.ana = pressed; - true - } - KeyCode::KeyE => { - self.kata = pressed; - true - } - KeyCode::Space => { + /// Read the active key/action binding map. + pub fn action_map(&self) -> &ActionMap { + &self.action_map + } + + /// Mutate the active key/action binding map. + pub fn action_map_mut(&mut self) -> &mut ActionMap { + &mut self.action_map + } + + /// Process a semantic camera action. + pub fn process_action(&mut self, action: CameraAction, state: ElementState) { + let pressed = state == ElementState::Pressed; + match action { + CameraAction::MoveForward => self.forward = pressed, + CameraAction::MoveBackward => self.backward = pressed, + CameraAction::MoveLeft => self.left = pressed, + CameraAction::MoveRight => self.right = pressed, + CameraAction::MoveUp => { self.up = pressed; - // Also track jump for physics mode if pressed { self.jump_pressed = true; } - true - } - KeyCode::ShiftLeft | KeyCode::ShiftRight => { - self.down = pressed; - true } - _ => false, + CameraAction::MoveDown => self.down = pressed, + CameraAction::MoveAna => self.ana = pressed, + CameraAction::MoveKata => self.kata = pressed, } } + /// Process keyboard input through the active [`ActionMap`]. + pub fn process_keyboard(&mut self, key: KeyCode, state: ElementState) -> bool { + let actions: Vec<_> = self.action_map.actions_for_key(key).collect(); + for action in &actions { + self.process_action(*action, state); + } + !actions.is_empty() + } + /// Process mouse button input pub fn process_mouse_button(&mut self, button: MouseButton, state: ElementState) { let pressed = state == ElementState::Pressed; @@ -955,6 +960,30 @@ mod tests { assert!((camera.yaw_rotated - 1.0).abs() < 0.001); } + #[test] + fn test_custom_action_map_rebinds_forward() { + let mut map = ActionMap::empty(); + map.bind(KeyCode::ArrowUp, CameraAction::MoveForward); + let mut controller = CameraController::new().with_action_map(map); + let mut camera = MockCamera::new(); + + assert!(!controller.process_keyboard(KeyCode::KeyW, ElementState::Pressed)); + assert!(controller.process_keyboard(KeyCode::ArrowUp, ElementState::Pressed)); + assert_eq!(controller.get_movement_input(), (1.0, 0.0)); + + controller.update(&mut camera, 1.0, false); + assert_eq!(camera.forward_moved, controller.move_speed); + } + + #[test] + fn test_process_action_bypasses_keyboard_layout() { + let mut controller = CameraController::new(); + controller.process_action(CameraAction::MoveAna, ElementState::Pressed); + assert_eq!(controller.get_w_input(), 1.0); + controller.process_action(CameraAction::MoveAna, ElementState::Released); + assert_eq!(controller.get_w_input(), 0.0); + } + #[test] fn test_update_returns_camera_position() { let mut controller = CameraController::new(); diff --git a/crates/rust4d_input/src/lib.rs b/crates/rust4d_input/src/lib.rs index d01fdd8..3375458 100644 --- a/crates/rust4d_input/src/lib.rs +++ b/crates/rust4d_input/src/lib.rs @@ -3,6 +3,8 @@ //! This crate provides input handling for 4D camera control, //! replicating 4D Golf-style controls. +mod action_map; mod camera_controller; +pub use action_map::{ActionMap, CameraAction}; pub use camera_controller::{CameraControl, CameraController}; diff --git a/crates/rust4d_scripting/src/bindings/ecs.rs b/crates/rust4d_scripting/src/bindings/ecs.rs index f81f805..ad62e8e 100644 --- a/crates/rust4d_scripting/src/bindings/ecs.rs +++ b/crates/rust4d_scripting/src/bindings/ecs.rs @@ -17,14 +17,16 @@ //! //! This module is owned by Scripting-ECS-Agent. -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use mlua::prelude::*; use rust4d_core::hecs::Entity; -/// Counter for generating incrementing fake entity IDs in stub mode. -/// This ensures each spawned entity has a unique ID for comparison purposes. -static STUB_ENTITY_COUNTER: AtomicU32 = AtomicU32::new(1); +/// Counter for generating deterministic stub entity IDs when no engine World +/// is connected. These are still wrapped as real `hecs::Entity` bit patterns, +/// so scripts can store, compare, and round-trip handles using the same API +/// real engine entities will use. +static STUB_ENTITY_COUNTER: AtomicU64 = AtomicU64::new(1); /// Track whether we've logged the "ECS not connected" warning. static ECS_WARNED: AtomicBool = AtomicBool::new(false); @@ -34,9 +36,32 @@ static ECS_WARNED: AtomicBool = AtomicBool::new(false); /// Provides methods for entity introspection and component access. /// Note: Component get/set operations are currently stubs pending /// engine integration with the actual hecs::World. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct LuaEntity(pub Entity); +impl LuaEntity { + /// Wrap a real `hecs::Entity` for Lua. + pub fn from_entity(entity: Entity) -> Self { + Self(entity) + } + + /// Reconstruct a Lua entity handle from its stable hecs bit pattern. + pub fn from_bits(bits: u64) -> Option { + Entity::from_bits(bits).map(Self) + } + + /// Create a deterministic stub handle for development mode. + fn stub(next_id: u64) -> LuaResult { + // hecs Entity bits: high 32 bits = generation, low 32 bits = id. + // Generation 1 is enough for stub handles and keeps the bit pattern + // valid for `Entity::from_bits`. + let generation: u64 = 1; + let entity_bits = (generation << 32) | (next_id & 0xffff_ffff); + Self::from_bits(entity_bits) + .ok_or_else(|| LuaError::RuntimeError("failed to create stub entity handle".into())) + } +} + impl LuaUserData for LuaEntity { fn add_methods>(methods: &mut M) { // entity:id() -> u64 @@ -48,6 +73,12 @@ impl LuaUserData for LuaEntity { // Useful for storing entity references externally or serialization. methods.add_method("to_bits", |_, this, ()| Ok(this.0.to_bits().get())); + // entity:equals(other) -> bool + methods.add_method("equals", |_, this, other: LuaAnyUserData| { + let other = other.borrow::()?; + Ok(this.0 == other.0) + }); + // entity:get(component_name) -> table or nil // Stub: Returns nil. Real implementation needs World access. methods.add_method("get", |_, this, component: String| { @@ -126,19 +157,8 @@ pub fn register(lua: &Lua) -> LuaResult<()> { log::trace!("[ecs] component: {}", pair.0); } - // Generate incrementing fake entity ID (MEDIUM-8) - // This ensures each entity can be compared correctly let fake_id = STUB_ENTITY_COUNTER.fetch_add(1, Ordering::Relaxed); - - // Create a fake Entity with the incrementing ID - // hecs Entity bits encoding: high 32 bits = generation (must be non-zero), low 32 bits = id - // We use generation 1 for all stub entities - let generation: u64 = 1; - let entity_bits = (generation << 32) | (fake_id as u64); - let entity = Entity::from_bits(entity_bits) - .expect("Entity::from_bits should succeed with valid generation"); - - Ok(LuaEntity(entity)) + LuaEntity::stub(fake_id) })?, )?; @@ -191,6 +211,15 @@ pub fn register(lua: &Lua) -> LuaResult<()> { })?, )?; + // world.entity_from_bits(bits) -> LuaEntity or nil + // Reconstructs an entity handle from `entity:to_bits()`. This is already + // the real hecs handle format, so once a World bridge exists scripts do + // not need a migration. + world_table.set( + "entity_from_bits", + lua.create_function(|_, bits: u64| Ok(LuaEntity::from_bits(bits)))?, + )?; + // world.despawn(entity) // Removes an entity from the world. // Stub: No-op, just logs. @@ -243,6 +272,7 @@ mod tests { assert!(world.contains_key("query").unwrap()); assert!(world.contains_key("find_by_name").unwrap()); assert!(world.contains_key("despawn").unwrap()); + assert!(world.contains_key("entity_from_bits").unwrap()); assert!(world.contains_key("entity_count").unwrap()); } @@ -421,14 +451,40 @@ mod tests { let generation: u64 = 1; let id: u64 = 42; let bits = (generation << 32) | id; - let entity = - LuaEntity(Entity::from_bits(bits).expect("should create entity from valid bits")); + let entity = LuaEntity::from_bits(bits).expect("should create entity from valid bits"); // Entity created with id 42 should have id 42 assert_eq!(entity.0.id(), 42); // to_bits returns a non-zero value assert!(entity.0.to_bits().get() > 0); } + #[test] + fn test_entity_from_bits_round_trip() { + let lua = create_lua_with_ecs(); + lua.load( + r#" + local e1 = world.spawn({ name = "roundtrip" }) + local bits = e1:to_bits() + local e2 = world.entity_from_bits(bits) + assert(e2 ~= nil, "entity_from_bits should reconstruct handle") + assert(e1:equals(e2), "round-tripped handle should compare equal") + assert(e1:id() == e2:id(), "round-tripped handle should keep id") + "#, + ) + .exec() + .expect("entity handles should round-trip through bits"); + } + + #[test] + fn test_entity_from_bits_rejects_zero() { + let lua = create_lua_with_ecs(); + let result: LuaValue = lua + .load("return world.entity_from_bits(0)") + .eval() + .expect("entity_from_bits should not error on invalid bits"); + assert!(result.is_nil()); + } + #[test] fn test_spawned_entities_have_unique_ids() { // Test that multiple spawned entities get unique IDs (MEDIUM-8 fix) From 30d97a1cea315f3f651d8fff2a52c6509301b742 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 10:57:30 +0100 Subject: [PATCH 8/8] docs: document primitive catalog, verification, and changelog - docs/shapes.md: full built-in shape catalog with construction math, cell counts, RON snippets, slice behavior, and verification workflow - README/docs index/examples README link the catalog and shape_showcase - user/developer guides point to the full catalog and update tesseract boundary-cell details - CHANGELOG records PR #15 and the engine-expansion branch Verification: - cargo fmt --all -- --check - cargo clippy --workspace --all-targets -- -D warnings - RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps - cargo test --workspace (984 passed) --- CHANGELOG.md | 78 ++++++++++++ README.md | 3 +- docs/4d-math.md | 1 + docs/README.md | 1 + docs/developer-guide.md | 7 +- docs/shapes.md | 257 ++++++++++++++++++++++++++++++++++++++++ docs/user-guide.md | 9 ++ examples/README.md | 1 + 8 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/shapes.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d19c8d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to Rust4D are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +Conventional Commits. + +## Unreleased — Engine Expansion + +### Added + +- General `Mesh4D` tetrahedral mesh type with merge, transform, weld, + validation, Gram-determinant cell volumes, and watertightness checks. +- Full primitive catalog: + - tesseract (fixed boundary-only tetrahedralization), + - hypersphere, + - regular 5-cell, 16-cell, 24-cell, 600-cell, + - spherinder, + - cubinder, + - duocylinder. +- RON `ShapeTemplate` variants for all primitives, including defaulted + resolution fields. +- Shape-aware physics collider hints for scene instantiation. +- `scenes/gallery.ron` with all primitive exhibits. +- `examples/shape_showcase.rs`, an offscreen visual verification harness for + the full primitive catalog. +- Two-sided Blinn-Phong lighting with specular highlights, point lights, and + distance fog. +- `rust4d_input::ActionMap` and `CameraAction` for semantic camera bindings. +- Lua ECS entity handle bit round-tripping via `world.entity_from_bits(bits)` + and `entity:equals(other)`. +- GitHub Actions CI: formatting, clippy with `-D warnings`, rustdoc with + `-D warnings`, and workspace tests. +- Project skills for 4D geometry, headless visual verification, and production + readiness. +- Shape catalog documentation (`docs/shapes.md`). + +### Changed + +- `CameraController` now processes semantic actions from an `ActionMap` while + preserving the legacy keyboard defaults. +- Workspace is now `rustfmt` clean. +- Rendering disables back-face culling because slice-generated triangle winding + is not stable across all marching-tetrahedra cases. + +### Fixed + +- Tesseract geometry now emits only the 48 boundary tetrahedra. The previous + 84-tetrahedron Kuhn-derived surface included 36 internal membranes, wasting + GPU slice work and producing spurious interior walls when viewed from inside. + +## PR #15 — 4D Rendering Debug Fix + +### Added + +- `tests/slice_invariant.rs`, an end-to-end invariant suite for camera, + physics, controller, and simulation movement. +- `examples/headless_protocol.rs`, an offscreen GPU visual verification harness + for slice-plane drift and projection issues. +- `flake.nix` dev shell with Rust, Vulkan wiring, lavapipe, and image tools. +- `docs/4d-math.md`, documenting rotors, SkipY, slicing, projection, movement + invariants, and matrix conventions. +- Minimal `AGENTS.md` plus progressive-disclosure skills. + +### Fixed + +- Long-standing 4D movement bug: WASD movement after 4D rotation drifted across + the slice plane because world axes were scaled anisotropically. Speeds now + scale semantic movement inputs instead. +- Perspective matrix depth range now matches wgpu `[0, 1]` rather than OpenGL + `[-1, 1]`. +- `rotate_w` and `rotate_xw` now operate in their documented 4D planes after + SkipY remapping. +- Removed dead `camera_eye` from `SliceParams`. + +### Quality + +- Workspace clippy-clean and rustdoc-clean at merge time. +- Windowed and headless visual verification performed. diff --git a/README.md b/README.md index 58c6cf7..466a6db 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This is not just a visualization trick - the engine actually computes 4D geometr ## Features -- **True 4D Geometry**: All primitives are mathematically defined in 4D space +- **True 4D Geometry**: tesseract, hypersphere, spherinder, cubinder, duocylinder, and the regular 5-cell/16-cell/24-cell/600-cell — all mathematically defined in 4D space - **GPU-Accelerated Slicing**: Compute shaders slice tetrahedra in parallel - **4D Physics**: Gravity, collision detection, and rigid body dynamics in 4D - **FPS-Style Controls**: Navigate 4D space with intuitive WASD + Q/E controls @@ -194,6 +194,7 @@ See [examples/README.md](examples/README.md) for the full example index and lear | [User Guide](docs/user-guide.md) — full feature reference | All users | | [Developer Guide](docs/developer-guide.md) — architecture, algorithms, testing, contributing | Contributors | | [The Mathematics of Rust4D](docs/4d-math.md) — rotors, SkipY, slicing, the slice invariant, conventions | Contributors | +| [Shape Catalog](docs/shapes.md) — built-in 4D primitives, construction math, RON snippets, verification | Users & contributors | | [ARCHITECTURE.md](ARCHITECTURE.md) — crate structure and data flow | Contributors | ## Inspiration diff --git a/docs/4d-math.md b/docs/4d-math.md index 91bb3c8..e0facc0 100644 --- a/docs/4d-math.md +++ b/docs/4d-math.md @@ -8,6 +8,7 @@ mismatches between these layers. Related reading: - [Developer Guide](./developer-guide.md) — architecture and algorithms +- [Shape Catalog](./shapes.md) — built-in primitives, construction math, scene syntax - [Getting Started](./getting-started.md) — intuition for 4D space - `.agents/skills/` — condensed working rules for these conventions diff --git a/docs/README.md b/docs/README.md index f280c4a..903fa25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Welcome to the Rust4D documentation. This is a 4D rendering engine written in Ru | [User Guide](./user-guide.md) | Comprehensive feature reference | All users | | [Developer Guide](./developer-guide.md) | Architecture, algorithms, contributing | Contributors | | [The Mathematics of Rust4D](./4d-math.md) | Rotors, SkipY, slicing, the slice invariant, conventions | Contributors | +| [Shape Catalog](./shapes.md) | Built-in 4D primitives, construction math, scene syntax, verification | Users & contributors | ## Quick Start diff --git a/docs/developer-guide.md b/docs/developer-guide.md index f343e71..f475c00 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -323,8 +323,13 @@ The trait requires: - `tetrahedra()`: Decomposition into 3-simplices for slicing Current implementations: -- `Tesseract4D`: 16 vertices, decomposed into tetrahedra via Kuhn triangulation +- `Tesseract4D`: 16 vertices, 48 boundary tetrahedra (8 cubic facets × 6) - `Hyperplane4D`: Bounded 4D floor plane +- `Mesh4D` primitives (`rust4d_math::primitives`): hypersphere, 5-cell, + 16-cell, 24-cell, 600-cell, spherinder, cubinder, duocylinder + +See the [Shape Catalog](./shapes.md) for construction details, scene syntax, +cell counts, and the visual verification workflow. ### rust4d_core diff --git a/docs/shapes.md b/docs/shapes.md new file mode 100644 index 0000000..458c6a1 --- /dev/null +++ b/docs/shapes.md @@ -0,0 +1,257 @@ +# Rust4D Shape Catalog + +Rust4D renders 4D objects by slicing a **tetrahedralized 3D boundary mesh** +with the camera's 3D hyperplane. This catalog documents every built-in shape, +how it is constructed, what it looks like under slicing, and how to use it in +RON scene files. + +For the math behind slicing, camera conventions, and W-depth, read +[The Mathematics of Rust4D](./4d-math.md). For the implementation details, +see `rust4d_math::Mesh4D` and `rust4d_math::primitives`. + +--- + +## Invariants for every shape + +Every production primitive is tested for: + +1. **Index validity** — all tetrahedra reference valid, distinct vertices. +2. **Watertightness** — every triangular face is shared by exactly two + tetrahedra (`Mesh4D::is_watertight`). This prevents slice cracks and + T-junctions. +3. **Boundary 3-volume** — exact for regular polytopes, convergent from below + for curved approximations. +4. **Visual rendering** — `cargo run --example shape_showcase` captures every + primitive at multiple slice offsets and 4D orientations. + +The renderer wants only boundary tetrahedra, not interior volume cells. This is +why the tesseract now emits 48 boundary tetrahedra rather than the older 84-tet +Kuhn complex that included 36 internal membranes. + +--- + +## RON scene syntax + +Shapes are serialized through `ShapeTemplate(type: "...")`: + +```ron +shape: ShapeTemplate(type: "Hypersphere", radius: 1.25, subdivisions: 2) +``` + +Resolution fields have defaults, so you may omit them: + +```ron +shape: ShapeTemplate(type: "Duocylinder", radius_xy: 1.0, radius_zw: 1.0) +``` + +All shapes are created in local space and positioned by `Transform4D`. + +--- + +## Tesseract + +**Type**: `Tesseract` +**Parameters**: `size` +**Cells**: 48 tetrahedra (8 cubic facets × 6 tetrahedra) + +The tesseract is the 4D hypercube. At a W-aligned slice through its center it +appears as a cube. Moving the slice through W shrinks/translates the visible +cross-section depending on orientation, just as a cube passing through a 2D +plane would show changing square cross-sections. + +```ron +shape: ShapeTemplate(type: "Tesseract", size: 2.0) +``` + +Implementation note: the boundary is induced by the 4D Kuhn triangulation and +filtered to only tetrahedra whose four vertices share a fixed coordinate bit. + +--- + +## Hypersphere + +**Type**: `Hypersphere` +**Parameters**: `radius`, `subdivisions = 2` +**Cells**: `16 · 8^subdivisions` + +A solid 4-ball bounded by a 3-sphere (`S³`, also called a glome). Its 3D slice +is a sphere that grows to full size and shrinks away as the camera moves along +ana/kata. + +```ron +shape: ShapeTemplate(type: "Hypersphere", radius: 1.25, subdivisions: 2) +``` + +Construction: start from the 16-cell, recursively split every tetrahedron into +8, and reproject midpoints to the sphere. The boundary 3-volume converges from +below to `2π²r³`. + +Recommended subdivisions: + +| Level | Cells | Use | +|-------|-------|-----| +| 0 | 16 | debugging / low-poly style | +| 1 | 128 | cheap rounded object | +| 2 | 1024 | default gameplay quality | +| 3 | 8192 | hero object | + +--- + +## Pentachoron / 5-cell + +**Type**: `Pentachoron` +**Parameters**: `circumradius` +**Cells**: 5 tetrahedra + +The 4-simplex: the simplest regular 4-polytope and the 4D analogue of the +tetrahedron. Its boundary is five regular tetrahedra. Slices are angular and +simple, useful as a diagnostic for degenerate cases because it has the fewest +possible cells. + +```ron +shape: ShapeTemplate(type: "Pentachoron", circumradius: 1.3) +``` + +--- + +## Hexadecachoron / 16-cell + +**Type**: `Hexadecachoron` +**Parameters**: `circumradius` +**Cells**: 16 tetrahedra + +The 4D orthoplex: vertices lie at `±r` on the four coordinate axes. It is the +4D analogue of the octahedron and the dual of the tesseract. Its clean +axis-aligned construction makes it a useful base mesh for hypersphere +subdivision. + +```ron +shape: ShapeTemplate(type: "Hexadecachoron", circumradius: 1.35) +``` + +--- + +## Icositetrachoron / 24-cell + +**Type**: `Icositetrachoron` +**Parameters**: `circumradius` +**Cells**: 96 tetrahedra (24 octahedral cells × 4 tetrahedra) + +The 24-cell is unique to four dimensions: it has no 3D or 5D analogue. It is +self-dual and has octahedral cells. Rust4D constructs it from permutations of +`(±1, ±1, 0, 0)`, then finds the 24 octahedral cells as support sets of the +dual 24-cell and splits each octahedron into four tetrahedra. + +```ron +shape: ShapeTemplate(type: "Icositetrachoron", circumradius: 1.5) +``` + +Use it when you want something unmistakably 4D but still cheap to render. + +--- + +## Hexacosichoron / 600-cell + +**Type**: `Hexacosichoron` +**Parameters**: `circumradius` +**Cells**: 600 tetrahedra + +The 600-cell is the 4D analogue of the icosahedron and the most intricate +regular primitive currently shipped. It has 120 vertices and 600 tetrahedral +cells. + +```ron +shape: ShapeTemplate(type: "Hexacosichoron", circumradius: 1.2) +``` + +Construction: vertices are the 120 unit quaternions of the binary icosahedral +group: + +- 8 coordinate-axis points, permutations of `(±1, 0, 0, 0)` +- 16 half-points `(±½, ±½, ±½, ±½)` +- 96 golden points from even permutations of `(±φ/2, ±1/2, ±1/(2φ), 0)` + +The edge graph uses distance `r/φ`; the 600 cells are exactly the 4-cliques of +that graph. Tests pin 120 vertices, 600 cells, watertightness, and the expected +regular-tetrahedron boundary volume. + +--- + +## Spherinder + +**Type**: `Spherinder` +**Parameters**: `radius`, `half_height`, `subdivisions = 2` +**Cells**: `5 ×` the number of icosphere triangles + +A 3-ball extruded along W: `B³ × segment`. It is the most literal 4D analogue +of a cylinder. A W-aligned slice shows a sphere that stays approximately +constant-sized through the tube, then disappears at the caps. + +```ron +shape: ShapeTemplate(type: "Spherinder", radius: 1.05, half_height: 1.25) +``` + +Boundary pieces: + +- two 3-ball caps, +- an `S² × segment` tube built from triangular prisms. + +--- + +## Cubinder + +**Type**: `Cubinder` +**Parameters**: `radius`, `half_size`, `segments = 24` +**Cells**: `18 × segments` + +A disk in XY crossed with a square in ZW: `D² × square`. It blends curved and +flat direction pairs, making it useful for testing how slicing behaves when a +shape has both cylindrical and prismatic structure. + +```ron +shape: ShapeTemplate(type: "Cubinder", radius: 1.05, half_size: 0.9, segments: 32) +``` + +Boundary pieces: + +- `S¹ × square` curved shell, +- `D² ×` each square edge. + +--- + +## Duocylinder + +**Type**: `Duocylinder` +**Parameters**: `radius_xy`, `radius_zw`, `segments = 24` +**Cells**: `6 × segments²` + +A product of two disks: `D² × D²`. The boundary consists of two solid-torus +pieces, `S¹ × D²` and `D² × S¹`, glued along a Clifford torus `S¹ × S¹`. +This is one of the most characteristically 4D objects in the engine. + +```ron +shape: ShapeTemplate(type: "Duocylinder", radius_xy: 1.0, radius_zw: 1.0, segments: 32) +``` + +At different 4D rotations it produces dramatically different 3D slices, +including tube-like and ball-like sections. Use `shape_showcase` to inspect it +from identity, XW, and ZW orientations. + +--- + +## Visual verification + +Generate captures for the entire catalog: + +```bash +nix develop --command cargo run --example shape_showcase .scratchpad/captures-gallery +``` + +Expected output: + +- 81 PPM files (`9 shapes × 3 offsets × 3 orientations`) +- zero zero-triangle captures +- a nonzero triangle count logged for every frame + +Representative contact sheets from this branch are in `.scratchpad/` during +development; they are intentionally gitignored. diff --git a/docs/user-guide.md b/docs/user-guide.md index 4cb86b1..6dbeeb9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -249,6 +249,9 @@ For physics, hyperplanes are represented separately as collision primitives. #### How Shapes Appear When Sliced +For a complete catalog of built-in 4D shapes and their slice behavior, see the +[Shape Catalog](./shapes.md). + The camera views 4D space by taking a 3D cross-section (slice) at a specific W coordinate. What you see depends on: 1. **Object's W position** relative to slice W @@ -466,6 +469,12 @@ let material = Material { ### Built-in Shapes +Rust4D now ships a full primitive catalog: tesseract, hypersphere, +pentachoron (5-cell), hexadecachoron (16-cell), icositetrachoron (24-cell), +hexacosichoron (600-cell), spherinder, cubinder, and duocylinder. See the +[Shape Catalog](./shapes.md) for construction math, RON snippets, cell counts, +and visual verification notes for every primitive. + #### Tesseract The tesseract (hypercube) is the primary 4D shape: diff --git a/examples/README.md b/examples/README.md index 81256d1..a1d65b0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,7 @@ cargo run --example 04_camera_exploration | 05_audio_demo | 4D spatial audio | AudioEngine, bus routing, W-distance attenuation | | ron_preview | Scene file viewer | RON scene loading | | headless_protocol | **Automated visual verification** — renders a scripted movement protocol offscreen, saves PPM frames + numeric slice-plane logs. No window needed. | Offscreen wgpu, slice invariant, screenshot diffing | +| shape_showcase | **Primitive catalog verification** — renders all built-in primitives at 3 slice offsets × 3 orientations, saves PPM frames and triangle-count logs. | Mesh4D, polytopes, curved shapes, visual regression | ## Learning Path