Skip to main content

threecrate_algorithms/
features.rs

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