A solid, written down: afield.lang.solidsl
In afield.lang.solidsl a shape is a value. Subtracting a cylinder from a cuboid builds an expression
tree, and from that one tree you can march a mesh, build an exact CSG solid, emit GLSL, or simplify
it. A document you can open and run is at the bottom of the page; the KDoc for
SolidExpr, Profile, SolidLowering and SolidTemplate covers every declaration used here.
The signed-distance vocabulary in mesh_sdf.kt works the other way round. It declares
typealias ScalarField3D = (Vec3) -> Float
and builds its primitives and combinators on top of it: sdfSphere, sdfBox, sdfTorus,
sdfUnion, sdfSmoothUnion, twisted, repeated. Every one of them returns a Kotlin lambda, and a
lambda cannot be printed, compared, serialized, rewritten, or emitted as GLSL.
RectSL's 2D tracks are expression trees for that reason, and SolidSL does the
same in three dimensions. Here is one part, and the four things you can ask of it:
val part =
(cuboid(4, 2.6, 0.7) + cylinder(1.4, 0.8)) -
cylinder(3.0, 0.42) -
Profile.rect(2.9, 0.85).offset(0.22).extruded(3.0).at(0, 1.05, -1.5)
part.toScalarField() // (Vec3) -> Float — marchable, and everything mesh_sdf.kt does
part.exactSolid() // Manifold's exact CSG — a guaranteed-closed Solid, or a stated refusal
part.toGlsl() // float sdSolid(vec3 p) — a real-time raymarch of the same source
part.simplified() // a smaller expression that means the same thing
println(part) // Difference(children=[Union(children=[Cuboid(x=4.0, …
The vocabulary
Solids, booleans, transforms, and a 2D Profile you sweep into the third dimension. Everything else
in the package reads a tree built from these.
| Example | Behavior |
|---|---|
sphere(1.0) |
Constructs a sphere centred on the origin. Its radius uses the same coordinate units as the rest of the expression. |
cuboid(2.0, 1.0, 0.5) / cuboid(1.0) |
Constructs an origin-centred box from full extents, or a cube from one side length. Dimensions are not half-extents. |
cylinder(2.0, 0.5, 0.25) / cone(2.0, 0.5) |
Builds a cylinder or frustum along z, centred on the origin. The cone is the zero-top-radius case, with its tip at positive half-height. |
torus(1.0, 0.2) |
Revolves a circular tube around the z axis, with the ring in the XY plane. The major radius is the ring radius and the minor radius is the tube radius. |
gyroid(3.0, 0.2) |
Defines an unbounded gyroid sheet field. Intersect it with a finite shape before expecting a bounded object. |
Profile.circle(0.5) / Profile.rect(1.0, 0.5) |
Builds a two-dimensional circular or rectangular profile. Extrude or revolve it to create a solid. |
Profile.star(5, 1.0, 0.4) / Profile.polygon(points) |
Builds a star from alternating radii or a polygon from supplied points. The profile can then enter the same sweep and Boolean operations. |
profile.offset(0.1) |
Offsets the profile boundary before a sweep. Use a profile offset when the two-dimensional cross-section is what should grow. |
profile.translated(1.0, 0.0).turned(30.0) |
Moves and rotates the profile in its plane. Rotation is in degrees. |
profile.sized(2.0) |
Scales a profile before extrusion or revolution. This changes the cross-section rather than the later solid’s height. |
profile.extruded(2.0, twistDegrees = 45.0) |
Sweeps the profile through the given height with optional twist in degrees. The result is a three-dimensional expression. |
profile.revolved(360.0) |
Revolves a profile through the supplied angle in degrees. A full turn closes the revolution around its axis. |
shape.at(1.0, 0.0, 0.0) |
Translates a solid expression by the given displacement. The source expression is retained as a child of the transform. |
shape.turned(0.0, 0.0, 45.0) |
Rotates about the origin using Euler angles in degrees, applied x then y then z. Translation order therefore matters. |
shape.sized(2.0) / shape.sized(2.0, 1.0, 0.5) |
Scales uniformly or separately along each axis. Use the uniform form when proportions should remain unchanged. |
shape.mirrored(1.0, 0.0, 0.0) |
Reflects across the plane specified by its normal. This reverses the side of the shape along that normal. |
shape.rounded(0.1) / shape.shelled(0.1) |
Rounds the field boundary outward by 0.1, or creates a shell with wall thickness 0.2 (twice the argument). These field operations require sampled lowering when the exact backend cannot represent them. |
a + b / a - b / a and b |
Forms union, ordered subtraction or intersection. The same Boolean spellings apply to profiles; subtraction removes b from a. |
unionOf(listOf(a, b, c)) / differenceOf(listOf(a, b, c)) / intersectionOf(listOf(a, b, c)) |
Combines more than two solids in one expression. Difference preserves the first shape and removes the later shapes. |
smoothUnion(a, b, 0.2) / smoothDifference(a, b, 0.2) |
Blends field boundaries with the supplied smoothing scale. Use sampled lowering for these smooth combinations. |
One knob controls resolution for the whole tree. SolidQuality(segments = 64, edgeLengthFraction =
1.0 / 55.0) sets how many sides a circle gets on the exact path and how fine the march is where one
is needed; segments = 0 lets the backend choose per radius.
Each primitive built through buildSolid and drawn in its own panel, with a swept star profile for
the sixth. gyroid is missing because this picture goes through the exact path and the gyroid is the
one primitive with no exact form — see refusals.
:::static
import afield.lang.solidsl.*
// Every panel frames its own bounds, so the small shapes are not lost beside the large ones.
val q = SolidQuality(segments = 64, edgeLengthFraction = 1.0 / 55.0)
// toTriMesh copies the geometry before the native build closes.
fun mesh(e: SolidExpr) =
e.buildSolid(q).use { build ->
build.solid.toTriMesh(normals = true)
}
threeGrid(
"sphere(1.0)" to mesh(sphere(1.0)),
"cuboid(1.6, 1.2, 0.8)" to mesh(cuboid(1.6, 1.2, 0.8)),
"cylinder(1.6, 0.7)" to mesh(cylinder(1.6, 0.7)),
"cone(1.6, 0.8)" to mesh(cone(1.6, 0.8)),
"torus(1.0, 0.32)" to mesh(torus(1.0, 0.32)),
"star(6).extruded(twist 90)" to mesh(Profile.star(6, 0.9, 0.45).extruded(1.2, twistDegrees = 90.0)),
channel = "vocab",
columns = 3,
height = 470,
view = MeshView(width = 300, height = 210, yawDegrees = 32.0, pitchDegrees = 16.0, wireframe = true),
)

