Skip to main content

threecrate_algorithms/
registration.rs

1//! Registration algorithms
2
3use crate::filtering::voxel_grid_filter;
4use crate::nearest_neighbor::KdTree;
5use nalgebra::{Matrix3, Matrix6, Translation3, UnitQuaternion, Vector3, Vector6};
6use rayon::prelude::*;
7use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result, Vector3f};
8
9/// Result of ICP registration
10#[derive(Debug, Clone)]
11pub struct ICPResult {
12    /// Final transformation
13    pub transformation: Isometry3<f32>,
14    /// Final mean squared error
15    pub mse: f32,
16    /// Number of iterations performed
17    pub iterations: usize,
18    /// Whether convergence was achieved
19    pub converged: bool,
20    /// Correspondences found in the last iteration
21    pub correspondences: Vec<(usize, usize)>,
22}
23
24/// One level in a coarse-to-fine ICP pyramid.
25#[derive(Debug, Clone)]
26pub struct IcpScaleLevel {
27    /// Voxel size used to downsample source and target before this ICP stage.
28    pub voxel_size: f32,
29    /// Maximum ICP iterations for this stage.
30    pub max_iterations: usize,
31    /// Optional correspondence cutoff for this stage.
32    pub max_correspondence_distance: Option<f32>,
33}
34
35/// Coarse-to-fine point-to-point ICP configuration.
36#[derive(Debug, Clone)]
37pub struct MultiScaleIcpConfig {
38    pub levels: Vec<IcpScaleLevel>,
39    pub final_refinement_iterations: usize,
40    pub final_max_correspondence_distance: Option<f32>,
41    /// Stop when RMSE improves by less than this fraction of its previous value.
42    pub convergence_threshold: f32,
43}
44
45impl Default for MultiScaleIcpConfig {
46    fn default() -> Self {
47        Self {
48            levels: vec![
49                IcpScaleLevel {
50                    voxel_size: 0.20,
51                    max_iterations: 10,
52                    max_correspondence_distance: Some(0.50),
53                },
54                IcpScaleLevel {
55                    voxel_size: 0.10,
56                    max_iterations: 10,
57                    max_correspondence_distance: Some(0.25),
58                },
59                IcpScaleLevel {
60                    voxel_size: 0.05,
61                    max_iterations: 15,
62                    max_correspondence_distance: Some(0.15),
63                },
64            ],
65            final_refinement_iterations: 10,
66            final_max_correspondence_distance: Some(0.10),
67            convergence_threshold: 1e-5,
68        }
69    }
70}
71
72/// Find the closest point in target cloud for each point in source cloud
73#[cfg(test)]
74fn find_correspondences(
75    source: &[Point3f],
76    target: &[Point3f],
77    max_distance: Option<f32>,
78) -> Vec<Option<(usize, f32)>> {
79    match KdTree::new(target) {
80        Ok(tree) => {
81            let no_previous = vec![None; source.len()];
82            find_correspondences_seeded(
83                source,
84                &Isometry3::identity(),
85                target,
86                &tree,
87                &no_previous,
88                max_distance_sq(max_distance),
89            )
90            .into_iter()
91            .map(|c| c.map(|(idx, dist_sq)| (idx, dist_sq.sqrt())))
92            .collect()
93        }
94        Err(_) => find_correspondences_brute_force(source, target, max_distance),
95    }
96}
97
98/// Turn an optional correspondence cutoff into the exclusive squared-distance
99/// bound taken by [`KdTree::find_nearest_bounded`], keeping the original
100/// semantics: a match at exactly `max_distance` is accepted, a negative cutoff
101/// rejects every match, and `None` (or NaN, which never compared as "too far")
102/// means no limit.
103fn max_distance_sq(max_distance: Option<f32>) -> f32 {
104    match max_distance {
105        Some(d) if d < 0.0 => 0.0,
106        Some(d) if d >= 0.0 => (d * d).next_up(),
107        _ => f32::INFINITY,
108    }
109}
110
111/// Find the nearest target point for each source point after applying
112/// `transform`, in parallel.
113///
114/// `previous` holds each source point's match from the last ICP iteration.
115/// Between iterations a point moves only by the small delta transform, so its
116/// old match is almost always at or near the new nearest neighbor; seeding the
117/// kd-tree search with it lets most of the tree be pruned immediately. The
118/// result is still the exact nearest neighbor — the seed is only a bound.
119///
120/// Returns `(target_index, squared_distance)` per source point, or `None` when
121/// no target point lies within `max_dist_sq`.
122/// Fewest points one parallel ICP task handles. ICP makes light passes over the
123/// cloud many times per run; splitting them finer wakes more threads than the
124/// work is worth, which is especially slow in VMs (waking a thread there costs
125/// far more than matching a few hundred points).
126const MIN_POINTS_PER_TASK: usize = 512;
127
128fn find_correspondences_seeded(
129    source: &[Point3f],
130    transform: &Isometry3<f32>,
131    target: &[Point3f],
132    target_tree: &KdTree,
133    previous: &[Option<(usize, f32)>],
134    max_dist_sq: f32,
135) -> Vec<Option<(usize, f32)>> {
136    source
137        .par_iter()
138        .zip(previous.par_iter())
139        .with_min_len(MIN_POINTS_PER_TASK)
140        .map(|(point, prev)| {
141            let moved = transform * point;
142            let seed = prev.map(|(idx, _)| (idx, (moved - target[idx]).magnitude_squared()));
143            target_tree.find_nearest_bounded(&moved, max_dist_sq, seed)
144        })
145        .collect()
146}
147
148/// One ICP iteration's parallel work in a single pass: re-match every source
149/// point (moved by `transform`, seeded with its previous match, updated in
150/// place in `matches`) and sum the statistics for the update. Doing both in
151/// one pass halves how often worker threads are woken per iteration.
152fn match_and_collect(
153    source: &[Point3f],
154    transform: &Isometry3<f32>,
155    target: &[Point3f],
156    target_tree: &KdTree,
157    matches: &mut [Option<(usize, f32)>],
158    max_dist_sq: f32,
159) -> CorrespondenceStats {
160    source
161        .par_chunks(MIN_POINTS_PER_TASK)
162        .zip(matches.par_chunks_mut(MIN_POINTS_PER_TASK))
163        .map(|(points, slots)| {
164            let mut stats = CorrespondenceStats::zero();
165            for (point, slot) in points.iter().zip(slots) {
166                let moved = transform * point;
167                let seed = slot.map(|(idx, _)| (idx, (moved - target[idx]).magnitude_squared()));
168                *slot = target_tree.find_nearest_bounded(&moved, max_dist_sq, seed);
169                if let Some((idx, _)) = *slot {
170                    stats = stats.add(&moved, &target[idx]);
171                }
172            }
173            stats
174        })
175        .reduce(CorrespondenceStats::zero, CorrespondenceStats::merge)
176}
177
178/// Brute-force fallback used only if the KD-tree cannot be built.
179#[cfg(test)]
180fn find_correspondences_brute_force(
181    source: &[Point3f],
182    target: &[Point3f],
183    max_distance: Option<f32>,
184) -> Vec<Option<(usize, f32)>> {
185    source
186        .par_iter()
187        .map(|source_point| {
188            let mut best_distance = f32::INFINITY;
189            let mut best_idx = None;
190
191            for (target_idx, target_point) in target.iter().enumerate() {
192                let distance = (source_point - target_point).magnitude();
193
194                if distance < best_distance {
195                    best_distance = distance;
196                    best_idx = Some(target_idx);
197                }
198            }
199
200            // Filter out correspondences that are too far
201            if let Some(max_dist) = max_distance {
202                if best_distance > max_dist {
203                    return None;
204                }
205            }
206
207            best_idx.map(|idx| (idx, best_distance))
208        })
209        .collect()
210}
211
212/// Running sums over point-to-point correspondences — enough to recover both
213/// the optimal rigid transform and the MSE without materialising the matched
214/// point lists, so a whole ICP iteration reduces in one parallel pass.
215///
216/// Sums are kept in `f64`: the covariance is formed in one pass as
217/// `Σ p qᵀ − n·p̄ q̄ᵀ`, which cancels badly in `f32` on clouds of 10⁵+ points.
218#[derive(Clone, Copy)]
219struct CorrespondenceStats {
220    count: usize,
221    sum_source: Vector3<f64>,
222    sum_target: Vector3<f64>,
223    sum_outer: Matrix3<f64>,
224    sum_sq_error: f64,
225}
226
227impl CorrespondenceStats {
228    fn zero() -> Self {
229        Self {
230            count: 0,
231            sum_source: Vector3::zeros(),
232            sum_target: Vector3::zeros(),
233            sum_outer: Matrix3::zeros(),
234            sum_sq_error: 0.0,
235        }
236    }
237
238    fn add(mut self, source: &Point3f, target: &Point3f) -> Self {
239        let p = source.coords.cast::<f64>();
240        let q = target.coords.cast::<f64>();
241        self.count += 1;
242        self.sum_source += p;
243        self.sum_target += q;
244        self.sum_outer += p * q.transpose();
245        self.sum_sq_error += (p - q).norm_squared();
246        self
247    }
248
249    fn merge(self, other: Self) -> Self {
250        Self {
251            count: self.count + other.count,
252            sum_source: self.sum_source + other.sum_source,
253            sum_target: self.sum_target + other.sum_target,
254            sum_outer: self.sum_outer + other.sum_outer,
255            sum_sq_error: self.sum_sq_error + other.sum_sq_error,
256        }
257    }
258
259    /// Accumulate `source` (moved by `transform`) against its matched targets.
260    fn collect(
261        source: &[Point3f],
262        transform: &Isometry3<f32>,
263        target: &[Point3f],
264        matches: &[Option<(usize, f32)>],
265    ) -> Self {
266        source
267            .par_iter()
268            .zip(matches.par_iter())
269            .with_min_len(MIN_POINTS_PER_TASK)
270            .fold(Self::zero, |stats, (point, m)| match m {
271                Some((idx, _)) => stats.add(&(transform * point), &target[*idx]),
272                None => stats,
273            })
274            .reduce(Self::zero, Self::merge)
275    }
276
277    /// Mean squared error between corresponding points
278    fn mse(&self) -> f32 {
279        if self.count == 0 {
280            0.0
281        } else {
282            (self.sum_sq_error / self.count as f64) as f32
283        }
284    }
285
286    /// Compute the optimal transformation using SVD
287    fn transformation(&self) -> Result<Isometry3<f32>> {
288        rigid_transform_from_sums(
289            self.count as f64,
290            &self.sum_source,
291            &self.sum_target,
292            &self.sum_outer,
293        )
294    }
295}
296
297/// Best rigid transform taking a set of source points onto their matched
298/// target points (Kabsch / SVD), from running sums over the matched pairs:
299/// the pair count, the sums of source and target points, and the sum of
300/// `source * targetᵀ`. Used by the CPU ICP and by the GPU ICP in
301/// `threecrate-gpu`, which computes the sums on the GPU.
302pub fn rigid_transform_from_sums(
303    count: f64,
304    sum_source: &Vector3<f64>,
305    sum_target: &Vector3<f64>,
306    sum_outer: &Matrix3<f64>,
307) -> Result<Isometry3<f32>> {
308    if count <= 0.0 {
309        return Err(Error::InvalidData(
310            "Point correspondence mismatch".to_string(),
311        ));
312    }
313
314    let source_centroid = sum_source / count;
315    let target_centroid = sum_target / count;
316
317    // Covariance H = Σ (p − p̄)(q − q̄)ᵀ, accumulated in f64 but decomposed
318    // in f32: nalgebra's f64 SVD fails to converge on some rank-deficient
319    // matrices (e.g. [[8,8,0],[8,8,0],[0,0,0]] from collinear clouds).
320    let h: Matrix3<f32> =
321        (sum_outer - source_centroid * target_centroid.transpose() * count).cast::<f32>();
322
323    // SVD decomposition, capped so a non-converging case errors, not hangs
324    const SVD_MAX_ITERATIONS: usize = 1000;
325    let svd = h
326        .try_svd(true, true, f32::EPSILON * 5.0, SVD_MAX_ITERATIONS)
327        .ok_or_else(|| Error::Algorithm("SVD did not converge".to_string()))?;
328    let u = svd
329        .u
330        .ok_or_else(|| Error::Algorithm("SVD U matrix not available".to_string()))?;
331    let v_t = svd
332        .v_t
333        .ok_or_else(|| Error::Algorithm("SVD V^T matrix not available".to_string()))?;
334
335    // Compute rotation matrix
336    let mut r = v_t.transpose() * u.transpose();
337
338    // Ensure proper rotation (det(R) = 1)
339    if r.determinant() < 0.0 {
340        let mut v_t_corrected = v_t;
341        v_t_corrected.set_row(2, &(-v_t.row(2)));
342        r = v_t_corrected.transpose() * u.transpose();
343    }
344
345    // Convert to unit quaternion
346    let rotation = UnitQuaternion::from_matrix(&r);
347
348    // Compute translation
349    let translation = target_centroid - rotation.cast::<f64>() * source_centroid;
350
351    Ok(Isometry3::from_parts(
352        Translation3::from(translation.cast::<f32>()),
353        rotation,
354    ))
355}
356
357/// True when every coordinate of `p` is finite.
358fn is_finite_point(p: &Point3f) -> bool {
359    p.coords.iter().all(|c| c.is_finite())
360}
361
362/// Where a cloud sits and how big it is: the centroid of its finite points
363/// and their RMS distance from it. ICP uses both to judge whether an update
364/// still moves the points by a meaningful amount.
365#[derive(Debug, Clone, Copy)]
366pub struct CloudScale {
367    pub centroid: Point3f,
368    pub radius: f32,
369}
370
371impl CloudScale {
372    pub fn of(points: &[Point3f]) -> Self {
373        let (sum, sum_sq, count) = points
374            .par_iter()
375            .filter(|p| is_finite_point(p))
376            .map(|p| {
377                let v = p.coords.cast::<f64>();
378                (v, v.norm_squared(), 1usize)
379            })
380            .reduce(
381                || (Vector3::zeros(), 0.0, 0),
382                |(a, a_sq, a_n), (b, b_sq, b_n)| (a + b, a_sq + b_sq, a_n + b_n),
383            );
384        let n = count.max(1) as f64;
385        let centroid = sum / n;
386        let radius_sq = (sum_sq / n - centroid.norm_squared()).max(0.0);
387        Self {
388            centroid: Point3f::from(centroid.cast::<f32>()),
389            radius: radius_sq.sqrt() as f32,
390        }
391    }
392}
393
394/// A frame centred on a point cloud, for running registration in `f32`.
395///
396/// Far from the origin (e.g. map coordinates millions of metres away), `f32`
397/// cannot represent small moves, so ICP would stall well short of alignment.
398/// Moving the clouds next to the origin first keeps full precision; results
399/// are converted back to the original frame.
400#[derive(Debug, Clone, Copy)]
401pub struct LocalFrame {
402    /// The original-frame point that becomes this frame's origin.
403    pub origin: Vector3<f64>,
404}
405
406impl LocalFrame {
407    /// A frame centred on the mean of the finite points (a single NaN would
408    /// otherwise make every local coordinate NaN).
409    pub fn centred_on(points: &[Point3f]) -> Self {
410        let (sum, count) = points
411            .par_iter()
412            .filter(|p| is_finite_point(p))
413            .map(|p| (p.coords.cast::<f64>(), 1usize))
414            .reduce(
415                || (Vector3::zeros(), 0),
416                |(a, a_n), (b, b_n)| (a + b, a_n + b_n),
417            );
418        Self {
419            origin: sum / count.max(1) as f64,
420        }
421    }
422
423    /// A frame centred on `points`, or `None` when they are close enough to
424    /// the origin (within 100x their own size) that `f32` loses nothing.
425    /// Clouds in sensor coordinates take the `None` path and skip the copies.
426    pub fn if_far_from_origin(points: &[Point3f]) -> Option<Self> {
427        let scale = CloudScale::of(points);
428        if scale.centroid.coords.norm() <= 100.0 * scale.radius {
429            return None;
430        }
431        Some(Self::centred_on(points))
432    }
433
434    /// `p` in this frame.
435    pub fn point_to_local(&self, p: &Point3f) -> Point3f {
436        Point3f::from((p.coords.cast::<f64>() - self.origin).cast::<f32>())
437    }
438
439    /// Every point of `points` in this frame (in parallel).
440    pub fn points_to_local(&self, points: &[Point3f]) -> Vec<Point3f> {
441        points.par_iter().map(|p| self.point_to_local(p)).collect()
442    }
443
444    /// A transform given in the original frame, expressed in this frame.
445    pub fn transform_to_local(&self, transform: &Isometry3<f32>) -> Isometry3<f32> {
446        let shift = Translation3::from(self.origin);
447        (shift.inverse() * transform.cast::<f64>() * shift).cast::<f32>()
448    }
449
450    /// A transform given in this frame, expressed in the original frame.
451    pub fn transform_to_world(&self, transform: &Isometry3<f32>) -> Isometry3<f32> {
452        let shift = Translation3::from(self.origin);
453        (shift * transform.cast::<f64>() * shift.inverse()).cast::<f32>()
454    }
455}
456
457/// Decide whether ICP has converged. Both tests are relative, so they behave the
458/// same on a small indoor scan as on a large outdoor one (an absolute change in
459/// MSE does not: MSE shrinks with the square of the scene size).
460///
461/// ICP stops when either:
462/// - the RMSE improved by less than `threshold` as a fraction of its previous
463///   value, or
464/// - the last update `delta` moved the points by less than `threshold` times the
465///   cloud's size, or
466/// - the error has stopped going down and the update is about one `f32`
467///   rounding step at these coordinates. This catches near-perfect alignments,
468///   where the RMSE is down to rounding noise and its relative change never
469///   settles. It only applies once the error stops improving, so a cloud far
470///   from the origin (e.g. map coordinates) still takes every useful step.
471///
472/// `centroid` is the source centroid before `delta` was applied.
473pub fn icp_converged(
474    previous_mse: f32,
475    current_mse: f32,
476    delta: &Isometry3<f32>,
477    centroid: &Point3f,
478    radius: f32,
479    threshold: f32,
480) -> bool {
481    // RMS point motion of a rigid update: centroid shift plus rotation about it.
482    let centroid_shift = (delta * centroid - centroid).norm();
483    let rotation_shift = delta.rotation.angle() * radius;
484    let motion = centroid_shift.hypot(rotation_shift);
485    if motion <= threshold * radius {
486        return true;
487    }
488
489    if !previous_mse.is_finite() {
490        return false;
491    }
492    let previous_rmse = previous_mse.sqrt();
493    if previous_rmse == 0.0
494        || (previous_rmse - current_mse.sqrt()).abs() / previous_rmse < threshold
495    {
496        return true;
497    }
498
499    let rounding_step = 2.0 * f32::EPSILON * (centroid.coords.norm() + radius);
500    current_mse >= previous_mse && motion <= rounding_step
501}
502
503/// Flatten per-source-point matches into `(source_index, target_index)` pairs.
504fn correspondence_pairs(matches: &[Option<(usize, f32)>]) -> Vec<(usize, usize)> {
505    matches
506        .iter()
507        .enumerate()
508        .filter_map(|(src_idx, m)| m.map(|(tgt_idx, _)| (src_idx, tgt_idx)))
509        .collect()
510}
511
512/// ICP (Iterative Closest Point) registration - Main function matching requested API
513///
514/// This function performs point cloud registration using the ICP algorithm.
515///
516/// # Arguments
517/// * `source` - Source point cloud to be aligned
518/// * `target` - Target point cloud to align to
519/// * `init` - Initial transformation estimate
520/// * `max_iters` - Maximum number of iterations
521///
522/// # Returns
523/// * `Isometry3<f32>` - Final transformation that aligns source to target
524pub fn icp(
525    source: &PointCloud<Point3f>,
526    target: &PointCloud<Point3f>,
527    init: Isometry3<f32>,
528    max_iters: usize,
529) -> Isometry3<f32> {
530    match icp_detailed(source, target, init, max_iters, None, 1e-6) {
531        Ok(result) => result.transformation,
532        Err(_) => init, // Return initial transformation on error
533    }
534}
535
536/// Detailed ICP registration with comprehensive options and result
537///
538/// This function provides full control over ICP parameters and returns detailed results.
539///
540/// # Arguments
541/// * `source` - Source point cloud to be aligned
542/// * `target` - Target point cloud to align to
543/// * `init` - Initial transformation estimate
544/// * `max_iters` - Maximum number of iterations
545/// * `max_correspondence_distance` - Maximum distance for valid correspondences (None = no limit)
546/// * `convergence_threshold` - Stop when RMSE improves by less than this fraction (e.g. 1e-6)
547///
548/// # Returns
549/// * `Result<ICPResult>` - Detailed ICP result including transformation, error, and convergence info
550pub fn icp_detailed(
551    source: &PointCloud<Point3f>,
552    target: &PointCloud<Point3f>,
553    init: Isometry3<f32>,
554    max_iters: usize,
555    max_correspondence_distance: Option<f32>,
556    convergence_threshold: f32,
557) -> Result<ICPResult> {
558    if source.is_empty() || target.is_empty() {
559        return Err(Error::InvalidData(
560            "Source or target point cloud is empty".to_string(),
561        ));
562    }
563
564    if max_iters == 0 {
565        return Err(Error::InvalidData(
566            "Max iterations must be positive".to_string(),
567        ));
568    }
569
570    let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
571        return icp_point_to_point_local(
572            &source.points,
573            &target.points,
574            init,
575            max_iters,
576            max_correspondence_distance,
577            convergence_threshold,
578        );
579    };
580    let mut result = icp_point_to_point_local(
581        &frame.points_to_local(&source.points),
582        &frame.points_to_local(&target.points),
583        frame.transform_to_local(&init),
584        max_iters,
585        max_correspondence_distance,
586        convergence_threshold,
587    )?;
588    result.transformation = frame.transform_to_world(&result.transformation);
589    Ok(result)
590}
591
592/// The point-to-point ICP loop, on clouds already moved into a [`LocalFrame`].
593fn icp_point_to_point_local(
594    source: &[Point3f],
595    target: &[Point3f],
596    init: Isometry3<f32>,
597    max_iters: usize,
598    max_correspondence_distance: Option<f32>,
599    convergence_threshold: f32,
600) -> Result<ICPResult> {
601    let mut current_transform = init;
602    let mut previous_mse = f32::INFINITY;
603    let target_tree = KdTree::new(target)?;
604    let max_dist_sq = max_distance_sq(max_correspondence_distance);
605    let source_scale = CloudScale::of(source);
606    let mut matches: Vec<Option<(usize, f32)>> = vec![None; source.len()];
607
608    for iteration in 0..max_iters {
609        // Find correspondences for the source moved by the current estimate,
610        // seeded with last iteration's matches
611        let stats = match_and_collect(
612            source,
613            &current_transform,
614            target,
615            &target_tree,
616            &mut matches,
617            max_dist_sq,
618        );
619
620        if stats.count < 3 {
621            return Err(Error::Algorithm(
622                "Insufficient correspondences found".to_string(),
623            ));
624        }
625
626        // Compute transformation for this iteration
627        let delta_transform = stats.transformation()?;
628
629        // Update transformation
630        let moved_centroid = current_transform * source_scale.centroid;
631        current_transform = delta_transform * current_transform;
632
633        // MSE of this iteration's correspondences (before the update)
634        let current_mse = stats.mse();
635
636        // Check for convergence
637        if icp_converged(
638            previous_mse,
639            current_mse,
640            &delta_transform,
641            &moved_centroid,
642            source_scale.radius,
643            convergence_threshold,
644        ) {
645            return Ok(ICPResult {
646                transformation: current_transform,
647                mse: current_mse,
648                iterations: iteration + 1,
649                converged: true,
650                correspondences: correspondence_pairs(&matches),
651            });
652        }
653
654        previous_mse = current_mse;
655    }
656
657    // Re-score the last correspondences under the final transformation
658    let final_stats = CorrespondenceStats::collect(source, &current_transform, target, &matches);
659    let final_mse = if final_stats.count > 0 {
660        final_stats.mse()
661    } else {
662        previous_mse
663    };
664
665    Ok(ICPResult {
666        transformation: current_transform,
667        mse: final_mse,
668        iterations: max_iters,
669        converged: false,
670        correspondences: correspondence_pairs(&matches),
671    })
672}
673
674/// Legacy ICP function with different signature for backward compatibility
675#[deprecated(note = "Use icp instead which matches the standard API")]
676pub fn icp_legacy(
677    source: &PointCloud<Point3f>,
678    target: &PointCloud<Point3f>,
679    max_iterations: usize,
680    threshold: f32,
681) -> Result<(threecrate_core::Transform3D, f32)> {
682    let init = Isometry3::identity();
683    let result = icp_detailed(source, target, init, max_iterations, Some(threshold), 1e-6)?;
684
685    // Convert Isometry3 to Transform3D
686    let transform = threecrate_core::Transform3D::from(result.transformation);
687
688    Ok((transform, result.mse))
689}
690
691/// Compute the optimal incremental transformation using linearized point-to-plane optimization.
692///
693/// Based on Chen & Medioni (1992) - Object Modelling by Registration of Multiple Range Images.
694///
695/// Minimizes sum_i [n_i · (R*s_i + t - d_i)]^2 via small-angle linearization, building
696/// a 6x6 linear system A^T*A*x = A^T*b where x = [α, β, γ, tx, ty, tz].
697fn compute_transformation_point_to_plane(
698    source_points: &[Point3f],
699    target_points: &[Point3f],
700    target_normals: &[Vector3f],
701) -> Result<Isometry3<f32>> {
702    if source_points.len() != target_points.len()
703        || source_points.len() != target_normals.len()
704        || source_points.is_empty()
705    {
706        return Err(Error::InvalidData(
707            "Point/normal count mismatch in point-to-plane optimization".to_string(),
708        ));
709    }
710
711    let mut ata = Matrix6::<f32>::zeros();
712    let mut atb = Vector6::<f32>::zeros();
713
714    for ((src, tgt), normal) in source_points
715        .iter()
716        .zip(target_points.iter())
717        .zip(target_normals.iter())
718    {
719        // Cross product c = s × n  (rotational part of the Jacobian row)
720        let c = src.coords.cross(normal);
721
722        // Row of A: [c.x, c.y, c.z, n.x, n.y, n.z]
723        let a_row = Vector6::new(c.x, c.y, c.z, normal.x, normal.y, normal.z);
724
725        // RHS: n · (d - s)
726        let b_i = normal.dot(&(tgt.coords - src.coords));
727
728        ata += a_row * a_row.transpose();
729        atb += a_row * b_i;
730    }
731
732    // Solve with Cholesky (fast, stable when A^T*A is positive definite);
733    // fall back to LU if the system is rank-deficient.
734    let x = if let Some(chol) = ata.cholesky() {
735        chol.solve(&atb)
736    } else {
737        ata.lu().solve(&atb).ok_or_else(|| {
738            Error::Algorithm("Point-to-plane system is ill-conditioned".to_string())
739        })?
740    };
741
742    // Compose small-angle rotations Rz(γ) * Ry(β) * Rx(α)
743    let rot_x = UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), x[0]);
744    let rot_y = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), x[1]);
745    let rot_z = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), x[2]);
746    let rotation = rot_z * rot_y * rot_x;
747
748    Ok(Isometry3::from_parts(
749        Translation3::new(x[3], x[4], x[5]),
750        rotation,
751    ))
752}
753
754/// Compute mean squared point-to-plane distance for a set of correspondences.
755fn compute_point_to_plane_mse(
756    source_points: &[Point3f],
757    target_points: &[Point3f],
758    normals: &[Vector3f],
759) -> f32 {
760    if source_points.is_empty() {
761        return 0.0;
762    }
763    let sum: f32 = source_points
764        .iter()
765        .zip(target_points.iter())
766        .zip(normals.iter())
767        .map(|((src, tgt), n)| {
768            let d = n.dot(&(tgt.coords - src.coords));
769            d * d
770        })
771        .sum();
772    sum / source_points.len() as f32
773}
774
775/// Point-to-plane ICP variant (requires target normals).
776///
777/// Uses the linearized Chen & Medioni (1992) formulation: each iteration solves a 6×6
778/// linear system instead of the full SVD used by point-to-point ICP.  This typically
779/// converges faster and more accurately on smooth surfaces.
780///
781/// # Arguments
782/// * `source`          - Source point cloud to be aligned
783/// * `target`          - Target point cloud to align to
784/// * `target_normals`  - Surface normals at each target point (must equal `target.len()`)
785/// * `init`            - Initial transformation estimate
786/// * `max_iters`       - Maximum number of iterations
787///
788/// # Returns
789/// * `Result<ICPResult>` – transformation, per-iteration error, convergence flag
790pub fn icp_point_to_plane(
791    source: &PointCloud<Point3f>,
792    target: &PointCloud<Point3f>,
793    target_normals: &[Vector3f],
794    init: Isometry3<f32>,
795    max_iters: usize,
796) -> Result<ICPResult> {
797    icp_point_to_plane_detailed(source, target, target_normals, init, max_iters, None, 1e-6)
798}
799
800/// Detailed point-to-plane ICP with full parameter control.
801///
802/// # Arguments
803/// * `source`                       - Source point cloud
804/// * `target`                       - Target point cloud
805/// * `target_normals`               - Surface normals at each target point
806/// * `init`                         - Initial transformation estimate
807/// * `max_iters`                    - Maximum number of iterations
808/// * `max_correspondence_distance`  - Optional distance cutoff for correspondence rejection
809/// * `convergence_threshold`        - Stop when RMSE improves by less than this fraction
810pub fn icp_point_to_plane_detailed(
811    source: &PointCloud<Point3f>,
812    target: &PointCloud<Point3f>,
813    target_normals: &[Vector3f],
814    init: Isometry3<f32>,
815    max_iters: usize,
816    max_correspondence_distance: Option<f32>,
817    convergence_threshold: f32,
818) -> Result<ICPResult> {
819    if source.is_empty() || target.is_empty() {
820        return Err(Error::InvalidData(
821            "Source or target point cloud is empty".to_string(),
822        ));
823    }
824    if target_normals.len() != target.points.len() {
825        return Err(Error::InvalidData(
826            "target_normals length must equal the number of target points".to_string(),
827        ));
828    }
829    if max_iters == 0 {
830        return Err(Error::InvalidData(
831            "Max iterations must be positive".to_string(),
832        ));
833    }
834
835    let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
836        return icp_point_to_plane_local(
837            &source.points,
838            &target.points,
839            target_normals,
840            init,
841            max_iters,
842            max_correspondence_distance,
843            convergence_threshold,
844        );
845    };
846    let mut result = icp_point_to_plane_local(
847        &frame.points_to_local(&source.points),
848        &frame.points_to_local(&target.points),
849        target_normals,
850        frame.transform_to_local(&init),
851        max_iters,
852        max_correspondence_distance,
853        convergence_threshold,
854    )?;
855    result.transformation = frame.transform_to_world(&result.transformation);
856    Ok(result)
857}
858
859/// The point-to-plane ICP loop, on clouds already moved into a [`LocalFrame`].
860fn icp_point_to_plane_local(
861    source: &[Point3f],
862    target: &[Point3f],
863    target_normals: &[Vector3f],
864    init: Isometry3<f32>,
865    max_iters: usize,
866    max_correspondence_distance: Option<f32>,
867    convergence_threshold: f32,
868) -> Result<ICPResult> {
869    let mut current_transform = init;
870    let mut previous_mse = f32::INFINITY;
871    let mut final_correspondences: Vec<(usize, usize)> = Vec::new();
872    let target_tree = KdTree::new(target)?;
873    let max_dist_sq = max_distance_sq(max_correspondence_distance);
874    let source_scale = CloudScale::of(source);
875    let mut correspondences: Vec<Option<(usize, f32)>> = vec![None; source.len()];
876
877    for iteration in 0..max_iters {
878        // Apply current estimate to source
879        let transformed_source: Vec<Point3f> =
880            source.par_iter().map(|p| current_transform * p).collect();
881
882        // Find nearest-neighbor correspondences, seeded with last iteration's matches
883        correspondences = find_correspondences_seeded(
884            source,
885            &current_transform,
886            target,
887            &target_tree,
888            &correspondences,
889            max_dist_sq,
890        );
891
892        let mut valid_source: Vec<Point3f> = Vec::new();
893        let mut valid_target: Vec<Point3f> = Vec::new();
894        let mut valid_normals: Vec<Vector3f> = Vec::new();
895        let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
896
897        for (src_idx, corr) in correspondences.iter().enumerate() {
898            if let Some((tgt_idx, _)) = corr {
899                valid_source.push(transformed_source[src_idx]);
900                valid_target.push(target[*tgt_idx]);
901                valid_normals.push(target_normals[*tgt_idx]);
902                corr_pairs.push((src_idx, *tgt_idx));
903            }
904        }
905
906        // Need at least 6 points to solve the 6-DOF system
907        if valid_source.len() < 6 {
908            return Err(Error::Algorithm(
909                "Insufficient correspondences for point-to-plane ICP (need ≥ 6)".to_string(),
910            ));
911        }
912
913        let delta =
914            compute_transformation_point_to_plane(&valid_source, &valid_target, &valid_normals)?;
915        let moved_centroid = current_transform * source_scale.centroid;
916        current_transform = delta * current_transform;
917
918        let current_mse = compute_point_to_plane_mse(&valid_source, &valid_target, &valid_normals);
919        if icp_converged(
920            previous_mse,
921            current_mse,
922            &delta,
923            &moved_centroid,
924            source_scale.radius,
925            convergence_threshold,
926        ) {
927            return Ok(ICPResult {
928                transformation: current_transform,
929                mse: current_mse,
930                iterations: iteration + 1,
931                converged: true,
932                correspondences: corr_pairs,
933            });
934        }
935
936        previous_mse = current_mse;
937        final_correspondences = corr_pairs;
938    }
939
940    Ok(ICPResult {
941        transformation: current_transform,
942        mse: previous_mse,
943        iterations: max_iters,
944        converged: false,
945        correspondences: final_correspondences,
946    })
947}
948
949/// Point-to-point ICP registration
950///
951/// This function performs point-to-point ICP registration using Euclidean distance minimization.
952/// It finds the rigid transformation that best aligns the source point cloud to the target.
953///
954/// # Arguments
955/// * `source` - Source point cloud to be aligned
956/// * `target` - Target point cloud to align to
957/// * `init` - Initial transformation estimate (use Isometry3::identity() for no initial guess)
958/// * `max_iterations` - Maximum number of iterations to perform
959/// * `convergence_threshold` - Stop when RMSE improves by less than this fraction (default: 1e-6)
960/// * `max_correspondence_distance` - Maximum distance for valid correspondences (None = no limit)
961///
962/// # Returns
963/// * `Result<ICPResult>` - Detailed ICP result including transformation, error, and convergence info
964///
965/// # Example
966/// ```rust
967/// use threecrate_algorithms::icp_point_to_point;
968/// use threecrate_core::{PointCloud, Point3f};
969/// use nalgebra::Isometry3;
970///
971/// fn main() -> Result<(), Box<dyn std::error::Error>> {
972///     // Create source and target point clouds
973///     let mut source = PointCloud::new();
974///     let mut target = PointCloud::new();
975///     
976///     // Add some points
977///     for i in 0..10 {
978///         let point = Point3f::new(i as f32, i as f32, 0.0);
979///         source.push(point);
980///         target.push(point + Point3f::new(1.0, 0.0, 0.0).coords); // Translated by (1,0,0)
981///     }
982///     
983///     let init = Isometry3::identity();
984///     let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None)?;
985///     println!("Converged: {}, MSE: {}", result.converged, result.mse);
986///     Ok(())
987/// }
988/// ```
989pub fn icp_point_to_point(
990    source: &PointCloud<Point3f>,
991    target: &PointCloud<Point3f>,
992    init: Isometry3<f32>,
993    max_iterations: usize,
994    convergence_threshold: f32,
995    max_correspondence_distance: Option<f32>,
996) -> Result<ICPResult> {
997    // Validate inputs
998    if source.is_empty() || target.is_empty() {
999        return Err(Error::InvalidData(
1000            "Source or target point cloud is empty".to_string(),
1001        ));
1002    }
1003
1004    if max_iterations == 0 {
1005        return Err(Error::InvalidData(
1006            "Max iterations must be positive".to_string(),
1007        ));
1008    }
1009
1010    if convergence_threshold <= 0.0 {
1011        return Err(Error::InvalidData(
1012            "Convergence threshold must be positive".to_string(),
1013        ));
1014    }
1015
1016    // Use the detailed ICP implementation with point-to-point distance minimization
1017    icp_detailed(
1018        source,
1019        target,
1020        init,
1021        max_iterations,
1022        max_correspondence_distance,
1023        convergence_threshold,
1024    )
1025}
1026
1027/// Point-to-point ICP registration with default parameters
1028///
1029/// Convenience function that uses reasonable default parameters for point-to-point ICP.
1030///
1031/// # Arguments
1032/// * `source` - Source point cloud to be aligned
1033/// * `target` - Target point cloud to align to
1034/// * `init` - Initial transformation estimate
1035/// * `max_iterations` - Maximum number of iterations
1036///
1037/// # Returns
1038/// * `Result<ICPResult>` - Detailed ICP result
1039pub fn icp_point_to_point_default(
1040    source: &PointCloud<Point3f>,
1041    target: &PointCloud<Point3f>,
1042    init: Isometry3<f32>,
1043    max_iterations: usize,
1044) -> Result<ICPResult> {
1045    icp_point_to_point(source, target, init, max_iterations, 1e-6, None)
1046}
1047
1048/// Coarse-to-fine point-to-point ICP using voxel downsampling at each scale.
1049pub fn multiscale_icp_point_to_point(
1050    source: &PointCloud<Point3f>,
1051    target: &PointCloud<Point3f>,
1052    init: Isometry3<f32>,
1053    config: &MultiScaleIcpConfig,
1054) -> Result<ICPResult> {
1055    if source.is_empty() || target.is_empty() {
1056        return Err(Error::InvalidData(
1057            "Source or target point cloud is empty".to_string(),
1058        ));
1059    }
1060    if config.levels.is_empty() {
1061        return Err(Error::InvalidData(
1062            "At least one ICP scale level is required".to_string(),
1063        ));
1064    }
1065    if config.convergence_threshold <= 0.0 {
1066        return Err(Error::InvalidData(
1067            "Convergence threshold must be positive".to_string(),
1068        ));
1069    }
1070    if config.final_refinement_iterations == 0 {
1071        return Err(Error::InvalidData(
1072            "Final refinement iterations must be positive".to_string(),
1073        ));
1074    }
1075
1076    let mut current_transform = init;
1077    let mut total_iterations = 0usize;
1078    let mut last_result: Option<ICPResult> = None;
1079
1080    for level in &config.levels {
1081        if level.voxel_size <= 0.0 {
1082            return Err(Error::InvalidData(
1083                "Scale voxel_size must be positive".to_string(),
1084            ));
1085        }
1086        if level.max_iterations == 0 {
1087            return Err(Error::InvalidData(
1088                "Scale max_iterations must be positive".to_string(),
1089            ));
1090        }
1091
1092        let source_down = voxel_grid_filter(source, level.voxel_size)?;
1093        let target_down = voxel_grid_filter(target, level.voxel_size)?;
1094        if source_down.len() < 3 || target_down.len() < 3 {
1095            continue;
1096        }
1097
1098        let result = icp_point_to_point(
1099            &source_down,
1100            &target_down,
1101            current_transform,
1102            level.max_iterations,
1103            config.convergence_threshold,
1104            level.max_correspondence_distance,
1105        )?;
1106
1107        current_transform = result.transformation;
1108        total_iterations += result.iterations;
1109        last_result = Some(result);
1110    }
1111
1112    if last_result.is_none() {
1113        return Err(Error::Algorithm(
1114            "No multiscale ICP level had enough downsampled points".to_string(),
1115        ));
1116    }
1117
1118    let final_result = icp_point_to_point(
1119        source,
1120        target,
1121        current_transform,
1122        config.final_refinement_iterations,
1123        config.convergence_threshold,
1124        config.final_max_correspondence_distance,
1125    )?;
1126
1127    Ok(ICPResult {
1128        transformation: final_result.transformation,
1129        mse: final_result.mse,
1130        iterations: total_iterations + final_result.iterations,
1131        converged: final_result.converged,
1132        correspondences: final_result.correspondences,
1133    })
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    use nalgebra::UnitQuaternion;
1141
1142    #[test]
1143    fn test_icp_identity_transformation() {
1144        // Create identical point clouds
1145        let mut source = PointCloud::new();
1146        let mut target = PointCloud::new();
1147
1148        for i in 0..10 {
1149            let point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1150            source.push(point);
1151            target.push(point);
1152        }
1153
1154        let init = Isometry3::identity();
1155        let result = icp_detailed(&source, &target, init, 10, None, 1e-6).unwrap();
1156
1157        // Should converge quickly with minimal transformation
1158        assert!(result.converged);
1159        assert!(result.mse < 1e-6);
1160        assert!(result.iterations <= 3);
1161    }
1162
1163    #[test]
1164    fn test_icp_translation() {
1165        // Create source and target with known translation
1166        let mut source = PointCloud::new();
1167        let mut target = PointCloud::new();
1168
1169        let translation = Vector3f::new(1.0, 2.0, 3.0);
1170
1171        for i in 0..10 {
1172            let source_point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1173            let target_point = source_point + translation;
1174            source.push(source_point);
1175            target.push(target_point);
1176        }
1177
1178        let init = Isometry3::identity();
1179        let result = icp_detailed(&source, &target, init, 50, None, 1e-6).unwrap();
1180
1181        // Check that the computed translation is in the right direction
1182        let computed_translation = result.transformation.translation.vector;
1183        // ICP may not converge exactly due to numerical precision and algorithm limitations
1184        // The algorithm should at least move in the correct direction
1185        assert!(
1186            computed_translation.magnitude() > 0.05,
1187            "Translation magnitude too small: {}",
1188            computed_translation.magnitude()
1189        );
1190
1191        assert!(result.mse < 2.0); // Allow for higher MSE in simple test cases
1192    }
1193
1194    #[test]
1195    fn test_icp_rotation() {
1196        // Create source and target with known rotation
1197        let mut source = PointCloud::new();
1198        let mut target = PointCloud::new();
1199
1200        let rotation =
1201            UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), std::f32::consts::FRAC_PI_4);
1202
1203        for i in 0..20 {
1204            let source_point = Point3f::new(i as f32, (i % 5) as f32, 0.0);
1205            let target_point = rotation * source_point;
1206            source.push(source_point);
1207            target.push(target_point);
1208        }
1209
1210        let init = Isometry3::identity();
1211        let result = icp_detailed(&source, &target, init, 100, None, 1e-6).unwrap();
1212
1213        // Should find a reasonable transformation for rotation
1214        assert!(result.mse < 1.0, "MSE too high: {}", result.mse);
1215    }
1216
1217    #[test]
1218    fn test_icp_insufficient_points() {
1219        let mut source = PointCloud::new();
1220        let mut target = PointCloud::new();
1221
1222        source.push(Point3f::new(0.0, 0.0, 0.0));
1223        target.push(Point3f::new(1.0, 1.0, 1.0));
1224
1225        let init = Isometry3::identity();
1226        let result = icp_detailed(&source, &target, init, 10, None, 1e-6);
1227
1228        assert!(result.is_err());
1229    }
1230
1231    #[test]
1232    fn test_icp_api_compatibility() {
1233        // Test the main API function
1234        let mut source = PointCloud::new();
1235        let mut target = PointCloud::new();
1236
1237        for i in 0..5 {
1238            let point = Point3f::new(i as f32, i as f32, 0.0);
1239            source.push(point);
1240            target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1241        }
1242
1243        let init = Isometry3::identity();
1244        let transform = icp(&source, &target, init, 20);
1245
1246        // Should return a valid transformation (not panic)
1247        assert!(transform.translation.vector.magnitude() > 0.5);
1248    }
1249
1250    #[test]
1251    fn test_icp_far_from_origin_does_not_stop_early() {
1252        // Terrain-like cloud in UTM-style map coordinates (millions of metres
1253        // from the origin), turned and shifted by a few metres. ICP needs
1254        // several real steps here and must not stop before it is aligned.
1255        let origin = nalgebra::Vector3::<f64>::new(500_000.0, 4_200_000.0, 0.0);
1256        let local: Vec<nalgebra::Vector3<f64>> = (0..40)
1257            .flat_map(|i| (0..40).map(move |j| (i as f64 * 10.0, j as f64 * 10.0)))
1258            .map(|(x, y)| nalgebra::Vector3::new(x, y, 30.0 * (x / 70.0).sin() * (y / 110.0).cos()))
1259            .collect();
1260        let centre = local.iter().sum::<nalgebra::Vector3<f64>>() / local.len() as f64 + origin;
1261
1262        // Truth: turn 0.01 rad about the cloud centre, then shift (3, -2, 0.5)
1263        let truth = nalgebra::Translation3::from(centre + nalgebra::Vector3::new(3.0, -2.0, 0.5))
1264            * nalgebra::UnitQuaternion::from_euler_angles(0.0, 0.0, 0.01)
1265            * nalgebra::Translation3::from(-centre);
1266
1267        let world: Vec<nalgebra::Point3<f64>> = local
1268            .iter()
1269            .map(|v| nalgebra::Point3::from(v + origin))
1270            .collect();
1271        let source = PointCloud::from_points(world.iter().map(|p| p.cast::<f32>()).collect());
1272        let target =
1273            PointCloud::from_points(world.iter().map(|p| (truth * p).cast::<f32>()).collect());
1274
1275        let result =
1276            icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None).unwrap();
1277
1278        // Judge by where the points land (in f64), not by the translation
1279        // vector: this far from the origin, a 1e-7 rotation rounding error is
1280        // already ~0.4 m of translation, which the translation then cancels.
1281        let estimate = result.transformation.cast::<f64>();
1282        let error = world
1283            .iter()
1284            .map(|p| (estimate * p - truth * p).norm())
1285            .fold(0.0, f64::max);
1286        assert!(
1287            result.converged && error < 0.05,
1288            "points {error} m off after {} iterations (converged: {})",
1289            result.iterations,
1290            result.converged
1291        );
1292    }
1293
1294    #[test]
1295    fn test_icp_convergence_is_scale_invariant() {
1296        // The same problem at 1x and at 1/100 scale (e.g. metres vs centimetres
1297        // of scene). With an absolute MSE rule the small copy stopped early.
1298        let solve = |scale: f32| {
1299            let mut source = PointCloud::new();
1300            for i in 0..40 {
1301                for j in 0..40 {
1302                    let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
1303                    let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
1304                    source.push(Point3f::new(x, y, z) * scale);
1305                }
1306            }
1307            let truth = Isometry3::from_parts(
1308                Translation3::new(0.05 * scale, -0.03 * scale, 0.02 * scale),
1309                UnitQuaternion::from_euler_angles(0.0, 0.0, 0.03),
1310            );
1311            let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
1312            let result =
1313                icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None)
1314                    .unwrap();
1315            let rotation_error =
1316                (result.transformation.rotation.inverse() * truth.rotation).angle();
1317            let translation_error =
1318                (result.transformation.translation.vector - truth.translation.vector).norm()
1319                    / scale;
1320            (result.iterations, rotation_error, translation_error)
1321        };
1322
1323        let (iters_big, rot_big, trans_big) = solve(1.0);
1324        let (iters_small, rot_small, trans_small) = solve(0.01);
1325
1326        assert!(rot_big < 1e-3 && trans_big < 1e-3);
1327        assert!(
1328            rot_small < 1e-3 && trans_small < 1e-3,
1329            "small-scale ICP stopped early: rot {rot_small}, trans {trans_small}, {iters_small} iterations"
1330        );
1331        assert!(
1332            iters_small.abs_diff(iters_big) <= 2,
1333            "iterations: big {iters_big}, small {iters_small}"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_max_distance_cutoff_semantics() {
1339        let source = vec![Point3f::new(0.0, 0.0, 0.0)];
1340        let target = vec![Point3f::new(0.5, 0.0, 0.0)];
1341
1342        // A match exactly at the cutoff is accepted
1343        assert!(find_correspondences(&source, &target, Some(0.5))[0].is_some());
1344        // A cutoff just below the match distance rejects it
1345        assert!(find_correspondences(&source, &target, Some(0.49))[0].is_none());
1346        // A negative cutoff rejects everything rather than being squared
1347        assert!(find_correspondences(&source, &target, Some(-0.5))[0].is_none());
1348        // No cutoff accepts
1349        assert!(find_correspondences(&source, &target, None)[0].is_some());
1350
1351        // Zero cutoff still accepts exact duplicates
1352        assert!(find_correspondences(&source, &source, Some(0.0))[0].is_some());
1353    }
1354
1355    #[test]
1356    fn test_correspondence_finding() {
1357        let source = vec![
1358            Point3f::new(0.0, 0.0, 0.0),
1359            Point3f::new(1.0, 0.0, 0.0),
1360            Point3f::new(0.0, 1.0, 0.0),
1361        ];
1362
1363        let target = vec![
1364            Point3f::new(0.1, 0.1, 0.0),
1365            Point3f::new(1.1, 0.1, 0.0),
1366            Point3f::new(0.1, 1.1, 0.0),
1367        ];
1368
1369        let correspondences = find_correspondences(&source, &target, None);
1370
1371        assert_eq!(correspondences.len(), 3);
1372        assert!(correspondences[0].is_some());
1373        assert!(correspondences[1].is_some());
1374        assert!(correspondences[2].is_some());
1375    }
1376
1377    #[test]
1378    fn test_icp_point_to_point_basic() {
1379        // Test basic functionality with simple point clouds
1380        let mut source = PointCloud::new();
1381        let mut target = PointCloud::new();
1382
1383        // Create a simple cube pattern
1384        for x in 0..3 {
1385            for y in 0..3 {
1386                for z in 0..3 {
1387                    let point = Point3f::new(x as f32, y as f32, z as f32);
1388                    source.push(point);
1389                    target.push(point + Vector3f::new(1.0, 0.5, 0.25)); // Known translation
1390                }
1391            }
1392        }
1393
1394        let init = Isometry3::identity();
1395        let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1396
1397        // Should converge and find a reasonable transformation
1398        assert!(result.converged || result.iterations == 50);
1399        assert!(result.mse < 2.0); // Allow for higher MSE in simple test cases
1400                                   // The transformation should at least move in the right direction
1401        let translation_mag = result.transformation.translation.vector.magnitude();
1402        assert!(
1403            translation_mag > 0.1,
1404            "Translation magnitude too small: {}",
1405            translation_mag
1406        );
1407    }
1408
1409    #[test]
1410    fn test_icp_point_to_point_with_noise() {
1411        // Test with noisy data
1412        let mut source = PointCloud::new();
1413        let mut target = PointCloud::new();
1414
1415        let translation = Vector3f::new(2.0, 1.0, 0.5);
1416        let rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.3);
1417        let transform = Isometry3::from_parts(
1418            Translation3::new(translation.x, translation.y, translation.z),
1419            rotation,
1420        );
1421
1422        // Create source points
1423        for i in 0..100 {
1424            let angle = (i as f32) * 0.1;
1425            let radius = 2.0 + (i % 10) as f32 * 0.1;
1426            let source_point = Point3f::new(
1427                radius * angle.cos(),
1428                radius * angle.sin(),
1429                (i % 5) as f32 * 0.5,
1430            );
1431            source.push(source_point);
1432        }
1433
1434        // Create target points with known transformation + noise
1435        for point in &source.points {
1436            let transformed = transform * point;
1437            // Add some noise
1438            let noise = Vector3f::new(
1439                (rand::random::<f32>() - 0.5) * 0.1,
1440                (rand::random::<f32>() - 0.5) * 0.1,
1441                (rand::random::<f32>() - 0.5) * 0.1,
1442            );
1443            target.push(transformed + noise);
1444        }
1445
1446        let init = Isometry3::identity();
1447        let result = icp_point_to_point(&source, &target, init, 100, 1e-5, None).unwrap();
1448
1449        // Should find a reasonable transformation despite noise
1450        assert!(result.mse < 0.5); // Allow for noise
1451        assert!(result.transformation.translation.vector.magnitude() > 1.0);
1452    }
1453
1454    #[test]
1455    fn test_icp_point_to_point_known_transform() {
1456        // Test with a known transformation
1457        let mut source = PointCloud::new();
1458        let mut target = PointCloud::new();
1459
1460        // Known transformation - use smaller values for better convergence
1461        let known_translation = Vector3f::new(1.0, -0.5, 0.25);
1462        let known_rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.2);
1463        let known_transform = Isometry3::from_parts(
1464            Translation3::new(
1465                known_translation.x,
1466                known_translation.y,
1467                known_translation.z,
1468            ),
1469            known_rotation,
1470        );
1471
1472        // Create source points in a grid
1473        for x in -2..=2 {
1474            for y in -2..=2 {
1475                for z in -1..=1 {
1476                    let point = Point3f::new(x as f32, y as f32, z as f32);
1477                    source.push(point);
1478                    target.push(known_transform * point);
1479                }
1480            }
1481        }
1482
1483        let init = Isometry3::identity();
1484        let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1485
1486        // Should find a transformation close to the known one
1487        let computed_translation = result.transformation.translation.vector;
1488        let translation_error = (computed_translation - known_translation).magnitude();
1489        assert!(
1490            translation_error < 1.0,
1491            "Translation error too large: {}",
1492            translation_error
1493        );
1494
1495        assert!(result.mse < 0.5);
1496    }
1497
1498    #[test]
1499    fn test_icp_point_to_point_convergence() {
1500        // Test convergence behavior
1501        let mut source = PointCloud::new();
1502        let mut target = PointCloud::new();
1503
1504        // Create point clouds that should converge quickly
1505        for i in 0..50 {
1506            let point = Point3f::new(i as f32 * 0.1, (i * 2) as f32 * 0.1, 0.0);
1507            source.push(point);
1508            target.push(point + Vector3f::new(0.5, 0.0, 0.0));
1509        }
1510
1511        let init = Isometry3::identity();
1512        let result = icp_point_to_point(&source, &target, init, 20, 1e-6, None).unwrap();
1513
1514        // Should converge quickly
1515        assert!(result.converged);
1516        assert!(result.iterations < 20);
1517        assert!(result.mse < 0.1);
1518    }
1519
1520    #[test]
1521    fn test_icp_point_to_point_max_distance() {
1522        // Test with maximum correspondence distance
1523        let mut source = PointCloud::new();
1524        let mut target = PointCloud::new();
1525
1526        // Create source points
1527        for i in 0..10 {
1528            source.push(Point3f::new(i as f32, 0.0, 0.0));
1529        }
1530
1531        // Create target points with some far away
1532        for i in 0..10 {
1533            if i < 5 {
1534                target.push(Point3f::new(i as f32 + 0.1, 0.0, 0.0)); // Close
1535            } else {
1536                target.push(Point3f::new(i as f32 + 10.0, 0.0, 0.0)); // Far away
1537            }
1538        }
1539
1540        let init = Isometry3::identity();
1541        let result = icp_point_to_point(&source, &target, init, 20, 1e-6, Some(1.0)).unwrap();
1542
1543        // Should only use correspondences within max_distance
1544        // Note: The algorithm might still find some correspondences due to the iterative nature
1545        // but it should use fewer correspondences than without the distance limit
1546        assert!(result.correspondences.len() <= 10);
1547        assert!(result.mse < 5.0); // Allow for higher MSE when using distance filtering
1548    }
1549
1550    #[test]
1551    fn test_icp_point_to_point_default() {
1552        // Test the default convenience function
1553        let mut source = PointCloud::new();
1554        let mut target = PointCloud::new();
1555
1556        for i in 0..10 {
1557            let point = Point3f::new(i as f32, i as f32, 0.0);
1558            source.push(point);
1559            target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1560        }
1561
1562        let init = Isometry3::identity();
1563        let result = icp_point_to_point_default(&source, &target, init, 30).unwrap();
1564
1565        // Should work with default parameters
1566        assert!(result.mse < 1.0);
1567        assert!(result.transformation.translation.vector.magnitude() > 0.5);
1568    }
1569
1570    #[test]
1571    fn test_icp_point_to_point_validation() {
1572        // Test input validation
1573        let empty_source = PointCloud::new();
1574        let mut target = PointCloud::new();
1575        target.push(Point3f::new(0.0, 0.0, 0.0));
1576
1577        let init = Isometry3::identity();
1578
1579        // Test empty source
1580        let result = icp_point_to_point(&empty_source, &target, init, 10, 1e-6, None);
1581        assert!(result.is_err());
1582
1583        // Test zero iterations
1584        let result = icp_point_to_point(&target, &target, init, 0, 1e-6, None);
1585        assert!(result.is_err());
1586
1587        // Test negative convergence threshold
1588        let result = icp_point_to_point(&target, &target, init, 10, -1e-6, None);
1589        assert!(result.is_err());
1590    }
1591
1592    // ── Point-to-plane ICP tests ──────────────────────────────────────────────
1593
1594    /// Build a Fibonacci-sphere cloud with outward-pointing unit normals.
1595    ///
1596    /// A sphere is the canonical test surface for point-to-plane ICP because the
1597    /// normals span all of 3-D space, ensuring the 6×6 linear system is full rank.
1598    fn make_sphere_cloud(n: usize) -> (PointCloud<Point3f>, Vec<Vector3f>) {
1599        let mut cloud = PointCloud::new();
1600        let mut normals = Vec::new();
1601        let radius = 3.0_f32;
1602        let golden_angle = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
1603        for i in 0..n {
1604            let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
1605            let r = (1.0 - y * y).max(0.0_f32).sqrt();
1606            let theta = golden_angle * i as f32;
1607            let x = theta.cos() * r;
1608            let z = theta.sin() * r;
1609            // (x, y, z) is already a unit vector (on the unit sphere)
1610            let normal = Vector3f::new(x, y, z);
1611            cloud.push(Point3f::new(x * radius, y * radius, z * radius));
1612            normals.push(normal);
1613        }
1614        (cloud, normals)
1615    }
1616
1617    #[test]
1618    fn test_icp_point_to_plane_identity() {
1619        let (source, normals) = make_sphere_cloud(50);
1620        let target = source.clone();
1621        let init = Isometry3::identity();
1622
1623        let result = icp_point_to_plane(&source, &target, &normals, init, 20).unwrap();
1624
1625        assert!(result.converged);
1626        assert!(result.mse < 1e-6, "mse={}", result.mse);
1627    }
1628
1629    #[test]
1630    fn test_icp_point_to_plane_translation() {
1631        // Small in-plane shift so nearest-neighbor correspondences remain correct.
1632        let (source, normals) = make_sphere_cloud(100);
1633        let shift = Vector3f::new(0.15, 0.0, 0.0);
1634
1635        let mut target = PointCloud::new();
1636        for p in &source.points {
1637            target.push(p + shift);
1638        }
1639        // Reuse the source normals as approximate target normals (valid for small shift).
1640        let result =
1641            icp_point_to_plane(&source, &target, &normals, Isometry3::identity(), 50).unwrap();
1642
1643        let t_err = (result.transformation.translation.vector - shift).magnitude();
1644        assert!(t_err < 0.3, "translation error={}", t_err);
1645        assert!(result.mse < 0.1, "mse={}", result.mse);
1646    }
1647
1648    #[test]
1649    fn test_icp_point_to_plane_validation() {
1650        let (source, normals) = make_sphere_cloud(20);
1651        let init = Isometry3::identity();
1652
1653        // Normals length mismatch
1654        let bad_normals = vec![Vector3f::new(0.0, 0.0, 1.0)];
1655        let result = icp_point_to_plane(&source, &source, &bad_normals, init, 10);
1656        assert!(result.is_err());
1657
1658        // Empty source
1659        let empty: PointCloud<Point3f> = PointCloud::new();
1660        let result = icp_point_to_plane(&empty, &source, &normals, init, 10);
1661        assert!(result.is_err());
1662
1663        // Zero iterations
1664        let result = icp_point_to_plane_detailed(&source, &source, &normals, init, 0, None, 1e-6);
1665        assert!(result.is_err());
1666    }
1667
1668    #[test]
1669    fn test_icp_point_to_plane_vs_point_to_point_convergence() {
1670        // Both variants must converge to a reasonable solution for the same sphere+shift input.
1671        let (source, normals) = make_sphere_cloud(80);
1672        let shift = Vector3f::new(0.1, 0.05, 0.0);
1673        let mut target = PointCloud::new();
1674        for p in &source.points {
1675            target.push(p + shift);
1676        }
1677
1678        let init = Isometry3::identity();
1679
1680        let p2pl_result = icp_point_to_plane(&source, &target, &normals, init, 50).unwrap();
1681        let p2pt_result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1682
1683        // Both should find a non-trivial transformation
1684        assert!(
1685            p2pl_result.transformation.translation.vector.magnitude() > 0.05,
1686            "p2pl did not translate: t={}",
1687            p2pl_result.transformation.translation.vector.magnitude()
1688        );
1689        assert!(
1690            p2pt_result.transformation.translation.vector.magnitude() > 0.05,
1691            "p2pt did not translate: t={}",
1692            p2pt_result.transformation.translation.vector.magnitude()
1693        );
1694        // Point-to-plane should converge (or at least not diverge)
1695        assert!(
1696            p2pl_result.converged || p2pl_result.mse < 0.1,
1697            "p2pl failed to converge: mse={}, iters={}",
1698            p2pl_result.mse,
1699            p2pl_result.iterations
1700        );
1701    }
1702
1703    #[test]
1704    fn test_icp_point_to_plane_detailed_max_distance() {
1705        let (source, normals) = make_sphere_cloud(50);
1706        let mut target = PointCloud::new();
1707        for p in &source.points {
1708            target.push(p + Vector3f::new(0.1, 0.0, 0.0));
1709        }
1710
1711        let init = Isometry3::identity();
1712        let result =
1713            icp_point_to_plane_detailed(&source, &target, &normals, init, 30, Some(5.0), 1e-6);
1714        assert!(result.is_ok(), "unexpected error: {:?}", result.err());
1715        let result = result.unwrap();
1716        assert!(result.mse < 0.5, "mse={}", result.mse);
1717    }
1718}