Skip to main content

spatialrust_registration/
gicp.rs

1// Explicit row/column indexing reads more clearly than iterators for fixed 3x3
2// and 3x6 linear-algebra kernels.
3#![allow(clippy::needless_range_loop)]
4
5use spatialrust_core::{HasPositions3, PointCloud, SpatialError, SpatialResult};
6use spatialrust_math::{
7    solve_linear_system, symmetric_eigen3, Isometry3, LeastSquaresResult, Mat3, Quat,
8    TransformPoint, Vec3,
9};
10use spatialrust_search::{KdTree, NearestNeighborIndex, Neighbor};
11
12use crate::registration::{PointCloudRegistration, RegistrationResult};
13
14type M3 = [[f64; 3]; 3];
15
16/// Configuration for Generalized ICP (plane-to-plane).
17///
18/// GICP models each point's local surface as an anisotropic Gaussian and
19/// minimizes the Mahalanobis distance between correspondences, combining the
20/// robustness of point-to-plane ICP for both clouds. Local covariances are
21/// estimated from k-nearest neighbors and regularized toward planar disks.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct GicpConfig {
24    /// Maximum number of GICP iterations.
25    pub max_iterations: usize,
26    /// Maximum correspondence distance.
27    pub max_correspondence_distance: f32,
28    /// Number of neighbors used to estimate per-point covariance.
29    pub k_neighbors: usize,
30    /// Planar regularization: eigenvalue assigned along the surface normal.
31    pub epsilon: f64,
32    /// Stop when the transform update is smaller than this threshold.
33    pub transformation_epsilon: f64,
34    /// Stop when the fitness is smaller than this threshold.
35    pub fitness_epsilon: f64,
36    /// Minimum number of correspondences required per iteration.
37    pub min_correspondences: usize,
38    /// Initial transform guess mapping source into target frame.
39    pub initial_guess: Isometry3<f32>,
40    /// When set (and the `register-gicp-gpu` feature is enabled), estimate
41    /// per-point covariances on the GPU using a uniform grid neighbor search of
42    /// this radius instead of the CPU KD-tree. Falls back to CPU on GPU errors.
43    pub covariance_radius: Option<f32>,
44}
45
46impl Default for GicpConfig {
47    fn default() -> Self {
48        Self {
49            max_iterations: 50,
50            max_correspondence_distance: 1.0,
51            k_neighbors: 20,
52            epsilon: 1e-3,
53            transformation_epsilon: 1e-8,
54            fitness_epsilon: 1e-6,
55            min_correspondences: 6,
56            initial_guess: Isometry3::identity(),
57            covariance_radius: None,
58        }
59    }
60}
61
62impl GicpConfig {
63    /// Creates a config with the given correspondence distance.
64    #[must_use]
65    pub fn with_correspondence_distance(max_correspondence_distance: f32) -> Self {
66        Self { max_correspondence_distance, ..Self::default() }
67    }
68}
69
70/// Generalized ICP registration.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct GicpRegistration {
73    config: GicpConfig,
74}
75
76impl GicpRegistration {
77    /// Creates a GICP algorithm from config.
78    #[must_use]
79    pub const fn new(config: GicpConfig) -> Self {
80        Self { config }
81    }
82
83    /// Returns the config.
84    #[must_use]
85    pub const fn config(&self) -> GicpConfig {
86        self.config
87    }
88
89    /// Per-point plane-regularized covariances, on the GPU when a covariance
90    /// radius is configured and the `register-gicp-gpu` feature is enabled.
91    fn point_covariances(&self, x: &[f32], y: &[f32], z: &[f32]) -> Vec<M3> {
92        #[cfg(all(feature = "register-gicp-gpu", not(target_arch = "wasm32")))]
93        if let Some(radius) = self.config.covariance_radius {
94            if let Some(cov) = gpu_covariances(x, y, z, radius, self.config.epsilon) {
95                return cov;
96            }
97        }
98        let tree = KdTree::from_slices(x, y, z);
99        covariances(x, y, z, &tree, self.config.k_neighbors, self.config.epsilon)
100    }
101
102    fn validate_inputs(&self, source: &PointCloud, target: &PointCloud) -> SpatialResult<()> {
103        if source.is_empty() || target.is_empty() {
104            return Err(SpatialError::InvalidArgument(
105                "GICP requires non-empty source and target point clouds".to_owned(),
106            ));
107        }
108        if self.config.k_neighbors < 3 {
109            return Err(SpatialError::InvalidArgument(
110                "GICP needs k_neighbors >= 3 for covariance estimation".to_owned(),
111            ));
112        }
113
114        Ok(())
115    }
116
117    /// Aligns `source` to `target` using Generalized ICP.
118    pub fn align_with_diagnostics(
119        &self,
120        source: &PointCloud,
121        target: &PointCloud,
122    ) -> SpatialResult<RegistrationResult> {
123        self.align_impl(source, target, Ok).map(|(result, _)| result)
124    }
125
126    /// Aligns with explicit GPU correspondence uploads/readbacks and a reused
127    /// target. Covariance estimation uses CPU k-NN when covariance_radius is
128    /// None, or explicit radius-GPU execution on this runtime when configured.
129    /// Hessian/gradient reduction, pose solving, transforms and fitness sums
130    /// remain CPU. All GPU errors propagate without the legacy CPU fallback.
131    /// This explicit path requires finite input/transformed XYZ.
132    #[cfg(feature = "register-gicp-gpu")]
133    pub fn align_with_gpu_correspondences(
134        &self,
135        runtime: &spatialrust_gpu::WgpuRuntime,
136        source: &PointCloud,
137        target: &PointCloud,
138    ) -> SpatialResult<GpuGicpDiagnostics> {
139        self.validate_inputs(source, target)?;
140        for cloud in [source, target] {
141            let (x, y, z) = cloud.positions3()?;
142            if x.iter().chain(y).chain(z).any(|v| !v.is_finite()) {
143                return Err(SpatialError::InvalidArgument(
144                    "GPU GICP requires finite XYZ".to_owned(),
145                ));
146            }
147        }
148        let (result, search) = self.align_impl(source, target, |tree| {
149            let target = spatialrust_gpu::GpuKdTree::upload(runtime, &tree)?;
150            let transfers = target.upload_transfers();
151            Ok(GpuGicpSearch { runtime, target, transfers, batches: 0, covariance_batches: 0 })
152        })?;
153        Ok(GpuGicpDiagnostics {
154            result,
155            transfers: search.transfers,
156            correspondence_batches: search.batches,
157            covariance_batches: search.covariance_batches,
158        })
159    }
160
161    fn align_impl<S: GicpSearch>(
162        &self,
163        source: &PointCloud,
164        target: &PointCloud,
165        make_search: impl FnOnce(KdTree) -> SpatialResult<S>,
166    ) -> SpatialResult<(RegistrationResult, S)> {
167        self.validate_inputs(source, target)?;
168
169        let (sx, sy, sz) = source.positions3()?;
170        let (tx, ty, tz) = target.positions3()?;
171        let mut search = make_search(KdTree::from_slices(tx, ty, tz))?;
172        let source_cov = search.covariances(self, sx, sy, sz)?;
173        let target_cov = search.covariances(self, tx, ty, tz)?;
174
175        let max_distance_squared =
176            self.config.max_correspondence_distance * self.config.max_correspondence_distance;
177
178        let mut transform = self.config.initial_guess;
179        let mut transformed = vec![Vec3::new(0.0, 0.0, 0.0); source.len()];
180        apply_transform(&mut transformed, sx, sy, sz, transform);
181
182        let mut iterations = 0usize;
183        let mut converged = false;
184
185        for _ in 0..self.config.max_iterations {
186            iterations += 1;
187            let rot = mat3_to_f64(transform.rotation().to_mat3());
188            let rot_t = transpose(rot);
189
190            let mut hessian = [[0.0_f64; 6]; 6];
191            let mut gradient = [0.0_f64; 6];
192            let mut count = 0usize;
193
194            search.visit(&transformed, |i, point, neighbor| {
195                if neighbor.distance_squared > max_distance_squared {
196                    return;
197                }
198                let j = neighbor.index;
199
200                // C = C_target + R C_source R^T, then M = C^-1.
201                let rotated_source = mat_mul(mat_mul(rot, source_cov[i]), rot_t);
202                let combined = mat_add(target_cov[j], rotated_source);
203                let Some(m) = inverse3(combined) else {
204                    return;
205                };
206
207                let e = [
208                    f64::from(point.x) - f64::from(tx[j]),
209                    f64::from(point.y) - f64::from(ty[j]),
210                    f64::from(point.z) - f64::from(tz[j]),
211                ];
212                // Jacobian rows (3x6): [-skew(point) | I].
213                let jac = jacobian([f64::from(point.x), f64::from(point.y), f64::from(point.z)]);
214                let mj = mat3x6_premul(m, &jac);
215                let me = mat_vec(m, e);
216
217                for a in 0..6 {
218                    for k in 0..3 {
219                        gradient[a] += jac[k][a] * me[k];
220                        for b in 0..6 {
221                            hessian[a][b] += jac[k][a] * mj[k][b];
222                        }
223                    }
224                }
225                count += 1;
226            })?;
227
228            if count < self.config.min_correspondences {
229                return Err(SpatialError::InvalidArgument(format!(
230                    "GICP found only {} correspondences, minimum is {}",
231                    count, self.config.min_correspondences
232                )));
233            }
234
235            let a_rows: Vec<Vec<f64>> = hessian.iter().map(|row| row.to_vec()).collect();
236            let neg_g: Vec<f64> = gradient.iter().map(|value| -value).collect();
237            let LeastSquaresResult::Solved(solution) = solve_linear_system(a_rows, neg_g) else {
238                return Err(SpatialError::InvalidArgument(
239                    "GICP normal equations were singular".to_owned(),
240                ));
241            };
242
243            transform = delta_from_solution(&solution).compose(transform);
244            apply_transform(&mut transformed, sx, sy, sz, transform);
245
246            if update_magnitude(&solution) < self.config.transformation_epsilon {
247                converged = true;
248                break;
249            }
250            if euclidean_fitness(&transformed, tx, ty, tz, &mut search, max_distance_squared)?
251                < self.config.fitness_epsilon
252            {
253                converged = true;
254                break;
255            }
256        }
257
258        Ok((
259            RegistrationResult {
260                transform,
261                fitness: euclidean_fitness(
262                    &transformed,
263                    tx,
264                    ty,
265                    tz,
266                    &mut search,
267                    max_distance_squared,
268                )?,
269                iterations,
270                converged,
271            },
272            search,
273        ))
274    }
275}
276
277impl PointCloudRegistration for GicpRegistration {
278    fn name(&self) -> &'static str {
279        "GicpRegistration"
280    }
281
282    fn align(&self, source: &PointCloud, target: &PointCloud) -> SpatialResult<RegistrationResult> {
283        self.align_with_diagnostics(source, target)
284    }
285}
286
287/// GPU path: per-point plane covariances via a uniform-grid neighbor search.
288/// Returns `None` on any GPU error so the caller falls back to the CPU path.
289#[cfg(all(feature = "register-gicp-gpu", not(target_arch = "wasm32")))]
290fn gpu_covariances(x: &[f32], y: &[f32], z: &[f32], radius: f32, epsilon: f64) -> Option<Vec<M3>> {
291    use spatialrust_gpu::{estimate_plane_covariances_grid_gpu, WgpuRuntime};
292    let runtime = WgpuRuntime::shared().ok()?;
293    let raw =
294        estimate_plane_covariances_grid_gpu(&runtime, x, y, z, radius, epsilon as f32).ok()?;
295    Some(
296        raw.into_iter()
297            .map(|[c00, c11, c22, c01, c02, c12]| {
298                [
299                    [c00 as f64, c01 as f64, c02 as f64],
300                    [c01 as f64, c11 as f64, c12 as f64],
301                    [c02 as f64, c12 as f64, c22 as f64],
302                ]
303            })
304            .collect(),
305    )
306}
307
308/// Computes per-point plane-regularized covariances from k-nearest neighbors.
309fn covariances(x: &[f32], y: &[f32], z: &[f32], tree: &KdTree, k: usize, epsilon: f64) -> Vec<M3> {
310    let len = x.len();
311    let mut out = Vec::with_capacity(len);
312    for index in 0..len {
313        out.push(covariance_at(x, y, z, tree, index, k, epsilon));
314    }
315    out
316}
317
318fn covariance_at(
319    x: &[f32],
320    y: &[f32],
321    z: &[f32],
322    tree: &KdTree,
323    index: usize,
324    k: usize,
325    epsilon: f64,
326) -> M3 {
327    let neighbors = tree.nearest_k(x[index], y[index], z[index], k);
328    let mut acc = spatialrust_math::CovarianceAccumulator3::new();
329    for neighbor in &neighbors {
330        acc.push(Vec3::new(x[neighbor.index], y[neighbor.index], z[neighbor.index]));
331    }
332    let cov = acc
333        .covariance()
334        .unwrap_or_else(|| Mat3::from_rows([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]));
335    regularize(cov, epsilon)
336}
337
338/// Replaces a covariance's eigenvalues with (epsilon, 1, 1) to model a planar disk.
339fn regularize(cov: Mat3<f64>, epsilon: f64) -> M3 {
340    let eigen = symmetric_eigen3(cov);
341    // Eigenvectors are columns; eigenvalues are ascending, so column 0 is the normal.
342    let v = eigen.eigenvectors.m;
343    let mut result = [[0.0_f64; 3]; 3];
344    for (col, scale) in [(0usize, epsilon), (1, 1.0), (2, 1.0)] {
345        let axis = [v[0][col], v[1][col], v[2][col]];
346        for r in 0..3 {
347            for c in 0..3 {
348                result[r][c] += scale * axis[r] * axis[c];
349            }
350        }
351    }
352    result
353}
354
355fn jacobian(p: [f64; 3]) -> [[f64; 6]; 3] {
356    // [-skew(p) | I3]
357    [
358        [0.0, p[2], -p[1], 1.0, 0.0, 0.0],
359        [-p[2], 0.0, p[0], 0.0, 1.0, 0.0],
360        [p[1], -p[0], 0.0, 0.0, 0.0, 1.0],
361    ]
362}
363
364fn apply_transform(
365    transformed: &mut [Vec3<f32>],
366    x: &[f32],
367    y: &[f32],
368    z: &[f32],
369    transform: Isometry3<f32>,
370) {
371    for (index, point) in transformed.iter_mut().enumerate() {
372        *point = transform.transform_point(Vec3::new(x[index], y[index], z[index]));
373    }
374}
375
376fn delta_from_solution(solution: &[f64]) -> Isometry3<f32> {
377    let (rx, ry, rz) = (solution[0], solution[1], solution[2]);
378    let angle = (rx * rx + ry * ry + rz * rz).sqrt();
379    let rotation = if angle > 1e-12 {
380        let axis = Vec3::new((rx / angle) as f32, (ry / angle) as f32, (rz / angle) as f32);
381        Quat::from_axis_angle(axis, angle as f32)
382    } else {
383        Quat::<f32>::identity()
384    };
385    Isometry3::new(rotation, Vec3::new(solution[3] as f32, solution[4] as f32, solution[5] as f32))
386}
387
388fn update_magnitude(solution: &[f64]) -> f64 {
389    solution.iter().map(|value| value * value).sum::<f64>().sqrt()
390}
391
392fn euclidean_fitness<S: GicpSearch>(
393    transformed: &[Vec3<f32>],
394    tx: &[f32],
395    ty: &[f32],
396    tz: &[f32],
397    search: &mut S,
398    max_distance_squared: f32,
399) -> SpatialResult<f64> {
400    let mut sum = 0.0_f64;
401    let mut count = 0usize;
402    search.visit(transformed, |_, point, neighbor| {
403        if neighbor.distance_squared > max_distance_squared {
404            return;
405        }
406        let dx = f64::from(point.x - tx[neighbor.index]);
407        let dy = f64::from(point.y - ty[neighbor.index]);
408        let dz = f64::from(point.z - tz[neighbor.index]);
409        sum += dx * dx + dy * dy + dz * dz;
410        count += 1;
411    })?;
412    Ok(if count == 0 { f64::MAX } else { sum / count as f64 })
413}
414
415fn mat3_to_f64(matrix: Mat3<f32>) -> M3 {
416    let mut out = [[0.0_f64; 3]; 3];
417    for r in 0..3 {
418        for c in 0..3 {
419            out[r][c] = f64::from(matrix.m[r][c]);
420        }
421    }
422    out
423}
424
425fn transpose(a: M3) -> M3 {
426    let mut out = [[0.0_f64; 3]; 3];
427    for r in 0..3 {
428        for c in 0..3 {
429            out[r][c] = a[c][r];
430        }
431    }
432    out
433}
434
435fn mat_mul(a: M3, b: M3) -> M3 {
436    let mut out = [[0.0_f64; 3]; 3];
437    for r in 0..3 {
438        for c in 0..3 {
439            for k in 0..3 {
440                out[r][c] += a[r][k] * b[k][c];
441            }
442        }
443    }
444    out
445}
446
447fn mat_add(a: M3, b: M3) -> M3 {
448    let mut out = [[0.0_f64; 3]; 3];
449    for r in 0..3 {
450        for c in 0..3 {
451            out[r][c] = a[r][c] + b[r][c];
452        }
453    }
454    out
455}
456
457fn mat_vec(a: M3, v: [f64; 3]) -> [f64; 3] {
458    [
459        a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
460        a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
461        a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
462    ]
463}
464
465/// Computes `m * j` where `m` is 3x3 and `j` is 3x6.
466fn mat3x6_premul(m: M3, j: &[[f64; 6]; 3]) -> [[f64; 6]; 3] {
467    let mut out = [[0.0_f64; 6]; 3];
468    for r in 0..3 {
469        for c in 0..6 {
470            for k in 0..3 {
471                out[r][c] += m[r][k] * j[k][c];
472            }
473        }
474    }
475    out
476}
477
478fn inverse3(a: M3) -> Option<M3> {
479    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
480        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
481        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
482    if det.abs() < 1e-18 {
483        return None;
484    }
485    let inv_det = 1.0 / det;
486    let mut out = [[0.0_f64; 3]; 3];
487    out[0][0] = (a[1][1] * a[2][2] - a[1][2] * a[2][1]) * inv_det;
488    out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
489    out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
490    out[1][0] = (a[1][2] * a[2][0] - a[1][0] * a[2][2]) * inv_det;
491    out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
492    out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
493    out[2][0] = (a[1][0] * a[2][1] - a[1][1] * a[2][0]) * inv_det;
494    out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
495    out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
496    Some(out)
497}
498
499trait GicpSearch {
500    fn visit(
501        &mut self,
502        points: &[Vec3<f32>],
503        consume: impl FnMut(usize, &Vec3<f32>, Neighbor),
504    ) -> SpatialResult<()>;
505    fn covariances(
506        &mut self,
507        registration: &GicpRegistration,
508        x: &[f32],
509        y: &[f32],
510        z: &[f32],
511    ) -> SpatialResult<Vec<M3>> {
512        Ok(registration.point_covariances(x, y, z))
513    }
514}
515impl GicpSearch for KdTree {
516    fn visit(
517        &mut self,
518        points: &[Vec3<f32>],
519        mut consume: impl FnMut(usize, &Vec3<f32>, Neighbor),
520    ) -> SpatialResult<()> {
521        for (i, point) in points.iter().enumerate() {
522            if let Some(neighbor) = self.nearest_one(point.x, point.y, point.z) {
523                consume(i, point, neighbor);
524            }
525        }
526        Ok(())
527    }
528}
529
530/// Result and explicit transfer accounting for GPU GICP correspondence search.
531#[cfg(feature = "register-gicp-gpu")]
532#[derive(Clone, Debug, PartialEq)]
533pub struct GpuGicpDiagnostics {
534    /// Pose, Euclidean fitness, iteration count and convergence flag.
535    pub result: RegistrationResult,
536    /// Target/query and optional radius-covariance uploads/readbacks.
537    pub transfers: spatialrust_core::TransferStats,
538    /// Completed nearest-query batches; depends on the stopping path.
539    pub correspondence_batches: usize,
540    /// Completed explicit radius-GPU covariance batches (0 or 2).
541    pub covariance_batches: usize,
542}
543
544#[cfg(feature = "register-gicp-gpu")]
545struct GpuGicpSearch<'a> {
546    runtime: &'a spatialrust_gpu::WgpuRuntime,
547    target: spatialrust_gpu::GpuKdTree,
548    transfers: spatialrust_core::TransferStats,
549    batches: usize,
550    covariance_batches: usize,
551}
552#[cfg(feature = "register-gicp-gpu")]
553impl GpuGicpSearch<'_> {
554    fn record(&mut self, transfers: spatialrust_core::TransferStats) {
555        self.transfers.record(
556            spatialrust_core::TransferDirection::HostToDevice,
557            transfers.host_to_device_bytes(),
558        );
559        self.transfers.record(
560            spatialrust_core::TransferDirection::DeviceToHost,
561            transfers.device_to_host_bytes(),
562        );
563    }
564}
565#[cfg(feature = "register-gicp-gpu")]
566impl GicpSearch for GpuGicpSearch<'_> {
567    fn visit(
568        &mut self,
569        points: &[Vec3<f32>],
570        mut consume: impl FnMut(usize, &Vec3<f32>, Neighbor),
571    ) -> SpatialResult<()> {
572        if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite()) {
573            return Err(SpatialError::InvalidArgument(
574                "GPU GICP requires finite transformed XYZ".to_owned(),
575            ));
576        }
577        let queries: Vec<_> = points.iter().map(|p| [p.x, p.y, p.z]).collect();
578        // Preserve GICP's unbounded search followed by its host rejection gate.
579        // For finite target/query coordinates this equals CPU nearest_one,
580        // including first-encountered ties and infinite squared distances.
581        let (neighbors, transfers) =
582            self.target.nearest_one_within_download(self.runtime, &queries, f32::INFINITY)?;
583        self.record(transfers);
584        self.batches += 1;
585        for (i, (point, neighbor)) in points.iter().zip(neighbors).enumerate() {
586            if let Some(neighbor) = neighbor {
587                consume(i, point, neighbor);
588            }
589        }
590        Ok(())
591    }
592    fn covariances(
593        &mut self,
594        registration: &GicpRegistration,
595        x: &[f32],
596        y: &[f32],
597        z: &[f32],
598    ) -> SpatialResult<Vec<M3>> {
599        let Some(radius) = registration.config.covariance_radius else {
600            return Ok(registration.point_covariances(x, y, z));
601        };
602        let (raw, transfers) = spatialrust_gpu::estimate_plane_covariances_grid_gpu_with_receipt(
603            self.runtime,
604            x,
605            y,
606            z,
607            radius,
608            registration.config.epsilon as f32,
609        )?;
610        self.record(transfers);
611        self.covariance_batches += 1;
612        Ok(raw
613            .into_iter()
614            .map(|[c00, c11, c22, c01, c02, c12]| {
615                [
616                    [f64::from(c00), f64::from(c01), f64::from(c02)],
617                    [f64::from(c01), f64::from(c11), f64::from(c12)],
618                    [f64::from(c02), f64::from(c12), f64::from(c22)],
619                ]
620            })
621            .collect())
622    }
623}
624
625#[cfg(feature = "register-gicp-gpu")]
626#[path = "gicp_reduction.rs"]
627mod reduction;
628#[cfg(feature = "register-gicp-gpu")]
629pub use reduction::{GpuGicpIteration, GpuGicpReductionDiagnostics};
630
631#[cfg(test)]
632mod tests {
633    use super::{GicpConfig, GicpRegistration};
634    use crate::registration::PointCloudRegistration;
635    use crate::transform::transform_point_cloud;
636    use spatialrust_core::{PointCloudBuilder, StandardSchemas};
637    use spatialrust_math::{Isometry3, Quat, TransformPoint, Vec3};
638
639    /// Three perpendicular faces (a box corner) giving full 6-DoF constraint.
640    fn box_corner() -> spatialrust_core::PointCloud {
641        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
642        for i in 0..10 {
643            for j in 0..10 {
644                let (a, b) = (i as f32 * 0.08, j as f32 * 0.08);
645                builder.push_point([a, b, 0.0]).unwrap();
646                builder.push_point([a, 0.0, b + 0.04]).unwrap();
647                builder.push_point([0.0, a + 0.04, b + 0.04]).unwrap();
648            }
649        }
650        builder.build().unwrap()
651    }
652
653    #[test]
654    fn aligns_rotated_and_translated_source() {
655        let target = box_corner();
656        let misalignment = Isometry3::new(
657            Quat::from_axis_angle(Vec3::new(0.0, 0.0, 1.0), 0.06),
658            Vec3::new(0.015, -0.01, 0.012),
659        );
660        let source = transform_point_cloud(&target, misalignment).unwrap();
661
662        let gicp = GicpRegistration::new(GicpConfig {
663            max_correspondence_distance: 0.2,
664            max_iterations: 40,
665            k_neighbors: 12,
666            ..GicpConfig::default()
667        });
668        let result = gicp.align(&source, &target).unwrap();
669        assert!(result.fitness < 1e-4, "fitness too high: {}", result.fitness);
670
671        let composed = result.transform.compose(misalignment);
672        let probe = Vec3::new(0.3, 0.4, 0.2);
673        let restored = composed.transform_point(probe);
674        assert!((restored.x - probe.x).abs() < 1e-2);
675        assert!((restored.y - probe.y).abs() < 1e-2);
676        assert!((restored.z - probe.z).abs() < 1e-2);
677    }
678
679    #[test]
680    fn rejects_tiny_neighbor_count() {
681        let cloud = box_corner();
682        let gicp = GicpRegistration::new(GicpConfig { k_neighbors: 2, ..GicpConfig::default() });
683        assert!(gicp.align(&cloud, &cloud).is_err());
684    }
685
686    #[cfg(all(feature = "register-gicp-gpu", not(target_arch = "wasm32")))]
687    #[test]
688    fn aligns_with_gpu_covariances() {
689        // Skip gracefully when no GPU/software adapter is available.
690        if spatialrust_gpu::WgpuRuntime::shared().is_err() {
691            return;
692        }
693        let target = box_corner();
694        let misalignment = Isometry3::new(
695            Quat::from_axis_angle(Vec3::new(0.0, 0.0, 1.0), 0.05),
696            Vec3::new(0.015, -0.01, 0.012),
697        );
698        let source = transform_point_cloud(&target, misalignment).unwrap();
699
700        let gicp = GicpRegistration::new(GicpConfig {
701            max_correspondence_distance: 0.2,
702            max_iterations: 40,
703            covariance_radius: Some(0.16),
704            ..GicpConfig::default()
705        });
706        let result = gicp.align(&source, &target).unwrap();
707        let composed = result.transform.compose(misalignment);
708        let probe = Vec3::new(0.3, 0.4, 0.2);
709        let restored = composed.transform_point(probe);
710        assert!((restored.x - probe.x).abs() < 2e-2, "x off: {}", restored.x);
711        assert!((restored.y - probe.y).abs() < 2e-2, "y off: {}", restored.y);
712        assert!((restored.z - probe.z).abs() < 2e-2, "z off: {}", restored.z);
713    }
714}