Meshes and 3D geometry
A mesh gives you a surface to work with as well as a shape to draw. Ask whether it is closed, subdivide it, smooth it without deflating it, reduce its triangle count, measure its volume, or shoot a ray at it. You can also write it out for another program to read.
Field's CPU mesh tools are built around TriMesh. They make primitives and surfaces from signed
distance fields, then let you inspect, reshape, simplify and export them. The operations run in a
box without a GPU; the graphics reference lists the API.
End a box on a TriMesh and Field draws it, with measurements beside the picture:

Use toFLines(...) to turn a mesh into a wireframe, crease set or normal field. You can then draw,
animate and export those lines alongside the rest of your work.
The value type
TriMesh(positions, indices, normals = null, uvs = null, colors = null)
Flat FloatArrays (positions is 3 * vertexCount floats, indices is 3 * triangleCount ints),
with optional per-vertex normals, uvs and colors. Three choices in that shape change how the
type behaves:
- Flat arrays, not a list of vertex objects.
Vec3inafield.math.linalgis mutable, and a mesh built out of mutable vectors is an aliasing bug waiting to happen: transform one vertex twice because two triangles share it and the surface tears. SoTriMeshstores floats and every accessor (position(i),faceNormal(t),triangle(t)) hands back a freshVec3that cannot write back into the mesh.positionInto(i, dest)is there for the inner loop that cares. - Immutable in practice. Everything returns a new mesh:
translated,scaled,rotated,transformed { },flipped,weld,loopSubdivide,decimate. So a pipeline stays readable as one expression, and re-running a box gives you the same answer every time. - Topology is computed, not stored.
topology()builds the edge map, the vertex stars and the boundary loops on demand and caches the expensive parts lazily. The mesh itself stays a plain buffer that a GPU upload or a file writer can take verbatim.
The generators return the minimal closed mesh for their shape, which is a much stronger guarantee than "it renders": a cube is 8 vertices and 12 triangles, not 24 and 12.

Four more generators build a mesh out of something you already have rather than out of parameters:
extrudeMesh (real ear clipping, so a concave polygon works and does not fan), revolveMesh (a
lathe), tubeMesh (a rotation-minimising sweep along a 3D path) and heightfieldMesh, which has a
lambda form and an Img.toHeightfieldMesh() form, and that second one is the bridge from the
imaging family into this one. The two heightfields are the only panels here
that are open surfaces rather than closed solids, so they report χ=1 instead of 2. A heightfield has
a rim.

Modelling with a field
With a signed distance field (SDF) a shape is an expression you edit rather than a thing you
assemble. A ScalarField3D is a type alias for
(Vec3) -> Float (negative inside, positive outside), and every combinator is one line.
:::static
// A shape as an expression. Every one of these is a (Vec3) -> Float; nothing is a mesh yet.
val ball = sdfSphere(0.8)
val bar = sdfBox(vec(2.2, 0.34, 0.34))
val sharp = sdfUnion(ball, bar)
val soft = sdfSmoothUnion(ball, bar, k = 0.3) // the k is the fillet radius
val drilled = sdfSmoothSubtract(soft, sdfCylinder(0.28, 4.0), k = 0.1)
val twisted = sdfBox(vec(1.2, 1.8, 0.4)).twisted(0.35)
// marchingCubes samples each grid corner and shares one vertex per crossed grid edge.
// The result is indexed; closure also requires the surface to stay inside the sampling bounds.
val window = boundsAround(radius = 1.35)
val sharpMesh = marchingCubes(sharp, window, 72)
val softMesh = marchingCubes(soft, window, 72)
val drilledMesh = marchingCubes(drilled, window, 72)
val twistedMesh = marchingCubes(twisted, boundsAround(radius = 1.2), 72)
// A shell looked at from outside is indistinguishable from the solid, so cut it open with a
// half-space: intersecting with sdfPlane keeps only the half you want and the wall becomes visible.
val shell = soft.shell(0.07)
val closedShell = marchingCubes(shell, window, 88)
val cutaway = marchingCubes(sdfIntersect(shell, sdfPlane(vec(0, 0, -1), 0.0)), window, 88)
:::fold print what each one came out as, then draw the six
// A fillet only ever ADDS material, so the smooth union must enclose more than the sharp one.
// That is a property you can assert rather than eyeball.
println("sharp union volume %.5f".format(sharpMesh.signedVolume()))
println(
"smooth union volume %.5f (the fillet adds %.5f)"
.format(
softMesh.signedVolume(),
softMesh.signedVolume() - sharpMesh.signedVolume(),
),
)
println("drilled ${drilledMesh.summary()}")
println("twisted ${twistedMesh.summary()}")
println("closed shell ${closedShell.summary()}")
println("cut open ${cutaway.summary()}")
showMeshGrid(
"sdfUnion(sphere, box)" to sharpMesh,
"sdfSmoothUnion(…, k = 0.3)" to softMesh,
"…then sdfSmoothSubtract(cylinder)" to drilledMesh,
"sdfBox(…).twisted(0.35)" to twistedMesh,
"shell(0.07) — closed, chi = 4" to closedShell,
"…cut open by sdfPlane" to cutaway,
columns = 2,
cellWidth = 320,
)
:::endfold

