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
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}