//! Tetrahedral mesh generation for head models. //! //! Provides mesh generation from FreeSurfer surfaces and layered //! spherical models for FEM forward solutions. use crate::error::{FemError, FemResult}; use nalgebra::{Matrix3, Vector3}; use ndarray::Array2; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Tissue layer identifier #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TissueLayer { /// Scalp/skin layer Scalp, /// Skull bone Skull, /// Cerebrospinal fluid Csf, /// Gray matter (cortex) GrayMatter, /// White matter WhiteMatter, /// Air (sinuses) Air, } impl TissueLayer { /// Get layer index for mesh material ID pub fn index(&self) -> usize { match self { TissueLayer::WhiteMatter => 0, TissueLayer::GrayMatter => 1, TissueLayer::Csf => 2, TissueLayer::Skull => 3, TissueLayer::Scalp => 4, TissueLayer::Air => 5, } } /// Get layer from index pub fn from_index(idx: usize) -> Option { match idx { 0 => Some(TissueLayer::WhiteMatter), 1 => Some(TissueLayer::GrayMatter), 2 => Some(TissueLayer::Csf), 3 => Some(TissueLayer::Skull), 4 => Some(TissueLayer::Scalp), 5 => Some(TissueLayer::Air), _ => None, } } } /// A node in the tetrahedral mesh #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeshNode { /// Node ID pub id: usize, /// Position in 3D space (meters) pub position: Vector3, /// Whether this is a boundary node pub is_boundary: bool, } impl MeshNode { /// Create a new mesh node pub fn new(id: usize, x: f64, y: f64, z: f64) -> Self { Self { id, position: Vector3::new(x, y, z), is_boundary: false, } } } /// A tetrahedral element #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TetElement { /// Element ID pub id: usize, /// Node indices (4 nodes for linear tetrahedron) pub nodes: [usize; 4], /// Tissue layer this element belongs to pub tissue: TissueLayer, /// Element volume (m³) pub volume: f64, } impl TetElement { /// Create a new tetrahedral element pub fn new(id: usize, nodes: [usize; 4], tissue: TissueLayer) -> Self { Self { id, nodes, tissue, volume: 0.0, } } /// Compute element volume from node positions pub fn compute_volume(&mut self, mesh_nodes: &[MeshNode]) -> f64 { let p0 = &mesh_nodes[self.nodes[0]].position; let p1 = &mesh_nodes[self.nodes[1]].position; let p2 = &mesh_nodes[self.nodes[2]].position; let p3 = &mesh_nodes[self.nodes[3]].position; // Volume = |det([p1-p0, p2-p0, p3-p0])| / 6 let v1 = p1 - p0; let v2 = p2 - p0; let v3 = p3 - p0; let det = v1.dot(&v2.cross(&v3)); self.volume = det.abs() / 6.0; self.volume } /// Compute shape function gradients (constant for linear tet) pub fn shape_gradients(&self, mesh_nodes: &[MeshNode]) -> [Vector3; 4] { let p0 = &mesh_nodes[self.nodes[0]].position; let p1 = &mesh_nodes[self.nodes[1]].position; let p2 = &mesh_nodes[self.nodes[2]].position; let p3 = &mesh_nodes[self.nodes[3]].position; // Jacobian matrix let j = Matrix3::new( p1.x - p0.x, p2.x - p0.x, p3.x - p0.x, p1.y - p0.y, p2.y - p0.y, p3.y - p0.y, p1.z - p0.z, p2.z - p0.z, p3.z - p0.z, ); let det = j.determinant(); if det.abs() < 1e-15 { return [Vector3::zeros(); 4]; } let j_inv = j.try_inverse().unwrap_or(Matrix3::identity()); // Shape function gradients in reference coordinates // N0 = 1 - xi - eta - zeta, N1 = xi, N2 = eta, N3 = zeta let grad_ref = [ Vector3::new(-1.0, -1.0, -1.0), Vector3::new(1.0, 0.0, 0.0), Vector3::new(0.0, 1.0, 0.0), Vector3::new(0.0, 0.0, 1.0), ]; // Transform to physical coordinates [ j_inv.transpose() * grad_ref[0], j_inv.transpose() * grad_ref[1], j_inv.transpose() * grad_ref[2], j_inv.transpose() * grad_ref[3], ] } /// Get element centroid pub fn centroid(&self, mesh_nodes: &[MeshNode]) -> Vector3 { let mut c = Vector3::zeros(); for &ni in &self.nodes { c += mesh_nodes[ni].position; } c / 4.0 } } /// Mesh quality metrics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeshQuality { /// Minimum element volume pub min_volume: f64, /// Maximum element volume pub max_volume: f64, /// Average element volume pub avg_volume: f64, /// Minimum aspect ratio (1 = ideal) pub min_aspect_ratio: f64, /// Average aspect ratio pub avg_aspect_ratio: f64, /// Number of degenerate elements (volume < tolerance) pub n_degenerate: usize, } /// Head mesh for FEM forward modeling #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeadMesh { /// Mesh nodes pub nodes: Vec, /// Tetrahedral elements pub elements: Vec, /// Number of elements per tissue layer pub elements_per_layer: HashMap, /// Mesh quality metrics pub quality: Option, } impl HeadMesh { /// Create a new empty head mesh pub fn new() -> Self { Self { nodes: Vec::new(), elements: Vec::new(), elements_per_layer: HashMap::new(), quality: None, } } /// Create a layered spherical head mesh /// /// # Arguments /// * `radii` - Radii for each layer boundary (inner to outer) /// * `n_radial` - Number of radial divisions per layer /// * `n_angular` - Number of angular divisions (icosahedral refinement level) pub fn spherical( radii: &[(TissueLayer, f64)], n_radial: usize, n_angular: usize, ) -> FemResult { let mut mesh = HeadMesh::new(); if radii.is_empty() { return Err(FemError::InvalidMesh("No layer radii specified".into())); } // Generate nodes on concentric spherical shells let n_shells = radii.len() * n_radial + 1; let mut shell_radii = Vec::with_capacity(n_shells); // Inner point at center shell_radii.push(0.0); for (i, (_, outer_r)) in radii.iter().enumerate() { let inner_r = if i == 0 { 0.0 } else { radii[i - 1].1 }; for j in 1..=n_radial { let t = j as f64 / n_radial as f64; let r = inner_r + t * (outer_r - inner_r); shell_radii.push(r); } } // Generate icosahedral points on each shell let ico_points = generate_icosahedral_points(n_angular); let n_points_per_shell = ico_points.len(); // Center node mesh.nodes.push(MeshNode::new(0, 0.0, 0.0, 0.0)); // Nodes on shells for (shell_idx, &r) in shell_radii.iter().enumerate().skip(1) { for (pt_idx, pt) in ico_points.iter().enumerate() { let node_id = 1 + (shell_idx - 1) * n_points_per_shell + pt_idx; let pos = pt * r; let mut node = MeshNode::new(node_id, pos.x, pos.y, pos.z); node.is_boundary = shell_idx == shell_radii.len() - 1; mesh.nodes.push(node); } } // Generate tetrahedral elements // Connect center to first shell let first_shell_start = 1; for tri in generate_icosahedral_triangles(n_angular) { let n0 = 0; // center let n1 = first_shell_start + tri[0]; let n2 = first_shell_start + tri[1]; let n3 = first_shell_start + tri[2]; let tissue = radii[0].0; let elem_id = mesh.elements.len(); mesh.elements .push(TetElement::new(elem_id, [n0, n1, n2, n3], tissue)); } // Connect adjacent shells with prism-split tetrahedra for shell in 1..shell_radii.len() - 1 { let inner_start = 1 + (shell - 1) * n_points_per_shell; let outer_start = inner_start + n_points_per_shell; // Determine tissue layer let layer_idx = shell / n_radial; let tissue = radii[layer_idx.min(radii.len() - 1)].0; for tri in generate_icosahedral_triangles(n_angular) { // Inner and outer triangle nodes let i0 = inner_start + tri[0]; let i1 = inner_start + tri[1]; let i2 = inner_start + tri[2]; let o0 = outer_start + tri[0]; let o1 = outer_start + tri[1]; let o2 = outer_start + tri[2]; // Split prism into 3 tetrahedra let elem_id = mesh.elements.len(); mesh.elements .push(TetElement::new(elem_id, [i0, i1, i2, o0], tissue)); mesh.elements .push(TetElement::new(elem_id + 1, [i1, i2, o0, o1], tissue)); mesh.elements .push(TetElement::new(elem_id + 2, [i2, o0, o1, o2], tissue)); } } // Compute element volumes and count per layer for elem in &mut mesh.elements { elem.compute_volume(&mesh.nodes); *mesh.elements_per_layer.entry(elem.tissue).or_insert(0) += 1; } mesh.compute_quality(); Ok(mesh) } /// Create a simple 3-layer spherical head pub fn three_layer_sphere( brain_radius: f64, skull_thickness: f64, scalp_thickness: f64, n_radial: usize, n_angular: usize, ) -> FemResult { let radii = vec![ (TissueLayer::GrayMatter, brain_radius), (TissueLayer::Skull, brain_radius + skull_thickness), ( TissueLayer::Scalp, brain_radius + skull_thickness + scalp_thickness, ), ]; Self::spherical(&radii, n_radial, n_angular) } /// Create a 5-layer spherical head (with CSF and white matter) pub fn five_layer_sphere( white_radius: f64, gray_thickness: f64, csf_thickness: f64, skull_thickness: f64, scalp_thickness: f64, n_radial: usize, n_angular: usize, ) -> FemResult { let gray_r = white_radius + gray_thickness; let csf_r = gray_r + csf_thickness; let skull_r = csf_r + skull_thickness; let scalp_r = skull_r + scalp_thickness; let radii = vec![ (TissueLayer::WhiteMatter, white_radius), (TissueLayer::GrayMatter, gray_r), (TissueLayer::Csf, csf_r), (TissueLayer::Skull, skull_r), (TissueLayer::Scalp, scalp_r), ]; Self::spherical(&radii, n_radial, n_angular) } /// Number of nodes pub fn n_nodes(&self) -> usize { self.nodes.len() } /// Number of elements pub fn n_elements(&self) -> usize { self.elements.len() } /// Compute mesh quality metrics pub fn compute_quality(&mut self) { if self.elements.is_empty() { return; } let mut min_vol = f64::MAX; let mut max_vol: f64 = 0.0; let mut sum_vol: f64 = 0.0; let mut n_degenerate = 0; let mut sum_aspect = 0.0; let mut min_aspect = f64::MAX; let vol_tol = 1e-20; for elem in &self.elements { let vol = elem.volume; min_vol = min_vol.min(vol); max_vol = max_vol.max(vol); sum_vol += vol; if vol < vol_tol { n_degenerate += 1; } // Compute aspect ratio (edge length ratio) let aspect = self.element_aspect_ratio(elem); sum_aspect += aspect; min_aspect = min_aspect.min(aspect); } let n = self.elements.len() as f64; self.quality = Some(MeshQuality { min_volume: min_vol, max_volume: max_vol, avg_volume: sum_vol / n, min_aspect_ratio: min_aspect, avg_aspect_ratio: sum_aspect / n, n_degenerate, }); } /// Compute aspect ratio for an element (shortest edge / longest edge) fn element_aspect_ratio(&self, elem: &TetElement) -> f64 { let edges = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]; let mut min_len = f64::MAX; let mut max_len: f64 = 0.0; for (i, j) in edges { let p1 = &self.nodes[elem.nodes[i]].position; let p2 = &self.nodes[elem.nodes[j]].position; let len = (p1 - p2).norm(); min_len = min_len.min(len); max_len = max_len.max(len); } if max_len > 1e-15 { min_len / max_len } else { 0.0 } } /// Get total mesh volume pub fn total_volume(&self) -> f64 { self.elements.iter().map(|e| e.volume).sum() } /// Get volume per tissue layer pub fn volume_per_layer(&self) -> HashMap { let mut volumes = HashMap::new(); for elem in &self.elements { *volumes.entry(elem.tissue).or_insert(0.0) += elem.volume; } volumes } /// Find element containing a point pub fn find_element(&self, point: &Vector3) -> Option { for (i, elem) in self.elements.iter().enumerate() { if self.point_in_element(elem, point) { return Some(i); } } None } /// Check if a point is inside an element using barycentric coordinates fn point_in_element(&self, elem: &TetElement, point: &Vector3) -> bool { let p0 = &self.nodes[elem.nodes[0]].position; let p1 = &self.nodes[elem.nodes[1]].position; let p2 = &self.nodes[elem.nodes[2]].position; let p3 = &self.nodes[elem.nodes[3]].position; // Compute barycentric coordinates let v0 = p1 - p0; let v1 = p2 - p0; let v2 = p3 - p0; let vp = point - p0; let d00 = v0.dot(&v0); let d01 = v0.dot(&v1); let d02 = v0.dot(&v2); let d11 = v1.dot(&v1); let d12 = v1.dot(&v2); let d22 = v2.dot(&v2); let dp0 = vp.dot(&v0); let dp1 = vp.dot(&v1); let dp2 = vp.dot(&v2); let det = d00 * (d11 * d22 - d12 * d12) - d01 * (d01 * d22 - d12 * d02) + d02 * (d01 * d12 - d11 * d02); if det.abs() < 1e-15 { return false; } let inv_det = 1.0 / det; // Compute barycentric coordinates let l1 = inv_det * (dp0 * (d11 * d22 - d12 * d12) + dp1 * (d02 * d12 - d01 * d22) + dp2 * (d01 * d12 - d02 * d11)); let l2 = inv_det * (dp0 * (d12 * d02 - d01 * d22) + dp1 * (d00 * d22 - d02 * d02) + dp2 * (d01 * d02 - d00 * d12)); let l3 = inv_det * (dp0 * (d01 * d12 - d11 * d02) + dp1 * (d01 * d02 - d00 * d12) + dp2 * (d00 * d11 - d01 * d01)); let l0 = 1.0 - l1 - l2 - l3; let eps = -1e-10; l0 >= eps && l1 >= eps && l2 >= eps && l3 >= eps } /// Get node positions as array pub fn node_positions(&self) -> Array2 { let n = self.nodes.len(); let mut pos = Array2::zeros((n, 3)); for (i, node) in self.nodes.iter().enumerate() { pos[[i, 0]] = node.position.x; pos[[i, 1]] = node.position.y; pos[[i, 2]] = node.position.z; } pos } /// Get element connectivity as array pub fn element_connectivity(&self) -> Array2 { let n = self.elements.len(); let mut conn = Array2::zeros((n, 4)); for (i, elem) in self.elements.iter().enumerate() { for j in 0..4 { conn[[i, j]] = elem.nodes[j]; } } conn } } impl Default for HeadMesh { fn default() -> Self { Self::new() } } /// Generate icosahedral sphere mesh (vertices and faces) at given refinement level fn generate_icosahedral_mesh(refinement: usize) -> (Vec>, Vec<[usize; 3]>) { // Base icosahedron vertices let phi = f64::midpoint(1.0, 5.0_f64.sqrt()); let scale = 1.0 / (1.0 + phi * phi).sqrt(); let mut vertices = vec![ Vector3::new(0.0, 1.0, phi) * scale, Vector3::new(0.0, -1.0, phi) * scale, Vector3::new(0.0, 1.0, -phi) * scale, Vector3::new(0.0, -1.0, -phi) * scale, Vector3::new(1.0, phi, 0.0) * scale, Vector3::new(-1.0, phi, 0.0) * scale, Vector3::new(1.0, -phi, 0.0) * scale, Vector3::new(-1.0, -phi, 0.0) * scale, Vector3::new(phi, 0.0, 1.0) * scale, Vector3::new(-phi, 0.0, 1.0) * scale, Vector3::new(phi, 0.0, -1.0) * scale, Vector3::new(-phi, 0.0, -1.0) * scale, ]; let mut faces = vec![ [0, 1, 8], [0, 8, 4], [0, 4, 5], [0, 5, 9], [0, 9, 1], [1, 6, 8], [8, 6, 10], [8, 10, 4], [4, 10, 2], [4, 2, 5], [5, 2, 11], [5, 11, 9], [9, 11, 7], [9, 7, 1], [1, 7, 6], [3, 6, 7], [3, 7, 11], [3, 11, 2], [3, 2, 10], [3, 10, 6], ]; // Refine by subdividing triangles for _ in 0..refinement { let mut new_faces = Vec::with_capacity(faces.len() * 4); let mut edge_midpoints: HashMap<(usize, usize), usize> = HashMap::new(); for face in &faces { let mut mid = [0usize; 3]; for i in 0..3 { let (a, b) = (face[i], face[(i + 1) % 3]); let key = if a < b { (a, b) } else { (b, a) }; mid[i] = *edge_midpoints.entry(key).or_insert_with(|| { let midpoint = (vertices[a] + vertices[b]).normalize(); vertices.push(midpoint); vertices.len() - 1 }); } // Create 4 new triangles new_faces.push([face[0], mid[0], mid[2]]); new_faces.push([face[1], mid[1], mid[0]]); new_faces.push([face[2], mid[2], mid[1]]); new_faces.push([mid[0], mid[1], mid[2]]); } faces = new_faces; } (vertices, faces) } /// Generate points on an icosahedral sphere at given refinement level fn generate_icosahedral_points(refinement: usize) -> Vec> { let (vertices, _) = generate_icosahedral_mesh(refinement); vertices } /// Generate icosahedral triangle indices at given refinement level fn generate_icosahedral_triangles(refinement: usize) -> Vec<[usize; 3]> { let (_, faces) = generate_icosahedral_mesh(refinement); faces } #[cfg(test)] mod tests { use super::*; #[test] fn test_tissue_layer_index() { assert_eq!(TissueLayer::WhiteMatter.index(), 0); assert_eq!(TissueLayer::Scalp.index(), 4); assert_eq!(TissueLayer::from_index(0), Some(TissueLayer::WhiteMatter)); } #[test] fn test_mesh_node() { let node = MeshNode::new(0, 1.0, 2.0, 3.0); assert_eq!(node.id, 0); assert!((node.position.x - 1.0).abs() < 1e-10); } #[test] fn test_tet_element_volume() { let nodes = vec![ MeshNode::new(0, 0.0, 0.0, 0.0), MeshNode::new(1, 1.0, 0.0, 0.0), MeshNode::new(2, 0.0, 1.0, 0.0), MeshNode::new(3, 0.0, 0.0, 1.0), ]; let mut elem = TetElement::new(0, [0, 1, 2, 3], TissueLayer::GrayMatter); let vol = elem.compute_volume(&nodes); // Volume of unit tetrahedron = 1/6 assert!((vol - 1.0 / 6.0).abs() < 1e-10); } #[test] fn test_icosahedral_points() { let points = generate_icosahedral_points(0); assert_eq!(points.len(), 12); // Base icosahedron let points = generate_icosahedral_points(1); assert!(points.len() > 12); // Refined // All points should be on unit sphere for p in &points { assert!((p.norm() - 1.0).abs() < 1e-10); } } #[test] fn test_three_layer_sphere() { let mesh = HeadMesh::three_layer_sphere( 0.08, // brain 0.007, // skull 0.006, // scalp 2, // radial divisions 1, // angular refinement ) .unwrap(); assert!(mesh.n_nodes() > 0); assert!(mesh.n_elements() > 0); // Should have elements in each layer assert!( mesh.elements_per_layer .contains_key(&TissueLayer::GrayMatter) ); assert!(mesh.elements_per_layer.contains_key(&TissueLayer::Skull)); assert!(mesh.elements_per_layer.contains_key(&TissueLayer::Scalp)); } #[test] fn test_five_layer_sphere() { let mesh = HeadMesh::five_layer_sphere( 0.06, // white matter 0.015, // gray matter 0.002, // CSF 0.007, // skull 0.006, // scalp 2, 1, ) .unwrap(); assert!(mesh.n_nodes() > 0); assert!(mesh.n_elements() > 0); assert!(mesh.elements_per_layer.len() == 5); } #[test] fn test_mesh_quality() { let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap(); assert!(mesh.quality.is_some()); let q = mesh.quality.as_ref().unwrap(); assert!(q.min_volume > 0.0); assert!(q.avg_volume > 0.0); assert!(q.avg_aspect_ratio > 0.0); assert!(q.avg_aspect_ratio <= 1.0); } #[test] fn test_find_element() { let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap(); // Point inside mesh let center = Vector3::new(0.0, 0.0, 0.0); let elem_idx = mesh.find_element(¢er); assert!(elem_idx.is_some()); // Point outside mesh let outside = Vector3::new(1.0, 0.0, 0.0); let elem_idx = mesh.find_element(&outside); assert!(elem_idx.is_none()); } }