In the cylinder and the cone panels, cylinder(1.6, 0.7) with segments = 64 comes out of the
exact path as a 64-gon prism, and the straight segments are visible around the flat end. Raise segments
and the silhouette tightens. The field lowering of the same expression has a true circular
cross-section at every resolution, and is wrong in a different way instead.
Two lowerings of one source
A SolidExpr lowers two ways, and afield.geometry.solid.manifold supplies the second of them.
Neither is a superset of the other, so which one you want depends on what you are going to do next:
| Property | toScalarField() |
exactSolid() |
|---|---|---|
| what the shape becomes | a distance field, sampled on a grid | a CSG tree, evaluated exactly |
| how a surface appears | marching cubes, O(h²) |
it is the surface |
| a cylinder is | a true cylinder | an inscribed n-gon prism |
| a smooth blend is | native | refused, with a reason |
| a gyroid is | native | refused |
| runs on a GPU | yes, via toGlsl() |
no |
| safe to subtract from | not reliably | always |
The practical consequence is a rule about which knob to turn. On the exact path the mesh is as coarse
as the shape allows — a flat face is two triangles, an L-bracket cut from a plate is 20 triangles
where the marched version of the same expression is 71,288 — and refining the march does nothing at
all, because there is no march. What you turn there is segments. On the field path, refining the
march improves a polyhedral shape and bottoms out on a curved one at Manifold's faceting floor, so
past a certain point you are paying for triangles that buy nothing.
crossCheck builds a shape both ways and compares volume, area, genus and a symmetric Hausdorff
distance, which is how the two are held to each other; dev_docs/SOLIDSL_AGREEMENT.md records what
that costs and where the floors are.
Refusals are in the type
exactSolid() does not return a Solid. It returns a SolidLowering, which is either the solid or
a refusal carrying a sentence about why, following RectSLFnLowering's convention: a null invites
a caller to substitute something plausible, and an exception loses the reason at the first
runCatching.
when (val l = shape.exactSolid()) {
is SolidLowering.Lowered -> println("volume ${l.value.volume}, genus ${l.value.genus}")
is SolidLowering.Refused -> println("no exact form: ${l.why}")
}
There are two flavours:
| node | why | flavour |
|---|---|---|
SmoothUnion, SmoothDifference |
the fillet surface lies on neither operand, so no union, intersection or difference of the two can produce it | permanent |
Gyroid |
a trigonometric field on all of space, not a bounded volume | permanent |
Round |
minkowskiSum with a sphere exists — but that sphere is an inscribed polyhedron, so the result is strictly inside the true offset by up to its sagitta, and refining the march does not close a gap that is not the march's |
would not converge |
Shell |
a grow minus an erode: Round's error twice over, with a concave decomposition between them |
would not converge |
The difference between the flavours is whether an exact-looking answer exists. A permanent refusal has none. A would-not-converge refusal has one available and it is wrong by up to the sagitta of a polyhedral ball, in a direction refining the march cannot close.
Every node in that table still draws: the field lowering takes all of them, so a refusal costs you
the exact backend, not the shape. allRefusals() lists every refusal in a
tree, and exactRefusal() answers the yes-or-no.
:::static
import afield.lang.solidsl.*
val q = SolidQuality(segments = 64, edgeLengthFraction = 1.0 / 70.0)
val blend = smoothUnion(sphere(0.5).at(0.0, 0.0, 1.0), cylinder(1.9, 0.26), k = 0.28)
val rounded = cuboid(1.2, 1.2, 1.2).rounded(0.18)
// A shelled sphere is a sphere from the outside, so cut it open — otherwise the picture
// shows nothing the plain sphere does not.
val shellCut = sphere(0.9).shelled(0.08) - cuboid(1.2, 1.2, 1.2).at(0.9, 0.0, 0.9)
// The gyroid has no window of its own and borrows the cuboid's, which is the bounds story below.
val gyr = gyroid(6.0, 0.30) and cuboid(2.6, 2.6, 2.6)
["blend" to blend, "rounded" to rounded, "shell" to shellCut, "gyroid" to gyr].forEach { (n, e) ->
println("%-8s refuses=%s".format(n, e.exactRefusal() != null))
}
threeGrid(
"smoothUnion(k = 0.28)" to blend.marchedMesh(q),
"cuboid.rounded(0.18)" to rounded.marchedMesh(q),
"sphere.shelled(0.08), cut open" to shellCut.marchedMesh(q),
"gyroid(6.0) and cuboid(2.6)" to gyr.marchedMesh(q),
channel = "refusals",
columns = 2,
height = 540,
view = MeshView(width = 330, height = 250, yawDegrees = 24.0, pitchDegrees = 8.0, wireframe = true),
)

