axiolid_levelset/lib.rs
1//! Level-set extraction: a closed manifold mesh from a scalar field.
2//!
3//! # Why tetrahedra rather than cubes
4//!
5//! Classic marching cubes is not manifold. Its 256-entry table has
6//! genuinely ambiguous face configurations: two diagonally opposite
7//! corners inside the level, and the other two outside, can be joined in
8//! two different ways. Neighbouring cells that resolve the same shared
9//! face differently leave a hole, and the result is neither closed nor
10//! two-manifold. Fixing that needs the disambiguated MC33 table plus a
11//! consistent face-resolution rule.
12//!
13//! This module decomposes each cell into six tetrahedra instead. A
14//! tetrahedron has four corners and therefore sixteen sign patterns, none
15//! of which is ambiguous: the surface crosses either three edges (one
16//! triangle) or four (two triangles), and the decomposition is forced.
17//!
18//! The decomposition used is Kuhn's: six tetrahedra sharing the cell's
19//! main diagonal, one per permutation of the three axes. Each cell face is
20//! then split along the diagonal joining the two corners that differ in
21//! both of that face's coordinates -- and because that choice depends only
22//! on the global grid indices, the cell on the other side of the face
23//! splits it exactly the same way. Watertightness is therefore structural
24//! rather than a property of a table that must be kept correct.
25//!
26//! The cost is more triangles than marching cubes for the same grid, and a
27//! slight directional bias from the diagonal. That is the price of the
28//! guarantee, and the guarantee is what this contract is for.
29//!
30//! # What this is not
31//!
32//! Extraction is an approximation. The mesh interpolates the field
33//! linearly along each edge, so a curved surface is faceted and the error
34//! shrinks with the grid, it does not vanish. This is not a certified
35//! path and does not claim to be.
36//!
37//! # Exact tangency
38//!
39//! A level set can pass exactly through a grid sample -- a unit sphere in a
40//! half-extent of 1.4 does it at several edge lengths. Every edge meeting
41//! that sample then crosses at the same point, the triangles between those
42//! crossings collapse, and the surface tears.
43//!
44//! This is resolved by simulation of simplicity rather than by special
45//! cases: symbolically each sample carries its own positive infinitesimal,
46//! so no sample sits exactly at the level and every crossing is strictly
47//! interior to its edge. See `SOS_DELTA` for the numeric stand-in and why
48//! its magnitude matters.
49
50use ahash::AHashMap;
51
52use axiolid_core::{Aabb, Point3, Scalar};
53use axiolid_mesh::TriMesh;
54
55/// Why a level set could not be extracted.
56#[derive(Debug, thiserror::Error, PartialEq)]
57#[non_exhaustive]
58pub enum LevelSetError {
59 /// The requested edge length is not a usable spacing.
60 #[error("edge length {0} is not a positive finite length")]
61 InvalidEdgeLength(Scalar),
62 /// The bounds are empty or not finite.
63 #[error("bounds are empty or non-finite along at least one axis")]
64 InvalidBounds,
65 /// The level itself is not a finite value.
66 #[error("level {0} is not finite")]
67 InvalidLevel(Scalar),
68 /// The field never crosses the level inside the bounds.
69 ///
70 /// Reported rather than answered with an empty mesh: a caller asking
71 /// for a surface that is not there has a bug upstream, and a
72 /// zero-triangle result looks like a successful extraction of nothing.
73 #[error("the field does not cross level {level} anywhere in the bounds")]
74 NoCrossing {
75 /// The level that was searched for.
76 level: Scalar,
77 },
78 /// The field returned a non-finite sample.
79 #[error("the field returned a non-finite value at {point:?}")]
80 NonFiniteSample {
81 /// Where the field misbehaved.
82 point: Point3,
83 },
84 /// The requested grid exceeds the sample budget.
85 #[error("the requested grid needs {requested} samples, over the {limit} budget")]
86 BudgetExceeded {
87 /// Samples the request would have taken.
88 requested: usize,
89 /// The cap that was not raised.
90 limit: usize,
91 },
92}
93
94/// Upper bound on grid samples, so a fine edge length on large bounds is
95/// refused up front instead of exhausting memory.
96/// Numeric stand-in for the infinitesimal in the simulation-of-simplicity
97/// rule: how far a crossing is held clear of either end of its edge.
98///
99/// The magnitude is bounded from both sides, and both bounds were found by
100/// measurement rather than chosen:
101///
102/// - Too small and the fix does nothing useful. At `1e-6` the triangles it
103/// creates have a doubled area around `1e-27`, below the audit's
104/// `tolerance^4` degeneracy threshold, so they are still counted as
105/// degenerate and the mesh still reads as open. The perturbation has to
106/// clear modelling tolerance to be a feature rather than dust.
107/// - Too large and it stops being an infinitesimal: it would move vertices
108/// far enough to compete with the tessellation error itself.
109///
110/// `1e-3` of an edge sits between those: a shift of at most
111/// `1e-3 * edge_length`, which is smaller than the `edge^2/8` chord error
112/// by orders of magnitude at every usable resolution, and large enough that
113/// two crossings never round together.
114const SOS_DELTA: Scalar = 1.0e-3;
115
116const MAX_SAMPLES: usize = 64_000_000;
117
118/// The six Kuhn tetrahedra of a unit cell, as corner indices.
119///
120/// Corner `i` has bits `(x, y, z)` with x least significant, so corner 0 is
121/// the minimum and corner 7 the maximum. Every tetrahedron runs from 0 to 7
122/// along a different axis order, which is what makes the shared faces agree
123/// between neighbouring cells.
124const KUHN_TETRAHEDRA: [[usize; 4]; 6] = [
125 [0, 1, 3, 7],
126 [0, 1, 5, 7],
127 [0, 2, 3, 7],
128 [0, 2, 6, 7],
129 [0, 4, 5, 7],
130 [0, 4, 6, 7],
131];
132
133/// Extract the level set of a scalar field as a closed manifold mesh.
134///
135/// `field` is sampled on a regular grid spanning `bounds`. The surface is
136/// where `field` equals `level`; the convention is that lower values are
137/// inside, so triangle winding puts the outward normal toward higher
138/// values.
139///
140/// The bounds are padded by one cell on every side and the field is forced
141/// to read as outside on that shell. Without it a surface reaching the edge
142/// of the bounds would be cut, leaving an open border -- and the closedness
143/// guarantee would be false exactly when the caller's bounds were tight.
144///
145/// # Errors
146///
147/// Refuses a non-positive edge length, empty or non-finite bounds, a
148/// non-finite level, a field that returns a non-finite sample, a grid over
149/// the sample budget, and a field that never crosses the level.
150pub fn level_set<F>(
151 field: F,
152 bounds: Aabb,
153 edge_length: Scalar,
154 level: Scalar,
155) -> Result<TriMesh, LevelSetError>
156where
157 F: Fn(Point3) -> Scalar,
158{
159 if !edge_length.is_finite() || edge_length <= 0.0 {
160 return Err(LevelSetError::InvalidEdgeLength(edge_length));
161 }
162 if !level.is_finite() {
163 return Err(LevelSetError::InvalidLevel(level));
164 }
165 let (min, max) = (bounds.min, bounds.max);
166 if !min.is_finite() || !max.is_finite() || max.x <= min.x || max.y <= min.y || max.z <= min.z {
167 return Err(LevelSetError::InvalidBounds);
168 }
169
170 // One padding cell each side, so a surface touching the bounds still
171 // closes instead of being clipped into an open sheet.
172 let counts = [
173 ((max.x - min.x) / edge_length).ceil() as usize + 3,
174 ((max.y - min.y) / edge_length).ceil() as usize + 3,
175 ((max.z - min.z) / edge_length).ceil() as usize + 3,
176 ];
177 let requested = counts[0]
178 .saturating_mul(counts[1])
179 .saturating_mul(counts[2]);
180 if requested > MAX_SAMPLES {
181 return Err(LevelSetError::BudgetExceeded {
182 requested,
183 limit: MAX_SAMPLES,
184 });
185 }
186
187 let origin = Point3::new(
188 min.x - edge_length,
189 min.y - edge_length,
190 min.z - edge_length,
191 );
192 let at = |i: usize, j: usize, k: usize| {
193 Point3::new(
194 origin.x + (i as Scalar) * edge_length,
195 origin.y + (j as Scalar) * edge_length,
196 origin.z + (k as Scalar) * edge_length,
197 )
198 };
199 let index_of = |i: usize, j: usize, k: usize| (k * counts[1] + j) * counts[0] + i;
200
201 // Sample once. The field is a caller closure and may be expensive, so
202 // it is never evaluated twice for the same grid point.
203 let mut samples = vec![0.0 as Scalar; requested];
204 for k in 0..counts[2] {
205 for j in 0..counts[1] {
206 for i in 0..counts[0] {
207 let point = at(i, j, k);
208 let on_shell = i == 0
209 || j == 0
210 || k == 0
211 || i == counts[0] - 1
212 || j == counts[1] - 1
213 || k == counts[2] - 1;
214 let value = if on_shell {
215 // Forced outside: this is what closes a surface that
216 // would otherwise run off the edge of the grid.
217 1.0
218 } else {
219 let raw = field(point);
220 if !raw.is_finite() {
221 return Err(LevelSetError::NonFiniteSample { point });
222 }
223 raw - level
224 };
225 samples[index_of(i, j, k)] = value;
226 }
227 }
228 }
229
230 let mut positions: Vec<Point3> = Vec::new();
231 let mut indices: Vec<u32> = Vec::new();
232 // Keyed by the two grid samples an intersection lies between, so both
233 // tetrahedra sharing that edge reuse one vertex. This welding is what
234 // makes the result closed rather than a soup of disconnected triangles.
235 let mut vertices: AHashMap<(usize, usize), u32> = AHashMap::new();
236 // A second index, keyed by exact position bits. Two DIFFERENT edges can
237 // cross at the same point -- when a crossing lands on a shared grid
238 // corner, for instance -- and giving that point two vertex ids collapses
239 // the incident triangles to zero area. Dropping those then tears a hole,
240 // which is how this first showed up: 36 exactly-zero-area faces and 48
241 // unmatched boundary edges. Welding by position removes the cause.
242 let mut welded: AHashMap<[u64; 3], u32> = AHashMap::new();
243
244 for k in 0..counts[2] - 1 {
245 for j in 0..counts[1] - 1 {
246 for i in 0..counts[0] - 1 {
247 let corner = |bit: usize| {
248 let (dx, dy, dz) = (bit & 1, (bit >> 1) & 1, (bit >> 2) & 1);
249 index_of(i + dx, j + dy, k + dz)
250 };
251 for tetrahedron in KUHN_TETRAHEDRA {
252 let nodes = tetrahedron.map(corner);
253 emit_tetrahedron(
254 nodes,
255 &samples,
256 &counts,
257 origin,
258 edge_length,
259 &mut positions,
260 &mut indices,
261 &mut vertices,
262 &mut welded,
263 );
264 }
265 }
266 }
267 }
268
269 if indices.is_empty() {
270 return Err(LevelSetError::NoCrossing { level });
271 }
272 Ok(TriMesh::new(positions, indices))
273}
274
275/// Emit the triangles of one tetrahedron.
276#[allow(clippy::too_many_arguments)]
277fn emit_tetrahedron(
278 nodes: [usize; 4],
279 samples: &[Scalar],
280 counts: &[usize; 3],
281 origin: Point3,
282 edge_length: Scalar,
283 positions: &mut Vec<Point3>,
284 indices: &mut Vec<u32>,
285 vertices: &mut AHashMap<(usize, usize), u32>,
286 welded: &mut AHashMap<[u64; 3], u32>,
287) {
288 // A sample exactly at the level would make an edge both crossing and
289 // not crossing depending on which side asks, so the rule has to be
290 // global rather than per-tetrahedron. Strictly-negative is inside:
291 // a grid point sitting exactly ON the surface then reads as outside
292 // from every tetrahedron that touches it, and the surface passes
293 // between grid points instead of through one. That keeps every
294 // crossing strictly interior to its edge, which is what stops two
295 // edges of one tetrahedron interpolating to the same position.
296 let inside = nodes.map(|node| samples[node] < 0.0);
297 let count = inside.iter().filter(|&&flag| flag).count();
298 if count == 0 || count == 4 {
299 return;
300 }
301
302 let mut interpolate = |a: usize, b: usize, positions: &mut Vec<Point3>| -> u32 {
303 let key = if a < b { (a, b) } else { (b, a) };
304 if let Some(&existing) = vertices.get(&key) {
305 return existing;
306 }
307 let (va, vb) = (samples[key.0], samples[key.1]);
308 let (pa, pb) = (
309 grid_point(key.0, counts, origin, edge_length),
310 grid_point(key.1, counts, origin, edge_length),
311 );
312 // Guard the coincident-value case: without it a flat region of the
313 // field divides by zero and produces a non-finite vertex.
314 let span = vb - va;
315 let raw = if span.abs() > Scalar::EPSILON {
316 (-va / span).clamp(0.0, 1.0)
317 } else {
318 0.5
319 };
320 // Simulation of simplicity, applied to the crossing parameter.
321 //
322 // Symbolically every sample carries its own positive infinitesimal,
323 // `v_i + e^i`, so no sample is ever exactly at the level. A sample
324 // that reads as an exact zero therefore yields a crossing that is
325 // infinitesimally along its edge rather than exactly at its
326 // endpoint, and `t` is never exactly 0 or 1.
327 //
328 // This matters because a crossing AT a grid point is shared by every
329 // edge meeting there: distinct edges produce one coincident vertex,
330 // the triangles between them collapse to zero area, and the surface
331 // is left with unmatched edges. Keeping the crossing strictly
332 // interior to its edge gives each edge its own vertex and keeps the
333 // result closed.
334 //
335 // `SOS_DELTA` is the numeric stand-in for the infinitesimal: small
336 // enough that it moves a vertex by at most `SOS_DELTA * edge_length`
337 // (well under any usable tolerance), large enough that two crossings
338 // on different edges cannot round to the same position -- the defect
339 // that a `Scalar::MIN_POSITIVE` offset produced, where the two points
340 // differed in bits but not in geometry.
341 //
342 // The edge key is index-ordered, so both tetrahedra sharing an edge
343 // compute the same `t` from the same pair and agree on the vertex.
344 // The perturbation is a function of the edge alone, which is what
345 // keeps it consistent across the whole grid.
346 let t = raw.clamp(SOS_DELTA, 1.0 - SOS_DELTA);
347 let point = pa + (pb - pa) * t;
348 let bits = [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()];
349 let index = match welded.get(&bits) {
350 Some(&existing) => existing,
351 None => {
352 let fresh = positions.len() as u32;
353 positions.push(point);
354 welded.insert(bits, fresh);
355 fresh
356 }
357 };
358 vertices.insert(key, index);
359 index
360 };
361
362 // Order the corners so the inside ones come first. The crossing pattern
363 // then depends only on how many are inside.
364 let mut ordered = [0usize; 4];
365 let (mut head, mut tail) = (0, 3);
366 for (slot, &node) in nodes.iter().enumerate() {
367 if inside[slot] {
368 ordered[head] = node;
369 head += 1;
370 } else {
371 ordered[tail] = node;
372 tail = tail.wrapping_sub(1);
373 }
374 }
375
376 // Orient from the field, not from one corner. The direction from the
377 // inside corners' centroid to the outside corners' centroid is the
378 // local outward direction, and unlike a single corner it stays well
379 // conditioned when the tetrahedron is thin.
380 let centroid = |nodes: &[usize]| {
381 let mut sum = Point3::ZERO;
382 for &node in nodes {
383 sum += grid_point(node, counts, origin, edge_length);
384 }
385 sum / (nodes.len() as Scalar)
386 };
387 let outward = centroid(&ordered[count..]) - centroid(&ordered[..count]);
388
389 match count {
390 // One corner inside: a triangle separating it from the other three.
391 1 => {
392 let a = interpolate(ordered[0], ordered[1], positions);
393 let b = interpolate(ordered[0], ordered[2], positions);
394 let c = interpolate(ordered[0], ordered[3], positions);
395 push_oriented(indices, positions, [a, b, c], outward);
396 }
397 // Three inside is the mirror image: one corner outside.
398 3 => {
399 let a = interpolate(ordered[3], ordered[0], positions);
400 let b = interpolate(ordered[3], ordered[1], positions);
401 let c = interpolate(ordered[3], ordered[2], positions);
402 push_oriented(indices, positions, [a, b, c], outward);
403 }
404 // Two inside, two outside: the surface cuts four edges, giving a
405 // quadrilateral split into two triangles.
406 _ => {
407 let a = interpolate(ordered[0], ordered[2], positions);
408 let b = interpolate(ordered[0], ordered[3], positions);
409 let c = interpolate(ordered[1], ordered[3], positions);
410 let d = interpolate(ordered[1], ordered[2], positions);
411 push_oriented(indices, positions, [a, b, c], outward);
412 push_oriented(indices, positions, [a, c, d], outward);
413 }
414 }
415}
416
417/// Append a triangle wound so its normal follows `outward`.
418///
419/// `outward` runs from the inside corners toward the outside ones, so it is
420/// the field's own local direction of increase. Deciding orientation from
421/// that rather than from a single corner keeps neighbouring tetrahedra
422/// agreeing even when one of them is thin.
423fn push_oriented(
424 indices: &mut Vec<u32>,
425 positions: &[Point3],
426 triangle: [u32; 3],
427 outward: axiolid_core::Vec3,
428) {
429 let [a, b, c] = triangle;
430 // A degenerate triangle has no orientation to fix and would only add a
431 // zero-area face, so it is dropped rather than emitted.
432 if a == b || b == c || a == c {
433 return;
434 }
435 let (pa, pb, pc) = (
436 positions[a as usize],
437 positions[b as usize],
438 positions[c as usize],
439 );
440 let normal = (pb - pa).cross(pc - pa);
441 if normal.dot(outward) >= 0.0 {
442 indices.extend_from_slice(&[a, b, c]);
443 } else {
444 indices.extend_from_slice(&[a, c, b]);
445 }
446}
447
448/// Recover a grid point from its flat sample index.
449fn grid_point(index: usize, counts: &[usize; 3], origin: Point3, edge_length: Scalar) -> Point3 {
450 let i = index % counts[0];
451 let j = (index / counts[0]) % counts[1];
452 let k = index / (counts[0] * counts[1]);
453 Point3::new(
454 origin.x + (i as Scalar) * edge_length,
455 origin.y + (j as Scalar) * edge_length,
456 origin.z + (k as Scalar) * edge_length,
457 )
458}