Skip to main content

scirs2_vision/
pointcloud.rs

1//! 3-D Point Cloud Processing.
2//!
3//! Provides [`PointCloud3D`] — a lightweight, self-contained point cloud
4//! container with per-point normals, colours and intensities — together with
5//! the following operations:
6//!
7//! | Operation | Function / Method |
8//! |-----------|-------------------|
9//! | Statistical outlier removal | [`PointCloud3D::remove_statistical_outliers`] |
10//! | Voxel grid downsampling | [`PointCloud3D::voxel_downsample`] |
11//! | PCA normal estimation | [`PointCloud3D::estimate_normals`] |
12//! | ICP rigid registration | [`PointCloud3D::icp_align`] |
13//! | RANSAC plane fitting | [`ransac_plane_fit`] |
14//! | PLY import / export | [`PointCloud3D::to_ply_string`], [`PointCloud3D::from_ply_str`] |
15
16use crate::camera::CameraIntrinsics;
17use std::collections::HashMap;
18
19// ─────────────────────────────────────────────────────────────────────────────
20// PointCloud3D
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// A 3-D point cloud.
24///
25/// Points are stored as `[f32; 3]` (x, y, z).  Optional per-point attributes
26/// (normals, colours, intensities) are stored as parallel `Vec`s.
27#[derive(Debug, Clone)]
28pub struct PointCloud3D {
29    /// `[x, y, z]` positions.
30    pub points: Vec<[f32; 3]>,
31    /// Unit surface normals — populated by [`Self::estimate_normals`].
32    pub normals: Option<Vec<[f32; 3]>>,
33    /// Per-point RGB colour in `[0, 255]`.
34    pub colors: Option<Vec<[u8; 3]>>,
35    /// Per-point scalar intensity.
36    pub intensities: Option<Vec<f32>>,
37}
38
39impl PointCloud3D {
40    // ── Constructors ─────────────────────────────────────────────────────
41
42    /// Create a new cloud from a `Vec` of `[x, y, z]` points.
43    pub fn new(points: Vec<[f32; 3]>) -> Self {
44        Self {
45            points,
46            normals: None,
47            colors: None,
48            intensities: None,
49        }
50    }
51
52    /// Unproject a depth map into a point cloud using pinhole intrinsics.
53    ///
54    /// Pixels with depth ≤ 0 are skipped.  The resulting cloud is in the
55    /// camera frame.
56    ///
57    /// # Arguments
58    /// * `depth`      – `[row][col]` depth map in metres.
59    /// * `intrinsics` – Camera intrinsics for unprojection.
60    pub fn from_depth_map(depth: &[Vec<f32>], intrinsics: &CameraIntrinsics) -> Self {
61        let rows = depth.len();
62        let mut pts = Vec::new();
63        for (r, row) in depth.iter().enumerate() {
64            let cols = row.len();
65            for (c, &d) in row.iter().enumerate() {
66                if d <= 0.0 {
67                    continue;
68                }
69                let xn = (c as f64 - intrinsics.cx) / intrinsics.fx;
70                let yn = (r as f64 - intrinsics.cy) / intrinsics.fy;
71                let z = d as f64;
72                pts.push([(xn * z) as f32, (yn * z) as f32, z as f32]);
73            }
74            let _ = rows; // suppress unused warning
75        }
76        Self::new(pts)
77    }
78
79    // ── Accessors ─────────────────────────────────────────────────────────
80
81    /// Number of points in the cloud.
82    #[inline]
83    pub fn len(&self) -> usize {
84        self.points.len()
85    }
86
87    /// Returns `true` when the cloud contains no points.
88    #[inline]
89    pub fn is_empty(&self) -> bool {
90        self.points.is_empty()
91    }
92
93    /// Centroid (mean position) of the cloud.
94    pub fn centroid(&self) -> [f32; 3] {
95        let n = self.points.len();
96        if n == 0 {
97            return [0.0; 3];
98        }
99        let mut sum = [0.0f64; 3];
100        for p in &self.points {
101            sum[0] += p[0] as f64;
102            sum[1] += p[1] as f64;
103            sum[2] += p[2] as f64;
104        }
105        let nf = n as f64;
106        [
107            (sum[0] / nf) as f32,
108            (sum[1] / nf) as f32,
109            (sum[2] / nf) as f32,
110        ]
111    }
112
113    /// Axis-aligned bounding box: `(min_xyz, max_xyz)`.
114    pub fn bounding_box(&self) -> ([f32; 3], [f32; 3]) {
115        if self.points.is_empty() {
116            return ([0.0; 3], [0.0; 3]);
117        }
118        let mut mn = [f32::INFINITY; 3];
119        let mut mx = [f32::NEG_INFINITY; 3];
120        for p in &self.points {
121            for k in 0..3 {
122                if p[k] < mn[k] {
123                    mn[k] = p[k];
124                }
125                if p[k] > mx[k] {
126                    mx[k] = p[k];
127                }
128            }
129        }
130        (mn, mx)
131    }
132
133    // ── Outlier removal ───────────────────────────────────────────────────
134
135    /// Remove statistical outliers (SOR filter).
136    ///
137    /// Each point's mean distance to its `k` nearest neighbours is computed.
138    /// Points whose mean distance exceeds `mean + std_ratio * std_dev` of the
139    /// global distribution are removed.
140    ///
141    /// `self` is mutated in place.  All optional attribute arrays (normals,
142    /// colors, intensities) are filtered to match.
143    pub fn remove_statistical_outliers(&mut self, k: usize, std_ratio: f64) {
144        let n = self.points.len();
145        if n == 0 || k == 0 {
146            return;
147        }
148
149        // Compute mean k-NN distance for every point
150        let mut mean_dists = Vec::with_capacity(n);
151        for i in 0..n {
152            let pi = self.points[i];
153            // Collect distances to all other points, keep k smallest
154            let mut dists: Vec<f32> = (0..n)
155                .filter(|&j| j != i)
156                .map(|j| dist3(pi, self.points[j]))
157                .collect();
158            dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
159            let k_actual = k.min(dists.len());
160            let mean: f64 =
161                dists[..k_actual].iter().map(|&v| v as f64).sum::<f64>() / k_actual as f64;
162            mean_dists.push(mean);
163        }
164
165        // Global statistics
166        let global_mean = mean_dists.iter().sum::<f64>() / n as f64;
167        let variance = mean_dists
168            .iter()
169            .map(|&v| (v - global_mean).powi(2))
170            .sum::<f64>()
171            / n as f64;
172        let global_std = variance.sqrt();
173        let threshold = global_mean + std_ratio * global_std;
174
175        // Keep points below threshold
176        let keep: Vec<bool> = mean_dists.iter().map(|&d| d <= threshold).collect();
177        self.filter_by_mask(&keep);
178    }
179
180    // ── Voxel downsampling ────────────────────────────────────────────────
181
182    /// Downsample the cloud by replacing all points inside each voxel cell
183    /// with their centroid.
184    ///
185    /// Returns a new cloud.  Optional attributes are NOT preserved (they would
186    /// require averaging, which depends on the attribute semantics).
187    pub fn voxel_downsample(&self, voxel_size: f32) -> Self {
188        if self.points.is_empty() || voxel_size <= 0.0 {
189            return self.clone();
190        }
191
192        // Map voxel key → accumulated sum + count
193        let mut voxels: HashMap<(i64, i64, i64), ([f64; 3], usize)> = HashMap::new();
194
195        for p in &self.points {
196            let key = (
197                (p[0] / voxel_size).floor() as i64,
198                (p[1] / voxel_size).floor() as i64,
199                (p[2] / voxel_size).floor() as i64,
200            );
201            let entry = voxels.entry(key).or_insert(([0.0; 3], 0));
202            entry.0[0] += p[0] as f64;
203            entry.0[1] += p[1] as f64;
204            entry.0[2] += p[2] as f64;
205            entry.1 += 1;
206        }
207
208        let pts: Vec<[f32; 3]> = voxels
209            .values()
210            .map(|(sum, cnt)| {
211                let n = *cnt as f64;
212                [
213                    (sum[0] / n) as f32,
214                    (sum[1] / n) as f32,
215                    (sum[2] / n) as f32,
216                ]
217            })
218            .collect();
219
220        Self::new(pts)
221    }
222
223    // ── Normal estimation ─────────────────────────────────────────────────
224
225    /// Estimate per-point surface normals by PCA on the `k`-nearest neighbour
226    /// neighbourhood.
227    ///
228    /// The smallest eigenvector of the 3×3 covariance matrix of the
229    /// neighbourhood is taken as the normal.  Orientation is made consistent
230    /// by flipping to point "upward" (positive Z component).
231    ///
232    /// `self.normals` is populated (or replaced) in place.
233    pub fn estimate_normals(&mut self, k: usize) {
234        let n = self.points.len();
235        if n == 0 {
236            return;
237        }
238
239        let mut normals = Vec::with_capacity(n);
240
241        for i in 0..n {
242            let pi = self.points[i];
243            let neighbours = self.k_nearest_neighbors(pi, k.min(n - 1).max(1));
244
245            if neighbours.is_empty() {
246                normals.push([0.0f32, 0.0, 1.0]);
247                continue;
248            }
249
250            // Neighbourhood centroid
251            let mut cx = 0.0f64;
252            let mut cy = 0.0f64;
253            let mut cz = 0.0f64;
254            for &idx in &neighbours {
255                let q = self.points[idx];
256                cx += q[0] as f64;
257                cy += q[1] as f64;
258                cz += q[2] as f64;
259            }
260            let m = neighbours.len() as f64;
261            cx /= m;
262            cy /= m;
263            cz /= m;
264
265            // 3×3 covariance
266            let mut cov = [[0.0f64; 3]; 3];
267            for &idx in &neighbours {
268                let q = self.points[idx];
269                let dx = q[0] as f64 - cx;
270                let dy = q[1] as f64 - cy;
271                let dz = q[2] as f64 - cz;
272                let diff = [dx, dy, dz];
273                for a in 0..3 {
274                    for b in 0..3 {
275                        cov[a][b] += diff[a] * diff[b];
276                    }
277                }
278            }
279
280            let normal = smallest_eigenvector_3x3(&cov);
281            // Ensure normal points toward positive Z (viewpoint consistency)
282            let nf = if normal[2] < 0.0 {
283                [-normal[0] as f32, -normal[1] as f32, -normal[2] as f32]
284            } else {
285                [normal[0] as f32, normal[1] as f32, normal[2] as f32]
286            };
287            normals.push(nf);
288        }
289
290        self.normals = Some(normals);
291    }
292
293    // ── ICP registration ──────────────────────────────────────────────────
294
295    /// Align `source` to `target` using point-to-point ICP.
296    ///
297    /// Returns the aligned source cloud and the 4×4 cumulative transform.
298    ///
299    /// The algorithm:
300    /// 1. For each source point find the nearest target point.
301    /// 2. Compute the optimal rigid transform via SVD of the cross-covariance.
302    /// 3. Apply the transform and update the cumulative matrix.
303    /// 4. Stop when the mean correspondence distance change is < `tolerance`
304    ///    or `max_iterations` is reached.
305    pub fn icp_align(
306        source: &PointCloud3D,
307        target: &PointCloud3D,
308        max_iterations: usize,
309        tolerance: f64,
310    ) -> (PointCloud3D, [[f64; 4]; 4]) {
311        if source.is_empty() || target.is_empty() {
312            return (source.clone(), identity4());
313        }
314
315        let mut current_pts = source.points.clone();
316        let mut cumulative = identity4();
317
318        let mut prev_mean_dist = f64::INFINITY;
319
320        for _iter in 0..max_iterations {
321            // Step 1: correspondences (nearest-target for each source point)
322            let mut src_corr = Vec::with_capacity(current_pts.len());
323            let mut tgt_corr = Vec::with_capacity(current_pts.len());
324            let mut total_dist = 0.0f64;
325
326            for &sp in &current_pts {
327                let (nn_idx, dist) = nearest_in_slice(&target.points, sp);
328                src_corr.push(sp);
329                tgt_corr.push(target.points[nn_idx]);
330                total_dist += dist as f64;
331            }
332
333            let mean_dist = total_dist / current_pts.len() as f64;
334
335            // Step 2: optimal rigid transform via SVD of cross-covariance
336            let (r, t) = optimal_rigid_transform(&src_corr, &tgt_corr);
337
338            // Step 3: apply transform
339            for p in current_pts.iter_mut() {
340                *p = apply_rigid(r, t, *p);
341            }
342
343            // Accumulate
344            cumulative = compose_mat4(mat4_from_rt(r, t), cumulative);
345
346            if (prev_mean_dist - mean_dist).abs() < tolerance {
347                break;
348            }
349            prev_mean_dist = mean_dist;
350        }
351
352        let aligned = PointCloud3D::new(current_pts);
353        (aligned, cumulative)
354    }
355
356    // ── k-NN ─────────────────────────────────────────────────────────────
357
358    /// Return the indices of the `k` nearest points (excluding self).
359    pub fn k_nearest_neighbors(&self, query: [f32; 3], k: usize) -> Vec<usize> {
360        k_nearest_in_slice(&self.points, query, k)
361    }
362
363    // ── PLY I/O ───────────────────────────────────────────────────────────
364
365    /// Serialise the cloud to an ASCII PLY string.
366    pub fn to_ply_string(&self) -> String {
367        let n = self.points.len();
368        let has_normals = self.normals.as_ref().map(|v| v.len() == n).unwrap_or(false);
369        let has_colors = self.colors.as_ref().map(|v| v.len() == n).unwrap_or(false);
370
371        let mut s = String::with_capacity(512 + n * 64);
372        s.push_str("ply\nformat ascii 1.0\n");
373        s.push_str(&format!("element vertex {}\n", n));
374        s.push_str("property float x\nproperty float y\nproperty float z\n");
375        if has_normals {
376            s.push_str("property float nx\nproperty float ny\nproperty float nz\n");
377        }
378        if has_colors {
379            s.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
380        }
381        s.push_str("end_header\n");
382
383        for i in 0..n {
384            let p = self.points[i];
385            s.push_str(&format!("{} {} {}", p[0], p[1], p[2]));
386            if has_normals {
387                let nm = self
388                    .normals
389                    .as_ref()
390                    .expect("normals present - guarded by has_normals check")[i];
391                s.push_str(&format!(" {} {} {}", nm[0], nm[1], nm[2]));
392            }
393            if has_colors {
394                let c = self
395                    .colors
396                    .as_ref()
397                    .expect("colors present - guarded by has_colors check")[i];
398                s.push_str(&format!(" {} {} {}", c[0], c[1], c[2]));
399            }
400            s.push('\n');
401        }
402        s
403    }
404
405    /// Parse an ASCII PLY string into a `PointCloud3D`.
406    ///
407    /// Supports `property float x/y/z`, `property float nx/ny/nz`, and
408    /// `property uchar red/green/blue`.
409    pub fn from_ply_str(s: &str) -> Result<Self, String> {
410        let mut lines = s.lines();
411
412        // Parse header
413        let first = lines.next().ok_or("Empty PLY")?;
414        if first.trim() != "ply" {
415            return Err(format!("Not a PLY file (got '{}')", first));
416        }
417
418        let mut n_vertices: Option<usize> = None;
419        let mut has_nx = false;
420        let mut has_red = false;
421
422        loop {
423            let line = lines.next().ok_or("Truncated PLY header")?;
424            let line = line.trim();
425            if line == "end_header" {
426                break;
427            }
428            if let Some(rest) = line.strip_prefix("element vertex ") {
429                n_vertices = Some(
430                    rest.trim()
431                        .parse::<usize>()
432                        .map_err(|e| format!("Bad vertex count: {}", e))?,
433                );
434            } else if line == "property float nx" {
435                has_nx = true;
436            } else if line == "property uchar red" {
437                has_red = true;
438            }
439        }
440
441        let n_vertices = n_vertices.ok_or("Missing 'element vertex' in PLY header")?;
442        let mut points = Vec::with_capacity(n_vertices);
443        let mut normals_v = if has_nx {
444            Some(Vec::with_capacity(n_vertices))
445        } else {
446            None
447        };
448        let mut colors_v = if has_red {
449            Some(Vec::with_capacity(n_vertices))
450        } else {
451            None
452        };
453
454        for line in lines.take(n_vertices) {
455            let mut toks = line.split_whitespace();
456            let x: f32 = toks
457                .next()
458                .and_then(|v| v.parse().ok())
459                .ok_or_else(|| format!("Bad x in '{}'", line))?;
460            let y: f32 = toks
461                .next()
462                .and_then(|v| v.parse().ok())
463                .ok_or_else(|| format!("Bad y in '{}'", line))?;
464            let z: f32 = toks
465                .next()
466                .and_then(|v| v.parse().ok())
467                .ok_or_else(|| format!("Bad z in '{}'", line))?;
468            points.push([x, y, z]);
469
470            if let Some(ref mut nv) = normals_v {
471                let nx: f32 = toks
472                    .next()
473                    .and_then(|v| v.parse().ok())
474                    .ok_or_else(|| format!("Bad nx in '{}'", line))?;
475                let ny: f32 = toks
476                    .next()
477                    .and_then(|v| v.parse().ok())
478                    .ok_or_else(|| format!("Bad ny in '{}'", line))?;
479                let nz: f32 = toks
480                    .next()
481                    .and_then(|v| v.parse().ok())
482                    .ok_or_else(|| format!("Bad nz in '{}'", line))?;
483                nv.push([nx, ny, nz]);
484            }
485
486            if let Some(ref mut cv) = colors_v {
487                let r: u8 = toks
488                    .next()
489                    .and_then(|v| v.parse().ok())
490                    .ok_or_else(|| format!("Bad red in '{}'", line))?;
491                let g: u8 = toks
492                    .next()
493                    .and_then(|v| v.parse().ok())
494                    .ok_or_else(|| format!("Bad green in '{}'", line))?;
495                let b: u8 = toks
496                    .next()
497                    .and_then(|v| v.parse().ok())
498                    .ok_or_else(|| format!("Bad blue in '{}'", line))?;
499                cv.push([r, g, b]);
500            }
501        }
502
503        Ok(Self {
504            normals: normals_v,
505            colors: colors_v,
506            intensities: None,
507            points,
508        })
509    }
510
511    // ── Private helpers ───────────────────────────────────────────────────
512
513    fn filter_by_mask(&mut self, keep: &[bool]) {
514        let pts: Vec<[f32; 3]> = self
515            .points
516            .iter()
517            .zip(keep.iter())
518            .filter(|(_, &k)| k)
519            .map(|(&p, _)| p)
520            .collect();
521
522        if let Some(ref mut norms) = self.normals {
523            *norms = norms
524                .iter()
525                .zip(keep.iter())
526                .filter(|(_, &k)| k)
527                .map(|(&n, _)| n)
528                .collect();
529        }
530        if let Some(ref mut cols) = self.colors {
531            *cols = cols
532                .iter()
533                .zip(keep.iter())
534                .filter(|(_, &k)| k)
535                .map(|(&c, _)| c)
536                .collect();
537        }
538        if let Some(ref mut ints) = self.intensities {
539            *ints = ints
540                .iter()
541                .zip(keep.iter())
542                .filter(|(_, &k)| k)
543                .map(|(&iv, _)| iv)
544                .collect();
545        }
546        self.points = pts;
547    }
548}
549
550// ─────────────────────────────────────────────────────────────────────────────
551// RANSAC plane fitting
552// ─────────────────────────────────────────────────────────────────────────────
553
554/// Fit a plane to a point cloud using RANSAC.
555///
556/// # Returns
557/// `Some(([a, b, c, d], inlier_indices))` where `a·x + b·y + c·z + d = 0`
558/// and the normal `[a, b, c]` has unit length.  Returns `None` when the cloud
559/// has fewer than 3 points or RANSAC fails to find a valid hypothesis.
560///
561/// # Arguments
562/// * `cloud`              – Input point cloud.
563/// * `distance_threshold` – Points within this distance are counted as inliers.
564/// * `max_iterations`     – Number of RANSAC trials.
565///
566/// # Example
567/// ```
568/// use scirs2_vision::pointcloud::{PointCloud3D, ransac_plane_fit};
569///
570/// let pts: Vec<[f32; 3]> = (0..20).flat_map(|i| {
571///     (0..20).map(move |j| [i as f32, j as f32, 0.0f32])
572/// }).collect();
573/// let cloud = PointCloud3D::new(pts);
574/// let result = ransac_plane_fit(&cloud, 0.01, 100);
575/// assert!(result.is_some());
576/// let (plane, _inliers) = result.unwrap();
577/// // Plane should be z=0, i.e. normal ≈ (0,0,1), d ≈ 0
578/// assert!(plane[2].abs() > 0.9, "plane={:?}", plane);
579/// ```
580pub fn ransac_plane_fit(
581    cloud: &PointCloud3D,
582    distance_threshold: f32,
583    max_iterations: usize,
584) -> Option<([f32; 4], Vec<usize>)> {
585    let n = cloud.points.len();
586    if n < 3 {
587        return None;
588    }
589
590    // Simple LCG RNG (no external deps)
591    let mut rng_state = 12345u64;
592    let mut rng_next = |max: usize| -> usize {
593        rng_state = rng_state
594            .wrapping_mul(6364136223846793005)
595            .wrapping_add(1442695040888963407);
596        ((rng_state >> 33) as usize) % max
597    };
598
599    let mut best_inliers: Vec<usize> = Vec::new();
600    let mut best_plane = [0.0f32; 4];
601
602    for _ in 0..max_iterations {
603        // Sample 3 distinct points
604        let i0 = rng_next(n);
605        let mut i1 = rng_next(n);
606        while i1 == i0 {
607            i1 = rng_next(n);
608        }
609        let mut i2 = rng_next(n);
610        while i2 == i0 || i2 == i1 {
611            i2 = rng_next(n);
612        }
613
614        let p0 = cloud.points[i0];
615        let p1 = cloud.points[i1];
616        let p2 = cloud.points[i2];
617
618        // Plane normal = (p1-p0) × (p2-p0)
619        let v1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
620        let v2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
621        let nx = v1[1] * v2[2] - v1[2] * v2[1];
622        let ny = v1[2] * v2[0] - v1[0] * v2[2];
623        let nz = v1[0] * v2[1] - v1[1] * v2[0];
624        let len = (nx * nx + ny * ny + nz * nz).sqrt();
625        if len < 1e-6 {
626            continue; // Degenerate
627        }
628        let (nx, ny, nz) = (nx / len, ny / len, nz / len);
629        let d = -(nx * p0[0] + ny * p0[1] + nz * p0[2]);
630
631        // Count inliers
632        let inliers: Vec<usize> = (0..n)
633            .filter(|&i| {
634                let p = cloud.points[i];
635                (nx * p[0] + ny * p[1] + nz * p[2] + d).abs() <= distance_threshold
636            })
637            .collect();
638
639        if inliers.len() > best_inliers.len() {
640            best_inliers = inliers;
641            best_plane = [nx, ny, nz, d];
642        }
643    }
644
645    if best_inliers.is_empty() {
646        None
647    } else {
648        Some((best_plane, best_inliers))
649    }
650}
651
652// ─────────────────────────────────────────────────────────────────────────────
653// Private math helpers
654// ─────────────────────────────────────────────────────────────────────────────
655
656#[inline]
657fn dist3(a: [f32; 3], b: [f32; 3]) -> f32 {
658    let dx = a[0] - b[0];
659    let dy = a[1] - b[1];
660    let dz = a[2] - b[2];
661    (dx * dx + dy * dy + dz * dz).sqrt()
662}
663
664/// Return the index and distance of the nearest point in a slice.
665fn nearest_in_slice(pts: &[[f32; 3]], query: [f32; 3]) -> (usize, f32) {
666    let mut best_idx = 0;
667    let mut best_dist = f32::INFINITY;
668    for (i, &p) in pts.iter().enumerate() {
669        let d = dist3(p, query);
670        if d < best_dist {
671            best_dist = d;
672            best_idx = i;
673        }
674    }
675    (best_idx, best_dist)
676}
677
678/// Return indices of k nearest neighbours in a slice (excluding exact query match).
679fn k_nearest_in_slice(pts: &[[f32; 3]], query: [f32; 3], k: usize) -> Vec<usize> {
680    if pts.is_empty() || k == 0 {
681        return Vec::new();
682    }
683    let mut indexed: Vec<(usize, f32)> = pts
684        .iter()
685        .enumerate()
686        .map(|(i, &p)| (i, dist3(p, query)))
687        .collect();
688    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
689    // Skip distance-0 (the point itself)
690    indexed
691        .iter()
692        .filter(|&&(_, d)| d > 0.0)
693        .take(k)
694        .map(|&(i, _)| i)
695        .collect()
696}
697
698/// Identity 4×4 matrix.
699fn identity4() -> [[f64; 4]; 4] {
700    [
701        [1.0, 0.0, 0.0, 0.0],
702        [0.0, 1.0, 0.0, 0.0],
703        [0.0, 0.0, 1.0, 0.0],
704        [0.0, 0.0, 0.0, 1.0],
705    ]
706}
707
708fn mat4_from_rt(r: [[f64; 3]; 3], t: [f64; 3]) -> [[f64; 4]; 4] {
709    [
710        [r[0][0], r[0][1], r[0][2], t[0]],
711        [r[1][0], r[1][1], r[1][2], t[1]],
712        [r[2][0], r[2][1], r[2][2], t[2]],
713        [0.0, 0.0, 0.0, 1.0],
714    ]
715}
716
717fn compose_mat4(a: [[f64; 4]; 4], b: [[f64; 4]; 4]) -> [[f64; 4]; 4] {
718    let mut c = [[0.0f64; 4]; 4];
719    for i in 0..4 {
720        for j in 0..4 {
721            for k in 0..4 {
722                c[i][j] += a[i][k] * b[k][j];
723            }
724        }
725    }
726    c
727}
728
729fn apply_rigid(r: [[f64; 3]; 3], t: [f64; 3], p: [f32; 3]) -> [f32; 3] {
730    let x = p[0] as f64;
731    let y = p[1] as f64;
732    let z = p[2] as f64;
733    [
734        (r[0][0] * x + r[0][1] * y + r[0][2] * z + t[0]) as f32,
735        (r[1][0] * x + r[1][1] * y + r[1][2] * z + t[1]) as f32,
736        (r[2][0] * x + r[2][1] * y + r[2][2] * z + t[2]) as f32,
737    ]
738}
739
740/// Compute optimal rigid transform (R, t) minimising sum of squared distances
741/// between corresponding point sets using SVD via Jacobi iterations.
742fn optimal_rigid_transform(src: &[[f32; 3]], tgt: &[[f32; 3]]) -> ([[f64; 3]; 3], [f64; 3]) {
743    let n = src.len();
744    if n == 0 {
745        return (
746            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
747            [0.0; 3],
748        );
749    }
750
751    // Centroids
752    let mut cs = [0.0f64; 3];
753    let mut ct = [0.0f64; 3];
754    for i in 0..n {
755        for k in 0..3 {
756            cs[k] += src[i][k] as f64;
757        }
758        for k in 0..3 {
759            ct[k] += tgt[i][k] as f64;
760        }
761    }
762    let nf = n as f64;
763    for k in 0..3 {
764        cs[k] /= nf;
765        ct[k] /= nf;
766    }
767
768    // Cross-covariance H = sum( (src_i - cs)^T * (tgt_i - ct) )
769    let mut h = [[0.0f64; 3]; 3];
770    for i in 0..n {
771        let ds = [
772            src[i][0] as f64 - cs[0],
773            src[i][1] as f64 - cs[1],
774            src[i][2] as f64 - cs[2],
775        ];
776        let dt = [
777            tgt[i][0] as f64 - ct[0],
778            tgt[i][1] as f64 - ct[1],
779            tgt[i][2] as f64 - ct[2],
780        ];
781        for a in 0..3 {
782            for b in 0..3 {
783                h[a][b] += ds[a] * dt[b];
784            }
785        }
786    }
787
788    // SVD of H via Jacobi (H = U * S * V^T → R = V * U^T)
789    let (u, _s, v) = svd3x3_jacobi(h);
790    let r = mat3_mul(v, mat3_transpose(u));
791
792    // Ensure proper rotation (det = +1)
793    let r = ensure_rotation(r);
794
795    // t = ct - R * cs
796    let rcs = mat3_vec(r, cs);
797    let t = [ct[0] - rcs[0], ct[1] - rcs[1], ct[2] - rcs[2]];
798
799    (r, t)
800}
801
802/// Estimate the smallest eigenvector of a symmetric 3×3 matrix using the
803/// power iteration on the *inverse* (shifted) matrix (Jacobi eigendecomposition).
804fn smallest_eigenvector_3x3(cov: &[[f64; 3]; 3]) -> [f64; 3] {
805    // Use Jacobi eigen decomposition
806    let (_, vecs) = jacobi_eigen3(cov);
807    // Find column with smallest eigenvalue
808    // jacobi_eigen3 returns (eigenvalues, eigenvectors-as-columns)
809    let (evals, evecs) = jacobi_eigen3(cov);
810    let mut min_idx = 0;
811    let mut min_val = evals[0];
812    for (i, &ev) in evals.iter().enumerate().skip(1) {
813        if ev < min_val {
814            min_val = ev;
815            min_idx = i;
816        }
817    }
818    let _ = vecs;
819    [evecs[0][min_idx], evecs[1][min_idx], evecs[2][min_idx]]
820}
821
822/// Jacobi eigendecomposition for a symmetric 3×3 matrix.
823/// Returns (eigenvalues, eigenvectors as columns: evecs[row][col]).
824fn jacobi_eigen3(a: &[[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
825    let mut m = *a;
826    let mut v = [[1.0f64, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; // eigenvectors
827
828    for _ in 0..50 {
829        // Find largest off-diagonal element
830        let mut max_val = 0.0f64;
831        let mut p = 0usize;
832        let mut q = 1usize;
833        #[allow(clippy::needless_range_loop)]
834        for i in 0..3 {
835            for j in (i + 1)..3 {
836                if m[i][j].abs() > max_val {
837                    max_val = m[i][j].abs();
838                    p = i;
839                    q = j;
840                }
841            }
842        }
843        if max_val < 1e-12 {
844            break;
845        }
846
847        // Compute Jacobi rotation
848        let theta = (m[q][q] - m[p][p]) / (2.0 * m[p][q]);
849        let t = if theta >= 0.0 {
850            1.0 / (theta + (1.0 + theta * theta).sqrt())
851        } else {
852            1.0 / (theta - (1.0 + theta * theta).sqrt())
853        };
854        let cos = 1.0 / (1.0 + t * t).sqrt();
855        let sin = t * cos;
856        let tau = sin / (1.0 + cos);
857
858        // Update m
859        let mpq = m[p][q];
860        m[p][p] -= t * mpq;
861        m[q][q] += t * mpq;
862        m[p][q] = 0.0;
863        m[q][p] = 0.0;
864
865        #[allow(clippy::needless_range_loop)]
866        for r in 0..3 {
867            if r != p && r != q {
868                let mrp = m[r][p];
869                let mrq = m[r][q];
870                m[r][p] = mrp - sin * (mrq + tau * mrp);
871                m[p][r] = m[r][p];
872                m[r][q] = mrq + sin * (mrp - tau * mrq);
873                m[q][r] = m[r][q];
874            }
875        }
876
877        // Update eigenvectors
878        #[allow(clippy::needless_range_loop)]
879        for r in 0..3 {
880            let vp = v[r][p];
881            let vq = v[r][q];
882            v[r][p] = vp - sin * (vq + tau * vp);
883            v[r][q] = vq + sin * (vp - tau * vq);
884        }
885    }
886
887    ([m[0][0], m[1][1], m[2][2]], v)
888}
889
890/// Jacobi SVD for a general (not necessarily symmetric) 3×3 matrix.
891/// Returns (U, S_diag, V) such that A = U * diag(S) * V^T.
892fn svd3x3_jacobi(a: [[f64; 3]; 3]) -> ([[f64; 3]; 3], [f64; 3], [[f64; 3]; 3]) {
893    // Compute A^T * A (symmetric) and decompose
894    let mut ata = [[0.0f64; 3]; 3];
895    #[allow(clippy::needless_range_loop)]
896    for i in 0..3 {
897        for j in 0..3 {
898            for k in 0..3 {
899                ata[i][j] += a[k][i] * a[k][j];
900            }
901        }
902    }
903    let (evals, v) = jacobi_eigen3(&ata);
904
905    // S = sqrt(evals)  (ensure non-negative)
906    let s = [
907        evals[0].max(0.0).sqrt(),
908        evals[1].max(0.0).sqrt(),
909        evals[2].max(0.0).sqrt(),
910    ];
911
912    // U_i = A * V_i / sigma_i
913    let mut u = [[0.0f64; 3]; 3];
914    for j in 0..3 {
915        if s[j] > 1e-10 {
916            for i in 0..3 {
917                u[i][j] = (a[i][0] * v[0][j] + a[i][1] * v[1][j] + a[i][2] * v[2][j]) / s[j];
918            }
919        } else {
920            // Handle zero singular value: pick arbitrary orthogonal vector
921            if j == 0 {
922                u[0][j] = 1.0;
923            } else if j == 1 {
924                u[1][j] = 1.0;
925            } else {
926                u[2][j] = 1.0;
927            }
928        }
929    }
930
931    (u, s, v)
932}
933
934fn mat3_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
935    let mut c = [[0.0f64; 3]; 3];
936    for i in 0..3 {
937        for j in 0..3 {
938            for k in 0..3 {
939                c[i][j] += a[i][k] * b[k][j];
940            }
941        }
942    }
943    c
944}
945
946fn mat3_transpose(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
947    [
948        [m[0][0], m[1][0], m[2][0]],
949        [m[0][1], m[1][1], m[2][1]],
950        [m[0][2], m[1][2], m[2][2]],
951    ]
952}
953
954fn mat3_vec(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
955    [
956        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
957        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
958        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
959    ]
960}
961
962fn ensure_rotation(r: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
963    // det(R): if < 0, flip last column
964    let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1])
965        - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
966        + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
967    if det < 0.0 {
968        let mut r2 = r;
969        r2[0][2] = -r2[0][2];
970        r2[1][2] = -r2[1][2];
971        r2[2][2] = -r2[2][2];
972        r2
973    } else {
974        r
975    }
976}
977
978// ─────────────────────────────────────────────────────────────────────────────
979// Tests
980// ─────────────────────────────────────────────────────────────────────────────
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985
986    fn plane_cloud() -> PointCloud3D {
987        // 10×10 grid on z=0 plane
988        let pts: Vec<[f32; 3]> = (0..10)
989            .flat_map(|i| (0..10).map(move |j| [i as f32, j as f32, 0.0f32]))
990            .collect();
991        PointCloud3D::new(pts)
992    }
993
994    #[test]
995    fn test_centroid_simple() {
996        let cloud = PointCloud3D::new(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]);
997        let c = cloud.centroid();
998        assert!((c[0] - 1.0).abs() < 1e-6);
999        assert!((c[1]).abs() < 1e-6);
1000        assert!((c[2]).abs() < 1e-6);
1001    }
1002
1003    #[test]
1004    fn test_bounding_box() {
1005        let cloud = PointCloud3D::new(vec![[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]]);
1006        let (mn, mx) = cloud.bounding_box();
1007        assert!((mn[0] - (-1.0)).abs() < 1e-6);
1008        assert!((mx[0] - 1.0).abs() < 1e-6);
1009        assert!((mn[2] - (-3.0)).abs() < 1e-6);
1010        assert!((mx[2] - 3.0).abs() < 1e-6);
1011    }
1012
1013    #[test]
1014    fn test_voxel_downsample_reduces_count() {
1015        let cloud = plane_cloud();
1016        let orig_n = cloud.len();
1017        let down = cloud.voxel_downsample(2.0);
1018        assert!(
1019            down.len() < orig_n,
1020            "downsampled: {}, orig: {}",
1021            down.len(),
1022            orig_n
1023        );
1024    }
1025
1026    #[test]
1027    fn test_voxel_downsample_empty() {
1028        let cloud = PointCloud3D::new(Vec::new());
1029        let down = cloud.voxel_downsample(1.0);
1030        assert!(down.is_empty());
1031    }
1032
1033    #[test]
1034    fn test_sor_removes_outliers() {
1035        let mut cloud = plane_cloud();
1036        // Add an obvious outlier far from the plane
1037        cloud.points.push([100.0, 100.0, 100.0]);
1038        let n_before = cloud.len();
1039        cloud.remove_statistical_outliers(5, 1.0);
1040        let n_after = cloud.len();
1041        assert!(n_after < n_before, "before={}, after={}", n_before, n_after);
1042    }
1043
1044    #[test]
1045    fn test_estimate_normals() {
1046        let mut cloud = plane_cloud();
1047        cloud.estimate_normals(5);
1048        let normals = cloud
1049            .normals
1050            .as_ref()
1051            .expect("normals should be populated after estimate_normals");
1052        assert_eq!(normals.len(), cloud.len());
1053        // Z component should be dominant (plane at z=0 → normal ≈ (0,0,1))
1054        for n in normals {
1055            assert!(n[2].abs() > 0.5, "normal z component too small: {:?}", n);
1056        }
1057    }
1058
1059    #[test]
1060    fn test_ransac_plane_fit_z0() {
1061        let cloud = plane_cloud();
1062        let result = ransac_plane_fit(&cloud, 0.01, 200);
1063        assert!(result.is_some(), "RANSAC found no plane");
1064        let (plane, inliers) = result.expect("RANSAC plane fit should find a plane");
1065        // Normal z component should be dominant
1066        assert!(plane[2].abs() > 0.9, "plane={:?}", plane);
1067        // Most points should be inliers
1068        assert!(inliers.len() > cloud.len() / 2);
1069    }
1070
1071    #[test]
1072    fn test_ply_roundtrip() {
1073        let pts = vec![[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]];
1074        let cloud = PointCloud3D::new(pts);
1075        let ply = cloud.to_ply_string();
1076        let loaded =
1077            PointCloud3D::from_ply_str(&ply).expect("from_ply_str should succeed on valid PLY");
1078        assert_eq!(loaded.len(), 2);
1079        assert!((loaded.points[0][0] - 1.0).abs() < 1e-5);
1080        assert!((loaded.points[1][2] - 6.0).abs() < 1e-5);
1081    }
1082
1083    #[test]
1084    fn test_ply_with_normals_roundtrip() {
1085        let pts = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0]];
1086        let norms = vec![[0.0f32, 0.0, 1.0], [0.0, 0.0, 1.0]];
1087        let cloud = PointCloud3D {
1088            points: pts,
1089            normals: Some(norms),
1090            colors: None,
1091            intensities: None,
1092        };
1093        let ply = cloud.to_ply_string();
1094        let loaded = PointCloud3D::from_ply_str(&ply)
1095            .expect("from_ply_str should succeed on PLY with normals");
1096        assert!(loaded.normals.is_some());
1097        let nv = loaded
1098            .normals
1099            .expect("normals should be present after round-trip with normals");
1100        assert!((nv[0][2] - 1.0).abs() < 1e-5);
1101    }
1102
1103    #[test]
1104    fn test_from_depth_map() {
1105        use crate::camera::CameraIntrinsics;
1106        let intrinsics = CameraIntrinsics::ideal(100.0, 100.0, 4.0, 4.0);
1107        let depth = vec![
1108            vec![0.0f32, 0.0, 1.0, 0.0, 0.0], // only col 2 has depth
1109            vec![0.0f32; 5],
1110        ];
1111        let cloud = PointCloud3D::from_depth_map(&depth, &intrinsics);
1112        assert_eq!(cloud.len(), 1);
1113        assert!((cloud.points[0][2] - 1.0).abs() < 1e-5);
1114    }
1115
1116    #[test]
1117    fn test_icp_identity() {
1118        // Source = target → transform should be identity
1119        let cloud = plane_cloud();
1120        let (aligned, _tf) = PointCloud3D::icp_align(&cloud, &cloud, 5, 1e-6);
1121        // Aligned should be approximately the same as original
1122        for (a, b) in aligned.points.iter().zip(cloud.points.iter()) {
1123            let d = dist3(*a, *b);
1124            assert!(d < 0.5, "dist={}", d);
1125        }
1126    }
1127
1128    #[test]
1129    fn test_k_nearest_neighbors() {
1130        let cloud = PointCloud3D::new(vec![
1131            [0.0, 0.0, 0.0],
1132            [1.0, 0.0, 0.0],
1133            [2.0, 0.0, 0.0],
1134            [10.0, 0.0, 0.0],
1135        ]);
1136        let nn = cloud.k_nearest_neighbors([0.5, 0.0, 0.0], 2);
1137        assert_eq!(nn.len(), 2);
1138        // Nearest should be index 0 and 1 (distance 0.5 each from query 0.5)
1139        assert!(nn.contains(&0) || nn.contains(&1));
1140    }
1141}