levelset(): a solid from a grid or a function(x,y,z) - #124
Merged
Conversation
Implicit surfaces -- metaballs, gyroids, anything defined by a formula or by sampled data -- previously meant meshing them yourself. BOSL2 does exactly that in isosurface.scad, 217 KB of marching cubes written in the language. Takes a GRID rather than Manifold's own SDF-callback shape, and that is the whole design decision. Measured both ways: building the field in script costs 0.41 us/sample against 0.96 us for a closure call per sample, because the arithmetic inlines into the comprehension. More importantly a grid means the C++ side never re-enters the evaluator, so canParallel can be true -- Manifold's docs warn that parallel policies crash runtimes that get called back by unregistered threads, which is precisely what a callback design gives up. Measured against BOSL2 on the same field: 50^3 0.26s vs 2.07s 8.0x 100^3 1.15s vs 14.59s 12.7x The trade is memory: ~67 bytes/cell, so 200^3 is about 550 MB and that is the practical ceiling. Documented rather than left to be discovered. Accuracy is bounded by the grid, not by Manifold, since it samples on a body-centred cubic lattice and a plain grid does not line up. Same accuracy as grid-based marching cubes in script; worse than LevelSet given a true SDF. Said so rather than implying the C++ path is exact. Ten tests including a torus for genus 1, two blobs for genus -1, and a deliberately asymmetric field for axis order -- a symmetric one passes even if the axes are transposed, which is the classic bug here. 1056 passing under both engines.
The grid form is faster and parallel; the function form is nicer to write and, less obviously, MORE ACCURATE -- Manifold picks its own sample points and can snap toward the true surface instead of interpolating a fixed lattice. Measured at matched resolution against the analytic sphere: 50^3 grid 0.23s (-0.244%) function 0.32s (-0.103%) 100^3 grid 1.06s (-0.059%) function 1.91s (-0.025%) So ~1.5x slower for ~2.4x the accuracy, and both still well ahead of BOSL2's 2.07s and 14.59s. Neither form is simply better, so both are offered and the trade is documented rather than buried. canParallel is the crux and is not a tuning knob: true for a grid, because the sampling lambda is pure C++, and false for a function, which re-enters the evaluator. The function form requires edge= explicitly. There is no grid to infer spacing from and the cost is cubic in it, so a silent default would produce either a useless mesh or a ten-minute one. Also removes undefined behaviour introduced while wiring this up: the grid lambda's ScalarField reference was bound to a null pointer on the function path. Never dereferenced, still UB, and exactly the kind of thing that works until a sanitizer or a compiler upgrade says otherwise. Six more tests, 1062 passing under both engines.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implicit surfaces — metaballs, gyroids, anything from a formula or sampled data — previously meant meshing them yourself. BOSL2 does exactly that:
isosurface.scadis 217 KB of marching cubes written in the language.Two intakes, and neither is simply better
field[i][j][k]function(x,y,z)Measured at matched resolution, sphere against analytic:
The function form is ~1.5× slower and ~2.4× more accurate — because Manifold picks its own sample points on a body-centred cubic lattice and can snap toward the surface, where a grid can only be interpolated. That accuracy advantage was not obvious up front and is the reason to offer both rather than pick one.
canParallelis the crux, not a tuning knob: true for a grid because the lambda is pure C++, false for a function because it re-enters the evaluator. Manifold warns that parallel policies "will crash language runtimes with runtime locks that expect to not be called back by unregistered threads."Against BOSL2, same field
levelsetisosurface()Correctness
Sphere volume converging with resolution: −1.04% → −0.24% → −0.09%, genus 0 throughout.
Sixteen tests, including the ones volume alone can't catch: a torus → genus 1, two disjoint blobs → genus −1, and a deliberately asymmetric field for axis order — bounding box comes out exactly
[[-5,-10,-20],[5,10,20]], where a symmetric field would pass even with the axes transposed.Also pinned: an isovalue nothing reaches → empty, and one everything satisfies → the whole bounding box. They look alike from outside and mean opposite things; my first version of that test asserted the wrong one.
Two limits stated rather than glossed
Memory — ~67 bytes/cell on the grid path, so 200³ ≈ 550 MB. The function path has no ceiling.
Accuracy on the grid path is bounded by your grid, not by Manifold. Hence
tolerance = -1is forced (a positive value would make Manifold do extra evaluations that only re-interpolate data already used) andedgeLengthdefaults to the grid spacing.The function form requires
edge=explicitly: there's no grid to infer it from and the cost is cubic in it, so a silent default would give either a useless mesh or a ten-minute one.Also fixes UB I introduced while wiring the second intake: the grid lambda's
ScalarField&was bound to a null pointer on the function path. Never dereferenced, still undefined, and exactly the kind of thing that works until a sanitizer disagrees.1062 tests pass under both engines. Version 0.49.0 → 0.50.0.
Not included, deliberately: 2D (
CrossSectionhas no equivalent), adaptive sampling, and BOSL2's metaball specs — this is a primitive forisosurface()to sit on, not a replacement.🤖 Generated with Claude Code