The box also prints each solid's volume and topology, and those numbers say more than the pictures. The sharp union encloses 2.2135 and the filleted one 2.2690, because a fillet only ever adds material. A test can assert that ordering; your eye cannot. Drilling through drops it to 1.8805 and takes χ from 2 to 0, genus 0 to 1: the drill added a handle. The closed shell reports χ = 4, which is exactly right, if odd at first: a shell is two nested closed surfaces, an outer and an inner, and 2 + 2 = 4. Cutting it with a half-space glues them into one surface again (χ = 2) and halves the z extent, from 1.739 to 0.870, which is how you know the cut actually happened.
showMeshGrid(...) is the fastest way to see a whole pipeline: labelled panels side by side, in the
Three.js viewer, so in a live box you can orbit the whole sheet at once. It is one WebGL context
tiled into viewports rather than one iframe per panel. See Field's 3D
output. Pass renderer = MeshRenderer.CPU for a
static rasterized sheet instead.
Each panel carries its label and the same fact line showMesh prints: counts, extent, χ, genus and
volume (facts = false leaves it off). For a sentence of your own under each cell, hand threeGrid
ThreeMeshPanel(label, mesh, facts = "…") cells; polytope_examples.md does
that throughout.
The primitives are sdfSphere, sdfBox, sdfRoundBox, sdfTorus, sdfPlane, sdfCylinder,
sdfCapsule, sdfGyroid and sdfMetaballs. The combinators are sdfUnion, sdfIntersect,
sdfSubtract, sdfInvert, their three sdfSmooth… variants, sdfExpUnion, and the modifiers
shell, rounded, translated, scaled, rotated, repeated, twisted and displaced.
gradient(p) and normalAt(p) finite-difference the field where you need a direction.
Looking at what you got
A broken mesh can still look fine. A missing cap, unwelded vertices or a tear in a marched surface
may be hard to see in a render. Start by measuring the mesh: the Euler characteristic
V - E + F is 2 for a closed genus-0 surface and 2 - 2g for genus g. Check watertightness and
manifoldness as well; the Euler characteristic alone cannot establish that a mesh is sound.
summary() prints the whole verdict in one line. The rest of the pieces are separately available:
isWatertight(), isManifold(), eulerCharacteristic(), genus(), boundaryLoops(),
surfaceArea(), signedVolume(), and topology() gives you the object they all delegate to.
The other half of "looking at what you got" is checking the geometry against an answer you already know. This box marches a sphere, whose volume should converge to 4/3 π, at two resolutions, beside a drilled solid and a gyroid:
:::static
// Marching cubes is O(h^2) accurate, so the error should fall by ~4x each time the resolution
// doubles. Watch it: this is the check that tells you the marcher is right, not just plausible.
val exact = 4.0 / 3.0 * PI
println("resolution tris volume relative error")
for (resolution in [12, 24, 48, 64]) {
val m = marchingCubes(sdfSphere(1.0), boundsAround(radius = 1.4), resolution)
val error = abs(m.signedVolume() - exact) / exact
println(
"%10d %7d %10.6f %14.5f watertight=%s chi=%d"
.format(
resolution,
m.triangleCount,
m.signedVolume(),
error,
m.isWatertight(),
m.eulerCharacteristic(),
),
)
}
println("the analytic answer is 4/3 pi = $exact")
// Drilling straight through a solid adds a handle, so the genus goes from 0 to 1. That is the
// topology layer telling you something a picture cannot: the two look identical from outside.
val drilled = marchingCubes(
sdfSubtract(sdfSphere(1.0), sdfCylinder(0.3, 4.0)),
boundsAround(radius = 1.3),
64,
)
println()
println("drilled sphere: ${drilled.summary()}")
/*
A gyroid is a triply-periodic minimal surface: one sheet with many handles, so chi goes strongly
negative. A scalar field need not be a distance function: signs classify corners,
while the sampled values locate interpolated edge crossings.
*/
val gyroid = marchingCubes(sdfGyroid(4.0, 0.3), boundsAround(radius = 1.0), 56)
println("gyroid: ${gyroid.summary()}")
:::fold draw the coarse and fine spheres beside the two harder shapes
val coarse = marchingCubes(sdfSphere(1.0), boundsAround(radius = 1.4), 16)
val fine = marchingCubes(sdfSphere(1.0), boundsAround(radius = 1.4), 64)
fun err(m: TriMesh) = "%.2f%%".format(100.0 * abs(m.signedVolume() - exact) / exact)
showMeshGrid(
"marched at 16 — volume off by ${err(coarse)}" to coarse,
"…at 64 — off by ${err(fine)}" to fine,
"sdfSubtract(sphere, cylinder) — genus 1" to drilled,
"sdfGyroid(4.0, 0.3) — one sheet, many handles" to gyroid,
columns = 2,
cellWidth = 340,
)
:::endfold

The two spheres are the same field at two grid resolutions: 1544 triangles and a volume of
4.1124 at 16, 24920 and 4.1840 at 64, against an analytic 4/3 π = 4.18879. Both are watertight
with χ = 2, and the error falls from 1.82% to 0.11%, roughly the O(h²) you should demand of a
marcher, and the printed table in the box carries all four resolutions. The drilled solid carries the
topology: it looks like a sphere from most angles and reports χ = 0, genus 1. The gyroid is the
awkward edge case (χ = −20, genus 4, and open, with 20 boundary loops) because the surface
runs out of the sampling box rather than closing inside it. summary() reports it as open, and
Field's 3D output draws those twenty loops over the
surface so you can see where they are.
Why the marched surface is closed
Plain marching cubes can leave holes. Two cubes sharing a face can each pick a different
triangulation for the four crossings on that face; the two disagree and the surface tears there.
marchingCubes resolves that face with the asymptotic decider, which is a pure function of the
face's own four corner values, so the two cubes always agree. It also nudges a crossing off a grid
corner whose sample sits exactly on the iso level, a case that is common (a unit sphere
on a 0.2 grid has corners at (1,0,0) and (0.6,0.8,0) precisely on it) and that otherwise
collapses every crossing out of that corner onto the corner itself.
Subdivide, and smooth without shrinking
loopSubdivide(times) quadruples the triangles and preserves χ exactly, so a watertight input stays
watertight at the same genus. Smoothing needs more care: the naive answer is
subtly wrong and the mesh will not tell you unless you measure it.
A Laplacian pass moves every vertex toward the average of its neighbours. That is diffusion, and
diffusion of a closed surface deflates it: run it long enough and the shape collapses to a point.
Taubin's fix is to alternate a positive shrinking step lambda with a slightly larger negative
inflating step mu, chosen so the pair is a low-pass filter with unity gain in the passband. The
noise still goes; the volume stays.
:::static
// Loop subdivision converges on the Loop LIMIT surface, not on the sphere its control vertices sit
// on, so the volume falls monotonically in geometrically shrinking steps. chi never moves.
var control = icosahedronMesh(0.8)
val rounds = ArrayList<TriMesh>()
:::fold subdivide three times, printing the volume step each round
println("round tris volume step")
var previous = control.signedVolume()
println("%5d %6d %10.5f".format(0, control.triangleCount, previous))
for (round in 1..3) {
control = control.loopSubdivide(1)
rounds.add(control)
val v = control.signedVolume()
println(
"%5d %6d %10.5f %9.5f chi=%d"
.format(
round,
control.triangleCount,
v,
v - previous,
control.eulerCharacteristic(),
),
)
previous = v
}
:::endfold
// Now the smoothing comparison. Same input, same iteration count, same lambda — the only
// difference is Taubin's second, inflating pass.
val rng = java.util.Random(7)
val clean = icosphereMesh(3, 0.8)
val noisy =
clean
.transformed { p ->
val s = 1f + (rng.nextFloat() - 0.5f) * 0.18f // 18% radial noise
vec(p.x * s, p.y * s, p.z * s)
}
.withVertexNormals()
val laplacian = noisy.laplacianSmooth(iterations = 30, lambda = 0.6)
val taubin = noisy.taubinSmooth(iterations = 30, lambda = 0.6, mu = -0.63)
:::fold print the four volumes — this is the whole point — and draw the six meshes
println()
println("clean volume %.5f".format(clean.signedVolume()))
println("noisy volume %.5f".format(noisy.signedVolume()))
println("laplacian volume %.5f <- a diffusion deflates the ball".format(laplacian.signedVolume()))
println("taubin volume %.5f <- the lambda/mu pair holds it".format(taubin.signedVolume()))
showMeshGrid(
"icosahedronMesh() control" to icosahedronMesh(0.8),
"loopSubdivide(1)" to rounds[0],
"loopSubdivide(3)" to rounds[2],
"+18% radial noise" to noisy,
"laplacianSmooth(30) — deflated" to laplacian,
"taubinSmooth(30) — volume held" to taubin,
columns = 3,
cellWidth = 290,
// One scale for every panel, so the deflated ball looks deflated.
sharedScale = true,
)
:::endfold

