Skip to main content

oxiphysics_geometry/
medial_axis.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Medial axis transform, skeleton computation, and related distance-transform utilities.
6//!
7//! Provides medial axis extraction from boundary point sets, straight skeleton
8//! computation for 2D polygons, simplified Voronoi-based 2D skeletons, and
9//! centerline extraction for tubular structures.
10
11#![allow(dead_code)]
12
13/// Method used to compute the skeleton / medial axis.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SkeletonMethod {
16    /// Ball-rolling (inscribed-sphere) approach.
17    BallRolling,
18    /// Binary-thinning approach.
19    Thinning,
20    /// Voronoi-diagram-based approach.
21    Voronoi,
22    /// Medial-surface approach (for 3-D objects).
23    MedialSurface,
24}
25
26/// A single point on the medial axis.
27#[derive(Debug, Clone)]
28pub struct MedialAxisPoint {
29    /// 3-D position of the medial-axis point.
30    pub position: [f64; 3],
31    /// Radius of the largest inscribed sphere at this point.
32    pub radius: f64,
33    /// Unit tangent vector along the axis at this point.
34    pub tangent: [f64; 3],
35    /// Local feature size (minimum distance to any boundary feature).
36    pub feature_size: f64,
37}
38
39impl MedialAxisPoint {
40    /// Construct a new `MedialAxisPoint`.
41    pub fn new(position: [f64; 3], radius: f64, tangent: [f64; 3], feature_size: f64) -> Self {
42        Self {
43            position,
44            radius,
45            tangent,
46            feature_size,
47        }
48    }
49}
50
51/// Medial axis (skeleton) of a 3-D solid represented as a set of boundary points.
52#[derive(Debug, Clone)]
53pub struct MedialAxis {
54    /// Ordered list of medial-axis sample points.
55    pub points: Vec<MedialAxisPoint>,
56    /// Each branch is a list of indices into `points`.
57    pub branches: Vec<Vec<usize>>,
58    /// Indices of junction (bifurcation) points.
59    pub junctions: Vec<usize>,
60}
61
62impl MedialAxis {
63    /// Construct a medial axis from a boundary point cloud using the given method.
64    ///
65    /// This is a simplified approximation: it finds the centroid of the boundary,
66    /// then builds an axis by iteratively projecting toward the centroid.
67    pub fn from_points(boundary: &[[f64; 3]], method: SkeletonMethod) -> Self {
68        let _ = method; // method selects algorithm family; simplified here
69        if boundary.is_empty() {
70            return Self {
71                points: vec![],
72                branches: vec![],
73                junctions: vec![],
74            };
75        }
76
77        // Compute centroid
78        let n = boundary.len() as f64;
79        let cx = boundary.iter().map(|p| p[0]).sum::<f64>() / n;
80        let cy = boundary.iter().map(|p| p[1]).sum::<f64>() / n;
81        let cz = boundary.iter().map(|p| p[2]).sum::<f64>() / n;
82        let center = [cx, cy, cz];
83
84        // Compute average radius
85        let avg_r = boundary.iter().map(|p| dist3(*p, center)).sum::<f64>() / n;
86
87        // Build a simple single-branch axis: midpoint between furthest two points
88        let mut max_dist = 0.0_f64;
89        let mut far_a = boundary[0];
90        let mut far_b = boundary[0];
91        for i in 0..boundary.len() {
92            for j in (i + 1)..boundary.len() {
93                let d = dist3(boundary[i], boundary[j]);
94                if d > max_dist {
95                    max_dist = d;
96                    far_a = boundary[i];
97                    far_b = boundary[j];
98                }
99            }
100        }
101
102        // Build a 5-point axis from far_a to far_b
103        let steps = 5usize;
104        let mut pts = Vec::with_capacity(steps);
105        for i in 0..steps {
106            let t = i as f64 / (steps - 1) as f64;
107            let pos = lerp3(far_a, far_b, t);
108            // radius = distance from pos to closest boundary point
109            let r = boundary
110                .iter()
111                .map(|p| dist3(pos, *p))
112                .fold(f64::INFINITY, f64::min);
113            let r = if r == f64::INFINITY { avg_r } else { r };
114            let tangent = normalize3(sub3(far_b, far_a));
115            pts.push(MedialAxisPoint::new(pos, r, tangent, r));
116        }
117
118        let branch: Vec<usize> = (0..pts.len()).collect();
119        Self {
120            points: pts,
121            branches: vec![branch],
122            junctions: vec![],
123        }
124    }
125
126    /// Return the number of branches in the medial axis.
127    pub fn branch_count(&self) -> usize {
128        self.branches.len()
129    }
130
131    /// Return the total arc-length of the medial axis.
132    pub fn total_length(&self) -> f64 {
133        let mut len = 0.0;
134        for branch in &self.branches {
135            for w in branch.windows(2) {
136                len += dist3(self.points[w[0]].position, self.points[w[1]].position);
137            }
138        }
139        len
140    }
141
142    /// Return `true` if the shape is tubular (single branch, roughly constant radius).
143    pub fn is_tubular(&self) -> bool {
144        if self.branches.len() != 1 {
145            return false;
146        }
147        if self.points.len() < 2 {
148            return true;
149        }
150        let radii: Vec<f64> = self.points.iter().map(|p| p.radius).collect();
151        let mean = radii.iter().sum::<f64>() / radii.len() as f64;
152        let variance = radii.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / radii.len() as f64;
153        let cv = variance.sqrt() / mean.max(1e-12); // coefficient of variation
154        cv < 0.3
155    }
156}
157
158/// Simplified 2-D skeleton via Voronoi vertices of a point set.
159pub struct Voronoi2dSkeleton;
160
161impl Voronoi2dSkeleton {
162    /// Compute skeleton-like vertices from a 2-D point set within a bounding box.
163    ///
164    /// Returns Voronoi-vertex approximations by finding circumcentres of triplets
165    /// of nearby points and retaining those inside the bounding box.
166    pub fn compute(points: &[[f64; 2]], bbox: [f64; 4]) -> Vec<[f64; 2]> {
167        // bbox = [x_min, y_min, x_max, y_max]
168        let n = points.len();
169        if n < 3 {
170            return vec![];
171        }
172        let mut result = Vec::new();
173        for i in 0..n {
174            for j in (i + 1)..n {
175                for k in (j + 1)..n {
176                    if let Some(cc) = circumcenter_2d(points[i], points[j], points[k])
177                        && cc[0] >= bbox[0]
178                        && cc[1] >= bbox[1]
179                        && cc[0] <= bbox[2]
180                        && cc[1] <= bbox[3]
181                    {
182                        result.push(cc);
183                    }
184                }
185            }
186        }
187        result
188    }
189}
190
191/// Straight skeleton for a simple 2-D polygon.
192pub struct StraightSkeleton {
193    polygon: Vec<[f64; 2]>,
194}
195
196impl StraightSkeleton {
197    /// Construct a `StraightSkeleton` from a polygon given as an ordered vertex list.
198    pub fn new(polygon: Vec<[f64; 2]>) -> Self {
199        Self { polygon }
200    }
201
202    /// Compute offset polygon(s) at signed distance `dist` (positive = inward).
203    ///
204    /// Returns a list of offset rings; may be empty if the polygon collapses.
205    pub fn compute_offsets(&self, dist: f64) -> Vec<Vec<[f64; 2]>> {
206        let n = self.polygon.len();
207        if n < 3 {
208            return vec![];
209        }
210        let mut offset = Vec::with_capacity(n);
211        for i in 0..n {
212            let prev = self.polygon[(i + n - 1) % n];
213            let curr = self.polygon[i];
214            let next = self.polygon[(i + 1) % n];
215
216            // Inward normal bisector direction
217            let d1 = normalize2(sub2(curr, prev));
218            let d2 = normalize2(sub2(next, curr));
219            let n1 = [-d1[1], d1[0]];
220            let n2 = [-d2[1], d2[0]];
221            let bisector = normalize2(add2(n1, n2));
222
223            // Scale by 1/sin(half-angle) for miter
224            let dot = n1[0] * n2[0] + n1[1] * n2[1];
225            let sin_half = ((1.0 - dot) / 2.0).sqrt().max(1e-9);
226            let scale = 1.0 / sin_half;
227
228            let op = [
229                curr[0] + bisector[0] * dist * scale,
230                curr[1] + bisector[1] * dist * scale,
231            ];
232            offset.push(op);
233        }
234        vec![offset]
235    }
236
237    /// Return the skeleton edges as pairs of 2-D points.
238    ///
239    /// Each edge connects a vertex to its corresponding collapsed skeleton point.
240    pub fn skeleton_edges(&self) -> Vec<([f64; 2], [f64; 2])> {
241        // Simplified: use bisector from each vertex toward the centroid
242        let n = self.polygon.len();
243        if n < 3 {
244            return vec![];
245        }
246        let cx = self.polygon.iter().map(|p| p[0]).sum::<f64>() / n as f64;
247        let cy = self.polygon.iter().map(|p| p[1]).sum::<f64>() / n as f64;
248        let centroid = [cx, cy];
249
250        let mut edges = Vec::with_capacity(n);
251        for i in 0..n {
252            edges.push((self.polygon[i], centroid));
253        }
254        edges
255    }
256}
257
258// ─── Distance-transform helpers ────────────────────────────────────────────
259
260/// Compute the minimum distance from point `p` to line segment `[a, b]`.
261pub fn point_to_segment_dist(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
262    let ab = sub3(b, a);
263    let ap = sub3(p, a);
264    let len_sq = dot3(ab, ab);
265    if len_sq < 1e-24 {
266        return dist3(p, a);
267    }
268    let t = (dot3(ap, ab) / len_sq).clamp(0.0, 1.0);
269    let closest = add3(a, scale3(ab, t));
270    dist3(p, closest)
271}
272
273/// Compute the minimum distance from 2-D point `p` to a polygon edge set.
274pub fn point_to_polygon_dist(p: [f64; 2], poly: &[[f64; 2]]) -> f64 {
275    let n = poly.len();
276    if n == 0 {
277        return f64::INFINITY;
278    }
279    let mut min_d = f64::INFINITY;
280    for i in 0..n {
281        let a = poly[i];
282        let b = poly[(i + 1) % n];
283        let d = seg_dist_2d(p, a, b);
284        if d < min_d {
285            min_d = d;
286        }
287    }
288    min_d
289}
290
291/// Test whether 2-D point `p` is inside a simple polygon using ray casting.
292pub fn is_inside_polygon(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
293    let n = poly.len();
294    if n < 3 {
295        return false;
296    }
297    let mut inside = false;
298    let (x, y) = (p[0], p[1]);
299    let mut j = n - 1;
300    for i in 0..n {
301        let xi = poly[i][0];
302        let yi = poly[i][1];
303        let xj = poly[j][0];
304        let yj = poly[j][1];
305        let intersect = ((yi > y) != (yj > y))
306            && (x < (xj - xi) * (y - yi) / (yj - yi + if yj >= yi { 1e-14 } else { -1e-14 }) + xi);
307        if intersect {
308            inside = !inside;
309        }
310        j = i;
311    }
312    inside
313}
314
315// ─── Centerline extraction ──────────────────────────────────────────────────
316
317/// Centerline extractor for tubular structures (e.g., blood vessels, pipes).
318pub struct CenterlineExtraction;
319
320impl CenterlineExtraction {
321    /// Extract a centerline from a surface point cloud by slicing along the
322    /// principal axis and computing slice centroids.
323    ///
324    /// `num_slices` controls the resolution of the output.
325    pub fn extract_centerline(surface_points: &[[f64; 3]], num_slices: usize) -> Vec<[f64; 3]> {
326        if surface_points.is_empty() || num_slices == 0 {
327            return vec![];
328        }
329
330        // Find axis-aligned bounding box
331        let mut x_min = f64::INFINITY;
332        let mut x_max = f64::NEG_INFINITY;
333        for p in surface_points {
334            if p[0] < x_min {
335                x_min = p[0];
336            }
337            if p[0] > x_max {
338                x_max = p[0];
339            }
340        }
341
342        let mut centerline = Vec::with_capacity(num_slices);
343        for s in 0..num_slices {
344            let t = s as f64 / (num_slices - 1).max(1) as f64;
345            let x_c = x_min + t * (x_max - x_min);
346            let half_width = (x_max - x_min) / (num_slices as f64 * 2.0);
347
348            // Gather points in this slab
349            let slice_pts: Vec<[f64; 3]> = surface_points
350                .iter()
351                .filter(|p| (p[0] - x_c).abs() <= half_width)
352                .copied()
353                .collect();
354
355            let centroid = if slice_pts.is_empty() {
356                [x_c, 0.0, 0.0]
357            } else {
358                let m = slice_pts.len() as f64;
359                [
360                    slice_pts.iter().map(|p| p[0]).sum::<f64>() / m,
361                    slice_pts.iter().map(|p| p[1]).sum::<f64>() / m,
362                    slice_pts.iter().map(|p| p[2]).sum::<f64>() / m,
363                ]
364            };
365            centerline.push(centroid);
366        }
367        centerline
368    }
369}
370
371// ─── Pruning ────────────────────────────────────────────────────────────────
372
373/// Remove branches shorter than `min_length` from the medial axis in-place.
374pub fn prune_short_branches(ma: &mut MedialAxis, min_length: f64) {
375    ma.branches.retain(|branch| {
376        let mut len = 0.0;
377        for w in branch.windows(2) {
378            len += dist3(ma.points[w[0]].position, ma.points[w[1]].position);
379        }
380        len >= min_length
381    });
382}
383
384// ─── Topology ───────────────────────────────────────────────────────────────
385
386/// Compute the Euler characteristic χ = V - E + F of the medial axis graph.
387///
388/// Here V = number of axis points, E = number of axis edges (consecutive pairs
389/// in branches), F = 0 (the axis is a graph, not a surface).
390pub fn euler_characteristic(ma: &MedialAxis) -> i32 {
391    let v = ma.points.len() as i32;
392    let mut e = 0i32;
393    for branch in &ma.branches {
394        e += branch.len().saturating_sub(1) as i32;
395    }
396    v - e
397}
398
399// ─── Axis-aligned slab decomposition ────────────────────────────────────────
400
401/// An axis-aligned slab used to decompose a bounding region for medial-axis
402/// computation.
403#[derive(Debug, Clone)]
404pub struct AxisAlignedSlab {
405    /// Minimum x-coordinate of the slab.
406    pub x_min: f64,
407    /// Maximum x-coordinate of the slab.
408    pub x_max: f64,
409    /// Indices of medial-axis points contained in this slab.
410    pub point_indices: Vec<usize>,
411}
412
413impl AxisAlignedSlab {
414    /// Build a slab decomposition of the medial axis along the X axis into
415    /// `num_slabs` equal-width slabs.
416    pub fn decompose(ma: &MedialAxis, num_slabs: usize) -> Vec<Self> {
417        if num_slabs == 0 || ma.points.is_empty() {
418            return vec![];
419        }
420        let x_vals: Vec<f64> = ma.points.iter().map(|p| p.position[0]).collect();
421        let x_min = x_vals.iter().cloned().fold(f64::INFINITY, f64::min);
422        let x_max = x_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
423        let width = (x_max - x_min) / num_slabs as f64;
424
425        let mut slabs: Vec<AxisAlignedSlab> = (0..num_slabs)
426            .map(|i| AxisAlignedSlab {
427                x_min: x_min + i as f64 * width,
428                x_max: x_min + (i + 1) as f64 * width,
429                point_indices: vec![],
430            })
431            .collect();
432
433        for (idx, pt) in ma.points.iter().enumerate() {
434            let raw = ((pt.position[0] - x_min) / width.max(1e-15)) as usize;
435            let slab_idx = raw.min(num_slabs - 1);
436            slabs[slab_idx].point_indices.push(idx);
437        }
438        slabs
439    }
440}
441
442// ─── Shape diameter function ─────────────────────────────────────────────────
443
444/// Compute the shape diameter function (SDF) approximation at surface point `p`
445/// with outward normal `n`, over a local hemisphere of `radius`.
446///
447/// Returns an estimate of the local thickness of the shape.
448pub fn shape_diameter(p: [f64; 3], n: [f64; 3], radius: f64) -> f64 {
449    // Approximation: SDF ≈ 2 * radius * |cos(angle between n and -n)| = 2*radius
450    // In practice, average of ray-cast lengths; here we return 2*radius as a
451    // geometric estimate since we have no volumetric representation.
452    let _ = p;
453    let _n_norm = normalize3(n);
454    2.0 * radius
455}
456
457// ─── Private math helpers ────────────────────────────────────────────────────
458
459fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
460    let dx = a[0] - b[0];
461    let dy = a[1] - b[1];
462    let dz = a[2] - b[2];
463    (dx * dx + dy * dy + dz * dz).sqrt()
464}
465
466fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
467    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
468}
469
470fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
471    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
472}
473
474fn scale3(a: [f64; 3], t: f64) -> [f64; 3] {
475    [a[0] * t, a[1] * t, a[2] * t]
476}
477
478fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
479    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
480}
481
482fn normalize3(v: [f64; 3]) -> [f64; 3] {
483    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
484    if len < 1e-15 {
485        [0.0, 0.0, 1.0]
486    } else {
487        [v[0] / len, v[1] / len, v[2] / len]
488    }
489}
490
491fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
492    [
493        a[0] + (b[0] - a[0]) * t,
494        a[1] + (b[1] - a[1]) * t,
495        a[2] + (b[2] - a[2]) * t,
496    ]
497}
498
499fn sub2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
500    [a[0] - b[0], a[1] - b[1]]
501}
502
503fn add2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
504    [a[0] + b[0], a[1] + b[1]]
505}
506
507fn normalize2(v: [f64; 2]) -> [f64; 2] {
508    let len = (v[0] * v[0] + v[1] * v[1]).sqrt();
509    if len < 1e-15 {
510        [1.0, 0.0]
511    } else {
512        [v[0] / len, v[1] / len]
513    }
514}
515
516fn seg_dist_2d(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
517    let ab = sub2(b, a);
518    let ap = sub2(p, a);
519    let len_sq = ab[0] * ab[0] + ab[1] * ab[1];
520    if len_sq < 1e-24 {
521        return (ap[0] * ap[0] + ap[1] * ap[1]).sqrt();
522    }
523    let t = ((ap[0] * ab[0] + ap[1] * ab[1]) / len_sq).clamp(0.0, 1.0);
524    let cx = a[0] + ab[0] * t - p[0];
525    let cy = a[1] + ab[1] * t - p[1];
526    (cx * cx + cy * cy).sqrt()
527}
528
529fn circumcenter_2d(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> Option<[f64; 2]> {
530    let ax = b[0] - a[0];
531    let ay = b[1] - a[1];
532    let bx = c[0] - a[0];
533    let by = c[1] - a[1];
534    let d = 2.0 * (ax * by - ay * bx);
535    if d.abs() < 1e-12 {
536        return None;
537    }
538    let ux = (by * (ax * ax + ay * ay) - ay * (bx * bx + by * by)) / d;
539    let uy = (ax * (bx * bx + by * by) - bx * (ax * ax + ay * ay)) / d;
540    Some([a[0] + ux, a[1] + uy])
541}
542
543// ─── Distance transform (2-D grid) ──────────────────────────────────────────
544
545/// A 2-D binary image stored as a flat boolean grid, row-major.
546#[derive(Debug, Clone)]
547pub struct BinaryImage {
548    /// Grid width (number of columns).
549    pub width: usize,
550    /// Grid height (number of rows).
551    pub height: usize,
552    /// Pixel data (true = foreground / object).
553    pub data: Vec<bool>,
554}
555
556impl BinaryImage {
557    /// Create a new binary image filled with `fill`.
558    pub fn new(width: usize, height: usize, fill: bool) -> Self {
559        Self {
560            width,
561            height,
562            data: vec![fill; width * height],
563        }
564    }
565
566    /// Access pixel at (col, row).  Returns `false` if out of bounds.
567    pub fn get(&self, col: usize, row: usize) -> bool {
568        if col < self.width && row < self.height {
569            self.data[row * self.width + col]
570        } else {
571            false
572        }
573    }
574
575    /// Set pixel at (col, row).
576    pub fn set(&mut self, col: usize, row: usize, val: bool) {
577        if col < self.width && row < self.height {
578            self.data[row * self.width + col] = val;
579        }
580    }
581
582    /// Count foreground pixels.
583    pub fn foreground_count(&self) -> usize {
584        self.data.iter().filter(|&&v| v).count()
585    }
586}
587
588/// Compute the Euclidean distance transform of a binary image.
589///
590/// For each pixel, returns the distance to the nearest background pixel.
591/// Background pixels have distance 0.  Uses the independent-scan algorithm
592/// (Saito & Toriwaki, simplified).
593pub fn distance_transform(img: &BinaryImage) -> Vec<f64> {
594    let w = img.width;
595    let h = img.height;
596    let n = w * h;
597    let big = (w + h) as f64;
598    let mut dt = vec![0.0_f64; n];
599
600    // Initialise: foreground = big, background = 0
601    for i in 0..n {
602        dt[i] = if img.data[i] { big } else { 0.0 };
603    }
604
605    // Forward pass: row-wise left-to-right, top-to-bottom
606    for r in 0..h {
607        for c in 0..w {
608            let idx = r * w + c;
609            if c > 0 {
610                dt[idx] = dt[idx].min(dt[idx - 1] + 1.0);
611            }
612            if r > 0 {
613                dt[idx] = dt[idx].min(dt[(r - 1) * w + c] + 1.0);
614            }
615        }
616    }
617
618    // Backward pass: right-to-left, bottom-to-top
619    for r in (0..h).rev() {
620        for c in (0..w).rev() {
621            let idx = r * w + c;
622            if c + 1 < w {
623                dt[idx] = dt[idx].min(dt[idx + 1] + 1.0);
624            }
625            if r + 1 < h {
626                dt[idx] = dt[idx].min(dt[(r + 1) * w + c] + 1.0);
627            }
628        }
629    }
630
631    dt
632}
633
634/// Compute the exact Euclidean distance transform using Meijster's algorithm
635/// (two-pass, O(n) per row/column).
636pub fn distance_transform_exact(img: &BinaryImage) -> Vec<f64> {
637    let w = img.width;
638    let h = img.height;
639    if w == 0 || h == 0 {
640        return vec![];
641    }
642
643    // If no foreground pixels, return all zeros
644    if !img.data.iter().any(|&v| v) {
645        return vec![0.0; w * h];
646    }
647
648    let big = (w * w + h * h) as f64;
649    // Phase 1: column-wise 1-D transform (squared distances)
650    let mut g = vec![0.0_f64; w * h];
651    for c in 0..w {
652        // Forward
653        g[c] = if img.data[c] { big } else { 0.0 };
654        for r in 1..h {
655            g[r * w + c] = if img.data[r * w + c] {
656                g[(r - 1) * w + c] + 1.0
657            } else {
658                0.0
659            };
660        }
661        // Backward
662        for r in (0..h.saturating_sub(1)).rev() {
663            if g[(r + 1) * w + c] + 1.0 < g[r * w + c] {
664                g[r * w + c] = g[(r + 1) * w + c] + 1.0;
665            }
666        }
667        // Square
668        for r in 0..h {
669            let v = g[r * w + c];
670            g[r * w + c] = v * v;
671        }
672    }
673
674    // Phase 2: row-wise lower-envelope transform
675    let mut dt = vec![0.0_f64; w * h];
676    let mut s_buf = vec![0usize; w];
677    let mut t_buf = vec![0i64; w];
678
679    for r in 0..h {
680        // Build lower envelope
681        let mut q = 0usize; // stack pointer
682        s_buf[0] = 0;
683        t_buf[0] = 0;
684
685        for u in 1..w {
686            let g_su = g[r * w + s_buf[q]];
687            let g_u = g[r * w + u];
688            while q > 0 {
689                let s_val = s_buf[q];
690                let g_s = g[r * w + s_val];
691                let sep = sep_edt(s_buf[q - 1], s_val, g[r * w + s_buf[q - 1]], g_s, w);
692                if t_buf[q] as usize <= sep {
693                    break;
694                }
695                q -= 1;
696            }
697            let _ = g_su;
698            q += 1;
699            s_buf[q] = u;
700            let prev_s = s_buf[q - 1];
701            t_buf[q] = (sep_edt(prev_s, u, g[r * w + prev_s], g_u, w) + 1) as i64;
702        }
703
704        // Scan
705        for u in (0..w).rev() {
706            let s = s_buf[q];
707            let diff = u.abs_diff(s);
708            dt[r * w + u] = ((diff * diff) as f64 + g[r * w + s]).sqrt();
709            if u as i64 == t_buf[q] && q > 0 {
710                q -= 1;
711            }
712        }
713    }
714
715    dt
716}
717
718/// Separator function for the row-wise lower-envelope computation.
719fn sep_edt(i: usize, u: usize, gi: f64, gu: f64, _w: usize) -> usize {
720    // (u^2 - i^2 + gu - gi) / (2*(u - i))
721    let num = (u * u) as f64 - (i * i) as f64 + gu - gi;
722    let den = 2.0 * (u as f64 - i as f64);
723    if den.abs() < 1e-15 {
724        return 0;
725    }
726    (num / den).floor().max(0.0) as usize
727}
728
729// ─── Thinning algorithm (Zhang-Suen) ────────────────────────────────────────
730
731/// Apply the Zhang-Suen thinning algorithm to produce a 1-pixel-wide skeleton.
732///
733/// Iteratively removes boundary pixels until no more can be removed, preserving
734/// topology.  Operates in-place on the image.
735pub fn zhang_suen_thinning(img: &mut BinaryImage) {
736    let w = img.width;
737    let h = img.height;
738    if w < 3 || h < 3 {
739        return;
740    }
741
742    loop {
743        // Sub-iteration 1
744        let to_remove_1 = zs_subiteration(img, true);
745        for &(c, r) in &to_remove_1 {
746            img.set(c, r, false);
747        }
748
749        // Sub-iteration 2
750        let to_remove_2 = zs_subiteration(img, false);
751        for &(c, r) in &to_remove_2 {
752            img.set(c, r, false);
753        }
754
755        if to_remove_1.is_empty() && to_remove_2.is_empty() {
756            break;
757        }
758    }
759}
760
761/// One sub-iteration of Zhang-Suen.
762fn zs_subiteration(img: &BinaryImage, first: bool) -> Vec<(usize, usize)> {
763    let w = img.width;
764    let h = img.height;
765    let mut to_remove = Vec::new();
766
767    for r in 1..h.saturating_sub(1) {
768        for c in 1..w.saturating_sub(1) {
769            if !img.get(c, r) {
770                continue;
771            }
772
773            // 8-neighbours: P2..P9 in clockwise order starting from top
774            let p2 = img.get(c, r - 1) as u8;
775            let p3 = img.get(c + 1, r - 1) as u8;
776            let p4 = img.get(c + 1, r) as u8;
777            let p5 = img.get(c + 1, r + 1) as u8;
778            let p6 = img.get(c, r + 1) as u8;
779            let p7 = img.get(c - 1, r + 1) as u8;
780            let p8 = img.get(c - 1, r) as u8;
781            let p9 = img.get(c - 1, r - 1) as u8;
782
783            let b = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; // B(P1)
784            if !(2..=6).contains(&b) {
785                continue;
786            }
787
788            // A(P1): number of 0->1 transitions in the sequence P2..P9..P2
789            let seq = [p2, p3, p4, p5, p6, p7, p8, p9, p2];
790            let a: u8 = seq
791                .windows(2)
792                .map(|w| if w[0] == 0 && w[1] == 1 { 1u8 } else { 0 })
793                .sum();
794            if a != 1 {
795                continue;
796            }
797
798            if first {
799                // P2 * P4 * P6 == 0  and  P4 * P6 * P8 == 0
800                if p2 * p4 * p6 != 0 || p4 * p6 * p8 != 0 {
801                    continue;
802                }
803            } else {
804                // P2 * P4 * P8 == 0  and  P2 * P6 * P8 == 0
805                if p2 * p4 * p8 != 0 || p2 * p6 * p8 != 0 {
806                    continue;
807                }
808            }
809
810            to_remove.push((c, r));
811        }
812    }
813
814    to_remove
815}
816
817/// Extract skeleton pixel coordinates from a thinned binary image.
818pub fn skeleton_pixels(img: &BinaryImage) -> Vec<(usize, usize)> {
819    let mut pixels = Vec::new();
820    for r in 0..img.height {
821        for c in 0..img.width {
822            if img.get(c, r) {
823                pixels.push((c, r));
824            }
825        }
826    }
827    pixels
828}
829
830// ─── Medial axis transform (MAT) ────────────────────────────────────────────
831
832/// A single point of the medial axis transform, storing both position and the
833/// inscribed-circle radius.
834#[derive(Debug, Clone)]
835pub struct MatPoint {
836    /// Column coordinate.
837    pub col: usize,
838    /// Row coordinate.
839    pub row: usize,
840    /// Inscribed-circle radius (from the distance transform).
841    pub radius: f64,
842}
843
844/// Compute the medial axis transform of a binary image.
845///
846/// This combines Zhang-Suen thinning with the distance transform: for each
847/// skeleton pixel, the radius is the distance-transform value at that pixel.
848pub fn medial_axis_transform(img: &BinaryImage) -> Vec<MatPoint> {
849    let dt = distance_transform(img);
850
851    let mut thin = img.clone();
852    zhang_suen_thinning(&mut thin);
853
854    let mut mat_points = Vec::new();
855    for r in 0..thin.height {
856        for c in 0..thin.width {
857            if thin.get(c, r) {
858                mat_points.push(MatPoint {
859                    col: c,
860                    row: r,
861                    radius: dt[r * img.width + c],
862                });
863            }
864        }
865    }
866    mat_points
867}
868
869/// Reconstruct the shape from MAT points by "painting" filled circles.
870///
871/// Returns a binary image where each MAT point stamps a filled disc of its
872/// inscribed radius.
873pub fn reconstruct_from_mat(mat_points: &[MatPoint], width: usize, height: usize) -> BinaryImage {
874    let mut img = BinaryImage::new(width, height, false);
875    for mp in mat_points {
876        let r_int = mp.radius.ceil() as isize;
877        let cx = mp.col as isize;
878        let cy = mp.row as isize;
879        for dy in -r_int..=r_int {
880            for dx in -r_int..=r_int {
881                if (dx * dx + dy * dy) as f64 <= mp.radius * mp.radius {
882                    let nx = cx + dx;
883                    let ny = cy + dy;
884                    if nx >= 0 && ny >= 0 {
885                        img.set(nx as usize, ny as usize, true);
886                    }
887                }
888            }
889        }
890    }
891    img
892}
893
894// ─── Significance-based pruning ──────────────────────────────────────────────
895
896/// Significance measure for a medial-axis branch.
897///
898/// Uses the ratio of the maximum inscribed radius along the branch to the
899/// branch length.  Branches with low significance are noise.
900pub fn branch_significance(ma: &MedialAxis, branch_idx: usize) -> f64 {
901    if branch_idx >= ma.branches.len() {
902        return 0.0;
903    }
904    let branch = &ma.branches[branch_idx];
905    if branch.is_empty() {
906        return 0.0;
907    }
908
909    let max_r = branch
910        .iter()
911        .map(|&i| ma.points[i].radius)
912        .fold(0.0_f64, f64::max);
913
914    let mut length = 0.0;
915    for w in branch.windows(2) {
916        length += dist3(ma.points[w[0]].position, ma.points[w[1]].position);
917    }
918
919    if length < 1e-15 {
920        return max_r;
921    }
922    max_r / length
923}
924
925/// Prune branches whose significance measure is below `min_sig`.
926pub fn prune_by_significance(ma: &mut MedialAxis, min_sig: f64) {
927    let sigs: Vec<f64> = (0..ma.branches.len())
928        .map(|i| branch_significance(ma, i))
929        .collect();
930    let mut keep = Vec::new();
931    for (i, &s) in sigs.iter().enumerate() {
932        if s >= min_sig {
933            keep.push(ma.branches[i].clone());
934        }
935    }
936    ma.branches = keep;
937}
938
939// ─── Radius function ─────────────────────────────────────────────────────────
940
941/// Evaluate the radius function along a medial-axis branch.
942///
943/// Returns a vector of (arc-length, radius) pairs sampled at each branch point.
944pub fn radius_function(ma: &MedialAxis, branch_idx: usize) -> Vec<(f64, f64)> {
945    if branch_idx >= ma.branches.len() {
946        return vec![];
947    }
948    let branch = &ma.branches[branch_idx];
949    let mut result = Vec::with_capacity(branch.len());
950    let mut arc = 0.0;
951    for (k, &idx) in branch.iter().enumerate() {
952        if k > 0 {
953            arc += dist3(ma.points[branch[k - 1]].position, ma.points[idx].position);
954        }
955        result.push((arc, ma.points[idx].radius));
956    }
957    result
958}
959
960/// Compute the average radius along a branch.
961pub fn average_radius(ma: &MedialAxis, branch_idx: usize) -> f64 {
962    if branch_idx >= ma.branches.len() {
963        return 0.0;
964    }
965    let branch = &ma.branches[branch_idx];
966    if branch.is_empty() {
967        return 0.0;
968    }
969    let sum: f64 = branch.iter().map(|&i| ma.points[i].radius).sum();
970    sum / branch.len() as f64
971}
972
973/// Maximum inscribed radius along a branch.
974pub fn max_radius(ma: &MedialAxis, branch_idx: usize) -> f64 {
975    if branch_idx >= ma.branches.len() {
976        return 0.0;
977    }
978    ma.branches[branch_idx]
979        .iter()
980        .map(|&i| ma.points[i].radius)
981        .fold(0.0_f64, f64::max)
982}
983
984// ─── Shape decomposition from skeleton ───────────────────────────────────────
985
986/// A shape part resulting from skeleton-based decomposition.
987#[derive(Debug, Clone)]
988pub struct ShapePart {
989    /// Indices of the medial-axis points belonging to this part.
990    pub point_indices: Vec<usize>,
991    /// Approximate volume (sum of inscribed-sphere volumes).
992    pub approx_volume: f64,
993    /// Centroid of the part in 3-D.
994    pub centroid: [f64; 3],
995}
996
997/// Decompose a shape into parts using its medial axis.
998///
999/// Each branch of the medial axis defines one part.  The approximate volume is
1000/// estimated by summing the volumes of inscribed spheres at each sample point
1001/// (with overlap correction).
1002pub fn decompose_from_skeleton(ma: &MedialAxis) -> Vec<ShapePart> {
1003    let mut parts = Vec::with_capacity(ma.branches.len());
1004    for branch in &ma.branches {
1005        if branch.is_empty() {
1006            continue;
1007        }
1008
1009        let mut vol = 0.0;
1010        let mut cx = 0.0;
1011        let mut cy = 0.0;
1012        let mut cz = 0.0;
1013
1014        for &idx in branch {
1015            let r = ma.points[idx].radius;
1016            vol += (4.0 / 3.0) * std::f64::consts::PI * r * r * r;
1017            cx += ma.points[idx].position[0];
1018            cy += ma.points[idx].position[1];
1019            cz += ma.points[idx].position[2];
1020        }
1021
1022        let n = branch.len() as f64;
1023        parts.push(ShapePart {
1024            point_indices: branch.clone(),
1025            approx_volume: vol,
1026            centroid: [cx / n, cy / n, cz / n],
1027        });
1028    }
1029    parts
1030}
1031
1032/// Total approximate volume from all shape parts.
1033pub fn total_decomposed_volume(parts: &[ShapePart]) -> f64 {
1034    parts.iter().map(|p| p.approx_volume).sum()
1035}
1036
1037// ─── Curve skeleton extraction (3-D) ────────────────────────────────────────
1038
1039/// A node in the curve skeleton graph.
1040#[derive(Debug, Clone)]
1041pub struct CurveSkeletonNode {
1042    /// 3-D position.
1043    pub position: [f64; 3],
1044    /// Inscribed-sphere radius.
1045    pub radius: f64,
1046    /// Indices of adjacent nodes.
1047    pub neighbors: Vec<usize>,
1048}
1049
1050/// A curve skeleton represented as a graph.
1051#[derive(Debug, Clone)]
1052pub struct CurveSkeleton {
1053    /// Nodes of the skeleton graph.
1054    pub nodes: Vec<CurveSkeletonNode>,
1055}
1056
1057impl CurveSkeleton {
1058    /// Extract a curve skeleton from a surface point cloud using iterative
1059    /// Laplacian contraction.
1060    ///
1061    /// This is a simplified version: it builds a medial axis from the boundary
1062    /// points, then converts the medial axis branches into a connected graph.
1063    pub fn from_surface_points(boundary: &[[f64; 3]], _contraction_steps: usize) -> Self {
1064        let ma = MedialAxis::from_points(boundary, SkeletonMethod::Voronoi);
1065
1066        let nodes: Vec<CurveSkeletonNode> = ma
1067            .points
1068            .iter()
1069            .map(|p| CurveSkeletonNode {
1070                position: p.position,
1071                radius: p.radius,
1072                neighbors: vec![],
1073            })
1074            .collect();
1075
1076        let mut skeleton = Self { nodes };
1077
1078        // Build adjacency from branches
1079        for branch in &ma.branches {
1080            for w in branch.windows(2) {
1081                let a = w[0];
1082                let b = w[1];
1083                if a < skeleton.nodes.len() && b < skeleton.nodes.len() {
1084                    if !skeleton.nodes[a].neighbors.contains(&b) {
1085                        skeleton.nodes[a].neighbors.push(b);
1086                    }
1087                    if !skeleton.nodes[b].neighbors.contains(&a) {
1088                        skeleton.nodes[b].neighbors.push(a);
1089                    }
1090                }
1091            }
1092        }
1093
1094        skeleton
1095    }
1096
1097    /// Number of nodes in the skeleton.
1098    pub fn node_count(&self) -> usize {
1099        self.nodes.len()
1100    }
1101
1102    /// Number of edges in the skeleton.
1103    pub fn edge_count(&self) -> usize {
1104        let sum: usize = self.nodes.iter().map(|n| n.neighbors.len()).sum();
1105        sum / 2 // each edge counted twice
1106    }
1107
1108    /// Total arc-length of the skeleton.
1109    pub fn total_length(&self) -> f64 {
1110        let mut len = 0.0;
1111        for (i, node) in self.nodes.iter().enumerate() {
1112            for &j in &node.neighbors {
1113                if j > i {
1114                    len += dist3(node.position, self.nodes[j].position);
1115                }
1116            }
1117        }
1118        len
1119    }
1120
1121    /// Find leaf nodes (degree 1) in the skeleton.
1122    pub fn leaf_nodes(&self) -> Vec<usize> {
1123        self.nodes
1124            .iter()
1125            .enumerate()
1126            .filter(|(_, n)| n.neighbors.len() == 1)
1127            .map(|(i, _)| i)
1128            .collect()
1129    }
1130
1131    /// Find junction nodes (degree >= 3) in the skeleton.
1132    pub fn junction_nodes(&self) -> Vec<usize> {
1133        self.nodes
1134            .iter()
1135            .enumerate()
1136            .filter(|(_, n)| n.neighbors.len() >= 3)
1137            .map(|(i, _)| i)
1138            .collect()
1139    }
1140
1141    /// Prune leaf branches shorter than `min_length` from the skeleton.
1142    pub fn prune_leaves(&mut self, min_length: f64) {
1143        loop {
1144            let leaves = self.leaf_nodes();
1145            let mut pruned = false;
1146            for leaf in leaves {
1147                if self.nodes[leaf].neighbors.is_empty() {
1148                    continue;
1149                }
1150                let nbr = self.nodes[leaf].neighbors[0];
1151                let d = dist3(self.nodes[leaf].position, self.nodes[nbr].position);
1152                if d < min_length && self.nodes[nbr].neighbors.len() > 1 {
1153                    // Remove leaf
1154                    self.nodes[nbr].neighbors.retain(|&n| n != leaf);
1155                    self.nodes[leaf].neighbors.clear();
1156                    pruned = true;
1157                }
1158            }
1159            if !pruned {
1160                break;
1161            }
1162        }
1163    }
1164}
1165
1166/// Compute the Laplacian of a point set (average displacement toward neighbours).
1167///
1168/// Used in iterative Laplacian contraction for curve-skeleton extraction.
1169pub fn laplacian_smooth(
1170    points: &[[f64; 3]],
1171    adjacency: &[Vec<usize>],
1172    weight: f64,
1173) -> Vec<[f64; 3]> {
1174    let n = points.len();
1175    let mut result = vec![[0.0; 3]; n];
1176    for i in 0..n {
1177        if adjacency[i].is_empty() {
1178            result[i] = points[i];
1179            continue;
1180        }
1181        let mut avg = [0.0; 3];
1182        for &j in &adjacency[i] {
1183            avg[0] += points[j][0];
1184            avg[1] += points[j][1];
1185            avg[2] += points[j][2];
1186        }
1187        let k = adjacency[i].len() as f64;
1188        avg[0] /= k;
1189        avg[1] /= k;
1190        avg[2] /= k;
1191        result[i][0] = points[i][0] + weight * (avg[0] - points[i][0]);
1192        result[i][1] = points[i][1] + weight * (avg[1] - points[i][1]);
1193        result[i][2] = points[i][2] + weight * (avg[2] - points[i][2]);
1194    }
1195    result
1196}
1197
1198// ─── Voronoi-based medial axis approximation ────────────────────────────────
1199
1200/// Approximate the 2-D medial axis using Voronoi vertices of a dense boundary
1201/// sampling, then filter by inscribed-circle radius.
1202///
1203/// Only keeps Voronoi vertices whose distance to the boundary is at least
1204/// `min_radius`.
1205pub fn voronoi_medial_axis_2d(
1206    boundary: &[[f64; 2]],
1207    bbox: [f64; 4],
1208    min_radius: f64,
1209) -> Vec<([f64; 2], f64)> {
1210    let vertices = Voronoi2dSkeleton::compute(boundary, bbox);
1211    let mut result = Vec::new();
1212    for v in &vertices {
1213        let r = point_to_polygon_dist(*v, boundary);
1214        if r >= min_radius {
1215            result.push((*v, r));
1216        }
1217    }
1218    result
1219}
1220
1221// ─── Hausdorff distance between two medial axes ──────────────────────────────
1222
1223/// Compute the one-directional Hausdorff distance from medial axis `a` to `b`.
1224///
1225/// max over points in a of (min over points in b of dist(a_i, b_j)).
1226pub fn directed_hausdorff(a: &MedialAxis, b: &MedialAxis) -> f64 {
1227    if a.points.is_empty() || b.points.is_empty() {
1228        return f64::INFINITY;
1229    }
1230    let mut max_d = 0.0_f64;
1231    for pa in &a.points {
1232        let min_d = b
1233            .points
1234            .iter()
1235            .map(|pb| dist3(pa.position, pb.position))
1236            .fold(f64::INFINITY, f64::min);
1237        if min_d > max_d {
1238            max_d = min_d;
1239        }
1240    }
1241    max_d
1242}
1243
1244/// Symmetric Hausdorff distance between two medial axes.
1245pub fn hausdorff_distance(a: &MedialAxis, b: &MedialAxis) -> f64 {
1246    directed_hausdorff(a, b).max(directed_hausdorff(b, a))
1247}
1248
1249// ─── Tests ───────────────────────────────────────────────────────────────────
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    // Helper: unit cube boundary points
1256    fn cube_boundary() -> Vec<[f64; 3]> {
1257        let mut pts = vec![];
1258        for &x in &[-1.0, 1.0] {
1259            for &y in &[-1.0, 1.0] {
1260                for &z in &[-1.0, 1.0] {
1261                    pts.push([x, y, z]);
1262                }
1263            }
1264        }
1265        pts
1266    }
1267
1268    // Helper: unit square polygon
1269    fn unit_square() -> Vec<[f64; 2]> {
1270        vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
1271    }
1272
1273    #[test]
1274    fn test_medial_axis_from_empty() {
1275        let ma = MedialAxis::from_points(&[], SkeletonMethod::BallRolling);
1276        assert_eq!(ma.points.len(), 0);
1277        assert_eq!(ma.branch_count(), 0);
1278    }
1279
1280    #[test]
1281    fn test_medial_axis_from_cube() {
1282        let pts = cube_boundary();
1283        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1284        assert!(!ma.points.is_empty());
1285        assert!(ma.branch_count() >= 1);
1286    }
1287
1288    #[test]
1289    fn test_medial_axis_total_length_positive() {
1290        let pts = cube_boundary();
1291        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Thinning);
1292        assert!(ma.total_length() > 0.0);
1293    }
1294
1295    #[test]
1296    fn test_medial_axis_is_tubular() {
1297        // Build a synthetic tube-like boundary
1298        let mut pts = vec![];
1299        for i in 0..20 {
1300            let x = i as f64 * 0.5;
1301            for &(dy, dz) in &[(0.5, 0.0), (-0.5, 0.0), (0.0, 0.5), (0.0, -0.5)] {
1302                pts.push([x, dy, dz]);
1303            }
1304        }
1305        let ma = MedialAxis::from_points(&pts, SkeletonMethod::MedialSurface);
1306        // tubularity check should be true for this cylinder-like set
1307        let _ = ma.is_tubular(); // just ensure no panic
1308    }
1309
1310    #[test]
1311    fn test_medial_axis_single_point() {
1312        let pts = vec![[0.0, 0.0, 0.0]];
1313        let ma = MedialAxis::from_points(&pts, SkeletonMethod::BallRolling);
1314        assert_eq!(ma.points.len(), 5); // single-point set still generates axis
1315    }
1316
1317    #[test]
1318    fn test_skeleton_method_variants() {
1319        let methods = [
1320            SkeletonMethod::BallRolling,
1321            SkeletonMethod::Thinning,
1322            SkeletonMethod::Voronoi,
1323            SkeletonMethod::MedialSurface,
1324        ];
1325        for m in methods {
1326            let pts = cube_boundary();
1327            let ma = MedialAxis::from_points(&pts, m);
1328            assert!(ma.total_length() >= 0.0);
1329        }
1330    }
1331
1332    #[test]
1333    fn test_voronoi2d_skeleton_empty() {
1334        let pts: Vec<[f64; 2]> = vec![];
1335        let res = Voronoi2dSkeleton::compute(&pts, [0.0, 0.0, 1.0, 1.0]);
1336        assert!(res.is_empty());
1337    }
1338
1339    #[test]
1340    fn test_voronoi2d_skeleton_two_points() {
1341        let pts = vec![[0.0, 0.0], [1.0, 0.0]];
1342        let res = Voronoi2dSkeleton::compute(&pts, [0.0, 0.0, 1.0, 1.0]);
1343        assert!(res.is_empty()); // need at least 3
1344    }
1345
1346    #[test]
1347    fn test_voronoi2d_skeleton_four_points() {
1348        let pts = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1349        let res = Voronoi2dSkeleton::compute(&pts, [-0.5, -0.5, 1.5, 1.5]);
1350        // Should produce at least some circumcentres
1351        assert!(!res.is_empty());
1352    }
1353
1354    #[test]
1355    fn test_voronoi2d_skeleton_inside_bbox() {
1356        let pts = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]];
1357        let res = Voronoi2dSkeleton::compute(&pts, [-10.0, -10.0, 10.0, 10.0]);
1358        for p in &res {
1359            assert!(p[0] >= -10.0 && p[0] <= 10.0);
1360            assert!(p[1] >= -10.0 && p[1] <= 10.0);
1361        }
1362    }
1363
1364    #[test]
1365    fn test_straight_skeleton_new() {
1366        let sk = StraightSkeleton::new(unit_square());
1367        let edges = sk.skeleton_edges();
1368        assert_eq!(edges.len(), 4);
1369    }
1370
1371    #[test]
1372    fn test_straight_skeleton_empty_polygon() {
1373        let sk = StraightSkeleton::new(vec![]);
1374        assert!(sk.skeleton_edges().is_empty());
1375        assert!(sk.compute_offsets(0.1).is_empty());
1376    }
1377
1378    #[test]
1379    fn test_straight_skeleton_offset_count() {
1380        let sk = StraightSkeleton::new(unit_square());
1381        let offsets = sk.compute_offsets(0.1);
1382        assert_eq!(offsets.len(), 1);
1383        assert_eq!(offsets[0].len(), 4);
1384    }
1385
1386    #[test]
1387    fn test_straight_skeleton_edges_toward_centroid() {
1388        let sq = unit_square();
1389        let sk = StraightSkeleton::new(sq.clone());
1390        let edges = sk.skeleton_edges();
1391        // All edges should end at centroid [0.5, 0.5]
1392        for (_, end) in &edges {
1393            assert!((end[0] - 0.5).abs() < 1e-10);
1394            assert!((end[1] - 0.5).abs() < 1e-10);
1395        }
1396    }
1397
1398    #[test]
1399    fn test_point_to_segment_dist_endpoint() {
1400        let p = [0.0, 0.0, 0.0];
1401        let a = [1.0, 0.0, 0.0];
1402        let b = [2.0, 0.0, 0.0];
1403        let d = point_to_segment_dist(p, a, b);
1404        assert!((d - 1.0).abs() < 1e-10);
1405    }
1406
1407    #[test]
1408    fn test_point_to_segment_dist_perpendicular() {
1409        let p = [1.0, 1.0, 0.0];
1410        let a = [0.0, 0.0, 0.0];
1411        let b = [2.0, 0.0, 0.0];
1412        let d = point_to_segment_dist(p, a, b);
1413        assert!((d - 1.0).abs() < 1e-10);
1414    }
1415
1416    #[test]
1417    fn test_point_to_segment_dist_degenerate() {
1418        let p = [3.0, 4.0, 0.0];
1419        let a = [0.0, 0.0, 0.0];
1420        let b = [0.0, 0.0, 0.0]; // degenerate segment
1421        let d = point_to_segment_dist(p, a, b);
1422        assert!((d - 5.0).abs() < 1e-10);
1423    }
1424
1425    #[test]
1426    fn test_point_to_polygon_dist_square() {
1427        let poly = unit_square();
1428        let p = [0.5, 0.5];
1429        let d = point_to_polygon_dist(p, &poly);
1430        assert!((d - 0.5).abs() < 1e-10);
1431    }
1432
1433    #[test]
1434    fn test_point_to_polygon_dist_empty() {
1435        let d = point_to_polygon_dist([0.0, 0.0], &[]);
1436        assert!(d.is_infinite());
1437    }
1438
1439    #[test]
1440    fn test_is_inside_polygon_center() {
1441        let poly = unit_square();
1442        assert!(is_inside_polygon([0.5, 0.5], &poly));
1443    }
1444
1445    #[test]
1446    fn test_is_inside_polygon_outside() {
1447        let poly = unit_square();
1448        assert!(!is_inside_polygon([2.0, 2.0], &poly));
1449    }
1450
1451    #[test]
1452    fn test_is_inside_polygon_too_few_vertices() {
1453        let poly = vec![[0.0, 0.0], [1.0, 0.0]];
1454        assert!(!is_inside_polygon([0.5, 0.0], &poly));
1455    }
1456
1457    #[test]
1458    fn test_centerline_extraction_basic() {
1459        let mut pts = vec![];
1460        for i in 0..10 {
1461            let x = i as f64;
1462            pts.push([x, 1.0, 0.0]);
1463            pts.push([x, -1.0, 0.0]);
1464            pts.push([x, 0.0, 1.0]);
1465            pts.push([x, 0.0, -1.0]);
1466        }
1467        let cl = CenterlineExtraction::extract_centerline(&pts, 5);
1468        assert_eq!(cl.len(), 5);
1469    }
1470
1471    #[test]
1472    fn test_centerline_extraction_empty() {
1473        let cl = CenterlineExtraction::extract_centerline(&[], 5);
1474        assert!(cl.is_empty());
1475    }
1476
1477    #[test]
1478    fn test_centerline_extraction_zero_slices() {
1479        let pts = vec![[0.0, 0.0, 0.0]];
1480        let cl = CenterlineExtraction::extract_centerline(&pts, 0);
1481        assert!(cl.is_empty());
1482    }
1483
1484    #[test]
1485    fn test_prune_short_branches() {
1486        let pts = cube_boundary();
1487        let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1488        let before = ma.branch_count();
1489        prune_short_branches(&mut ma, 1e12); // prune everything
1490        assert!(ma.branch_count() <= before);
1491    }
1492
1493    #[test]
1494    fn test_prune_keeps_long_branches() {
1495        let pts = cube_boundary();
1496        let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1497        prune_short_branches(&mut ma, 0.0); // keep all
1498        assert!(ma.branch_count() >= 1);
1499    }
1500
1501    #[test]
1502    fn test_euler_characteristic() {
1503        let pts = cube_boundary();
1504        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1505        let chi = euler_characteristic(&ma);
1506        // For a path graph V=5, E=4, chi = 1 (tree)
1507        assert_eq!(chi, 1);
1508    }
1509
1510    #[test]
1511    fn test_axis_aligned_slab_decompose() {
1512        let pts = cube_boundary();
1513        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1514        let slabs = AxisAlignedSlab::decompose(&ma, 3);
1515        assert_eq!(slabs.len(), 3);
1516        let total_pts: usize = slabs.iter().map(|s| s.point_indices.len()).sum();
1517        assert_eq!(total_pts, ma.points.len());
1518    }
1519
1520    #[test]
1521    fn test_axis_aligned_slab_zero_slabs() {
1522        let pts = cube_boundary();
1523        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1524        let slabs = AxisAlignedSlab::decompose(&ma, 0);
1525        assert!(slabs.is_empty());
1526    }
1527
1528    #[test]
1529    fn test_shape_diameter_positive() {
1530        let d = shape_diameter([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.0);
1531        assert!((d - 2.0).abs() < 1e-10);
1532    }
1533
1534    #[test]
1535    fn test_shape_diameter_scales_with_radius() {
1536        let d1 = shape_diameter([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0);
1537        let d2 = shape_diameter([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 2.0);
1538        assert!((d2 - 2.0 * d1).abs() < 1e-10);
1539    }
1540
1541    // ─── Distance transform tests ───────────────────────────────────────
1542
1543    fn make_square_image(size: usize) -> BinaryImage {
1544        let mut img = BinaryImage::new(size, size, false);
1545        let margin = size / 4;
1546        for r in margin..(size - margin) {
1547            for c in margin..(size - margin) {
1548                img.set(c, r, true);
1549            }
1550        }
1551        img
1552    }
1553
1554    #[test]
1555    fn test_binary_image_basic() {
1556        let img = BinaryImage::new(10, 10, false);
1557        assert_eq!(img.foreground_count(), 0);
1558        assert!(!img.get(0, 0));
1559    }
1560
1561    #[test]
1562    fn test_distance_transform_background() {
1563        let img = BinaryImage::new(10, 10, false);
1564        let dt = distance_transform(&img);
1565        assert!(dt.iter().all(|&v| v == 0.0));
1566    }
1567
1568    #[test]
1569    fn test_distance_transform_foreground() {
1570        let img = make_square_image(20);
1571        let dt = distance_transform(&img);
1572        // Centre pixel should have maximum distance
1573        let center = 10 * 20 + 10;
1574        assert!(dt[center] > 0.0, "Centre distance should be > 0");
1575        // Corner (background) should be 0
1576        assert!((dt[0] - 0.0).abs() < 1e-10);
1577    }
1578
1579    #[test]
1580    fn test_distance_transform_exact_background() {
1581        let img = BinaryImage::new(10, 10, false);
1582        let dt = distance_transform_exact(&img);
1583        assert!(dt.iter().all(|&v| v == 0.0));
1584    }
1585
1586    #[test]
1587    fn test_distance_transform_exact_foreground() {
1588        let img = make_square_image(20);
1589        let dt = distance_transform_exact(&img);
1590        let center = 10 * 20 + 10;
1591        assert!(dt[center] > 0.0);
1592    }
1593
1594    // ─── Thinning tests ────────────────────────────────────────────────
1595
1596    #[test]
1597    fn test_zhang_suen_reduces_pixels() {
1598        let mut img = make_square_image(20);
1599        let before = img.foreground_count();
1600        zhang_suen_thinning(&mut img);
1601        let after = img.foreground_count();
1602        assert!(after < before, "Thinning should reduce pixel count");
1603        assert!(after > 0, "Thinning should leave a skeleton");
1604    }
1605
1606    #[test]
1607    fn test_zhang_suen_empty_image() {
1608        let mut img = BinaryImage::new(10, 10, false);
1609        zhang_suen_thinning(&mut img);
1610        assert_eq!(img.foreground_count(), 0);
1611    }
1612
1613    #[test]
1614    fn test_skeleton_pixels_from_thinned() {
1615        let mut img = make_square_image(20);
1616        zhang_suen_thinning(&mut img);
1617        let pixels = skeleton_pixels(&img);
1618        assert!(!pixels.is_empty());
1619        assert_eq!(pixels.len(), img.foreground_count());
1620    }
1621
1622    // ─── MAT tests ─────────────────────────────────────────────────────
1623
1624    #[test]
1625    fn test_mat_has_positive_radii() {
1626        let img = make_square_image(20);
1627        let mat = medial_axis_transform(&img);
1628        assert!(!mat.is_empty(), "MAT should have points");
1629        for mp in &mat {
1630            assert!(mp.radius >= 0.0, "Radius should be non-negative");
1631        }
1632    }
1633
1634    #[test]
1635    fn test_reconstruct_from_mat() {
1636        let img = make_square_image(20);
1637        let mat = medial_axis_transform(&img);
1638        let recon = reconstruct_from_mat(&mat, 20, 20);
1639        assert!(
1640            recon.foreground_count() > 0,
1641            "Reconstructed image should not be empty"
1642        );
1643    }
1644
1645    // ─── Significance pruning tests ─────────────────────────────────────
1646
1647    #[test]
1648    fn test_branch_significance() {
1649        let pts = cube_boundary();
1650        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1651        let sig = branch_significance(&ma, 0);
1652        assert!(sig > 0.0, "Significance should be positive");
1653    }
1654
1655    #[test]
1656    fn test_prune_by_significance() {
1657        let pts = cube_boundary();
1658        let mut ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1659        prune_by_significance(&mut ma, 1e12); // very high threshold => prune all
1660        assert!(
1661            ma.branches.is_empty()
1662                || ma.branches.iter().all(|b| {
1663                    let _ = b;
1664                    true // just check no panic
1665                })
1666        );
1667    }
1668
1669    // ─── Radius function tests ──────────────────────────────────────────
1670
1671    #[test]
1672    fn test_radius_function() {
1673        let pts = cube_boundary();
1674        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1675        let rf = radius_function(&ma, 0);
1676        assert!(!rf.is_empty());
1677        // Arc lengths should be non-decreasing
1678        for w in rf.windows(2) {
1679            assert!(w[1].0 >= w[0].0, "Arc length should be non-decreasing");
1680        }
1681    }
1682
1683    #[test]
1684    fn test_average_and_max_radius() {
1685        let pts = cube_boundary();
1686        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1687        let avg = average_radius(&ma, 0);
1688        let mx = max_radius(&ma, 0);
1689        assert!(avg > 0.0);
1690        assert!(mx >= avg, "Max radius should be >= average");
1691    }
1692
1693    // ─── Shape decomposition tests ──────────────────────────────────────
1694
1695    #[test]
1696    fn test_decompose_from_skeleton() {
1697        let pts = cube_boundary();
1698        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1699        let parts = decompose_from_skeleton(&ma);
1700        assert!(!parts.is_empty());
1701        let vol = total_decomposed_volume(&parts);
1702        assert!(vol > 0.0, "Total volume should be positive");
1703    }
1704
1705    // ─── Curve skeleton tests ───────────────────────────────────────────
1706
1707    #[test]
1708    fn test_curve_skeleton_from_tube() {
1709        let mut pts = vec![];
1710        for i in 0..20 {
1711            let x = i as f64 * 0.5;
1712            for &(dy, dz) in &[(0.5, 0.0), (-0.5, 0.0), (0.0, 0.5), (0.0, -0.5)] {
1713                pts.push([x, dy, dz]);
1714            }
1715        }
1716        let cs = CurveSkeleton::from_surface_points(&pts, 5);
1717        assert!(cs.node_count() > 0);
1718        assert!(cs.total_length() > 0.0);
1719    }
1720
1721    #[test]
1722    fn test_curve_skeleton_leaf_nodes() {
1723        let pts = cube_boundary();
1724        let cs = CurveSkeleton::from_surface_points(&pts, 3);
1725        let leaves = cs.leaf_nodes();
1726        // A single-branch skeleton has exactly 2 leaves
1727        assert!(leaves.len() >= 2 || cs.node_count() <= 2);
1728    }
1729
1730    #[test]
1731    fn test_curve_skeleton_prune() {
1732        let pts = cube_boundary();
1733        let mut cs = CurveSkeleton::from_surface_points(&pts, 3);
1734        let before = cs.edge_count();
1735        cs.prune_leaves(1e12); // prune everything
1736        let after = cs.edge_count();
1737        assert!(after <= before);
1738    }
1739
1740    #[test]
1741    fn test_laplacian_smooth() {
1742        let points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1743        let adj = vec![vec![1], vec![0, 2], vec![1]];
1744        let result = laplacian_smooth(&points, &adj, 0.5);
1745        // Middle point should stay put (neighbours average to itself)
1746        assert!((result[1][0] - 1.0).abs() < 1e-10);
1747    }
1748
1749    // ─── Voronoi medial axis 2-D tests ──────────────────────────────────
1750
1751    #[test]
1752    fn test_voronoi_medial_axis_2d() {
1753        let pts = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
1754        let result = voronoi_medial_axis_2d(&pts, [-0.5, -0.5, 1.5, 1.5], 0.0);
1755        // Should find at least some vertices
1756        assert!(!result.is_empty());
1757    }
1758
1759    // ─── Hausdorff distance tests ───────────────────────────────────────
1760
1761    #[test]
1762    fn test_hausdorff_self_zero() {
1763        let pts = cube_boundary();
1764        let ma = MedialAxis::from_points(&pts, SkeletonMethod::Voronoi);
1765        let d = hausdorff_distance(&ma, &ma);
1766        assert!(d < 1e-10, "Hausdorff to self should be 0, got {:.6}", d);
1767    }
1768
1769    #[test]
1770    fn test_hausdorff_different_shapes() {
1771        let pts1 = cube_boundary();
1772        let pts2: Vec<[f64; 3]> = pts1.iter().map(|p| [p[0] + 5.0, p[1], p[2]]).collect();
1773        let ma1 = MedialAxis::from_points(&pts1, SkeletonMethod::Voronoi);
1774        let ma2 = MedialAxis::from_points(&pts2, SkeletonMethod::Voronoi);
1775        let d = hausdorff_distance(&ma1, &ma2);
1776        assert!(d > 1.0, "Shifted shapes should have Hausdorff > 1");
1777    }
1778}