Every Profile node lowers exactly, Profile.offset included, because
Section.offset(d, JoinType.ROUND) is the Minkowski sum with a disc, which is what sdf − d means.
So a rounded slot has an exact form where a rounded solid does not: Clipper offsets a 2D region
as a region, and the 3D case has to go through a polyhedral ball.
The hybrid: march the smallest subtree that needs it
A naive fallback asks "does the whole tree lower exactly?" and marches everything if the answer is
no. One smooth blend anywhere then buys a hundred-thousand-triangle approximation of a plate that
was perfectly exact. buildSolid walks down instead.
(plate + smoothUnion(ball, post, k = 0.28)).buildSolid().use { build ->
println(build.report())
}
// hybrid: 1 subtree(s) marched, the rest exact (39552 triangles, genus 4)
// marched SmoothUnion at edgeLength 0.0416 — a smooth blend's fillet surface lies on neither operand…
Measured on that part: 39,552 triangles against 110,168 for the naive whole-tree march at the same edge length, a factor of 2.79. The plate's six faces stay six faces, and the result is still one guaranteed-closed Manifold solid, because the exact operand and the marched one meet in a real batch boolean.
How much the hybrid saves depends on the part. The plate below is smaller relative to its boss, so the marched subtree's bounds cover most of the shape and the triangle counts come out close: 29,632 against 31,240. The difference is in where the triangles go, which the tessellation in the two panels shows. On the left the plate is a handful of large triangles with long diagonals across each face, and each bore is a clean swept strip: those are exact planes and exact cylinders, and they are coarse because a plane needs two triangles and no more. Only the blended boss carries a fine mesh, because only the boss was sampled. On the right the same part is uniformly dense: the flat faces are diced into thousands of triangles that buy nothing, because marching cubes has no way to know a plane is a plane. The code builds the part both ways and draws them side by side:
:::static
import afield.lang.solidsl.*
val q = SolidQuality(segments = 96, edgeLengthFraction = 1.0 / 80.0)
val plate = cuboid(3.2, 2.2, 0.42)
val bores = unionOf(
[-1.1 to -0.7, 1.1 to -0.7, -1.1 to 0.7, 1.1 to 0.7].map { (x, y) ->
cylinder(2.0, 0.30, segments = 96).at(x, y, 0.0)
},
)
val boss = smoothUnion(
sphere(0.42, segments = 96).at(0.0, 0.0, 0.62),
cylinder(1.1, 0.26, segments = 96).at(0.0, 0.0, 0.30),
k = 0.22,
)
val part = (plate - bores) + boss
part.buildSolid(q).use { build ->
println(build.report())
val naive = part.marchedMesh(q)
println("hybrid ${build.solid.triangleCount} triangles, naive ${naive.triangleCount}")
threeGrid(
"buildSolid — exact plate, marched boss" to build.solid.toTriMesh(normals = true),
"marchedMesh — everything approximated" to naive,
channel = "hybrid",
columns = 2,
height = 360,
view = MeshView(width = 360, height = 300, yawDegrees = 34.0, pitchDegrees = 24.0, wireframe = true),
)
}

The hybrid build needs the expression to know its own extent, which a closure cannot tell you:
SolidExpr.bounds() is computed symbolically, with nothing sampled and nothing
built. It is also what lets gyroid() and cuboid(2, 2, 2) work at all: the gyroid child has no
window of its own and borrows its parent's.
The same source as a shader
Two calls turn an expression into GLSL:
val emitted = scene.toGlsl() // GlslSolid(sdSolid, 174 statements, stepScale 0.4, 2 notes)
println(scene.toRaymarchFragment()) // sphere tracing, a normal, two lights, ready to paste
toRaymarchFragment() hands back a whole fragment shader, with the normal taken from the field's
gradient. toGlsl() is the function alone, if you have a pipeline of
your own to put it in.
The fragment shader reads three push constants: the target's size in pixels, a time that turns the
camera about the part, and a camera distance in multiples of the part's radius. A box beneath a
renderer draws it on a fullscreen triangle with SOLID_RAYMARCH_VERTEX, the vertex shader that goes
with it, and binds the three on the triangle. This is the plate and boss from the hybrid section,
sphere-traced on the GPU instead of meshed:
import afield.lang.solidsl.*
val plate = cuboid(3.2, 2.2, 0.42)
val bores = unionOf(
[-1.1 to -0.7, 1.1 to -0.7, -1.1 to 0.7, 1.1 to 0.7].map { (x, y) ->
cylinder(2.0, 0.30).at(x, y, 0.0)
},
)
val boss = smoothUnion(sphere(0.42).at(0.0, 0.0, 0.62), cylinder(1.1, 0.26).at(0.0, 0.0, 0.30), k = 0.22)
val part = (plate - bores) + boss
// the generated GLSL is this module's fragment shader
val shader = Module(
parameters(renderer) {
vertex = SOLID_RAYMARCH_VERTEX
fragment = part.toRaymarchFragment()
depthTest = false
},
).pointer()
val quad = fullscreenTriangle(shader)
shader += "quad" to quad
quad.push.bind("uResolution") { vec(window.width.toFloat(), window.height.toFloat()) }
quad.push.bind("uTime") { 1.2f }
quad.push.bind("uCameraDistance") { 1.6f }
renderer += "solid" to shader

