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