Skip to main content

rigidity_spatial/
lib.rs

1//! Spatial indices.
2//!
3//! This crate is separate from the core because the core's direct
4//! dependencies are fixed and checked in CI: `kiddo` does not fit there,
5//! and file I/O is no place for a data structure.
6//!
7//! The core defines the [`NeighborSearch`] contract and a brute-force
8//! reference implementation; the working one lives here. Drawing the
9//! boundary at a trait rather than a type leaves room for a grid-based or
10//! GPU search later without touching ICP.
11
12use kiddo::{ImmutableKdTree, SquaredEuclidean};
13use nalgebra::Vector3;
14use rigidity_core::{Neighbor, NeighborSearch, PointCloud, neighbors::compare_neighbors};
15use std::num::NonZeroUsize;
16
17/// An error while building the index.
18#[derive(Debug, thiserror::Error)]
19pub enum SpatialError {
20    /// Kiddo failed to build the tree.
21    #[error("kd-tree construction failed: {0}")]
22    Construction(String),
23}
24
25/// A kd-tree built over a cloud.
26///
27/// The tree is built in **absolute** coordinates, the same ones
28/// [`PointCloud::point`] returns. That makes distances bit-for-bit
29/// comparable with the brute-force reference; otherwise the agreement test
30/// would need a tolerance and would stop catching indexing mistakes.
31pub struct KdTree {
32    tree: ImmutableKdTree<f64, 3>,
33    len: usize,
34}
35
36impl KdTree {
37    /// Builds a tree over a cloud.
38    pub fn build(cloud: &PointCloud) -> Result<Self, SpatialError> {
39        Self::build_observed(cloud, |_, _| {})
40    }
41
42    /// The same, reporting progress after each internal phase.
43    ///
44    /// `progress` receives `(completed, total)` in phases: the
45    /// coordinates are extracted, then the tree is built. Two steps is all
46    /// the resolution there is — `kiddo` builds the tree in one opaque
47    /// call, and on a large cloud that call is the larger half of the
48    /// wait. Pretending otherwise would mean a progress bar that lies.
49    pub fn build_observed<F>(cloud: &PointCloud, mut progress: F) -> Result<Self, SpatialError>
50    where
51        F: FnMut(usize, usize),
52    {
53        /// Coordinates, then construction.
54        const PHASES: usize = 2;
55
56        let entries: Vec<[f64; 3]> = (0..cloud.len())
57            .map(|i| {
58                let p = cloud.point(i);
59                [p.x, p.y, p.z]
60            })
61            .collect();
62        progress(1, PHASES);
63
64        let tree = ImmutableKdTree::new_from_slice(&entries)
65            .map_err(|e| SpatialError::Construction(e.to_string()))?;
66        progress(2, PHASES);
67
68        Ok(Self {
69            tree,
70            len: cloud.len(),
71        })
72    }
73
74    /// Number of indexed points.
75    pub fn len(&self) -> usize {
76        self.len
77    }
78
79    /// Whether the tree is empty.
80    pub fn is_empty(&self) -> bool {
81        self.len == 0
82    }
83}
84
85impl NeighborSearch for KdTree {
86    fn knn_into(&self, query: &Vector3<f64>, k: usize, out: &mut Vec<Neighbor>) {
87        out.clear();
88        let Some(count) = NonZeroUsize::new(k.min(self.len)) else {
89            return;
90        };
91        let point = [query.x, query.y, query.z];
92
93        // Fast path: ICP asks for exactly one neighbour, while `nearest_n`
94        // returns a `Vec` and therefore allocates per query. On a million
95        // points that is a million allocator calls per iteration.
96        // `nearest_one` returns a single value instead.
97        if count.get() == 1 {
98            let found = self
99                .tree
100                .query(&point)
101                .nearest_one::<SquaredEuclidean<f64>>()
102                .execute();
103            out.push(Neighbor {
104                index: found.item,
105                distance_squared: found.distance,
106            });
107            return;
108        }
109
110        let found = self
111            .tree
112            .query(&point)
113            .nearest_n::<SquaredEuclidean<f64>>(count)
114            .execute();
115        for item in found {
116            out.push(Neighbor {
117                index: item.item,
118                distance_squared: item.distance,
119            });
120        }
121        // kiddo makes no promise about breaking ties by index, so impose
122        // the contract here.
123        out.sort_unstable_by(compare_neighbors);
124    }
125
126    fn radius_into(&self, query: &Vector3<f64>, radius: f64, out: &mut Vec<Neighbor>) {
127        out.clear();
128        let point = [query.x, query.y, query.z];
129        // The threshold is in the metric's own units: for SquaredEuclidean
130        // that is a squared distance, not a distance.
131        let found = self
132            .tree
133            .query(&point)
134            .within::<SquaredEuclidean<f64>>(radius * radius)
135            .execute();
136        for item in found {
137            out.push(Neighbor {
138                index: item.item,
139                distance_squared: item.distance,
140            });
141        }
142        out.sort_unstable_by(compare_neighbors);
143    }
144}