The noisy input encloses 2.1293 across an extent of 1.701. Thirty Laplacian iterations at λ = 0.6 leave 1.1509 across 1.301: it has thrown away 46% of the volume. Thirty Taubin iterations of the same λ, with μ = −0.63, leave 2.1840 across 1.631, within about 3% of the clean ball this noise was added to, and, at 1280 triangles and χ = 2 throughout, just as smooth. Reach for Taubin whenever the volume has to survive the smoothing.
In the top row, the volume falls 1.2985 → 0.8518 → … → 0.7490 and does not stop at the sphere the icosahedron's vertices sit on, because Loop subdivision converges on the Loop limit surface, not on whatever you imagined the control mesh approximated. The steps shrink geometrically and χ never moves off 2.
Both smoothers take cotangent = true for the cotangent-weighted Laplacian, which is the right
choice on an irregular triangulation because it weights by the geometry rather than by valence, and
withVertexNormals() recomputes the shading normals afterwards.
Decimate to a budget, and know the price
decimate(targetTriangles) is quadric-error-metric edge collapse: each vertex accumulates the
quadric of its incident face planes, every candidate collapse is scored by the error at its optimal
position, and the cheapest is taken until the budget is met. Collapses are vetoed by the link
condition (so the mesh cannot go non-manifold), by a normal-flip test, and by a shape test that
defers a collapse which would produce a sliver.
After reducing the triangle count, check the deviation: how far either mesh's vertices sit from
the other's surface. maxDeviation(a, b) measures both directions, so you can see how much shape
the simplification has lost.
:::static
val detailed = icosphereMesh(4, 0.8) // 5120 triangles
println("start: ${detailed.summary()}")
println()
println("target got deviation min angle watertight")
val budgets = ArrayList<Pair<String, TriMesh>>()
for (target in [2000, 600, 150]) {
val small = detailed.decimate(target)
// Symmetric: the largest distance either mesh's vertices sit from the other's surface.
val deviation = maxDeviation(detailed, small)
println(
"%6d %6d %11.5f %11.1f %s"
.format(
target,
small.triangleCount,
deviation,
small.quality().minAngleDegrees,
small.isWatertight(),
),
)
budgets.add("decimate($target) — deviation ${deviation.f4}" to small)
}
:::fold draw the original beside the three budgets
showMeshGrid(
"icosphereMesh(4) — ${detailed.triangleCount} tris" to detailed,
budgets[0].first to budgets[0].second,
budgets[1].first to budgets[1].second,
budgets[2].first to budgets[2].second,
columns = 2,
cellWidth = 340,
)
:::endfold

Every budget is hit exactly (2000, 600 and 150 triangles) and all three stay watertight at χ = 2, which is the link condition doing its job. The volume drifts from 2.1400 down to 2.1252, 2.0860 and 2.0724 (the collapses cut corners off a convex solid, so it can only lose). The number to budget against is the deviation in each panel heading: 0.0047 at 2000 triangles, 0.0166 at 600 and 0.0326 at 150, on a ball of radius 0.8, so a 34× reduction costs you about 4% of the radius. The deviation grows slower than the triangle count falls. Decimation trades on that asymmetry.
quality() is the other half of that judgement: minimum and mean triangle angle, aspect ratio,
degenerate count. A decimator that hits its budget by producing slivers has not helped you, and the
minimum angle is where that shows up first.
Drawing it, and writing it out
toFLines(mode) is the bridge back into Field. Five modes:
| mode | what you get |
|---|---|
MeshLines.EDGES |
every unique edge, one FLine each — the full wireframe |
MeshLines.CREASES |
only edges where the two faces disagree by more than creaseAngleDegrees, plus every boundary edge |
MeshLines.BOUNDARY |
the boundary loops, as closed FLines — empty for a watertight mesh |
MeshLines.NORMALS |
one short segment per vertex along its normal — a normal-field debugger |
MeshLines.FACES |
one closed triangle per face; draws each edge twice, but keeps faces apart |
CREASES is the one that makes a drawing readable: on a smooth mesh the full wireframe is a grey mat
and the crease set is correctly empty, while on a faceted or CSG solid the creases are exactly the
silhouette you would have drawn by hand. Once you have FLines, everything else in Field applies:
lines(mesh.toFLines(MeshLines.CREASES, creaseAngleDegrees = 30.0))
Going the other way, tubeMesh(fline, radius, sides, samples, caps) sweeps a tube along an FLine
using rotation-minimising frames rather than Frenet frames, so it does not twist where the path's
curvature swings through vertical.
For files: toObj(name) / objToTriMesh(text) and toStlAscii / toStlBinary /
stlToTriMesh(bytes), plus saveObj(file), saveStl(file) and triMeshFromObj(file). The
two formats differ in what you get back. OBJ keeps the index structure, so a watertight mesh
round-trips watertight. STL is triangle soup (three unshared vertices per triangle, no indices at
all), so it comes back with 3× the vertices and isWatertight() false until you weld(1e-5) it. Both
recover the volume exactly.
Shading normals are a choice rather than a property, and the choice changes the vertex count. A
generator ships without normals when its vertices sit on a crease, and then flatShaded() splits
every one, shadedNormals(creaseAngleDegrees = 35.0) splits only across creases, and
withVertexNormals() shares them all: 144, 50 and 26 vertices for the same 12-sided cylinder, from a
hard-faceted tube to one with a rounded-off rim:

OBJ and STL are the two formats this family reads and writes without a dependency; PLY, glTF and GLB read as well. Geometry from disk is the route from any format to pixels: one call per format, a measured table of what each one loses in each direction, and a list of what cannot be read at all (STEP and every other CAD boundary representation).
:::static
// A tube swept along an FLine: rotation-minimising frames, so it does not twist where the path's
// curvature swings through vertical.
val path = FLine()
for (i in 0..120) {
val t = i / 120.0
val a = 2.0 * PI * 2.0 * t
val p = vec(cos(a) * 0.6, (t - 0.5) * 1.3, sin(a) * 0.6)
if (i == 0) path.moveTo(p) else path.lineTo(p)
}
val coil = tubeMesh(path, radius = 0.1, sides = 18, samples = 120, caps = true)
println("tube along an FLine: ${coil.summary()}")
// A coarse torus, so there really are creases to keep. On a fine one the crease set is empty,
// which is the correct answer and a dull demonstration.
val ring = torusMesh(14, 8, 0.6, 0.22)
println(
"torus: ${ring.toFLines(MeshLines.EDGES).size} edges, " +
"${ring.toFLines(MeshLines.CREASES, creaseAngleDegrees = 30.0).size} creases at 30 degrees, " +
"${ring.toFLines(MeshLines.NORMALS).size} normals",
)
val patch = planeMesh(6, 6, 1.2, 1.2)
println(
"plane patch: ${patch.toFLines(MeshLines.BOUNDARY).size} boundary loop(s), " +
"${patch.toFLines(MeshLines.BOUNDARY).firstOrNull()?.nodes?.size} nodes around it",
)
// OBJ keeps the indices; STL is soup and needs a weld. Both must recover the volume exactly.
val source = icosphereMesh(3, 0.7)
val viaObj = objToTriMesh(source.toObj("ball"))
val viaStl = stlToTriMesh(source.toStlBinary())
println()
println("source ${source.vertexCount} verts volume %.6f".format(source.signedVolume()))
println(
"via OBJ ${viaObj.vertexCount} verts volume %.6f watertight ${viaObj.isWatertight()}"
.format(
viaObj.signedVolume(),
),
)
println(
"via STL ${viaStl.vertexCount} verts volume %.6f watertight ${viaStl.isWatertight()}"
.format(
viaStl.signedVolume(),
),
)
println("STL welded ${viaStl.weld(1e-5).vertexCount} verts watertight ${viaStl.weld(1e-5).isWatertight()}")
:::fold draw the coil beside the coarse torus and the STL round trip
showMeshGrid(
"tubeMesh(FLine, sides = 18)" to coil,
"torusMesh(14, 8) — creases to keep" to ring,
"icosphereMesh(3) via STL, welded" to viaStl.weld(1e-5),
columns = 3,
cellWidth = 250,
)
:::endfold

