Skip to main content

axiolid_pointcloud_reconstruction_sdf/
lib.rs

1#![forbid(unsafe_code)]
2//! Reference reconstruction: a signed-distance field from samples, extracted
3//! as a level set.
4//!
5//! # Why this method
6//!
7//! The kernel already owns both halves of this: [`PointIndex`] answers
8//! nearest-neighbour queries, and `axiolid-levelset` extracts a closed
9//! manifold surface from any scalar field. Composing them gives a
10//! reconstruction with **no external dependency** and no new geometric
11//! machinery to verify.
12//!
13//! That matters more than raw quality. Poisson reconstruction produces a
14//! smoother surface, but adopting it would mean vendoring a solver whose
15//! numerics we cannot audit, to satisfy a contract whose whole purpose is
16//! swappability. This provider exists so the contract is *verifiable* — a
17//! better one can replace it without any consumer noticing.
18//!
19//! # How it works
20//!
21//! For a query point `p`, find the nearest samples and estimate the signed
22//! distance to the surface they lie on:
23//!
24//! - **With normals**, project onto the neighbour's tangent plane. The sign
25//!   is which side of that plane `p` falls on, so the surface passes exactly
26//!   through the samples.
27//! - **Without normals**, use unsigned distance offset by the sample
28//!   spacing. This produces a surface *around* the points rather than
29//!   through them, which is honest: with no orientation information there is
30//!   no way to say which side is inside.
31//!
32//! The distinction is reported in the evidence, never hidden: a
33//! positions-only reconstruction is a genuinely weaker result.
34//!
35//! # What it is not
36//!
37//! Not a hole filler. Where the capture has no data the field is
38//! extrapolated from distant samples, and those triangles are counted in
39//! `interpolated_triangles` so a caller can see how much of the surface is
40//! inference rather than measurement.
41
42use axiolid_contracts::{
43    Backend, BackendDescriptor, BackendId, CancellationGranularity, Determinism, ExecutionOptions,
44    ExecutionTarget, GeomResult, ScratchRequirement,
45};
46use axiolid_core::{Aabb, Point3, Scalar, Vec3};
47use axiolid_levelset::level_set;
48use axiolid_mesh::audit_mesh;
49use axiolid_pointcloud::PointCloud;
50use axiolid_pointcloud_reconstruction_contract::{
51    PointcloudReconstruction, Reconstruction, ReconstructionEvidence, ReconstructionOutcome,
52    ReconstructionRefusal, ReconstructionRequest, Resolution,
53};
54use axiolid_spatial::PointIndex;
55
56/// How many neighbours the field estimate blends.
57///
58/// One neighbour makes the field a Voronoi surface with visible facets;
59/// blending a few smooths it without washing out real detail. Six is enough
60/// to span a local neighbourhood on a roughly uniform capture.
61const BLEND_NEIGHBOURS: usize = 6;
62
63/// Reference reconstruction provider.
64#[derive(Debug, Default, Clone, Copy)]
65pub struct SdfReconstruction;
66
67impl SdfReconstruction {
68    /// Backend identity.
69    pub const ID: BackendId = BackendId::new("axiolid.pointcloud.sdf");
70
71    /// Construct the provider.
72    pub const fn new() -> Self {
73        Self
74    }
75}
76
77impl Backend for SdfReconstruction {
78    fn descriptor(&self) -> BackendDescriptor {
79        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
80    }
81}
82
83impl PointcloudReconstruction for SdfReconstruction {
84    fn scratch_requirement(&self) -> ScratchRequirement {
85        // The grid dominates: bounded by the extent over the edge length.
86        // Declared unbounded because that product is a function of the
87        // request, not of this provider.
88        ScratchRequirement::Unbounded
89    }
90
91    fn determinism(&self) -> Determinism {
92        // Every step is order-independent: the grid is walked in index
93        // order and neighbour queries break ties by point index, so the
94        // same input yields the same floats.
95        Determinism::Bitwise
96    }
97
98    fn cancellation_granularity(&self) -> CancellationGranularity {
99        // The level-set extraction is a single opaque call.
100        CancellationGranularity::None
101    }
102
103    fn minimum_points(&self) -> usize {
104        // Fewer than four points cannot bound a volume, so no surface can
105        // be estimated from them.
106        4
107    }
108
109    fn reconstruct(
110        &self,
111        cloud: &PointCloud,
112        request: &ReconstructionRequest,
113        options: &ExecutionOptions,
114    ) -> GeomResult<Reconstruction> {
115        let points = cloud.points();
116        if points.len() < self.minimum_points() {
117            return Ok(Reconstruction::Refused(
118                ReconstructionRefusal::TooFewPoints {
119                    supplied: points.len(),
120                    required: self.minimum_points(),
121                },
122            ));
123        }
124
125        let Some((min_corner, max_corner)) = cloud.bounds() else {
126            return Ok(Reconstruction::Refused(
127                ReconstructionRefusal::DegenerateExtent {
128                    detail: "cloud has no finite bounds".to_owned(),
129                },
130            ));
131        };
132
133        // A set with no thickness on some axis has no surface to
134        // reconstruct: fitting one would produce a zero-volume sheet and
135        // present it as a solid.
136        let span = max_corner - min_corner;
137        let extent = span.x.max(span.y).max(span.z);
138        if extent <= 0.0 {
139            return Ok(Reconstruction::Refused(
140                ReconstructionRefusal::DegenerateExtent {
141                    detail: "all points are coincident".to_owned(),
142                },
143            ));
144        }
145        let thinnest = span.x.min(span.y).min(span.z);
146        if thinnest <= extent * 1e-12 {
147            return Ok(Reconstruction::Refused(
148                ReconstructionRefusal::DegenerateExtent {
149                    detail: format!(
150                        "points are collinear or coplanar: extent {extent} but thinnest axis {thinnest}"
151                    ),
152                },
153            ));
154        }
155
156        let index = PointIndex::build(points);
157        let spacing = median_spacing(&index, points);
158        // Explicit about NaN: a spacing that is not a positive number means
159        // the samples carry no usable scale, whether it is zero or NaN.
160        if !spacing.is_finite() || spacing <= 0.0 {
161            return Ok(Reconstruction::Refused(
162                ReconstructionRefusal::DegenerateExtent {
163                    detail: "every sample is coincident with its neighbour".to_owned(),
164                },
165            ));
166        }
167
168        let edge_length = match request.resolution {
169            Resolution::FromSampleSpacing => spacing,
170            Resolution::TargetEdgeLength(requested) => {
171                if !requested.is_finite() || requested <= 0.0 {
172                    return Ok(Reconstruction::Refused(
173                        ReconstructionRefusal::Unsupported {
174                            detail: format!("edge length {requested} is not a usable length"),
175                        },
176                    ));
177                }
178                // Reconstructing finer than the samples resolve would
179                // present interpolation as measurement. Refuse rather than
180                // silently clamp, so the caller learns the data's limit.
181                if requested < spacing * 0.5 {
182                    return Ok(Reconstruction::Refused(
183                        ReconstructionRefusal::ResolutionExceedsData {
184                            requested,
185                            sample_spacing: spacing,
186                        },
187                    ));
188                }
189                requested
190            }
191        };
192
193        let normals = if request.use_normals {
194            cloud.normals()
195        } else {
196            None
197        };
198        let used_normals = normals.is_some();
199
200        // The influence radius must span several samples so the field is
201        // continuous between them; too small and the surface breaks into
202        // disconnected blobs around each point.
203        let influence = spacing * 2.5;
204
205        let field = |probe: Point3| -> Scalar {
206            signed_distance(&index, points, normals, probe, influence, spacing)
207        };
208
209        // Pad the extraction volume so a surface reaching the capture's
210        // edge still closes rather than being clipped open.
211        let pad = Vec3::splat(influence + edge_length * 2.0);
212        let mut padded = Aabb::default();
213        padded.extend(min_corner - pad);
214        padded.extend(max_corner + pad);
215
216        options.check_cancelled()?;
217
218        let mesh = match level_set(field, padded, edge_length, 0.0) {
219            Ok(mesh) => mesh,
220            Err(error) => {
221                return Ok(Reconstruction::Refused(
222                    ReconstructionRefusal::Unsupported {
223                        detail: format!("level-set extraction refused: {error}"),
224                    },
225                ));
226            }
227        };
228
229        let health = audit_mesh(&mesh, options.tolerance());
230        let closed = health.is_closed_two_manifold();
231
232        if request.require_closed && !closed {
233            return Ok(Reconstruction::Refused(
234                ReconstructionRefusal::CannotClose {
235                    detail: format!(
236                        "extraction left {} boundary edges; the capture does not cover the whole object",
237                        health.boundary_edges
238                    ),
239                },
240            ));
241        }
242
243        // Count how much of the result rests on measured data. A triangle
244        // whose centroid is further from any sample than the influence
245        // radius was inferred, not observed.
246        let interpolated = count_interpolated(&mesh, &index, points, influence);
247
248        let mut evidence = ReconstructionEvidence::measured();
249        evidence.input_points = points.len();
250        evidence.used_points = points.len() - index.rejected();
251        evidence.output_triangles = mesh.indices.len() / 3;
252        evidence.output_components = component_count(&mesh);
253        evidence.closed = closed;
254        evidence.achieved_edge_length = edge_length;
255        evidence.sample_spacing = spacing;
256        evidence.used_normals = used_normals;
257        evidence.interpolated_triangles = interpolated;
258
259        Ok(Reconstruction::Surface(Box::new(
260            ReconstructionOutcome::new(mesh, evidence),
261        )))
262    }
263}
264
265/// Median distance from each sample to its nearest other sample.
266///
267/// The scale the capture actually resolves. Median rather than mean so a
268/// handful of outliers cannot inflate it — an outlier sitting far from the
269/// surface would otherwise raise the estimated spacing and coarsen the
270/// whole reconstruction.
271fn median_spacing(index: &PointIndex, points: &[Point3]) -> Scalar {
272    // Sampling is enough for a median and keeps this linear on huge
273    // captures. Deterministic stride, not random, so the estimate is
274    // reproducible.
275    let stride = (points.len() / 512).max(1);
276    let mut distances: Vec<Scalar> = Vec::new();
277    let mut hits = Vec::new();
278    for point in points.iter().step_by(stride) {
279        if !point.is_finite() {
280            continue;
281        }
282        // Two nearest: the first is the point itself at distance zero.
283        if index.nearest_into(*point, 2, &mut hits).is_err() {
284            continue;
285        }
286        if let Some(hit) = hits.iter().find(|h| h.distance > 0.0) {
287            distances.push(hit.distance);
288        }
289    }
290    if distances.is_empty() {
291        return 0.0;
292    }
293    distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
294    distances[distances.len() / 2]
295}
296
297/// Signed distance from `probe` to the surface the samples lie on.
298///
299/// Positive outside, negative inside, matching the convention
300/// `axiolid-levelset` expects.
301fn signed_distance(
302    index: &PointIndex,
303    points: &[Point3],
304    normals: Option<&[Vec3]>,
305    probe: Point3,
306    influence: Scalar,
307    spacing: Scalar,
308) -> Scalar {
309    let mut hits = Vec::new();
310    if index
311        .nearest_into(probe, BLEND_NEIGHBOURS, &mut hits)
312        .is_err()
313        || hits.is_empty()
314    {
315        // No usable samples: report "far outside" rather than zero, which
316        // the extractor would read as a surface crossing and fabricate
317        // geometry out of nothing.
318        return influence.max(spacing);
319    }
320
321    match normals {
322        Some(normals) => {
323            // Signed distance to each neighbour's tangent plane, blended by
324            // inverse-square weight. The surface passes through the samples
325            // because a probe exactly on a sample's plane scores zero.
326            let mut weighted = 0.0;
327            let mut total = 0.0;
328            for hit in &hits {
329                let normal = normals[hit.index];
330                let length = normal.length();
331                if length <= 0.0 {
332                    continue;
333                }
334                let plane_distance = (probe - points[hit.index]).dot(normal / length);
335                // Offset by a small epsilon so a probe sitting exactly on a
336                // sample still has a defined weight.
337                let weight = 1.0 / (hit.distance * hit.distance + spacing * spacing * 1e-6);
338                weighted += plane_distance * weight;
339                total += weight;
340            }
341            if total > 0.0 {
342                weighted / total
343            } else {
344                // Normals present but all degenerate: fall back to the
345                // unsigned estimate rather than dividing by zero.
346                hits[0].distance - spacing
347            }
348        }
349        None => {
350            // No orientation information exists, so no inside can be
351            // determined. Offsetting the unsigned distance produces a
352            // surface at `spacing` around the samples: a shrink-wrap, not a
353            // fit. Weaker, and reported as such in the evidence.
354            hits[0].distance - spacing
355        }
356    }
357}
358
359/// Triangles resting on inference rather than measurement.
360///
361/// A triangle whose centroid is further from every sample than the
362/// influence radius was extrapolated across a gap in the capture.
363fn count_interpolated(
364    mesh: &axiolid_mesh::TriMesh,
365    index: &PointIndex,
366    points: &[Point3],
367    influence: Scalar,
368) -> usize {
369    let _ = points;
370    let mut count = 0;
371    for triangle in mesh.indices.chunks_exact(3) {
372        let centroid = (mesh.positions[triangle[0] as usize]
373            + mesh.positions[triangle[1] as usize]
374            + mesh.positions[triangle[2] as usize])
375            / 3.0;
376        match index.nearest(centroid) {
377            Ok(Some(hit)) if hit.distance <= influence => {}
378            _ => count += 1,
379        }
380    }
381    count
382}
383
384/// Connected components of the result, by shared vertex index.
385///
386/// A capture with gaps often reconstructs into several shells; reporting the
387/// count lets a caller detect that here rather than downstream.
388fn component_count(mesh: &axiolid_mesh::TriMesh) -> usize {
389    if mesh.positions.is_empty() {
390        return 0;
391    }
392    let mut parent: Vec<usize> = (0..mesh.positions.len()).collect();
393    fn find(parent: &mut [usize], mut node: usize) -> usize {
394        while parent[node] != node {
395            parent[node] = parent[parent[node]];
396            node = parent[node];
397        }
398        node
399    }
400    for triangle in mesh.indices.chunks_exact(3) {
401        let a = find(&mut parent, triangle[0] as usize);
402        let b = find(&mut parent, triangle[1] as usize);
403        let c = find(&mut parent, triangle[2] as usize);
404        parent[b] = a;
405        parent[c] = a;
406    }
407    // Only vertices actually used by a triangle count as a component.
408    let mut used = vec![false; mesh.positions.len()];
409    for &corner in &mesh.indices {
410        used[corner as usize] = true;
411    }
412    let mut roots = std::collections::BTreeSet::new();
413    for (vertex, _) in used.iter().enumerate().filter(|(_, used)| **used) {
414        let root = find(&mut parent, vertex);
415        roots.insert(root);
416    }
417    roots.len()
418}