1use std::f64::consts::PI;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Vec3 {
21 pub x: f64,
23 pub y: f64,
25 pub z: f64,
27}
28
29impl Vec3 {
30 pub fn new(x: f64, y: f64, z: f64) -> Self {
32 Self { x, y, z }
33 }
34
35 pub fn zero() -> Self {
37 Self::new(0.0, 0.0, 0.0)
38 }
39
40 pub fn dot(&self, other: &Self) -> f64 {
42 self.x * other.x + self.y * other.y + self.z * other.z
43 }
44
45 pub fn norm(&self) -> f64 {
47 self.dot(self).sqrt()
48 }
49
50 pub fn normalize(&self) -> Self {
52 let n = self.norm();
53 if n < 1e-15 {
54 Self::zero()
55 } else {
56 Self::new(self.x / n, self.y / n, self.z / n)
57 }
58 }
59
60 pub fn cross(&self, other: &Self) -> Self {
62 Self::new(
63 self.y * other.z - self.z * other.y,
64 self.z * other.x - self.x * other.z,
65 self.x * other.y - self.y * other.x,
66 )
67 }
68
69 pub fn add(&self, other: &Self) -> Self {
71 Self::new(self.x + other.x, self.y + other.y, self.z + other.z)
72 }
73
74 pub fn sub(&self, other: &Self) -> Self {
76 Self::new(self.x - other.x, self.y - other.y, self.z - other.z)
77 }
78
79 pub fn scale(&self, s: f64) -> Self {
81 Self::new(self.x * s, self.y * s, self.z * s)
82 }
83
84 pub fn distance_to(&self, other: &Self) -> f64 {
86 self.sub(other).norm()
87 }
88}
89
90#[derive(Debug, Clone)]
98pub struct DicomSliceOrientation {
99 pub row_cosines: Vec3,
101 pub col_cosines: Vec3,
103}
104
105impl DicomSliceOrientation {
106 pub fn new(row: [f64; 3], col: [f64; 3]) -> Self {
108 Self {
109 row_cosines: Vec3::new(row[0], row[1], row[2]).normalize(),
110 col_cosines: Vec3::new(col[0], col[1], col[2]).normalize(),
111 }
112 }
113
114 pub fn normal(&self) -> Vec3 {
116 self.row_cosines.cross(&self.col_cosines).normalize()
117 }
118
119 pub fn is_orthogonal(&self, tol: f64) -> bool {
122 self.row_cosines.dot(&self.col_cosines).abs() < tol
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct DicomImagePlane {
129 pub image_position: Vec3,
131 pub pixel_spacing: [f64; 2],
133 pub orientation: DicomSliceOrientation,
135 pub cols: usize,
137 pub rows: usize,
139}
140
141impl DicomImagePlane {
142 pub fn new(
144 image_position: [f64; 3],
145 pixel_spacing: [f64; 2],
146 row_cosines: [f64; 3],
147 col_cosines: [f64; 3],
148 rows: usize,
149 cols: usize,
150 ) -> Self {
151 Self {
152 image_position: Vec3::new(image_position[0], image_position[1], image_position[2]),
153 pixel_spacing,
154 orientation: DicomSliceOrientation::new(row_cosines, col_cosines),
155 cols,
156 rows,
157 }
158 }
159
160 pub fn pixel_to_patient(&self, i: f64, j: f64) -> Vec3 {
163 let dr = self
164 .orientation
165 .row_cosines
166 .scale(i * self.pixel_spacing[0]);
167 let dc = self
168 .orientation
169 .col_cosines
170 .scale(j * self.pixel_spacing[1]);
171 self.image_position.add(&dr).add(&dc)
172 }
173
174 pub fn slice_position(&self, slice_index: usize, slice_spacing: f64) -> Vec3 {
177 let normal = self.orientation.normal();
178 self.image_position
179 .add(&normal.scale(slice_index as f64 * slice_spacing))
180 }
181}
182
183#[derive(Debug, Clone)]
191pub struct ScalarVolume {
192 pub data: Vec<f64>,
194 pub nx: usize,
196 pub ny: usize,
198 pub nz: usize,
200 pub voxel_size: [f64; 3],
202 pub origin: Vec3,
204}
205
206impl ScalarVolume {
207 pub fn new(nx: usize, ny: usize, nz: usize, voxel_size: [f64; 3], origin: Vec3) -> Self {
209 Self {
210 data: vec![0.0; nx * ny * nz],
211 nx,
212 ny,
213 nz,
214 voxel_size,
215 origin,
216 }
217 }
218
219 #[inline]
221 pub fn index(&self, ix: usize, iy: usize, iz: usize) -> usize {
222 iz * self.ny * self.nx + iy * self.nx + ix
223 }
224
225 pub fn get(&self, ix: usize, iy: usize, iz: usize) -> Option<f64> {
228 if ix < self.nx && iy < self.ny && iz < self.nz {
229 Some(self.data[self.index(ix, iy, iz)])
230 } else {
231 None
232 }
233 }
234
235 pub fn set(&mut self, ix: usize, iy: usize, iz: usize, val: f64) {
237 if ix < self.nx && iy < self.ny && iz < self.nz {
238 let idx = self.index(ix, iy, iz);
239 self.data[idx] = val;
240 }
241 }
242
243 pub fn world_to_voxel(&self, world: Vec3) -> Option<[f64; 3]> {
246 let fx = (world.x - self.origin.x) / self.voxel_size[0];
247 let fy = (world.y - self.origin.y) / self.voxel_size[1];
248 let fz = (world.z - self.origin.z) / self.voxel_size[2];
249 if fx < 0.0
250 || fy < 0.0
251 || fz < 0.0
252 || fx > (self.nx - 1) as f64
253 || fy > (self.ny - 1) as f64
254 || fz > (self.nz - 1) as f64
255 {
256 None
257 } else {
258 Some([fx, fy, fz])
259 }
260 }
261
262 pub fn trilinear_interp(&self, world: Vec3) -> Option<f64> {
266 let [fx, fy, fz] = self.world_to_voxel(world)?;
267 let x0 = fx.floor() as usize;
268 let y0 = fy.floor() as usize;
269 let z0 = fz.floor() as usize;
270 let x1 = (x0 + 1).min(self.nx - 1);
271 let y1 = (y0 + 1).min(self.ny - 1);
272 let z1 = (z0 + 1).min(self.nz - 1);
273 let xd = fx - x0 as f64;
274 let yd = fy - y0 as f64;
275 let zd = fz - z0 as f64;
276
277 macro_rules! v {
278 ($xi:expr, $yi:expr, $zi:expr) => {
279 self.data[self.index($xi, $yi, $zi)]
280 };
281 }
282
283 let c00 = v!(x0, y0, z0) * (1.0 - xd) + v!(x1, y0, z0) * xd;
284 let c01 = v!(x0, y0, z1) * (1.0 - xd) + v!(x1, y0, z1) * xd;
285 let c10 = v!(x0, y1, z0) * (1.0 - xd) + v!(x1, y1, z0) * xd;
286 let c11 = v!(x0, y1, z1) * (1.0 - xd) + v!(x1, y1, z1) * xd;
287 let c0 = c00 * (1.0 - yd) + c10 * yd;
288 let c1 = c01 * (1.0 - yd) + c11 * yd;
289 Some(c0 * (1.0 - zd) + c1 * zd)
290 }
291}
292
293#[derive(Debug, Clone, Copy)]
299pub struct HuThresholds {
300 pub cortical_bone_min: f64,
302 pub cancellous_bone_min: f64,
304 pub soft_tissue_max: f64,
306 pub air_min: f64,
308}
309
310impl HuThresholds {
311 pub fn default_ct() -> Self {
313 Self {
314 cortical_bone_min: 700.0,
315 cancellous_bone_min: 200.0,
316 soft_tissue_max: 200.0,
317 air_min: -900.0,
318 }
319 }
320
321 pub fn classify(&self, hu: f64) -> TissueClass {
323 if hu >= self.cortical_bone_min {
324 TissueClass::CorticalBone
325 } else if hu >= self.cancellous_bone_min {
326 TissueClass::CancellousBone
327 } else if hu <= self.air_min {
328 TissueClass::Air
329 } else if hu < 0.0 {
330 TissueClass::Fat
331 } else {
332 TissueClass::SoftTissue
333 }
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum TissueClass {
340 CorticalBone,
342 CancellousBone,
344 SoftTissue,
346 Fat,
348 Air,
350}
351
352pub fn threshold_segment(volume: &ScalarVolume, threshold: f64) -> Vec<bool> {
357 volume.data.iter().map(|&v| v >= threshold).collect()
358}
359
360pub fn bone_volume_mm3(volume: &ScalarVolume, threshold: f64) -> f64 {
362 let vv = volume.voxel_size[0] * volume.voxel_size[1] * volume.voxel_size[2];
363 volume.data.iter().filter(|&&v| v >= threshold).count() as f64 * vv
364}
365
366#[derive(Debug, Clone, Copy)]
372pub struct CenterlinePoint {
373 pub position: Vec3,
375 pub radius: f64,
377}
378
379#[derive(Debug, Clone)]
381pub struct VesselCenterline {
382 pub points: Vec<CenterlinePoint>,
384}
385
386impl VesselCenterline {
387 pub fn new(pts: Vec<(Vec3, f64)>) -> Self {
389 Self {
390 points: pts
391 .into_iter()
392 .map(|(p, r)| CenterlinePoint {
393 position: p,
394 radius: r,
395 })
396 .collect(),
397 }
398 }
399
400 pub fn arc_length(&self) -> f64 {
402 self.points
403 .windows(2)
404 .map(|w| w[0].position.distance_to(&w[1].position))
405 .sum()
406 }
407
408 pub fn min_radius(&self) -> f64 {
410 self.points
411 .iter()
412 .map(|p| p.radius)
413 .fold(f64::INFINITY, f64::min)
414 }
415
416 pub fn max_radius(&self) -> f64 {
418 self.points
419 .iter()
420 .map(|p| p.radius)
421 .fold(f64::NEG_INFINITY, f64::max)
422 }
423
424 pub fn tortuosity(&self) -> f64 {
427 if self.points.len() < 2 {
428 return 1.0;
429 }
430 let chord = self
431 .points
432 .first()
433 .expect("operation should succeed")
434 .position
435 .distance_to(
436 &self
437 .points
438 .last()
439 .expect("collection should not be empty")
440 .position,
441 );
442 if chord < 1e-9 {
443 return 1.0;
444 }
445 self.arc_length() / chord
446 }
447}
448
449pub fn extract_centerline_approx(
454 mask: &[bool],
455 nx: usize,
456 ny: usize,
457 nz: usize,
458 voxel_size: [f64; 3],
459 origin: Vec3,
460) -> VesselCenterline {
461 let mut centres: Vec<Vec3> = Vec::new();
463 for iz in 0..nz {
464 for iy in 0..ny {
465 for ix in 0..nx {
466 let idx = iz * ny * nx + iy * nx + ix;
467 if mask[idx] {
468 centres.push(Vec3::new(
469 origin.x + ix as f64 * voxel_size[0],
470 origin.y + iy as f64 * voxel_size[1],
471 origin.z + iz as f64 * voxel_size[2],
472 ));
473 }
474 }
475 }
476 }
477 if centres.is_empty() {
478 return VesselCenterline { points: Vec::new() };
479 }
480 centres.sort_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal));
482
483 let mut cl_pts: Vec<CenterlinePoint> = Vec::new();
485 let mut i = 0;
486 while i < centres.len() {
487 let z_ref = centres[i].z;
488 let mut sum = Vec3::zero();
489 let mut count = 0usize;
490 let start = i;
491 while i < centres.len() && (centres[i].z - z_ref).abs() < voxel_size[2] * 0.5 {
492 sum = sum.add(¢res[i]);
493 count += 1;
494 i += 1;
495 }
496 let centroid = sum.scale(1.0 / count as f64);
497 let r = if count > 1 {
499 centres[start..i]
500 .iter()
501 .map(|p| centroid.distance_to(p))
502 .sum::<f64>()
503 / count as f64
504 } else {
505 voxel_size[0]
506 };
507 cl_pts.push(CenterlinePoint {
508 position: centroid,
509 radius: r,
510 });
511 }
512 VesselCenterline { points: cl_pts }
513}
514
515#[derive(Debug, Clone, Copy)]
521pub struct Aabb {
522 pub min: Vec3,
524 pub max: Vec3,
526}
527
528impl Aabb {
529 pub fn new(min: Vec3, max: Vec3) -> Self {
531 Self { min, max }
532 }
533
534 pub fn from_points(pts: &[Vec3]) -> Option<Self> {
536 if pts.is_empty() {
537 return None;
538 }
539 let mut mn = pts[0];
540 let mut mx = pts[0];
541 for p in pts.iter().skip(1) {
542 mn.x = mn.x.min(p.x);
543 mn.y = mn.y.min(p.y);
544 mn.z = mn.z.min(p.z);
545 mx.x = mx.x.max(p.x);
546 mx.y = mx.y.max(p.y);
547 mx.z = mx.z.max(p.z);
548 }
549 Some(Self { min: mn, max: mx })
550 }
551
552 pub fn centre(&self) -> Vec3 {
554 Vec3::new(
555 (self.min.x + self.max.x) * 0.5,
556 (self.min.y + self.max.y) * 0.5,
557 (self.min.z + self.max.z) * 0.5,
558 )
559 }
560
561 pub fn volume(&self) -> f64 {
563 let d = self.max.sub(&self.min);
564 d.x.max(0.0) * d.y.max(0.0) * d.z.max(0.0)
565 }
566
567 pub fn diagonal(&self) -> f64 {
569 self.min.distance_to(&self.max)
570 }
571
572 pub fn contains(&self, p: Vec3) -> bool {
574 p.x >= self.min.x
575 && p.x <= self.max.x
576 && p.y >= self.min.y
577 && p.y <= self.max.y
578 && p.z >= self.min.z
579 && p.z <= self.max.z
580 }
581
582 pub fn expand(&self, margin: f64) -> Self {
584 Self {
585 min: self.min.sub(&Vec3::new(margin, margin, margin)),
586 max: self.max.add(&Vec3::new(margin, margin, margin)),
587 }
588 }
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum CoordSystem {
598 Ras,
600 Lps,
602}
603
604pub fn lps_to_ras(p: Vec3) -> Vec3 {
607 Vec3::new(-p.x, -p.y, p.z)
608}
609
610pub fn ras_to_lps(p: Vec3) -> Vec3 {
612 Vec3::new(-p.x, -p.y, p.z)
613}
614
615#[derive(Debug, Clone, Copy)]
620pub struct AffineMatrix4 {
621 pub m: [[f64; 4]; 4],
623}
624
625impl AffineMatrix4 {
626 pub fn identity() -> Self {
628 let mut m = [[0.0f64; 4]; 4];
629 m[0][0] = 1.0;
630 m[1][1] = 1.0;
631 m[2][2] = 1.0;
632 m[3][3] = 1.0;
633 Self { m }
634 }
635
636 pub fn transform_point(&self, p: Vec3) -> Vec3 {
638 let m = &self.m;
639 Vec3::new(
640 m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z + m[0][3],
641 m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z + m[1][3],
642 m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z + m[2][3],
643 )
644 }
645
646 pub fn from_dicom(
649 pixel_spacing: [f64; 2],
650 slice_spacing: f64,
651 image_position: Vec3,
652 row_cosines: Vec3,
653 col_cosines: Vec3,
654 ) -> Self {
655 let normal = row_cosines.cross(&col_cosines).normalize();
656 let mut m = [[0.0f64; 4]; 4];
657 m[0][0] = row_cosines.x * pixel_spacing[0];
659 m[1][0] = row_cosines.y * pixel_spacing[0];
660 m[2][0] = row_cosines.z * pixel_spacing[0];
661 m[0][1] = col_cosines.x * pixel_spacing[1];
663 m[1][1] = col_cosines.y * pixel_spacing[1];
664 m[2][1] = col_cosines.z * pixel_spacing[1];
665 m[0][2] = normal.x * slice_spacing;
667 m[1][2] = normal.y * slice_spacing;
668 m[2][2] = normal.z * slice_spacing;
669 m[0][3] = image_position.x;
671 m[1][3] = image_position.y;
672 m[2][3] = image_position.z;
673 m[3][3] = 1.0;
674 Self { m }
675 }
676}
677
678#[derive(Debug, Clone, Copy)]
685pub struct CuttingPlane {
686 pub point: Vec3,
688 pub normal: Vec3,
690}
691
692impl CuttingPlane {
693 pub fn new(point: Vec3, normal: Vec3) -> Self {
696 Self {
697 point,
698 normal: normal.normalize(),
699 }
700 }
701
702 pub fn signed_distance(&self, p: Vec3) -> f64 {
704 self.normal.dot(&p.sub(&self.point))
705 }
706
707 pub fn is_resected(&self, p: Vec3) -> bool {
709 self.signed_distance(p) >= 0.0
710 }
711
712 pub fn intersect_segment(&self, a: Vec3, b: Vec3) -> Option<Vec3> {
715 let da = self.signed_distance(a);
716 let db = self.signed_distance(b);
717 if (da - db).abs() < 1e-12 {
718 return None; }
720 let t = da / (da - db);
721 Some(a.add(&b.sub(&a).scale(t)))
722 }
723}
724
725pub fn resection_volume_approx(
730 vertices: &[Vec3],
731 plane: &CuttingPlane,
732 total_volume_mm3: f64,
733) -> f64 {
734 if vertices.is_empty() {
735 return 0.0;
736 }
737 let resected = vertices.iter().filter(|&&v| plane.is_resected(v)).count();
738 total_volume_mm3 * resected as f64 / vertices.len() as f64
739}
740
741#[derive(Debug, Clone)]
743pub struct ResectionPlan {
744 pub planes: Vec<CuttingPlane>,
746 pub label: String,
748}
749
750impl ResectionPlan {
751 pub fn new(label: &str) -> Self {
753 Self {
754 planes: Vec::new(),
755 label: label.to_string(),
756 }
757 }
758
759 pub fn add_plane(&mut self, plane: CuttingPlane) {
761 self.planes.push(plane);
762 }
763
764 pub fn is_fully_resected(&self, p: Vec3) -> bool {
767 self.planes.iter().all(|pl| pl.is_resected(p))
768 }
769}
770
771#[derive(Debug, Clone)]
777pub struct IsoSurface {
778 pub vertices: Vec<Vec3>,
780 pub triangles: Vec<[usize; 3]>,
782}
783
784impl IsoSurface {
785 pub fn triangle_count(&self) -> usize {
787 self.triangles.len()
788 }
789
790 pub fn vertex_count(&self) -> usize {
792 self.vertices.len()
793 }
794
795 pub fn surface_area(&self) -> f64 {
797 self.triangles
798 .iter()
799 .map(|tri| {
800 let a = self.vertices[tri[0]];
801 let b = self.vertices[tri[1]];
802 let c = self.vertices[tri[2]];
803 let ab = b.sub(&a);
804 let ac = c.sub(&a);
805 ab.cross(&ac).norm() * 0.5
806 })
807 .sum()
808 }
809}
810
811const EDGE_TABLE: [u16; 256] = [
816 0x000, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a,
817 0xd03, 0xe09, 0xf00, 0x190, 0x099, 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, 0x99c, 0x895,
818 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, 0x230, 0x339, 0x033, 0x13a, 0x636, 0x73f, 0x435,
819 0x53c, 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, 0x3a0, 0x2a9, 0x1a3, 0x0aa,
820 0x7a6, 0x6af, 0x5a5, 0x4ac, 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, 0x460,
821 0x569, 0x663, 0x76a, 0x066, 0x16f, 0x265, 0x36c, 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963,
822 0xa69, 0xb60, 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0x0ff, 0x3f5, 0x2fc, 0xdfc, 0xcf5, 0xfff,
823 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x055, 0x15c,
824 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6,
825 0x2cf, 0x1c5, 0x0cc, 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, 0x8c0, 0x9c9,
826 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, 0x0cc, 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9,
827 0x7c0, 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, 0x15c, 0x055, 0x35f, 0x256,
828 0x55a, 0x453, 0x759, 0x650, 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, 0x2fc,
829 0x3f5, 0x0ff, 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f,
830 0xd65, 0xc6c, 0x36c, 0x265, 0x16f, 0x066, 0x76a, 0x663, 0x569, 0x460, 0xca0, 0xda9, 0xea3,
831 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, 0x4ac, 0x5a5, 0x6af, 0x7a6, 0x0aa, 0x1a3, 0x2a9, 0x3a0,
832 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x835, 0xb3f, 0xa36, 0x53c, 0x435, 0x73f, 0x636, 0x13a,
833 0x033, 0x339, 0x230, 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, 0x69c, 0x795,
834 0x49f, 0x596, 0x29a, 0x393, 0x099, 0x190, 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905,
835 0x80c, 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x000,
836];
837
838pub fn marching_cubes(volume: &ScalarVolume, iso_value: f64) -> IsoSurface {
845 let nx = volume.nx;
846 let ny = volume.ny;
847 let nz = volume.nz;
848 let [dx, dy, dz] = volume.voxel_size;
849
850 let mut vertices: Vec<Vec3> = Vec::new();
851 let mut triangles: Vec<[usize; 3]> = Vec::new();
852
853 let interp = |p0: Vec3, v0: f64, p1: Vec3, v1: f64| -> Vec3 {
855 if (v1 - v0).abs() < 1e-10 {
856 return p0.add(&p1).scale(0.5);
857 }
858 let t = (iso_value - v0) / (v1 - v0);
859 p0.add(&p1.sub(&p0).scale(t))
860 };
861
862 for iz in 0..nz.saturating_sub(1) {
863 for iy in 0..ny.saturating_sub(1) {
864 for ix in 0..nx.saturating_sub(1) {
865 let corners_idx = [
867 (ix, iy, iz),
868 (ix + 1, iy, iz),
869 (ix + 1, iy + 1, iz),
870 (ix, iy + 1, iz),
871 (ix, iy, iz + 1),
872 (ix + 1, iy, iz + 1),
873 (ix + 1, iy + 1, iz + 1),
874 (ix, iy + 1, iz + 1),
875 ];
876 let corners_pos: Vec<Vec3> = corners_idx
877 .iter()
878 .map(|&(cx, cy, cz)| {
879 Vec3::new(
880 volume.origin.x + cx as f64 * dx,
881 volume.origin.y + cy as f64 * dy,
882 volume.origin.z + cz as f64 * dz,
883 )
884 })
885 .collect();
886 let vals: Vec<f64> = corners_idx
887 .iter()
888 .map(|&(cx, cy, cz)| volume.get(cx, cy, cz).unwrap_or(0.0))
889 .collect();
890
891 let mut cube_idx: usize = 0;
893 for (i, &v) in vals.iter().enumerate() {
894 if v >= iso_value {
895 cube_idx |= 1 << i;
896 }
897 }
898
899 if cube_idx == 0 || cube_idx == 255 {
900 continue;
901 }
902
903 let _edge_bits = EDGE_TABLE[cube_idx];
904
905 let edge_verts: Vec<Vec3> = [
907 (0, 1),
908 (1, 2),
909 (2, 3),
910 (3, 0),
911 (4, 5),
912 (5, 6),
913 (6, 7),
914 (7, 4),
915 (0, 4),
916 (1, 5),
917 (2, 6),
918 (3, 7),
919 ]
920 .iter()
921 .map(|&(a, b)| interp(corners_pos[a], vals[a], corners_pos[b], vals[b]))
922 .collect();
923
924 let active: Vec<usize> = (0..12)
928 .filter(|&e| (EDGE_TABLE[cube_idx] >> e) & 1 == 1)
929 .collect();
930 let base = vertices.len();
931 for &e in &active {
932 vertices.push(edge_verts[e]);
933 }
934 let n = active.len();
935 for i in 1..n.saturating_sub(1) {
936 triangles.push([base, base + i, base + i + 1]);
937 }
938 }
939 }
940 }
941
942 IsoSurface {
943 vertices,
944 triangles,
945 }
946}
947
948pub fn angle_at_vertex(a: Vec3, b: Vec3, c: Vec3) -> f64 {
955 let ba = a.sub(&b).normalize();
956 let bc = c.sub(&b).normalize();
957 ba.dot(&bc).clamp(-1.0, 1.0).acos()
958}
959
960pub fn landmark_distance(a: Vec3, b: Vec3) -> f64 {
962 a.distance_to(&b)
963}
964
965pub fn mesh_volume_mm3(mesh: &IsoSurface) -> f64 {
969 let mut vol = 0.0f64;
970 for tri in &mesh.triangles {
971 let a = mesh.vertices[tri[0]];
972 let b = mesh.vertices[tri[1]];
973 let c = mesh.vertices[tri[2]];
974 vol += a.dot(&b.cross(&c));
976 }
977 (vol / 6.0).abs()
978}
979
980pub fn point_cloud_centroid(pts: &[Vec3]) -> Option<Vec3> {
982 if pts.is_empty() {
983 return None;
984 }
985 let mut s = Vec3::zero();
986 for p in pts {
987 s = s.add(p);
988 }
989 Some(s.scale(1.0 / pts.len() as f64))
990}
991
992pub fn hausdorff_distance(a: &[Vec3], b: &[Vec3]) -> f64 {
994 if a.is_empty() || b.is_empty() {
995 return 0.0;
996 }
997 a.iter()
998 .map(|pa| {
999 b.iter()
1000 .map(|pb| pa.distance_to(pb))
1001 .fold(f64::INFINITY, f64::min)
1002 })
1003 .fold(f64::NEG_INFINITY, f64::max)
1004}
1005
1006#[derive(Debug, Clone, Copy)]
1012pub struct LaplacianSmoothParams {
1013 pub lambda: f64,
1016 pub iterations: usize,
1018}
1019
1020impl LaplacianSmoothParams {
1021 pub fn medical_default() -> Self {
1023 Self {
1024 lambda: 0.3,
1025 iterations: 10,
1026 }
1027 }
1028}
1029
1030pub fn smooth_medical_mesh(mesh: &IsoSurface, params: LaplacianSmoothParams) -> IsoSurface {
1035 let nv = mesh.vertices.len();
1036 if nv == 0 {
1037 return mesh.clone();
1038 }
1039
1040 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); nv];
1042 for tri in &mesh.triangles {
1043 let [a, b, c] = *tri;
1044 adj[a].push(b);
1045 adj[a].push(c);
1046 adj[b].push(a);
1047 adj[b].push(c);
1048 adj[c].push(a);
1049 adj[c].push(b);
1050 }
1051
1052 let mut verts = mesh.vertices.clone();
1053 for _ in 0..params.iterations {
1054 let prev = verts.clone();
1055 for i in 0..nv {
1056 if adj[i].is_empty() {
1057 continue;
1058 }
1059 let mut s = Vec3::zero();
1060 for &j in &adj[i] {
1061 s = s.add(&prev[j]);
1062 }
1063 let centroid = s.scale(1.0 / adj[i].len() as f64);
1064 let delta = centroid.sub(&prev[i]).scale(params.lambda);
1065 verts[i] = prev[i].add(&delta);
1066 }
1067 }
1068
1069 IsoSurface {
1070 vertices: verts,
1071 triangles: mesh.triangles.clone(),
1072 }
1073}
1074
1075pub fn taubin_smooth_medical_mesh(
1078 mesh: &IsoSurface,
1079 lambda: f64,
1080 mu: f64,
1081 iterations: usize,
1082) -> IsoSurface {
1083 let forward = LaplacianSmoothParams {
1084 lambda,
1085 iterations: 1,
1086 };
1087 let backward = LaplacianSmoothParams {
1088 lambda: mu,
1089 iterations: 1,
1090 };
1091 let mut current = mesh.clone();
1092 for _ in 0..iterations {
1093 current = smooth_medical_mesh(¤t, forward);
1094 current = smooth_medical_mesh(¤t, backward);
1095 }
1096 current
1097}
1098
1099#[derive(Debug, Clone)]
1105pub struct SliceStack {
1106 pub slice_positions: Vec<f64>,
1109 pub pixel_spacing: [f64; 2],
1111 pub matrix_size: [usize; 2],
1113}
1114
1115impl SliceStack {
1116 pub fn new(
1118 slice_positions: Vec<f64>,
1119 pixel_spacing: [f64; 2],
1120 matrix_size: [usize; 2],
1121 ) -> Self {
1122 Self {
1123 slice_positions,
1124 pixel_spacing,
1125 matrix_size,
1126 }
1127 }
1128
1129 pub fn slice_count(&self) -> usize {
1131 self.slice_positions.len()
1132 }
1133
1134 pub fn mean_slice_spacing(&self) -> Option<f64> {
1137 let n = self.slice_positions.len();
1138 if n < 2 {
1139 return None;
1140 }
1141 let total: f64 = self
1142 .slice_positions
1143 .windows(2)
1144 .map(|w| (w[1] - w[0]).abs())
1145 .sum();
1146 Some(total / (n - 1) as f64)
1147 }
1148
1149 pub fn slab_thickness(&self) -> f64 {
1151 if self.slice_positions.is_empty() {
1152 return 0.0;
1153 }
1154 let min = self
1155 .slice_positions
1156 .iter()
1157 .cloned()
1158 .fold(f64::INFINITY, f64::min);
1159 let max = self
1160 .slice_positions
1161 .iter()
1162 .cloned()
1163 .fold(f64::NEG_INFINITY, f64::max);
1164 max - min
1165 }
1166
1167 pub fn field_of_view(&self) -> [f64; 2] {
1169 [
1170 self.pixel_spacing[0] * self.matrix_size[0] as f64,
1171 self.pixel_spacing[1] * self.matrix_size[1] as f64,
1172 ]
1173 }
1174
1175 pub fn voxel_volume_mm3(&self) -> Option<f64> {
1178 let ss = self.mean_slice_spacing()?;
1179 Some(self.pixel_spacing[0] * self.pixel_spacing[1] * ss)
1180 }
1181}
1182
1183#[derive(Debug, Clone, Copy)]
1189pub struct SphereFit {
1190 pub centre: Vec3,
1192 pub radius: f64,
1194 pub rms_error: f64,
1196}
1197
1198pub fn fit_sphere_to_points(pts: &[Vec3]) -> Option<SphereFit> {
1207 let n = pts.len();
1208 if n < 4 {
1209 return None;
1210 }
1211 let cx: f64 = pts.iter().map(|p| p.x).sum::<f64>() / n as f64;
1212 let cy: f64 = pts.iter().map(|p| p.y).sum::<f64>() / n as f64;
1213 let cz: f64 = pts.iter().map(|p| p.z).sum::<f64>() / n as f64;
1214 let centre = Vec3::new(cx, cy, cz);
1215 let r_vals: Vec<f64> = pts.iter().map(|p| centre.distance_to(p)).collect();
1216 let radius = r_vals.iter().sum::<f64>() / n as f64;
1217 let rms = (r_vals
1218 .iter()
1219 .map(|&r| (r - radius) * (r - radius))
1220 .sum::<f64>()
1221 / n as f64)
1222 .sqrt();
1223 Some(SphereFit {
1224 centre,
1225 radius,
1226 rms_error: rms,
1227 })
1228}
1229
1230#[derive(Debug, Clone)]
1236pub struct Landmark {
1237 pub label: String,
1239 pub position: Vec3,
1241}
1242
1243impl Landmark {
1244 pub fn new(label: &str, position: Vec3) -> Self {
1246 Self {
1247 label: label.to_string(),
1248 position,
1249 }
1250 }
1251}
1252
1253pub fn pelvic_tilt_deg(asis_l: Vec3, asis_r: Vec3, pubic_symphysis: Vec3) -> f64 {
1259 let mid_asis = asis_l.add(&asis_r).scale(0.5);
1260 let v1 = asis_r.sub(&asis_l);
1261 let v2 = pubic_symphysis.sub(&mid_asis);
1262 let normal = v1.cross(&v2).normalize();
1263 let vertical = Vec3::new(0.0, 1.0, 0.0);
1264 let cos_angle = normal.dot(&vertical).clamp(-1.0, 1.0);
1265 cos_angle.acos() * 180.0 / PI
1266}
1267
1268pub fn femoral_neck_shaft_angle_deg(head_centre: Vec3, neck_centre: Vec3, shaft_axis: Vec3) -> f64 {
1271 let neck_axis = head_centre.sub(&neck_centre).normalize();
1272 let shaft = shaft_axis.normalize();
1273 let cos_a = neck_axis.dot(&shaft).clamp(-1.0, 1.0);
1274 cos_a.acos() * 180.0 / PI
1275}
1276
1277#[cfg(test)]
1282mod tests {
1283 use super::*;
1284
1285 #[test]
1288 fn test_vec3_dot() {
1289 let a = Vec3::new(1.0, 0.0, 0.0);
1290 let b = Vec3::new(0.0, 1.0, 0.0);
1291 assert!((a.dot(&b)).abs() < 1e-12);
1292 }
1293
1294 #[test]
1295 fn test_vec3_cross() {
1296 let a = Vec3::new(1.0, 0.0, 0.0);
1297 let b = Vec3::new(0.0, 1.0, 0.0);
1298 let c = a.cross(&b);
1299 assert!((c.z - 1.0).abs() < 1e-12);
1300 assert!(c.x.abs() < 1e-12);
1301 assert!(c.y.abs() < 1e-12);
1302 }
1303
1304 #[test]
1305 fn test_vec3_normalize() {
1306 let v = Vec3::new(3.0, 4.0, 0.0);
1307 let n = v.normalize();
1308 assert!((n.norm() - 1.0).abs() < 1e-12);
1309 }
1310
1311 #[test]
1312 fn test_vec3_distance() {
1313 let a = Vec3::new(0.0, 0.0, 0.0);
1314 let b = Vec3::new(3.0, 4.0, 0.0);
1315 assert!((a.distance_to(&b) - 5.0).abs() < 1e-12);
1316 }
1317
1318 #[test]
1321 fn test_dicom_orientation_normal_orthogonal_to_row_col() {
1322 let orient = DicomSliceOrientation::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1323 let n = orient.normal();
1324 assert!(n.dot(&orient.row_cosines).abs() < 1e-10);
1325 assert!(n.dot(&orient.col_cosines).abs() < 1e-10);
1326 }
1327
1328 #[test]
1329 fn test_dicom_orientation_is_orthogonal_axial() {
1330 let orient = DicomSliceOrientation::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1331 assert!(orient.is_orthogonal(1e-9));
1332 }
1333
1334 #[test]
1337 fn test_pixel_to_patient_origin() {
1338 let plane = DicomImagePlane::new(
1339 [10.0, 20.0, 30.0],
1340 [0.5, 0.5],
1341 [1.0, 0.0, 0.0],
1342 [0.0, 1.0, 0.0],
1343 512,
1344 512,
1345 );
1346 let p = plane.pixel_to_patient(0.0, 0.0);
1347 assert!((p.x - 10.0).abs() < 1e-9);
1348 assert!((p.y - 20.0).abs() < 1e-9);
1349 assert!((p.z - 30.0).abs() < 1e-9);
1350 }
1351
1352 #[test]
1353 fn test_pixel_to_patient_offset() {
1354 let plane = DicomImagePlane::new(
1355 [0.0, 0.0, 0.0],
1356 [1.0, 1.0],
1357 [1.0, 0.0, 0.0],
1358 [0.0, 1.0, 0.0],
1359 512,
1360 512,
1361 );
1362 let p = plane.pixel_to_patient(3.0, 4.0);
1363 assert!((p.x - 3.0).abs() < 1e-9);
1364 assert!((p.y - 4.0).abs() < 1e-9);
1365 }
1366
1367 #[test]
1370 fn test_scalar_volume_set_get() {
1371 let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1372 vol.set(1, 2, 3, 42.0);
1373 assert_eq!(vol.get(1, 2, 3), Some(42.0));
1374 }
1375
1376 #[test]
1377 fn test_scalar_volume_out_of_bounds() {
1378 let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1379 assert_eq!(vol.get(4, 0, 0), None);
1380 }
1381
1382 #[test]
1383 fn test_trilinear_interp_at_voxel_centre() {
1384 let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1385 vol.set(1, 1, 1, 100.0);
1386 let world = Vec3::new(1.0, 1.0, 1.0);
1388 let v = vol.trilinear_interp(world).unwrap();
1389 assert!((v - 100.0).abs() < 1e-6, "got {v}");
1390 }
1391
1392 #[test]
1393 fn test_trilinear_interp_outside_returns_none() {
1394 let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1395 assert!(vol.trilinear_interp(Vec3::new(10.0, 0.0, 0.0)).is_none());
1396 }
1397
1398 #[test]
1401 fn test_hu_classify_cortical_bone() {
1402 let th = HuThresholds::default_ct();
1403 assert_eq!(th.classify(1000.0), TissueClass::CorticalBone);
1404 }
1405
1406 #[test]
1407 fn test_hu_classify_cancellous() {
1408 let th = HuThresholds::default_ct();
1409 assert_eq!(th.classify(300.0), TissueClass::CancellousBone);
1410 }
1411
1412 #[test]
1413 fn test_hu_classify_soft_tissue() {
1414 let th = HuThresholds::default_ct();
1415 assert_eq!(th.classify(50.0), TissueClass::SoftTissue);
1416 }
1417
1418 #[test]
1419 fn test_hu_classify_air() {
1420 let th = HuThresholds::default_ct();
1421 assert_eq!(th.classify(-1000.0), TissueClass::Air);
1422 }
1423
1424 #[test]
1427 fn test_threshold_segment_count() {
1428 let mut vol = ScalarVolume::new(2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1429 vol.set(0, 0, 0, 800.0);
1430 vol.set(1, 1, 1, 900.0);
1431 let mask = threshold_segment(&vol, 700.0);
1432 assert_eq!(mask.iter().filter(|&&b| b).count(), 2);
1433 }
1434
1435 #[test]
1436 fn test_bone_volume_mm3() {
1437 let mut vol = ScalarVolume::new(2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1438 vol.set(0, 0, 0, 800.0);
1439 let bv = bone_volume_mm3(&vol, 700.0);
1440 assert!((bv - 1.0).abs() < 1e-9); }
1442
1443 #[test]
1446 fn test_aabb_from_points() {
1447 let pts = vec![
1448 Vec3::new(1.0, 2.0, 3.0),
1449 Vec3::new(-1.0, 5.0, 0.0),
1450 Vec3::new(4.0, 0.0, 2.0),
1451 ];
1452 let bb = Aabb::from_points(&pts).unwrap();
1453 assert!((bb.min.x - (-1.0)).abs() < 1e-9);
1454 assert!((bb.max.y - 5.0).abs() < 1e-9);
1455 }
1456
1457 #[test]
1458 fn test_aabb_contains() {
1459 let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0));
1460 assert!(bb.contains(Vec3::new(5.0, 5.0, 5.0)));
1461 assert!(!bb.contains(Vec3::new(11.0, 5.0, 5.0)));
1462 }
1463
1464 #[test]
1465 fn test_aabb_volume() {
1466 let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 3.0, 4.0));
1467 assert!((bb.volume() - 24.0).abs() < 1e-9);
1468 }
1469
1470 #[test]
1471 fn test_aabb_expand() {
1472 let bb = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1473 let expanded = bb.expand(1.0);
1474 assert!((expanded.min.x - (-1.0)).abs() < 1e-9);
1475 assert!((expanded.max.x - 2.0).abs() < 1e-9);
1476 }
1477
1478 #[test]
1481 fn test_lps_to_ras_round_trip() {
1482 let p = Vec3::new(1.0, 2.0, 3.0);
1483 let ras = lps_to_ras(p);
1484 let back = ras_to_lps(ras);
1485 assert!((back.x - p.x).abs() < 1e-12);
1486 assert!((back.y - p.y).abs() < 1e-12);
1487 assert!((back.z - p.z).abs() < 1e-12);
1488 }
1489
1490 #[test]
1491 fn test_affine_identity_transform() {
1492 let m = AffineMatrix4::identity();
1493 let p = Vec3::new(1.0, 2.0, 3.0);
1494 let tp = m.transform_point(p);
1495 assert!((tp.x - 1.0).abs() < 1e-12);
1496 assert!((tp.y - 2.0).abs() < 1e-12);
1497 assert!((tp.z - 3.0).abs() < 1e-12);
1498 }
1499
1500 #[test]
1501 fn test_affine_from_dicom_pixel_zero() {
1502 let m = AffineMatrix4::from_dicom(
1503 [0.5, 0.5],
1504 1.0,
1505 Vec3::new(10.0, 20.0, 0.0),
1506 Vec3::new(1.0, 0.0, 0.0),
1507 Vec3::new(0.0, 1.0, 0.0),
1508 );
1509 let origin_voxel = m.transform_point(Vec3::zero());
1510 assert!((origin_voxel.x - 10.0).abs() < 1e-9);
1511 assert!((origin_voxel.y - 20.0).abs() < 1e-9);
1512 }
1513
1514 #[test]
1517 fn test_cutting_plane_signed_distance() {
1518 let plane = CuttingPlane::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
1519 assert!((plane.signed_distance(Vec3::new(0.0, 0.0, 3.0)) - 3.0).abs() < 1e-9);
1520 assert!((plane.signed_distance(Vec3::new(0.0, 0.0, -2.0)) - (-2.0)).abs() < 1e-9);
1521 }
1522
1523 #[test]
1524 fn test_cutting_plane_intersection() {
1525 let plane = CuttingPlane::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
1526 let a = Vec3::new(0.0, 0.0, -1.0);
1527 let b = Vec3::new(0.0, 0.0, 1.0);
1528 let pt = plane.intersect_segment(a, b).unwrap();
1529 assert!(pt.z.abs() < 1e-9);
1530 }
1531
1532 #[test]
1533 fn test_resection_plan_all_resected() {
1534 let mut plan = ResectionPlan::new("test");
1535 plan.add_plane(CuttingPlane::new(Vec3::zero(), Vec3::new(1.0, 0.0, 0.0)));
1536 assert!(plan.is_fully_resected(Vec3::new(5.0, 0.0, 0.0)));
1537 assert!(!plan.is_fully_resected(Vec3::new(-1.0, 0.0, 0.0)));
1538 }
1539
1540 #[test]
1543 fn test_angle_at_vertex_right_angle() {
1544 let a = Vec3::new(1.0, 0.0, 0.0);
1545 let b = Vec3::zero();
1546 let c = Vec3::new(0.0, 1.0, 0.0);
1547 let angle = angle_at_vertex(a, b, c);
1548 assert!((angle - PI / 2.0).abs() < 1e-10);
1549 }
1550
1551 #[test]
1552 fn test_angle_at_vertex_straight_line() {
1553 let a = Vec3::new(-1.0, 0.0, 0.0);
1554 let b = Vec3::zero();
1555 let c = Vec3::new(1.0, 0.0, 0.0);
1556 let angle = angle_at_vertex(a, b, c);
1557 assert!((angle - PI).abs() < 1e-10);
1558 }
1559
1560 #[test]
1561 fn test_landmark_distance() {
1562 let a = Vec3::new(0.0, 0.0, 0.0);
1563 let b = Vec3::new(0.0, 0.0, 5.0);
1564 assert!((landmark_distance(a, b) - 5.0).abs() < 1e-12);
1565 }
1566
1567 #[test]
1568 fn test_point_cloud_centroid() {
1569 let pts = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 2.0, 2.0)];
1570 let c = point_cloud_centroid(&pts).unwrap();
1571 assert!((c.x - 1.0).abs() < 1e-12);
1572 assert!((c.y - 1.0).abs() < 1e-12);
1573 }
1574
1575 #[test]
1576 fn test_hausdorff_distance_same_sets() {
1577 let pts = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
1578 assert!(hausdorff_distance(&pts, &pts) < 1e-12);
1579 }
1580
1581 #[test]
1582 fn test_hausdorff_distance_offset() {
1583 let a = vec![Vec3::new(0.0, 0.0, 0.0)];
1584 let b = vec![Vec3::new(3.0, 4.0, 0.0)];
1585 assert!((hausdorff_distance(&a, &b) - 5.0).abs() < 1e-12);
1586 }
1587
1588 #[test]
1591 fn test_slice_stack_mean_spacing() {
1592 let positions: Vec<f64> = (0..10).map(|i| i as f64 * 2.5).collect();
1593 let stack = SliceStack::new(positions, [0.5, 0.5], [512, 512]);
1594 let ms = stack.mean_slice_spacing().unwrap();
1595 assert!((ms - 2.5).abs() < 1e-9);
1596 }
1597
1598 #[test]
1599 fn test_slice_stack_fov() {
1600 let stack = SliceStack::new(vec![0.0, 1.0], [0.5, 0.5], [512, 512]);
1601 let fov = stack.field_of_view();
1602 assert!((fov[0] - 256.0).abs() < 1e-9);
1603 }
1604
1605 #[test]
1606 fn test_slice_stack_slab_thickness() {
1607 let positions: Vec<f64> = (0..5).map(|i| i as f64).collect();
1608 let stack = SliceStack::new(positions, [1.0, 1.0], [256, 256]);
1609 assert!((stack.slab_thickness() - 4.0).abs() < 1e-9);
1610 }
1611
1612 #[test]
1615 fn test_vessel_centerline_arc_length() {
1616 let cl = VesselCenterline::new(vec![
1617 (Vec3::new(0.0, 0.0, 0.0), 2.0),
1618 (Vec3::new(0.0, 0.0, 5.0), 2.0),
1619 (Vec3::new(0.0, 0.0, 10.0), 2.0),
1620 ]);
1621 assert!((cl.arc_length() - 10.0).abs() < 1e-9);
1622 }
1623
1624 #[test]
1625 fn test_vessel_centerline_tortuosity_straight() {
1626 let cl = VesselCenterline::new(vec![
1627 (Vec3::new(0.0, 0.0, 0.0), 1.0),
1628 (Vec3::new(0.0, 0.0, 5.0), 1.0),
1629 ]);
1630 assert!((cl.tortuosity() - 1.0).abs() < 1e-9);
1631 }
1632
1633 #[test]
1634 fn test_vessel_centerline_tortuosity_curved() {
1635 let cl = VesselCenterline::new(vec![
1636 (Vec3::new(0.0, 0.0, 0.0), 1.0),
1637 (Vec3::new(3.0, 4.0, 0.0), 1.0),
1638 (Vec3::new(6.0, 0.0, 0.0), 1.0),
1639 ]);
1640 let t = cl.tortuosity();
1642 assert!(t > 1.0);
1643 }
1644
1645 #[test]
1648 fn test_sphere_fit_unit_sphere() {
1649 let pts: Vec<Vec3> = (0..20)
1650 .map(|i| {
1651 let theta = i as f64 * PI / 10.0;
1652 Vec3::new(theta.cos(), theta.sin(), 0.0)
1653 })
1654 .collect();
1655 let fit = fit_sphere_to_points(&pts).unwrap();
1656 assert!((fit.radius - 1.0).abs() < 1e-6);
1657 }
1658
1659 #[test]
1660 fn test_sphere_fit_needs_four_points() {
1661 let pts = vec![
1662 Vec3::new(1.0, 0.0, 0.0),
1663 Vec3::new(-1.0, 0.0, 0.0),
1664 Vec3::new(0.0, 1.0, 0.0),
1665 ];
1666 assert!(fit_sphere_to_points(&pts).is_none());
1667 }
1668
1669 #[test]
1672 fn test_marching_cubes_empty_volume() {
1673 let vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1674 let mesh = marching_cubes(&vol, 500.0);
1675 assert_eq!(mesh.triangle_count(), 0);
1676 }
1677
1678 #[test]
1679 fn test_marching_cubes_full_volume() {
1680 let mut vol = ScalarVolume::new(4, 4, 4, [1.0, 1.0, 1.0], Vec3::zero());
1681 for v in vol.data.iter_mut() {
1682 *v = 1000.0;
1683 }
1684 let mesh = marching_cubes(&vol, 500.0);
1685 assert_eq!(mesh.triangle_count(), 0);
1687 }
1688
1689 #[test]
1690 fn test_smooth_medical_mesh_does_not_crash() {
1691 let mesh = IsoSurface {
1692 vertices: vec![
1693 Vec3::new(0.0, 0.0, 0.0),
1694 Vec3::new(1.0, 0.0, 0.0),
1695 Vec3::new(0.0, 1.0, 0.0),
1696 ],
1697 triangles: vec![[0, 1, 2]],
1698 };
1699 let smoothed = smooth_medical_mesh(&mesh, LaplacianSmoothParams::medical_default());
1700 assert_eq!(smoothed.vertex_count(), 3);
1701 }
1702
1703 #[test]
1704 fn test_taubin_smooth_preserves_vertex_count() {
1705 let mesh = IsoSurface {
1706 vertices: vec![
1707 Vec3::new(0.0, 0.0, 0.0),
1708 Vec3::new(1.0, 0.0, 0.0),
1709 Vec3::new(0.5, 1.0, 0.0),
1710 Vec3::new(0.5, 0.5, 1.0),
1711 ],
1712 triangles: vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]],
1713 };
1714 let out = taubin_smooth_medical_mesh(&mesh, 0.33, -0.34, 5);
1715 assert_eq!(out.vertex_count(), 4);
1716 }
1717
1718 #[test]
1721 fn test_pelvic_tilt_symmetric_pelvis() {
1722 let asis_l = Vec3::new(-80.0, 0.0, 0.0);
1723 let asis_r = Vec3::new(80.0, 0.0, 0.0);
1724 let ps = Vec3::new(0.0, -100.0, -40.0);
1725 let tilt = pelvic_tilt_deg(asis_l, asis_r, ps);
1726 assert!(tilt.is_finite() && tilt > 0.0);
1728 }
1729
1730 #[test]
1731 fn test_femoral_neck_shaft_angle() {
1732 let head = Vec3::new(0.0, 35.0, 0.0); let neck = Vec3::new(0.0, 0.0, 0.0);
1735 let shaft = Vec3::new(0.0, -1.0, 0.0); let angle = femoral_neck_shaft_angle_deg(head, neck, shaft);
1737 assert!((angle - 180.0).abs() < 1e-9);
1738 }
1739
1740 #[test]
1741 fn test_extract_centerline_empty_mask() {
1742 let mask = vec![false; 8];
1743 let cl = extract_centerline_approx(&mask, 2, 2, 2, [1.0, 1.0, 1.0], Vec3::zero());
1744 assert_eq!(cl.points.len(), 0);
1745 }
1746
1747 #[test]
1748 fn test_mesh_volume_tetrahedron() {
1749 let mesh = IsoSurface {
1751 vertices: vec![
1752 Vec3::new(0.0, 0.0, 0.0),
1753 Vec3::new(1.0, 0.0, 0.0),
1754 Vec3::new(0.0, 1.0, 0.0),
1755 Vec3::new(0.0, 0.0, 1.0),
1756 ],
1757 triangles: vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]],
1758 };
1759 let vol = mesh_volume_mm3(&mesh);
1760 assert!((vol - 1.0 / 6.0).abs() < 0.01, "got {vol}");
1762 }
1763}