The emitted code is straight-line, single-assignment and entirely scalar — no vec3 locals, no
swizzles, no loops. GPU drivers re-vectorise it anyway, and code that plain is easy to parse back,
which is how the emitter is tested.
Read GlslSolid.stepScale before you trace with it. Sphere tracing is only safe where the field is a
lower bound on the true distance, and several nodes break that: Intersect and Difference are
pointwise maxima, and a twisted Extrude shears space. The emitter reports 1.0, 0.7 or 0.4 with
the offending node named; multiply your march step by it.
The simplifier
Most of simplified() is housekeeping any algebra accumulates the moment somebody generates code
into it: identity transforms drop, adjacent translations fold, nested booleans of one kind flatten
into a single n-ary node — which matters here because n-ary is what reaches Manifold's batch
boolean rather than a fold of n−1 nested ones.
Two rules go further, and both need a tree rather than a closure. Duplicate operands collapse,
because two SolidExpr.Sphere(1.0) are == where two sdfSphere(1.0) closures are different
objects forever. And an operand that cannot reach the body is dropped, decided from symbolic
bounds: on a plate with fifty bores of which three actually meet it, the emitted shader goes from
610 statements to 46.
One rule can change which backend you get. SmoothUnion(a, b, k = 0) is a union, and a union has
an exact CSG form where a smooth blend never will, so simplifying can move an expression off the
level-set fallback and onto the exact path:
println(smoothUnion(a, b, k = 0.0).simplificationReport())
// nodes 3 -> 1, GLSL 19 -> 10 statements, path level set -> exact
Three rewrites are refused, so do not expect them: composing two general Euler rotations (a matrix
does not always come back out as three angles), reassociating SmoothUnion, reordering a
Difference. The last two are simply not associative and not commutative.
Solving for the numbers
A tree you can read is also a tree you can differentiate, and that is the third thing you can do with
one source. Declare which numbers in a shape are free, hand over some samples of the shape you
actually want, and a gradient finds them — the 3D version of what
RectSL's inverse animation (RectSLFn.kt in afield.lang.rectsl) does for 2D tracks.
The machinery is afield.math.autodiff.AD, reverse-mode automatic differentiation over Double:
SolidGradient.kt is SolidField.kt's toField3 transposed line for line into AD arithmetic.
Reverse mode because the objective is one scalar loss over many sample points and a handful of
parameters, so one forward pass and one backward sweep give every parameter derivative at once. The
position gradient falls off the same tape for free, which is what makes the fitted result still a
field you can march.
Naming the numbers
A SolidExpr is an anonymous tree of data classes with no names anywhere in it. A parameter is
bound instead to a structural address:
val part = smoothUnion(sphere(0.45), cylinder(1.90, 0.16), k = 0.18)
part.numericSlots()
/*
SmoothUnion/a/Sphere.radius = 0.45
SmoothUnion/b/Cylinder.height = 1.9
SmoothUnion/b/Cylinder.radiusLow = 0.16
SmoothUnion/b/Cylinder.radiusHigh = 0.16
SmoothUnion.k = 0.18
*/
val template =
part
.parameterize {
param("sphereRadius", "SmoothUnion/a/Sphere.radius")
param("barHeight", "SmoothUnion/b/Cylinder.height")
param("barRadius") { it.node == "Cylinder" && it.field.startsWith("radius") } // both, tied
}
.orThrow()
template.gradientAt(0.9, 0.0, 0.0, template.seed)
// value 0.450000 dPos 1.000, 0.000, 0.000 dParam -1.000, 0.000, 0.000
barRadius drives two slots, which is how a symmetry constraint is written: "the bar is a
cylinder, not a frustum", in the parameterisation rather than in a comment. A fit over two
independently-varying radii and a fit over one shared radius are different problems and the notation
should be able to say which is meant.
segments is missing from that list, and cannot be fitted. toScalarField() ignores it entirely — a
Sphere's field is a true sphere whatever its segments says — and the exact lowering consumes it
as a count of polygon sides, where its effect is a jump. Neither has a derivative with respect to it.
What parameterize refuses
Differentiating refuses nothing: every node with a field form has a gradient form, because AD is
closed over everything Field3 uses. It is binding a parameter that can be refused, and the
refusals fall into three groups you can act on:
- the address is wrong — a path that does not resolve, a predicate that matched nothing, or no
param(…)call at all. The message lists the slots that do exist, sonumericSlots()and the refusal tell you the same thing from either end. - two names, one number — a name declared twice, two names claiming the same slot, or one name tying slots that currently hold different values, which would make the seed ambiguous.
- the seed is singular before the first step — a
Scalefactor at zero (the field divides by it and the derivative goes as \( 1/s^2 \)) or a blendkat zero (its field clamps at1e-9, below which the blend is a hard boolean with no derivative ink, so the parameter would never move).
Which nodes are good to fit is the inverse of which nodes lower exactly:
| node | toScalarField |
exactSolid |
the gradient |
|---|---|---|---|
SmoothUnion |
native | refused — the fillet lies on neither operand | native, and C¹: no kink at all |
SmoothDifference |
native | refused | native, C¹ |
Gyroid |
native | refused — not a bounded volume | native; one abs kink on the mid-sheet |
Round |
native | refused — would not converge | native, exactly f − r |
Union |
native | native | a subgradient on the seam, split 0.5/0.5 |
The two nodes the exact kernel refuses outright are the two the gradient handles best, so if a shape
is going to be fitted, write it with smoothUnion and smoothDifference. gradientNotes() says
which of these apply to your own expression, beside fieldNotes() and allRefusals().
Where the gradient has a kink
A distance field is full of kinks: min and max are CSG, abs is Shell and Gyroid,
sqrt at zero is every radial primitive's own centre. abs, min and max split the derivative
0.5/0.5 at an exact tie, so a sample that lands exactly where two CSG children meet gets half the
gradient from each side rather than an arbitrary one of them.
gradientNotes() also flags the two places a smaller step cannot help. At a radial primitive's own
centre √· has an infinite derivative, so a sample there gives Infinity in the position gradient
and NaN as soon as it meets a zero: sample away from centres, or use the parameter gradient, which
is finite there. And a parameter that only feeds an empty subtree, or a segments count, has
derivative exactly zero for every value, so a descent on it reports convergence immediately. That
second one arrives looking like success.
Inverse design
This fit samples a target shape made from the template itself, starts from wrong values for all three parameters, and recovers them:
val target = template.withValues(doubleArrayOf(0.45, 1.90, 0.16)).sampleField(count = 2400, seed = 91)
val fit = template.fitParameters(target, start = doubleArrayOf(0.26, 1.05, 0.34))
fit.report()
/*
fit 3 parameter(s) in 14 iterations (gradient)
sphereRadius 0.260000 -> 0.450000
barHeight 1.050000 -> 1.900000
barRadius 0.340000 -> 0.160000
loss 2.140904e-02 -> 4.609346e-22
improvement 4.645e+19x
*/
fit.fitted // an ordinary SolidExpr again: marchable, GLSL-able, printable
The parameters come back to about 1e-10, and fit.fitted is an ordinary expression you can use
like any other.
Target, seed and fitted, all three marched in the same run, each panel labelled with the loss measured for it:
:::static
import afield.lang.solidsl.*
// The model, and the three numbers to be solved for. `barRadius` drives BOTH of the cylinder's
// radii, which is how "the bar is a cylinder, not a frustum" gets written down.
val template =
smoothUnion(sphere(0.45), cylinder(1.90, 0.16), k = 0.18)
.parameterize {
param("sphereRadius", "SmoothUnion/a/Sphere.radius")
param("barHeight", "SmoothUnion/b/Cylinder.height")
param("barRadius") { it.node == "Cylinder" && it.field.startsWith("radius") }
}
.orThrow()
val truth = doubleArrayOf(0.45, 1.90, 0.16)
val seed = doubleArrayOf(0.26, 1.05, 0.34) // wrong by 42%, 45% and 112%
// The target is a bag of points with a signed distance at each. Nothing below sees `truth` again.
val target = template.withValues(truth).sampleField(count = 2400, seed = 91)
val fit = template.fitParameters(target, seed)
println(fit.report())
// Every number in the three labels is measured in THIS run. There is no remembered before-value:
// `initialLoss` is the seed's loss, computed by the same code that computed the fitted one.
fun m(e: SolidExpr) = e.march(resolution = 104)
threeGrid(
"seed · loss %.3e".format(fit.initialLoss) to m(template.withValues(seed)),
"fitted · loss %.3e (%.2gx)".format(fit.loss, fit.improvementFactor) to m(fit.fitted),
"target · the shape the samples came from" to m(template.withValues(truth)),
channel = "inverse",
columns = 3,
height = 300,
view = MeshView(width = 320, height = 250, yawDegrees = 32.0, pitchDegrees = 22.0, wireframe = true),
)

