//! Field types for 3D temperature and ablation zone data use crate::geometry::{BoundingBox3D, Point3D}; use serde::{Deserialize, Serialize}; /// 3D temperature field on a regular grid #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemperatureField { /// Grid resolution (nx, ny, nz) pub resolution: (usize, usize, usize), /// Temperature values in row-major order [nz][ny][nx] in °C pub values: Vec, /// Physical bounds of the domain pub bounds: BoundingBox3D, } impl TemperatureField { /// Create a uniform temperature field #[must_use] pub fn uniform(resolution: (usize, usize, usize), bounds: BoundingBox3D, value: f32) -> Self { let n = resolution.0 * resolution.1 * resolution.2; Self { resolution, values: vec![value; n], bounds, } } /// Create field at body temperature (37°C) #[must_use] pub fn body_temperature(resolution: (usize, usize, usize), bounds: BoundingBox3D) -> Self { Self::uniform(resolution, bounds, 37.0) } /// Get temperature at grid index (i, j, k) where i=x, j=y, k=z #[must_use] pub fn at(&self, i: usize, j: usize, k: usize) -> f32 { let (nx, ny, _nz) = self.resolution; self.values[k * ny * nx + j * nx + i] } /// Set temperature at grid index pub fn set(&mut self, i: usize, j: usize, k: usize, value: f32) { let (nx, ny, _nz) = self.resolution; self.values[k * ny * nx + j * nx + i] = value; } /// Get grid spacing in each dimension #[must_use] pub fn spacing(&self) -> Point3D { let (nx, ny, nz) = self.resolution; let size = self.bounds.size(); Point3D::new( size.x / (nx - 1).max(1) as f32, size.y / (ny - 1).max(1) as f32, size.z / (nz - 1).max(1) as f32, ) } /// Get physical coordinates for grid index #[must_use] pub fn coords_at(&self, i: usize, j: usize, k: usize) -> Point3D { let spacing = self.spacing(); Point3D::new( self.bounds.min.x + i as f32 * spacing.x, self.bounds.min.y + j as f32 * spacing.y, self.bounds.min.z + k as f32 * spacing.z, ) } /// Get min and max temperature values #[must_use] pub fn min_max(&self) -> (f32, f32) { let min = self.values.iter().copied().fold(f32::INFINITY, f32::min); let max = self .values .iter() .copied() .fold(f32::NEG_INFINITY, f32::max); (min, max) } /// Get maximum temperature #[must_use] pub fn max_temperature(&self) -> f32 { self.values .iter() .copied() .fold(f32::NEG_INFINITY, f32::max) } /// Total number of grid points #[must_use] pub fn len(&self) -> usize { self.resolution.0 * self.resolution.1 * self.resolution.2 } /// Check if empty #[must_use] pub fn is_empty(&self) -> bool { self.values.is_empty() } /// Count voxels above ablation threshold (60°C) #[must_use] pub fn ablated_voxel_count(&self) -> usize { self.values.iter().filter(|&&t| t >= 60.0).count() } /// Calculate ablated volume in m³ #[must_use] pub fn ablated_volume(&self) -> f32 { let spacing = self.spacing(); let voxel_volume = spacing.x * spacing.y * spacing.z; self.ablated_voxel_count() as f32 * voxel_volume } } /// Axis for 2D slice extraction #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum SliceAxis { /// Slice perpendicular to X axis (sagittal in medical imaging) X, /// Slice perpendicular to Y axis (coronal in medical imaging) Y, /// Slice perpendicular to Z axis (axial/transverse in medical imaging) #[default] Z, } impl SliceAxis { /// Get display name #[must_use] pub fn display_name(&self) -> &'static str { match self { Self::X => "Sagittal (YZ)", Self::Y => "Coronal (XZ)", Self::Z => "Axial (XY)", } } } /// 2D slice of temperature data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SliceData { /// Axis perpendicular to the slice pub axis: SliceAxis, /// Position along the axis (in meters, physical coordinates) pub position: f32, /// Index along the axis (grid index) pub index: usize, /// 2D resolution (width, height) pub resolution: (usize, usize), /// Temperature values in row-major order [height][width] pub values: Vec, /// Physical bounds of the 2D slice pub bounds_2d: (f32, f32, f32, f32), // (min_u, max_u, min_v, max_v) } impl SliceData { /// Extract a slice from a 3D temperature field #[must_use] pub fn from_field(field: &TemperatureField, axis: SliceAxis, index: usize) -> Self { let (nx, ny, nz) = field.resolution; let (resolution, values, position, bounds_2d) = match axis { SliceAxis::X => { // YZ slice at x=index let i = index.min(nx - 1); let mut vals = Vec::with_capacity(ny * nz); for k in 0..nz { for j in 0..ny { vals.push(field.at(i, j, k)); } } let pos = field.coords_at(i, 0, 0).x; let b = ( field.bounds.min.y, field.bounds.max.y, field.bounds.min.z, field.bounds.max.z, ); ((ny, nz), vals, pos, b) } SliceAxis::Y => { // XZ slice at y=index let j = index.min(ny - 1); let mut vals = Vec::with_capacity(nx * nz); for k in 0..nz { for i in 0..nx { vals.push(field.at(i, j, k)); } } let pos = field.coords_at(0, j, 0).y; let b = ( field.bounds.min.x, field.bounds.max.x, field.bounds.min.z, field.bounds.max.z, ); ((nx, nz), vals, pos, b) } SliceAxis::Z => { // XY slice at z=index let k = index.min(nz - 1); let mut vals = Vec::with_capacity(nx * ny); for j in 0..ny { for i in 0..nx { vals.push(field.at(i, j, k)); } } let pos = field.coords_at(0, 0, k).z; let b = ( field.bounds.min.x, field.bounds.max.x, field.bounds.min.y, field.bounds.max.y, ); ((nx, ny), vals, pos, b) } }; Self { axis, position, index, resolution, values, bounds_2d, } } /// Get min and max values in the slice #[must_use] pub fn min_max(&self) -> (f32, f32) { let min = self.values.iter().copied().fold(f32::INFINITY, f32::min); let max = self .values .iter() .copied() .fold(f32::NEG_INFINITY, f32::max); (min, max) } } /// Ablation zone represented as an isosurface mesh #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AblationZone { /// Vertices of the isosurface mesh (x, y, z triplets, in meters) pub vertices: Vec, /// Triangle indices (3 indices per triangle) pub indices: Vec, /// Volume of ablated tissue (m³) pub volume: f32, /// Volume in mm³ (more clinically useful) pub volume_mm3: f32, /// Threshold temperature used (typically 60°C) pub threshold: f32, /// Approximate dimensions (width, height, depth) in meters pub dimensions: (f32, f32, f32), } impl AblationZone { /// Create an empty ablation zone (no tissue ablated yet) #[must_use] pub fn empty() -> Self { Self { vertices: Vec::new(), indices: Vec::new(), volume: 0.0, volume_mm3: 0.0, threshold: 60.0, dimensions: (0.0, 0.0, 0.0), } } /// Create from volume measurement only (no mesh) #[must_use] pub fn from_volume(volume_m3: f32, threshold: f32) -> Self { Self { vertices: Vec::new(), indices: Vec::new(), volume: volume_m3, volume_mm3: volume_m3 * 1e9, // Convert m³ to mm³ threshold, dimensions: (0.0, 0.0, 0.0), } } /// Number of triangles in the mesh #[must_use] pub fn triangle_count(&self) -> usize { self.indices.len() / 3 } /// Number of vertices #[must_use] pub fn vertex_count(&self) -> usize { self.vertices.len() / 3 } /// Check if there is any ablation #[must_use] pub fn is_empty(&self) -> bool { self.volume < 1e-12 // Less than 1 nanoliter } } impl Default for AblationZone { fn default() -> Self { Self::empty() } } #[cfg(test)] mod tests { use super::*; fn test_bounds() -> BoundingBox3D { BoundingBox3D::from_dimensions(0.1, 0.1, 0.1) } #[test] fn test_temperature_field_uniform() { let field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0); assert_eq!(field.len(), 1000); assert!((field.at(5, 5, 5) - 37.0).abs() < 1e-6); } #[test] fn test_temperature_field_set() { let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0); field.set(5, 5, 5, 100.0); assert!((field.at(5, 5, 5) - 100.0).abs() < 1e-6); assert!((field.at(0, 0, 0) - 37.0).abs() < 1e-6); } #[test] fn test_temperature_field_spacing() { let field = TemperatureField::uniform((11, 11, 11), test_bounds(), 37.0); let spacing = field.spacing(); assert!((spacing.x - 0.01).abs() < 1e-6); assert!((spacing.y - 0.01).abs() < 1e-6); assert!((spacing.z - 0.01).abs() < 1e-6); } #[test] fn test_ablated_volume() { let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0); // Heat up a single voxel field.set(5, 5, 5, 70.0); assert_eq!(field.ablated_voxel_count(), 1); } #[test] fn test_slice_extraction() { let mut field = TemperatureField::uniform((10, 10, 10), test_bounds(), 37.0); field.set(5, 5, 5, 100.0); // Z slice at k=5 should contain the hot spot let slice = SliceData::from_field(&field, SliceAxis::Z, 5); assert_eq!(slice.resolution, (10, 10)); let (min, max) = slice.min_max(); assert!((max - 100.0).abs() < 1e-6); assert!((min - 37.0).abs() < 1e-6); } #[test] fn test_ablation_zone_empty() { let zone = AblationZone::empty(); assert!(zone.is_empty()); assert_eq!(zone.triangle_count(), 0); } #[test] fn test_ablation_zone_from_volume() { let volume_m3 = 1e-6; // 1 mL = 1 cm³ = 1e-6 m³ let zone = AblationZone::from_volume(volume_m3, 60.0); assert!((zone.volume_mm3 - 1000.0).abs() < 1e-6); // 1000 mm³ } }