Skip to main content

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}