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}