Skip to main content

axiolid_spatial/
points.rs

1//! Nearest-neighbour and radius queries over point sets.
2//!
3//! # Exact, not broad phase
4//!
5//! The BVH in this crate answers with AABB *lower bounds*: a candidate may
6//! be further away than its bound suggests, so its results are broad-phase
7//! candidates that a narrow phase must confirm.
8//!
9//! A point has no extent, so the distance to it is exact. These queries
10//! therefore return real distances and are complete: a radius query returns
11//! every point inside the radius and nothing else. That difference is why
12//! the result types here are distinct from [`NearestCandidate`] — a caller
13//! must never mistake an exact hit for a candidate needing confirmation, or
14//! the reverse.
15//!
16//! [`NearestCandidate`]: crate::NearestCandidate
17//!
18//! # Allocation
19//!
20//! Queries take a callback and allocate nothing per hit. The index itself
21//! is built once and reused. `*_into` helpers exist for callers that do
22//! want a vector, but they are a convenience over the callback form rather
23//! than the primitive.
24//!
25//! # Determinism
26//!
27//! Ties are broken by point index, so equal distances always resolve the
28//! same way. Sorted variants order by distance then index. Without this a
29//! caller could get different neighbours across runs of identical input,
30//! which would make any downstream reconstruction irreproducible.
31
32use axiolid_core::{Point3, Scalar};
33
34/// A point found by a query, with its exact distance.
35///
36/// Distinct from a broad-phase candidate: `distance` is measured, not
37/// bounded, and needs no narrow-phase confirmation.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct PointHit {
40    /// Index of the point in the source cloud.
41    pub index: usize,
42    /// Exact distance from the query position.
43    pub distance: Scalar,
44}
45
46/// Why a query could not be answered.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum PointQueryError {
50    /// The query position is not finite.
51    NonFiniteQuery,
52    /// A radius is negative or not finite.
53    InvalidRadius,
54}
55
56impl core::fmt::Display for PointQueryError {
57    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
58        match self {
59            Self::NonFiniteQuery => f.write_str("query position is not finite"),
60            Self::InvalidRadius => f.write_str("radius must be finite and non-negative"),
61        }
62    }
63}
64
65impl core::error::Error for PointQueryError {}
66
67/// A uniform-grid index over a point set.
68///
69/// A grid rather than a k-d tree because scan data is dense and roughly
70/// uniform: bucketing is O(n) to build with no comparisons, and a radius
71/// query touches only the cells the sphere overlaps. A k-d tree wins on
72/// wildly non-uniform data, which is why this type is deliberately not the
73/// only shape the API could take — the query methods are what callers use,
74/// so the structure can change without moving them.
75#[derive(Debug, Clone)]
76pub struct PointIndex {
77    points: Vec<Point3>,
78    /// Cell edge length. Always positive and finite once constructed.
79    cell: Scalar,
80    origin: Point3,
81    /// Grid dimensions in cells.
82    dims: [usize; 3],
83    /// Start offset per cell into `ordered`, with a trailing total.
84    starts: Vec<u32>,
85    /// Point indices grouped by cell.
86    ordered: Vec<u32>,
87}
88
89impl PointIndex {
90    /// Build an index over the given positions.
91    ///
92    /// Non-finite positions are dropped from the index and reported by
93    /// [`rejected`](Self::rejected); they are not silently treated as
94    /// present at the origin, which would corrupt every query near it.
95    pub fn build(points: &[Point3]) -> Self {
96        let finite: Vec<u32> = points
97            .iter()
98            .enumerate()
99            .filter(|(_, p)| p.is_finite())
100            .map(|(index, _)| index as u32)
101            .collect();
102
103        if finite.is_empty() {
104            return Self {
105                points: points.to_vec(),
106                cell: 1.0,
107                origin: Point3::ZERO,
108                dims: [1, 1, 1],
109                starts: vec![0, 0],
110                ordered: Vec::new(),
111            };
112        }
113
114        let mut min = points[finite[0] as usize];
115        let mut max = min;
116        for &i in &finite {
117            let p = points[i as usize];
118            min = Point3::new(min.x.min(p.x), min.y.min(p.y), min.z.min(p.z));
119            max = Point3::new(max.x.max(p.x), max.y.max(p.y), max.z.max(p.z));
120        }
121        let span = max - min;
122
123        // Target roughly one point per cell: cube-root the count and divide
124        // the extent by it. A degenerate extent (all points coincident, or
125        // a planar sheet) collapses to a single cell rather than dividing
126        // by zero.
127        let target = (finite.len() as Scalar).cbrt().max(1.0);
128        let longest = span.x.max(span.y).max(span.z);
129        let cell = if longest > 0.0 {
130            (longest / target).max(Scalar::MIN_POSITIVE)
131        } else {
132            1.0
133        };
134
135        let dims = [
136            ((span.x / cell).ceil() as usize + 1).max(1),
137            ((span.y / cell).ceil() as usize + 1).max(1),
138            ((span.z / cell).ceil() as usize + 1).max(1),
139        ];
140        let cell_count = dims[0] * dims[1] * dims[2];
141
142        // Counting sort into cells: one pass to count, one to place. No
143        // per-cell Vec, so building costs two linear passes and one
144        // allocation for each of the two arrays.
145        let mut counts = vec![0u32; cell_count + 1];
146        let locate = |p: Point3| -> usize {
147            let ix = (((p.x - min.x) / cell) as usize).min(dims[0] - 1);
148            let iy = (((p.y - min.y) / cell) as usize).min(dims[1] - 1);
149            let iz = (((p.z - min.z) / cell) as usize).min(dims[2] - 1);
150            (iz * dims[1] + iy) * dims[0] + ix
151        };
152        for &i in &finite {
153            counts[locate(points[i as usize]) + 1] += 1;
154        }
155        for k in 1..counts.len() {
156            counts[k] += counts[k - 1];
157        }
158        let starts = counts.clone();
159
160        let mut cursor = counts;
161        let mut ordered = vec![0u32; finite.len()];
162        for &i in &finite {
163            let cell_index = locate(points[i as usize]);
164            ordered[cursor[cell_index] as usize] = i;
165            cursor[cell_index] += 1;
166        }
167
168        Self {
169            points: points.to_vec(),
170            cell,
171            origin: min,
172            dims,
173            starts,
174            ordered,
175        }
176    }
177
178    /// Number of positions the index was built over, including rejected.
179    pub fn len(&self) -> usize {
180        self.points.len()
181    }
182
183    /// Whether the index holds no positions.
184    pub fn is_empty(&self) -> bool {
185        self.points.is_empty()
186    }
187
188    /// Positions dropped because they were not finite.
189    pub fn rejected(&self) -> usize {
190        self.points.len() - self.ordered.len()
191    }
192
193    /// Visit every point within `radius` of `query`.
194    ///
195    /// Results are exact and complete. Visit order is unspecified; use
196    /// [`radius_into`](Self::radius_into) when order matters.
197    ///
198    /// # Errors
199    ///
200    /// Refuses a non-finite query position and a negative or non-finite
201    /// radius, rather than returning an empty result that a caller could
202    /// mistake for "nothing nearby".
203    pub fn for_each_within(
204        &self,
205        query: Point3,
206        radius: Scalar,
207        mut visit: impl FnMut(PointHit),
208    ) -> Result<(), PointQueryError> {
209        if !query.is_finite() {
210            return Err(PointQueryError::NonFiniteQuery);
211        }
212        if !radius.is_finite() || radius < 0.0 {
213            return Err(PointQueryError::InvalidRadius);
214        }
215        if self.ordered.is_empty() {
216            return Ok(());
217        }
218
219        let radius_squared = radius * radius;
220        let lo = self.cell_of(query - Point3::splat(radius));
221        let hi = self.cell_of(query + Point3::splat(radius));
222
223        for iz in lo[2]..=hi[2] {
224            for iy in lo[1]..=hi[1] {
225                for ix in lo[0]..=hi[0] {
226                    let cell_index = (iz * self.dims[1] + iy) * self.dims[0] + ix;
227                    let from = self.starts[cell_index] as usize;
228                    let to = self.starts[cell_index + 1] as usize;
229                    for &point_index in &self.ordered[from..to] {
230                        let point = self.points[point_index as usize];
231                        // Compare squared distances: the square root is
232                        // only paid for points that actually qualify.
233                        let squared = (point - query).length_squared();
234                        if squared <= radius_squared {
235                            visit(PointHit {
236                                index: point_index as usize,
237                                distance: squared.sqrt(),
238                            });
239                        }
240                    }
241                }
242            }
243        }
244        Ok(())
245    }
246
247    /// Collect every point within `radius`, ordered by distance then index.
248    ///
249    /// The vector is cleared first, so a caller may reuse one buffer across
250    /// many queries and allocate once.
251    ///
252    /// # Errors
253    ///
254    /// As [`for_each_within`](Self::for_each_within).
255    pub fn radius_into(
256        &self,
257        query: Point3,
258        radius: Scalar,
259        out: &mut Vec<PointHit>,
260    ) -> Result<(), PointQueryError> {
261        out.clear();
262        self.for_each_within(query, radius, |hit| out.push(hit))?;
263        sort_hits(out);
264        Ok(())
265    }
266
267    /// Collect the `k` nearest points, ordered by distance then index.
268    ///
269    /// Fewer than `k` are returned when the cloud holds fewer usable
270    /// points; that is a complete answer, not a truncated one.
271    ///
272    /// # Errors
273    ///
274    /// Refuses a non-finite query position.
275    pub fn nearest_into(
276        &self,
277        query: Point3,
278        k: usize,
279        out: &mut Vec<PointHit>,
280    ) -> Result<(), PointQueryError> {
281        out.clear();
282        if !query.is_finite() {
283            return Err(PointQueryError::NonFiniteQuery);
284        }
285        if k == 0 || self.ordered.is_empty() {
286            return Ok(());
287        }
288
289        // Grow a search sphere until it holds k points, then confirm with
290        // one exact radius query. Doubling keeps the number of rounds
291        // logarithmic in how badly the first guess was scaled, and the
292        // final query guarantees completeness -- expanding rings alone can
293        // miss a nearer point sitting just outside the last ring visited.
294        let mut radius = self.cell * (k as Scalar).cbrt().max(1.0);
295        let ceiling = self.diagonal();
296        loop {
297            self.radius_into(query, radius.min(ceiling), out)?;
298            if out.len() >= k || radius >= ceiling {
299                break;
300            }
301            radius *= 2.0;
302        }
303        out.truncate(k);
304        Ok(())
305    }
306
307    /// The single nearest point, if the cloud has a usable one.
308    ///
309    /// # Errors
310    ///
311    /// Refuses a non-finite query position.
312    pub fn nearest(&self, query: Point3) -> Result<Option<PointHit>, PointQueryError> {
313        let mut out = Vec::new();
314        self.nearest_into(query, 1, &mut out)?;
315        Ok(out.into_iter().next())
316    }
317
318    /// Clamp a position to a cell coordinate.
319    fn cell_of(&self, p: Point3) -> [usize; 3] {
320        let axis = |value: Scalar, origin: Scalar, dim: usize| -> usize {
321            let raw = (value - origin) / self.cell;
322            if raw < 0.0 {
323                0
324            } else {
325                (raw as usize).min(dim - 1)
326            }
327        };
328        [
329            axis(p.x, self.origin.x, self.dims[0]),
330            axis(p.y, self.origin.y, self.dims[1]),
331            axis(p.z, self.origin.z, self.dims[2]),
332        ]
333    }
334
335    /// Diagonal of the indexed extent, used as a search ceiling.
336    fn diagonal(&self) -> Scalar {
337        let span = Point3::new(
338            self.dims[0] as Scalar * self.cell,
339            self.dims[1] as Scalar * self.cell,
340            self.dims[2] as Scalar * self.cell,
341        );
342        (span.x * span.x + span.y * span.y + span.z * span.z).sqrt()
343    }
344}
345
346/// Order hits by distance, breaking ties by index.
347fn sort_hits(hits: &mut [PointHit]) {
348    hits.sort_by(|a, b| {
349        a.distance
350            .partial_cmp(&b.distance)
351            .unwrap_or(core::cmp::Ordering::Equal)
352            .then(a.index.cmp(&b.index))
353    });
354}