Skip to main content

threecrate_algorithms/
features.rs

1//! Feature extraction algorithms
2
3use threecrate_core::{PointCloud, Result, Point3f, NormalPoint3f, Vector3f, Error};
4use nalgebra::Matrix3;
5use rayon::prelude::*;
6use std::cmp::Ordering;
7
8/// Number of bins per angular feature sub-histogram
9const FPFH_BINS: usize = 11;
10
11/// Total FPFH descriptor dimensionality (3 sub-histograms × 11 bins)
12pub const FPFH_DIM: usize = 33;
13
14/// Configuration for FPFH feature extraction
15#[derive(Debug, Clone)]
16pub struct FpfhConfig {
17    /// Radius for neighbor search. Points within this distance contribute to the descriptor.
18    pub search_radius: f32,
19    /// Fallback number of nearest neighbors when radius search yields fewer than this many points.
20    pub k_neighbors: usize,
21}
22
23impl Default for FpfhConfig {
24    fn default() -> Self {
25        Self {
26            search_radius: 0.1,
27            k_neighbors: 10,
28        }
29    }
30}
31
32/// Compute the Darboux frame angular features (α, φ, θ) for a point pair.
33///
34/// Based on Rusu et al. (2009) "Fast Point Feature Histograms (FPFH) for 3D Registration"
35fn compute_pair_features(
36    p_s: Point3f,
37    n_s: threecrate_core::Vector3f,
38    p_t: Point3f,
39    n_t: threecrate_core::Vector3f,
40) -> Option<(f32, f32, f32)> {
41    let delta = p_t - p_s;
42    let dist = delta.magnitude();
43
44    if dist < 1e-10 {
45        return None;
46    }
47
48    let d = delta / dist; // unit direction vector from p_s to p_t
49
50    let u = n_s; // u = n_s
51    let v_unnorm = u.cross(&d); // v = u × d
52    let v_mag = v_unnorm.magnitude();
53
54    if v_mag < 1e-10 {
55        // n_s and d are parallel — degenerate pair, skip
56        return None;
57    }
58
59    let v = v_unnorm / v_mag;
60    let w = u.cross(&v);
61
62    let alpha = v.dot(&n_t); // α = v · n_t  ∈ [-1, 1]
63    let phi = u.dot(&d); // φ = u · d    ∈ [-1, 1]
64    let theta = w.dot(&n_t).atan2(u.dot(&n_t)); // θ = atan2(w·n_t, u·n_t) ∈ [-π, π]
65
66    Some((alpha, phi, theta))
67}
68
69/// Map a value in `[lo, hi]` to a bin index in `[0, n_bins)`.
70#[inline]
71fn to_bin(value: f32, lo: f32, hi: f32, n_bins: usize) -> usize {
72    let normalised = (value - lo) / (hi - lo);
73    let bin = (normalised * n_bins as f32) as usize;
74    bin.min(n_bins - 1)
75}
76
77/// Compute the SPFH (Simplified Point Feature Histogram) for a single point.
78fn compute_spfh(
79    query_idx: usize,
80    points: &[NormalPoint3f],
81    neighbor_indices: &[usize],
82) -> [f32; FPFH_DIM] {
83    let mut histogram = [0.0f32; FPFH_DIM];
84    let mut count = 0usize;
85
86    let p_s = points[query_idx].position;
87    let n_s = points[query_idx].normal;
88
89    for &nb_idx in neighbor_indices {
90        if nb_idx == query_idx {
91            continue;
92        }
93        let p_t = points[nb_idx].position;
94        let n_t = points[nb_idx].normal;
95
96        if let Some((alpha, phi, theta)) = compute_pair_features(p_s, n_s, p_t, n_t) {
97            let bin_alpha = to_bin(alpha, -1.0, 1.0, FPFH_BINS);
98            let bin_phi = to_bin(phi, -1.0, 1.0, FPFH_BINS);
99            let bin_theta =
100                to_bin(theta, -std::f32::consts::PI, std::f32::consts::PI, FPFH_BINS);
101
102            histogram[bin_alpha] += 1.0;
103            histogram[FPFH_BINS + bin_phi] += 1.0;
104            histogram[2 * FPFH_BINS + bin_theta] += 1.0;
105            count += 1;
106        }
107    }
108
109    // Normalise each sub-histogram by the number of valid pairs
110    if count > 0 {
111        let scale = 1.0 / count as f32;
112        for h in histogram.iter_mut() {
113            *h *= scale;
114        }
115    }
116
117    histogram
118}
119
120/// Find indices of neighbors for a given query point.
121///
122/// First tries radius-based search. Falls back to k-NN when radius yields
123/// fewer than `config.k_neighbors` points.
124fn find_neighbors(points: &[NormalPoint3f], query_idx: usize, config: &FpfhConfig) -> Vec<usize> {
125    let query = &points[query_idx].position;
126    let radius_sq = config.search_radius * config.search_radius;
127
128    let mut within_radius: Vec<(usize, f32)> = points
129        .iter()
130        .enumerate()
131        .filter_map(|(i, p)| {
132            if i == query_idx {
133                return None;
134            }
135            let d_sq = (p.position - query).magnitude_squared();
136            if d_sq <= radius_sq {
137                Some((i, d_sq))
138            } else {
139                None
140            }
141        })
142        .collect();
143
144    if within_radius.len() >= config.k_neighbors {
145        within_radius
146            .sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
147        return within_radius.into_iter().map(|(i, _)| i).collect();
148    }
149
150    // Fallback: k-NN over all points
151    let mut all: Vec<(usize, f32)> = points
152        .iter()
153        .enumerate()
154        .filter_map(|(i, p)| {
155            if i == query_idx {
156                return None;
157            }
158            Some((i, (p.position - query).magnitude_squared()))
159        })
160        .collect();
161
162    all.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
163    all.truncate(config.k_neighbors);
164    all.into_iter().map(|(i, _)| i).collect()
165}
166
167/// Extract FPFH (Fast Point Feature Histograms) features from a point cloud
168/// with pre-computed normals.
169///
170/// Returns a 33-dimensional descriptor `[f32; 33]` per point consisting of
171/// three 11-bin sub-histograms for the angular features α, φ and θ between
172/// each point and its neighbours.
173///
174/// Reference: Rusu et al. (2009) "Fast Point Feature Histograms (FPFH) for 3D Registration"
175///
176/// # Arguments
177/// * `cloud`  - Point cloud with pre-computed unit normals.
178/// * `config` - Search radius and k-NN fallback configuration.
179pub fn extract_fpfh_features_with_normals(
180    cloud: &PointCloud<NormalPoint3f>,
181    config: &FpfhConfig,
182) -> Result<Vec<[f32; FPFH_DIM]>> {
183    if cloud.is_empty() {
184        return Ok(Vec::new());
185    }
186
187    if config.search_radius <= 0.0 {
188        return Err(Error::InvalidData(
189            "search_radius must be positive".to_string(),
190        ));
191    }
192
193    let points = &cloud.points;
194    let n = points.len();
195
196    // Step 1: collect neighbours for every point
197    let neighbors_per_point: Vec<Vec<usize>> =
198        (0..n).map(|i| find_neighbors(points, i, config)).collect();
199
200    // Step 2: compute SPFH for every point in parallel
201    let spfh: Vec<[f32; FPFH_DIM]> = (0..n)
202        .into_par_iter()
203        .map(|i| compute_spfh(i, points, &neighbors_per_point[i]))
204        .collect();
205
206    // Step 3: FPFH(p) = SPFH(p) + (1/k) Σ (1/dist_i · SPFH(p_i))
207    let fpfh: Vec<[f32; FPFH_DIM]> = (0..n)
208        .into_par_iter()
209        .map(|i| {
210            let query_pos = &points[i].position;
211            let mut descriptor = spfh[i];
212
213            let neighbors = &neighbors_per_point[i];
214            if neighbors.is_empty() {
215                return descriptor;
216            }
217
218            let mut weight_sum = 0.0f32;
219            let mut weighted = [0.0f32; FPFH_DIM];
220
221            for &nb_idx in neighbors {
222                let dist = (points[nb_idx].position - query_pos).magnitude();
223                if dist < 1e-10 {
224                    continue;
225                }
226                let w = 1.0 / dist;
227                weight_sum += w;
228                for (j, val) in spfh[nb_idx].iter().enumerate() {
229                    weighted[j] += w * val;
230                }
231            }
232
233            if weight_sum > 0.0 {
234                let inv_w = 1.0 / weight_sum;
235                for j in 0..FPFH_DIM {
236                    descriptor[j] += inv_w * weighted[j];
237                }
238
239                // Renormalise each 11-bin sub-histogram so values sum to 1
240                for part in 0..3usize {
241                    let start = part * FPFH_BINS;
242                    let end = start + FPFH_BINS;
243                    let sum: f32 = descriptor[start..end].iter().sum();
244                    if sum > 0.0 {
245                        for h in &mut descriptor[start..end] {
246                            *h /= sum;
247                        }
248                    }
249                }
250            }
251
252            descriptor
253        })
254        .collect();
255
256    Ok(fpfh)
257}
258
259/// Extract FPFH features from a plain point cloud, estimating normals first.
260///
261/// Normals are estimated using k = 10 nearest neighbours. For better control,
262/// pre-compute normals with [`crate::normals::estimate_normals`] and call
263/// [`extract_fpfh_features_with_normals`] directly.
264///
265/// Returns a 33-element `Vec<f32>` descriptor per point.
266pub fn extract_fpfh_features(cloud: &PointCloud<Point3f>) -> Result<Vec<Vec<f32>>> {
267    use crate::normals::estimate_normals;
268
269    if cloud.is_empty() {
270        return Ok(Vec::new());
271    }
272
273    if cloud.len() < 3 {
274        return Err(Error::InvalidData(
275            "At least 3 points are required to estimate normals for FPFH".to_string(),
276        ));
277    }
278
279    let cloud_with_normals = estimate_normals(cloud, 10)?;
280    let config = FpfhConfig::default();
281    let features = extract_fpfh_features_with_normals(&cloud_with_normals, &config)?;
282    Ok(features.into_iter().map(|f| f.to_vec()).collect())
283}
284
285// =============================================================================
286// SHOT (Signature of Histograms of OrienTations)
287// Reference: Tombari, Salti & Di Stefano (2010)
288// "Unique Signatures of Histograms for Local Surface Description"
289// =============================================================================
290
291/// Azimuth sectors in SHOT's spherical partition
292const SHOT_N_AZIMUTH: usize = 8;
293/// Elevation hemispheres (south / north of LRF z-axis)
294const SHOT_N_ELEVATION: usize = 2;
295/// Radial shells (inner / outer half of support sphere)
296const SHOT_N_RADIAL: usize = 2;
297/// Normal-orientation histogram bins per SHOT volume
298const SHOT_N_BINS: usize = 11;
299/// Total number of SHOT volumes: 8 × 2 × 2 = 32
300const SHOT_N_VOLUMES: usize = SHOT_N_AZIMUTH * SHOT_N_ELEVATION * SHOT_N_RADIAL;
301/// Total SHOT descriptor dimensionality: 32 volumes × 11 bins = 352
302pub const SHOT_DIM: usize = SHOT_N_VOLUMES * SHOT_N_BINS;
303
304/// Azimuth sectors in USC's spatial histogram
305const USC_N_AZIMUTH: usize = 8;
306/// Elevation (cosine-uniform) divisions in USC
307const USC_N_ELEVATION: usize = 4;
308/// Radial shells in USC
309const USC_N_RADIAL: usize = 4;
310/// Total USC descriptor dimensionality: 8 × 4 × 4 = 128
311pub const USC_DIM: usize = USC_N_AZIMUTH * USC_N_ELEVATION * USC_N_RADIAL;
312
313/// Which SHOT-family descriptor to compute
314#[derive(Debug, Clone, Copy, PartialEq, Default)]
315pub enum ShotVariant {
316    /// Standard SHOT — 352-dimensional histogram of surface-normal orientations per volume
317    #[default]
318    Standard,
319    /// Unique Shape Context — 128-dimensional spatial density histogram sharing the same LRF
320    UniqueShapeContext,
321}
322
323/// Configuration for SHOT / USC feature extraction
324#[derive(Debug, Clone)]
325pub struct ShotConfig {
326    /// Support sphere radius (same role as `FpfhConfig::search_radius`)
327    pub search_radius: f32,
328    /// Minimum neighbors from radius search; falls back to k-NN when fewer found
329    pub k_neighbors: usize,
330    /// Which descriptor variant to compute
331    pub variant: ShotVariant,
332}
333
334impl Default for ShotConfig {
335    fn default() -> Self {
336        Self {
337            search_radius: 0.2,
338            k_neighbors: 10,
339            variant: ShotVariant::Standard,
340        }
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Internal helpers
346// ---------------------------------------------------------------------------
347
348/// Find neighbor indices for SHOT (radius search with k-NN fallback).
349fn find_shot_neighbors(
350    points: &[NormalPoint3f],
351    query_idx: usize,
352    config: &ShotConfig,
353) -> Vec<usize> {
354    let query = &points[query_idx].position;
355    let r_sq = config.search_radius * config.search_radius;
356
357    let mut within: Vec<(usize, f32)> = points
358        .iter()
359        .enumerate()
360        .filter_map(|(i, p)| {
361            if i == query_idx {
362                return None;
363            }
364            let d_sq = (p.position - query).magnitude_squared();
365            if d_sq <= r_sq { Some((i, d_sq)) } else { None }
366        })
367        .collect();
368
369    if within.len() >= config.k_neighbors {
370        within.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
371        return within.into_iter().map(|(i, _)| i).collect();
372    }
373
374    // k-NN fallback
375    let mut all: Vec<(usize, f32)> = points
376        .iter()
377        .enumerate()
378        .filter_map(|(i, p)| {
379            if i == query_idx { None }
380            else { Some((i, (p.position - query).magnitude_squared())) }
381        })
382        .collect();
383    all.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
384    all.truncate(config.k_neighbors);
385    all.into_iter().map(|(i, _)| i).collect()
386}
387
388/// Compute the SHOT Local Reference Frame (LRF).
389///
390/// Returns (x_axis, y_axis, z_axis) forming a right-handed coordinate frame.
391/// The z-axis is the disambiguated surface normal; x-axis is derived from the
392/// weighted covariance of the neighborhood.
393fn compute_shot_lrf(
394    query_pos: threecrate_core::Point3f,
395    query_normal: Vector3f,
396    neighbors: &[usize],
397    points: &[NormalPoint3f],
398    radius: f32,
399) -> (Vector3f, Vector3f, Vector3f) {
400    // --- z-axis: use provided normal, then disambiguate direction ---
401    let mut z_axis = if query_normal.magnitude() > 1e-10 {
402        query_normal.normalize()
403    } else {
404        Vector3f::new(0.0, 0.0, 1.0)
405    };
406
407    let n_pos_z = neighbors.iter()
408        .filter(|&&i| z_axis.dot(&(points[i].position - query_pos)) >= 0.0)
409        .count();
410    if n_pos_z * 2 < neighbors.len() {
411        z_axis = -z_axis;
412    }
413
414    // --- x-axis: eigenvector of largest eigenvalue of the weighted covariance ---
415    let mut cov = Matrix3::<f32>::zeros();
416    for &i in neighbors {
417        let dv = points[i].position - query_pos; // Vector3f
418        let dist = dv.magnitude();
419        let w = (radius - dist).max(0.0);
420        cov += w * dv * dv.transpose();
421    }
422
423    let eig = cov.symmetric_eigen();
424    let (max_idx, _) = eig.eigenvalues.iter().enumerate()
425        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(Ordering::Equal))
426        .unwrap_or((0, &0.0));
427    let col = eig.eigenvectors.column(max_idx);
428    let mut x_axis = Vector3f::new(col[0], col[1], col[2]);
429
430    // Disambiguate
431    let n_pos_x = neighbors.iter()
432        .filter(|&&i| x_axis.dot(&(points[i].position - query_pos)) >= 0.0)
433        .count();
434    if n_pos_x * 2 < neighbors.len() {
435        x_axis = -x_axis;
436    }
437
438    // Project onto tangent plane and renormalize
439    let x_proj = x_axis - z_axis * z_axis.dot(&x_axis);
440    let x_axis = if x_proj.magnitude() > 1e-10 {
441        x_proj.normalize()
442    } else {
443        let c = Vector3f::new(1.0, 0.0, 0.0);
444        let p = c - z_axis * z_axis.dot(&c);
445        if p.magnitude() > 1e-10 {
446            p.normalize()
447        } else {
448            let c2 = Vector3f::new(0.0, 1.0, 0.0);
449            (c2 - z_axis * z_axis.dot(&c2)).normalize()
450        }
451    };
452
453    let y_axis = z_axis.cross(&x_axis);
454    (x_axis, y_axis, z_axis)
455}
456
457/// Compute a single 352-dimensional SHOT descriptor.
458fn compute_shot_impl(
459    query_idx: usize,
460    points: &[NormalPoint3f],
461    neighbors: &[usize],
462    radius: f32,
463) -> [f32; SHOT_DIM] {
464    let mut desc = [0.0f32; SHOT_DIM];
465    if neighbors.is_empty() {
466        return desc;
467    }
468
469    let qp = points[query_idx].position;
470    let (x_axis, y_axis, z_axis) =
471        compute_shot_lrf(qp, points[query_idx].normal, neighbors, points, radius);
472
473    let mut vol_counts = [0u32; SHOT_N_VOLUMES];
474
475    for &ni in neighbors {
476        if ni == query_idx { continue; }
477        let dv = points[ni].position - qp;
478        let dist = dv.magnitude();
479        if dist < 1e-10 || dist > radius { continue; }
480
481        let lx = x_axis.dot(&dv);
482        let ly = y_axis.dot(&dv);
483        let lz = z_axis.dot(&dv);
484
485        let r_bin = if dist <= radius * 0.5 { 0 } else { 1 };
486        let e_bin = if lz < 0.0 { 0 } else { 1 };
487        let az_norm = (ly.atan2(lx) + std::f32::consts::PI) / (2.0 * std::f32::consts::PI);
488        let a_bin = ((az_norm * SHOT_N_AZIMUTH as f32) as usize).min(SHOT_N_AZIMUTH - 1);
489
490        let vol = r_bin * (SHOT_N_ELEVATION * SHOT_N_AZIMUTH) + e_bin * SHOT_N_AZIMUTH + a_bin;
491
492        let cos_theta = z_axis.dot(&points[ni].normal).clamp(-1.0, 1.0);
493        let n_bin = to_bin(cos_theta, -1.0, 1.0, SHOT_N_BINS);
494
495        desc[vol * SHOT_N_BINS + n_bin] += 1.0;
496        vol_counts[vol] += 1;
497    }
498
499    // Normalize each sub-histogram by its volume's point count
500    for vol in 0..SHOT_N_VOLUMES {
501        let c = vol_counts[vol] as f32;
502        if c > 0.0 {
503            for v in &mut desc[vol * SHOT_N_BINS..(vol + 1) * SHOT_N_BINS] {
504                *v /= c;
505            }
506        }
507    }
508
509    // L2-normalize the full descriptor
510    let norm: f32 = desc.iter().map(|&v| v * v).sum::<f32>().sqrt();
511    if norm > 1e-10 {
512        for v in &mut desc { *v /= norm; }
513    }
514    desc
515}
516
517/// Compute a single 128-dimensional USC descriptor.
518///
519/// USC encodes spatial point density in a 3-D histogram (azimuth × elevation × radial)
520/// using the same LRF as SHOT, giving a rotationally unique but normal-free descriptor.
521fn compute_usc_impl(
522    query_idx: usize,
523    points: &[NormalPoint3f],
524    neighbors: &[usize],
525    radius: f32,
526) -> [f32; USC_DIM] {
527    let mut desc = [0.0f32; USC_DIM];
528    if neighbors.is_empty() {
529        return desc;
530    }
531
532    let qp = points[query_idx].position;
533    let (x_axis, y_axis, z_axis) =
534        compute_shot_lrf(qp, points[query_idx].normal, neighbors, points, radius);
535
536    let mut total = 0usize;
537    for &ni in neighbors {
538        if ni == query_idx { continue; }
539        let dv = points[ni].position - qp;
540        let dist = dv.magnitude();
541        if dist < 1e-10 || dist > radius { continue; }
542
543        let lx = x_axis.dot(&dv);
544        let ly = y_axis.dot(&dv);
545        let lz = z_axis.dot(&dv);
546
547        // Azimuth: 8 uniform sectors in the tangent plane
548        let az_norm = (ly.atan2(lx) + std::f32::consts::PI) / (2.0 * std::f32::consts::PI);
549        let a_bin = ((az_norm * USC_N_AZIMUTH as f32) as usize).min(USC_N_AZIMUTH - 1);
550
551        // Elevation: cosine-uniform over [-1, 1] → 4 equal-area polar bands
552        let cos_el = (lz / dist).clamp(-1.0, 1.0);
553        let e_bin = to_bin(cos_el, -1.0, 1.0, USC_N_ELEVATION);
554
555        // Radial: 4 equal-width shells over [0, radius]
556        let r_bin = ((dist / radius * USC_N_RADIAL as f32) as usize).min(USC_N_RADIAL - 1);
557
558        let bin = a_bin * (USC_N_ELEVATION * USC_N_RADIAL) + e_bin * USC_N_RADIAL + r_bin;
559        desc[bin] += 1.0;
560        total += 1;
561    }
562
563    if total > 0 {
564        let scale = 1.0 / total as f32;
565        for v in &mut desc { *v *= scale; }
566    }
567
568    // L2-normalize
569    let norm: f32 = desc.iter().map(|&v| v * v).sum::<f32>().sqrt();
570    if norm > 1e-10 {
571        for v in &mut desc { *v /= norm; }
572    }
573    desc
574}
575
576// ---------------------------------------------------------------------------
577// Public API
578// ---------------------------------------------------------------------------
579
580/// Extract SHOT or USC features from a point cloud with pre-computed normals.
581///
582/// Selects the descriptor variant via `config.variant`:
583/// - [`ShotVariant::Standard`] → 352-dimensional SHOT descriptor per point
584/// - [`ShotVariant::UniqueShapeContext`] → 128-dimensional USC descriptor per point
585///
586/// Computation is parallelised over points with rayon.
587///
588/// Reference: Tombari, Salti & Di Stefano (2010)
589/// "Unique Signatures of Histograms for Local Surface Description", ECCV 2010.
590///
591/// # Arguments
592/// * `cloud`  – Point cloud with pre-computed unit normals.
593/// * `config` – Search radius, k-NN fallback, and variant selection.
594pub fn extract_shot_features_with_normals(
595    cloud: &PointCloud<NormalPoint3f>,
596    config: &ShotConfig,
597) -> Result<Vec<Vec<f32>>> {
598    if cloud.is_empty() {
599        return Ok(Vec::new());
600    }
601    if config.search_radius <= 0.0 {
602        return Err(Error::InvalidData("search_radius must be positive".into()));
603    }
604
605    let points = &cloud.points;
606    let n = points.len();
607
608    // Gather neighbors (sequential — avoids borrowing issues with par_iter)
609    let neighbors: Vec<Vec<usize>> =
610        (0..n).map(|i| find_shot_neighbors(points, i, config)).collect();
611
612    // Compute descriptors in parallel
613    let descriptors: Vec<Vec<f32>> = (0..n)
614        .into_par_iter()
615        .map(|i| match config.variant {
616            ShotVariant::Standard =>
617                compute_shot_impl(i, points, &neighbors[i], config.search_radius).to_vec(),
618            ShotVariant::UniqueShapeContext =>
619                compute_usc_impl(i, points, &neighbors[i], config.search_radius).to_vec(),
620        })
621        .collect();
622
623    Ok(descriptors)
624}
625
626#[cfg(test)]
627mod shot_tests {
628    use super::*;
629    use threecrate_core::{NormalPoint3f, Vector3f, Point3f, PointCloud};
630
631    fn make_plane(n: usize) -> PointCloud<NormalPoint3f> {
632        let side = (n as f64).sqrt().ceil() as usize;
633        let step = 1.0 / side as f32;
634        let mut cloud = PointCloud::new();
635        'outer: for i in 0..side {
636            for j in 0..side {
637                if cloud.len() == n { break 'outer; }
638                cloud.push(NormalPoint3f {
639                    position: Point3f::new(i as f32 * step, j as f32 * step, 0.0),
640                    normal: Vector3f::new(0.0, 0.0, 1.0),
641                });
642            }
643        }
644        cloud
645    }
646
647    #[test]
648    fn test_shot_empty_cloud() {
649        let cloud = PointCloud::<NormalPoint3f>::new();
650        let result = extract_shot_features_with_normals(&cloud, &ShotConfig::default()).unwrap();
651        assert!(result.is_empty());
652    }
653
654    #[test]
655    fn test_shot_descriptor_dim() {
656        let cloud = make_plane(25);
657        let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
658        let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
659        assert_eq!(result.len(), 25);
660        for d in &result { assert_eq!(d.len(), SHOT_DIM); }
661    }
662
663    #[test]
664    fn test_usc_descriptor_dim() {
665        let cloud = make_plane(25);
666        let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::UniqueShapeContext };
667        let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
668        assert_eq!(result.len(), 25);
669        for d in &result { assert_eq!(d.len(), USC_DIM); }
670    }
671
672    #[test]
673    fn test_shot_non_negative() {
674        let cloud = make_plane(25);
675        let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
676        let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
677        for d in &result {
678            for &v in d { assert!(v >= 0.0, "negative value: {v}"); }
679        }
680    }
681
682    #[test]
683    fn test_shot_l2_normalized_or_zero() {
684        let cloud = make_plane(25);
685        let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
686        let result = extract_shot_features_with_normals(&cloud, &config).unwrap();
687        for d in &result {
688            let norm: f32 = d.iter().map(|&v| v * v).sum::<f32>().sqrt();
689            assert!(norm < 1e-6 || (norm - 1.0).abs() < 1e-4, "norm={norm}");
690        }
691    }
692
693    #[test]
694    fn test_shot_invalid_radius() {
695        let cloud = make_plane(9);
696        let config = ShotConfig { search_radius: -0.1, k_neighbors: 5, variant: ShotVariant::Standard };
697        assert!(extract_shot_features_with_normals(&cloud, &config).is_err());
698    }
699
700    #[test]
701    fn test_shot_deterministic() {
702        let cloud = make_plane(25);
703        let config = ShotConfig { search_radius: 0.5, k_neighbors: 5, variant: ShotVariant::Standard };
704        let r1 = extract_shot_features_with_normals(&cloud, &config).unwrap();
705        let r2 = extract_shot_features_with_normals(&cloud, &config).unwrap();
706        for (d1, d2) in r1.iter().zip(&r2) {
707            for (&v1, &v2) in d1.iter().zip(d2) {
708                assert!((v1 - v2).abs() < 1e-6);
709            }
710        }
711    }
712
713    #[test]
714    fn test_shot_plane_vs_sphere_differ() {
715        let plane = make_plane(25);
716        let config = ShotConfig { search_radius: 0.6, k_neighbors: 8, variant: ShotVariant::Standard };
717        let plane_descs = extract_shot_features_with_normals(&plane, &config).unwrap();
718
719        let mut sphere = PointCloud::<NormalPoint3f>::new();
720        for i in 0..5usize {
721            for j in 0..5usize {
722                let theta = std::f32::consts::PI * i as f32 / 4.0;
723                let phi   = 2.0 * std::f32::consts::PI * j as f32 / 5.0;
724                let x = theta.sin() * phi.cos();
725                let y = theta.sin() * phi.sin();
726                let z = theta.cos();
727                sphere.push(NormalPoint3f {
728                    position: Point3f::new(x, y, z),
729                    normal:   Vector3f::new(x, y, z),
730                });
731            }
732        }
733        let sphere_descs = extract_shot_features_with_normals(&sphere, &config).unwrap();
734
735        let any_different = plane_descs.iter().any(|pd| {
736            sphere_descs.iter().any(|sd| {
737                pd.iter().zip(sd).map(|(a, b)| (a - b).abs()).sum::<f32>() > 0.05
738            })
739        });
740        assert!(any_different, "plane and sphere descriptors should differ");
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use threecrate_core::{NormalPoint3f, Vector3f};
748
749    /// Build a flat XY-plane point cloud with upward normals.
750    fn make_plane_cloud(n: usize) -> PointCloud<NormalPoint3f> {
751        let side = (n as f64).sqrt().ceil() as usize;
752        let step = 1.0 / side as f32;
753        let mut cloud = PointCloud::new();
754        'outer: for i in 0..side {
755            for j in 0..side {
756                if cloud.len() == n {
757                    break 'outer;
758                }
759                cloud.push(NormalPoint3f {
760                    position: Point3f::new(i as f32 * step, j as f32 * step, 0.0),
761                    normal: Vector3f::new(0.0, 0.0, 1.0),
762                });
763            }
764        }
765        cloud
766    }
767
768    #[test]
769    fn test_fpfh_empty_cloud() {
770        let cloud = PointCloud::<NormalPoint3f>::new();
771        let config = FpfhConfig::default();
772        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
773        assert!(result.is_empty());
774    }
775
776    #[test]
777    fn test_fpfh_descriptor_dimension() {
778        let cloud = make_plane_cloud(25);
779        let config = FpfhConfig {
780            search_radius: 0.5,
781            k_neighbors: 5,
782        };
783        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
784        assert_eq!(result.len(), cloud.len());
785        for desc in &result {
786            assert_eq!(desc.len(), FPFH_DIM);
787        }
788    }
789
790    #[test]
791    fn test_fpfh_descriptor_non_negative() {
792        let cloud = make_plane_cloud(25);
793        let config = FpfhConfig {
794            search_radius: 0.5,
795            k_neighbors: 5,
796        };
797        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
798        for desc in &result {
799            for &v in desc.iter() {
800                assert!(v >= 0.0, "Descriptor value must be non-negative, got {}", v);
801            }
802        }
803    }
804
805    #[test]
806    fn test_fpfh_sub_histograms_normalised() {
807        let cloud = make_plane_cloud(36);
808        let config = FpfhConfig {
809            search_radius: 0.5,
810            k_neighbors: 8,
811        };
812        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
813        for desc in &result {
814            for part in 0..3 {
815                let start = part * FPFH_BINS;
816                let end = start + FPFH_BINS;
817                let sum: f32 = desc[start..end].iter().sum();
818                assert!(
819                    sum < 1e-6 || (sum - 1.0).abs() < 1e-4,
820                    "Sub-histogram {} sum = {}, expected 0 or ~1.0",
821                    part,
822                    sum
823                );
824            }
825        }
826    }
827
828    #[test]
829    fn test_fpfh_identical_clouds_same_descriptors() {
830        let cloud = make_plane_cloud(25);
831        let config = FpfhConfig {
832            search_radius: 0.5,
833            k_neighbors: 5,
834        };
835        let r1 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
836        let r2 = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
837        for (d1, d2) in r1.iter().zip(r2.iter()) {
838            for (&v1, &v2) in d1.iter().zip(d2.iter()) {
839                assert!((v1 - v2).abs() < 1e-6);
840            }
841        }
842    }
843
844    #[test]
845    fn test_fpfh_plane_vs_sphere_differ() {
846        let plane = make_plane_cloud(25);
847        let config = FpfhConfig {
848            search_radius: 0.5,
849            k_neighbors: 5,
850        };
851        let plane_desc = extract_fpfh_features_with_normals(&plane, &config).unwrap();
852
853        // Build a spherical cloud
854        let mut sphere = PointCloud::<NormalPoint3f>::new();
855        let steps = 5usize;
856        for i in 0..steps {
857            for j in 0..steps {
858                let theta = std::f32::consts::PI * i as f32 / (steps - 1) as f32;
859                let phi = 2.0 * std::f32::consts::PI * j as f32 / steps as f32;
860                let x = theta.sin() * phi.cos();
861                let y = theta.sin() * phi.sin();
862                let z = theta.cos();
863                sphere.push(NormalPoint3f {
864                    position: Point3f::new(x, y, z),
865                    normal: Vector3f::new(x, y, z),
866                });
867            }
868        }
869
870        let sphere_desc = extract_fpfh_features_with_normals(&sphere, &config).unwrap();
871
872        let mut any_different = false;
873        'outer: for pd in &plane_desc {
874            for sd in &sphere_desc {
875                let diff: f32 = pd.iter().zip(sd.iter()).map(|(a, b)| (a - b).abs()).sum();
876                if diff > 0.1 {
877                    any_different = true;
878                    break 'outer;
879                }
880            }
881        }
882        assert!(
883            any_different,
884            "Plane and sphere descriptors should differ significantly"
885        );
886    }
887
888    #[test]
889    fn test_fpfh_from_xyz() {
890        let mut cloud = PointCloud::<Point3f>::new();
891        for i in 0..5 {
892            for j in 0..5 {
893                cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
894            }
895        }
896        let result = extract_fpfh_features(&cloud).unwrap();
897        assert_eq!(result.len(), cloud.len());
898        for desc in &result {
899            assert_eq!(desc.len(), FPFH_DIM);
900        }
901    }
902
903    #[test]
904    fn test_fpfh_invalid_radius() {
905        let cloud = make_plane_cloud(9);
906        let config = FpfhConfig {
907            search_radius: -1.0,
908            k_neighbors: 5,
909        };
910        let result = extract_fpfh_features_with_normals(&cloud, &config);
911        assert!(result.is_err());
912    }
913
914    #[test]
915    fn test_fpfh_single_point_all_zero() {
916        let mut cloud = PointCloud::<NormalPoint3f>::new();
917        cloud.push(NormalPoint3f {
918            position: Point3f::new(0.0, 0.0, 0.0),
919            normal: Vector3f::new(0.0, 0.0, 1.0),
920        });
921        let config = FpfhConfig {
922            search_radius: 1.0,
923            k_neighbors: 1,
924        };
925        let result = extract_fpfh_features_with_normals(&cloud, &config).unwrap();
926        assert_eq!(result.len(), 1);
927        assert!(
928            result[0].iter().all(|&v| v == 0.0),
929            "Single-point descriptor should be all zeros"
930        );
931    }
932}