The coil is the frame test: 4320 triangles, watertight, χ = 2, and no twist anywhere along the helix even though the path's curvature swings through vertical twice. The round-tripped sphere comes back at 642 vertices, watertight, χ = 2, volume 1.4244, the same 642 vertices it started with, after having been flattened to 3840 unshared ones by the STL writer and welded back.
Measuring and querying
surfaceArea(), signedVolume() (the divergence-theorem sum, so it is signed: an inside-out
closed mesh reports a negative volume, which is the only thing that will tell you), centroid() for
the area-weighted surface centroid and solidCentroid() for the enclosed solid's.
Discrete curvature: gaussianCurvature() is the angle deficit and meanCurvature() the cotangent
Laplacian's magnitude, both divided by the mixed Voronoi area from mixedVertexAreas(). On a sphere
of radius r they are 1/r² and 1/r. A torus is the shape to look at, because its Gaussian
curvature has both signs, positive on the outside and negative in the hole:

A useful global check is Gauss–Bonnet: the integral of Gaussian curvature over a closed surface is 2πχ. Compare the computed integral with that value, while keeping in mind that a global sum can hide local errors that cancel each other.
val k = mesh.gaussianCurvature()
val areas = mesh.mixedVertexAreas()
val total = (0 until mesh.vertexCount).sumOf { k[it] * areas[it] } // == 2 * PI * chi
bvh() builds a bounding-volume hierarchy to accelerate spatial queries:
raycast(origin, direction), raycastAll(...), closestPoint(query), contains(query) (a parity
test, so it needs a watertight mesh) and signedDistance(query). asSdf() hands the whole thing
back as a ScalarField3D, which closes the loop: you can march a mesh's own signed distance field
and get a remeshed version of it. There are TriMesh.raycast / closestPoint / contains
shorthands that build a throwaway BVH, fine for one query and wrong for a loop.
convexHullMesh(points) is an incremental hull with no dependencies, and
deviationFrom / maxDeviation / isContainedIn / samplePoints are the comparison tools.
A Voronoi diagram as solids
voronoi3d(...) clips half-spaces and hands back VoronoiCell3s with polygonal faces.
VoronoiCell3.toTriMesh(shrink) and VoronoiLattice.toTriMesh(shrink, boundedOnly) turn those into
geometry you can measure, cut, export or print. Because a Voronoi tessellation partitions its clip box
exactly, there is a one-line total check on the result: the cell volumes must sum to the box's own
volume.

