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