The middle and right panels are the same shape, to about 1e-10. All three panels share one camera,
because MeshView.distance is in bounding-sphere radii and auto-framing would draw a shape and a
1.5× copy of it at the same apparent size.
When only half the model can fit
The fit above is the easy case: the target was generated from the model, so a correct answer
existed, and the loss reaches 4.6e-22. A real target is rarely something your model can reproduce
exactly.
Here is the same fit on a problem with no exact answer. The target is a sphere and a cylinder. The model is a cube and a cylinder. The cylinder is the same primitive on both sides, so its numbers are recoverable. The cube is being asked to be a sphere, and no side length achieves that.
val truth = sphere(0.62) + cylinder(2.20, 0.28)
val target = truth.sampleField(count = 3000, seed = 17)
val model =
(cuboid(1.0) + cylinder(1.0, 1.0).turned(y = 1.0))
.parameterize {
param("cubeSize") { it.node == "Cuboid" } // one number, all three extents
param("barHeight") { it.node == "Cylinder" && it.field == "height" }
param("barRadius") { it.node == "Cylinder" && it.field.startsWith("radius") }
param("barTilt") { it.node == "Rotate" && it.field == "y" } // an ANGLE, in degrees
}
.orThrow()
val fit = model.fitParameters(
target,
start = doubleArrayOf(1.35, 1.60, 0.42, 25.0),
SolidFitOptions(method = OptimizeMethod.ADAM, iterations = 6000, learningRate = 0.015),
)
fit
.report()
/*
fit 4 parameter(s) in 4176 iterations (gradient)
cubeSize 1.350000 -> 0.855279
barHeight 1.600000 -> 2.197225
barRadius 0.420000 -> 0.281335
barTilt 25.000000 -> -0.009525
loss 7.044887e-02 -> 3.766267e-03
rms distance 2.654221e-01 -> 6.136992e-02
improvement 18.71x
*/
Marched, the seed, the fit and the target:
:::static
import afield.lang.solidsl.*
// A sphere and a cylinder. The model below gets a CUBE and a cylinder.
val truth = sphere(0.62) + cylinder(2.20, 0.28)
val target = truth.sampleField(count = 3000, seed = 17)
val model =
(cuboid(1.0) + cylinder(1.0, 1.0).turned(y = 1.0))
.parameterize {
param("cubeSize") { it.node == "Cuboid" }
param("barHeight") { it.node == "Cylinder" && it.field == "height" }
param("barRadius") { it.node == "Cylinder" && it.field.startsWith("radius") }
param("barTilt") { it.node == "Rotate" && it.field == "y" }
}
.orThrow()
val seed = doubleArrayOf(1.35, 1.60, 0.42, 25.0)
val fit = model.fitParameters(
target,
seed,
SolidFitOptions(method = OptimizeMethod.ADAM, iterations = 6000, learningRate = 0.015),
)
println(fit.report())
/*
`margin` is not decoration here. `march`'s window defaults to the shape's OWN bounding box, and
a cuboid's faces lie exactly ON that boundary — the outermost grid samples sit at the zero
crossing, the side faces never close, and the cube renders as a flat sheet. A sphere only touches
its box tangentially, at six points, which is why the fit above gets away with the default.
*/
fun m(e: SolidExpr) = e.march(resolution = 104, margin = 0.06)
threeGrid(
"model at the seed · loss %.3e".format(fit.initialLoss) to m(model.withValues(seed)),
"fitted · loss %.3e (%.1fx)".format(fit.loss, fit.improvementFactor) to m(fit.fitted),
"target · a SPHERE and a cylinder" to m(truth),
channel = "partial",
columns = 3,
height = 300,
view = MeshView(width = 320, height = 250, yawDegrees = 32.0, pitchDegrees = 22.0, wireframe = true),
)

