axiolid_pointcloud_reconstruction_sdf/
lib.rs1#![forbid(unsafe_code)]
2use 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
56const BLEND_NEIGHBOURS: usize = 6;
62
63#[derive(Debug, Default, Clone, Copy)]
65pub struct SdfReconstruction;
66
67impl SdfReconstruction {
68 pub const ID: BackendId = BackendId::new("axiolid.pointcloud.sdf");
70
71 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 ScratchRequirement::Unbounded
89 }
90
91 fn determinism(&self) -> Determinism {
92 Determinism::Bitwise
96 }
97
98 fn cancellation_granularity(&self) -> CancellationGranularity {
99 CancellationGranularity::None
101 }
102
103 fn minimum_points(&self) -> usize {
104 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 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 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 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 let influence = spacing * 2.5;
204
205 let field = |probe: Point3| -> Scalar {
206 signed_distance(&index, points, normals, probe, influence, spacing)
207 };
208
209 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 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
265fn median_spacing(index: &PointIndex, points: &[Point3]) -> Scalar {
272 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 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
297fn 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 return influence.max(spacing);
319 }
320
321 match normals {
322 Some(normals) => {
323 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 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 hits[0].distance - spacing
347 }
348 }
349 None => {
350 hits[0].distance - spacing
355 }
356 }
357}
358
359fn 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
384fn 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 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}