Skip to main content

qsm_core/utils/
curvature.rs

1//! Surface Curvature Calculation for QSMART
2//!
3//! This module computes Gaussian and mean curvatures at the surface of a 3D binary mask,
4//! based on the discrete differential geometry approach.
5//!
6//! The curvatures are used in QSMART to weight the spatially-dependent filtering
7//! near brain boundaries to reduce artifacts.
8//!
9//! Uses 2D Delaunay triangulation (via delaunator crate) matching MATLAB's approach:
10//! `tri = delaunay(x, y)` - triangulates on x,y coordinates with z as height.
11//!
12//! Reference:
13//! Meyer, M., Desbrun, M., Schröder, P., Barr, A.H. (2003).
14//! "Discrete Differential-Geometry Operators for Triangulated 2-Manifolds."
15//! Visualization and Mathematics III, 35-57. https://doi.org/10.1007/978-3-662-05105-4_2
16//!
17//! Reference implementation: https://www.mathworks.com/matlabcentral/fileexchange/61136-curvatures
18
19use std::collections::HashMap;
20use std::f64::consts::PI;
21use delaunator::{triangulate, Point};
22use crate::Grid;
23
24/// Result of curvature calculation
25pub struct CurvatureResult {
26    /// Gaussian curvature at surface voxels (full volume, 0 for non-surface)
27    pub gaussian_curvature: Vec<f64>,
28    /// Mean curvature at surface voxels (full volume, 0 for non-surface)
29    pub mean_curvature: Vec<f64>,
30    /// Indices of surface voxels
31    pub surface_indices: Vec<usize>,
32}
33
34/// Simple 3D point structure
35#[derive(Clone, Copy, Debug)]
36struct Point3D {
37    x: f64,
38    y: f64,
39    z: f64,
40}
41
42impl Point3D {
43    fn new(x: f64, y: f64, z: f64) -> Self {
44        Self { x, y, z }
45    }
46
47    fn sub(&self, other: &Point3D) -> Point3D {
48        Point3D::new(self.x - other.x, self.y - other.y, self.z - other.z)
49    }
50
51    fn dot(&self, other: &Point3D) -> f64 {
52        self.x * other.x + self.y * other.y + self.z * other.z
53    }
54
55    fn cross(&self, other: &Point3D) -> Point3D {
56        Point3D::new(
57            self.y * other.z - self.z * other.y,
58            self.z * other.x - self.x * other.z,
59            self.x * other.y - self.y * other.x,
60        )
61    }
62
63    fn norm(&self) -> f64 {
64        (self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
65    }
66
67    fn normalize(&self) -> Point3D {
68        let n = self.norm();
69        if n > 1e-10 {
70            Point3D::new(self.x / n, self.y / n, self.z / n)
71        } else {
72            Point3D::new(0.0, 0.0, 0.0)
73        }
74    }
75
76    fn scale(&self, s: f64) -> Point3D {
77        Point3D::new(self.x * s, self.y * s, self.z * s)
78    }
79
80    fn add(&self, other: &Point3D) -> Point3D {
81        Point3D::new(self.x + other.x, self.y + other.y, self.z + other.z)
82    }
83}
84
85/// Triangle structure
86#[derive(Clone, Copy, Debug)]
87struct Triangle {
88    v0: usize,
89    v1: usize,
90    v2: usize,
91}
92
93/// Extract surface voxels from a binary mask
94///
95/// Matches MATLAB's approach: curvMask = mask - imerode(mask, strel('sphere',1))
96/// Surface voxels are those in the mask but not in the eroded mask.
97fn extract_surface_voxels(
98    mask: &[u8],
99    nx: usize, ny: usize, nz: usize,
100) -> Vec<usize> {
101    let eroded = erode_mask(mask, nx, ny, nz, 1);
102
103    let mut surface = Vec::new();
104    for i in 0..mask.len() {
105        if mask[i] != 0 && eroded[i] == 0 {
106            surface.push(i);
107        }
108    }
109
110    surface
111}
112
113/// 2D Delaunay triangulation of surface points
114///
115/// This matches MATLAB's approach: `tri = delaunay(x, y)`
116/// Triangulates on x,y coordinates, treating z as a height field.
117///
118/// Points must have unique (x,y) coordinates (caller should deduplicate first).
119///
120/// Returns (triangles, boundary_flags) where boundary_flags[i] is true
121/// if vertex i is on the convex hull boundary (matching MATLAB's freeBoundary).
122fn triangulate_surface(
123    points: &[Point3D],
124) -> (Vec<Triangle>, Vec<bool>) {
125    if points.len() < 3 {
126        return (Vec::new(), vec![false; points.len()]);
127    }
128
129    // Convert to delaunator's Point format (2D: x, y only)
130    let coords: Vec<Point> = points.iter()
131        .map(|p| Point { x: p.x, y: p.y })
132        .collect();
133
134    // Run 2D Delaunay triangulation
135    let result = triangulate(&coords);
136
137    // Identify boundary vertices (convex hull of the 2D triangulation)
138    let mut boundary = vec![false; points.len()];
139    for &idx in &result.hull {
140        boundary[idx] = true;
141    }
142
143    // Convert triangles
144    let mut triangles = Vec::with_capacity(result.triangles.len() / 3);
145    for i in (0..result.triangles.len()).step_by(3) {
146        triangles.push(Triangle {
147            v0: result.triangles[i],
148            v1: result.triangles[i + 1],
149            v2: result.triangles[i + 2],
150        });
151    }
152
153    (triangles, boundary)
154}
155
156/// Compute Gaussian and mean curvatures using discrete differential geometry
157///
158/// Based on Meyer et al., "Discrete differential-geometry operators for triangulated 2-manifolds"
159///
160/// Boundary vertices (on the triangulation free boundary) get GC=0, MC=0
161/// to match MATLAB's curvatures.m behavior.
162fn compute_curvatures_from_mesh(
163    points: &[Point3D],
164    triangles: &[Triangle],
165    boundary: &[bool],
166) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
167    let n_points = points.len();
168    let mut gaussian_curvature = vec![0.0f64; n_points];
169    let mut mean_curvature = vec![0.0f64; n_points];
170    let mut angle_sum = vec![0.0f64; n_points];
171    let mut area_mixed = vec![0.0f64; n_points];
172    let mut mean_curv_vec = vec![Point3D::new(0.0, 0.0, 0.0); n_points];
173    let mut normal_vec = vec![Point3D::new(0.0, 0.0, 0.0); n_points];
174
175    // Process each triangle
176    for tri in triangles {
177        let p0 = &points[tri.v0];
178        let p1 = &points[tri.v1];
179        let p2 = &points[tri.v2];
180
181        // Edge vectors
182        let e01 = p1.sub(p0); // v0 -> v1
183        let e12 = p2.sub(p1); // v1 -> v2
184        let e20 = p0.sub(p2); // v2 -> v0
185
186        let l01 = e01.norm();
187        let l12 = e12.norm();
188        let l20 = e20.norm();
189
190        if l01 < 1e-10 || l12 < 1e-10 || l20 < 1e-10 {
191            continue;
192        }
193
194        // Triangle area
195        let cross = e01.cross(&e12.scale(-1.0));
196        let area = 0.5 * cross.norm();
197        if area < 1e-10 {
198            continue;
199        }
200
201        // Triangle normal
202        let face_normal = cross.normalize();
203
204        // Angles at each vertex
205        let cos_a0 = e01.normalize().dot(&e20.scale(-1.0).normalize());
206        let cos_a1 = e01.scale(-1.0).normalize().dot(&e12.normalize());
207        let cos_a2 = e12.scale(-1.0).normalize().dot(&e20.normalize());
208
209        let a0 = cos_a0.clamp(-1.0, 1.0).acos();
210        let a1 = cos_a1.clamp(-1.0, 1.0).acos();
211        let a2 = cos_a2.clamp(-1.0, 1.0).acos();
212
213        // Accumulate angle sums for Gaussian curvature
214        angle_sum[tri.v0] += a0;
215        angle_sum[tri.v1] += a1;
216        angle_sum[tri.v2] += a2;
217
218        // Compute cotangent weights for mean curvature
219        let cot_a0 = cos_a0 / (1.0 - cos_a0 * cos_a0).sqrt().max(1e-10);
220        let cot_a1 = cos_a1 / (1.0 - cos_a1 * cos_a1).sqrt().max(1e-10);
221        let cot_a2 = cos_a2 / (1.0 - cos_a2 * cos_a2).sqrt().max(1e-10);
222
223        // Compute A_mixed for each vertex
224        // Check if any angle is obtuse
225        let obtuse_0 = a0 > PI / 2.0;
226        let obtuse_1 = a1 > PI / 2.0;
227        let obtuse_2 = a2 > PI / 2.0;
228
229        // Add contribution to A_mixed for each vertex
230        if obtuse_0 {
231            area_mixed[tri.v0] += area / 2.0;
232        } else if obtuse_1 || obtuse_2 {
233            area_mixed[tri.v0] += area / 4.0;
234        } else {
235            area_mixed[tri.v0] += (l20 * l20 * cot_a1 + l01 * l01 * cot_a2) / 8.0;
236        }
237
238        if obtuse_1 {
239            area_mixed[tri.v1] += area / 2.0;
240        } else if obtuse_0 || obtuse_2 {
241            area_mixed[tri.v1] += area / 4.0;
242        } else {
243            area_mixed[tri.v1] += (l01 * l01 * cot_a2 + l12 * l12 * cot_a0) / 8.0;
244        }
245
246        if obtuse_2 {
247            area_mixed[tri.v2] += area / 2.0;
248        } else if obtuse_0 || obtuse_1 {
249            area_mixed[tri.v2] += area / 4.0;
250        } else {
251            area_mixed[tri.v2] += (l12 * l12 * cot_a0 + l20 * l20 * cot_a1) / 8.0;
252        }
253
254        // Mean curvature vector contribution
255        mean_curv_vec[tri.v0] = mean_curv_vec[tri.v0].add(&e01.scale(cot_a2).add(&e20.scale(-cot_a1)));
256        mean_curv_vec[tri.v1] = mean_curv_vec[tri.v1].add(&e12.scale(cot_a0).add(&e01.scale(-cot_a2)));
257        mean_curv_vec[tri.v2] = mean_curv_vec[tri.v2].add(&e20.scale(cot_a1).add(&e12.scale(-cot_a0)));
258
259        // Accumulate face normal for vertex normal using incenter-based distance weighting
260        // Matches MATLAB: wi = 1/norm(incenter - vertex); n_vec += wi * faceNormal
261        // Incenter = (a*P0 + b*P1 + c*P2) / (a+b+c) where a=|P1P2|, b=|P0P2|, c=|P0P1|
262        let perim = l12 + l20 + l01;
263        if perim > 1e-10 {
264            let incenter = p0.scale(l12).add(&p1.scale(l20)).add(&p2.scale(l01)).scale(1.0 / perim);
265
266            let w0 = 1.0 / p0.sub(&incenter).norm().max(1e-10);
267            let w1 = 1.0 / p1.sub(&incenter).norm().max(1e-10);
268            let w2 = 1.0 / p2.sub(&incenter).norm().max(1e-10);
269
270            normal_vec[tri.v0] = normal_vec[tri.v0].add(&face_normal.scale(w0));
271            normal_vec[tri.v1] = normal_vec[tri.v1].add(&face_normal.scale(w1));
272            normal_vec[tri.v2] = normal_vec[tri.v2].add(&face_normal.scale(w2));
273        }
274    }
275
276    // Compute final curvature values
277    // Skip boundary vertices (GC=0, MC=0) matching MATLAB's freeBoundary check
278    for i in 0..n_points {
279        if boundary[i] {
280            // Boundary vertices get zero curvature (unreliable)
281            continue;
282        }
283
284        if area_mixed[i] > 1e-10 {
285            // Gaussian curvature: K = (2π - Σθ) / A_mixed
286            gaussian_curvature[i] = (2.0 * PI - angle_sum[i]) / area_mixed[i];
287
288            // Mean curvature: H = |mean_curv_vec| / (4 * A_mixed)
289            let mc_vec = mean_curv_vec[i].scale(0.25 / area_mixed[i]);
290            let mc_mag = mc_vec.norm();
291
292            // Determine sign from dot product with normal
293            let n_vec = normal_vec[i].normalize();
294            let sign = if mc_vec.dot(&n_vec) < 0.0 { -1.0 } else { 1.0 };
295
296            mean_curvature[i] = sign * mc_mag;
297        }
298    }
299
300    (gaussian_curvature, mean_curvature, area_mixed)
301}
302
303/// Calculate proximity maps using curvature at the brain surface
304///
305/// This is the main entry point matching QSMART's calculate_curvature function.
306///
307/// # Arguments
308/// * `mask` - Binary brain mask
309/// * `prox1` - Initial proximity map from Gaussian smoothing
310/// * `lower_lim` - Clamping value for proximity (default 0.6)
311/// * `curv_constant` - Scaling constant for curvature (default 500)
312/// * `sigma` - Kernel size for smoothing curvature
313/// * `nx`, `ny`, `nz` - Volume dimensions
314///
315/// # Returns
316/// Modified proximity map incorporating curvature-based edge weighting
317pub fn calculate_curvature_proximity(
318    mask: &[u8],
319    prox1: &[f64],
320    lower_lim: f64,
321    curv_constant: f64,
322    sigma: f64,
323    grid: &Grid,
324) -> (Vec<f64>, Vec<f64>) {
325    let (nx, ny, nz) = grid.dims;
326    let n_total = nx * ny * nz;
327
328    // Extract surface voxels
329    let surface_indices = extract_surface_voxels(mask, nx, ny, nz);
330
331    if surface_indices.is_empty() {
332        return (prox1.to_vec(), vec![1.0; n_total]);
333    }
334
335    // Convert surface indices to 3D points
336    let all_points: Vec<Point3D> = surface_indices
337        .iter()
338        .map(|&idx| {
339            let i = idx % nx;
340            let j = (idx / nx) % ny;
341            let k = idx / (nx * ny);
342            Point3D::new(i as f64, j as f64, k as f64)
343        })
344        .collect();
345
346    // Deduplicate (x,y) coordinates before triangulation.
347    // MATLAB's delaunay (via Qhull) suppresses duplicate (x,y) points, keeping
348    // the first occurrence (smallest z). Duplicate vertices get GC=Inf → curvI=1.0.
349    // We explicitly dedup to avoid degenerate zero-area triangles that would
350    // corrupt curvature values.
351    let mut xy_to_rep: HashMap<(usize, usize), usize> = HashMap::new();
352    let mut is_representative = vec![false; all_points.len()];
353    for (idx, p) in all_points.iter().enumerate() {
354        let key = (p.x as usize, p.y as usize);
355        xy_to_rep.entry(key).or_insert_with(|| {
356            is_representative[idx] = true;
357            idx
358        });
359    }
360
361    // Build representative point array and index mapping
362    let rep_indices: Vec<usize> = (0..all_points.len())
363        .filter(|&i| is_representative[i])
364        .collect();
365    let mut orig_to_rep = vec![0usize; all_points.len()];
366    for (new_idx, &old_idx) in rep_indices.iter().enumerate() {
367        orig_to_rep[old_idx] = new_idx;
368    }
369    let rep_points: Vec<Point3D> = rep_indices.iter().map(|&i| all_points[i].clone()).collect();
370
371    // Triangulate unique representatives via Qhull (same library MATLAB uses)
372    let (triangles, boundary) = triangulate_surface(&rep_points);
373
374    // Compute curvatures on representative points
375    let (gc, _mc, _amixed) = compute_curvatures_from_mesh(&rep_points, &triangles, &boundary);
376
377    // Create full curvature volume
378    let mut curv_i = vec![1.0f64; n_total];
379
380    // Find max negative curvature for scaling
381    let max_neg_gc = gc.iter()
382        .filter(|&&v| v < 0.0)
383        .map(|&v| v.abs())
384        .fold(1.0f64, |a, b| a.max(b));
385
386    // Scale and assign curvature values for representative vertices only.
387    // Non-representative (duplicate x,y) vertices keep curvI=1.0, matching MATLAB
388    // where suppressed duplicates get GC=Inf → scaledGC=1.0.
389    for (orig_idx, &vol_idx) in surface_indices.iter().enumerate() {
390        if !is_representative[orig_idx] {
391            continue; // duplicate (x,y) → curvI=1.0
392        }
393        let rep_idx = orig_to_rep[orig_idx];
394        let g = gc[rep_idx];
395        let scaled = if g < 0.0 {
396            g / max_neg_gc * curv_constant
397        } else if g > 0.0 {
398            1.0
399        } else {
400            // GC == 0: boundary vertices, flat regions
401            0.0
402        };
403        curv_i[vol_idx] = scaled;
404    }
405
406    // Smooth the curvature map
407    let sigmas = [sigma, 2.0 * sigma, 2.0 * sigma];
408    let prox3 = gaussian_smooth_3d_masked(&curv_i, mask, nx, ny, nz, &sigmas);
409
410    // Clamp prox3 values
411    let prox3_clamped: Vec<f64> = prox3.iter().enumerate()
412        .map(|(i, &v)| {
413            if mask[i] == 0 {
414                0.0
415            } else if v < 0.5 && v != 0.0 {
416                0.5
417            } else {
418                v
419            }
420        })
421        .collect();
422
423    // Multiply with initial proximity
424    let mut prox: Vec<f64> = prox1.iter()
425        .zip(prox3_clamped.iter())
426        .map(|(&p1, &p3)| p1 * p3)
427        .collect();
428
429    // Edge proximity calculation (prox4)
430    // Matches MATLAB order of operations:
431    //   prox4 = prox .* (mask - imerode(mask, strel('sphere',1)));
432    //   prox4(prox4==0) = 1;
433    //   prox4((imdilate(mask, strel('sphere',5)) - mask)==1) = 0;
434    let surface_mask = create_surface_mask(mask, nx, ny, nz);
435    let dilated_mask = dilate_mask(mask, nx, ny, nz, 5);
436
437    // Step 1: prox4 = prox * surface_mask (surface voxels get prox, rest get 0)
438    let mut prox4 = vec![0.0f64; n_total];
439    for i in 0..n_total {
440        if surface_mask[i] != 0 {
441            prox4[i] = prox[i];
442        }
443    }
444    // Step 2: set ALL zero-valued voxels to 1 (interior + outside + surface with prox==0)
445    for i in 0..n_total {
446        if prox4[i] == 0.0 {
447            prox4[i] = 1.0;
448        }
449    }
450    // Step 3: set dilated shell outside mask to 0
451    for i in 0..n_total {
452        if dilated_mask[i] != 0 && mask[i] == 0 {
453            prox4[i] = 0.0;
454        }
455    }
456
457    // Smooth prox4
458    let prox4_smooth = gaussian_smooth_3d_masked(&prox4, &vec![1u8; n_total], nx, ny, nz, &[5.0, 10.0, 10.0]);
459
460    // Clamp proximity values
461    for i in 0..n_total {
462        if mask[i] == 0 {
463            prox[i] = 0.0;
464        } else if prox[i] < lower_lim && prox[i] != 0.0 {
465            prox[i] = lower_lim;
466        }
467    }
468
469    // Edge refinement
470    for i in 0..n_total {
471        prox[i] *= prox4_smooth[i];
472    }
473
474    (prox, curv_i)
475}
476
477/// Create a surface mask (boundary voxels)
478fn create_surface_mask(mask: &[u8], nx: usize, ny: usize, nz: usize) -> Vec<u8> {
479    let eroded = erode_mask(mask, nx, ny, nz, 1);
480    let mut surface = vec![0u8; mask.len()];
481
482    for i in 0..mask.len() {
483        if mask[i] != 0 && eroded[i] == 0 {
484            surface[i] = 1;
485        }
486    }
487
488    surface
489}
490
491/// Erode a binary mask using spherical structuring element
492fn erode_mask(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32) -> Vec<u8> {
493    let n_total = nx * ny * nz;
494    let mut eroded = vec![0u8; n_total];
495
496    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
497
498    for k in 0..nz {
499        for j in 0..ny {
500            for i in 0..nx {
501                if mask[idx(i, j, k)] == 0 {
502                    continue;
503                }
504
505                let mut all_inside = true;
506
507                'outer: for dz in -radius..=radius {
508                    for dy in -radius..=radius {
509                        for dx in -radius..=radius {
510                            let dist2 = dx * dx + dy * dy + dz * dz;
511                            if dist2 > radius * radius {
512                                continue;
513                            }
514
515                            let ni = i as i32 + dx;
516                            let nj = j as i32 + dy;
517                            let nk = k as i32 + dz;
518
519                            if ni < 0 || ni >= nx as i32 ||
520                               nj < 0 || nj >= ny as i32 ||
521                               nk < 0 || nk >= nz as i32 {
522                                all_inside = false;
523                                break 'outer;
524                            }
525
526                            if mask[idx(ni as usize, nj as usize, nk as usize)] == 0 {
527                                all_inside = false;
528                                break 'outer;
529                            }
530                        }
531                    }
532                }
533
534                if all_inside {
535                    eroded[idx(i, j, k)] = 1;
536                }
537            }
538        }
539    }
540
541    eroded
542}
543
544/// Dilate a binary mask using spherical structuring element
545fn dilate_mask(mask: &[u8], nx: usize, ny: usize, nz: usize, radius: i32) -> Vec<u8> {
546    let n_total = nx * ny * nz;
547    let mut dilated = vec![0u8; n_total];
548
549    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
550
551    for k in 0..nz {
552        for j in 0..ny {
553            for i in 0..nx {
554                if mask[idx(i, j, k)] != 0 {
555                    // Set all neighbors within radius
556                    for dz in -radius..=radius {
557                        for dy in -radius..=radius {
558                            for dx in -radius..=radius {
559                                let dist2 = dx * dx + dy * dy + dz * dz;
560                                if dist2 > radius * radius {
561                                    continue;
562                                }
563
564                                let ni = i as i32 + dx;
565                                let nj = j as i32 + dy;
566                                let nk = k as i32 + dz;
567
568                                if ni >= 0 && ni < nx as i32 &&
569                                   nj >= 0 && nj < ny as i32 &&
570                                   nk >= 0 && nk < nz as i32 {
571                                    dilated[idx(ni as usize, nj as usize, nk as usize)] = 1;
572                                }
573                            }
574                        }
575                    }
576                }
577            }
578        }
579    }
580
581    dilated
582}
583
584/// Morphological closing (dilation followed by erosion)
585pub fn morphological_close(mask: &[u8], grid: &Grid, radius: i32) -> Vec<u8> {
586    let (nx, ny, nz) = grid.dims;
587    let dilated = dilate_mask(mask, nx, ny, nz, radius);
588    erode_mask(&dilated, nx, ny, nz, radius)
589}
590
591/// 3D Gaussian smoothing with anisotropic sigma
592fn gaussian_smooth_3d_masked(
593    data: &[f64],
594    mask: &[u8],
595    nx: usize, ny: usize, nz: usize,
596    sigmas: &[f64; 3],
597) -> Vec<f64> {
598    // Apply separable 1D convolutions
599    let smoothed_x = convolve_1d_direction_masked(data, mask, nx, ny, nz, sigmas[0], 'x');
600    let smoothed_xy = convolve_1d_direction_masked(&smoothed_x, mask, nx, ny, nz, sigmas[1], 'y');
601    let smoothed_xyz = convolve_1d_direction_masked(&smoothed_xy, mask, nx, ny, nz, sigmas[2], 'z');
602
603    // Apply mask
604    smoothed_xyz.iter()
605        .enumerate()
606        .map(|(i, &v)| if mask[i] != 0 { v } else { 0.0 })
607        .collect()
608}
609
610/// 1D convolution with Gaussian kernel along specified axis
611/// Uses replicate padding to match MATLAB's imgaussfilt3 behavior
612fn convolve_1d_direction_masked(
613    data: &[f64],
614    _mask: &[u8],
615    nx: usize, ny: usize, nz: usize,
616    sigma: f64,
617    direction: char,
618) -> Vec<f64> {
619    if sigma <= 0.0 {
620        return data.to_vec();
621    }
622
623    let n_total = nx * ny * nz;
624    let mut result = vec![0.0f64; n_total];
625
626    // Create 1D Gaussian kernel
627    // Match MATLAB's imgaussfilt3 default: filterSize = 2*ceil(2*sigma)+1
628    let kernel_radius = (2.0 * sigma).ceil() as i32;
629    let kernel_size = 2 * kernel_radius + 1;
630    let mut kernel = vec![0.0f64; kernel_size as usize];
631
632    let mut sum = 0.0;
633    for i in 0..kernel_size {
634        let x = (i - kernel_radius) as f64;
635        kernel[i as usize] = (-x * x / (2.0 * sigma * sigma)).exp();
636        sum += kernel[i as usize];
637    }
638
639    // Normalize
640    for k in kernel.iter_mut() {
641        *k /= sum;
642    }
643
644    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
645
646    // Helper functions for replicate padding (clamp to valid range)
647    let clamp_x = |x: i32| -> usize { x.max(0).min(nx as i32 - 1) as usize };
648    let clamp_y = |y: i32| -> usize { y.max(0).min(ny as i32 - 1) as usize };
649    let clamp_z = |z: i32| -> usize { z.max(0).min(nz as i32 - 1) as usize };
650
651    match direction {
652        'x' => {
653            for k in 0..nz {
654                for j in 0..ny {
655                    for i in 0..nx {
656                        let mut conv_sum = 0.0;
657
658                        for ki in 0..kernel_size {
659                            let offset = ki - kernel_radius;
660                            let ni = clamp_x(i as i32 + offset);
661                            conv_sum += data[idx(ni, j, k)] * kernel[ki as usize];
662                        }
663
664                        result[idx(i, j, k)] = conv_sum;
665                    }
666                }
667            }
668        }
669        'y' => {
670            for k in 0..nz {
671                for j in 0..ny {
672                    for i in 0..nx {
673                        let mut conv_sum = 0.0;
674
675                        for ki in 0..kernel_size {
676                            let offset = ki - kernel_radius;
677                            let nj = clamp_y(j as i32 + offset);
678                            conv_sum += data[idx(i, nj, k)] * kernel[ki as usize];
679                        }
680
681                        result[idx(i, j, k)] = conv_sum;
682                    }
683                }
684            }
685        }
686        'z' => {
687            for k in 0..nz {
688                for j in 0..ny {
689                    for i in 0..nx {
690                        let mut conv_sum = 0.0;
691
692                        for ki in 0..kernel_size {
693                            let offset = ki - kernel_radius;
694                            let nk = clamp_z(k as i32 + offset);
695                            conv_sum += data[idx(i, j, nk)] * kernel[ki as usize];
696                        }
697
698                        result[idx(i, j, k)] = conv_sum;
699                    }
700                }
701            }
702        }
703        _ => panic!("Invalid convolution direction"),
704    }
705
706    result
707}
708
709/// Simple Gaussian curvature calculation for mask boundary
710/// Returns full volume with curvature values at surface voxels
711pub fn calculate_gaussian_curvature(
712    mask: &[u8],
713    grid: &Grid,
714) -> CurvatureResult {
715    let (nx, ny, nz) = grid.dims;
716    let n_total = nx * ny * nz;
717
718    // Extract surface voxels
719    let surface_indices = extract_surface_voxels(mask, nx, ny, nz);
720
721    if surface_indices.is_empty() {
722        return CurvatureResult {
723            gaussian_curvature: vec![0.0; n_total],
724            mean_curvature: vec![0.0; n_total],
725            surface_indices: Vec::new(),
726        };
727    }
728
729    // Convert surface indices to 3D points
730    let all_points: Vec<Point3D> = surface_indices
731        .iter()
732        .map(|&idx| {
733            let i = idx % nx;
734            let j = (idx / nx) % ny;
735            let k = idx / (nx * ny);
736            Point3D::new(i as f64, j as f64, k as f64)
737        })
738        .collect();
739
740    // Deduplicate (x,y) — same logic as calculate_curvature_proximity
741    let mut xy_to_rep: HashMap<(usize, usize), usize> = HashMap::new();
742    let mut is_representative = vec![false; all_points.len()];
743    for (idx, p) in all_points.iter().enumerate() {
744        let key = (p.x as usize, p.y as usize);
745        xy_to_rep.entry(key).or_insert_with(|| {
746            is_representative[idx] = true;
747            idx
748        });
749    }
750    let rep_indices: Vec<usize> = (0..all_points.len())
751        .filter(|&i| is_representative[i])
752        .collect();
753    let mut orig_to_rep = vec![0usize; all_points.len()];
754    for (new_idx, &old_idx) in rep_indices.iter().enumerate() {
755        orig_to_rep[old_idx] = new_idx;
756    }
757    let rep_points: Vec<Point3D> = rep_indices.iter().map(|&i| all_points[i].clone()).collect();
758
759    // Triangulate unique representatives via Qhull
760    let (triangles, boundary) = triangulate_surface(&rep_points);
761
762    // Compute curvatures on representatives
763    let (gc_points, mc_points, _amixed) = compute_curvatures_from_mesh(&rep_points, &triangles, &boundary);
764
765    // Create full volumes — only representative vertices get curvature values
766    let mut gaussian_curvature = vec![0.0f64; n_total];
767    let mut mean_curvature = vec![0.0f64; n_total];
768
769    for (orig_idx, &vol_idx) in surface_indices.iter().enumerate() {
770        if is_representative[orig_idx] {
771            let rep_idx = orig_to_rep[orig_idx];
772            gaussian_curvature[vol_idx] = gc_points[rep_idx];
773            mean_curvature[vol_idx] = mc_points[rep_idx];
774        }
775    }
776
777    CurvatureResult {
778        gaussian_curvature,
779        mean_curvature,
780        surface_indices,
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
789        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
790    }
791
792    #[test]
793    fn test_extract_surface_basic() {
794        // 3x3x3 cube with center filled
795        let mut mask = vec![0u8; 27];
796        mask[13] = 1; // Center voxel
797
798        let surface = extract_surface_voxels(&mask, 3, 3, 3);
799        assert_eq!(surface.len(), 1);
800        assert_eq!(surface[0], 13);
801    }
802
803    #[test]
804    fn test_erode_mask() {
805        // 5x5x5 solid cube
806        let mask = vec![1u8; 125];
807        let eroded = erode_mask(&mask, 5, 5, 5, 1);
808
809        // Center 3x3x3 should remain
810        let count: usize = eroded.iter().map(|&v| v as usize).sum();
811        assert!(count > 0);
812        assert!(count < 125);
813    }
814
815    #[test]
816    fn test_dilate_mask() {
817        // Single center voxel in 5x5x5
818        let mut mask = vec![0u8; 125];
819        mask[62] = 1; // Center
820
821        let dilated = dilate_mask(&mask, 5, 5, 5, 1);
822
823        // Should expand to 6-connectivity
824        let count: usize = dilated.iter().map(|&v| v as usize).sum();
825        assert!(count >= 7); // At least 7 voxels (center + 6 neighbors)
826    }
827
828    // =====================================================================
829    // Helper: create a 3D sphere mask
830    // =====================================================================
831
832    /// Create a solid sphere mask centered in an n x n x n volume.
833    fn make_sphere_mask(n: usize, radius: f64) -> Vec<u8> {
834        let center = n as f64 / 2.0;
835        let n_total = n * n * n;
836        let mut mask = vec![0u8; n_total];
837
838        for k in 0..n {
839            for j in 0..n {
840                for i in 0..n {
841                    let dx = i as f64 - center;
842                    let dy = j as f64 - center;
843                    let dz = k as f64 - center;
844                    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
845                    if dist < radius {
846                        mask[i + j * n + k * n * n] = 1;
847                    }
848                }
849            }
850        }
851
852        mask
853    }
854
855    // =====================================================================
856    // Tests for Point3D operations
857    // =====================================================================
858
859    #[test]
860    fn test_point3d_sub() {
861        let a = Point3D::new(3.0, 4.0, 5.0);
862        let b = Point3D::new(1.0, 1.0, 1.0);
863        let c = a.sub(&b);
864        assert!((c.x - 2.0).abs() < 1e-10);
865        assert!((c.y - 3.0).abs() < 1e-10);
866        assert!((c.z - 4.0).abs() < 1e-10);
867    }
868
869    #[test]
870    fn test_point3d_dot() {
871        let a = Point3D::new(1.0, 2.0, 3.0);
872        let b = Point3D::new(4.0, 5.0, 6.0);
873        let d = a.dot(&b);
874        assert!((d - 32.0).abs() < 1e-10); // 1*4 + 2*5 + 3*6 = 32
875    }
876
877    #[test]
878    fn test_point3d_cross() {
879        let a = Point3D::new(1.0, 0.0, 0.0);
880        let b = Point3D::new(0.0, 1.0, 0.0);
881        let c = a.cross(&b);
882        assert!((c.x - 0.0).abs() < 1e-10);
883        assert!((c.y - 0.0).abs() < 1e-10);
884        assert!((c.z - 1.0).abs() < 1e-10);
885    }
886
887    #[test]
888    fn test_point3d_norm() {
889        let p = Point3D::new(3.0, 4.0, 0.0);
890        assert!((p.norm() - 5.0).abs() < 1e-10);
891    }
892
893    #[test]
894    fn test_point3d_normalize() {
895        let p = Point3D::new(0.0, 0.0, 5.0);
896        let n = p.normalize();
897        assert!((n.x - 0.0).abs() < 1e-10);
898        assert!((n.y - 0.0).abs() < 1e-10);
899        assert!((n.z - 1.0).abs() < 1e-10);
900    }
901
902    #[test]
903    fn test_point3d_normalize_zero() {
904        let p = Point3D::new(0.0, 0.0, 0.0);
905        let n = p.normalize();
906        assert!((n.x).abs() < 1e-10);
907        assert!((n.y).abs() < 1e-10);
908        assert!((n.z).abs() < 1e-10);
909    }
910
911    #[test]
912    fn test_point3d_scale_and_add() {
913        let a = Point3D::new(1.0, 2.0, 3.0);
914        let b = a.scale(2.0);
915        assert!((b.x - 2.0).abs() < 1e-10);
916        assert!((b.y - 4.0).abs() < 1e-10);
917        assert!((b.z - 6.0).abs() < 1e-10);
918
919        let c = Point3D::new(0.5, 0.5, 0.5);
920        let d = b.add(&c);
921        assert!((d.x - 2.5).abs() < 1e-10);
922        assert!((d.y - 4.5).abs() < 1e-10);
923        assert!((d.z - 6.5).abs() < 1e-10);
924    }
925
926    // =====================================================================
927    // Tests for extract_surface_voxels
928    // =====================================================================
929
930    #[test]
931    fn test_extract_surface_sphere() {
932        let n = 10;
933        let mask = make_sphere_mask(n, 3.5);
934        let surface = extract_surface_voxels(&mask, n, n, n);
935
936        // Surface should be non-empty
937        assert!(!surface.is_empty(), "Sphere should have surface voxels");
938
939        // All surface indices should be within the mask
940        for &idx in &surface {
941            assert_eq!(mask[idx], 1, "Surface voxel should be in mask");
942        }
943
944        // Surface count should be less than total mask count
945        let mask_count: usize = mask.iter().map(|&v| v as usize).sum();
946        assert!(
947            surface.len() < mask_count,
948            "Surface ({}) should be smaller than total mask ({})",
949            surface.len(),
950            mask_count
951        );
952    }
953
954    #[test]
955    fn test_extract_surface_empty_mask() {
956        let mask = vec![0u8; 27];
957        let surface = extract_surface_voxels(&mask, 3, 3, 3);
958        assert!(surface.is_empty(), "Empty mask should have no surface voxels");
959    }
960
961    // =====================================================================
962    // Tests for erode_mask (more thorough)
963    // =====================================================================
964
965    #[test]
966    fn test_erode_mask_sphere() {
967        let n = 10;
968        let mask = make_sphere_mask(n, 4.0);
969        let eroded = erode_mask(&mask, n, n, n, 1);
970
971        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
972        let eroded_count: usize = eroded.iter().map(|&v| v as usize).sum();
973        assert!(
974            eroded_count < orig_count,
975            "Eroded sphere should be smaller: {} < {}",
976            eroded_count,
977            orig_count
978        );
979        assert!(eroded_count > 0, "Eroded sphere should not be empty");
980
981        // Center should still be in eroded mask
982        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
983        assert_eq!(eroded[center], 1, "Center should survive erosion");
984    }
985
986    #[test]
987    fn test_erode_mask_single_voxel() {
988        // A single voxel should be eroded away
989        let mut mask = vec![0u8; 125];
990        mask[62] = 1; // center of 5x5x5
991        let eroded = erode_mask(&mask, 5, 5, 5, 1);
992        let count: usize = eroded.iter().map(|&v| v as usize).sum();
993        assert_eq!(count, 0, "Single voxel should be fully eroded");
994    }
995
996    // =====================================================================
997    // Tests for dilate_mask (more thorough)
998    // =====================================================================
999
1000    #[test]
1001    fn test_dilate_mask_sphere() {
1002        let n = 10;
1003        let mask = make_sphere_mask(n, 3.0);
1004        let dilated = dilate_mask(&mask, n, n, n, 1);
1005
1006        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
1007        let dilated_count: usize = dilated.iter().map(|&v| v as usize).sum();
1008        assert!(
1009            dilated_count > orig_count,
1010            "Dilated sphere should be larger: {} > {}",
1011            dilated_count,
1012            orig_count
1013        );
1014    }
1015
1016    #[test]
1017    fn test_dilate_mask_radius_2() {
1018        let mut mask = vec![0u8; 125];
1019        mask[62] = 1; // center of 5x5x5
1020        let dilated = dilate_mask(&mask, 5, 5, 5, 2);
1021        let count: usize = dilated.iter().map(|&v| v as usize).sum();
1022        // Should be more than radius=1 dilation
1023        assert!(count > 7, "Radius-2 dilation should produce more than 7 voxels, got {}", count);
1024    }
1025
1026    // =====================================================================
1027    // Tests for morphological_close
1028    // =====================================================================
1029
1030    #[test]
1031    fn test_morphological_close_fills_small_gaps() {
1032        let n = 10;
1033        let mut mask = make_sphere_mask(n, 4.0);
1034        // Remove a surface voxel to create a small gap
1035        let surface = extract_surface_voxels(&mask, n, n, n);
1036        if !surface.is_empty() {
1037            mask[surface[0]] = 0;
1038        }
1039
1040        let closed = morphological_close(&mask, &grid(n, n, n), 1);
1041        let orig_count: usize = mask.iter().map(|&v| v as usize).sum();
1042        let closed_count: usize = closed.iter().map(|&v| v as usize).sum();
1043        // Closing should recover the gap or at least not shrink significantly
1044        assert!(
1045            closed_count >= orig_count,
1046            "Closing should not reduce mask size: {} vs {}",
1047            closed_count,
1048            orig_count
1049        );
1050    }
1051
1052    #[test]
1053    fn test_morphological_close_empty() {
1054        let mask = vec![0u8; 27];
1055        let closed = morphological_close(&mask, &grid(3, 3, 3), 1);
1056        let count: usize = closed.iter().map(|&v| v as usize).sum();
1057        assert_eq!(count, 0, "Closing empty mask should stay empty");
1058    }
1059
1060    // =====================================================================
1061    // Tests for create_surface_mask
1062    // =====================================================================
1063
1064    #[test]
1065    fn test_create_surface_mask_sphere() {
1066        let n = 10;
1067        let mask = make_sphere_mask(n, 4.0);
1068        let surface = create_surface_mask(&mask, n, n, n);
1069        let surface_count: usize = surface.iter().map(|&v| v as usize).sum();
1070        let mask_count: usize = mask.iter().map(|&v| v as usize).sum();
1071
1072        assert!(surface_count > 0, "Surface mask should be non-empty");
1073        assert!(
1074            surface_count < mask_count,
1075            "Surface ({}) should be smaller than mask ({})",
1076            surface_count,
1077            mask_count
1078        );
1079
1080        // Every surface voxel should be in the original mask
1081        for i in 0..surface.len() {
1082            if surface[i] > 0 {
1083                assert_eq!(mask[i], 1, "Surface voxel should be in original mask");
1084            }
1085        }
1086    }
1087
1088    // =====================================================================
1089    // Tests for triangulate_surface
1090    // =====================================================================
1091
1092    #[test]
1093    fn test_triangulate_surface_few_points() {
1094        // Less than 3 points should return empty triangulation
1095        let points = vec![Point3D::new(0.0, 0.0, 0.0), Point3D::new(1.0, 1.0, 1.0)];
1096        let (triangles, boundary) = triangulate_surface(&points);
1097        assert!(triangles.is_empty(), "Less than 3 points should give no triangles");
1098        assert_eq!(boundary.len(), 2);
1099    }
1100
1101    #[test]
1102    fn test_triangulate_surface_square_points() {
1103        // Four points forming a square in XY
1104        let points = vec![
1105            Point3D::new(0.0, 0.0, 0.0),
1106            Point3D::new(1.0, 0.0, 0.0),
1107            Point3D::new(0.0, 1.0, 0.0),
1108            Point3D::new(1.0, 1.0, 0.0),
1109        ];
1110        let (triangles, boundary) = triangulate_surface(&points);
1111        // Should produce 2 triangles from 4 points
1112        assert_eq!(triangles.len(), 2, "4 points should produce 2 triangles");
1113        // All 4 points are on the convex hull
1114        for &b in &boundary {
1115            assert!(b, "All 4 points should be on boundary");
1116        }
1117    }
1118
1119    // =====================================================================
1120    // Tests for compute_curvatures_from_mesh
1121    // =====================================================================
1122
1123    #[test]
1124    fn test_compute_curvatures_from_mesh_flat_surface() {
1125        // A flat grid of points (z=0) should have zero curvature
1126        let points = vec![
1127            Point3D::new(0.0, 0.0, 0.0),
1128            Point3D::new(1.0, 0.0, 0.0),
1129            Point3D::new(2.0, 0.0, 0.0),
1130            Point3D::new(0.0, 1.0, 0.0),
1131            Point3D::new(1.0, 1.0, 0.0),
1132            Point3D::new(2.0, 1.0, 0.0),
1133            Point3D::new(0.0, 2.0, 0.0),
1134            Point3D::new(1.0, 2.0, 0.0),
1135            Point3D::new(2.0, 2.0, 0.0),
1136        ];
1137
1138        // Create triangulation for the 3x3 grid
1139        let triangles = vec![
1140            Triangle { v0: 0, v1: 1, v2: 4 },
1141            Triangle { v0: 0, v1: 4, v2: 3 },
1142            Triangle { v0: 1, v1: 2, v2: 5 },
1143            Triangle { v0: 1, v1: 5, v2: 4 },
1144            Triangle { v0: 3, v1: 4, v2: 7 },
1145            Triangle { v0: 3, v1: 7, v2: 6 },
1146            Triangle { v0: 4, v1: 5, v2: 8 },
1147            Triangle { v0: 4, v1: 8, v2: 7 },
1148        ];
1149
1150        // All boundary except center vertex (index 4)
1151        let boundary = vec![true, true, true, true, false, true, true, true, true];
1152
1153        let (gc, mc, _amixed) = compute_curvatures_from_mesh(&points, &triangles, &boundary);
1154
1155        // Center vertex (not boundary) on flat surface should have ~zero curvature
1156        assert!(
1157            gc[4].abs() < 1e-6,
1158            "Flat surface should have ~0 Gaussian curvature, got {}",
1159            gc[4]
1160        );
1161        assert!(
1162            mc[4].abs() < 1e-6,
1163            "Flat surface should have ~0 mean curvature, got {}",
1164            mc[4]
1165        );
1166    }
1167
1168    #[test]
1169    fn test_compute_curvatures_from_mesh_degenerate_triangle() {
1170        // Degenerate triangle (collinear points) should not crash
1171        let points = vec![
1172            Point3D::new(0.0, 0.0, 0.0),
1173            Point3D::new(1.0, 0.0, 0.0),
1174            Point3D::new(2.0, 0.0, 0.0), // collinear
1175        ];
1176        let triangles = vec![Triangle { v0: 0, v1: 1, v2: 2 }];
1177        let boundary = vec![false, false, false];
1178        let (gc, mc, _amixed) = compute_curvatures_from_mesh(&points, &triangles, &boundary);
1179        // Should not crash; values may be zero because area is zero
1180        assert_eq!(gc.len(), 3);
1181        assert_eq!(mc.len(), 3);
1182    }
1183
1184    #[test]
1185    fn test_compute_curvatures_from_mesh_boundary_zero() {
1186        // Boundary vertices should have zero curvature
1187        let points = vec![
1188            Point3D::new(0.0, 0.0, 0.0),
1189            Point3D::new(1.0, 0.0, 0.0),
1190            Point3D::new(0.5, 1.0, 1.0),
1191        ];
1192        let triangles = vec![Triangle { v0: 0, v1: 1, v2: 2 }];
1193        let boundary = vec![true, true, true]; // all boundary
1194        let (gc, mc, _amixed) = compute_curvatures_from_mesh(&points, &triangles, &boundary);
1195        for i in 0..3 {
1196            assert!((gc[i]).abs() < 1e-10, "Boundary vertex GC should be 0");
1197            assert!((mc[i]).abs() < 1e-10, "Boundary vertex MC should be 0");
1198        }
1199    }
1200
1201    // =====================================================================
1202    // Tests for convolve_1d_direction_masked
1203    // =====================================================================
1204
1205    #[test]
1206    fn test_convolve_1d_direction_uniform() {
1207        let n = 8;
1208        let data = vec![5.0; n * n * n];
1209        let mask = vec![1u8; n * n * n];
1210
1211        let result_x = convolve_1d_direction_masked(&data, &mask, n, n, n, 1.0, 'x');
1212        let result_y = convolve_1d_direction_masked(&data, &mask, n, n, n, 1.0, 'y');
1213        let result_z = convolve_1d_direction_masked(&data, &mask, n, n, n, 1.0, 'z');
1214
1215        // Uniform data should stay uniform after convolution
1216        for &v in &result_x {
1217            assert!((v - 5.0).abs() < 0.1, "X convolution should preserve uniform data, got {}", v);
1218        }
1219        for &v in &result_y {
1220            assert!((v - 5.0).abs() < 0.1, "Y convolution should preserve uniform data, got {}", v);
1221        }
1222        for &v in &result_z {
1223            assert!((v - 5.0).abs() < 0.1, "Z convolution should preserve uniform data, got {}", v);
1224        }
1225    }
1226
1227    #[test]
1228    fn test_convolve_1d_direction_zero_sigma() {
1229        let n = 5;
1230        let data = vec![3.0; n * n * n];
1231        let mask = vec![1u8; n * n * n];
1232
1233        let result = convolve_1d_direction_masked(&data, &mask, n, n, n, 0.0, 'x');
1234        assert_eq!(result, data, "Zero sigma should return copy of input");
1235    }
1236
1237    // =====================================================================
1238    // Tests for gaussian_smooth_3d_masked
1239    // =====================================================================
1240
1241    #[test]
1242    fn test_gaussian_smooth_3d_masked_uniform() {
1243        let n = 8;
1244        let data = vec![10.0; n * n * n];
1245        let mask = vec![1u8; n * n * n];
1246        let sigmas = [1.0, 1.0, 1.0];
1247        let result = gaussian_smooth_3d_masked(&data, &mask, n, n, n, &sigmas);
1248        assert_eq!(result.len(), n * n * n);
1249        for &v in &result {
1250            assert!(v.is_finite(), "Result should be finite");
1251            assert!((v - 10.0).abs() < 1.0, "Uniform data should stay near 10.0, got {}", v);
1252        }
1253    }
1254
1255    #[test]
1256    fn test_gaussian_smooth_3d_masked_applies_mask() {
1257        let n = 8;
1258        let data = vec![10.0; n * n * n];
1259        let mut mask = vec![1u8; n * n * n];
1260        // Zero out half the mask
1261        for i in 0..(n * n * n / 2) {
1262            mask[i] = 0;
1263        }
1264        let sigmas = [1.0, 1.0, 1.0];
1265        let result = gaussian_smooth_3d_masked(&data, &mask, n, n, n, &sigmas);
1266        // Masked-out voxels should be 0
1267        for i in 0..result.len() {
1268            if mask[i] == 0 {
1269                assert!((result[i]).abs() < 1e-10, "Masked-out voxel should be 0, got {}", result[i]);
1270            }
1271        }
1272    }
1273
1274    // =====================================================================
1275    // Tests for calculate_gaussian_curvature (main public function)
1276    // =====================================================================
1277
1278    #[test]
1279    fn test_calculate_gaussian_curvature_sphere() {
1280        let n = 12;
1281        let mask = make_sphere_mask(n, 4.5);
1282        let result = calculate_gaussian_curvature(&mask, &grid(n, n, n));
1283
1284        assert_eq!(result.gaussian_curvature.len(), n * n * n);
1285        assert_eq!(result.mean_curvature.len(), n * n * n);
1286        assert!(!result.surface_indices.is_empty(), "Should have surface indices");
1287
1288        // Surface curvature values should be finite
1289        for &idx in &result.surface_indices {
1290            assert!(
1291                result.gaussian_curvature[idx].is_finite(),
1292                "GC at surface index {} should be finite",
1293                idx
1294            );
1295            assert!(
1296                result.mean_curvature[idx].is_finite(),
1297                "MC at surface index {} should be finite",
1298                idx
1299            );
1300        }
1301
1302        // Non-surface voxels should have zero curvature
1303        let surface_set: std::collections::HashSet<usize> =
1304            result.surface_indices.iter().cloned().collect();
1305        for i in 0..(n * n * n) {
1306            if !surface_set.contains(&i) {
1307                assert!(
1308                    (result.gaussian_curvature[i]).abs() < 1e-10,
1309                    "Non-surface GC should be 0"
1310                );
1311                assert!(
1312                    (result.mean_curvature[i]).abs() < 1e-10,
1313                    "Non-surface MC should be 0"
1314                );
1315            }
1316        }
1317    }
1318
1319    #[test]
1320    fn test_calculate_gaussian_curvature_empty_mask() {
1321        let n = 5;
1322        let mask = vec![0u8; n * n * n];
1323        let result = calculate_gaussian_curvature(&mask, &grid(n, n, n));
1324        assert!(result.surface_indices.is_empty());
1325        assert!(result.gaussian_curvature.iter().all(|&v| v == 0.0));
1326        assert!(result.mean_curvature.iter().all(|&v| v == 0.0));
1327    }
1328
1329    #[test]
1330    fn test_calculate_gaussian_curvature_single_voxel() {
1331        let mut mask = vec![0u8; 125];
1332        mask[62] = 1; // single voxel in center of 5x5x5
1333        let result = calculate_gaussian_curvature(&mask, &grid(5, 5, 5));
1334        // Single voxel is its own surface after erosion removes it
1335        // Result depends on whether erosion removes it entirely
1336        assert_eq!(result.gaussian_curvature.len(), 125);
1337        assert_eq!(result.mean_curvature.len(), 125);
1338    }
1339
1340    // =====================================================================
1341    // Tests for calculate_curvature_proximity (main entry point)
1342    // =====================================================================
1343
1344    #[test]
1345    fn test_calculate_curvature_proximity_sphere() {
1346        let n = 12;
1347        let mask = make_sphere_mask(n, 4.5);
1348        let n_total = n * n * n;
1349
1350        // Create an initial proximity map (all 1.0 inside mask)
1351        let prox1: Vec<f64> = mask.iter().map(|&v| v as f64).collect();
1352
1353        let (prox, curv_i) = calculate_curvature_proximity(
1354            &mask, &prox1, 0.6, 500.0, 1.0, &grid(n, n, n),
1355        );
1356
1357        assert_eq!(prox.len(), n_total);
1358        assert_eq!(curv_i.len(), n_total);
1359
1360        // All prox values should be finite
1361        for (i, &v) in prox.iter().enumerate() {
1362            assert!(v.is_finite(), "Prox at {} should be finite, got {}", i, v);
1363        }
1364
1365        // All curv_i values should be finite
1366        for (i, &v) in curv_i.iter().enumerate() {
1367            assert!(v.is_finite(), "Curv_i at {} should be finite, got {}", i, v);
1368        }
1369    }
1370
1371    #[test]
1372    fn test_calculate_curvature_proximity_empty_surface() {
1373        let n = 5;
1374        let mask = vec![0u8; n * n * n];
1375        let n_total = n * n * n;
1376        let prox1 = vec![1.0; n_total];
1377
1378        let (prox, curv_i) = calculate_curvature_proximity(
1379            &mask, &prox1, 0.6, 500.0, 1.0, &grid(n, n, n),
1380        );
1381
1382        // With empty mask, should return prox1 and all-ones curv_i
1383        assert_eq!(prox.len(), n_total);
1384        assert_eq!(curv_i.len(), n_total);
1385        for &v in &curv_i {
1386            assert!((v - 1.0).abs() < 1e-10, "Empty surface should give curv_i=1.0");
1387        }
1388    }
1389
1390    #[test]
1391    fn test_calculate_curvature_proximity_respects_mask() {
1392        let n = 12;
1393        let mask = make_sphere_mask(n, 4.5);
1394        let n_total = n * n * n;
1395        let prox1: Vec<f64> = mask.iter().map(|&v| v as f64).collect();
1396
1397        let (prox, _curv_i) = calculate_curvature_proximity(
1398            &mask, &prox1, 0.6, 500.0, 1.0, &grid(n, n, n),
1399        );
1400
1401        // Outside mask, proximity should be 0 (due to prox1 being 0 there)
1402        // or small from smoothing bleed
1403        for i in 0..n_total {
1404            assert!(prox[i].is_finite(), "Prox should be finite everywhere");
1405        }
1406    }
1407
1408    #[test]
1409    fn test_calculate_curvature_proximity_varying_params() {
1410        let n = 12;
1411        let mask = make_sphere_mask(n, 4.5);
1412        let prox1: Vec<f64> = mask.iter().map(|&v| v as f64).collect();
1413
1414        // Different lower_lim and curv_constant
1415        let (prox_a, _) = calculate_curvature_proximity(
1416            &mask, &prox1, 0.3, 100.0, 0.5, &grid(n, n, n),
1417        );
1418        let (prox_b, _) = calculate_curvature_proximity(
1419            &mask, &prox1, 0.9, 1000.0, 2.0, &grid(n, n, n),
1420        );
1421
1422        // Both should produce finite results
1423        for &v in &prox_a {
1424            assert!(v.is_finite());
1425        }
1426        for &v in &prox_b {
1427            assert!(v.is_finite());
1428        }
1429    }
1430}