Skip to main content

volren_core/volume/
mod.rs

1//! Volume module: scalar trait, typed volume, type-erased volume.
2
3mod dyn_volume;
4mod scalar;
5
6pub use dyn_volume::DynVolume;
7pub use scalar::Scalar;
8
9use glam::{DMat3, DVec3, UVec3};
10use thiserror::Error;
11
12use crate::math::Aabb;
13
14// ── Error type ────────────────────────────────────────────────────────────────
15
16/// Errors produced during volume construction or access.
17#[derive(Debug, Error, PartialEq)]
18#[non_exhaustive]
19pub enum VolumeError {
20    /// The supplied data length does not match the declared dimensions.
21    #[error(
22        "data length {actual} does not match dimensions \
23         {dims_x}×{dims_y}×{dims_z}×{components} = {expected}"
24    )]
25    DimensionMismatch {
26        /// Expected number of scalars.
27        expected: usize,
28        /// Actual number of scalars.
29        actual: usize,
30        /// Volume dimensions used for the expectation.
31        dims_x: u32,
32        /// Volume Y dimension.
33        dims_y: u32,
34        /// Volume Z dimension.
35        dims_z: u32,
36        /// Number of components per voxel.
37        components: u32,
38    },
39
40    /// At least one dimension was zero.
41    #[error("dimensions must be non-zero, got ({x}, {y}, {z})")]
42    ZeroDimension {
43        /// X dimension.
44        x: u32,
45        /// Y dimension.
46        y: u32,
47        /// Z dimension.
48        z: u32,
49    },
50
51    /// At least one spacing value was zero or negative.
52    #[error("spacing must be positive, got ({x}, {y}, {z})")]
53    InvalidSpacing {
54        /// X spacing.
55        x: f64,
56        /// Y spacing.
57        y: f64,
58        /// Z spacing.
59        z: f64,
60    },
61
62    /// The number of components per voxel was zero.
63    #[error("components must be >= 1, got 0")]
64    ZeroComponents,
65
66    /// Slices have inconsistent lengths.
67    #[error("slice {index} has length {actual}, expected {expected} (width × height)")]
68    InconsistentSlice {
69        /// Slice index that was wrong.
70        index: usize,
71        /// Expected length.
72        expected: usize,
73        /// Actual length.
74        actual: usize,
75    },
76
77    /// No slices were provided.
78    #[error("at least one slice must be provided")]
79    EmptySlices,
80}
81
82// ── VolumeInfo trait ──────────────────────────────────────────────────────────
83
84/// Read-only metadata about a volume, without exposing the scalar type.
85///
86/// Implemented by both [`Volume<T>`] and [`DynVolume`]. Allows geometry and
87/// coordinate operations without monomorphisation on the scalar type.
88pub trait VolumeInfo {
89    /// Number of voxels along each axis: `[nx, ny, nz]`.
90    fn dimensions(&self) -> UVec3;
91
92    /// Physical size of each voxel in world units (typically millimetres).
93    fn spacing(&self) -> DVec3;
94
95    /// World-space position of voxel `(0, 0, 0)`.
96    fn origin(&self) -> DVec3;
97
98    /// 3×3 orientation matrix whose columns are the axis directions.
99    /// For axis-aligned volumes this is the identity matrix.
100    fn direction(&self) -> DMat3;
101
102    /// Number of scalar values stored per voxel (1 for grayscale).
103    fn components(&self) -> u32;
104
105    /// Convert a continuous voxel index `(i, j, k)` to world coordinates.
106    ///
107    /// Formula: `world = origin + direction * (ijk * spacing)`
108    fn index_to_world(&self, ijk: DVec3) -> DVec3 {
109        self.origin() + self.direction() * (ijk * self.spacing())
110    }
111
112    /// Convert world coordinates to a continuous voxel index.
113    ///
114    /// This is the inverse of [`VolumeInfo::index_to_world`].
115    fn world_to_index(&self, xyz: DVec3) -> DVec3 {
116        let rel = xyz - self.origin();
117        // direction is orthonormal, so inverse = transpose
118        let inv = self.direction().transpose();
119        inv * rel / self.spacing()
120    }
121
122    /// Axis-aligned bounding box enclosing all voxel centres in world space.
123    fn world_bounds(&self) -> Aabb {
124        let dims = self.dimensions().as_dvec3();
125        // corners of the index grid (0 to dims-1)
126        let corners = [
127            DVec3::ZERO,
128            DVec3::new(dims.x - 1.0, 0.0, 0.0),
129            DVec3::new(0.0, dims.y - 1.0, 0.0),
130            DVec3::new(0.0, 0.0, dims.z - 1.0),
131            DVec3::new(dims.x - 1.0, dims.y - 1.0, 0.0),
132            DVec3::new(dims.x - 1.0, 0.0, dims.z - 1.0),
133            DVec3::new(0.0, dims.y - 1.0, dims.z - 1.0),
134            dims - DVec3::ONE,
135        ];
136        let world_corners: Vec<DVec3> = corners.iter().map(|&c| self.index_to_world(c)).collect();
137
138        let min = world_corners
139            .iter()
140            .fold(DVec3::splat(f64::INFINITY), |a, &b| a.min(b));
141        let max = world_corners
142            .iter()
143            .fold(DVec3::splat(f64::NEG_INFINITY), |a, &b| a.max(b));
144        Aabb::new(min, max)
145    }
146}
147
148// ── Volume<T> ─────────────────────────────────────────────────────────────────
149
150/// A regular 3D grid of scalar values stored contiguously in memory.
151///
152/// The memory layout is **X-fastest** (row-major in image terms):
153/// `data[x + y * nx + z * nx * ny]` for single-component data.
154///
155/// # VTK Equivalent
156/// `vtkImageData` with scalar data in `PointData`.
157///
158/// # Type Parameter
159/// `T` must implement [`Scalar`], which is sealed to known numeric types.
160#[derive(Debug, Clone)]
161pub struct Volume<T: Scalar> {
162    data: Vec<T>,
163    dimensions: UVec3,
164    spacing: DVec3,
165    origin: DVec3,
166    direction: DMat3,
167    components: u32,
168    scalar_range_cache: std::cell::OnceCell<(f64, f64)>,
169}
170
171impl<T: Scalar> Volume<T> {
172    // ── Constructors ──────────────────────────────────────────────────────────
173
174    /// Create a volume from a flat voxel buffer.
175    ///
176    /// `data.len()` must equal `dimensions.x * dimensions.y * dimensions.z * components`.
177    ///
178    /// # Errors
179    /// Returns [`VolumeError`] if dimensions, spacing, or data length are invalid.
180    pub fn from_data(
181        data: Vec<T>,
182        dimensions: UVec3,
183        spacing: DVec3,
184        origin: DVec3,
185        direction: DMat3,
186        components: u32,
187    ) -> Result<Self, VolumeError> {
188        Self::validate(dimensions, spacing, components)?;
189        let expected = (dimensions.x as usize)
190            * (dimensions.y as usize)
191            * (dimensions.z as usize)
192            * (components as usize);
193        if data.len() != expected {
194            return Err(VolumeError::DimensionMismatch {
195                expected,
196                actual: data.len(),
197                dims_x: dimensions.x,
198                dims_y: dimensions.y,
199                dims_z: dimensions.z,
200                components,
201            });
202        }
203        Ok(Self {
204            data,
205            dimensions,
206            spacing,
207            origin,
208            direction,
209            components,
210            scalar_range_cache: std::cell::OnceCell::new(),
211        })
212    }
213
214    /// Assemble a volume from a sequence of 2D frames stacked along Z.
215    ///
216    /// Each slice must have exactly `width * height` scalars in row-major order
217    /// (X-fastest). Useful when building a volume from DICOM slices.
218    ///
219    /// # Errors
220    /// Returns [`VolumeError`] if any slice has the wrong length or inputs are invalid.
221    pub fn from_slices(
222        slices: &[&[T]],
223        width: u32,
224        height: u32,
225        spacing: DVec3,
226        origin: DVec3,
227        direction: DMat3,
228    ) -> Result<Self, VolumeError> {
229        if slices.is_empty() {
230            return Err(VolumeError::EmptySlices);
231        }
232        let expected_len = (width as usize) * (height as usize);
233        for (i, slice) in slices.iter().enumerate() {
234            if slice.len() != expected_len {
235                return Err(VolumeError::InconsistentSlice {
236                    index: i,
237                    expected: expected_len,
238                    actual: slice.len(),
239                });
240            }
241        }
242        let depth = slices.len() as u32;
243        let mut data = Vec::with_capacity(expected_len * slices.len());
244        for slice in slices {
245            data.extend_from_slice(slice);
246        }
247        Self::from_data(
248            data,
249            UVec3::new(width, height, depth),
250            spacing,
251            origin,
252            direction,
253            1,
254        )
255    }
256
257    // ── Voxel access ──────────────────────────────────────────────────────────
258
259    /// Direct voxel access by integer index (component 0).
260    ///
261    /// Returns `None` if any index is out of bounds.
262    #[must_use]
263    pub fn get(&self, x: u32, y: u32, z: u32) -> Option<T> {
264        if x >= self.dimensions.x || y >= self.dimensions.y || z >= self.dimensions.z {
265            return None;
266        }
267        let idx = x as usize
268            + y as usize * self.dimensions.x as usize
269            + z as usize * (self.dimensions.x as usize * self.dimensions.y as usize);
270        self.data.get(idx).copied()
271    }
272
273    /// Sample with trilinear interpolation at a continuous voxel index.
274    ///
275    /// Returns `None` if the index falls outside `[0, dims-1]` on any axis.
276    /// Only samples the first component for multi-component volumes.
277    #[must_use]
278    pub fn sample_linear(&self, ijk: DVec3) -> Option<f64> {
279        let dims = self.dimensions.as_dvec3() - DVec3::ONE;
280        if ijk.x < 0.0 || ijk.y < 0.0 || ijk.z < 0.0 {
281            return None;
282        }
283        if ijk.x > dims.x || ijk.y > dims.y || ijk.z > dims.z {
284            return None;
285        }
286
287        let i0 = ijk.x.floor() as u32;
288        let j0 = ijk.y.floor() as u32;
289        let k0 = ijk.z.floor() as u32;
290        let i1 = (i0 + 1).min(self.dimensions.x - 1);
291        let j1 = (j0 + 1).min(self.dimensions.y - 1);
292        let k1 = (k0 + 1).min(self.dimensions.z - 1);
293
294        let tx = ijk.x - i0 as f64;
295        let ty = ijk.y - j0 as f64;
296        let tz = ijk.z - k0 as f64;
297
298        macro_rules! g {
299            ($x:expr, $y:expr, $z:expr) => {
300                self.get($x, $y, $z).unwrap_or(T::min_value()).to_f64()
301            };
302        }
303
304        let c000 = g!(i0, j0, k0);
305        let c100 = g!(i1, j0, k0);
306        let c010 = g!(i0, j1, k0);
307        let c110 = g!(i1, j1, k0);
308        let c001 = g!(i0, j0, k1);
309        let c101 = g!(i1, j0, k1);
310        let c011 = g!(i0, j1, k1);
311        let c111 = g!(i1, j1, k1);
312
313        let c00 = c000 * (1.0 - tx) + c100 * tx;
314        let c01 = c001 * (1.0 - tx) + c101 * tx;
315        let c10 = c010 * (1.0 - tx) + c110 * tx;
316        let c11 = c011 * (1.0 - tx) + c111 * tx;
317        let c0 = c00 * (1.0 - ty) + c10 * ty;
318        let c1 = c01 * (1.0 - ty) + c11 * ty;
319        Some(c0 * (1.0 - tz) + c1 * tz)
320    }
321
322    /// Sample nearest-neighbour at a continuous voxel index.
323    ///
324    /// Returns `None` if the index falls outside the volume.
325    #[must_use]
326    pub fn sample_nearest(&self, ijk: DVec3) -> Option<T> {
327        let x = ijk.x.round() as i64;
328        let y = ijk.y.round() as i64;
329        let z = ijk.z.round() as i64;
330        if x < 0 || y < 0 || z < 0 {
331            return None;
332        }
333        self.get(x as u32, y as u32, z as u32)
334    }
335
336    /// Compute the (min, max) scalar range of all voxels.
337    ///
338    /// The result is computed once and cached.
339    #[must_use]
340    pub fn scalar_range(&self) -> (f64, f64) {
341        *self.scalar_range_cache.get_or_init(|| {
342            self.data
343                .iter()
344                .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
345                    let f = v.to_f64();
346                    (lo.min(f), hi.max(f))
347                })
348        })
349    }
350
351    /// Raw byte slice of the voxel data, suitable for GPU upload.
352    #[must_use]
353    pub fn as_bytes(&self) -> &[u8] {
354        bytemuck::cast_slice(&self.data)
355    }
356
357    /// Borrow the typed scalar buffer.
358    #[must_use]
359    pub fn data(&self) -> &[T] {
360        &self.data
361    }
362
363    // ── Private helpers ───────────────────────────────────────────────────────
364
365    fn validate(dimensions: UVec3, spacing: DVec3, components: u32) -> Result<(), VolumeError> {
366        if dimensions.x == 0 || dimensions.y == 0 || dimensions.z == 0 {
367            return Err(VolumeError::ZeroDimension {
368                x: dimensions.x,
369                y: dimensions.y,
370                z: dimensions.z,
371            });
372        }
373        if spacing.x <= 0.0 || spacing.y <= 0.0 || spacing.z <= 0.0 {
374            return Err(VolumeError::InvalidSpacing {
375                x: spacing.x,
376                y: spacing.y,
377                z: spacing.z,
378            });
379        }
380        if components == 0 {
381            return Err(VolumeError::ZeroComponents);
382        }
383        Ok(())
384    }
385}
386
387impl<T: Scalar> VolumeInfo for Volume<T> {
388    fn dimensions(&self) -> UVec3 {
389        self.dimensions
390    }
391    fn spacing(&self) -> DVec3 {
392        self.spacing
393    }
394    fn origin(&self) -> DVec3 {
395        self.origin
396    }
397    fn direction(&self) -> DMat3 {
398        self.direction
399    }
400    fn components(&self) -> u32 {
401        self.components
402    }
403}
404
405// ── Tests ─────────────────────────────────────────────────────────────────────
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use approx::assert_abs_diff_eq;
411    use glam::{DMat3, DVec3, UVec3};
412
413    fn unit_volume() -> Volume<u8> {
414        let data = (0u8..=3)
415            .flat_map(|z| (0u8..=3).flat_map(move |y| (0u8..=3).map(move |x| x + y * 4 + z * 16)))
416            .collect();
417        Volume::from_data(
418            data,
419            UVec3::new(4, 4, 4),
420            DVec3::ONE,
421            DVec3::ZERO,
422            DMat3::IDENTITY,
423            1,
424        )
425        .unwrap()
426    }
427
428    #[test]
429    fn from_data_happy_path() {
430        let vol = unit_volume();
431        assert_eq!(vol.dimensions(), UVec3::new(4, 4, 4));
432    }
433
434    #[test]
435    fn from_data_wrong_length() {
436        let err = Volume::<u8>::from_data(
437            vec![0u8; 10],
438            UVec3::new(4, 4, 4),
439            DVec3::ONE,
440            DVec3::ZERO,
441            DMat3::IDENTITY,
442            1,
443        );
444        assert!(matches!(err, Err(VolumeError::DimensionMismatch { .. })));
445    }
446
447    #[test]
448    fn from_data_zero_dim() {
449        let err = Volume::<u8>::from_data(
450            vec![],
451            UVec3::new(0, 4, 4),
452            DVec3::ONE,
453            DVec3::ZERO,
454            DMat3::IDENTITY,
455            1,
456        );
457        assert!(matches!(err, Err(VolumeError::ZeroDimension { .. })));
458    }
459
460    #[test]
461    fn from_data_invalid_spacing() {
462        let err = Volume::<u8>::from_data(
463            vec![0u8; 64],
464            UVec3::new(4, 4, 4),
465            DVec3::new(0.0, 1.0, 1.0),
466            DVec3::ZERO,
467            DMat3::IDENTITY,
468            1,
469        );
470        assert!(matches!(err, Err(VolumeError::InvalidSpacing { .. })));
471    }
472
473    #[test]
474    fn get_voxel() {
475        let vol = unit_volume();
476        assert_eq!(vol.get(0, 0, 0), Some(0));
477        assert_eq!(vol.get(3, 3, 3), Some(3 + 3 * 4 + 3 * 16));
478        assert_eq!(vol.get(4, 0, 0), None);
479    }
480
481    #[test]
482    fn scalar_range() {
483        let vol = unit_volume();
484        let (lo, hi) = vol.scalar_range();
485        assert_abs_diff_eq!(lo, 0.0, epsilon = 1e-10);
486        assert_abs_diff_eq!(hi, 3.0 + 3.0 * 4.0 + 3.0 * 16.0, epsilon = 1e-10);
487    }
488
489    #[test]
490    fn sample_linear_at_integer_matches_get() {
491        let vol = unit_volume();
492        let v = vol.sample_linear(DVec3::new(1.0, 2.0, 3.0)).unwrap();
493        assert_abs_diff_eq!(v, vol.get(1, 2, 3).unwrap().to_f64(), epsilon = 1e-10);
494    }
495
496    #[test]
497    fn sample_linear_out_of_bounds_returns_none() {
498        let vol = unit_volume();
499        assert!(vol.sample_linear(DVec3::new(-0.1, 0.0, 0.0)).is_none());
500        assert!(vol.sample_linear(DVec3::new(3.1, 0.0, 0.0)).is_none());
501    }
502
503    #[test]
504    fn index_to_world_origin_aligned() {
505        let vol = unit_volume();
506        // Identity direction, unit spacing, zero origin: index = world
507        let world = vol.index_to_world(DVec3::new(1.0, 2.0, 3.0));
508        assert_abs_diff_eq!(world.x, 1.0, epsilon = 1e-10);
509        assert_abs_diff_eq!(world.y, 2.0, epsilon = 1e-10);
510        assert_abs_diff_eq!(world.z, 3.0, epsilon = 1e-10);
511    }
512
513    #[test]
514    fn index_world_round_trip() {
515        let vol = Volume::<f32>::from_data(
516            vec![0.0f32; 8 * 8 * 8],
517            UVec3::new(8, 8, 8),
518            DVec3::new(0.5, 0.75, 1.25),
519            DVec3::new(10.0, 20.0, 30.0),
520            DMat3::IDENTITY,
521            1,
522        )
523        .unwrap();
524        let ijk = DVec3::new(3.5, 2.0, 6.0);
525        let world = vol.index_to_world(ijk);
526        let back = vol.world_to_index(world);
527        assert_abs_diff_eq!(back.x, ijk.x, epsilon = 1e-10);
528        assert_abs_diff_eq!(back.y, ijk.y, epsilon = 1e-10);
529        assert_abs_diff_eq!(back.z, ijk.z, epsilon = 1e-10);
530    }
531
532    #[test]
533    fn from_slices_assembles_correctly() {
534        let slice0: Vec<u8> = (0..4).collect();
535        let slice1: Vec<u8> = (4..8).collect();
536        let vol = Volume::from_slices(
537            &[slice0.as_slice(), slice1.as_slice()],
538            2,
539            2,
540            DVec3::ONE,
541            DVec3::ZERO,
542            DMat3::IDENTITY,
543        )
544        .unwrap();
545        assert_eq!(vol.dimensions(), UVec3::new(2, 2, 2));
546        assert_eq!(vol.get(0, 0, 0), Some(0));
547        assert_eq!(vol.get(0, 0, 1), Some(4));
548    }
549
550    #[test]
551    fn from_slices_inconsistent_length() {
552        let s0 = vec![0u8; 4];
553        let s1 = vec![0u8; 3]; // wrong
554        let err = Volume::<u8>::from_slices(
555            &[s0.as_slice(), s1.as_slice()],
556            2,
557            2,
558            DVec3::ONE,
559            DVec3::ZERO,
560            DMat3::IDENTITY,
561        );
562        assert!(matches!(
563            err,
564            Err(VolumeError::InconsistentSlice { index: 1, .. })
565        ));
566    }
567
568    #[test]
569    fn world_bounds_axis_aligned() {
570        let vol = Volume::<u8>::from_data(
571            vec![0u8; 4 * 4 * 4],
572            UVec3::new(4, 4, 4),
573            DVec3::new(2.0, 3.0, 4.0),
574            DVec3::new(1.0, 1.0, 1.0),
575            DMat3::IDENTITY,
576            1,
577        )
578        .unwrap();
579        let bounds = vol.world_bounds();
580        // origin=1, spacing=2,3,4, dims=4 → max corner = 1 + 3*2/3/4
581        assert_abs_diff_eq!(bounds.min.x, 1.0, epsilon = 1e-10);
582        assert_abs_diff_eq!(bounds.max.x, 1.0 + 3.0 * 2.0, epsilon = 1e-10);
583    }
584}
585
586#[cfg(test)]
587mod proptests {
588    use super::*;
589    use glam::{DMat3, DVec3};
590    use proptest::prelude::*;
591
592    fn make_vol() -> Volume<u8> {
593        Volume::from_data(
594            vec![0u8; 4 * 4 * 4],
595            UVec3::new(4, 4, 4),
596            DVec3::new(1.0, 1.0, 1.0),
597            DVec3::ZERO,
598            DMat3::IDENTITY,
599            1,
600        )
601        .unwrap()
602    }
603
604    proptest! {
605        #[test]
606        fn index_to_world_round_trip(
607            ix in 0.0f64..3.0,
608            iy in 0.0f64..3.0,
609            iz in 0.0f64..3.0,
610        ) {
611            let vol = make_vol();
612            let idx = DVec3::new(ix, iy, iz);
613            let world = vol.index_to_world(idx);
614            let back = vol.world_to_index(world);
615            prop_assert!((back.x - idx.x).abs() < 1e-8, "x: {} vs {}", idx.x, back.x);
616            prop_assert!((back.y - idx.y).abs() < 1e-8, "y: {} vs {}", idx.y, back.y);
617            prop_assert!((back.z - idx.z).abs() < 1e-8, "z: {} vs {}", idx.z, back.z);
618        }
619
620        #[test]
621        fn world_to_index_round_trip(
622            wx in 0.0f64..3.0,
623            wy in 0.0f64..3.0,
624            wz in 0.0f64..3.0,
625        ) {
626            let vol = make_vol();
627            let world = DVec3::new(wx, wy, wz);
628            let idx = vol.world_to_index(world);
629            let back = vol.index_to_world(idx);
630            prop_assert!((back.x - world.x).abs() < 1e-8, "x: {} vs {}", world.x, back.x);
631            prop_assert!((back.y - world.y).abs() < 1e-8, "y: {} vs {}", world.y, back.y);
632            prop_assert!((back.z - world.z).abs() < 1e-8, "z: {} vs {}", world.z, back.z);
633        }
634    }
635}