Compare the rods: same length, same thickness, in the middle and right panels. Then compare the bodies: a cube and a sphere, and no amount of further descent changes that.
The descent, as frames
fit.run.trajectory is the parameter vector at every recorded iteration, and fit.shapeAt(i)
substitutes one back into the template, so you can march the shapes the optimizer really held. That
shows something the two ends cannot: when each parameter stops moving.
:::static
import afield.lang.solidsl.*
val truth = sphere(0.62) + cylinder(2.20, 0.28)
val target = truth.sampleField(count = 3000, seed = 17)
val model =
(cuboid(1.0) + cylinder(1.0, 1.0).turned(y = 1.0))
.parameterize {
param("cubeSize") { it.node == "Cuboid" }
param("barHeight") { it.node == "Cylinder" && it.field == "height" }
param("barRadius") { it.node == "Cylinder" && it.field.startsWith("radius") }
param("barTilt") { it.node == "Rotate" && it.field == "y" }
}
.orThrow()
val fit = model.fitParameters(
target,
doubleArrayOf(1.35, 1.60, 0.42, 25.0),
SolidFitOptions(method = OptimizeMethod.ADAM, iterations = 6000, learningRate = 0.015),
)
/*
`run.trajectory` is the parameter vector at every recorded iteration, indexed exactly like
`run.losses`. `shapeAt` substitutes one back into the template, so these are the shapes the
optimizer really held -- not six descents truncated at six different caps.
*/
val frames = [0, 150, 500, 1200, 2400, fit.run.trajectory.lastIndex]
val tilt = model.names.indexOf("barTilt")
threeGrid(
*frames
.map { i ->
"iter %d · tilt %.1f deg · loss %.2e".format(i, fit.run.trajectory[i][tilt], fit.run.losses[i]) to
fit.shapeAt(i).march(resolution = 88, margin = 0.06)
}
.toTypedArray(),
channel = "converging",
columns = 6,
height = 210,
view = MeshView(width = 190, height = 170, yawDegrees = 32.0, pitchDegrees = 22.0, wireframe = true),
)

