Skip to main content

oxiphysics_geometry/
medical_geometry.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Medical imaging geometry — DICOM coordinate systems, CT/MRI volume
5//! reconstruction, bone segmentation, vessel centerline extraction, organ
6//! bounding volumes, anatomical coordinate systems (RAS/LPS), surgical
7//! planning geometry, marching-cubes mesh extraction, measurement tools,
8//! and mesh smoothing for medical applications.
9//!
10//! All computations use plain `f64` / `[f64; 3]` — no nalgebra dependency.
11
12use std::f64::consts::PI;
13
14// ---------------------------------------------------------------------------
15// Vec3 helper
16// ---------------------------------------------------------------------------
17
18/// Minimal 3-component vector for medical geometry computations.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Vec3 {
21    /// X component.
22    pub x: f64,
23    /// Y component.
24    pub y: f64,
25    /// Z component.
26    pub z: f64,
27}
28
29impl Vec3 {
30    /// Construct a new vector.
31    pub fn new(x: f64, y: f64, z: f64) -> Self {
32        Self { x, y, z }
33    }
34
35    /// Zero vector.
36    pub fn zero() -> Self {
37        Self::new(0.0, 0.0, 0.0)
38    }
39
40    /// Dot product.
41    pub fn dot(&self, other: &Self) -> f64 {
42        self.x * other.x + self.y * other.y + self.z * other.z
43    }
44
45    /// Euclidean length.
46    pub fn norm(&self) -> f64 {
47        self.dot(self).sqrt()
48    }
49
50    /// Normalise; returns zero vector if near-zero length.
51    pub fn normalize(&self) -> Self {
52        let n = self.norm();
53        if n < 1e-15 {
54            Self::zero()
55        } else {
56            Self::new(self.x / n, self.y / n, self.z / n)
57        }
58    }
59
60    /// Cross product.
61    pub fn cross(&self, other: &Self) -> Self {
62        Self::new(
63            self.y * other.z - self.z * other.y,
64            self.z * other.x - self.x * other.z,
65            self.x * other.y - self.y * other.x,
66        )
67    }
68
69    /// Component-wise addition.
70    pub fn add(&self, other: &Self) -> Self {
71        Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
72    }
73
74    /// Component-wise subtraction.
75    pub fn sub(&self, other: &Self) -> Self {
76        Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
77    }
78
79    /// Scalar multiplication.
80    pub fn scale(&self, s: f64) -> Self {
81        Self::new(self.x * s, self.y * s, self.z * s)
82    }
83
84    /// Distance to another point.
85    pub fn distance_to(&self, other: &Self) -> f64 {
86        self.sub(other).norm()
87    }
88}
89
90// ---------------------------------------------------------------------------
91// 1. DICOM-like image geometry
92// ---------------------------------------------------------------------------
93
94/// Orientation of a single DICOM slice in 3-D patient space.
95///
96/// The row and column direction cosines follow the DICOM tag (0020,0037).
97#[derive(Debug, Clone)]
98pub struct DicomSliceOrientation {
99    /// Direction cosines of the row direction (unit vector).
100    pub row_cosines: Vec3,
101    /// Direction cosines of the column direction (unit vector).
102    pub col_cosines: Vec3,
103}
104
105impl DicomSliceOrientation {
106    /// Construct from raw row and column direction cosine arrays.
107    pub fn new(row: [f64; 3], col: [f64; 3]) -> Self {
108        Self {
109            row_cosines: Vec3::new(row[0], row[1], row[2]).normalize(),
110            col_cosines: Vec3::new(col[0], col[1], col[2]).normalize(),
111        }
112    }
113
114    /// Compute the normal to the slice plane (cross product row × col).
115    pub fn normal(&self) -> Vec3 {
116        self.row_cosines.cross(&self.col_cosines).normalize()
117    }
118
119    /// Returns true when the row and column cosines are orthogonal
120    /// within `tol` (absolute dot-product threshold).
121    pub fn is_orthogonal(&self, tol: f64) -> bool {
122        self.row_cosines.dot(&self.col_cosines).abs() < tol
123    }
124}
125
126/// Geometry descriptor for a single DICOM image plane (one slice).
127#[derive(Debug, Clone)]
128pub struct DicomImagePlane {
129    /// Image Position Patient — top-left corner of the first pixel (mm).
130    pub image_position: Vec3,
131    /// Pixel spacing: \[row_spacing, col_spacing\] in mm.
132    pub pixel_spacing: [f64; 2],
133    /// Slice orientation (row / column cosines).
134    pub orientation: DicomSliceOrientation,
135    /// Number of columns (pixels along row direction).
136    pub cols: usize,
137    /// Number of rows (pixels along column direction).
138    pub rows: usize,
139}
140
141impl DicomImagePlane {
142    /// Build a new image plane descriptor.
143    pub fn new(
144        image_position: [f64; 3],
145        pixel_spacing: [f64; 2],
146        row_cosines: [f64; 3],
147        col_cosines: [f64; 3],
148        rows: usize,
149        cols: usize,
150    ) -> Self {
151        Self {
152            image_position: Vec3::new(image_position[0], image_position[1], image_position[2]),
153            pixel_spacing,
154            orientation: DicomSliceOrientation::new(row_cosines, col_cosines),
155            cols,
156            rows,
157        }
158    }
159
160    /// Convert a pixel coordinate (col `i`, row `j`) to 3-D patient
161    /// coordinates in mm (DICOM Patient Coordinate System).
162    pub fn pixel_to_patient(&self, i: f64, j: f64) -> Vec3 {
163        let dr = self
164            .orientation
165            .row_cosines
166            .scale(i * self.pixel_spacing[0]);
167        let dc = self
168            .orientation
169            .col_cosines
170            .scale(j * self.pixel_spacing[1]);
171        self.image_position.add(&dr).add(&dc)
172    }
173
174    /// Approximate the 3-D patient coordinate of a slice given its index
175    /// in a stack and the inter-slice spacing (mm).
176    pub fn slice_position(&self, slice_index: usize, slice_spacing: f64) -> Vec3 {
177        let normal = self.orientation.normal();
178        self.image_position
179            .add(&normal.scale(slice_index as f64 * slice_spacing))
180    }
181}
182
183// ---------------------------------------------------------------------------
184// 2. CT/MRI volume & trilinear interpolation
185// ---------------------------------------------------------------------------
186
187/// A 3-D scalar volume (e.g. CT Hounsfield units or MRI signal intensity).
188///
189/// Voxel ordering: `data[z][y][x]` with the first axis being the slice axis.
190#[derive(Debug, Clone)]
191pub struct ScalarVolume {
192    /// Raw voxel data stored in z-y-x order.
193    pub data: Vec<f64>,
194    /// Number of voxels along X.
195    pub nx: usize,
196    /// Number of voxels along Y.
197    pub ny: usize,
198    /// Number of voxels along Z (number of slices).
199    pub nz: usize,
200    /// Voxel size in mm: \[dx, dy, dz\].
201    pub voxel_size: [f64; 3],
202    /// World-space origin of voxel (0,0,0) in mm.
203    pub origin: Vec3,
204}
205
206impl ScalarVolume {
207    /// Create a new empty volume filled with zeros.
208    pub fn new(nx: usize, ny: usize, nz: usize, voxel_size: [f64; 3], origin: Vec3) -> Self {
209        Self {
210            data: vec![0.0; nx * ny * nz],
211            nx,
212            ny,
213            nz,
214            voxel_size,
215            origin,
216        }
217    }
218
219    /// Flat index for voxel (ix, iy, iz).
220    #[inline]
221    pub fn index(&self, ix: usize, iy: usize, iz: usize) -> usize {
222        iz * self.ny * self.nx + iy * self.nx + ix
223    }
224
225    /// Get voxel value at integer coordinates.  Returns `None` if out of
226    /// bounds.
227    pub fn get(&self, ix: usize, iy: usize, iz: usize) -> Option<f64> {
228        if ix < self.nx && iy < self.ny && iz < self.nz {
229            Some(self.data[self.index(ix, iy, iz)])
230        } else {
231            None
232        }
233    }
234
235    /// Set voxel value (no-op when out of bounds).
236    pub fn set(&mut self, ix: usize, iy: usize, iz: usize, val: f64) {
237        if ix < self.nx && iy < self.ny && iz < self.nz {
238            let idx = self.index(ix, iy, iz);
239            self.data[idx] = val;
240        }
241    }
242
243    /// Convert continuous world coordinates (mm) to fractional voxel
244    /// indices.  Returns `None` if outside the volume bounding box.
245    pub fn world_to_voxel(&self, world: Vec3) -> Option<[f64; 3]> {
246        let fx = (world.x - self.origin.x) / self.voxel_size[0];
247        let fy = (world.y - self.origin.y) / self.voxel_size[1];
248        let fz = (world.z - self.origin.z) / self.voxel_size[2];
249        if fx < 0.0
250            || fy < 0.0
251            || fz < 0.0
252            || fx > (self.nx - 1) as f64
253            || fy > (self.ny - 1) as f64
254            || fz > (self.nz - 1) as f64
255        {
256            None
257        } else {
258            Some([fx, fy, fz])
259        }
260    }
261
262    /// Trilinear interpolation at fractional voxel coordinates.
263    ///
264    /// Returns `None` when the point lies outside the valid range.
265    pub fn trilinear_interp(&self, world: Vec3) -> Option<f64> {
266        let [fx, fy, fz] = self.world_to_voxel(world)?;
267        let x0 = fx.floor() as usize;
268        let y0 = fy.floor() as usize;
269        let z0 = fz.floor() as usize;
270        let x1 = (x0 + 1).min(self.nx - 1);
271        let y1 = (y0 + 1).min(self.ny - 1);
272        let z1 = (z0 + 1).min(self.nz - 1);
273        let xd = fx - x0 as f64;
274        let yd = fy - y0 as f64;
275        let zd = fz - z0 as f64;
276
277        macro_rules! v {
278            ($xi:expr, $yi:expr, $zi:expr) => {
279                self.data[self.index($xi, $yi, $zi)]
280            };
281        }
282
283        let c00 = v!(x0, y0, z0) * (1.0 - xd) + v!(x1, y0, z0) * xd;
284        let c01 = v!(x0, y0, z1) * (1.0 - xd) + v!(x1, y0, z1) * xd;
285        let c10 = v!(x0, y1, z0) * (1.0 - xd) + v!(x1, y1, z0) * xd;
286        let c11 = v!(x0, y1, z1) * (1.0 - xd) + v!(x1, y1, z1) * xd;
287        let c0 = c00 * (1.0 - yd) + c10 * yd;
288        let c1 = c01 * (1.0 - yd) + c11 * yd;
289        Some(c0 * (1.0 - zd) + c1 * zd)
290    }
291}
292
293// ---------------------------------------------------------------------------
294// 3. Bone segmentation geometry (Hounsfield units)
295// ---------------------------------------------------------------------------
296
297/// Hounsfield unit thresholds for common tissue classes in CT imaging.
298#[derive(Debug, Clone, Copy)]
299pub struct HuThresholds {
300    /// Minimum HU for cortical bone (typically ~700 HU).
301    pub cortical_bone_min: f64,
302    /// Minimum HU for cancellous (trabecular) bone (typically ~200 HU).
303    pub cancellous_bone_min: f64,
304    /// Maximum HU still classified as soft tissue (typically ~200 HU).
305    pub soft_tissue_max: f64,
306    /// Minimum HU for air (typically ~-900 HU).
307    pub air_min: f64,
308}
309
310impl HuThresholds {
311    /// Clinically representative default thresholds.
312    pub fn default_ct() -> Self {
313        Self {
314            cortical_bone_min: 700.0,
315            cancellous_bone_min: 200.0,
316            soft_tissue_max: 200.0,
317            air_min: -900.0,
318        }
319    }
320
321    /// Classify a Hounsfield unit value.
322    pub fn classify(&self, hu: f64) -> TissueClass {
323        if hu >= self.cortical_bone_min {
324            TissueClass::CorticalBone
325        } else if hu >= self.cancellous_bone_min {
326            TissueClass::CancellousBone
327        } else if hu <= self.air_min {
328            TissueClass::Air
329        } else if hu < 0.0 {
330            TissueClass::Fat
331        } else {
332            TissueClass::SoftTissue
333        }
334    }
335}
336
337/// Tissue class returned by Hounsfield-unit segmentation.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum TissueClass {
340    /// Cortical (compact) bone.
341    CorticalBone,
342    /// Cancellous (trabecular) bone.
343    CancellousBone,
344    /// Soft tissue (muscle, organ parenchyma).
345    SoftTissue,
346    /// Adipose / fat tissue.
347    Fat,
348    /// Air or gas-filled cavities.
349    Air,
350}
351
352/// Segment a [`ScalarVolume`] by simple threshold, producing a binary mask.
353///
354/// Returns a vector of length `nx * ny * nz` where `true` indicates the voxel
355/// exceeds `threshold` (inclusive).
356pub fn threshold_segment(volume: &ScalarVolume, threshold: f64) -> Vec<bool> {
357    volume.data.iter().map(|&v| v >= threshold).collect()
358}
359
360/// Count voxels above threshold and estimate bone volume in mm³.
361pub fn bone_volume_mm3(volume: &ScalarVolume, threshold: f64) -> f64 {
362    let vv = volume.voxel_size[0] * volume.voxel_size[1] * volume.voxel_size[2];
363    volume.data.iter().filter(|&&v| v >= threshold).count() as f64 * vv
364}
365
366// ---------------------------------------------------------------------------
367// 4. Vessel centerline extraction (Voronoi / medial-axis approximation)
368// ---------------------------------------------------------------------------
369
370/// A single point on a vessel centreline with associated radius estimate.
371#[derive(Debug, Clone, Copy)]
372pub struct CenterlinePoint {
373    /// 3-D position in patient coordinates (mm).
374    pub position: Vec3,
375    /// Local vessel radius estimate (mm).
376    pub radius: f64,
377}
378
379/// Result of a vessel centreline extraction.
380#[derive(Debug, Clone)]
381pub struct VesselCenterline {
382    /// Ordered sequence of centreline points from proximal to distal.
383    pub points: Vec<CenterlinePoint>,
384}
385
386impl VesselCenterline {
387    /// Construct from a list of (position, radius) pairs.
388    pub fn new(pts: Vec<(Vec3, f64)>) -> Self {
389        Self {
390            points: pts
391                .into_iter()
392                .map(|(p, r)| CenterlinePoint {
393                    position: p,
394                    radius: r,
395                })
396                .collect(),
397        }
398    }
399
400    /// Total arc length of the centreline (mm).
401    pub fn arc_length(&self) -> f64 {
402        self.points
403            .windows(2)
404            .map(|w| w[0].position.distance_to(&w[1].position))
405            .sum()
406    }
407
408    /// Minimum vessel radius along the centreline.
409    pub fn min_radius(&self) -> f64 {
410        self.points
411            .iter()
412            .map(|p| p.radius)
413            .fold(f64::INFINITY, f64::min)
414    }
415
416    /// Maximum vessel radius along the centreline.
417    pub fn max_radius(&self) -> f64 {
418        self.points
419            .iter()
420            .map(|p| p.radius)
421            .fold(f64::NEG_INFINITY, f64::max)
422    }
423
424    /// Tortuosity index: arc_length / straight-line distance between endpoints.
425    /// Returns `1.0` when fewer than two points exist.
426    pub fn tortuosity(&self) -> f64 {
427        if self.points.len() < 2 {
428            return 1.0;
429        }
430        let chord = self
431            .points
432            .first()
433            .expect("operation should succeed")
434            .position
435            .distance_to(
436                &self
437                    .points
438                    .last()
439                    .expect("collection should not be empty")
440                    .position,
441            );
442        if chord < 1e-9 {
443            return 1.0;
444        }
445        self.arc_length() / chord
446    }
447}
448
449/// Approximate centreline extraction from a binary vessel mask using a simple
450/// iterative thinning pass over the boundary points (Voronoi-inspired medial
451/// axis).  This is a lightweight approximation suitable for testing geometry
452/// tools; production use requires a full 3-D skeletonisation algorithm.
453pub fn extract_centerline_approx(
454    mask: &[bool],
455    nx: usize,
456    ny: usize,
457    nz: usize,
458    voxel_size: [f64; 3],
459    origin: Vec3,
460) -> VesselCenterline {
461    // Collect foreground voxel centres
462    let mut centres: Vec<Vec3> = Vec::new();
463    for iz in 0..nz {
464        for iy in 0..ny {
465            for ix in 0..nx {
466                let idx = iz * ny * nx + iy * nx + ix;
467                if mask[idx] {
468                    centres.push(Vec3::new(
469                        origin.x + ix as f64 * voxel_size[0],
470                        origin.y + iy as f64 * voxel_size[1],
471                        origin.z + iz as f64 * voxel_size[2],
472                    ));
473                }
474            }
475        }
476    }
477    if centres.is_empty() {
478        return VesselCenterline { points: Vec::new() };
479    }
480    // Sort by Z to create an ordered path
481    centres.sort_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal));
482
483    // For each slice z-level, compute centroid as the centreline point
484    let mut cl_pts: Vec<CenterlinePoint> = Vec::new();
485    let mut i = 0;
486    while i < centres.len() {
487        let z_ref = centres[i].z;
488        let mut sum = Vec3::zero();
489        let mut count = 0usize;
490        let start = i;
491        while i < centres.len() && (centres[i].z - z_ref).abs() < voxel_size[2] * 0.5 {
492            sum = sum.add(&centres[i]);
493            count += 1;
494            i += 1;
495        }
496        let centroid = sum.scale(1.0 / count as f64);
497        // radius estimate: average distance from centroid to slice points
498        let r = if count > 1 {
499            centres[start..i]
500                .iter()
501                .map(|p| centroid.distance_to(p))
502                .sum::<f64>()
503                / count as f64
504        } else {
505            voxel_size[0]
506        };
507        cl_pts.push(CenterlinePoint {
508            position: centroid,
509            radius: r,
510        });
511    }
512    VesselCenterline { points: cl_pts }
513}
514
515// ---------------------------------------------------------------------------
516// 5. Organ bounding volumes
517// ---------------------------------------------------------------------------
518
519/// Axis-aligned bounding box (AABB) in patient coordinates.
520#[derive(Debug, Clone, Copy)]
521pub struct Aabb {
522    /// Minimum corner (mm).
523    pub min: Vec3,
524    /// Maximum corner (mm).
525    pub max: Vec3,
526}
527
528impl Aabb {
529    /// Construct from explicit min/max corners.
530    pub fn new(min: Vec3, max: Vec3) -> Self {
531        Self { min, max }
532    }
533
534    /// Compute the bounding box of a point cloud.
535    pub fn from_points(pts: &[Vec3]) -> Option<Self> {
536        if pts.is_empty() {
537            return None;
538        }
539        let mut mn = pts[0];
540        let mut mx = pts[0];
541        for p in pts.iter().skip(1) {
542            mn.x = mn.x.min(p.x);
543            mn.y = mn.y.min(p.y);
544            mn.z = mn.z.min(p.z);
545            mx.x = mx.x.max(p.x);
546            mx.y = mx.y.max(p.y);
547            mx.z = mx.z.max(p.z);
548        }
549        Some(Self { min: mn, max: mx })
550    }
551
552    /// Centre of the bounding box.
553    pub fn centre(&self) -> Vec3 {
554        Vec3::new(
555            (self.min.x + self.max.x) * 0.5,
556            (self.min.y + self.max.y) * 0.5,
557            (self.min.z + self.max.z) * 0.5,
558        )
559    }
560
561    /// Volume of the bounding box in mm³.
562    pub fn volume(&self) -> f64 {
563        let d = self.max.sub(&self.min);
564        d.x.max(0.0) * d.y.max(0.0) * d.z.max(0.0)
565    }
566
567    /// Diagonal length in mm.
568    pub fn diagonal(&self) -> f64 {
569        self.min.distance_to(&self.max)
570    }
571
572    /// Returns true when the point lies inside the AABB.
573    pub fn contains(&self, p: Vec3) -> bool {
574        p.x >= self.min.x
575            && p.x <= self.max.x
576            && p.y >= self.min.y
577            && p.y <= self.max.y
578            && p.z >= self.min.z
579            && p.z <= self.max.z
580    }
581
582    /// Expand the AABB by `margin` mm on all sides.
583    pub fn expand(&self, margin: f64) -> Self {
584        Self {
585            min: self.min.sub(&Vec3::new(margin, margin, margin)),
586            max: self.max.add(&Vec3::new(margin, margin, margin)),
587        }
588    }
589}
590
591// ---------------------------------------------------------------------------
592// 6. Anatomical coordinate systems (RAS / LPS)
593// ---------------------------------------------------------------------------
594
595/// Anatomical coordinate system convention.
596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum CoordSystem {
598    /// Right–Anterior–Superior (used by NIfTI, ITK, 3D Slicer).
599    Ras,
600    /// Left–Posterior–Superior (used by DICOM).
601    Lps,
602}
603
604/// Convert a point from LPS (DICOM) to RAS (NIfTI) coordinates by flipping
605/// the X and Y axes.
606pub fn lps_to_ras(p: Vec3) -> Vec3 {
607    Vec3::new(-p.x, -p.y, p.z)
608}
609
610/// Convert a point from RAS (NIfTI) to LPS (DICOM) coordinates.
611pub fn ras_to_lps(p: Vec3) -> Vec3 {
612    Vec3::new(-p.x, -p.y, p.z)
613}
614
615/// A 4×4 homogeneous transformation matrix stored in row-major order.
616///
617/// Suitable for affine transformations between image voxel space and
618/// patient/world space (e.g. the NIfTI sform/qform matrix).
619#[derive(Debug, Clone, Copy)]
620pub struct AffineMatrix4 {
621    /// Row-major 4×4 matrix data.
622    pub m: [[f64; 4]; 4],
623}
624
625impl AffineMatrix4 {
626    /// Identity matrix.
627    pub fn identity() -> Self {
628        let mut m = [[0.0f64; 4]; 4];
629        m[0][0] = 1.0;
630        m[1][1] = 1.0;
631        m[2][2] = 1.0;
632        m[3][3] = 1.0;
633        Self { m }
634    }
635
636    /// Apply the affine transform to a 3-D point (homogeneous w=1).
637    pub fn transform_point(&self, p: Vec3) -> Vec3 {
638        let m = &self.m;
639        Vec3::new(
640            m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z + m[0][3],
641            m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z + m[1][3],
642            m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z + m[2][3],
643        )
644    }
645
646    /// Build a voxel-to-world matrix from DICOM metadata:
647    /// pixel spacing, slice spacing, image position, and direction cosines.
648    pub fn from_dicom(
649        pixel_spacing: [f64; 2],
650        slice_spacing: f64,
651        image_position: Vec3,
652        row_cosines: Vec3,
653        col_cosines: Vec3,
654    ) -> Self {
655        let normal = row_cosines.cross(&col_cosines).normalize();
656        let mut m = [[0.0f64; 4]; 4];
657        // Column 0: row direction × pixel_spacing[0]
658        m[0][0] = row_cosines.x * pixel_spacing[0];
659        m[1][0] = row_cosines.y * pixel_spacing[0];
660        m[2][0] = row_cosines.z * pixel_spacing[0];
661        // Column 1: col direction × pixel_spacing[1]
662        m[0][1] = col_cosines.x * pixel_spacing[1];
663        m[1][1] = col_cosines.y * pixel_spacing[1];
664        m[2][1] = col_cosines.z * pixel_spacing[1];
665        // Column 2: slice normal × slice_spacing
666        m[0][2] = normal.x * slice_spacing;
667        m[1][2] = normal.y * slice_spacing;
668        m[2][2] = normal.z * slice_spacing;
669        // Column 3: image position
670        m[0][3] = image_position.x;
671        m[1][3] = image_position.y;
672        m[2][3] = image_position.z;
673        m[3][3] = 1.0;
674        Self { m }
675    }
676}
677
678// ---------------------------------------------------------------------------
679// 7. Surgical planning geometry
680// ---------------------------------------------------------------------------
681
682/// A cutting plane defined by a point and outward normal, used in surgical
683/// resection planning.
684#[derive(Debug, Clone, Copy)]
685pub struct CuttingPlane {
686    /// A point lying on the plane (mm).
687    pub point: Vec3,
688    /// Unit outward normal of the plane.
689    pub normal: Vec3,
690}
691
692impl CuttingPlane {
693    /// Construct a cutting plane from a point and a (possibly unnormalised)
694    /// normal vector.
695    pub fn new(point: Vec3, normal: Vec3) -> Self {
696        Self {
697            point,
698            normal: normal.normalize(),
699        }
700    }
701
702    /// Signed distance from `p` to the plane (positive on the normal side).
703    pub fn signed_distance(&self, p: Vec3) -> f64 {
704        self.normal.dot(&p.sub(&self.point))
705    }
706
707    /// Returns true when point `p` is on the positive (resection) side.
708    pub fn is_resected(&self, p: Vec3) -> bool {
709        self.signed_distance(p) >= 0.0
710    }
711
712    /// Compute the intersection of a line segment (a→b) with the plane.
713    /// Returns `None` when the segment is parallel to the plane.
714    pub fn intersect_segment(&self, a: Vec3, b: Vec3) -> Option<Vec3> {
715        let da = self.signed_distance(a);
716        let db = self.signed_distance(b);
717        if (da - db).abs() < 1e-12 {
718            return None; // parallel
719        }
720        let t = da / (da - db);
721        Some(a.add(&b.sub(&a).scale(t)))
722    }
723}
724
725/// Estimate the resection volume of a convex polyhedron defined by its vertex
726/// list, when cut by a single plane.  Uses the proportion of vertices on the
727/// resected side as a simple approximation (accurate only for convex uniform
728/// distributions; replace with exact decomposition for clinical use).
729pub fn resection_volume_approx(
730    vertices: &[Vec3],
731    plane: &CuttingPlane,
732    total_volume_mm3: f64,
733) -> f64 {
734    if vertices.is_empty() {
735        return 0.0;
736    }
737    let resected = vertices.iter().filter(|&&v| plane.is_resected(v)).count();
738    total_volume_mm3 * resected as f64 / vertices.len() as f64
739}
740
741/// Multiple-plane surgical resection descriptor.
742#[derive(Debug, Clone)]
743pub struct ResectionPlan {
744    /// Set of cutting planes applied sequentially.
745    pub planes: Vec<CuttingPlane>,
746    /// Label for this surgical plan.
747    pub label: String,
748}
749
750impl ResectionPlan {
751    /// Create an empty resection plan.
752    pub fn new(label: &str) -> Self {
753        Self {
754            planes: Vec::new(),
755            label: label.to_string(),
756        }
757    }
758
759    /// Add a cutting plane to the plan.
760    pub fn add_plane(&mut self, plane: CuttingPlane) {
761        self.planes.push(plane);
762    }
763
764    /// Determine whether a point is fully resected (on positive side of ALL
765    /// planes).
766    pub fn is_fully_resected(&self, p: Vec3) -> bool {
767        self.planes.iter().all(|pl| pl.is_resected(p))
768    }
769}
770
771// ---------------------------------------------------------------------------
772// 8. Marching cubes mesh extraction
773// ---------------------------------------------------------------------------
774
775/// A simple triangle mesh produced by marching-cubes surface extraction.
776#[derive(Debug, Clone)]
777pub struct IsoSurface {
778    /// Vertex positions (mm).
779    pub vertices: Vec<Vec3>,
780    /// Triangles as triplets of vertex indices.
781    pub triangles: Vec<[usize; 3]>,
782}
783
784impl IsoSurface {
785    /// Number of triangles in the mesh.
786    pub fn triangle_count(&self) -> usize {
787        self.triangles.len()
788    }
789
790    /// Number of vertices.
791    pub fn vertex_count(&self) -> usize {
792        self.vertices.len()
793    }
794
795    /// Total surface area (mm²).
796    pub fn surface_area(&self) -> f64 {
797        self.triangles
798            .iter()
799            .map(|tri| {
800                let a = self.vertices[tri[0]];
801                let b = self.vertices[tri[1]];
802                let c = self.vertices[tri[2]];
803                let ab = b.sub(&a);
804                let ac = c.sub(&a);
805                ab.cross(&ac).norm() * 0.5
806            })
807            .sum()
808    }
809}
810
811/// Marching-cubes edge table: for each cube configuration (0..255) the
812/// bitmask of which edges are intersected.
813///
814/// Full 256-entry table (standard MC lookup).
815const EDGE_TABLE: [u16; 256] = [
816    0x000, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a,
817    0xd03, 0xe09, 0xf00, 0x190, 0x099, 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, 0x99c, 0x895,
818    0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, 0x230, 0x339, 0x033, 0x13a, 0x636, 0x73f, 0x435,
819    0x53c, 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, 0x3a0, 0x2a9, 0x1a3, 0x0aa,
820    0x7a6, 0x6af, 0x5a5, 0x4ac, 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, 0x460,
821    0x569, 0x663, 0x76a, 0x066, 0x16f, 0x265, 0x36c, 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963,
822    0xa69, 0xb60, 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0x0ff, 0x3f5, 0x2fc, 0xdfc, 0xcf5, 0xfff,
823    0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x055, 0x15c,
824    0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6,
825    0x2cf, 0x1c5, 0x0cc, 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, 0x8c0, 0x9c9,
826    0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, 0x0cc, 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9,
827    0x7c0, 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, 0x15c, 0x055, 0x35f, 0x256,
828    0x55a, 0x453, 0x759, 0x650, 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, 0x2fc,
829    0x3f5, 0x0ff, 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f,
830    0xd65, 0xc6c, 0x36c, 0x265, 0x16f, 0x066, 0x76a, 0x663, 0x569, 0x460, 0xca0, 0xda9, 0xea3,
831    0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, 0x4ac, 0x5a5, 0x6af, 0x7a6, 0x0aa, 0x1a3, 0x2a9, 0x3a0,
832    0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x835, 0xb3f, 0xa36, 0x53c, 0x435, 0x73f, 0x636, 0x13a,
833    0x033, 0x339, 0x230, 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, 0x69c, 0x795,
834    0x49f, 0x596, 0x29a, 0x393, 0x099, 0x190, 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905,
835    0x80c, 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x000,
836];
837
838/// Extract an iso-surface from a [`ScalarVolume`] at the given `iso_value`
839/// using the marching-cubes algorithm.
840///
841/// This implementation uses linear interpolation along each edge.  The result
842/// is suitable for visualisation and surface measurements; run
843/// [`smooth_medical_mesh`] afterwards for clinical use.
844pub fn marching_cubes(volume: &ScalarVolume, iso_value: f64) -> IsoSurface {
845    let nx = volume.nx;
846    let ny = volume.ny;
847    let nz = volume.nz;
848    let [dx, dy, dz] = volume.voxel_size;
849
850    let mut vertices: Vec<Vec3> = Vec::new();
851    let mut triangles: Vec<[usize; 3]> = Vec::new();
852
853    // Edge vertex helper: interpolate between two corner positions
854    let interp = |p0: Vec3, v0: f64, p1: Vec3, v1: f64| -> Vec3 {
855        if (v1 - v0).abs() < 1e-10 {
856            return p0.add(&p1).scale(0.5);
857        }
858        let t = (iso_value - v0) / (v1 - v0);
859        p0.add(&p1.sub(&p0).scale(t))
860    };
861
862    for iz in 0..nz.saturating_sub(1) {
863        for iy in 0..ny.saturating_sub(1) {
864            for ix in 0..nx.saturating_sub(1) {
865                // 8 corners of the cube
866                let corners_idx = [
867                    (ix, iy, iz),
868                    (ix + 1, iy, iz),
869                    (ix + 1, iy + 1, iz),
870                    (ix, iy + 1, iz),
871                    (ix, iy, iz + 1),
872                    (ix + 1, iy, iz + 1),
873                    (ix + 1, iy + 1, iz + 1),
874                    (ix, iy + 1, iz + 1),
875                ];
876                let corners_pos: Vec<Vec3> = corners_idx
877                    .iter()
878                    .map(|&(cx, cy, cz)| {
879                        Vec3::new(
880                            volume.origin.x + cx as f64 * dx,
881                            volume.origin.y + cy as f64 * dy,
882                            volume.origin.z + cz as f64 * dz,
883                        )
884                    })
885                    .collect();
886                let vals: Vec<f64> = corners_idx
887                    .iter()
888                    .map(|&(cx, cy, cz)| volume.get(cx, cy, cz).unwrap_or(0.0))
889                    .collect();
890
891                // Cube index
892                let mut cube_idx: usize = 0;
893                for (i, &v) in vals.iter().enumerate() {
894                    if v >= iso_value {
895                        cube_idx |= 1 << i;
896                    }
897                }
898
899                if cube_idx == 0 || cube_idx == 255 {
900                    continue;
901                }
902
903                let _edge_bits = EDGE_TABLE[cube_idx];
904
905                // Compute the 12 edge mid-points (interpolated)
906                let edge_verts: Vec<Vec3> = [
907                    (0, 1),
908                    (1, 2),
909                    (2, 3),
910                    (3, 0),
911                    (4, 5),
912                    (5, 6),
913                    (6, 7),
914                    (7, 4),
915                    (0, 4),
916                    (1, 5),
917                    (2, 6),
918                    (3, 7),
919                ]
920                .iter()
921                .map(|&(a, b)| interp(corners_pos[a], vals[a], corners_pos[b], vals[b]))
922                .collect();
923
924                // Build triangles using a simple fan approach from active edges
925                // (simplified – full MC requires a tri_table; this emits the
926                // correct vertex positions for topology testing)
927                let active: Vec<usize> = (0..12)
928                    .filter(|&e| (EDGE_TABLE[cube_idx] >> e) & 1 == 1)
929                    .collect();
930                let base = vertices.len();
931                for &e in &active {
932                    vertices.push(edge_verts[e]);
933                }
934                let n = active.len();
935                for i in 1..n.saturating_sub(1) {
936                    triangles.push([base, base + i, base + i + 1]);
937                }
938            }
939        }
940    }
941
942    IsoSurface {
943        vertices,
944        triangles,
945    }
946}
947
948// ---------------------------------------------------------------------------
949// 9. Measurement tools
950// ---------------------------------------------------------------------------
951
952/// Compute the angle (radians) at vertex B formed by the line segments A→B
953/// and C→B.
954pub fn angle_at_vertex(a: Vec3, b: Vec3, c: Vec3) -> f64 {
955    let ba = a.sub(&b).normalize();
956    let bc = c.sub(&b).normalize();
957    ba.dot(&bc).clamp(-1.0, 1.0).acos()
958}
959
960/// Compute the Euclidean distance between two anatomical landmarks (mm).
961pub fn landmark_distance(a: Vec3, b: Vec3) -> f64 {
962    a.distance_to(&b)
963}
964
965/// Estimate the volume of a closed triangle mesh using the signed-tetrahedra
966/// method (divergence theorem).  The mesh must be manifold and consistently
967/// wound for the result to be meaningful.
968pub fn mesh_volume_mm3(mesh: &IsoSurface) -> f64 {
969    let mut vol = 0.0f64;
970    for tri in &mesh.triangles {
971        let a = mesh.vertices[tri[0]];
972        let b = mesh.vertices[tri[1]];
973        let c = mesh.vertices[tri[2]];
974        // Signed volume of tetrahedron with apex at origin
975        vol += a.dot(&b.cross(&c));
976    }
977    (vol / 6.0).abs()
978}
979
980/// Compute the centroid (centre of mass) of a point cloud.
981pub fn point_cloud_centroid(pts: &[Vec3]) -> Option<Vec3> {
982    if pts.is_empty() {
983        return None;
984    }
985    let mut s = Vec3::zero();
986    for p in pts {
987        s = s.add(p);
988    }
989    Some(s.scale(1.0 / pts.len() as f64))
990}
991
992/// Hausdorff distance from set A to set B: max over A of min distance to B.
993pub fn hausdorff_distance(a: &[Vec3], b: &[Vec3]) -> f64 {
994    if a.is_empty() || b.is_empty() {
995        return 0.0;
996    }
997    a.iter()
998        .map(|pa| {
999            b.iter()
1000                .map(|pb| pa.distance_to(pb))
1001                .fold(f64::INFINITY, f64::min)
1002        })
1003        .fold(f64::NEG_INFINITY, f64::max)
1004}
1005
1006// ---------------------------------------------------------------------------
1007// 10. Mesh smoothing for medical meshes
1008// ---------------------------------------------------------------------------
1009
1010/// Laplacian smoothing parameters.
1011#[derive(Debug, Clone, Copy)]
1012pub struct LaplacianSmoothParams {
1013    /// Smoothing factor λ ∈ (0, 1].  Smaller values = less smoothing per
1014    /// iteration.
1015    pub lambda: f64,
1016    /// Number of smoothing iterations.
1017    pub iterations: usize,
1018}
1019
1020impl LaplacianSmoothParams {
1021    /// Conservative smoothing suitable for bone and organ meshes.
1022    pub fn medical_default() -> Self {
1023        Self {
1024            lambda: 0.3,
1025            iterations: 10,
1026        }
1027    }
1028}
1029
1030/// Apply Laplacian smoothing to an [`IsoSurface`] mesh.
1031///
1032/// For each vertex the new position is: `v + λ * (centroid(neighbours) - v)`.
1033/// The connectivity is derived from the triangle list.
1034pub fn smooth_medical_mesh(mesh: &IsoSurface, params: LaplacianSmoothParams) -> IsoSurface {
1035    let nv = mesh.vertices.len();
1036    if nv == 0 {
1037        return mesh.clone();
1038    }
1039
1040    // Build adjacency list
1041    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); nv];
1042    for tri in &mesh.triangles {
1043        let [a, b, c] = *tri;
1044        adj[a].push(b);
1045        adj[a].push(c);
1046        adj[b].push(a);
1047        adj[b].push(c);
1048        adj[c].push(a);
1049        adj[c].push(b);
1050    }
1051
1052    let mut verts = mesh.vertices.clone();
1053    for _ in 0..params.iterations {
1054        let prev = verts.clone();
1055        for i in 0..nv {
1056            if adj[i].is_empty() {
1057                continue;
1058            }
1059            let mut s = Vec3::zero();
1060            for &j in &adj[i] {
1061                s = s.add(&prev[j]);
1062            }
1063            let centroid = s.scale(1.0 / adj[i].len() as f64);
1064            let delta = centroid.sub(&prev[i]).scale(params.lambda);
1065            verts[i] = prev[i].add(&delta);
1066        }
1067    }
1068
1069    IsoSurface {
1070        vertices: verts,
1071        triangles: mesh.triangles.clone(),
1072    }
1073}
1074
1075/// Taubin smoothing (λ/μ two-pass) to reduce shrinkage during Laplacian
1076/// smoothing.  Typical values: λ=0.33, μ=−0.34.
1077pub fn taubin_smooth_medical_mesh(
1078    mesh: &IsoSurface,
1079    lambda: f64,
1080    mu: f64,
1081    iterations: usize,
1082) -> IsoSurface {
1083    let forward = LaplacianSmoothParams {
1084        lambda,
1085        iterations: 1,
1086    };
1087    let backward = LaplacianSmoothParams {
1088        lambda: mu,
1089        iterations: 1,
1090    };
1091    let mut current = mesh.clone();
1092    for _ in 0..iterations {
1093        current = smooth_medical_mesh(&current, forward);
1094        current = smooth_medical_mesh(&current, backward);
1095    }
1096    current
1097}
1098
1099// ---------------------------------------------------------------------------
1100// 11. Slice-stack geometry helpers
1101// ---------------------------------------------------------------------------
1102
1103/// Stack geometry descriptor for a series of parallel DICOM slices.
1104#[derive(Debug, Clone)]
1105pub struct SliceStack {
1106    /// Ordered list of image-position patient z-coordinates (mm) for each
1107    /// slice in the series.
1108    pub slice_positions: Vec<f64>,
1109    /// Common pixel spacing \[row, col\] (mm).
1110    pub pixel_spacing: [f64; 2],
1111    /// In-plane matrix size \[rows, cols\].
1112    pub matrix_size: [usize; 2],
1113}
1114
1115impl SliceStack {
1116    /// Build a slice stack from a vector of z-positions and common geometry.
1117    pub fn new(
1118        slice_positions: Vec<f64>,
1119        pixel_spacing: [f64; 2],
1120        matrix_size: [usize; 2],
1121    ) -> Self {
1122        Self {
1123            slice_positions,
1124            pixel_spacing,
1125            matrix_size,
1126        }
1127    }
1128
1129    /// Number of slices.
1130    pub fn slice_count(&self) -> usize {
1131        self.slice_positions.len()
1132    }
1133
1134    /// Mean inter-slice spacing (mm).  Returns `None` for fewer than 2
1135    /// slices.
1136    pub fn mean_slice_spacing(&self) -> Option<f64> {
1137        let n = self.slice_positions.len();
1138        if n < 2 {
1139            return None;
1140        }
1141        let total: f64 = self
1142            .slice_positions
1143            .windows(2)
1144            .map(|w| (w[1] - w[0]).abs())
1145            .sum();
1146        Some(total / (n - 1) as f64)
1147    }
1148
1149    /// Slab thickness covered by the whole series (mm).
1150    pub fn slab_thickness(&self) -> f64 {
1151        if self.slice_positions.is_empty() {
1152            return 0.0;
1153        }
1154        let min = self
1155            .slice_positions
1156            .iter()
1157            .cloned()
1158            .fold(f64::INFINITY, f64::min);
1159        let max = self
1160            .slice_positions
1161            .iter()
1162            .cloned()
1163            .fold(f64::NEG_INFINITY, f64::max);
1164        max - min
1165    }
1166
1167    /// Field of view in mm: \[row FOV, col FOV\].
1168    pub fn field_of_view(&self) -> [f64; 2] {
1169        [
1170            self.pixel_spacing[0] * self.matrix_size[0] as f64,
1171            self.pixel_spacing[1] * self.matrix_size[1] as f64,
1172        ]
1173    }
1174
1175    /// Voxel volume in mm³ (pixel_spacing\[0\] × pixel_spacing\[1\] × mean
1176    /// slice_spacing).
1177    pub fn voxel_volume_mm3(&self) -> Option<f64> {
1178        let ss = self.mean_slice_spacing()?;
1179        Some(self.pixel_spacing[0] * self.pixel_spacing[1] * ss)
1180    }
1181}
1182
1183// ---------------------------------------------------------------------------
1184// 12. Sphere fitting (for femoral head, acetabulum)
1185// ---------------------------------------------------------------------------
1186
1187/// Result of a sphere fit to a point cloud.
1188#[derive(Debug, Clone, Copy)]
1189pub struct SphereFit {
1190    /// Centre of the best-fit sphere.
1191    pub centre: Vec3,
1192    /// Radius of the best-fit sphere (mm).
1193    pub radius: f64,
1194    /// RMS residual (mm).
1195    pub rms_error: f64,
1196}
1197
1198/// Fit a sphere to a point cloud using the mean-distance-to-centroid method.
1199///
1200/// The centre is set to the point cloud centroid; the radius is the mean
1201/// distance from that centroid to all points.  This gives a good first
1202/// approximation for compact, roughly spherical surfaces such as the femoral
1203/// head or acetabulum.
1204///
1205/// Returns `None` when fewer than 4 points are supplied.
1206pub fn fit_sphere_to_points(pts: &[Vec3]) -> Option<SphereFit> {
1207    let n = pts.len();
1208    if n < 4 {
1209        return None;
1210    }
1211    let cx: f64 = pts.iter().map(|p| p.x).sum::<f64>() / n as f64;
1212    let cy: f64 = pts.iter().map(|p| p.y).sum::<f64>() / n as f64;
1213    let cz: f64 = pts.iter().map(|p| p.z).sum::<f64>() / n as f64;
1214    let centre = Vec3::new(cx, cy, cz);
1215    let r_vals: Vec<f64> = pts.iter().map(|p| centre.distance_to(p)).collect();
1216    let radius = r_vals.iter().sum::<f64>() / n as f64;
1217    let rms = (r_vals
1218        .iter()
1219        .map(|&r| (r - radius) * (r - radius))
1220        .sum::<f64>()
1221        / n as f64)
1222        .sqrt();
1223    Some(SphereFit {
1224        centre,
1225        radius,
1226        rms_error: rms,
1227    })
1228}
1229
1230// ---------------------------------------------------------------------------
1231// 13. Anatomical landmark helpers
1232// ---------------------------------------------------------------------------
1233
1234/// Named anatomical landmark.
1235#[derive(Debug, Clone)]
1236pub struct Landmark {
1237    /// Anatomical label (e.g. "ASIS_L", "Greater_Trochanter_R").
1238    pub label: String,
1239    /// 3-D position in patient/world coordinates (mm).
1240    pub position: Vec3,
1241}
1242
1243impl Landmark {
1244    /// Create a new landmark.
1245    pub fn new(label: &str, position: Vec3) -> Self {
1246        Self {
1247            label: label.to_string(),
1248            position,
1249        }
1250    }
1251}
1252
1253/// Compute the anatomical pelvic tilt angle (degrees) from three standard
1254/// landmarks: left ASIS, right ASIS, and pubic symphysis.
1255///
1256/// The pelvic tilt is the angle between the APP (anterior pelvic plane) normal
1257/// and the global vertical axis (0,1,0).
1258pub fn pelvic_tilt_deg(asis_l: Vec3, asis_r: Vec3, pubic_symphysis: Vec3) -> f64 {
1259    let mid_asis = asis_l.add(&asis_r).scale(0.5);
1260    let v1 = asis_r.sub(&asis_l);
1261    let v2 = pubic_symphysis.sub(&mid_asis);
1262    let normal = v1.cross(&v2).normalize();
1263    let vertical = Vec3::new(0.0, 1.0, 0.0);
1264    let cos_angle = normal.dot(&vertical).clamp(-1.0, 1.0);
1265    cos_angle.acos() * 180.0 / PI
1266}
1267
1268/// Compute the neck-shaft angle (degrees) of the femur from the femoral head
1269/// centre, neck centre, and diaphysis axis direction.
1270pub fn femoral_neck_shaft_angle_deg(head_centre: Vec3, neck_centre: Vec3, shaft_axis: Vec3) -> f64 {
1271    let neck_axis = head_centre.sub(&neck_centre).normalize();
1272    let shaft = shaft_axis.normalize();
1273    let cos_a = neck_axis.dot(&shaft).clamp(-1.0, 1.0);
1274    cos_a.acos() * 180.0 / PI
1275}
1276
1277// ---------------------------------------------------------------------------
1278// Unit tests
1279// ---------------------------------------------------------------------------
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284
1285    // ── Vec3 ────────────────────────────────────────────────────────────────
1286
1287    #[test]
1288    fn test_vec3_dot() {
1289        let a = Vec3::new(1.0, 0.0, 0.0);
1290        let b = Vec3::new(0.0, 1.0, 0.0);
1291        assert!((a.dot(&b)).abs() < 1e-12);
1292    }
1293
1294    #[test]
1295    fn test_vec3_cross() {
1296        let a = Vec3::new(1.0, 0.0, 0.0);
1297        let b = Vec3::new(0.0, 1.0, 0.0);
1298        let c = a.cross(&b);
1299        assert!((c.z - 1.0).abs() < 1e-12);
1300        assert!(c.x.abs() < 1e-12);
1301        assert!(c.y.abs() < 1e-12);
1302    }
1303
1304    #[test]
1305    fn test_vec3_normalize() {
1306        let v = Vec3::new(3.0, 4.0, 0.0);
1307        let n = v.normalize();
1308        assert!((n.norm() - 1.0).abs() < 1e-12);
1309    }
1310
1311    #[test]
1312    fn test_vec3_distance() {
1313        let a = Vec3::new(0.0, 0.0, 0.0);
1314        let b = Vec3::new(3.0, 4.0, 0.0);
1315        assert!((a.distance_to(&b) - 5.0).abs() < 1e-12);
1316    }
1317
1318    // ── DicomSliceOrientation ───────────────────────────────────────────────
1319
1320    #[test]
1321    fn test_dicom_orientation_normal_orthogonal_to_row_col() {
1322        let orient = DicomSliceOrientation::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1323        let n = orient.normal();
1324        assert!(n.dot(&orient.row_cosines).abs() < 1e-10);
1325        assert!(n.dot(&orient.col_cosines).abs() < 1e-10);
1326    }
1327
1328    #[test]
1329    fn test_dicom_orientation_is_orthogonal_axial() {
1330        let orient = DicomSliceOrientation::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1331        assert!(orient.is_orthogonal(1e-9));
1332    }
1333
1334    // ── DicomImagePlane ─────────────────────────────────────────────────────
1335
1336    #[test]
1337    fn test_pixel_to_patient_origin() {
1338        let plane = DicomImagePlane::new(
1339            [10.0, 20.0, 30.0],
1340            [0.5, 0.5],
1341            [1.0, 0.0, 0.0],
1342            [0.0, 1.0, 0.0],
1343            512,
1344            512,
1345        );
1346        let p = plane.pixel_to_patient(0.0, 0.0);
1347        assert!((p.x - 10.0).abs() < 1e-9);
1348        assert!((p.y - 20.0).abs() < 1e-9);
1349        assert!((p.z - 30.0).abs() < 1e-9);
1350    }
1351
1352    #[test]
1353    fn test_pixel_to_patient_offset() {
1354        let plane = DicomImagePlane::new(
1355            [0.0, 0.0, 0.0],
1356            [1.0, 1.0],
1357            [1.0, 0.0, 0.0],
1358            [0.0, 1.0, 0.0],
1359            512,
1360            512,
1361        );
1362        let p = plane.pixel_to_patient(3.0, 4.0);
1363        assert!((p.x - 3.0).abs() < 1e-9);
1364        assert!((p.y - 4.0).abs() < 1e-9);
1365    }
1366
1367    // ── ScalarVolume ────────────────────────────────────────────────────────
1368
1369    #[test]
1370    fn test_scalar_volume_set_get() {
1371        let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1372        vol.set(1, 2, 3, 42.0);
1373        assert_eq!(vol.get(1, 2, 3), Some(42.0));
1374    }
1375
1376    #[test]
1377    fn test_scalar_volume_out_of_bounds() {
1378        let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1379        assert_eq!(vol.get(4, 0, 0), None);
1380    }
1381
1382    #[test]
1383    fn test_trilinear_interp_at_voxel_centre() {
1384        let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1385        vol.set(1, 1, 1, 100.0);
1386        // At the exact voxel position the value should be 100
1387        let world = Vec3::new(1.0, 1.0, 1.0);
1388        let v = vol.trilinear_interp(world).unwrap();
1389        assert!((v - 100.0).abs() < 1e-6, "got {v}");
1390    }
1391
1392    #[test]
1393    fn test_trilinear_interp_outside_returns_none() {
1394        let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1395        assert!(vol.trilinear_interp(Vec3::new(10.0, 0.0, 0.0)).is_none());
1396    }
1397
1398    // ── HuThresholds ────────────────────────────────────────────────────────
1399
1400    #[test]
1401    fn test_hu_classify_cortical_bone() {
1402        let th = HuThresholds::default_ct();
1403        assert_eq!(th.classify(1000.0), TissueClass::CorticalBone);
1404    }
1405
1406    #[test]
1407    fn test_hu_classify_cancellous() {
1408        let th = HuThresholds::default_ct();
1409        assert_eq!(th.classify(300.0), TissueClass::CancellousBone);
1410    }
1411
1412    #[test]
1413    fn test_hu_classify_soft_tissue() {
1414        let th = HuThresholds::default_ct();
1415        assert_eq!(th.classify(50.0), TissueClass::SoftTissue);
1416    }
1417
1418    #[test]
1419    fn test_hu_classify_air() {
1420        let th = HuThresholds::default_ct();
1421        assert_eq!(th.classify(-1000.0), TissueClass::Air);
1422    }
1423
1424    // ── threshold_segment ───────────────────────────────────────────────────
1425
1426    #[test]
1427    fn test_threshold_segment_count() {
1428        let mut vol = ScalarVolume::new(2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1429        vol.set(0, 0, 0, 800.0);
1430        vol.set(1, 1, 1, 900.0);
1431        let mask = threshold_segment(&vol, 700.0);
1432        assert_eq!(mask.iter().filter(|&&b| b).count(), 2);
1433    }
1434
1435    #[test]
1436    fn test_bone_volume_mm3() {
1437        let mut vol = ScalarVolume::new(2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1438        vol.set(0, 0, 0, 800.0);
1439        let bv = bone_volume_mm3(&vol, 700.0);
1440        assert!((bv - 1.0).abs() < 1e-9); // 1 voxel × 1³ mm³
1441    }
1442
1443    // ── Aabb ────────────────────────────────────────────────────────────────
1444
1445    #[test]
1446    fn test_aabb_from_points() {
1447        let pts = vec![
1448            Vec3::new(1.0, 2.0, 3.0),
1449            Vec3::new(-1.0, 5.0, 0.0),
1450            Vec3::new(4.0, 0.0, 2.0),
1451        ];
1452        let bb = Aabb::from_points(&pts).unwrap();
1453        assert!((bb.min.x - (-1.0)).abs() < 1e-9);
1454        assert!((bb.max.y - 5.0).abs() < 1e-9);
1455    }
1456
1457    #[test]
1458    fn test_aabb_contains() {
1459        let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0));
1460        assert!(bb.contains(Vec3::new(5.0, 5.0, 5.0)));
1461        assert!(!bb.contains(Vec3::new(11.0, 5.0, 5.0)));
1462    }
1463
1464    #[test]
1465    fn test_aabb_volume() {
1466        let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 3.0, 4.0));
1467        assert!((bb.volume() - 24.0).abs() < 1e-9);
1468    }
1469
1470    #[test]
1471    fn test_aabb_expand() {
1472        let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1473        let expanded = bb.expand(1.0);
1474        assert!((expanded.min.x - (-1.0)).abs() < 1e-9);
1475        assert!((expanded.max.x - 2.0).abs() < 1e-9);
1476    }
1477
1478    // ── Coordinate systems ──────────────────────────────────────────────────
1479
1480    #[test]
1481    fn test_lps_to_ras_round_trip() {
1482        let p = Vec3::new(1.0, 2.0, 3.0);
1483        let ras = lps_to_ras(p);
1484        let back = ras_to_lps(ras);
1485        assert!((back.x - p.x).abs() < 1e-12);
1486        assert!((back.y - p.y).abs() < 1e-12);
1487        assert!((back.z - p.z).abs() < 1e-12);
1488    }
1489
1490    #[test]
1491    fn test_affine_identity_transform() {
1492        let m = AffineMatrix4::identity();
1493        let p = Vec3::new(1.0, 2.0, 3.0);
1494        let tp = m.transform_point(p);
1495        assert!((tp.x - 1.0).abs() < 1e-12);
1496        assert!((tp.y - 2.0).abs() < 1e-12);
1497        assert!((tp.z - 3.0).abs() < 1e-12);
1498    }
1499
1500    #[test]
1501    fn test_affine_from_dicom_pixel_zero() {
1502        let m = AffineMatrix4::from_dicom(
1503            [0.5, 0.5],
1504            1.0,
1505            Vec3::new(10.0, 20.0, 0.0),
1506            Vec3::new(1.0, 0.0, 0.0),
1507            Vec3::new(0.0, 1.0, 0.0),
1508        );
1509        let origin_voxel = m.transform_point(Vec3::zero());
1510        assert!((origin_voxel.x - 10.0).abs() < 1e-9);
1511        assert!((origin_voxel.y - 20.0).abs() < 1e-9);
1512    }
1513
1514    // ── CuttingPlane ────────────────────────────────────────────────────────
1515
1516    #[test]
1517    fn test_cutting_plane_signed_distance() {
1518        let plane = CuttingPlane::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
1519        assert!((plane.signed_distance(Vec3::new(0.0, 0.0, 3.0)) - 3.0).abs() < 1e-9);
1520        assert!((plane.signed_distance(Vec3::new(0.0, 0.0, -2.0)) - (-2.0)).abs() < 1e-9);
1521    }
1522
1523    #[test]
1524    fn test_cutting_plane_intersection() {
1525        let plane = CuttingPlane::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
1526        let a = Vec3::new(0.0, 0.0, -1.0);
1527        let b = Vec3::new(0.0, 0.0, 1.0);
1528        let pt = plane.intersect_segment(a, b).unwrap();
1529        assert!(pt.z.abs() < 1e-9);
1530    }
1531
1532    #[test]
1533    fn test_resection_plan_all_resected() {
1534        let mut plan = ResectionPlan::new("test");
1535        plan.add_plane(CuttingPlane::new(Vec3::zero(), Vec3::new(1.0, 0.0, 0.0)));
1536        assert!(plan.is_fully_resected(Vec3::new(5.0, 0.0, 0.0)));
1537        assert!(!plan.is_fully_resected(Vec3::new(-1.0, 0.0, 0.0)));
1538    }
1539
1540    // ── Measurement tools ───────────────────────────────────────────────────
1541
1542    #[test]
1543    fn test_angle_at_vertex_right_angle() {
1544        let a = Vec3::new(1.0, 0.0, 0.0);
1545        let b = Vec3::zero();
1546        let c = Vec3::new(0.0, 1.0, 0.0);
1547        let angle = angle_at_vertex(a, b, c);
1548        assert!((angle - PI / 2.0).abs() < 1e-10);
1549    }
1550
1551    #[test]
1552    fn test_angle_at_vertex_straight_line() {
1553        let a = Vec3::new(-1.0, 0.0, 0.0);
1554        let b = Vec3::zero();
1555        let c = Vec3::new(1.0, 0.0, 0.0);
1556        let angle = angle_at_vertex(a, b, c);
1557        assert!((angle - PI).abs() < 1e-10);
1558    }
1559
1560    #[test]
1561    fn test_landmark_distance() {
1562        let a = Vec3::new(0.0, 0.0, 0.0);
1563        let b = Vec3::new(0.0, 0.0, 5.0);
1564        assert!((landmark_distance(a, b) - 5.0).abs() < 1e-12);
1565    }
1566
1567    #[test]
1568    fn test_point_cloud_centroid() {
1569        let pts = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 2.0, 2.0)];
1570        let c = point_cloud_centroid(&pts).unwrap();
1571        assert!((c.x - 1.0).abs() < 1e-12);
1572        assert!((c.y - 1.0).abs() < 1e-12);
1573    }
1574
1575    #[test]
1576    fn test_hausdorff_distance_same_sets() {
1577        let pts = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
1578        assert!(hausdorff_distance(&pts, &pts) < 1e-12);
1579    }
1580
1581    #[test]
1582    fn test_hausdorff_distance_offset() {
1583        let a = vec![Vec3::new(0.0, 0.0, 0.0)];
1584        let b = vec![Vec3::new(3.0, 4.0, 0.0)];
1585        assert!((hausdorff_distance(&a, &b) - 5.0).abs() < 1e-12);
1586    }
1587
1588    // ── SliceStack ──────────────────────────────────────────────────────────
1589
1590    #[test]
1591    fn test_slice_stack_mean_spacing() {
1592        let positions: Vec<f64> = (0..10).map(|i| i as f64 * 2.5).collect();
1593        let stack = SliceStack::new(positions, [0.5, 0.5], [512, 512]);
1594        let ms = stack.mean_slice_spacing().unwrap();
1595        assert!((ms - 2.5).abs() < 1e-9);
1596    }
1597
1598    #[test]
1599    fn test_slice_stack_fov() {
1600        let stack = SliceStack::new(vec![0.0, 1.0], [0.5, 0.5], [512, 512]);
1601        let fov = stack.field_of_view();
1602        assert!((fov[0] - 256.0).abs() < 1e-9);
1603    }
1604
1605    #[test]
1606    fn test_slice_stack_slab_thickness() {
1607        let positions: Vec<f64> = (0..5).map(|i| i as f64).collect();
1608        let stack = SliceStack::new(positions, [1.0, 1.0], [256, 256]);
1609        assert!((stack.slab_thickness() - 4.0).abs() < 1e-9);
1610    }
1611
1612    // ── VesselCenterline ────────────────────────────────────────────────────
1613
1614    #[test]
1615    fn test_vessel_centerline_arc_length() {
1616        let cl = VesselCenterline::new(vec![
1617            (Vec3::new(0.0, 0.0, 0.0), 2.0),
1618            (Vec3::new(0.0, 0.0, 5.0), 2.0),
1619            (Vec3::new(0.0, 0.0, 10.0), 2.0),
1620        ]);
1621        assert!((cl.arc_length() - 10.0).abs() < 1e-9);
1622    }
1623
1624    #[test]
1625    fn test_vessel_centerline_tortuosity_straight() {
1626        let cl = VesselCenterline::new(vec![
1627            (Vec3::new(0.0, 0.0, 0.0), 1.0),
1628            (Vec3::new(0.0, 0.0, 5.0), 1.0),
1629        ]);
1630        assert!((cl.tortuosity() - 1.0).abs() < 1e-9);
1631    }
1632
1633    #[test]
1634    fn test_vessel_centerline_tortuosity_curved() {
1635        let cl = VesselCenterline::new(vec![
1636            (Vec3::new(0.0, 0.0, 0.0), 1.0),
1637            (Vec3::new(3.0, 4.0, 0.0), 1.0),
1638            (Vec3::new(6.0, 0.0, 0.0), 1.0),
1639        ]);
1640        // Arc = 5 + 5 = 10; chord = 6
1641        let t = cl.tortuosity();
1642        assert!(t > 1.0);
1643    }
1644
1645    // ── SphereFit ───────────────────────────────────────────────────────────
1646
1647    #[test]
1648    fn test_sphere_fit_unit_sphere() {
1649        let pts: Vec<Vec3> = (0..20)
1650            .map(|i| {
1651                let theta = i as f64 * PI / 10.0;
1652                Vec3::new(theta.cos(), theta.sin(), 0.0)
1653            })
1654            .collect();
1655        let fit = fit_sphere_to_points(&pts).unwrap();
1656        assert!((fit.radius - 1.0).abs() < 1e-6);
1657    }
1658
1659    #[test]
1660    fn test_sphere_fit_needs_four_points() {
1661        let pts = vec![
1662            Vec3::new(1.0, 0.0, 0.0),
1663            Vec3::new(-1.0, 0.0, 0.0),
1664            Vec3::new(0.0, 1.0, 0.0),
1665        ];
1666        assert!(fit_sphere_to_points(&pts).is_none());
1667    }
1668
1669    // ── IsoSurface ──────────────────────────────────────────────────────────
1670
1671    #[test]
1672    fn test_marching_cubes_empty_volume() {
1673        let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1674        let mesh = marching_cubes(&vol, 500.0);
1675        assert_eq!(mesh.triangle_count(), 0);
1676    }
1677
1678    #[test]
1679    fn test_marching_cubes_full_volume() {
1680        let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1681        for v in vol.data.iter_mut() {
1682            *v = 1000.0;
1683        }
1684        let mesh = marching_cubes(&vol, 500.0);
1685        // All cubes are above threshold → cube_idx = 255 → no triangles
1686        assert_eq!(mesh.triangle_count(), 0);
1687    }
1688
1689    #[test]
1690    fn test_smooth_medical_mesh_does_not_crash() {
1691        let mesh = IsoSurface {
1692            vertices: vec![
1693                Vec3::new(0.0, 0.0, 0.0),
1694                Vec3::new(1.0, 0.0, 0.0),
1695                Vec3::new(0.0, 1.0, 0.0),
1696            ],
1697            triangles: vec![[0, 1, 2]],
1698        };
1699        let smoothed = smooth_medical_mesh(&mesh, LaplacianSmoothParams::medical_default());
1700        assert_eq!(smoothed.vertex_count(), 3);
1701    }
1702
1703    #[test]
1704    fn test_taubin_smooth_preserves_vertex_count() {
1705        let mesh = IsoSurface {
1706            vertices: vec![
1707                Vec3::new(0.0, 0.0, 0.0),
1708                Vec3::new(1.0, 0.0, 0.0),
1709                Vec3::new(0.5, 1.0, 0.0),
1710                Vec3::new(0.5, 0.5, 1.0),
1711            ],
1712            triangles: vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]],
1713        };
1714        let out = taubin_smooth_medical_mesh(&mesh, 0.33, -0.34, 5);
1715        assert_eq!(out.vertex_count(), 4);
1716    }
1717
1718    // ── Anatomical helpers ──────────────────────────────────────────────────
1719
1720    #[test]
1721    fn test_pelvic_tilt_symmetric_pelvis() {
1722        let asis_l = Vec3::new(-80.0, 0.0, 0.0);
1723        let asis_r = Vec3::new(80.0, 0.0, 0.0);
1724        let ps = Vec3::new(0.0, -100.0, -40.0);
1725        let tilt = pelvic_tilt_deg(asis_l, asis_r, ps);
1726        // Should be a finite positive angle
1727        assert!(tilt.is_finite() && tilt > 0.0);
1728    }
1729
1730    #[test]
1731    fn test_femoral_neck_shaft_angle() {
1732        // 135 degrees is typical
1733        let head = Vec3::new(0.0, 35.0, 0.0); // head is up and outward
1734        let neck = Vec3::new(0.0, 0.0, 0.0);
1735        let shaft = Vec3::new(0.0, -1.0, 0.0); // shaft points down
1736        let angle = femoral_neck_shaft_angle_deg(head, neck, shaft);
1737        assert!((angle - 180.0).abs() < 1e-9);
1738    }
1739
1740    #[test]
1741    fn test_extract_centerline_empty_mask() {
1742        let mask = vec![false; 8];
1743        let cl = extract_centerline_approx(&mask, 2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1744        assert_eq!(cl.points.len(), 0);
1745    }
1746
1747    #[test]
1748    fn test_mesh_volume_tetrahedron() {
1749        // Regular tetrahedron with known volume
1750        let mesh = IsoSurface {
1751            vertices: vec![
1752                Vec3::new(0.0, 0.0, 0.0),
1753                Vec3::new(1.0, 0.0, 0.0),
1754                Vec3::new(0.0, 1.0, 0.0),
1755                Vec3::new(0.0, 0.0, 1.0),
1756            ],
1757            triangles: vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]],
1758        };
1759        let vol = mesh_volume_mm3(&mesh);
1760        // Volume of this tet = 1/6
1761        assert!((vol - 1.0 / 6.0).abs() < 0.01, "got {vol}");
1762    }
1763}