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
524///
525/// # Errors
526/// Fails on empty clouds or when no correspondences are found, instead of
527/// returning `init` as if it had converged.
528pub fn icp(
529    source: &PointCloud<Point3f>,
530    target: &PointCloud<Point3f>,
531    init: Isometry3<f32>,
532    max_iters: usize,
533) -> Result<Isometry3<f32>> {
534    Ok(icp_detailed(source, target, init, max_iters, None, 1e-6)?.transformation)
535}
536
537/// Detailed ICP registration with comprehensive options and result
538///
539/// This function provides full control over ICP parameters and returns detailed results.
540///
541/// # Arguments
542/// * `source` - Source point cloud to be aligned
543/// * `target` - Target point cloud to align to
544/// * `init` - Initial transformation estimate
545/// * `max_iters` - Maximum number of iterations
546/// * `max_correspondence_distance` - Maximum distance for valid correspondences (None = no limit)
547/// * `convergence_threshold` - Stop when RMSE improves by less than this fraction (e.g. 1e-6)
548///
549/// # Returns
550/// * `Result<ICPResult>` - Detailed ICP result including transformation, error, and convergence info
551pub fn icp_detailed(
552    source: &PointCloud<Point3f>,
553    target: &PointCloud<Point3f>,
554    init: Isometry3<f32>,
555    max_iters: usize,
556    max_correspondence_distance: Option<f32>,
557    convergence_threshold: f32,
558) -> Result<ICPResult> {
559    if source.is_empty() || target.is_empty() {
560        return Err(Error::InvalidData(
561            "Source or target point cloud is empty".to_string(),
562        ));
563    }
564
565    if max_iters == 0 {
566        return Err(Error::InvalidData(
567            "Max iterations must be positive".to_string(),
568        ));
569    }
570
571    let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
572        return icp_point_to_point_local(
573            &source.points,
574            &target.points,
575            init,
576            max_iters,
577            max_correspondence_distance,
578            convergence_threshold,
579        );
580    };
581    let mut result = icp_point_to_point_local(
582        &frame.points_to_local(&source.points),
583        &frame.points_to_local(&target.points),
584        frame.transform_to_local(&init),
585        max_iters,
586        max_correspondence_distance,
587        convergence_threshold,
588    )?;
589    result.transformation = frame.transform_to_world(&result.transformation);
590    Ok(result)
591}
592
593/// The point-to-point ICP loop, on clouds already moved into a [`LocalFrame`].
594fn icp_point_to_point_local(
595    source: &[Point3f],
596    target: &[Point3f],
597    init: Isometry3<f32>,
598    max_iters: usize,
599    max_correspondence_distance: Option<f32>,
600    convergence_threshold: f32,
601) -> Result<ICPResult> {
602    let mut current_transform = init;
603    let mut previous_mse = f32::INFINITY;
604    let target_tree = KdTree::new(target)?;
605    let max_dist_sq = max_distance_sq(max_correspondence_distance);
606    let source_scale = CloudScale::of(source);
607    let mut matches: Vec<Option<(usize, f32)>> = vec![None; source.len()];
608
609    for iteration in 0..max_iters {
610        // Find correspondences for the source moved by the current estimate,
611        // seeded with last iteration's matches
612        let stats = match_and_collect(
613            source,
614            &current_transform,
615            target,
616            &target_tree,
617            &mut matches,
618            max_dist_sq,
619        );
620
621        if stats.count < 3 {
622            return Err(Error::Algorithm(
623                "Insufficient correspondences found".to_string(),
624            ));
625        }
626
627        // Compute transformation for this iteration
628        let delta_transform = stats.transformation()?;
629
630        // Update transformation
631        let moved_centroid = current_transform * source_scale.centroid;
632        current_transform = delta_transform * current_transform;
633
634        // MSE of this iteration's correspondences (before the update)
635        let current_mse = stats.mse();
636
637        // Check for convergence
638        if icp_converged(
639            previous_mse,
640            current_mse,
641            &delta_transform,
642            &moved_centroid,
643            source_scale.radius,
644            convergence_threshold,
645        ) {
646            return Ok(ICPResult {
647                transformation: current_transform,
648                mse: current_mse,
649                iterations: iteration + 1,
650                converged: true,
651                correspondences: correspondence_pairs(&matches),
652            });
653        }
654
655        previous_mse = current_mse;
656    }
657
658    // Re-score the last correspondences under the final transformation
659    let final_stats = CorrespondenceStats::collect(source, &current_transform, target, &matches);
660    let final_mse = if final_stats.count > 0 {
661        final_stats.mse()
662    } else {
663        previous_mse
664    };
665
666    Ok(ICPResult {
667        transformation: current_transform,
668        mse: final_mse,
669        iterations: max_iters,
670        converged: false,
671        correspondences: correspondence_pairs(&matches),
672    })
673}
674
675/// Legacy ICP function with different signature for backward compatibility
676#[deprecated(note = "Use icp instead which matches the standard API")]
677pub fn icp_legacy(
678    source: &PointCloud<Point3f>,
679    target: &PointCloud<Point3f>,
680    max_iterations: usize,
681    threshold: f32,
682) -> Result<(threecrate_core::Transform3D, f32)> {
683    let init = Isometry3::identity();
684    let result = icp_detailed(source, target, init, max_iterations, Some(threshold), 1e-6)?;
685
686    // Convert Isometry3 to Transform3D
687    let transform = threecrate_core::Transform3D::from(result.transformation);
688
689    Ok((transform, result.mse))
690}
691
692/// Compute the optimal incremental transformation using linearized point-to-plane optimization.
693///
694/// Based on Chen & Medioni (1992) - Object Modelling by Registration of Multiple Range Images.
695///
696/// Minimizes sum_i [n_i · (R*s_i + t - d_i)]^2 via small-angle linearization, building
697/// a 6x6 linear system A^T*A*x = A^T*b where x = [α, β, γ, tx, ty, tz].
698fn compute_transformation_point_to_plane(
699    source_points: &[Point3f],
700    target_points: &[Point3f],
701    target_normals: &[Vector3f],
702) -> Result<Isometry3<f32>> {
703    if source_points.len() != target_points.len()
704        || source_points.len() != target_normals.len()
705        || source_points.is_empty()
706    {
707        return Err(Error::InvalidData(
708            "Point/normal count mismatch in point-to-plane optimization".to_string(),
709        ));
710    }
711
712    let mut ata = Matrix6::<f32>::zeros();
713    let mut atb = Vector6::<f32>::zeros();
714
715    for ((src, tgt), normal) in source_points
716        .iter()
717        .zip(target_points.iter())
718        .zip(target_normals.iter())
719    {
720        // Cross product c = s × n  (rotational part of the Jacobian row)
721        let c = src.coords.cross(normal);
722
723        // Row of A: [c.x, c.y, c.z, n.x, n.y, n.z]
724        let a_row = Vector6::new(c.x, c.y, c.z, normal.x, normal.y, normal.z);
725
726        // RHS: n · (d - s)
727        let b_i = normal.dot(&(tgt.coords - src.coords));
728
729        ata += a_row * a_row.transpose();
730        atb += a_row * b_i;
731    }
732
733    // Solve with Cholesky (fast, stable when A^T*A is positive definite);
734    // fall back to LU if the system is rank-deficient.
735    let x = if let Some(chol) = ata.cholesky() {
736        chol.solve(&atb)
737    } else {
738        ata.lu().solve(&atb).ok_or_else(|| {
739            Error::Algorithm("Point-to-plane system is ill-conditioned".to_string())
740        })?
741    };
742
743    // Compose small-angle rotations Rz(γ) * Ry(β) * Rx(α)
744    let rot_x = UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), x[0]);
745    let rot_y = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), x[1]);
746    let rot_z = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), x[2]);
747    let rotation = rot_z * rot_y * rot_x;
748
749    Ok(Isometry3::from_parts(
750        Translation3::new(x[3], x[4], x[5]),
751        rotation,
752    ))
753}
754
755/// Compute mean squared point-to-plane distance for a set of correspondences.
756fn compute_point_to_plane_mse(
757    source_points: &[Point3f],
758    target_points: &[Point3f],
759    normals: &[Vector3f],
760) -> f32 {
761    if source_points.is_empty() {
762        return 0.0;
763    }
764    let sum: f32 = source_points
765        .iter()
766        .zip(target_points.iter())
767        .zip(normals.iter())
768        .map(|((src, tgt), n)| {
769            let d = n.dot(&(tgt.coords - src.coords));
770            d * d
771        })
772        .sum();
773    sum / source_points.len() as f32
774}
775
776/// Point-to-plane ICP variant (requires target normals).
777///
778/// Uses the linearized Chen & Medioni (1992) formulation: each iteration solves a 6×6
779/// linear system instead of the full SVD used by point-to-point ICP.  This typically
780/// converges faster and more accurately on smooth surfaces.
781///
782/// # Arguments
783/// * `source`          - Source point cloud to be aligned
784/// * `target`          - Target point cloud to align to
785/// * `target_normals`  - Surface normals at each target point (must equal `target.len()`)
786/// * `init`            - Initial transformation estimate
787/// * `max_iters`       - Maximum number of iterations
788///
789/// # Returns
790/// * `Result<ICPResult>` – transformation, per-iteration error, convergence flag
791pub fn icp_point_to_plane(
792    source: &PointCloud<Point3f>,
793    target: &PointCloud<Point3f>,
794    target_normals: &[Vector3f],
795    init: Isometry3<f32>,
796    max_iters: usize,
797) -> Result<ICPResult> {
798    icp_point_to_plane_detailed(source, target, target_normals, init, max_iters, None, 1e-6)
799}
800
801/// Detailed point-to-plane ICP with full parameter control.
802///
803/// # Arguments
804/// * `source`                       - Source point cloud
805/// * `target`                       - Target point cloud
806/// * `target_normals`               - Surface normals at each target point
807/// * `init`                         - Initial transformation estimate
808/// * `max_iters`                    - Maximum number of iterations
809/// * `max_correspondence_distance`  - Optional distance cutoff for correspondence rejection
810/// * `convergence_threshold`        - Stop when RMSE improves by less than this fraction
811pub fn icp_point_to_plane_detailed(
812    source: &PointCloud<Point3f>,
813    target: &PointCloud<Point3f>,
814    target_normals: &[Vector3f],
815    init: Isometry3<f32>,
816    max_iters: usize,
817    max_correspondence_distance: Option<f32>,
818    convergence_threshold: f32,
819) -> Result<ICPResult> {
820    if source.is_empty() || target.is_empty() {
821        return Err(Error::InvalidData(
822            "Source or target point cloud is empty".to_string(),
823        ));
824    }
825    if target_normals.len() != target.points.len() {
826        return Err(Error::InvalidData(
827            "target_normals length must equal the number of target points".to_string(),
828        ));
829    }
830    if max_iters == 0 {
831        return Err(Error::InvalidData(
832            "Max iterations must be positive".to_string(),
833        ));
834    }
835
836    let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
837        return icp_point_to_plane_local(
838            &source.points,
839            &target.points,
840            target_normals,
841            init,
842            max_iters,
843            max_correspondence_distance,
844            convergence_threshold,
845        );
846    };
847    let mut result = icp_point_to_plane_local(
848        &frame.points_to_local(&source.points),
849        &frame.points_to_local(&target.points),
850        target_normals,
851        frame.transform_to_local(&init),
852        max_iters,
853        max_correspondence_distance,
854        convergence_threshold,
855    )?;
856    result.transformation = frame.transform_to_world(&result.transformation);
857    Ok(result)
858}
859
860/// The point-to-plane ICP loop, on clouds already moved into a [`LocalFrame`].
861fn icp_point_to_plane_local(
862    source: &[Point3f],
863    target: &[Point3f],
864    target_normals: &[Vector3f],
865    init: Isometry3<f32>,
866    max_iters: usize,
867    max_correspondence_distance: Option<f32>,
868    convergence_threshold: f32,
869) -> Result<ICPResult> {
870    let mut current_transform = init;
871    let mut previous_mse = f32::INFINITY;
872    let mut final_correspondences: Vec<(usize, usize)> = Vec::new();
873    let target_tree = KdTree::new(target)?;
874    let max_dist_sq = max_distance_sq(max_correspondence_distance);
875    let source_scale = CloudScale::of(source);
876    let mut correspondences: Vec<Option<(usize, f32)>> = vec![None; source.len()];
877
878    for iteration in 0..max_iters {
879        // Apply current estimate to source
880        let transformed_source: Vec<Point3f> =
881            source.par_iter().map(|p| current_transform * p).collect();
882
883        // Find nearest-neighbor correspondences, seeded with last iteration's matches
884        correspondences = find_correspondences_seeded(
885            source,
886            &current_transform,
887            target,
888            &target_tree,
889            &correspondences,
890            max_dist_sq,
891        );
892
893        let mut valid_source: Vec<Point3f> = Vec::new();
894        let mut valid_target: Vec<Point3f> = Vec::new();
895        let mut valid_normals: Vec<Vector3f> = Vec::new();
896        let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
897
898        for (src_idx, corr) in correspondences.iter().enumerate() {
899            if let Some((tgt_idx, _)) = corr {
900                valid_source.push(transformed_source[src_idx]);
901                valid_target.push(target[*tgt_idx]);
902                valid_normals.push(target_normals[*tgt_idx]);
903                corr_pairs.push((src_idx, *tgt_idx));
904            }
905        }
906
907        // Need at least 6 points to solve the 6-DOF system
908        if valid_source.len() < 6 {
909            return Err(Error::Algorithm(
910                "Insufficient correspondences for point-to-plane ICP (need ≥ 6)".to_string(),
911            ));
912        }
913
914        let delta =
915            compute_transformation_point_to_plane(&valid_source, &valid_target, &valid_normals)?;
916        let moved_centroid = current_transform * source_scale.centroid;
917        current_transform = delta * current_transform;
918
919        let current_mse = compute_point_to_plane_mse(&valid_source, &valid_target, &valid_normals);
920        if icp_converged(
921            previous_mse,
922            current_mse,
923            &delta,
924            &moved_centroid,
925            source_scale.radius,
926            convergence_threshold,
927        ) {
928            return Ok(ICPResult {
929                transformation: current_transform,
930                mse: current_mse,
931                iterations: iteration + 1,
932                converged: true,
933                correspondences: corr_pairs,
934            });
935        }
936
937        previous_mse = current_mse;
938        final_correspondences = corr_pairs;
939    }
940
941    Ok(ICPResult {
942        transformation: current_transform,
943        mse: previous_mse,
944        iterations: max_iters,
945        converged: false,
946        correspondences: final_correspondences,
947    })
948}
949
950/// Point-to-point ICP registration
951///
952/// This function performs point-to-point ICP registration using Euclidean distance minimization.
953/// It finds the rigid transformation that best aligns the source point cloud to the target.
954///
955/// # Arguments
956/// * `source` - Source point cloud to be aligned
957/// * `target` - Target point cloud to align to
958/// * `init` - Initial transformation estimate (use Isometry3::identity() for no initial guess)
959/// * `max_iterations` - Maximum number of iterations to perform
960/// * `convergence_threshold` - Stop when RMSE improves by less than this fraction (default: 1e-6)
961/// * `max_correspondence_distance` - Maximum distance for valid correspondences (None = no limit)
962///
963/// # Returns
964/// * `Result<ICPResult>` - Detailed ICP result including transformation, error, and convergence info
965///
966/// # Example
967/// ```rust
968/// use threecrate_algorithms::icp_point_to_point;
969/// use threecrate_core::{PointCloud, Point3f};
970/// use nalgebra::Isometry3;
971///
972/// fn main() -> Result<(), Box<dyn std::error::Error>> {
973///     // Create source and target point clouds
974///     let mut source = PointCloud::new();
975///     let mut target = PointCloud::new();
976///     
977///     // Add some points
978///     for i in 0..10 {
979///         let point = Point3f::new(i as f32, i as f32, 0.0);
980///         source.push(point);
981///         target.push(point + Point3f::new(1.0, 0.0, 0.0).coords); // Translated by (1,0,0)
982///     }
983///     
984///     let init = Isometry3::identity();
985///     let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None)?;
986///     println!("Converged: {}, MSE: {}", result.converged, result.mse);
987///     Ok(())
988/// }
989/// ```
990pub fn icp_point_to_point(
991    source: &PointCloud<Point3f>,
992    target: &PointCloud<Point3f>,
993    init: Isometry3<f32>,
994    max_iterations: usize,
995    convergence_threshold: f32,
996    max_correspondence_distance: Option<f32>,
997) -> Result<ICPResult> {
998    // Validate inputs
999    if source.is_empty() || target.is_empty() {
1000        return Err(Error::InvalidData(
1001            "Source or target point cloud is empty".to_string(),
1002        ));
1003    }
1004
1005    if max_iterations == 0 {
1006        return Err(Error::InvalidData(
1007            "Max iterations must be positive".to_string(),
1008        ));
1009    }
1010
1011    if convergence_threshold <= 0.0 {
1012        return Err(Error::InvalidData(
1013            "Convergence threshold must be positive".to_string(),
1014        ));
1015    }
1016
1017    // Use the detailed ICP implementation with point-to-point distance minimization
1018    icp_detailed(
1019        source,
1020        target,
1021        init,
1022        max_iterations,
1023        max_correspondence_distance,
1024        convergence_threshold,
1025    )
1026}
1027
1028/// Point-to-point ICP registration with default parameters
1029///
1030/// Convenience function that uses reasonable default parameters for point-to-point ICP.
1031///
1032/// # Arguments
1033/// * `source` - Source point cloud to be aligned
1034/// * `target` - Target point cloud to align to
1035/// * `init` - Initial transformation estimate
1036/// * `max_iterations` - Maximum number of iterations
1037///
1038/// # Returns
1039/// * `Result<ICPResult>` - Detailed ICP result
1040pub fn icp_point_to_point_default(
1041    source: &PointCloud<Point3f>,
1042    target: &PointCloud<Point3f>,
1043    init: Isometry3<f32>,
1044    max_iterations: usize,
1045) -> Result<ICPResult> {
1046    icp_point_to_point(source, target, init, max_iterations, 1e-6, None)
1047}
1048
1049/// Coarse-to-fine point-to-point ICP using voxel downsampling at each scale.
1050pub fn multiscale_icp_point_to_point(
1051    source: &PointCloud<Point3f>,
1052    target: &PointCloud<Point3f>,
1053    init: Isometry3<f32>,
1054    config: &MultiScaleIcpConfig,
1055) -> Result<ICPResult> {
1056    if source.is_empty() || target.is_empty() {
1057        return Err(Error::InvalidData(
1058            "Source or target point cloud is empty".to_string(),
1059        ));
1060    }
1061    if config.levels.is_empty() {
1062        return Err(Error::InvalidData(
1063            "At least one ICP scale level is required".to_string(),
1064        ));
1065    }
1066    if config.convergence_threshold <= 0.0 {
1067        return Err(Error::InvalidData(
1068            "Convergence threshold must be positive".to_string(),
1069        ));
1070    }
1071    if config.final_refinement_iterations == 0 {
1072        return Err(Error::InvalidData(
1073            "Final refinement iterations must be positive".to_string(),
1074        ));
1075    }
1076
1077    let mut current_transform = init;
1078    let mut total_iterations = 0usize;
1079    let mut last_result: Option<ICPResult> = None;
1080
1081    for level in &config.levels {
1082        if level.voxel_size <= 0.0 {
1083            return Err(Error::InvalidData(
1084                "Scale voxel_size must be positive".to_string(),
1085            ));
1086        }
1087        if level.max_iterations == 0 {
1088            return Err(Error::InvalidData(
1089                "Scale max_iterations must be positive".to_string(),
1090            ));
1091        }
1092
1093        let source_down = voxel_grid_filter(source, level.voxel_size)?;
1094        let target_down = voxel_grid_filter(target, level.voxel_size)?;
1095        if source_down.len() < 3 || target_down.len() < 3 {
1096            continue;
1097        }
1098
1099        let result = icp_point_to_point(
1100            &source_down,
1101            &target_down,
1102            current_transform,
1103            level.max_iterations,
1104            config.convergence_threshold,
1105            level.max_correspondence_distance,
1106        )?;
1107
1108        current_transform = result.transformation;
1109        total_iterations += result.iterations;
1110        last_result = Some(result);
1111    }
1112
1113    if last_result.is_none() {
1114        return Err(Error::Algorithm(
1115            "No multiscale ICP level had enough downsampled points".to_string(),
1116        ));
1117    }
1118
1119    let final_result = icp_point_to_point(
1120        source,
1121        target,
1122        current_transform,
1123        config.final_refinement_iterations,
1124        config.convergence_threshold,
1125        config.final_max_correspondence_distance,
1126    )?;
1127
1128    Ok(ICPResult {
1129        transformation: final_result.transformation,
1130        mse: final_result.mse,
1131        iterations: total_iterations + final_result.iterations,
1132        converged: final_result.converged,
1133        correspondences: final_result.correspondences,
1134    })
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    use nalgebra::UnitQuaternion;
1142
1143    #[test]
1144    fn test_icp_identity_transformation() {
1145        // Create identical point clouds
1146        let mut source = PointCloud::new();
1147        let mut target = PointCloud::new();
1148
1149        for i in 0..10 {
1150            let point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1151            source.push(point);
1152            target.push(point);
1153        }
1154
1155        let init = Isometry3::identity();
1156        let result = icp_detailed(&source, &target, init, 10, None, 1e-6).unwrap();
1157
1158        // Should converge quickly with minimal transformation
1159        assert!(result.converged);
1160        assert!(result.mse < 1e-6);
1161        assert!(result.iterations <= 3);
1162    }
1163
1164    #[test]
1165    fn test_icp_translation() {
1166        // Create source and target with known translation
1167        let mut source = PointCloud::new();
1168        let mut target = PointCloud::new();
1169
1170        let translation = Vector3f::new(1.0, 2.0, 3.0);
1171
1172        for i in 0..10 {
1173            let source_point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1174            let target_point = source_point + translation;
1175            source.push(source_point);
1176            target.push(target_point);
1177        }
1178
1179        let init = Isometry3::identity();
1180        let result = icp_detailed(&source, &target, init, 50, None, 1e-6).unwrap();
1181
1182        // Check that the computed translation is in the right direction
1183        let computed_translation = result.transformation.translation.vector;
1184        // ICP may not converge exactly due to numerical precision and algorithm limitations
1185        // The algorithm should at least move in the correct direction
1186        assert!(
1187            computed_translation.magnitude() > 0.05,
1188            "Translation magnitude too small: {}",
1189            computed_translation.magnitude()
1190        );
1191
1192        assert!(result.mse < 2.0); // Allow for higher MSE in simple test cases
1193    }
1194
1195    #[test]
1196    fn test_icp_rotation() {
1197        // Create source and target with known rotation
1198        let mut source = PointCloud::new();
1199        let mut target = PointCloud::new();
1200
1201        let rotation =
1202            UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), std::f32::consts::FRAC_PI_4);
1203
1204        for i in 0..20 {
1205            let source_point = Point3f::new(i as f32, (i % 5) as f32, 0.0);
1206            let target_point = rotation * source_point;
1207            source.push(source_point);
1208            target.push(target_point);
1209        }
1210
1211        let init = Isometry3::identity();
1212        let result = icp_detailed(&source, &target, init, 100, None, 1e-6).unwrap();
1213
1214        // Should find a reasonable transformation for rotation
1215        assert!(result.mse < 1.0, "MSE too high: {}", result.mse);
1216    }
1217
1218    #[test]
1219    fn test_icp_insufficient_points() {
1220        let mut source = PointCloud::new();
1221        let mut target = PointCloud::new();
1222
1223        source.push(Point3f::new(0.0, 0.0, 0.0));
1224        target.push(Point3f::new(1.0, 1.0, 1.0));
1225
1226        let init = Isometry3::identity();
1227        let result = icp_detailed(&source, &target, init, 10, None, 1e-6);
1228
1229        assert!(result.is_err());
1230    }
1231
1232    #[test]
1233    fn test_icp_api_compatibility() {
1234        // Test the main API function
1235        let mut source = PointCloud::new();
1236        let mut target = PointCloud::new();
1237
1238        for i in 0..5 {
1239            let point = Point3f::new(i as f32, i as f32, 0.0);
1240            source.push(point);
1241            target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1242        }
1243
1244        let init = Isometry3::identity();
1245        let transform = icp(&source, &target, init, 20).unwrap();
1246
1247        // Should return a valid transformation (not panic)
1248        assert!(transform.translation.vector.magnitude() > 0.5);
1249
1250        let empty = PointCloud::new();
1251        assert!(icp(&empty, &target, init, 20).is_err());
1252    }
1253
1254    #[test]
1255    fn test_icp_far_from_origin_does_not_stop_early() {
1256        // Terrain-like cloud in UTM-style map coordinates (millions of metres
1257        // from the origin), turned and shifted by a few metres. ICP needs
1258        // several real steps here and must not stop before it is aligned.
1259        let origin = nalgebra::Vector3::<f64>::new(500_000.0, 4_200_000.0, 0.0);
1260        let local: Vec<nalgebra::Vector3<f64>> = (0..40)
1261            .flat_map(|i| (0..40).map(move |j| (i as f64 * 10.0, j as f64 * 10.0)))
1262            .map(|(x, y)| nalgebra::Vector3::new(x, y, 30.0 * (x / 70.0).sin() * (y / 110.0).cos()))
1263            .collect();
1264        let centre = local.iter().sum::<nalgebra::Vector3<f64>>() / local.len() as f64 + origin;
1265
1266        // Truth: turn 0.01 rad about the cloud centre, then shift (3, -2, 0.5)
1267        let truth = nalgebra::Translation3::from(centre + nalgebra::Vector3::new(3.0, -2.0, 0.5))
1268            * nalgebra::UnitQuaternion::from_euler_angles(0.0, 0.0, 0.01)
1269            * nalgebra::Translation3::from(-centre);
1270
1271        let world: Vec<nalgebra::Point3<f64>> = local
1272            .iter()
1273            .map(|v| nalgebra::Point3::from(v + origin))
1274            .collect();
1275        let source = PointCloud::from_points(world.iter().map(|p| p.cast::<f32>()).collect());
1276        let target =
1277            PointCloud::from_points(world.iter().map(|p| (truth * p).cast::<f32>()).collect());
1278
1279        let result =
1280            icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None).unwrap();
1281
1282        // Judge by where the points land (in f64), not by the translation
1283        // vector: this far from the origin, a 1e-7 rotation rounding error is
1284        // already ~0.4 m of translation, which the translation then cancels.
1285        let estimate = result.transformation.cast::<f64>();
1286        let error = world
1287            .iter()
1288            .map(|p| (estimate * p - truth * p).norm())
1289            .fold(0.0, f64::max);
1290        assert!(
1291            result.converged && error < 0.05,
1292            "points {error} m off after {} iterations (converged: {})",
1293            result.iterations,
1294            result.converged
1295        );
1296    }
1297
1298    #[test]
1299    fn test_icp_convergence_is_scale_invariant() {
1300        // The same problem at 1x and at 1/100 scale (e.g. metres vs centimetres
1301        // of scene). With an absolute MSE rule the small copy stopped early.
1302        let solve = |scale: f32| {
1303            let mut source = PointCloud::new();
1304            for i in 0..40 {
1305                for j in 0..40 {
1306                    let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
1307                    let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
1308                    source.push(Point3f::new(x, y, z) * scale);
1309                }
1310            }
1311            let truth = Isometry3::from_parts(
1312                Translation3::new(0.05 * scale, -0.03 * scale, 0.02 * scale),
1313                UnitQuaternion::from_euler_angles(0.0, 0.0, 0.03),
1314            );
1315            let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
1316            let result =
1317                icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None)
1318                    .unwrap();
1319            let rotation_error =
1320                (result.transformation.rotation.inverse() * truth.rotation).angle();
1321            let translation_error =
1322                (result.transformation.translation.vector - truth.translation.vector).norm()
1323                    / scale;
1324            (result.iterations, rotation_error, translation_error)
1325        };
1326
1327        let (iters_big, rot_big, trans_big) = solve(1.0);
1328        let (iters_small, rot_small, trans_small) = solve(0.01);
1329
1330        assert!(rot_big < 1e-3 && trans_big < 1e-3);
1331        assert!(
1332            rot_small < 1e-3 && trans_small < 1e-3,
1333            "small-scale ICP stopped early: rot {rot_small}, trans {trans_small}, {iters_small} iterations"
1334        );
1335        assert!(
1336            iters_small.abs_diff(iters_big) <= 2,
1337            "iterations: big {iters_big}, small {iters_small}"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_max_distance_cutoff_semantics() {
1343        let source = vec![Point3f::new(0.0, 0.0, 0.0)];
1344        let target = vec![Point3f::new(0.5, 0.0, 0.0)];
1345
1346        // A match exactly at the cutoff is accepted
1347        assert!(find_correspondences(&source, &target, Some(0.5))[0].is_some());
1348        // A cutoff just below the match distance rejects it
1349        assert!(find_correspondences(&source, &target, Some(0.49))[0].is_none());
1350        // A negative cutoff rejects everything rather than being squared
1351        assert!(find_correspondences(&source, &target, Some(-0.5))[0].is_none());
1352        // No cutoff accepts
1353        assert!(find_correspondences(&source, &target, None)[0].is_some());
1354
1355        // Zero cutoff still accepts exact duplicates
1356        assert!(find_correspondences(&source, &source, Some(0.0))[0].is_some());
1357    }
1358
1359    #[test]
1360    fn test_correspondence_finding() {
1361        let source = vec![
1362            Point3f::new(0.0, 0.0, 0.0),
1363            Point3f::new(1.0, 0.0, 0.0),
1364            Point3f::new(0.0, 1.0, 0.0),
1365        ];
1366
1367        let target = vec![
1368            Point3f::new(0.1, 0.1, 0.0),
1369            Point3f::new(1.1, 0.1, 0.0),
1370            Point3f::new(0.1, 1.1, 0.0),
1371        ];
1372
1373        let correspondences = find_correspondences(&source, &target, None);
1374
1375        assert_eq!(correspondences.len(), 3);
1376        assert!(correspondences[0].is_some());
1377        assert!(correspondences[1].is_some());
1378        assert!(correspondences[2].is_some());
1379    }
1380
1381    #[test]
1382    fn test_icp_point_to_point_basic() {
1383        // Test basic functionality with simple point clouds
1384        let mut source = PointCloud::new();
1385        let mut target = PointCloud::new();
1386
1387        // Create a simple cube pattern
1388        for x in 0..3 {
1389            for y in 0..3 {
1390                for z in 0..3 {
1391                    let point = Point3f::new(x as f32, y as f32, z as f32);
1392                    source.push(point);
1393                    target.push(point + Vector3f::new(1.0, 0.5, 0.25)); // Known translation
1394                }
1395            }
1396        }
1397
1398        let init = Isometry3::identity();
1399        let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1400
1401        // Should converge and find a reasonable transformation
1402        assert!(result.converged || result.iterations == 50);
1403        assert!(result.mse < 2.0); // Allow for higher MSE in simple test cases
1404                                   // The transformation should at least move in the right direction
1405        let translation_mag = result.transformation.translation.vector.magnitude();
1406        assert!(
1407            translation_mag > 0.1,
1408            "Translation magnitude too small: {}",
1409            translation_mag
1410        );
1411    }
1412
1413    #[test]
1414    fn test_icp_point_to_point_with_noise() {
1415        // Test with noisy data
1416        let mut source = PointCloud::new();
1417        let mut target = PointCloud::new();
1418
1419        let translation = Vector3f::new(2.0, 1.0, 0.5);
1420        let rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.3);
1421        let transform = Isometry3::from_parts(
1422            Translation3::new(translation.x, translation.y, translation.z),
1423            rotation,
1424        );
1425
1426        // Create source points
1427        for i in 0..100 {
1428            let angle = (i as f32) * 0.1;
1429            let radius = 2.0 + (i % 10) as f32 * 0.1;
1430            let source_point = Point3f::new(
1431                radius * angle.cos(),
1432                radius * angle.sin(),
1433                (i % 5) as f32 * 0.5,
1434            );
1435            source.push(source_point);
1436        }
1437
1438        // Create target points with known transformation + noise
1439        for point in &source.points {
1440            let transformed = transform * point;
1441            // Add some noise
1442            let noise = Vector3f::new(
1443                (rand::random::<f32>() - 0.5) * 0.1,
1444                (rand::random::<f32>() - 0.5) * 0.1,
1445                (rand::random::<f32>() - 0.5) * 0.1,
1446            );
1447            target.push(transformed + noise);
1448        }
1449
1450        let init = Isometry3::identity();
1451        let result = icp_point_to_point(&source, &target, init, 100, 1e-5, None).unwrap();
1452
1453        // Should find a reasonable transformation despite noise
1454        assert!(result.mse < 0.5); // Allow for noise
1455        assert!(result.transformation.translation.vector.magnitude() > 1.0);
1456    }
1457
1458    #[test]
1459    fn test_icp_point_to_point_known_transform() {
1460        // Test with a known transformation
1461        let mut source = PointCloud::new();
1462        let mut target = PointCloud::new();
1463
1464        // Known transformation - use smaller values for better convergence
1465        let known_translation = Vector3f::new(1.0, -0.5, 0.25);
1466        let known_rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.2);
1467        let known_transform = Isometry3::from_parts(
1468            Translation3::new(
1469                known_translation.x,
1470                known_translation.y,
1471                known_translation.z,
1472            ),
1473            known_rotation,
1474        );
1475
1476        // Create source points in a grid
1477        for x in -2..=2 {
1478            for y in -2..=2 {
1479                for z in -1..=1 {
1480                    let point = Point3f::new(x as f32, y as f32, z as f32);
1481                    source.push(point);
1482                    target.push(known_transform * point);
1483                }
1484            }
1485        }
1486
1487        let init = Isometry3::identity();
1488        let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1489
1490        // Should find a transformation close to the known one
1491        let computed_translation = result.transformation.translation.vector;
1492        let translation_error = (computed_translation - known_translation).magnitude();
1493        assert!(
1494            translation_error < 1.0,
1495            "Translation error too large: {}",
1496            translation_error
1497        );
1498
1499        assert!(result.mse < 0.5);
1500    }
1501
1502    #[test]
1503    fn test_icp_point_to_point_convergence() {
1504        // Test convergence behavior
1505        let mut source = PointCloud::new();
1506        let mut target = PointCloud::new();
1507
1508        // Create point clouds that should converge quickly
1509        for i in 0..50 {
1510            let point = Point3f::new(i as f32 * 0.1, (i * 2) as f32 * 0.1, 0.0);
1511            source.push(point);
1512            target.push(point + Vector3f::new(0.5, 0.0, 0.0));
1513        }
1514
1515        let init = Isometry3::identity();
1516        let result = icp_point_to_point(&source, &target, init, 20, 1e-6, None).unwrap();
1517
1518        // Should converge quickly
1519        assert!(result.converged);
1520        assert!(result.iterations < 20);
1521        assert!(result.mse < 0.1);
1522    }
1523
1524    #[test]
1525    fn test_icp_point_to_point_max_distance() {
1526        // Test with maximum correspondence distance
1527        let mut source = PointCloud::new();
1528        let mut target = PointCloud::new();
1529
1530        // Create source points
1531        for i in 0..10 {
1532            source.push(Point3f::new(i as f32, 0.0, 0.0));
1533        }
1534
1535        // Create target points with some far away
1536        for i in 0..10 {
1537            if i < 5 {
1538                target.push(Point3f::new(i as f32 + 0.1, 0.0, 0.0)); // Close
1539            } else {
1540                target.push(Point3f::new(i as f32 + 10.0, 0.0, 0.0)); // Far away
1541            }
1542        }
1543
1544        let init = Isometry3::identity();
1545        let result = icp_point_to_point(&source, &target, init, 20, 1e-6, Some(1.0)).unwrap();
1546
1547        // Should only use correspondences within max_distance
1548        // Note: The algorithm might still find some correspondences due to the iterative nature
1549        // but it should use fewer correspondences than without the distance limit
1550        assert!(result.correspondences.len() <= 10);
1551        assert!(result.mse < 5.0); // Allow for higher MSE when using distance filtering
1552    }
1553
1554    #[test]
1555    fn test_icp_point_to_point_default() {
1556        // Test the default convenience function
1557        let mut source = PointCloud::new();
1558        let mut target = PointCloud::new();
1559
1560        for i in 0..10 {
1561            let point = Point3f::new(i as f32, i as f32, 0.0);
1562            source.push(point);
1563            target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1564        }
1565
1566        let init = Isometry3::identity();
1567        let result = icp_point_to_point_default(&source, &target, init, 30).unwrap();
1568
1569        // Should work with default parameters
1570        assert!(result.mse < 1.0);
1571        assert!(result.transformation.translation.vector.magnitude() > 0.5);
1572    }
1573
1574    #[test]
1575    fn test_icp_point_to_point_validation() {
1576        // Test input validation
1577        let empty_source = PointCloud::new();
1578        let mut target = PointCloud::new();
1579        target.push(Point3f::new(0.0, 0.0, 0.0));
1580
1581        let init = Isometry3::identity();
1582
1583        // Test empty source
1584        let result = icp_point_to_point(&empty_source, &target, init, 10, 1e-6, None);
1585        assert!(result.is_err());
1586
1587        // Test zero iterations
1588        let result = icp_point_to_point(&target, &target, init, 0, 1e-6, None);
1589        assert!(result.is_err());
1590
1591        // Test negative convergence threshold
1592        let result = icp_point_to_point(&target, &target, init, 10, -1e-6, None);
1593        assert!(result.is_err());
1594    }
1595
1596    // ── Point-to-plane ICP tests ──────────────────────────────────────────────
1597
1598    /// Build a Fibonacci-sphere cloud with outward-pointing unit normals.
1599    ///
1600    /// A sphere is the canonical test surface for point-to-plane ICP because the
1601    /// normals span all of 3-D space, ensuring the 6×6 linear system is full rank.
1602    fn make_sphere_cloud(n: usize) -> (PointCloud<Point3f>, Vec<Vector3f>) {
1603        let mut cloud = PointCloud::new();
1604        let mut normals = Vec::new();
1605        let radius = 3.0_f32;
1606        let golden_angle = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
1607        for i in 0..n {
1608            let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
1609            let r = (1.0 - y * y).max(0.0_f32).sqrt();
1610            let theta = golden_angle * i as f32;
1611            let x = theta.cos() * r;
1612            let z = theta.sin() * r;
1613            // (x, y, z) is already a unit vector (on the unit sphere)
1614            let normal = Vector3f::new(x, y, z);
1615            cloud.push(Point3f::new(x * radius, y * radius, z * radius));
1616            normals.push(normal);
1617        }
1618        (cloud, normals)
1619    }
1620
1621    #[test]
1622    fn test_icp_point_to_plane_identity() {
1623        let (source, normals) = make_sphere_cloud(50);
1624        let target = source.clone();
1625        let init = Isometry3::identity();
1626
1627        let result = icp_point_to_plane(&source, &target, &normals, init, 20).unwrap();
1628
1629        assert!(result.converged);
1630        assert!(result.mse < 1e-6, "mse={}", result.mse);
1631    }
1632
1633    #[test]
1634    fn test_icp_point_to_plane_translation() {
1635        // Small in-plane shift so nearest-neighbor correspondences remain correct.
1636        let (source, normals) = make_sphere_cloud(100);
1637        let shift = Vector3f::new(0.15, 0.0, 0.0);
1638
1639        let mut target = PointCloud::new();
1640        for p in &source.points {
1641            target.push(p + shift);
1642        }
1643        // Reuse the source normals as approximate target normals (valid for small shift).
1644        let result =
1645            icp_point_to_plane(&source, &target, &normals, Isometry3::identity(), 50).unwrap();
1646
1647        let t_err = (result.transformation.translation.vector - shift).magnitude();
1648        assert!(t_err < 0.3, "translation error={}", t_err);
1649        assert!(result.mse < 0.1, "mse={}", result.mse);
1650    }
1651
1652    #[test]
1653    fn test_icp_point_to_plane_validation() {
1654        let (source, normals) = make_sphere_cloud(20);
1655        let init = Isometry3::identity();
1656
1657        // Normals length mismatch
1658        let bad_normals = vec![Vector3f::new(0.0, 0.0, 1.0)];
1659        let result = icp_point_to_plane(&source, &source, &bad_normals, init, 10);
1660        assert!(result.is_err());
1661
1662        // Empty source
1663        let empty: PointCloud<Point3f> = PointCloud::new();
1664        let result = icp_point_to_plane(&empty, &source, &normals, init, 10);
1665        assert!(result.is_err());
1666
1667        // Zero iterations
1668        let result = icp_point_to_plane_detailed(&source, &source, &normals, init, 0, None, 1e-6);
1669        assert!(result.is_err());
1670    }
1671
1672    #[test]
1673    fn test_icp_point_to_plane_vs_point_to_point_convergence() {
1674        // Both variants must converge to a reasonable solution for the same sphere+shift input.
1675        let (source, normals) = make_sphere_cloud(80);
1676        let shift = Vector3f::new(0.1, 0.05, 0.0);
1677        let mut target = PointCloud::new();
1678        for p in &source.points {
1679            target.push(p + shift);
1680        }
1681
1682        let init = Isometry3::identity();
1683
1684        let p2pl_result = icp_point_to_plane(&source, &target, &normals, init, 50).unwrap();
1685        let p2pt_result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1686
1687        // Both should find a non-trivial transformation
1688        assert!(
1689            p2pl_result.transformation.translation.vector.magnitude() > 0.05,
1690            "p2pl did not translate: t={}",
1691            p2pl_result.transformation.translation.vector.magnitude()
1692        );
1693        assert!(
1694            p2pt_result.transformation.translation.vector.magnitude() > 0.05,
1695            "p2pt did not translate: t={}",
1696            p2pt_result.transformation.translation.vector.magnitude()
1697        );
1698        // Point-to-plane should converge (or at least not diverge)
1699        assert!(
1700            p2pl_result.converged || p2pl_result.mse < 0.1,
1701            "p2pl failed to converge: mse={}, iters={}",
1702            p2pl_result.mse,
1703            p2pl_result.iterations
1704        );
1705    }
1706
1707    #[test]
1708    fn test_icp_point_to_plane_detailed_max_distance() {
1709        let (source, normals) = make_sphere_cloud(50);
1710        let mut target = PointCloud::new();
1711        for p in &source.points {
1712            target.push(p + Vector3f::new(0.1, 0.0, 0.0));
1713        }
1714
1715        let init = Isometry3::identity();
1716        let result =
1717            icp_point_to_plane_detailed(&source, &target, &normals, init, 30, Some(5.0), 1e-6);
1718        assert!(result.is_ok(), "unexpected error: {:?}", result.err());
1719        let result = result.unwrap();
1720        assert!(result.mse < 0.5, "mse={}", result.mse);
1721    }
1722}