The cube settles almost immediately, and the angle takes the whole descent. cubeSize goes
1.350 → 0.847 inside the first 150 iterations and then spends the remaining four thousand between
0.879 and 0.855; barTilt goes 25.0° → 22.5° in that same span and needs until iteration 2400
to reach 0.18°. Both are visible in the strip: the body settles in frame two, the rod is still
turning in frame five.
barRadius gets worse before it gets better. It goes 0.420 → 0.464 over the first 150 iterations before coming back down through
0.356 and 0.290 to 0.281, which is the fat stub in frame two. While the cube is still far too
big the rod is the only thing that can carry the target's volume, and it is rewarded for thickening
until the cube gets out of the way. The loss falls at every frame, so only a per-parameter trace
shows this.
The cube reaches its compromise early because a compromise is a shallow, wide minimum: it is already
as close to a sphere as a cube gets. The angle has a real minimum at the bottom of a narrow valley,
so it walks all the way down. Stopped at iteration 500, this fit reports a reasonable-looking
loss 1.38e-02, five times lower than the seed, and a barTilt of 16.3° that is still far off.
At the end the cylinder is recovered: 2.197225 against a true 2.20, 0.281335 against a true
0.28, and barTilt, seeded 25° off, back to -0.009525°. The cube settled at 0.855279. No side
length makes a cube a sphere, but there is a sensible range: a cube that fits a sphere of radius R at all has a side between the inscribed
\( 2R/\sqrt{3} = 0.7159 \) and the circumscribed 2R = 1.2400. 0.855279 sits inside it, nearer the inscribed
end, which is where a least-squares fit on a field belongs.
Adding the tilt does not change the final loss: the four-parameter residual and the three-parameter one agree to six figures. All of the residual comes from the cube.
The report prints all four numbers the same way, and nothing in the loss says which are recovered values and which is a compromise. Only you know which parts of your model can represent which parts of the target, so read the report with that in mind.
Why this fit needs 6000 iterations when the recovery fit needed 14
barTilt is measured in degrees and everything else is measured in units of the shape,
and SolidFitOptions carries one learningRate for all of them. ADAM's step is bounded by that
rate, so a parameter whose natural scale is ~25 moves at the same ~0.015 per iteration as one
whose natural scale is ~1. At iterations = 1200 this fit stopped on max iterations with
barTilt 25.000000 -> 5.752590. It was still moving: 19.25° in 1200 steps is the step cap times
the iteration count. At 6000 it converges on the gradient tolerance in 4176.
So the angle costs about ten times the iterations of the same fit without it (4176 against
387). If you add an angle to an ADAM fit and it seems to stall, check the arithmetic:
iterations x learningRate against the distance the parameter has to travel. Or leave
method at its LBFGS default, whose line search is indifferent to units: on this problem it
reaches the same loss in 36 iterations. The figures use ADAM because its small steps make a
readable filmstrip (dev_docs/SOLIDSL_OPTIMIZER_MEASUREMENTS.md).
A cube small enough to sit wholly inside the bar never moves at all: seeded at 0.40 against a bar
of radius 0.55, cubeSize comes back 0.400000 and fit.deadParameters names it.
What makes a parameter refuse to move
This is the failure mode of inverse design, and it presents as a converged optimisation. A hard
Union is a pointwise min, so it hands the whole derivative to whichever child is nearest. A
child that is nowhere nearest (a small sphere swallowed inside a large one) receives exactly 0.0
from every sample, and a descent on its radius stops immediately because the gradient really is
zero.
unionOf([sphere(1.0), sphere(0.25)]).parameterize { … }
.fitParameters(target, …).deadParameters // [inner]
smoothUnion(sphere(1.0), sphere(0.25), k = 0.9).parameterize { … }
.fitParameters(target, …).deadParameters // []
SolidFit.deadParameters names the parameters whose gradient was exactly zero at the start, and
those the descent never moved off their seed. It is
reported rather than thrown, because a legitimately dead parameter is a fine thing to have in a
vector — a fit over six holes where one is genuinely irrelevant — and it is the unexpectedly dead
one that looks like a success.
Fitting to a point cloud
fitParameters wants signed distances at its sample points, which is a rich target and a slightly
circular one: the distances come from toScalarField(), so the oracle already speaks the model's
language. fitToPointCloud takes what you actually have — unoriented points on a surface, no sign,
no normals, no correspondence, some noise:
val cloud = target.samplePointCloud(count = 3000, seed = 4, noise = 0.004)
val fit = template.fitToPointCloud(cloud, start = doubleArrayOf(0.3, 1.1, 0.4))
The loss is \( f(p_i)^2 \) averaged over the cloud, and everything that goes wrong follows from what it
leaves out. Nothing in it wants the interior filled, so a shape that puts some surface through the
points scores as well as the one that generated them. A cloud that surrounds a closed surface pins it
from every side and recovers well; crop it to one hemisphere and every parameter that only shows
itself on the missing side wanders freely, with the loss never going up. Shell and Round are
riskier here than under fitParameters for the same reason: abs(f) − t is zero on two surfaces, so
a shell can satisfy a cloud with the wrong one. SolidCloudFit.unconstrainedParameters is the
point-cloud analogue of deadParameters and exists for the same reason — the failure arrives looking
like convergence.
A one-sided view
Here the target is three lobes in a row, each with its own radius, and the cloud is cropped to
x < 0.10, as a scanner looking from one side would see it. The right-hand lobe is centred at
x = 0.8, so no point lands on it. unconstrainedParameters is computed from the gradient at the
start, so a fit with iterations = 0 reports it before the descent takes a single step:
:::static
import afield.lang.solidsl.*
// Three lobes on the X axis with independent radii.
val template =
smoothUnion(
smoothUnion(sphere(0.50), sphere(0.35).at(-0.80, 0.0, 0.0), 0.25),
sphere(0.42).at(0.80, 0.0, 0.0),
0.25,
)
.parameterize {
param("middle", "SmoothUnion/a/SmoothUnion/a/Sphere.radius")
param("left", "SmoothUnion/a/SmoothUnion/b/Translate/Sphere.radius")
param("right", "SmoothUnion/b/Translate/Sphere.radius")
}
.orThrow()
val truth = doubleArrayOf(0.50, 0.35, 0.42)
val seed = doubleArrayOf(0.30, 0.56, 0.20)
// The view from one side: everything right of x = 0.10 is never seen.
val cloud = template.withValues(truth).samplePointCloud(count = 4000, seed = 4).filter { x, _, _ -> x < 0.10 }
// Before the descent: zero iterations, so this is only the gradient at the seed.
val before = template.fitToPointCloud(cloud, seed, SolidFitOptions(iterations = 0))
println("before any step, unconstrained: ${before.unconstrainedParameters}")
val fit = template.fitToPointCloud(cloud, seed)
println(fit.report())
// Each cloud point drawn as a tiny octahedron, so the cloud can sit in a mesh panel.
fun dots(c: SolidPointCloud, r: Float = 0.014f): TriMesh {
val pos = FloatArray(c.size * 18)
val faces = intArrayOf(0, 2, 4, 2, 1, 4, 1, 3, 4, 3, 0, 4, 2, 0, 5, 1, 2, 5, 3, 1, 5, 0, 3, 5)
val idx = IntArray(c.size * 24)
for (i in 0 until c.size) {
val x = c.xs[i].toFloat()
val y = c.ys[i].toFloat()
val z = c.zs[i].toFloat()
floatArrayOf(x + r, y, z, x - r, y, z, x, y + r, z, x, y - r, z, x, y, z + r, x, y, z - r).copyInto(pos, i * 18)
for (k in faces.indices) idx[i * 24 + k] = i * 6 + faces[k]
}
return TriMesh(pos, idx)
}
val right = template.names.indexOf("right")
fun m(e: SolidExpr) = e.march(resolution = 96, margin = 0.05)
threeGrid(
"the cloud · ${cloud.size} points, x < 0.10" to dots(cloud),
"seed · right = %.2f".format(seed[right]) to m(template.withValues(seed)),
"fitted · right = %.6f".format(fit.values[right]) to m(fit.fitted),
"truth · right = %.2f".format(truth[right]) to m(template.withValues(truth)),
channel = "one_sided",
columns = 4,
height = 230,
view =
MeshView(width = 250, height = 190, yawDegrees = 0.0, pitchDegrees = 30.0, distance = 3.6, wireframe = false),
padding = 1.41,
)