val lattice = Voronoi3d.build(sites, clipPadding = 0.15f)
val total = lattice.cells.sumOf { abs(it.toTriMesh(shrink = 1.0).signedVolume()) }
// total agrees with the clip box's volume to ~1e-8 relative
showMeshGrid(
"all the cells" to lattice.toTriMesh(shrink = 1.0),
"the interior cells, exploded" to lattice.toTriMesh(shrink = 0.6, boundedOnly = true),
columns = 2,
)
How a Voronoi face finds its corners
Face membership in voronoi.kt
is combinatorial rather than metric: a corner records which three planes produced it, and a
face's polygon is exactly the corners recording that plane. That matters around a
near-cospherical site cluster, where the genuine corners sit closer together than any usable
distance tolerance, and a face decided by proximity claims corners that are not its own and
fails to close. dev_docs/MESH_GEOMETRY.md has the measurements.
Recipes
| Task | Call | What to expect |
|---|---|---|
| a shape from an equation | marchingCubes(field, boundsAround(radius = r), resolution) |
Extracts the zero surface of a scalar field within the supplied bounds. Increase resolution to resolve finer geometry, at greater sampling cost. |
| is this mesh usable? | println(mesh.summary()) |
Reports mesh counts, extent and topology measurements. Inspect it before treating imported geometry as a closed solid. |
| a mesh from a photo or a heightfield | img.toHeightfieldMesh(scale = 0.25) or heightfieldMesh(nx, nz) { x, z -> h } |
Builds a sampled height surface from image values or a function. The result is a heightfield rather than a closed volume. |
| a lathed profile | revolveMesh(profile, axis, segments) |
Revolves a profile around RevolveAxis.Y or RevolveAxis.Z. Segment count controls angular tessellation. |
| a swept tube along a path | tubeMesh(fline, radius, sides, samples) |
Sweeps a polygonal cross-section along the path. Radius sets thickness, sides sets the cross-section detail and samples sets path resolution. |
| smooth without shrinking | taubinSmooth(iterations = 30, lambda = 0.6, mu = -0.63) |
Alternates smoothing passes to reduce shrinkage. Compare the result with the original when feature preservation matters. |
| more detail | loopSubdivide(2) |
Subdivides triangle connectivity twice. This adds mesh detail and changes the surface; it does not recover detail absent from the source. |
| less detail, bounded error | decimate(target) then maxDeviation(original, small) |
Reduces the mesh toward the requested target, then measures deviation from the original. Treat the measurement as a check on the chosen simplification. |
| hard edges for a renderer | flatShaded(), or shadedNormals(creaseAngleDegrees = 35.0) |
Creates flat face normals or splits shading at the chosen crease angle in degrees. This controls visible hard edges separately from topology repair. |
| soft edges | withVertexNormals() |
Computes shared vertex normals for smooth shading. It does not weld disconnected vertices or close holes. |
| a mesh that came in broken | repaired(1e-6) (weld, drop degenerates, fix winding) |
Welds nearby vertices, removes degenerate triangles and repairs winding. Check topology afterwards; repair is not proof that an arbitrary input became a valid solid. |
| just the biggest piece | largestComponent() |
Keeps the largest connected component. Smaller disconnected pieces are removed. |
| draw it in Field | lines(mesh.toFLines(MeshLines.CREASES)) |
Extracts crease edges as line geometry for a Field drawing. This emphasizes sharp changes in surface direction. |
| colour it by a per-vertex number | coloredByScalar(values, "coolwarm") |
Maps one scalar per vertex through the chosen palette. Keep the value array aligned with the mesh’s vertex indices. |
| an outline, not a solid | toFLines(MeshLines.BOUNDARY) |
Extracts open boundary edges. A watertight mesh has no such boundary outline. |
| hand it to another program | mesh.saveObj(File("out.obj")) |
Writes the mesh as OBJ. Choose a format according to which normals, colours and material data need to survive. |
| point-in-solid, many times | val tree = mesh.bvh() then tree.contains(p) |
Builds an acceleration structure once for repeated inside tests. Reuse it while the mesh geometry is unchanged. |
| the mesh's own distance field | mesh.bvh().asSdf() |
Turns mesh distance queries into a scalar field. This lets a mesh participate in field-based modelling. |
| a normalised mesh for a thumbnail | mesh.normalized(1.0) |
Rescales and centres a mesh for display. Use this on a copy when the original dimensions have physical meaning. |
Trying it yourself
Every runnable example above is a box: paste it into Field, change a resolution, a smoothing parameter or an SDF expression, and run it again. To start from boxes that already run, open mesh_geometry_examples from Starting points on the welcome screen, or open it here.
Agents: where the pictures come from
The images under the runnable examples are each box's own output, drawn by the Three.js viewer
showMeshGrid uses, and the docs build executes them. The assets/mesh_gallery/ figures are
committed captures from the mesh.gallery FIELD_TEST
(MESH_GALLERY_DIR=public/docs/assets/mesh_gallery python3 tools/run_field_tests.py --id mesh.gallery --port 9700),
composited from renderPixels, so they show the CPU rasterizer rather than the viewer. The
gallery's remaining figures, which the runnable examples already cover, stay in
dev_docs/assets/mesh_gallery/.
The gallery test fails if a generator stops being watertight, if a marched sphere's volume error stops falling with resolution, if a decimation misses its budget, if Gauss–Bonnet stops matching 2πχ, or if the Voronoi cells stop tiling their clip box.
Reference
Value type
Construct indexed triangle meshes and transform or colour them. These operations work on mesh values without requiring a renderer.
| Entry point | Behavior |
|---|---|
TriMesh |
An indexed triangle mesh: vertexCount * 3 position floats, triangleCount * 3 indices, and optional matching normal / uv / colour arrays. API. |
TriMeshBuilder |
The accumulator every generator in this family is written against: append vertices, append triangles that reference them, build. API. |
inline fun triMesh(block: TriMeshBuilder.() -> Unit): TriMesh |
Run block against a fresh TriMeshBuilder and TriMeshBuilder.build the result. API. |
fun position(i: Int): Vec3 |
A fresh Vec3 holding vertex i's position — mutating it does not touch the mesh. API. |
val triangle: Int |
Index of the triangle that was hit. API. |
fun faceNormal(t: Int): Vec3 |
Triangle t's geometric normal from its winding — normalize((b-a) x (c-a)). API. |
fun triangleArea(t: Int): Double |
Triangle t's area — half the length of the cross product, in double precision. API. |
fun vertices(): List<Vec3> |
All vertex positions as fresh vectors — convenient, but allocates one per vertex. API. |
fun withColor(r: Number, g: Number, b: Number): TriMesh |
This mesh painted a single flat colour — the fast way to make a preview readable. API. |
fun coloredBy(f: (Vec3) -> Vec3): TriMesh |
This mesh with per-vertex colours from a function of position. API. |
fun transformed(f: (Vec3) -> Vec3): TriMesh |
Every vertex position through f, indices unchanged. API. |
fun translated(d: Vec3): TriMesh |
Translated by a vector. API. |
fun scaled(s: Number): TriMesh |
Uniformly scaled about the origin. API. |
fun rotated(q: Quat): TriMesh |
Rotated by a Quat. Rigid, so the normals are rotated with the positions rather than dropped. API. |
fun flipped(): TriMesh |
Every triangle wound the other way, and every normal negated — an inside-out copy. API. |
fun filterTriangles(keep: (Int) -> Boolean): TriMesh |
A mesh made of the triangles keep accepts, with the now-unused vertices removed and the indices renumbered. Attributes follow their vertices. API. |
operator fun plus(other: TriMesh): TriMesh |
Two meshes as one, other's indices shifted past this one's vertices. API. |
Topology
Inspect connectivity before treating a mesh as a solid.
| Entry point | Behavior |
|---|---|
MeshTopology |
Cached connectivity for one TriMesh. API. |
val topology: MeshTopology |
The topology this diagnosis reads. Shared, so ask it your own questions rather than rebuilding. API. |
val isWatertight: Boolean |
True when the mesh is manifold and has no boundary — a closed surface enclosing a volume. API. |
val isManifold: Boolean |
True when every edge has one or two triangles and every vertex star is a single fan. API. |
val isConsistentlyOriented: Boolean by lazy |
True when neighbouring triangles agree about which side is out: every interior edge is traversed in opposite directions by its two triangles. API. |
val eulerCharacteristic: Int |
V - E + F over the vertices triangles actually use. API. |
val genus: Int? |
Genus from χ = 2c - 2g - b — the number of handles, 1 for a torus, 2 for a two-holed solid — where c is componentCount and b the number of boundaryLoops. API. |
val boundaryLoops: List<BoundaryLoopInfo> |
Every open boundary loop, measured — see BoundaryLoopInfo. Largest first. API. |
val components: List<MeshComponentInfo> |
Each connected piece, measured — see MeshComponentInfo. Largest first. API. |
fun TriMesh.weld(tolerance: Double = 1e-6): TriMesh |
Merge vertices closer together than tolerance and renumber the triangles onto the survivors. API. |
fun TriMesh.fixWinding(): TriMesh |
Make the winding consistent across the whole mesh, then make it point outward. API. |
fun TriMesh.removeDegenerateTriangles(areaEpsilon: Double = 1e-14): TriMesh |
Drop triangles with no area — a repeated index, or three collinear corners — and any vertex that leaves unreferenced. API. |
fun TriMesh.removeUnusedVertices(): TriMesh |
Drop vertices no triangle references, renumbering the rest. API. |
fun TriMesh.repaired(tolerance: Double = 1e-6): TriMesh |
Weld, drop degenerate triangles, drop unused vertices and fix the winding — the one call to make before trusting a mesh that came from a file, a scanner, or a per-cell generator. API. |
fun TriMesh.largestComponent(): TriMesh |
The largest connected component, by triangle count — the usual way to throw away the specks a noisy iso-surface leaves around the shape you wanted. API. |
Generators
Build a starting shape from primitives, profiles or a height field.
| Entry point | Behavior |
|---|---|
fun boxMesh(sizeX: Number = 1.0, sizeY: Number = 1.0, sizeZ: Number = 1.0): TriMesh |
An axis-aligned box centred on the origin, 8 vertices and 12 triangles — the minimal watertight cube. API. |
fun cubeMesh(size: Number = 1.0): TriMesh |
A cube: boxMesh with all three extents equal to size. API. |
fun planeMesh(nx: Int = 1, ny: Int = 1, width: Number = 1.0, height: Number = 1.0): TriMesh |
A rectangular grid of quads in the xy plane at z = 0, facing +z, with normals and uvs. API. |
fun uvSphereMesh(segments: Int = 32, rings: Int = 16, radius: Number = 1.0): TriMesh |
A latitude/longitude sphere: two pole vertices and rings - 1 rows of segments. API. |
fun icosphereMesh(subdivisions: Int = 2, radius: Number = 1.0): TriMesh |
A geodesic sphere: an icosahedron with each triangle split into four subdivisions times, every vertex pushed out to radius. API. |
fun torusMesh(majorSegments: Int = 48, minorSegments: Int = 24, majorRadius: Number = 1.0, minorRadius: Number = 0.35): TriMesh |
A torus in the xz plane: majorRadius out to the centre of the tube, minorRadius the tube itself. Watertight, smooth normals, and the family's canonical genus 1 shape — χ = 0, MeshTopology.genus 1. API. |
fun cylinderMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, caps: Boolean = true): TriMesh |
A cylinder along y, centred on the origin. Watertight with caps (χ = 2); an open tube without them (χ = 0, two boundary loops). API. |
fun coneMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, cap: Boolean = true): TriMesh |
A cone along y with its apex at +height/2 and its base at -height/2. Watertight with cap (χ = 2). No normals — the apex is a genuine singularity. API. |
fun capsuleMesh(segments: Int = 32, rings: Int = 8, radius: Number = 0.4, height: Number = 1.0): TriMesh |
A capsule (cylinder with hemispherical ends) along y: total length height + 2 * radius. API. |
fun tetrahedronMesh(radius: Number = 1.0): TriMesh |
A regular tetrahedron with its vertices on a sphere of radius. 4 vertices, 4 triangles, χ = 2. No normals — every face is a crease. API. |
fun octahedronMesh(radius: Number = 1.0): TriMesh |
A regular octahedron with its 6 vertices on a sphere of radius. 8 triangles, χ = 2. API. |
fun icosahedronMesh(radius: Number = 1.0): TriMesh |
A regular icosahedron with its 12 vertices on a sphere of radius. 20 triangles, χ = 2. API. |
fun dodecahedronMesh(radius: Number = 1.0): TriMesh |
A regular dodecahedron with its 20 vertices on a sphere of radius, as 12 pentagons fanned from their own centres — 32 vertices, 60 triangles, χ = 2. API. |
fun triangulatePolygon(polygon: List<Vec2>): List<Int> |
Ear-clipping triangulation of a simple (non-self-intersecting) polygon. API. |
fun polygonMesh(polygon: List<Vec2>): TriMesh |
A flat, triangulated polygon in the xy plane at z = 0, facing +z. Concave polygons are handled (triangulatePolygon). A disc: χ = 1, one boundary loop. API. |
fun extrudeMesh(polygon: List<Vec2>, height: Number = 1.0, caps: Boolean = true): TriMesh |
A prism: polygon in the xy plane swept along +z by height. API. |
fun revolveMesh(profile: List<Vec2>, segments: Int = 48, turns: Double = 1.0): TriMesh |
A lathe: a 2D profile of (radius, y) points swept around the y axis. API. |
fun heightfieldMesh(nx: Int = 64, nz: Int = 64, width: Number = 2.0, depth: Number = 2.0, height: (Float, Float) -> Float): TriMesh |
A landscape: a grid in the xz plane whose y comes from height, with computed normals and uvs. API. |
fun Img.toHeightfieldMesh(scale: Number = 0.25, width: Number = 2.0, depth: Number? = null, step: Int = 1): TriMesh |
A heightfield sampled from an Img — one grid vertex per pixel by default, with the image's luminance (or its single channel) as y. API. |
Fields / marching cubes
Define a solid by a scalar field and extract its surface with marching cubes.
| Entry point | Behavior |
|---|---|
ScalarField3D |
A scalar function of x, y and z. Negative values represent the inside when it is used as a signed-distance field; marching cubes extracts an iso-surface from it. |
fun boundsOf(a: Vec3, b: Vec3): AxisAlignedBoundingBox3D |
An AxisAlignedBoundingBox3D from two corners, sorted so min <= max on every axis. API. |
fun boundsAround(center: Vec3 = Vec3(0f, 0f, 0f), radius: Number = 1.0): AxisAlignedBoundingBox3D |
A cube of side 2 * radius centred on center — the usual march window for a centred shape. API. |
fun bounds(): AxisAlignedBoundingBox3D |
Bounds of the whole hierarchy — the mesh's AABB, for free. API. |
fun sdfSphere(radius: Number = 1.0, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A sphere of radius centred on center. Exact signed distance. API. |
fun sdfBox(size: Vec3 = Vec3(1f, 1f, 1f), center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
An axis-aligned box of full extents size centred on center. Exact signed distance (the standard length(max(q,0)) + min(max(q.x,q.y,q.z),0) form, correct outside and inside). API. |
fun sdfRoundBox(size: Vec3 = Vec3(1f, 1f, 1f), radius: Number = 0.1, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A box with its edges and corners rounded off by radius — sdfBox shrunk then offset. API. |
fun sdfTorus(majorRadius: Number = 1.0, minorRadius: Number = 0.3, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A torus in the xz plane: majorRadius to the tube centre, minorRadius the tube. Exact. API. |
fun sdfPlane(normal: Vec3 = Vec3(0f, 1f, 0f), offset: Number = 0.0): ScalarField3D |
A half-space: the signed distance to the plane through normal * offset with that normal. API. |
fun sdfCylinder(radius: Number = 0.5, height: Number = 1.0, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A finite cylinder along y: radius across, height tall, centred on center. Exact. API. |
fun sdfCapsule(a: Vec3, b: Vec3, radius: Number = 0.25): ScalarField3D |
A capsule: the radius-thick tube around the segment from a to b. Exact. API. |
fun sdfGyroid(scale: Number = 3.0, thickness: Number = 0.2): ScalarField3D |
The gyroid, sin x cos y + sin y cos z + sin z cos x, thresholded to a sheet of thickness. API. |
fun sdfMetaballs(centers: List<Vec3>, radius: Number = 0.5, iso: Number = 1.0): ScalarField3D |
A sum of smooth radial blobs — the classic metaball field, iso at the surface. API. |
fun sdfUnion(vararg fields: ScalarField3D): ScalarField3D |
Everything inside any of fields — pointwise min. API. |
fun sdfIntersect(vararg fields: ScalarField3D): ScalarField3D |
Everything inside all of fields — pointwise max. API. |
fun sdfSubtract(a: ScalarField3D, b: ScalarField3D): ScalarField3D |
a with b drilled out of it — max(a, -b). API. |
fun sdfInvert(f: ScalarField3D): ScalarField3D |
Inside and outside swapped. API. |
fun sdfSmoothUnion(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
A union with a fillet of radius roughly k where the two surfaces meet — the polynomial smooth minimum, the operator that makes SDF modelling feel like clay rather than CSG. API. |
fun sdfSmoothIntersect(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
sdfSmoothUnion's counterpart for intersection: a filleted max. API. |
fun sdfSmoothSubtract(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
sdfSubtract with a fillet, for a drilled hole whose rim is not a knife edge. API. |
fun sdfExpUnion(fields: List<ScalarField3D>, k: Number = 8.0): ScalarField3D |
The exponential smooth minimum — smoother than sdfSmoothUnion and, unlike it, associative, so blending many shapes at once gives the same answer whatever order you fold them in. API. |
fun ScalarField3D.shell(thickness: Number = 0.05): ScalarField3D |
A hollow shell of wall thickness 2 * thickness around this field's surface. API. |
fun ScalarField3D.rounded(radius: Number = 0.05): ScalarField3D |
This shape grown outward by radius, which rounds off every convex edge it has. API. |
fun ScalarField3D.translated(offset: Vec3): ScalarField3D |
This field moved by offset. API. |
fun ScalarField3D.scaled(factor: Number): ScalarField3D |
This field uniformly scaled about the origin. Distances are rescaled too, so it stays an SDF. API. |
fun ScalarField3D.rotated(q: Quat): ScalarField3D |
This field rotated by q. Rigid, so it stays an SDF. API. |
fun ScalarField3D.repeated(period: Vec3): ScalarField3D |
This shape tiled infinitely on a lattice of period — one primitive, unlimited copies, at no extra cost per copy. Zero on an axis leaves that axis untiled. API. |
fun ScalarField3D.twisted(turnsPerUnit: Number = 0.25): ScalarField3D |
This shape twisted about y by turnsPerUnit full turns per unit of height. API. |
fun ScalarField3D.displaced(amplitude: Number = 0.05, frequency: Number = 6.0): ScalarField3D |
This shape's surface pushed in and out by a sine lattice of amplitude amplitude and spatial frequency — the cheapest interesting texture, and no longer a true distance function (which the marcher does not mind). API. |
fun ScalarField3D.gradient(p: Vec3, h: Number = 1e-3): Vec3 |
Central-difference gradient of this field at p, step h. Six evaluations. API. |
fun ScalarField3D.normalAt(p: Vec3, h: Number = 1e-3): Vec3 |
The unit outward normal of this field's iso-surface at p — gradient, normalised. API. |
fun marchingCubes(field: ScalarField3D, bounds: AxisAlignedBoundingBox3D, nx: Int, ny: Int, nz: Int, iso: Double = 0.0, positiveOutside: Boolean = true, vertexNormals: Boolean = true): TriMesh |
marchingCubes with the cell count given per axis, when the shape wants an anisotropic grid. API. |
fun ScalarField3D.march(bounds: AxisAlignedBoundingBox3D = boundsAround(), resolution: Int = 48, iso: Double = 0.0, positiveOutside: Boolean = true): TriMesh |
marchingCubes as a method on the field, for sdfSphere(1.0).march(...)-style chains. API. |
fun Img.asHeightSdf(scale: Number = 0.25, bounds: AxisAlignedBoundingBox3D = boundsAround()): ScalarField3D |
The image as a 3D height field: the image's value at (x, z) minus y, the same for every y. Marching it gives the same surface Img.toHeightfieldMesh builds directly, which is a useful cross-check and occasionally a useful modelling primitive (you can subtract a heightfield from a solid). API. |
Processing
Change surface normals, density or smoothness.
| Entry point | Behavior |
|---|---|
fun TriMesh.withVertexNormals(areaWeighted: Boolean = true): TriMesh |
Per-vertex normals from the incident triangles, each weighted by its area. API. |
fun TriMesh.flatShaded(): TriMesh |
One normal per triangle: every triangle gets its own three vertices, so every edge is hard. API. |
fun TriMesh.shadedNormals(creaseAngleDegrees: Double = 35.0): TriMesh |
Crease-aware normals: smooth across edges where the two faces are within creaseAngleDegrees of each other, hard where they are not. API. |
fun TriMesh.loopSubdivide(times: Int = 1): TriMesh |
Loop subdivision, times rounds: every triangle becomes four, and every old vertex moves toward the average of its neighbours by the Loop weights. API. |
fun TriMesh.laplacianSmooth(iterations: Int = 1, lambda: Double = 0.5, cotangent: Boolean = false, preserveBoundary: Boolean = true): TriMesh |
Laplacian smoothing: each vertex moved lambda of the way toward the weighted average of its neighbours, iterations times. API. |
fun TriMesh.taubinSmooth(iterations: Int = 10, lambda: Double = 0.5, mu: Double = -0.53, cotangent: Boolean = false, preserveBoundary: Boolean = true): TriMesh |
Taubin λ/μ smoothing: a shrinking pass at +λ followed by an inflating pass at -μ, iterations times. API. |
fun TriMesh.decimate(targetTriangles: Int, maxError: Double? = null): TriMesh |
Simplify to at most targetTriangles by quadric-error-metric edge collapse. API. |
fun TriMesh.quality(): MeshQuality |
Measure this mesh's triangle shapes — see MeshQuality. API. |
MeshQuality |
How well-shaped a mesh's triangles are — the number to look at before and after a remesh, a decimation or a marching pass. API. |
Measure / query
Measure a surface or query where points and rays meet it.
| Entry point | Behavior |
|---|---|
fun TriMesh.surfaceArea(): Double |
Total area of every triangle, in double precision. API. |
fun TriMesh.signedVolume(): Double |
Volume enclosed, as the signed sum of the tetrahedra from the origin to each triangle. API. |
fun TriMesh.volume(): Double |
signedVolume's magnitude — the volume whichever way the mesh is wound. API. |
fun TriMesh.centroid(): Vec3 |
The area-weighted centroid of the surface — where a hollow shell of this shape would balance. API. |
fun TriMesh.solidCentroid(): Vec3 |
The centre of mass of the enclosed solid, assuming uniform density — the volume-weighted sum of the same tetrahedra signedVolume uses. API. |
fun TriMesh.mixedVertexAreas(): DoubleArray |
Per-vertex mixed Voronoi area — the area each vertex "owns", the denominator both curvatures need. API. |
fun TriMesh.gaussianCurvature(): DoubleArray |
Discrete Gaussian curvature per vertex: the angle deficit \( 2\pi - \sum\theta \) divided by the vertex's mixed area. API. |
fun TriMesh.meanCurvature(): DoubleArray |
Discrete mean curvature per vertex, signed against the surface normal: half the magnitude of the cotangent Laplacian, divided by the mixed area. API. |
MeshBvh |
A bounding-volume hierarchy over a mesh's triangles: build once, query many times. API. |
fun TriMesh.bvh(leafSize: Int = 8): MeshBvh |
Build a MeshBvh over this mesh. Hold on to it — building is the expensive part. API. |
fun TriMesh.raycast(origin: Vec3, direction: Vec3): RayHit? |
One-shot ray query. Builds a BVH, so use bvh directly for more than a handful of rays. API. |
fun raycastAll(origin: Vec3, direction: Vec3, maxDistance: Double = Double.MAX_VALUE): List<RayHit> |
Every triangle the ray meets, nearest first — what a "cut this solid with a line" query wants. API. |
fun closestPoint(query: Vec3): SurfacePoint? |
The nearest point of the mesh's surface to query, or null for an empty mesh. API. |
fun contains(query: Vec3): Boolean |
Is query inside the mesh? API. |
fun signedDistance(query: Vec3): Double |
Distance to the surface, negative inside — the mesh as a ScalarField3D. API. |
fun asSdf(): ScalarField3D |
This mesh as a signed-distance ScalarField3D, for composing with the sdf… combinators. API. |
RayHit |
What a ray hit: the triangle, the distance along the ray, and the point and normal there. API. |
SurfacePoint |
The nearest point on a mesh to a query point. API. |
fun convexHullMesh(points: List<Vec3>, tolerance: Double = 1e-7): TriMesh |
The convex hull of a point cloud, as a TriMesh — incremental construction, no dependencies. API. |
fun TriMesh.convexHull(): TriMesh |
The convex hull of this mesh's own vertices — see convexHullMesh. API. |
fun TriMesh.deviationFrom(other: TriMesh): Double |
How far apart two meshes are, sampled: the largest distance from a vertex of other to the surface of this mesh (a one-sided, vertex-sampled Hausdorff distance). API. |
fun maxDeviation(a: TriMesh, b: TriMesh): Double |
The larger of the two one-sided deviationFrom distances — symmetric, still vertex-sampled. API. |
fun TriMesh.isContainedIn(hullCandidate: TriMesh, tolerance: Double = 1e-4): Boolean |
The solid angle-free, purely combinatorial check that this mesh's vertices all lie on or inside hullCandidate — used by the convex-hull tests, and occasionally useful on its own. API. |
fun TriMesh.samplePoints(count: Int, seed: Long = 1L): List<Vec3> |
Uniform-ish points on this mesh's surface, chosen by area so a finely triangulated region is not over-sampled — the sampler a Monte-Carlo volume check or a point-cloud export wants. API. |
fun angleBetweenDegrees(a: Vec3, b: Vec3): Double |
The angle between two vectors in degrees, 0..180, or 0 if either has no length. API. |
I/O
Read or write mesh files. OBJ and STL use the direct helpers; the broader loader also accepts PLY and scene formats such as glTF.
| Entry point | Behavior |
|---|---|
fun TriMesh.toObj(name: String = "mesh", decimals: Int = 6): String |
This mesh as Wavefront OBJ text: v positions, then vn normals and vt uvs if present, then f faces with 1-based indices. API. |
fun objToTriMesh(text: String): TriMesh |
Parse Wavefront OBJ text into a TriMesh. API. |
fun TriMesh.saveObj(file: File, name: String = file.nameWithoutExtension) |
Write this mesh to file as OBJ. Creates parent directories. API. |
fun triMeshFromObj(file: File): TriMesh |
Read an OBJ file — see objToTriMesh for what survives. API. |
fun TriMesh.toStlAscii(name: String = "mesh"): String |
This mesh as ASCII STL. One facet per triangle with its face normal, no vertex sharing (the format has none). API. |
fun TriMesh.toStlBinary(header: String = "Field afield.stdlib mesh_io.kt"): ByteArray |
This mesh as binary STL: an 80-byte header, the triangle count, then 50 bytes per triangle (little-endian float normal, three float positions, a zero attribute short). API. |
fun stlToTriMesh(bytes: ByteArray): TriMesh |
Parse STL, ASCII or binary — the format is sniffed, not declared. API. |
fun TriMesh.saveStl(file: File) |
Write binary STL to file. Creates parent directories. API. |
fun meshFromFile(path: String): TriMesh |
Load a mesh from a path, choosing the reader by extension (.obj, .stl). API. |
meshFromAnyFile(path) |
Loads OBJ, STL, PLY or an Assimp-supported scene into a CPU TriMesh. Scene parts are baked through their transforms; unreadable paths throw instead of returning an empty mesh. |
Field integration
Convert a mesh for display, renderer upload or further Field drawing.
| Entry point | Behavior |
|---|---|
MeshLines |
Which lines TriMesh.toFLines should produce. API. |
fun TriMesh.toFLines(mode: MeshLines = MeshLines.EDGES, creaseAngleDegrees: Double = 25.0, normalLength: Double = 0.03): List<FLine> |
This mesh as drawable FLines — the fastest way to see a mesh in a Field document without involving a renderer. API. |
fun tubeMesh(path: List<Vec3>, radius: Number = 0.1, sides: Int = 12, closed: Boolean = false, caps: Boolean = true): TriMesh |
A round tube swept along a 3D polyline — the mesh you want for a drawn stroke, a trajectory or a graph edge in 3D. API. |
fun VoronoiCell3.toTriMesh(shrink: Double = 1.0, fanFromCentroid: Boolean = true, healToConvexHull: Boolean = true): TriMesh |
One 3D Voronoi cell's polygonal faces, triangulated into a closed TriMesh. API. |
fun VoronoiLattice.toTriMesh(shrink: Double = 0.92, boundedOnly: Boolean = false): TriMesh |
Every cell of a 3D Voronoi lattice as one TriMesh, one connected component per cell. API. |
fun TriMesh.toCapturedMesh(label: String = "TriMesh", creaseAngleDegrees: Double = 35.0): VisionGeometryPreview.CapturedMesh |
Pack this mesh into the 16-float-per-vertex layout VisionGeometryPreview's rasterizer consumes, so the whole shaded / depth-buffered / wireframed renderer applies to a TriMesh for free. API. |
fun TriMesh.coloredByScalar(values: DoubleArray, colormap: String = "viridis", low: Double? = null, high: Double? = null, percentile: Double = 0.02): TriMesh |
Per-vertex colours from a per-vertex scalar, through the same afield.graphics.color.Colormaps tables RectSL's charts and Img.colormap use — so a curvature field, a distance field and a bar chart in the same document are coloured by one source of truth. API. |
MeshView |
How to draw a TriMesh in a preview: where the camera stands and what the frame looks like. API. |
fun TriMesh.renderPng(view: MeshView = MeshView(), caption: String? = null, title: String? = null): ByteArray |
This mesh rasterised to PNG bytes: perspective, z-buffer, two-light lambert shading over the per-vertex colours, and a depth-tested wireframe. API. |
fun TriMesh.renderPixels(view: MeshView = MeshView()): VisionGeometryPreview.Raster |
This mesh's raster as ARGB ints plus its size — for compositing several meshes into one figure. API. |
fun TriMesh.toFigureHtml(view: MeshView = MeshView(), caption: String? = null, title: String? = null): String |
A complete <figure> — this mesh rendered as an inline PNG plus its measurements as a caption — for display(…) in a box output or for the value visualizer. API. |
fun Box.showMesh(mesh: TriMesh, channel: String = "main", view: MeshView = MeshView(), caption: String? = null, title: String? = null, renderer: MeshRenderer = MeshRenderer.GPU, height: Int = 430) |
Push a rendered view of mesh to one of this box's output channels, with its measurements as a caption. API. |
fun Box.showMeshGrid(vararg panels: Pair<String, TriMesh>, channel: String = "main", columns: Int = 2, cellWidth: Int = 380, view: MeshView = MeshView(), renderer: MeshRenderer = MeshRenderer.GPU) |
Several meshes side by side, each rendered and captioned — the fastest way to see a whole pipeline (generated / subdivided / smoothed / decimated) side by side. API. |
fun Box.showMeshTurntable(mesh: TriMesh, channel: String = "main", views: List<MeshView> = MeshView.turntable(4), cellWidth: Int = 260, title: String? = null, renderer: MeshRenderer = MeshRenderer.GPU) |
A turntable contact sheet of one mesh: the same shape from views, laid out in a row. API. |
fun TriMesh.summary(): String |
A one-line factual summary of a mesh: counts, extent, topology and volume — the thing to print when you want the numbers rather than the picture. API. |
fun TriMesh.longestEdge(): Double |
Longest edge in the mesh — a useful scale for choosing a weld tolerance or a march resolution. API. |
fun TriMesh.meanEdgeLength(): Double |
Mean edge length over the mesh's unique edges. API. |
fun TriMesh.normalized(size: Number = 1.0): TriMesh |
This mesh recentred on its own bounding box and scaled so its longest axis is size. API. |
Source:
src/jvmMain/kotlin/afield/geometry/mesh/mesh.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_topology.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_generate.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_sdf.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_process.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_measure.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_io.kt,
src/jvmMain/kotlin/afield/geometry/mesh/mesh_field.kt.
Developer notes, including the measurements behind the Voronoi fix:
dev_docs/MESH_GEOMETRY.md.
Reading a mesh in from PLY, glTF or GLB: Geometry from disk, over
afield/graphics/scene/MeshFromDisk.kt.
Build a scene with meshes, lines and points using three { … }. Use geometry review to select a visible feature and return its coordinates, note and images to Field.