1#![allow(dead_code)]
13#![allow(clippy::too_many_arguments)]
14
15use std::f64::consts::PI;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct Vec3 {
24 pub x: f64,
26 pub y: f64,
28 pub z: f64,
30}
31
32impl Vec3 {
33 pub fn new(x: f64, y: f64, z: f64) -> Self {
35 Self { x, y, z }
36 }
37
38 pub fn zero() -> Self {
40 Self::new(0.0, 0.0, 0.0)
41 }
42
43 pub fn dot(&self, other: &Self) -> f64 {
45 self.x * other.x + self.y * other.y + self.z * other.z
46 }
47
48 pub fn norm(&self) -> f64 {
50 self.dot(self).sqrt()
51 }
52
53 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 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 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 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 pub fn scale(&self, s: f64) -> Self {
84 Self::new(self.x * s, self.y * s, self.z * s)
85 }
86
87 pub fn distance_to(&self, other: &Self) -> f64 {
89 self.sub(other).norm()
90 }
91}
92
93#[derive(Debug, Clone)]
101pub struct DicomSliceOrientation {
102 pub row_cosines: Vec3,
104 pub col_cosines: Vec3,
106}
107
108impl DicomSliceOrientation {
109 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 pub fn normal(&self) -> Vec3 {
119 self.row_cosines.cross(&self.col_cosines).normalize()
120 }
121
122 pub fn is_orthogonal(&self, tol: f64) -> bool {
125 self.row_cosines.dot(&self.col_cosines).abs() < tol
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct DicomImagePlane {
132 pub image_position: Vec3,
134 pub pixel_spacing: [f64; 2],
136 pub orientation: DicomSliceOrientation,
138 pub cols: usize,
140 pub rows: usize,
142}
143
144impl DicomImagePlane {
145 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 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 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#[derive(Debug, Clone)]
194pub struct ScalarVolume {
195 pub data: Vec<f64>,
197 pub nx: usize,
199 pub ny: usize,
201 pub nz: usize,
203 pub voxel_size: [f64; 3],
205 pub origin: Vec3,
207}
208
209impl ScalarVolume {
210 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 #[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 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 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 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 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#[derive(Debug, Clone, Copy)]
302pub struct HuThresholds {
303 pub cortical_bone_min: f64,
305 pub cancellous_bone_min: f64,
307 pub soft_tissue_max: f64,
309 pub air_min: f64,
311}
312
313impl HuThresholds {
314 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum TissueClass {
343 CorticalBone,
345 CancellousBone,
347 SoftTissue,
349 Fat,
351 Air,
353}
354
355pub fn threshold_segment(volume: &ScalarVolume, threshold: f64) -> Vec<bool> {
360 volume.data.iter().map(|&v| v >= threshold).collect()
361}
362
363pub 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#[derive(Debug, Clone, Copy)]
375pub struct CenterlinePoint {
376 pub position: Vec3,
378 pub radius: f64,
380}
381
382#[derive(Debug, Clone)]
384pub struct VesselCenterline {
385 pub points: Vec<CenterlinePoint>,
387}
388
389impl VesselCenterline {
390 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 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 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 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 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
452pub 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 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 centres.sort_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal));
485
486 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(¢res[i]);
496 count += 1;
497 i += 1;
498 }
499 let centroid = sum.scale(1.0 / count as f64);
500 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#[derive(Debug, Clone, Copy)]
524pub struct Aabb {
525 pub min: Vec3,
527 pub max: Vec3,
529}
530
531impl Aabb {
532 pub fn new(min: Vec3, max: Vec3) -> Self {
534 Self { min, max }
535 }
536
537 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 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 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 pub fn diagonal(&self) -> f64 {
572 self.min.distance_to(&self.max)
573 }
574
575 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum CoordSystem {
601 Ras,
603 Lps,
605}
606
607pub fn lps_to_ras(p: Vec3) -> Vec3 {
610 Vec3::new(-p.x, -p.y, p.z)
611}
612
613pub fn ras_to_lps(p: Vec3) -> Vec3 {
615 Vec3::new(-p.x, -p.y, p.z)
616}
617
618#[derive(Debug, Clone, Copy)]
623pub struct AffineMatrix4 {
624 pub m: [[f64; 4]; 4],
626}
627
628impl AffineMatrix4 {
629 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 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 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 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 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 m[0][2] = normal.x * slice_spacing;
670 m[1][2] = normal.y * slice_spacing;
671 m[2][2] = normal.z * slice_spacing;
672 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#[derive(Debug, Clone, Copy)]
688pub struct CuttingPlane {
689 pub point: Vec3,
691 pub normal: Vec3,
693}
694
695impl CuttingPlane {
696 pub fn new(point: Vec3, normal: Vec3) -> Self {
699 Self {
700 point,
701 normal: normal.normalize(),
702 }
703 }
704
705 pub fn signed_distance(&self, p: Vec3) -> f64 {
707 self.normal.dot(&p.sub(&self.point))
708 }
709
710 pub fn is_resected(&self, p: Vec3) -> bool {
712 self.signed_distance(p) >= 0.0
713 }
714
715 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; }
723 let t = da / (da - db);
724 Some(a.add(&b.sub(&a).scale(t)))
725 }
726}
727
728pub 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#[derive(Debug, Clone)]
746pub struct ResectionPlan {
747 pub planes: Vec<CuttingPlane>,
749 pub label: String,
751}
752
753impl ResectionPlan {
754 pub fn new(label: &str) -> Self {
756 Self {
757 planes: Vec::new(),
758 label: label.to_string(),
759 }
760 }
761
762 pub fn add_plane(&mut self, plane: CuttingPlane) {
764 self.planes.push(plane);
765 }
766
767 pub fn is_fully_resected(&self, p: Vec3) -> bool {
770 self.planes.iter().all(|pl| pl.is_resected(p))
771 }
772}
773
774#[derive(Debug, Clone)]
780pub struct IsoSurface {
781 pub vertices: Vec<Vec3>,
783 pub triangles: Vec<[usize; 3]>,
785}
786
787impl IsoSurface {
788 pub fn triangle_count(&self) -> usize {
790 self.triangles.len()
791 }
792
793 pub fn vertex_count(&self) -> usize {
795 self.vertices.len()
796 }
797
798 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
814const 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
841pub 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 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 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 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 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 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
951pub 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
963pub fn landmark_distance(a: Vec3, b: Vec3) -> f64 {
965 a.distance_to(&b)
966}
967
968pub 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 vol += a.dot(&b.cross(&c));
979 }
980 (vol / 6.0).abs()
981}
982
983pub 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
995pub 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#[derive(Debug, Clone, Copy)]
1015pub struct LaplacianSmoothParams {
1016 pub lambda: f64,
1019 pub iterations: usize,
1021}
1022
1023impl LaplacianSmoothParams {
1024 pub fn medical_default() -> Self {
1026 Self {
1027 lambda: 0.3,
1028 iterations: 10,
1029 }
1030 }
1031}
1032
1033pub 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 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
1078pub 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(¤t, forward);
1097 current = smooth_medical_mesh(¤t, backward);
1098 }
1099 current
1100}
1101
1102#[derive(Debug, Clone)]
1108pub struct SliceStack {
1109 pub slice_positions: Vec<f64>,
1112 pub pixel_spacing: [f64; 2],
1114 pub matrix_size: [usize; 2],
1116}
1117
1118impl SliceStack {
1119 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 pub fn slice_count(&self) -> usize {
1134 self.slice_positions.len()
1135 }
1136
1137 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 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 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 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#[derive(Debug, Clone, Copy)]
1192pub struct SphereFit {
1193 pub centre: Vec3,
1195 pub radius: f64,
1197 pub rms_error: f64,
1199}
1200
1201pub 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#[derive(Debug, Clone)]
1239pub struct Landmark {
1240 pub label: String,
1242 pub position: Vec3,
1244}
1245
1246impl Landmark {
1247 pub fn new(label: &str, position: Vec3) -> Self {
1249 Self {
1250 label: label.to_string(),
1251 position,
1252 }
1253 }
1254}
1255
1256pub 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
1271pub 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#[cfg(test)]
1285mod tests {
1286 use super::*;
1287
1288 #[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 #[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 #[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 #[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 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 #[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 #[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); }
1445
1446 #[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 #[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 #[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 #[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 #[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 #[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 let t = cl.tortuosity();
1645 assert!(t > 1.0);
1646 }
1647
1648 #[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 #[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 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 #[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 assert!(tilt.is_finite() && tilt > 0.0);
1731 }
1732
1733 #[test]
1734 fn test_femoral_neck_shaft_angle() {
1735 let head = Vec3::new(0.0, 35.0, 0.0); let neck = Vec3::new(0.0, 0.0, 0.0);
1738 let shaft = Vec3::new(0.0, -1.0, 0.0); 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 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 assert!((vol - 1.0 / 6.0).abs() < 0.01, "got {vol}");
1765 }
1766}