Skip to content

levelset(): a solid from a grid or a function(x,y,z) - #124

Merged
revarbat merged 2 commits into
mainfrom
levelset-grid
Aug 27, 2026
Merged

levelset(): a solid from a grid or a function(x,y,z)#124
revarbat merged 2 commits into
mainfrom
levelset-grid

Conversation

@revarbat

@revarbat revarbat commented Aug 27, 2026

Copy link
Copy Markdown
Member
// from a grid you built
levelset(field, bounds=[[-25,-25,-25],[25,25,25]], isovalue=20);

// or straight from a function
levelset(function(x,y,z) sqrt(x*x+y*y+z*z),
         bounds=[[-30,-30,-30],[30,30,30]], isovalue=20, edge=1.2);

Implicit surfaces — metaballs, gyroids, anything from a formula or sampled data — previously meant meshing them yourself. BOSL2 does exactly that: isosurface.scad is 217 KB of marching cubes written in the language.

Two intakes, and neither is simply better

sampling parallel accuracy ceiling
grid field[i][j][k] 0.41 µs yes capped by the grid ~550 MB at 200³
function(x,y,z) 0.96 µs no Manifold snaps toward the true surface none

Measured at matched resolution, sphere against analytic:

grid function
50³ 0.23 s, −0.244% 0.32 s, −0.103%
100³ 1.06 s, −0.059% 1.91 s, −0.025%

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.

canParallel is 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

grid levelset BOSL2 isosurface()
50³ 0.26 s 2.07 s 8.0×
100³ 1.15 s 14.59 s 12.7×

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 = -1 is forced (a positive value would make Manifold do extra evaluations that only re-interpolate data already used) and edgeLength defaults 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 (CrossSection has no equivalent), adaptive sampling, and BOSL2's metaball specs — this is a primitive for isosurface() to sit on, not a replacement.

🤖 Generated with Claude Code

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.
@revarbat revarbat changed the title levelset(field, bounds, isovalue): a solid from a grid of samples levelset(): a solid from a grid or a function(x,y,z) Aug 27, 2026
@revarbat
revarbat merged commit d719b3e into main Aug 27, 2026
3 checks passed
@revarbat
revarbat deleted the levelset-grid branch August 27, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant