1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SkeletonMethod {
13 BallRolling,
15 Thinning,
17 Voronoi,
19 MedialSurface,
21}
22
23#[derive(Debug, Clone)]
25pub struct MedialAxisPoint {
26 pub position: [f64; 3],
28 pub radius: f64,
30 pub tangent: [f64; 3],
32 pub feature_size: f64,
34}
35
36impl MedialAxisPoint {
37 pub fn new(position: [f64; 3], radius: f64, tangent: [f64; 3], feature_size: f64) -> Self {
39 Self {
40 position,
41 radius,
42 tangent,
43 feature_size,
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct MedialAxis {
51 pub points: Vec<MedialAxisPoint>,
53 pub branches: Vec<Vec<usize>>,
55 pub junctions: Vec<usize>,
57}
58
59impl MedialAxis {
60 pub fn from_points(boundary: &[[f64; 3]], method: SkeletonMethod) -> Self {
65 let _ = method; if boundary.is_empty() {
67 return Self {
68 points: vec![],
69 branches: vec![],
70 junctions: vec![],
71 };
72 }
73
74 let n = boundary.len() as f64;
76 let cx = boundary.iter().map(|p| p[0]).sum::<f64>() / n;
77 let cy = boundary.iter().map(|p| p[1]).sum::<f64>() / n;
78 let cz = boundary.iter().map(|p| p[2]).sum::<f64>() / n;
79 let center = [cx, cy, cz];
80
81 let avg_r = boundary.iter().map(|p| dist3(*p, center)).sum::<f64>() / n;
83
84 let mut max_dist = 0.0_f64;
86 let mut far_a = boundary[0];
87 let mut far_b = boundary[0];
88 for i in 0..boundary.len() {
89 for j in (i + 1)..boundary.len() {
90 let d = dist3(boundary[i], boundary[j]);
91 if d > max_dist {
92 max_dist = d;
93 far_a = boundary[i];
94 far_b = boundary[j];
95 }
96 }
97 }
98
99 let steps = 5usize;
101 let mut pts = Vec::with_capacity(steps);
102 for i in 0..steps {
103 let t = i as f64 / (steps - 1) as f64;
104 let pos = lerp3(far_a, far_b, t);
105 let r = boundary
107 .iter()
108 .map(|p| dist3(pos, *p))
109 .fold(f64::INFINITY, f64::min);
110 let r = if r == f64::INFINITY { avg_r } else { r };
111 let tangent = normalize3(sub3(far_b, far_a));
112 pts.push(MedialAxisPoint::new(pos, r, tangent, r));
113 }
114
115 let branch: Vec<usize> = (0..pts.len()).collect();
116 Self {
117 points: pts,
118 branches: vec![branch],
119 junctions: vec![],
120 }
121 }
122
123 pub fn branch_count(&self) -> usize {
125 self.branches.len()
126 }
127
128 pub fn total_length(&self) -> f64 {
130 let mut len = 0.0;
131 for branch in &self.branches {
132 for w in branch.windows(2) {
133 len += dist3(self.points[w[0]].position, self.points[w[1]].position);
134 }
135 }
136 len
137 }
138
139 pub fn is_tubular(&self) -> bool {
141 if self.branches.len() != 1 {
142 return false;
143 }
144 if self.points.len() < 2 {
145 return true;
146 }
147 let radii: Vec<f64> = self.points.iter().map(|p| p.radius).collect();
148 let mean = radii.iter().sum::<f64>() / radii.len() as f64;
149 let variance = radii.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / radii.len() as f64;
150 let cv = variance.sqrt() / mean.max(1e-12); cv < 0.3
152 }
153}
154
155pub struct Voronoi2dSkeleton;
157
158impl Voronoi2dSkeleton {
159 pub fn compute(points: &[[f64; 2]], bbox: [f64; 4]) -> Vec<[f64; 2]> {
164 let n = points.len();
166 if n < 3 {
167 return vec![];
168 }
169 let mut result = Vec::new();
170 for i in 0..n {
171 for j in (i + 1)..n {
172 for k in (j + 1)..n {
173 if let Some(cc) = circumcenter_2d(points[i], points[j], points[k])
174 && cc[0] >= bbox[0]
175 && cc[1] >= bbox[1]
176 && cc[0] <= bbox[2]
177 && cc[1] <= bbox[3]
178 {
179 result.push(cc);
180 }
181 }
182 }
183 }
184 result
185 }
186}
187
188pub struct StraightSkeleton {
190 polygon: Vec<[f64; 2]>,
191}
192
193impl StraightSkeleton {
194 pub fn new(polygon: Vec<[f64; 2]>) -> Self {
196 Self { polygon }
197 }
198
199 pub fn compute_offsets(&self, dist: f64) -> Vec<Vec<[f64; 2]>> {
203 let n = self.polygon.len();
204 if n < 3 {
205 return vec![];
206 }
207 let mut offset = Vec::with_capacity(n);
208 for i in 0..n {
209 let prev = self.polygon[(i + n - 1) % n];
210 let curr = self.polygon[i];
211 let next = self.polygon[(i + 1) % n];
212
213 let d1 = normalize2(sub2(curr, prev));
215 let d2 = normalize2(sub2(next, curr));
216 let n1 = [-d1[1], d1[0]];
217 let n2 = [-d2[1], d2[0]];
218 let bisector = normalize2(add2(n1, n2));
219
220 let dot = n1[0] * n2[0] + n1[1] * n2[1];
222 let sin_half = ((1.0 - dot) / 2.0).sqrt().max(1e-9);
223 let scale = 1.0 / sin_half;
224
225 let op = [
226 curr[0] + bisector[0] * dist * scale,
227 curr[1] + bisector[1] * dist * scale,
228 ];
229 offset.push(op);
230 }
231 vec![offset]
232 }
233
234 pub fn skeleton_edges(&self) -> Vec<([f64; 2], [f64; 2])> {
238 let n = self.polygon.len();
240 if n < 3 {
241 return vec![];
242 }
243 let cx = self.polygon.iter().map(|p| p[0]).sum::<f64>() / n as f64;
244 let cy = self.polygon.iter().map(|p| p[1]).sum::<f64>() / n as f64;
245 let centroid = [cx, cy];
246
247 let mut edges = Vec::with_capacity(n);
248 for i in 0..n {
249 edges.push((self.polygon[i], centroid));
250 }
251 edges
252 }
253}
254
255pub fn point_to_segment_dist(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
259 let ab = sub3(b, a);
260 let ap = sub3(p, a);
261 let len_sq = dot3(ab, ab);
262 if len_sq < 1e-24 {
263 return dist3(p, a);
264 }
265 let t = (dot3(ap, ab) / len_sq).clamp(0.0, 1.0);
266 let closest = add3(a, scale3(ab, t));
267 dist3(p, closest)
268}
269
270pub fn point_to_polygon_dist(p: [f64; 2], poly: &[[f64; 2]]) -> f64 {
272 let n = poly.len();
273 if n == 0 {
274 return f64::INFINITY;
275 }
276 let mut min_d = f64::INFINITY;
277 for i in 0..n {
278 let a = poly[i];
279 let b = poly[(i + 1) % n];
280 let d = seg_dist_2d(p, a, b);
281 if d < min_d {
282 min_d = d;
283 }
284 }
285 min_d
286}
287
288pub fn is_inside_polygon(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
290 let n = poly.len();
291 if n < 3 {
292 return false;
293 }
294 let mut inside = false;
295 let (x, y) = (p[0], p[1]);
296 let mut j = n - 1;
297 for i in 0..n {
298 let xi = poly[i][0];
299 let yi = poly[i][1];
300 let xj = poly[j][0];
301 let yj = poly[j][1];
302 let intersect = ((yi > y) != (yj > y))
303 && (x < (xj - xi) * (y - yi) / (yj - yi + if yj >= yi { 1e-14 } else { -1e-14 }) + xi);
304 if intersect {
305 inside = !inside;
306 }
307 j = i;
308 }
309 inside
310}
311
312pub struct CenterlineExtraction;
316
317impl CenterlineExtraction {
318 pub fn extract_centerline(surface_points: &[[f64; 3]], num_slices: usize) -> Vec<[f64; 3]> {
323 if surface_points.is_empty() || num_slices == 0 {
324 return vec![];
325 }
326
327 let mut x_min = f64::INFINITY;
329 let mut x_max = f64::NEG_INFINITY;
330 for p in surface_points {
331 if p[0] < x_min {
332 x_min = p[0];
333 }
334 if p[0] > x_max {
335 x_max = p[0];
336 }
337 }
338
339 let mut centerline = Vec::with_capacity(num_slices);
340 for s in 0..num_slices {
341 let t = s as f64 / (num_slices - 1).max(1) as f64;
342 let x_c = x_min + t * (x_max - x_min);
343 let half_width = (x_max - x_min) / (num_slices as f64 * 2.0);
344
345 let slice_pts: Vec<[f64; 3]> = surface_points
347 .iter()
348 .filter(|p| (p[0] - x_c).abs() <= half_width)
349 .copied()
350 .collect();
351
352 let centroid = if slice_pts.is_empty() {
353 [x_c, 0.0, 0.0]
354 } else {
355 let m = slice_pts.len() as f64;
356 [
357 slice_pts.iter().map(|p| p[0]).sum::<f64>() / m,
358 slice_pts.iter().map(|p| p[1]).sum::<f64>() / m,
359 slice_pts.iter().map(|p| p[2]).sum::<f64>() / m,
360 ]
361 };
362 centerline.push(centroid);
363 }
364 centerline
365 }
366}
367
368pub fn prune_short_branches(ma: &mut MedialAxis, min_length: f64) {
372 ma.branches.retain(|branch| {
373 let mut len = 0.0;
374 for w in branch.windows(2) {
375 len += dist3(ma.points[w[0]].position, ma.points[w[1]].position);
376 }
377 len >= min_length
378 });
379}
380
381pub fn euler_characteristic(ma: &MedialAxis) -> i32 {
388 let v = ma.points.len() as i32;
389 let mut e = 0i32;
390 for branch in &ma.branches {
391 e += branch.len().saturating_sub(1) as i32;
392 }
393 v - e
394}
395
396#[derive(Debug, Clone)]
401pub struct AxisAlignedSlab {
402 pub x_min: f64,
404 pub x_max: f64,
406 pub point_indices: Vec<usize>,
408}
409
410impl AxisAlignedSlab {
411 pub fn decompose(ma: &MedialAxis, num_slabs: usize) -> Vec<Self> {
414 if num_slabs == 0 || ma.points.is_empty() {
415 return vec![];
416 }
417 let x_vals: Vec<f64> = ma.points.iter().map(|p| p.position[0]).collect();
418 let x_min = x_vals.iter().cloned().fold(f64::INFINITY, f64::min);
419 let x_max = x_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
420 let width = (x_max - x_min) / num_slabs as f64;
421
422 let mut slabs: Vec<AxisAlignedSlab> = (0..num_slabs)
423 .map(|i| AxisAlignedSlab {
424 x_min: x_min + i as f64 * width,
425 x_max: x_min + (i + 1) as f64 * width,
426 point_indices: vec![],
427 })
428 .collect();
429
430 for (idx, pt) in ma.points.iter().enumerate() {
431 let raw = ((pt.position[0] - x_min) / width.max(1e-15)) as usize;
432 let slab_idx = raw.min(num_slabs - 1);
433 slabs[slab_idx].point_indices.push(idx);
434 }
435 slabs
436 }
437}
438
439pub fn shape_diameter(p: [f64; 3], n: [f64; 3], radius: f64) -> f64 {
446 let _ = p;
450 let _n_norm = normalize3(n);
451 2.0 * radius
452}
453
454fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
457 let dx = a[0] - b[0];
458 let dy = a[1] - b[1];
459 let dz = a[2] - b[2];
460 (dx * dx + dy * dy + dz * dz).sqrt()
461}
462
463fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
464 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
465}
466
467fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
468 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
469}
470
471fn scale3(a: [f64; 3], t: f64) -> [f64; 3] {
472 [a[0] * t, a[1] * t, a[2] * t]
473}
474
475fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
476 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
477}
478
479fn normalize3(v: [f64; 3]) -> [f64; 3] {
480 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
481 if len < 1e-15 {
482 [0.0, 0.0, 1.0]
483 } else {
484 [v[0] / len, v[1] / len, v[2] / len]
485 }
486}
487
488fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
489 [
490 a[0] + (b[0] - a[0]) * t,
491 a[1] + (b[1] - a[1]) * t,
492 a[2] + (b[2] - a[2]) * t,
493 ]
494}
495
496fn sub2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
497 [a[0] - b[0], a[1] - b[1]]
498}
499
500fn add2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
501 [a[0] + b[0], a[1] + b[1]]
502}
503
504fn normalize2(v: [f64; 2]) -> [f64; 2] {
505 let len = (v[0] * v[0] + v[1] * v[1]).sqrt();
506 if len < 1e-15 {
507 [1.0, 0.0]
508 } else {
509 [v[0] / len, v[1] / len]
510 }
511}
512
513fn seg_dist_2d(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
514 let ab = sub2(b, a);
515 let ap = sub2(p, a);
516 let len_sq = ab[0] * ab[0] + ab[1] * ab[1];
517 if len_sq < 1e-24 {
518 return (ap[0] * ap[0] + ap[1] * ap[1]).sqrt();
519 }
520 let t = ((ap[0] * ab[0] + ap[1] * ab[1]) / len_sq).clamp(0.0, 1.0);
521 let cx = a[0] + ab[0] * t - p[0];
522 let cy = a[1] + ab[1] * t - p[1];
523 (cx * cx + cy * cy).sqrt()
524}
525
526fn circumcenter_2d(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> Option<[f64; 2]> {
527 let ax = b[0] - a[0];
528 let ay = b[1] - a[1];
529 let bx = c[0] - a[0];
530 let by = c[1] - a[1];
531 let d = 2.0 * (ax * by - ay * bx);
532 if d.abs() < 1e-12 {
533 return None;
534 }
535 let ux = (by * (ax * ax + ay * ay) - ay * (bx * bx + by * by)) / d;
536 let uy = (ax * (bx * bx + by * by) - bx * (ax * ax + ay * ay)) / d;
537 Some([a[0] + ux, a[1] + uy])
538}
539
540#[derive(Debug, Clone)]
544pub struct BinaryImage {
545 pub width: usize,
547 pub height: usize,
549 pub data: Vec<bool>,
551}
552
553impl BinaryImage {
554 pub fn new(width: usize, height: usize, fill: bool) -> Self {
556 Self {
557 width,
558 height,
559 data: vec![fill; width * height],
560 }
561 }
562
563 pub fn get(&self, col: usize, row: usize) -> bool {
565 if col < self.width && row < self.height {
566 self.data[row * self.width + col]
567 } else {
568 false
569 }
570 }
571
572 pub fn set(&mut self, col: usize, row: usize, val: bool) {
574 if col < self.width && row < self.height {
575 self.data[row * self.width + col] = val;
576 }
577 }
578
579 pub fn foreground_count(&self) -> usize {
581 self.data.iter().filter(|&&v| v).count()
582 }
583}
584
585pub fn distance_transform(img: &BinaryImage) -> Vec<f64> {
591 let w = img.width;
592 let h = img.height;
593 let n = w * h;
594 let big = (w + h) as f64;
595 let mut dt = vec![0.0_f64; n];
596
597 for (i, dt_val) in dt.iter_mut().enumerate().take(n) {
599 *dt_val = if img.data[i] { big } else { 0.0 };
600 }
601
602 for r in 0..h {
604 for c in 0..w {
605 let idx = r * w + c;
606 if c > 0 {
607 dt[idx] = dt[idx].min(dt[idx - 1] + 1.0);
608 }
609 if r > 0 {
610 dt[idx] = dt[idx].min(dt[(r - 1) * w + c] + 1.0);
611 }
612 }
613 }
614
615 for r in (0..h).rev() {
617 for c in (0..w).rev() {
618 let idx = r * w + c;
619 if c + 1 < w {
620 dt[idx] = dt[idx].min(dt[idx + 1] + 1.0);
621 }
622 if r + 1 < h {
623 dt[idx] = dt[idx].min(dt[(r + 1) * w + c] + 1.0);
624 }
625 }
626 }
627
628 dt
629}
630
631pub fn distance_transform_exact(img: &BinaryImage) -> Vec<f64> {
634 let w = img.width;
635 let h = img.height;
636 if w == 0 || h == 0 {
637 return vec![];
638 }
639
640 if !img.data.iter().any(|&v| v) {
642 return vec![0.0; w * h];
643 }
644
645 let big = (w * w + h * h) as f64;
646 let mut g = vec![0.0_f64; w * h];
648 for c in 0..w {
649 g[c] = if img.data[c] { big } else { 0.0 };
651 for r in 1..h {
652 g[r * w + c] = if img.data[r * w + c] {
653 g[(r - 1) * w + c] + 1.0
654 } else {
655 0.0
656 };
657 }
658 for r in (0..h.saturating_sub(1)).rev() {
660 if g[(r + 1) * w + c] + 1.0 < g[r * w + c] {
661 g[r * w + c] = g[(r + 1) * w + c] + 1.0;
662 }
663 }
664 for r in 0..h {
666 let v = g[r * w + c];
667 g[r * w + c] = v * v;
668 }
669 }
670
671 let mut dt = vec![0.0_f64; w * h];
673 let mut s_buf = vec![0usize; w];
674 let mut t_buf = vec![0i64; w];
675
676 for r in 0..h {
677 let mut q = 0usize; s_buf[0] = 0;
680 t_buf[0] = 0;
681
682 for u in 1..w {
683 let g_su = g[r * w + s_buf[q]];
684 let g_u = g[r * w + u];
685 while q > 0 {
686 let s_val = s_buf[q];
687 let g_s = g[r * w + s_val];
688 let sep = sep_edt(s_buf[q - 1], s_val, g[r * w + s_buf[q - 1]], g_s, w);
689 if t_buf[q] as usize <= sep {
690 break;
691 }
692 q -= 1;
693 }
694 let _ = g_su;
695 q += 1;
696 s_buf[q] = u;
697 let prev_s = s_buf[q - 1];
698 t_buf[q] = (sep_edt(prev_s, u, g[r * w + prev_s], g_u, w) + 1) as i64;
699 }
700
701 for u in (0..w).rev() {
703 let s = s_buf[q];
704 let diff = u.abs_diff(s);
705 dt[r * w + u] = ((diff * diff) as f64 + g[r * w + s]).sqrt();
706 if u as i64 == t_buf[q] && q > 0 {
707 q -= 1;
708 }
709 }
710 }
711
712 dt
713}
714
715fn sep_edt(i: usize, u: usize, gi: f64, gu: f64, _w: usize) -> usize {
717 let num = (u * u) as f64 - (i * i) as f64 + gu - gi;
719 let den = 2.0 * (u as f64 - i as f64);
720 if den.abs() < 1e-15 {
721 return 0;
722 }
723 (num / den).floor().max(0.0) as usize
724}
725
726pub fn zhang_suen_thinning(img: &mut BinaryImage) {
733 let w = img.width;
734 let h = img.height;
735 if w < 3 || h < 3 {
736 return;
737 }
738
739 loop {
740 let to_remove_1 = zs_subiteration(img, true);
742 for &(c, r) in &to_remove_1 {
743 img.set(c, r, false);
744 }
745
746 let to_remove_2 = zs_subiteration(img, false);
748 for &(c, r) in &to_remove_2 {
749 img.set(c, r, false);
750 }
751
752 if to_remove_1.is_empty() && to_remove_2.is_empty() {
753 break;
754 }
755 }
756}
757
758fn zs_subiteration(img: &BinaryImage, first: bool) -> Vec<(usize, usize)> {
760 let w = img.width;
761 let h = img.height;
762 let mut to_remove = Vec::new();
763
764 for r in 1..h.saturating_sub(1) {
765 for c in 1..w.saturating_sub(1) {
766 if !img.get(c, r) {
767 continue;
768 }
769
770 let p2 = img.get(c, r - 1) as u8;
772 let p3 = img.get(c + 1, r - 1) as u8;
773 let p4 = img.get(c + 1, r) as u8;
774 let p5 = img.get(c + 1, r + 1) as u8;
775 let p6 = img.get(c, r + 1) as u8;
776 let p7 = img.get(c - 1, r + 1) as u8;
777 let p8 = img.get(c - 1, r) as u8;
778 let p9 = img.get(c - 1, r - 1) as u8;
779
780 let b = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; if !(2..=6).contains(&b) {
782 continue;
783 }
784
785 let seq = [p2, p3, p4, p5, p6, p7, p8, p9, p2];
787 let a: u8 = seq
788 .windows(2)
789 .map(|w| if w[0] == 0 && w[1] == 1 { 1u8 } else { 0 })
790 .sum();
791 if a != 1 {
792 continue;
793 }
794
795 if first {
796 if p2 * p4 * p6 != 0 || p4 * p6 * p8 != 0 {
798 continue;
799 }
800 } else {
801 if p2 * p4 * p8 != 0 || p2 * p6 * p8 != 0 {
803 continue;
804 }
805 }
806
807 to_remove.push((c, r));
808 }
809 }
810
811 to_remove
812}
813
814pub fn skeleton_pixels(img: &BinaryImage) -> Vec<(usize, usize)> {
816 let mut pixels = Vec::new();
817 for r in 0..img.height {
818 for c in 0..img.width {
819 if img.get(c, r) {
820 pixels.push((c, r));
821 }
822 }
823 }
824 pixels
825}
826
827#[derive(Debug, Clone)]
832pub struct MatPoint {
833 pub col: usize,
835 pub row: usize,
837 pub radius: f64,
839}
840
841pub fn medial_axis_transform(img: &BinaryImage) -> Vec<MatPoint> {
846 let dt = distance_transform(img);
847
848 let mut thin = img.clone();
849 zhang_suen_thinning(&mut thin);
850
851 let mut mat_points = Vec::new();
852 for r in 0..thin.height {
853 for c in 0..thin.width {
854 if thin.get(c, r) {
855 mat_points.push(MatPoint {
856 col: c,
857 row: r,
858 radius: dt[r * img.width + c],
859 });
860 }
861 }
862 }
863 mat_points
864}
865
866pub fn reconstruct_from_mat(mat_points: &[MatPoint], width: usize, height: usize) -> BinaryImage {
871 let mut img = BinaryImage::new(width, height, false);
872 for mp in mat_points {
873 let r_int = mp.radius.ceil() as isize;
874 let cx = mp.col as isize;
875 let cy = mp.row as isize;
876 for dy in -r_int..=r_int {
877 for dx in -r_int..=r_int {
878 if (dx * dx + dy * dy) as f64 <= mp.radius * mp.radius {
879 let nx = cx + dx;
880 let ny = cy + dy;
881 if nx >= 0 && ny >= 0 {
882 img.set(nx as usize, ny as usize, true);
883 }
884 }
885 }
886 }
887 }
888 img
889}
890
891pub fn branch_significance(ma: &MedialAxis, branch_idx: usize) -> f64 {
898 if branch_idx >= ma.branches.len() {
899 return 0.0;
900 }
901 let branch = &ma.branches[branch_idx];
902 if branch.is_empty() {
903 return 0.0;
904 }
905
906 let max_r = branch
907 .iter()
908 .map(|&i| ma.points[i].radius)
909 .fold(0.0_f64, f64::max);
910
911 let mut length = 0.0;
912 for w in branch.windows(2) {
913 length += dist3(ma.points[w[0]].position, ma.points[w[1]].position);
914 }
915
916 if length < 1e-15 {
917 return max_r;
918 }
919 max_r / length
920}
921
922pub fn prune_by_significance(ma: &mut MedialAxis, min_sig: f64) {
924 let sigs: Vec<f64> = (0..ma.branches.len())
925 .map(|i| branch_significance(ma, i))
926 .collect();
927 let mut keep = Vec::new();
928 for (i, &s) in sigs.iter().enumerate() {
929 if s >= min_sig {
930 keep.push(ma.branches[i].clone());
931 }
932 }
933 ma.branches = keep;
934}
935
936pub fn radius_function(ma: &MedialAxis, branch_idx: usize) -> Vec<(f64, f64)> {
942 if branch_idx >= ma.branches.len() {
943 return vec![];
944 }
945 let branch = &ma.branches[branch_idx];
946 let mut result = Vec::with_capacity(branch.len());
947 let mut arc = 0.0;
948 for (k, &idx) in branch.iter().enumerate() {
949 if k > 0 {
950 arc += dist3(ma.points[branch[k - 1]].position, ma.points[idx].position);
951 }
952 result.push((arc, ma.points[idx].radius));
953 }
954 result
955}
956
957pub fn average_radius(ma: &MedialAxis, branch_idx: usize) -> f64 {
959 if branch_idx >= ma.branches.len() {
960 return 0.0;
961 }
962 let branch = &ma.branches[branch_idx];
963 if branch.is_empty() {
964 return 0.0;
965 }
966 let sum: f64 = branch.iter().map(|&i| ma.points[i].radius).sum();
967 sum / branch.len() as f64
968}
969
970pub fn max_radius(ma: &MedialAxis, branch_idx: usize) -> f64 {
972 if branch_idx >= ma.branches.len() {
973 return 0.0;
974 }
975 ma.branches[branch_idx]
976 .iter()
977 .map(|&i| ma.points[i].radius)
978 .fold(0.0_f64, f64::max)
979}
980
981#[derive(Debug, Clone)]
985pub struct ShapePart {
986 pub point_indices: Vec<usize>,
988 pub approx_volume: f64,
990 pub centroid: [f64; 3],
992}
993
994pub fn decompose_from_skeleton(ma: &MedialAxis) -> Vec<ShapePart> {
1000 let mut parts = Vec::with_capacity(ma.branches.len());
1001 for branch in &ma.branches {
1002 if branch.is_empty() {
1003 continue;
1004 }
1005
1006 let mut vol = 0.0;
1007 let mut cx = 0.0;
1008 let mut cy = 0.0;
1009 let mut cz = 0.0;
1010
1011 for &idx in branch {
1012 let r = ma.points[idx].radius;
1013 vol += (4.0 / 3.0) * std::f64::consts::PI * r * r * r;
1014 cx += ma.points[idx].position[0];
1015 cy += ma.points[idx].position[1];
1016 cz += ma.points[idx].position[2];
1017 }
1018
1019 let n = branch.len() as f64;
1020 parts.push(ShapePart {
1021 point_indices: branch.clone(),
1022 approx_volume: vol,
1023 centroid: [cx / n, cy / n, cz / n],
1024 });
1025 }
1026 parts
1027}
1028
1029pub fn total_decomposed_volume(parts: &[ShapePart]) -> f64 {
1031 parts.iter().map(|p| p.approx_volume).sum()
1032}
1033
1034#[derive(Debug, Clone)]
1038pub struct CurveSkeletonNode {
1039 pub position: [f64; 3],
1041 pub radius: f64,
1043 pub neighbors: Vec<usize>,
1045}
1046
1047#[derive(Debug, Clone)]
1049pub struct CurveSkeleton {
1050 pub nodes: Vec<CurveSkeletonNode>,
1052}
1053
1054impl CurveSkeleton {
1055 pub fn from_surface_points(boundary: &[[f64; 3]], _contraction_steps: usize) -> Self {
1061 let ma = MedialAxis::from_points(boundary, SkeletonMethod::Voronoi);
1062
1063 let nodes: Vec<CurveSkeletonNode> = ma
1064 .points
1065 .iter()
1066 .map(|p| CurveSkeletonNode {
1067 position: p.position,
1068 radius: p.radius,
1069 neighbors: vec![],
1070 })
1071 .collect();
1072
1073 let mut skeleton = Self { nodes };
1074
1075 for branch in &ma.branches {
1077 for w in branch.windows(2) {
1078 let a = w[0];
1079 let b = w[1];
1080 if a < skeleton.nodes.len() && b < skeleton.nodes.len() {
1081 if !skeleton.nodes[a].neighbors.contains(&b) {
1082 skeleton.nodes[a].neighbors.push(b);
1083 }
1084 if !skeleton.nodes[b].neighbors.contains(&a) {
1085 skeleton.nodes[b].neighbors.push(a);
1086 }
1087 }
1088 }
1089 }
1090
1091 skeleton
1092 }
1093
1094 pub fn node_count(&self) -> usize {
1096 self.nodes.len()
1097 }
1098
1099 pub fn edge_count(&self) -> usize {
1101 let sum: usize = self.nodes.iter().map(|n| n.neighbors.len()).sum();
1102 sum / 2 }
1104
1105 pub fn total_length(&self) -> f64 {
1107 let mut len = 0.0;
1108 for (i, node) in self.nodes.iter().enumerate() {
1109 for &j in &node.neighbors {
1110 if j > i {
1111 len += dist3(node.position, self.nodes[j].position);
1112 }
1113 }
1114 }
1115 len
1116 }
1117
1118 pub fn leaf_nodes(&self) -> Vec<usize> {
1120 self.nodes
1121 .iter()
1122 .enumerate()
1123 .filter(|(_, n)| n.neighbors.len() == 1)
1124 .map(|(i, _)| i)
1125 .collect()
1126 }
1127
1128 pub fn junction_nodes(&self) -> Vec<usize> {
1130 self.nodes
1131 .iter()
1132 .enumerate()
1133 .filter(|(_, n)| n.neighbors.len() >= 3)
1134 .map(|(i, _)| i)
1135 .collect()
1136 }
1137
1138 pub fn prune_leaves(&mut self, min_length: f64) {
1140 loop {
1141 let leaves = self.leaf_nodes();
1142 let mut pruned = false;
1143 for leaf in leaves {
1144 if self.nodes[leaf].neighbors.is_empty() {
1145 continue;
1146 }
1147 let nbr = self.nodes[leaf].neighbors[0];
1148 let d = dist3(self.nodes[leaf].position, self.nodes[nbr].position);
1149 if d < min_length && self.nodes[nbr].neighbors.len() > 1 {
1150 self.nodes[nbr].neighbors.retain(|&n| n != leaf);
1152 self.nodes[leaf].neighbors.clear();
1153 pruned = true;
1154 }
1155 }
1156 if !pruned {
1157 break;
1158 }
1159 }
1160 }
1161}
1162
1163pub fn laplacian_smooth(
1167 points: &[[f64; 3]],
1168 adjacency: &[Vec<usize>],
1169 weight: f64,
1170) -> Vec<[f64; 3]> {
1171 let n = points.len();
1172 let mut result = vec![[0.0; 3]; n];
1173 for i in 0..n {
1174 if adjacency[i].is_empty() {
1175 result[i] = points[i];
1176 continue;
1177 }
1178 let mut avg = [0.0; 3];
1179 for &j in &adjacency[i] {
1180 avg[0] += points[j][0];
1181 avg[1] += points[j][1];
1182 avg[2] += points[j][2];
1183 }
1184 let k = adjacency[i].len() as f64;
1185 avg[0] /= k;
1186 avg[1] /= k;
1187 avg[2] /= k;
1188 result[i][0] = points[i][0] + weight * (avg[0] - points[i][0]);
1189 result[i][1] = points[i][1] + weight * (avg[1] - points[i][1]);
1190 result[i][2] = points[i][2] + weight * (avg[2] - points[i][2]);
1191 }
1192 result
1193}
1194
1195pub fn voronoi_medial_axis_2d(
1203 boundary: &[[f64; 2]],
1204 bbox: [f64; 4],
1205 min_radius: f64,
1206) -> Vec<([f64; 2], f64)> {
1207 let vertices = Voronoi2dSkeleton::compute(boundary, bbox);
1208 let mut result = Vec::new();
1209 for v in &vertices {
1210 let r = point_to_polygon_dist(*v, boundary);
1211 if r >= min_radius {
1212 result.push((*v, r));
1213 }
1214 }
1215 result
1216}
1217
1218pub fn directed_hausdorff(a: &MedialAxis, b: &MedialAxis) -> f64 {
1224 if a.points.is_empty() || b.points.is_empty() {
1225 return f64::INFINITY;
1226 }
1227 let mut max_d = 0.0_f64;
1228 for pa in &a.points {
1229 let min_d = b
1230 .points
1231 .iter()
1232 .map(|pb| dist3(pa.position, pb.position))
1233 .fold(f64::INFINITY, f64::min);
1234 if min_d > max_d {
1235 max_d = min_d;
1236 }
1237 }
1238 max_d
1239}
1240
1241pub fn hausdorff_distance(a: &MedialAxis, b: &MedialAxis) -> f64 {
1243 directed_hausdorff(a, b).max(directed_hausdorff(b, a))
1244}
1245
1246#[cfg(test)]
1249mod tests {
1250 use super::*;
1251
1252 fn cube_boundary() -> Vec<[f64; 3]> {
1254 let mut pts = vec![];
1255 for &x in &[-1.0, 1.0] {
1256 for &y in &[-1.0, 1.0] {
1257 for &z in &[-1.0, 1.0] {
1258 pts.push([x, y, z]);
1259 }
1260 }
1261 }
1262 pts
1263 }
1264
1265 fn unit_square() -> Vec<[f64; 2]> {
1267 vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
1268 }
1269
1270 #[test]
1271 fn test_medial_axis_from_empty() {
1272 let ma = MedialAxis::from_points(&[], SkeletonMethod::BallRolling);
1273 assert_eq!(ma.points.len(), 0);
1274 assert_eq!(ma.branch_count(), 0);
1275 }
1276
1277 #[test]
1278 fn test_medial_axis_from_cube() {
1279 let pts = cube_boundary();
1280 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1281 assert!(!ma.points.is_empty());
1282 assert!(ma.branch_count() >= 1);
1283 }
1284
1285 #[test]
1286 fn test_medial_axis_total_length_positive() {
1287 let pts = cube_boundary();
1288 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Thinning);
1289 assert!(ma.total_length() > 0.0);
1290 }
1291
1292 #[test]
1293 fn test_medial_axis_is_tubular() {
1294 let mut pts = vec![];
1296 for i in 0..20 {
1297 let x = i as f64 * 0.5;
1298 for &(dy, dz) in &[(0.5, 0.0), (-0.5, 0.0), (0.0, 0.5), (0.0, -0.5)] {
1299 pts.push([x, dy, dz]);
1300 }
1301 }
1302 let ma = MedialAxis::from_points(&pts, SkeletonMethod::MedialSurface);
1303 let _ = ma.is_tubular(); }
1306
1307 #[test]
1308 fn test_medial_axis_single_point() {
1309 let pts = vec![[0.0, 0.0, 0.0]];
1310 let ma = MedialAxis::from_points(&pts, SkeletonMethod::BallRolling);
1311 assert_eq!(ma.points.len(), 5); }
1313
1314 #[test]
1315 fn test_skeleton_method_variants() {
1316 let methods = [
1317 SkeletonMethod::BallRolling,
1318 SkeletonMethod::Thinning,
1319 SkeletonMethod::Voronoi,
1320 SkeletonMethod::MedialSurface,
1321 ];
1322 for m in methods {
1323 let pts = cube_boundary();
1324 let ma = MedialAxis::from_points(&pts, m);
1325 assert!(ma.total_length() >= 0.0);
1326 }
1327 }
1328
1329 #[test]
1330 fn test_voronoi2d_skeleton_empty() {
1331 let pts: Vec<[f64; 2]> = vec![];
1332 let res = Voronoi2dSkeleton::compute(&pts, [0.0, 0.0, 1.0, 1.0]);
1333 assert!(res.is_empty());
1334 }
1335
1336 #[test]
1337 fn test_voronoi2d_skeleton_two_points() {
1338 let pts = vec![[0.0, 0.0], [1.0, 0.0]];
1339 let res = Voronoi2dSkeleton::compute(&pts, [0.0, 0.0, 1.0, 1.0]);
1340 assert!(res.is_empty()); }
1342
1343 #[test]
1344 fn test_voronoi2d_skeleton_four_points() {
1345 let pts = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1346 let res = Voronoi2dSkeleton::compute(&pts, [-0.5, -0.5, 1.5, 1.5]);
1347 assert!(!res.is_empty());
1349 }
1350
1351 #[test]
1352 fn test_voronoi2d_skeleton_inside_bbox() {
1353 let pts = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
1354 let res = Voronoi2dSkeleton::compute(&pts, [-10.0, -10.0, 10.0, 10.0]);
1355 for p in &res {
1356 assert!(p[0] >= -10.0 && p[0] <= 10.0);
1357 assert!(p[1] >= -10.0 && p[1] <= 10.0);
1358 }
1359 }
1360
1361 #[test]
1362 fn test_straight_skeleton_new() {
1363 let sk = StraightSkeleton::new(unit_square());
1364 let edges = sk.skeleton_edges();
1365 assert_eq!(edges.len(), 4);
1366 }
1367
1368 #[test]
1369 fn test_straight_skeleton_empty_polygon() {
1370 let sk = StraightSkeleton::new(vec![]);
1371 assert!(sk.skeleton_edges().is_empty());
1372 assert!(sk.compute_offsets(0.1).is_empty());
1373 }
1374
1375 #[test]
1376 fn test_straight_skeleton_offset_count() {
1377 let sk = StraightSkeleton::new(unit_square());
1378 let offsets = sk.compute_offsets(0.1);
1379 assert_eq!(offsets.len(), 1);
1380 assert_eq!(offsets[0].len(), 4);
1381 }
1382
1383 #[test]
1384 fn test_straight_skeleton_edges_toward_centroid() {
1385 let sq = unit_square();
1386 let sk = StraightSkeleton::new(sq.clone());
1387 let edges = sk.skeleton_edges();
1388 for (_, end) in &edges {
1390 assert!((end[0] - 0.5).abs() < 1e-10);
1391 assert!((end[1] - 0.5).abs() < 1e-10);
1392 }
1393 }
1394
1395 #[test]
1396 fn test_point_to_segment_dist_endpoint() {
1397 let p = [0.0, 0.0, 0.0];
1398 let a = [1.0, 0.0, 0.0];
1399 let b = [2.0, 0.0, 0.0];
1400 let d = point_to_segment_dist(p, a, b);
1401 assert!((d - 1.0).abs() < 1e-10);
1402 }
1403
1404 #[test]
1405 fn test_point_to_segment_dist_perpendicular() {
1406 let p = [1.0, 1.0, 0.0];
1407 let a = [0.0, 0.0, 0.0];
1408 let b = [2.0, 0.0, 0.0];
1409 let d = point_to_segment_dist(p, a, b);
1410 assert!((d - 1.0).abs() < 1e-10);
1411 }
1412
1413 #[test]
1414 fn test_point_to_segment_dist_degenerate() {
1415 let p = [3.0, 4.0, 0.0];
1416 let a = [0.0, 0.0, 0.0];
1417 let b = [0.0, 0.0, 0.0]; let d = point_to_segment_dist(p, a, b);
1419 assert!((d - 5.0).abs() < 1e-10);
1420 }
1421
1422 #[test]
1423 fn test_point_to_polygon_dist_square() {
1424 let poly = unit_square();
1425 let p = [0.5, 0.5];
1426 let d = point_to_polygon_dist(p, &poly);
1427 assert!((d - 0.5).abs() < 1e-10);
1428 }
1429
1430 #[test]
1431 fn test_point_to_polygon_dist_empty() {
1432 let d = point_to_polygon_dist([0.0, 0.0], &[]);
1433 assert!(d.is_infinite());
1434 }
1435
1436 #[test]
1437 fn test_is_inside_polygon_center() {
1438 let poly = unit_square();
1439 assert!(is_inside_polygon([0.5, 0.5], &poly));
1440 }
1441
1442 #[test]
1443 fn test_is_inside_polygon_outside() {
1444 let poly = unit_square();
1445 assert!(!is_inside_polygon([2.0, 2.0], &poly));
1446 }
1447
1448 #[test]
1449 fn test_is_inside_polygon_too_few_vertices() {
1450 let poly = vec![[0.0, 0.0], [1.0, 0.0]];
1451 assert!(!is_inside_polygon([0.5, 0.0], &poly));
1452 }
1453
1454 #[test]
1455 fn test_centerline_extraction_basic() {
1456 let mut pts = vec![];
1457 for i in 0..10 {
1458 let x = i as f64;
1459 pts.push([x, 1.0, 0.0]);
1460 pts.push([x, -1.0, 0.0]);
1461 pts.push([x, 0.0, 1.0]);
1462 pts.push([x, 0.0, -1.0]);
1463 }
1464 let cl = CenterlineExtraction::extract_centerline(&pts, 5);
1465 assert_eq!(cl.len(), 5);
1466 }
1467
1468 #[test]
1469 fn test_centerline_extraction_empty() {
1470 let cl = CenterlineExtraction::extract_centerline(&[], 5);
1471 assert!(cl.is_empty());
1472 }
1473
1474 #[test]
1475 fn test_centerline_extraction_zero_slices() {
1476 let pts = vec![[0.0, 0.0, 0.0]];
1477 let cl = CenterlineExtraction::extract_centerline(&pts, 0);
1478 assert!(cl.is_empty());
1479 }
1480
1481 #[test]
1482 fn test_prune_short_branches() {
1483 let pts = cube_boundary();
1484 let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1485 let before = ma.branch_count();
1486 prune_short_branches(&mut ma, 1e12); assert!(ma.branch_count() <= before);
1488 }
1489
1490 #[test]
1491 fn test_prune_keeps_long_branches() {
1492 let pts = cube_boundary();
1493 let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1494 prune_short_branches(&mut ma, 0.0); assert!(ma.branch_count() >= 1);
1496 }
1497
1498 #[test]
1499 fn test_euler_characteristic() {
1500 let pts = cube_boundary();
1501 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1502 let chi = euler_characteristic(&ma);
1503 assert_eq!(chi, 1);
1505 }
1506
1507 #[test]
1508 fn test_axis_aligned_slab_decompose() {
1509 let pts = cube_boundary();
1510 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1511 let slabs = AxisAlignedSlab::decompose(&ma, 3);
1512 assert_eq!(slabs.len(), 3);
1513 let total_pts: usize = slabs.iter().map(|s| s.point_indices.len()).sum();
1514 assert_eq!(total_pts, ma.points.len());
1515 }
1516
1517 #[test]
1518 fn test_axis_aligned_slab_zero_slabs() {
1519 let pts = cube_boundary();
1520 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1521 let slabs = AxisAlignedSlab::decompose(&ma, 0);
1522 assert!(slabs.is_empty());
1523 }
1524
1525 #[test]
1526 fn test_shape_diameter_positive() {
1527 let d = shape_diameter([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.0);
1528 assert!((d - 2.0).abs() < 1e-10);
1529 }
1530
1531 #[test]
1532 fn test_shape_diameter_scales_with_radius() {
1533 let d1 = shape_diameter([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0);
1534 let d2 = shape_diameter([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 2.0);
1535 assert!((d2 - 2.0 * d1).abs() < 1e-10);
1536 }
1537
1538 fn make_square_image(size: usize) -> BinaryImage {
1541 let mut img = BinaryImage::new(size, size, false);
1542 let margin = size / 4;
1543 for r in margin..(size - margin) {
1544 for c in margin..(size - margin) {
1545 img.set(c, r, true);
1546 }
1547 }
1548 img
1549 }
1550
1551 #[test]
1552 fn test_binary_image_basic() {
1553 let img = BinaryImage::new(10, 10, false);
1554 assert_eq!(img.foreground_count(), 0);
1555 assert!(!img.get(0, 0));
1556 }
1557
1558 #[test]
1559 fn test_distance_transform_background() {
1560 let img = BinaryImage::new(10, 10, false);
1561 let dt = distance_transform(&img);
1562 assert!(dt.iter().all(|&v| v == 0.0));
1563 }
1564
1565 #[test]
1566 fn test_distance_transform_foreground() {
1567 let img = make_square_image(20);
1568 let dt = distance_transform(&img);
1569 let center = 10 * 20 + 10;
1571 assert!(dt[center] > 0.0, "Centre distance should be > 0");
1572 assert!((dt[0] - 0.0).abs() < 1e-10);
1574 }
1575
1576 #[test]
1577 fn test_distance_transform_exact_background() {
1578 let img = BinaryImage::new(10, 10, false);
1579 let dt = distance_transform_exact(&img);
1580 assert!(dt.iter().all(|&v| v == 0.0));
1581 }
1582
1583 #[test]
1584 fn test_distance_transform_exact_foreground() {
1585 let img = make_square_image(20);
1586 let dt = distance_transform_exact(&img);
1587 let center = 10 * 20 + 10;
1588 assert!(dt[center] > 0.0);
1589 }
1590
1591 #[test]
1594 fn test_zhang_suen_reduces_pixels() {
1595 let mut img = make_square_image(20);
1596 let before = img.foreground_count();
1597 zhang_suen_thinning(&mut img);
1598 let after = img.foreground_count();
1599 assert!(after < before, "Thinning should reduce pixel count");
1600 assert!(after > 0, "Thinning should leave a skeleton");
1601 }
1602
1603 #[test]
1604 fn test_zhang_suen_empty_image() {
1605 let mut img = BinaryImage::new(10, 10, false);
1606 zhang_suen_thinning(&mut img);
1607 assert_eq!(img.foreground_count(), 0);
1608 }
1609
1610 #[test]
1611 fn test_skeleton_pixels_from_thinned() {
1612 let mut img = make_square_image(20);
1613 zhang_suen_thinning(&mut img);
1614 let pixels = skeleton_pixels(&img);
1615 assert!(!pixels.is_empty());
1616 assert_eq!(pixels.len(), img.foreground_count());
1617 }
1618
1619 #[test]
1622 fn test_mat_has_positive_radii() {
1623 let img = make_square_image(20);
1624 let mat = medial_axis_transform(&img);
1625 assert!(!mat.is_empty(), "MAT should have points");
1626 for mp in &mat {
1627 assert!(mp.radius >= 0.0, "Radius should be non-negative");
1628 }
1629 }
1630
1631 #[test]
1632 fn test_reconstruct_from_mat() {
1633 let img = make_square_image(20);
1634 let mat = medial_axis_transform(&img);
1635 let recon = reconstruct_from_mat(&mat, 20, 20);
1636 assert!(
1637 recon.foreground_count() > 0,
1638 "Reconstructed image should not be empty"
1639 );
1640 }
1641
1642 #[test]
1645 fn test_branch_significance() {
1646 let pts = cube_boundary();
1647 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1648 let sig = branch_significance(&ma, 0);
1649 assert!(sig > 0.0, "Significance should be positive");
1650 }
1651
1652 #[test]
1653 fn test_prune_by_significance() {
1654 let pts = cube_boundary();
1655 let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1656 prune_by_significance(&mut ma, 1e12); assert!(
1658 ma.branches.is_empty()
1659 || ma.branches.iter().all(|b| {
1660 let _ = b;
1661 true })
1663 );
1664 }
1665
1666 #[test]
1669 fn test_radius_function() {
1670 let pts = cube_boundary();
1671 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1672 let rf = radius_function(&ma, 0);
1673 assert!(!rf.is_empty());
1674 for w in rf.windows(2) {
1676 assert!(w[1].0 >= w[0].0, "Arc length should be non-decreasing");
1677 }
1678 }
1679
1680 #[test]
1681 fn test_average_and_max_radius() {
1682 let pts = cube_boundary();
1683 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1684 let avg = average_radius(&ma, 0);
1685 let mx = max_radius(&ma, 0);
1686 assert!(avg > 0.0);
1687 assert!(mx >= avg, "Max radius should be >= average");
1688 }
1689
1690 #[test]
1693 fn test_decompose_from_skeleton() {
1694 let pts = cube_boundary();
1695 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1696 let parts = decompose_from_skeleton(&ma);
1697 assert!(!parts.is_empty());
1698 let vol = total_decomposed_volume(&parts);
1699 assert!(vol > 0.0, "Total volume should be positive");
1700 }
1701
1702 #[test]
1705 fn test_curve_skeleton_from_tube() {
1706 let mut pts = vec![];
1707 for i in 0..20 {
1708 let x = i as f64 * 0.5;
1709 for &(dy, dz) in &[(0.5, 0.0), (-0.5, 0.0), (0.0, 0.5), (0.0, -0.5)] {
1710 pts.push([x, dy, dz]);
1711 }
1712 }
1713 let cs = CurveSkeleton::from_surface_points(&pts, 5);
1714 assert!(cs.node_count() > 0);
1715 assert!(cs.total_length() > 0.0);
1716 }
1717
1718 #[test]
1719 fn test_curve_skeleton_leaf_nodes() {
1720 let pts = cube_boundary();
1721 let cs = CurveSkeleton::from_surface_points(&pts, 3);
1722 let leaves = cs.leaf_nodes();
1723 assert!(leaves.len() >= 2 || cs.node_count() <= 2);
1725 }
1726
1727 #[test]
1728 fn test_curve_skeleton_prune() {
1729 let pts = cube_boundary();
1730 let mut cs = CurveSkeleton::from_surface_points(&pts, 3);
1731 let before = cs.edge_count();
1732 cs.prune_leaves(1e12); let after = cs.edge_count();
1734 assert!(after <= before);
1735 }
1736
1737 #[test]
1738 fn test_laplacian_smooth() {
1739 let points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1740 let adj = vec![vec![1], vec![0, 2], vec![1]];
1741 let result = laplacian_smooth(&points, &adj, 0.5);
1742 assert!((result[1][0] - 1.0).abs() < 1e-10);
1744 }
1745
1746 #[test]
1749 fn test_voronoi_medial_axis_2d() {
1750 let pts = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1751 let result = voronoi_medial_axis_2d(&pts, [-0.5, -0.5, 1.5, 1.5], 0.0);
1752 assert!(!result.is_empty());
1754 }
1755
1756 #[test]
1759 fn test_hausdorff_self_zero() {
1760 let pts = cube_boundary();
1761 let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1762 let d = hausdorff_distance(&ma, &ma);
1763 assert!(d < 1e-10, "Hausdorff to self should be 0, got {:.6}", d);
1764 }
1765
1766 #[test]
1767 fn test_hausdorff_different_shapes() {
1768 let pts1 = cube_boundary();
1769 let pts2: Vec<[f64; 3]> = pts1.iter().map(|p| [p[0] + 5.0, p[1], p[2]]).collect();
1770 let ma1 = MedialAxis::from_points(&pts1, SkeletonMethod::Voronoi);
1771 let ma2 = MedialAxis::from_points(&pts2, SkeletonMethod::Voronoi);
1772 let d = hausdorff_distance(&ma1, &ma2);
1773 assert!(d > 1.0, "Shifted shapes should have Hausdorff > 1");
1774 }
1775}