382 lines
11 KiB
Rust
382 lines
11 KiB
Rust
//! 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<f32>,
|
|
/// 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<f32>,
|
|
/// 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<f32>,
|
|
/// Triangle indices (3 indices per triangle)
|
|
pub indices: Vec<u32>,
|
|
/// 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³
|
|
}
|
|
}
|