The left and middle lobes come back to within 4e-4 of the truth. right is still 0.200000,
the seed, after a descent that reports convergence, and unconstrainedParameters named it before
the first step. The loss cannot see it: there are no points out there to be off the surface. More
iterations, a different optimiser or a restart do not change that; more of the view does.
Axis conventions
Primitives are centred on the origin. Everything else follows Manifold's conventions unchanged:
cylinderruns along Z.Profile.extruded(h)runs fromz = 0toz = h— not centred, becauseSection.extrudedis not.Profile.revolved()turns about Z, profile at \( x \ge 0 \), counter-clockwise from+X.turned(x, y, z)is in degrees, applied X then Y then Z.
There is no Torus node:
torus(R, r) is a function that builds Profile.circle(r).translated(R, 0).revolved(), which is
what a torus is.
Open this
The solidsl_gallery starting point has three boxes. Open it and run them:
- one shape, two evaluations — one part put through both lowerings, with the meshes side by side.
Change
boreRadiusor dropqualityto 24 and watch the agreement degrade to the faceting floor. - the same source as a GLSL raymarcher — the emitted shader, the step scale, and the simplifier dropping 40 extra bores, most of them nowhere near the part.
- refusals, and the hybrid build — the whole refusal catalogue with its reasons, and the smallest-subtree level set beside what the naive one would have cost.
All three need nothing but Field; the second needs no Manifold at all. The first and third print a clear "install Manifold" message and fall back to the field lowering when it is absent.
For fitting there is a second starting point, solidsl_inverse,
which needs no Manifold at all. Open it and run its three boxes:
- a solid, and its exact gradient — the slot census, a template, and
checkGradientagainst central differences computed in the box. The last probe is the ball's own centre, where the parameter gradient is exact and the position gradient isNaNand the check says so. - inverse design — the fit above, with the loss curve, the seed and the fitted solid in one output.
- what refuses, and the one that does not — every parameterisation refusal with its reason, then
a swallowed child dying under a hard
Unionand living under a smooth one.