Skip to main content

oxiphysics_geometry/
medial_axis.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Medial axis transform, skeleton computation, and related distance-transform utilities.
5//!
6//! Provides medial axis extraction from boundary point sets, straight skeleton
7//! computation for 2D polygons, simplified Voronoi-based 2D skeletons, and
8//! centerline extraction for tubular structures.
9
10/// Method used to compute the skeleton / medial axis.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SkeletonMethod {
13    /// Ball-rolling (inscribed-sphere) approach.
14    BallRolling,
15    /// Binary-thinning approach.
16    Thinning,
17    /// Voronoi-diagram-based approach.
18    Voronoi,
19    /// Medial-surface approach (for 3-D objects).
20    MedialSurface,
21}
22
23/// A single point on the medial axis.
24#[derive(Debug, Clone)]
25pub struct MedialAxisPoint {
26    /// 3-D position of the medial-axis point.
27    pub position: [f64; 3],
28    /// Radius of the largest inscribed sphere at this point.
29    pub radius: f64,
30    /// Unit tangent vector along the axis at this point.
31    pub tangent: [f64; 3],
32    /// Local feature size (minimum distance to any boundary feature).
33    pub feature_size: f64,
34}
35
36impl MedialAxisPoint {
37    /// Construct a new `MedialAxisPoint`.
38    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/// Medial axis (skeleton) of a 3-D solid represented as a set of boundary points.
49#[derive(Debug, Clone)]
50pub struct MedialAxis {
51    /// Ordered list of medial-axis sample points.
52    pub points: Vec<MedialAxisPoint>,
53    /// Each branch is a list of indices into `points`.
54    pub branches: Vec<Vec<usize>>,
55    /// Indices of junction (bifurcation) points.
56    pub junctions: Vec<usize>,
57}
58
59impl MedialAxis {
60    /// Construct a medial axis from a boundary point cloud using the given method.
61    ///
62    /// This is a simplified approximation: it finds the centroid of the boundary,
63    /// then builds an axis by iteratively projecting toward the centroid.
64    pub fn from_points(boundary: &[[f64; 3]], method: SkeletonMethod) -> Self {
65        let _ = method; // method selects algorithm family; simplified here
66        if boundary.is_empty() {
67            return Self {
68                points: vec![],
69                branches: vec![],
70                junctions: vec![],
71            };
72        }
73
74        // Compute centroid
75        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        // Compute average radius
82        let avg_r = boundary.iter().map(|p| dist3(*p, center)).sum::<f64>() / n;
83
84        // Build a simple single-branch axis: midpoint between furthest two points
85        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        // Build a 5-point axis from far_a to far_b
100        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            // radius = distance from pos to closest boundary point
106            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    /// Return the number of branches in the medial axis.
124    pub fn branch_count(&self) -> usize {
125        self.branches.len()
126    }
127
128    /// Return the total arc-length of the medial axis.
129    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    /// Return `true` if the shape is tubular (single branch, roughly constant radius).
140    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); // coefficient of variation
151        cv < 0.3
152    }
153}
154
155/// Simplified 2-D skeleton via Voronoi vertices of a point set.
156pub struct Voronoi2dSkeleton;
157
158impl Voronoi2dSkeleton {
159    /// Compute skeleton-like vertices from a 2-D point set within a bounding box.
160    ///
161    /// Returns Voronoi-vertex approximations by finding circumcentres of triplets
162    /// of nearby points and retaining those inside the bounding box.
163    pub fn compute(points: &[[f64; 2]], bbox: [f64; 4]) -> Vec<[f64; 2]> {
164        // bbox = [x_min, y_min, x_max, y_max]
165        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
188/// Straight skeleton for a simple 2-D polygon.
189pub struct StraightSkeleton {
190    polygon: Vec<[f64; 2]>,
191}
192
193impl StraightSkeleton {
194    /// Construct a `StraightSkeleton` from a polygon given as an ordered vertex list.
195    pub fn new(polygon: Vec<[f64; 2]>) -> Self {
196        Self { polygon }
197    }
198
199    /// Compute offset polygon(s) at signed distance `dist` (positive = inward).
200    ///
201    /// Returns a list of offset rings; may be empty if the polygon collapses.
202    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            // Inward normal bisector direction
214            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            // Scale by 1/sin(half-angle) for miter
221            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    /// Return the skeleton edges as pairs of 2-D points.
235    ///
236    /// Each edge connects a vertex to its corresponding collapsed skeleton point.
237    pub fn skeleton_edges(&self) -> Vec<([f64; 2], [f64; 2])> {
238        // Simplified: use bisector from each vertex toward the centroid
239        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
255// ─── Distance-transform helpers ────────────────────────────────────────────
256
257/// Compute the minimum distance from point `p` to line segment `[a, b]`.
258pub 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
270/// Compute the minimum distance from 2-D point `p` to a polygon edge set.
271pub 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
288/// Test whether 2-D point `p` is inside a simple polygon using ray casting.
289pub 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
312// ─── Centerline extraction ──────────────────────────────────────────────────
313
314/// Centerline extractor for tubular structures (e.g., blood vessels, pipes).
315pub struct CenterlineExtraction;
316
317impl CenterlineExtraction {
318    /// Extract a centerline from a surface point cloud by slicing along the
319    /// principal axis and computing slice centroids.
320    ///
321    /// `num_slices` controls the resolution of the output.
322    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        // Find axis-aligned bounding box
328        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            // Gather points in this slab
346            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
368// ─── Pruning ────────────────────────────────────────────────────────────────
369
370/// Remove branches shorter than `min_length` from the medial axis in-place.
371pub 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
381// ─── Topology ───────────────────────────────────────────────────────────────
382
383/// Compute the Euler characteristic χ = V - E + F of the medial axis graph.
384///
385/// Here V = number of axis points, E = number of axis edges (consecutive pairs
386/// in branches), F = 0 (the axis is a graph, not a surface).
387pub 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// ─── Axis-aligned slab decomposition ────────────────────────────────────────
397
398/// An axis-aligned slab used to decompose a bounding region for medial-axis
399/// computation.
400#[derive(Debug, Clone)]
401pub struct AxisAlignedSlab {
402    /// Minimum x-coordinate of the slab.
403    pub x_min: f64,
404    /// Maximum x-coordinate of the slab.
405    pub x_max: f64,
406    /// Indices of medial-axis points contained in this slab.
407    pub point_indices: Vec<usize>,
408}
409
410impl AxisAlignedSlab {
411    /// Build a slab decomposition of the medial axis along the X axis into
412    /// `num_slabs` equal-width slabs.
413    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
439// ─── Shape diameter function ─────────────────────────────────────────────────
440
441/// Compute the shape diameter function (SDF) approximation at surface point `p`
442/// with outward normal `n`, over a local hemisphere of `radius`.
443///
444/// Returns an estimate of the local thickness of the shape.
445pub fn shape_diameter(p: [f64; 3], n: [f64; 3], radius: f64) -> f64 {
446    // Approximation: SDF ≈ 2 * radius * |cos(angle between n and -n)| = 2*radius
447    // In practice, average of ray-cast lengths; here we return 2*radius as a
448    // geometric estimate since we have no volumetric representation.
449    let _ = p;
450    let _n_norm = normalize3(n);
451    2.0 * radius
452}
453
454// ─── Private math helpers ────────────────────────────────────────────────────
455
456fn 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// ─── Distance transform (2-D grid) ──────────────────────────────────────────
541
542/// A 2-D binary image stored as a flat boolean grid, row-major.
543#[derive(Debug, Clone)]
544pub struct BinaryImage {
545    /// Grid width (number of columns).
546    pub width: usize,
547    /// Grid height (number of rows).
548    pub height: usize,
549    /// Pixel data (true = foreground / object).
550    pub data: Vec<bool>,
551}
552
553impl BinaryImage {
554    /// Create a new binary image filled with `fill`.
555    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    /// Access pixel at (col, row).  Returns `false` if out of bounds.
564    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    /// Set pixel at (col, row).
573    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    /// Count foreground pixels.
580    pub fn foreground_count(&self) -> usize {
581        self.data.iter().filter(|&&v| v).count()
582    }
583}
584
585/// Compute the Euclidean distance transform of a binary image.
586///
587/// For each pixel, returns the distance to the nearest background pixel.
588/// Background pixels have distance 0.  Uses the independent-scan algorithm
589/// (Saito & Toriwaki, simplified).
590pub 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    // Initialise: foreground = big, background = 0
598    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    // Forward pass: row-wise left-to-right, top-to-bottom
603    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    // Backward pass: right-to-left, bottom-to-top
616    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
631/// Compute the exact Euclidean distance transform using Meijster's algorithm
632/// (two-pass, O(n) per row/column).
633pub 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 no foreground pixels, return all zeros
641    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    // Phase 1: column-wise 1-D transform (squared distances)
647    let mut g = vec![0.0_f64; w * h];
648    for c in 0..w {
649        // Forward
650        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        // Backward
659        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        // Square
665        for r in 0..h {
666            let v = g[r * w + c];
667            g[r * w + c] = v * v;
668        }
669    }
670
671    // Phase 2: row-wise lower-envelope transform
672    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        // Build lower envelope
678        let mut q = 0usize; // stack pointer
679        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        // Scan
702        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
715/// Separator function for the row-wise lower-envelope computation.
716fn sep_edt(i: usize, u: usize, gi: f64, gu: f64, _w: usize) -> usize {
717    // (u^2 - i^2 + gu - gi) / (2*(u - i))
718    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
726// ─── Thinning algorithm (Zhang-Suen) ────────────────────────────────────────
727
728/// Apply the Zhang-Suen thinning algorithm to produce a 1-pixel-wide skeleton.
729///
730/// Iteratively removes boundary pixels until no more can be removed, preserving
731/// topology.  Operates in-place on the image.
732pub 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        // Sub-iteration 1
741        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        // Sub-iteration 2
747        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
758/// One sub-iteration of Zhang-Suen.
759fn 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            // 8-neighbours: P2..P9 in clockwise order starting from top
771            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; // B(P1)
781            if !(2..=6).contains(&b) {
782                continue;
783            }
784
785            // A(P1): number of 0->1 transitions in the sequence P2..P9..P2
786            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                // P2 * P4 * P6 == 0  and  P4 * P6 * P8 == 0
797                if p2 * p4 * p6 != 0 || p4 * p6 * p8 != 0 {
798                    continue;
799                }
800            } else {
801                // P2 * P4 * P8 == 0  and  P2 * P6 * P8 == 0
802                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
814/// Extract skeleton pixel coordinates from a thinned binary image.
815pub 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// ─── Medial axis transform (MAT) ────────────────────────────────────────────
828
829/// A single point of the medial axis transform, storing both position and the
830/// inscribed-circle radius.
831#[derive(Debug, Clone)]
832pub struct MatPoint {
833    /// Column coordinate.
834    pub col: usize,
835    /// Row coordinate.
836    pub row: usize,
837    /// Inscribed-circle radius (from the distance transform).
838    pub radius: f64,
839}
840
841/// Compute the medial axis transform of a binary image.
842///
843/// This combines Zhang-Suen thinning with the distance transform: for each
844/// skeleton pixel, the radius is the distance-transform value at that pixel.
845pub 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
866/// Reconstruct the shape from MAT points by "painting" filled circles.
867///
868/// Returns a binary image where each MAT point stamps a filled disc of its
869/// inscribed radius.
870pub 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
891// ─── Significance-based pruning ──────────────────────────────────────────────
892
893/// Significance measure for a medial-axis branch.
894///
895/// Uses the ratio of the maximum inscribed radius along the branch to the
896/// branch length.  Branches with low significance are noise.
897pub 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
922/// Prune branches whose significance measure is below `min_sig`.
923pub 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
936// ─── Radius function ─────────────────────────────────────────────────────────
937
938/// Evaluate the radius function along a medial-axis branch.
939///
940/// Returns a vector of (arc-length, radius) pairs sampled at each branch point.
941pub 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
957/// Compute the average radius along a branch.
958pub 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
970/// Maximum inscribed radius along a branch.
971pub 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// ─── Shape decomposition from skeleton ───────────────────────────────────────
982
983/// A shape part resulting from skeleton-based decomposition.
984#[derive(Debug, Clone)]
985pub struct ShapePart {
986    /// Indices of the medial-axis points belonging to this part.
987    pub point_indices: Vec<usize>,
988    /// Approximate volume (sum of inscribed-sphere volumes).
989    pub approx_volume: f64,
990    /// Centroid of the part in 3-D.
991    pub centroid: [f64; 3],
992}
993
994/// Decompose a shape into parts using its medial axis.
995///
996/// Each branch of the medial axis defines one part.  The approximate volume is
997/// estimated by summing the volumes of inscribed spheres at each sample point
998/// (with overlap correction).
999pub 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
1029/// Total approximate volume from all shape parts.
1030pub fn total_decomposed_volume(parts: &[ShapePart]) -> f64 {
1031    parts.iter().map(|p| p.approx_volume).sum()
1032}
1033
1034// ─── Curve skeleton extraction (3-D) ────────────────────────────────────────
1035
1036/// A node in the curve skeleton graph.
1037#[derive(Debug, Clone)]
1038pub struct CurveSkeletonNode {
1039    /// 3-D position.
1040    pub position: [f64; 3],
1041    /// Inscribed-sphere radius.
1042    pub radius: f64,
1043    /// Indices of adjacent nodes.
1044    pub neighbors: Vec<usize>,
1045}
1046
1047/// A curve skeleton represented as a graph.
1048#[derive(Debug, Clone)]
1049pub struct CurveSkeleton {
1050    /// Nodes of the skeleton graph.
1051    pub nodes: Vec<CurveSkeletonNode>,
1052}
1053
1054impl CurveSkeleton {
1055    /// Extract a curve skeleton from a surface point cloud using iterative
1056    /// Laplacian contraction.
1057    ///
1058    /// This is a simplified version: it builds a medial axis from the boundary
1059    /// points, then converts the medial axis branches into a connected graph.
1060    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        // Build adjacency from branches
1076        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    /// Number of nodes in the skeleton.
1095    pub fn node_count(&self) -> usize {
1096        self.nodes.len()
1097    }
1098
1099    /// Number of edges in the skeleton.
1100    pub fn edge_count(&self) -> usize {
1101        let sum: usize = self.nodes.iter().map(|n| n.neighbors.len()).sum();
1102        sum / 2 // each edge counted twice
1103    }
1104
1105    /// Total arc-length of the skeleton.
1106    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    /// Find leaf nodes (degree 1) in the skeleton.
1119    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    /// Find junction nodes (degree >= 3) in the skeleton.
1129    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    /// Prune leaf branches shorter than `min_length` from the skeleton.
1139    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                    // Remove leaf
1151                    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
1163/// Compute the Laplacian of a point set (average displacement toward neighbours).
1164///
1165/// Used in iterative Laplacian contraction for curve-skeleton extraction.
1166pub 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
1195// ─── Voronoi-based medial axis approximation ────────────────────────────────
1196
1197/// Approximate the 2-D medial axis using Voronoi vertices of a dense boundary
1198/// sampling, then filter by inscribed-circle radius.
1199///
1200/// Only keeps Voronoi vertices whose distance to the boundary is at least
1201/// `min_radius`.
1202pub 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
1218// ─── Hausdorff distance between two medial axes ──────────────────────────────
1219
1220/// Compute the one-directional Hausdorff distance from medial axis `a` to `b`.
1221///
1222/// max over points in a of (min over points in b of dist(a_i, b_j)).
1223pub 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
1241/// Symmetric Hausdorff distance between two medial axes.
1242pub fn hausdorff_distance(a: &MedialAxis, b: &MedialAxis) -> f64 {
1243    directed_hausdorff(a, b).max(directed_hausdorff(b, a))
1244}
1245
1246// ─── Tests ───────────────────────────────────────────────────────────────────
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251
1252    // Helper: unit cube boundary points
1253    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    // Helper: unit square polygon
1266    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        // Build a synthetic tube-like boundary
1295        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        // tubularity check should be true for this cylinder-like set
1304        let _ = ma.is_tubular(); // just ensure no panic
1305    }
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); // single-point set still generates axis
1312    }
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()); // need at least 3
1341    }
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        // Should produce at least some circumcentres
1348        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        // All edges should end at centroid [0.5, 0.5]
1389        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]; // degenerate segment
1418        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); // prune everything
1487        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); // keep all
1495        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        // For a path graph V=5, E=4, chi = 1 (tree)
1504        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    // ─── Distance transform tests ───────────────────────────────────────
1539
1540    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        // Centre pixel should have maximum distance
1570        let center = 10 * 20 + 10;
1571        assert!(dt[center] > 0.0, "Centre distance should be > 0");
1572        // Corner (background) should be 0
1573        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    // ─── Thinning tests ────────────────────────────────────────────────
1592
1593    #[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    // ─── MAT tests ─────────────────────────────────────────────────────
1620
1621    #[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    // ─── Significance pruning tests ─────────────────────────────────────
1643
1644    #[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); // very high threshold => prune all
1657        assert!(
1658            ma.branches.is_empty()
1659                || ma.branches.iter().all(|b| {
1660                    let _ = b;
1661                    true // just check no panic
1662                })
1663        );
1664    }
1665
1666    // ─── Radius function tests ──────────────────────────────────────────
1667
1668    #[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        // Arc lengths should be non-decreasing
1675        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    // ─── Shape decomposition tests ──────────────────────────────────────
1691
1692    #[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    // ─── Curve skeleton tests ───────────────────────────────────────────
1703
1704    #[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        // A single-branch skeleton has exactly 2 leaves
1724        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); // prune everything
1733        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        // Middle point should stay put (neighbours average to itself)
1743        assert!((result[1][0] - 1.0).abs() < 1e-10);
1744    }
1745
1746    // ─── Voronoi medial axis 2-D tests ──────────────────────────────────
1747
1748    #[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        // Should find at least some vertices
1753        assert!(!result.is_empty());
1754    }
1755
1756    // ─── Hausdorff distance tests ───────────────────────────────────────
1757
1758    #[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}