mesh_generate.kt
src/jvmMain/kotlin/afield/geometry/mesh/mesh_generate.kt · 1056 lines
package and imports — 79 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.geometry.mesh
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.graphics.render.rangeTo
import afield.graphics.shader.compute
import afield.graphics.text.TextLayout
import afield.graphics.text.layout
import afield.graphics.text.toMesh
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.floor
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.product
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.vec
import afield.math.linalg.xy
import afield.math.linalg.xz
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.media.image.close
import afield.media.image.open
import afield.media.image.rotate
import afield.media.image.split
import afield.media.image.threshold
import afield.media.video.image
import afield.simulation.automata.toMesh
import afield.simulation.automata.trajectory
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.r
import afield.data.collections.s
import afield.document.workspace.path
import afield.graphics.shader.mesh
import afield.graphics.shader.vertex
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.dot
import afield.math.autodiff.sum
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.ab
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.ia
import afield.math.linalg.l
import afield.math.linalg.x
import afield.media.image.Img
import afield.media.image.luma
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.ui.output.h
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
Where a TriMesh comes from: the primitives (boxMesh, uvSphereMesh, icosphereMesh,
torusMesh, cylinderMesh, coneMesh, capsuleMesh, the five platonics) and the derived
generators that build one out of something you already have (extrudeMesh, revolveMesh,
tubeMesh, heightfieldMesh, reliefMesh, polygonMesh).
Welding and normals
Everything here is welded and watertight where it can be. A generator returns the minimal
vertex set, so MeshTopology.eulerCharacteristic of a box, a sphere, a cone or a capped cylinder
is exactly 2, of a torus exactly 0, and TriMesh.isWatertight is true. The volume, curvature
and decimation code all need that; a mesh that merely renders is not enough for any of them.
Normals are only attached where they are unambiguous. A sphere, icosphere, torus and capsule are smooth everywhere, so they carry per-vertex normals. A box, cylinder, cone, extrusion or platonic solid has creases: the two faces meeting at an edge want different normals at the same vertex, and you cannot have both a shared vertex and two normals. Duplicating the vertex would break watertightness and averaging across the crease would round it off, so these ship with no normals and you pick:
boxMesh().flatShaded() // one normal per triangle: hard edges, 3 verts per tri
boxMesh().shadedNormals(35.0) // split only where faces disagree by > 35 degrees
boxMesh().withVertexNormals() // one averaged normal per shared vertex: rounded corners
The preview / render bridges in mesh_field.kt call TriMesh.shadedNormals for you when a mesh
arrives without normals, so a bare boxMesh() still looks like a box.
Orientation
+y is up, matching the preview camera. Radial generators sweep x = r cos u, z = -r sin u, which
is the handedness that makes counter-clockwise-from-outside windings come out right. planeMesh
and polygonMesh live in the xy plane facing +z; heightfieldMesh lives in the xz plane
facing +y: a plane is a wall and a heightfield is a landscape.
Boxes and planes
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.
No normals (see the file overview): the eight corners are shared by three faces each. χ = 2.
@param sizeX full extent along x (not the half-extent). @param sizeY full extent along y. @param sizeZ full extent along z.
fun boxMesh(sizeX: Number = 1.0, sizeY: Number = 1.0, sizeZ: Number = 1.0): TriMesh {
val hx = sizeX.toFloat() / 2f; val hy = sizeY.toFloat() / 2f; val hz = sizeZ.toFloat() / 2f
return triMesh {
// bit 0 = +x, bit 1 = +y, bit 2 = +z
for (i in 0..7) {
vertex(
if (i and 1 != 0) hx else -hx,
if (i and 2 != 0) hy else -hy,
if (i and 4 != 0) hz else -hz,
)
}
quad(4, 5, 7, 6) // +z
quad(1, 0, 2, 3) // -z
quad(1, 3, 7, 5) // +x
quad(0, 4, 6, 2) // -x
quad(2, 6, 7, 3) // +y
quad(0, 1, 5, 4) // -y
}
}
fun cubeMesh(size: Number = 1.0): TriMesh = boxMesh(size, size, size)
A cube: boxMesh with all three extents equal to size.
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.
A disc, not a solid: χ = 1, one boundary loop, TriMesh.isWatertight false. Use it as a wall, a
sprite, or the input to a displacement.
@param nx quads across (>= 1), so nx + 1 vertices.
@param ny quads up.
@param width total extent along x, centred on the origin.
@param height total extent along y.
fun planeMesh(nx: Int = 1, ny: Int = 1, width: Number = 1.0, height: Number = 1.0): TriMesh {
require(nx >= 1 && ny >= 1) { "planeMesh needs at least one quad in each direction, got ${nx}x$ny" }
val w = width.toFloat(); val h = height.toFloat()
val up = Vec3(0f, 0f, 1f)
return triMesh {
for (j in 0..ny) for (i in 0..nx) {
val u = i.toFloat() / nx
val v = j.toFloat() / ny
vertex((u - 0.5f) * w, (v - 0.5f) * h, 0f, normal = Vec3(up), uv = Vec2(u, v))
}
fun at(i: Int, j: Int) = j * (nx + 1) + i
for (j in 0 until ny) for (i in 0 until nx) {
quad(at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1))
}
}
}
Spheres
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.
Watertight with χ = 2, no degenerate pole triangles (the poles are single vertices with a
triangle fan, not a collapsed quad row), and smooth per-vertex normals. The seam is welded, which
means the uv parameterisation would be discontinuous there, so no uvs are attached — a
watertight sphere and a seamless uv layout are mutually exclusive.
icosphereMesh has more uniform triangles and is usually the better sphere; this one exists because its rows are a predictable lattice, which some displacement work wants.
@param segments longitude divisions (>= 3).
@param rings latitude bands (>= 2), so rings - 1 interior rows.
fun uvSphereMesh(segments: Int = 32, rings: Int = 16, radius: Number = 1.0): TriMesh {
require(segments >= 3) { "uvSphereMesh needs >= 3 segments, got $segments" }
require(rings >= 2) { "uvSphereMesh needs >= 2 rings, got $rings" }
val r = radius.toFloat()
return triMesh {
val north = vertex(0f, r, 0f, normal = Vec3(0f, 1f, 0f))
val rows = Array(rings - 1) { IntArray(segments) }
for (j in 1 until rings) {
val theta = PI * j / rings
val y = (cos(theta) * r).toFloat()
val ringR = (sin(theta) * r).toFloat()
for (i in 0 until segments) {
val phi = 2.0 * PI * i / segments
val x = (cos(phi) * ringR).toFloat()
val z = (-sin(phi) * ringR).toFloat()
rows[j - 1][i] = vertex(x, y, z, normal = Vec3(x / r, y / r, z / r))
}
}
val south = vertex(0f, -r, 0f, normal = Vec3(0f, -1f, 0f))
val top = rows[0]
for (i in 0 until segments) triangle(north, top[i], top[(i + 1) % segments])
for (j in 0 until rings - 2) {
val upper = rows[j]; val lower = rows[j + 1]
for (i in 0 until segments) {
val n = (i + 1) % segments
quad(upper[n], upper[i], lower[i], lower[n])
}
}
val bottom = rows[rings - 2]
for (i in 0 until segments) triangle(south, bottom[(i + 1) % segments], bottom[i])
}
}
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.
20 * 4^subdivisions triangles, all near-equilateral — which is why this is the sphere to reach
for when the triangles themselves matter (curvature, decimation, relaxation, anything that dislikes
the sliver triangles a UV sphere has at its poles). Watertight, χ = 2, smooth normals.
@param subdivisions 0 gives the bare icosahedron; 3 gives 1280 triangles; 6 gives 81920.
fun icosphereMesh(subdivisions: Int = 2, radius: Number = 1.0): TriMesh {
require(subdivisions >= 0) { "subdivisions must be >= 0, got $subdivisions" }
val r = radius.toFloat()
var mesh = icosahedronMesh(1.0)
repeat(subdivisions) { mesh = subdivideAndProject(mesh) }
// Push out to the target radius and attach the exact analytic normals.
val pos = FloatArray(mesh.positions.size)
val nor = FloatArray(mesh.positions.size)
for (v in 0 until mesh.vertexCount) {
val x = mesh.x(v); val y = mesh.y(v); val z = mesh.z(v)
val l = sqrt(x * x + y * y + z * z)
val nx = x / l; val ny = y / l; val nz = z / l
nor[v * 3] = nx; nor[v * 3 + 1] = ny; nor[v * 3 + 2] = nz
pos[v * 3] = nx * r; pos[v * 3 + 1] = ny * r; pos[v * 3 + 2] = nz * r
}
return TriMesh(pos, mesh.indices, nor)
}
One 1-to-4 split of every triangle, midpoints shared across edges and re-projected to the unit sphere.
private fun subdivideAndProject(mesh: TriMesh): TriMesh {
val builder = TriMeshBuilder()
for (v in 0 until mesh.vertexCount) builder.vertex(mesh.x(v), mesh.y(v), mesh.z(v))
val midpoints = HashMap<Long, Int>()
val stride = (mesh.vertexCount + 1).toLong()
fun mid(a: Int, b: Int): Int {
val lo = min(a, b); val hi = max(a, b)
return midpoints.getOrPut(lo.toLong() * stride + hi) {
val x = (mesh.x(a) + mesh.x(b)) / 2f
val y = (mesh.y(a) + mesh.y(b)) / 2f
val z = (mesh.z(a) + mesh.z(b)) / 2f
val l = sqrt(x * x + y * y + z * z)
builder.vertex(x / l, y / l, z / l)
}
}
for (t in 0 until mesh.triangleCount) {
val a = mesh.ia(t); val b = mesh.ib(t); val c = mesh.ic(t)
val ab = mid(a, b); val bc = mid(b, c); val ca = mid(c, a)
builder.triangle(a, ab, ca)
builder.triangle(b, bc, ab)
builder.triangle(c, ca, bc)
builder.triangle(ab, bc, ca)
}
return builder.build()
}
Platonic solids
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.
fun tetrahedronMesh(radius: Number = 1.0): TriMesh {
val s = (radius.toFloat() / sqrt(3f))
return TriMesh.of(
listOf(Vec3(s, s, s), Vec3(s, -s, -s), Vec3(-s, s, -s), Vec3(-s, -s, s)),
listOf(0, 1, 2, 0, 2, 3, 0, 3, 1, 1, 3, 2),
).fixWinding()
}
fun octahedronMesh(radius: Number = 1.0): TriMesh
A regular octahedron with its 6 vertices on a sphere of radius. 8 triangles, χ = 2.
fun octahedronMesh(radius: Number = 1.0): TriMesh {
val r = radius.toFloat()
return TriMesh.of(
listOf(Vec3(r, 0f, 0f), Vec3(-r, 0f, 0f), Vec3(0f, r, 0f), Vec3(0f, -r, 0f), Vec3(0f, 0f, r), Vec3(0f, 0f, -r)),
listOf(
0, 2, 4, 2, 1, 4, 1, 3, 4, 3, 0, 4,
2, 0, 5, 1, 2, 5, 3, 1, 5, 0, 3, 5,
),
).fixWinding()
}
fun icosahedronMesh(radius: Number = 1.0): TriMesh
A regular icosahedron with its 12 vertices on a sphere of radius. 20 triangles, χ = 2.
The seed icosphereMesh subdivides. Built from the three golden rectangles and then passed
through TriMesh.fixWinding, so the outward orientation is derived rather than a hand-typed
face table that would be one transposition away from silently inside-out.
fun icosahedronMesh(radius: Number = 1.0): TriMesh {
val phi = (1.0 + sqrt(5.0)).toFloat() / 2f
val scale = radius.toFloat() / sqrt(1f + phi * phi)
// The three mutually perpendicular golden rectangles, four corners each.
val v = mutableListOf<Vec3>()
for (s1 in intArrayOf(-1, 1)) for (s2 in intArrayOf(-1, 1)) {
v.add(Vec3(0f, s1 * scale, s2 * phi * scale))
v.add(Vec3(s1 * scale, s2 * phi * scale, 0f))
v.add(Vec3(s1 * phi * scale, 0f, s2 * scale))
}
// 12 vertices; the 20 faces are every triple whose mutual distances are the icosahedron's edge
// length, which is the shortest non-zero distance in the set.
var shortest = Float.MAX_VALUE
for (i in v.indices) for (j in i + 1 until v.size) {
val d = v[i].distance(v[j])
if (d > 1e-4f && d < shortest) shortest = d
}
val tol = shortest * 0.05f
val faces = mutableListOf<Int>()
for (i in v.indices) for (j in i + 1 until v.size) for (k in j + 1 until v.size) {
if (abs(v[i].distance(v[j]) - shortest) < tol &&
abs(v[j].distance(v[k]) - shortest) < tol &&
abs(v[k].distance(v[i]) - shortest) < tol
) {
faces.add(i); faces.add(j); faces.add(k)
}
}
check(faces.size == 60) { "icosahedron face search found ${faces.size / 3} faces, expected 20" }
return TriMesh.of(v, faces).fixWinding()
}
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.
Constructed as the dual of the icosahedron: the 20 icosahedron face centres are the dodecahedron's vertices, and each icosahedron vertex's 5 incident faces, sorted by angle around it, are one pentagon. That makes this a working demonstration of MeshTopology as much as a primitive.
fun dodecahedronMesh(radius: Number = 1.0): TriMesh {
val ico = icosahedronMesh(1.0)
val topo = ico.topology()
val r = radius.toFloat()
// Dual vertices: normalised icosahedron face centroids.
val centres = (0 until ico.triangleCount).map { t ->
val (a, b, c) = ico.triangle(t)
Vec3((a.x + b.x + c.x) / 3f, (a.y + b.y + c.y) / 3f, (a.z + b.z + c.z) / 3f).normalize().mul(r)
}
return triMesh {
val corner = centres.map { vertex(it.x, it.y, it.z) }
for (v in 0 until ico.vertexCount) {
val star = topo.vertexTriangles[v]
check(star.size == 5) { "icosahedron vertex $v has ${star.size} faces, expected 5" }
val axis = ico.position(v).normalize()
// A basis in the plane perpendicular to the axis, to sort the ring by angle.
val helper = if (abs(axis.x) < 0.9f) Vec3(1f, 0f, 0f) else Vec3(0f, 1f, 0f)
val e1 = Vec3(helper).cross(axis).normalize()
val e2 = Vec3(axis).cross(e1).normalize()
val ring = star.sortedBy { t ->
val p = centres[t]
atan2(p.dot(e2).toDouble(), p.dot(e1).toDouble())
}
val mid = Vec3(0f, 0f, 0f)
for (t in ring) mid.add(centres[t])
mid.mul(1f / ring.size)
val hub = vertex(mid.x, mid.y, mid.z)
for (i in ring.indices) {
triangle(hub, corner[ring[i]], corner[ring[(i + 1) % ring.size]])
}
}
}.fixWinding()
}
Surfaces of revolution
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).
No normals: the ring vertices are shared between the smooth side and the flat caps, which want
different normals. cylinderMesh(64).shadedNormals(35.0) renders it correctly — smooth around,
hard at the rim.
@param segments divisions around the axis (>= 3).
fun cylinderMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, caps: Boolean = true): TriMesh {
require(segments >= 3) { "cylinderMesh needs >= 3 segments, got $segments" }
val r = radius.toFloat(); val hy = height.toFloat() / 2f
return triMesh {
val bottom = IntArray(segments); val top = IntArray(segments)
for (i in 0 until segments) {
val phi = 2.0 * PI * i / segments
val x = (cos(phi) * r).toFloat(); val z = (-sin(phi) * r).toFloat()
bottom[i] = vertex(x, -hy, z)
top[i] = vertex(x, hy, z)
}
for (i in 0 until segments) {
val n = (i + 1) % segments
quad(bottom[i], bottom[n], top[n], top[i])
}
if (caps) {
val topHub = vertex(0f, hy, 0f)
val bottomHub = vertex(0f, -hy, 0f)
for (i in 0 until segments) {
val n = (i + 1) % segments
triangle(topHub, top[i], top[n])
triangle(bottomHub, bottom[n], bottom[i])
}
}
}
}
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.
@param segments divisions around the axis (>= 3).
fun coneMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, cap: Boolean = true): TriMesh {
require(segments >= 3) { "coneMesh needs >= 3 segments, got $segments" }
val r = radius.toFloat(); val hy = height.toFloat() / 2f
return triMesh {
val apex = vertex(0f, hy, 0f)
val ring = IntArray(segments) { i ->
val phi = 2.0 * PI * i / segments
vertex((cos(phi) * r).toFloat(), -hy, (-sin(phi) * r).toFloat())
}
for (i in 0 until segments) triangle(apex, ring[i], ring[(i + 1) % segments])
if (cap) {
val hub = vertex(0f, -hy, 0f)
for (i in 0 until segments) triangle(hub, ring[(i + 1) % segments], ring[i])
}
}
}
fun torusMesh
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.
@param majorSegments divisions the long way round (>= 3). @param minorSegments divisions around the tube (>= 3).
fun torusMesh(
majorSegments: Int = 48,
minorSegments: Int = 24,
majorRadius: Number = 1.0,
minorRadius: Number = 0.35,
): TriMesh {
require(majorSegments >= 3 && minorSegments >= 3) {
"torusMesh needs >= 3 segments each way, got ${majorSegments}x$minorSegments"
}
val bigR = majorRadius.toFloat(); val smallR = minorRadius.toFloat()
return triMesh {
val grid = Array(majorSegments) { IntArray(minorSegments) }
for (i in 0 until majorSegments) {
val u = 2.0 * PI * i / majorSegments
val cu = cos(u).toFloat(); val su = (-sin(u)).toFloat()
for (j in 0 until minorSegments) {
val v = 2.0 * PI * j / minorSegments
val cv = cos(v).toFloat(); val sv = sin(v).toFloat()
val nx = cv * cu; val ny = sv; val nz = cv * su
grid[i][j] = vertex(
(bigR + smallR * cv) * cu, smallR * sv, (bigR + smallR * cv) * su,
normal = Vec3(nx, ny, nz),
)
}
}
for (i in 0 until majorSegments) for (j in 0 until minorSegments) {
val i2 = (i + 1) % majorSegments; val j2 = (j + 1) % minorSegments
quad(grid[i][j], grid[i2][j], grid[i2][j2], grid[i][j2])
}
}
}
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.
Watertight with χ = 2 and smooth normals everywhere — the hemisphere meets the cylinder
tangentially, so there is no crease and one normal per vertex is correct. That makes it the
complex primitive that still needs no shading pass.
@param segments divisions around the axis (>= 3).
@param rings latitude bands per hemisphere (>= 1).
@param height length of the straight section, 0 for a sphere.
fun capsuleMesh(segments: Int = 32, rings: Int = 8, radius: Number = 0.4, height: Number = 1.0): TriMesh {
require(segments >= 3) { "capsuleMesh needs >= 3 segments, got $segments" }
require(rings >= 1) { "capsuleMesh needs >= 1 ring per hemisphere, got $rings" }
val r = radius.toFloat(); val hy = height.toFloat() / 2f
return triMesh {
val north = vertex(0f, hy + r, 0f, normal = Vec3(0f, 1f, 0f))
val rows = ArrayList<IntArray>()
// theta sweeps 0..pi; the sphere centre jumps from +hy to -hy at the equator, which is what
// inserts the straight section without breaking the normal field.
val total = 2 * rings
for (j in 1 until total) {
val theta = PI * j / total
val ct = cos(theta).toFloat(); val st = sin(theta).toFloat()
val offset = if (j <= rings) hy else -hy
val row = IntArray(segments)
for (i in 0 until segments) {
val phi = 2.0 * PI * i / segments
val cx = cos(phi).toFloat(); val cz = (-sin(phi)).toFloat()
val nx = st * cx; val ny = ct; val nz = st * cz
row[i] = vertex(nx * r, offset + ny * r, nz * r, normal = Vec3(nx, ny, nz))
}
rows.add(row)
}
val south = vertex(0f, -hy - r, 0f, normal = Vec3(0f, -1f, 0f))
val top = rows.first()
for (i in 0 until segments) triangle(north, top[i], top[(i + 1) % segments])
for (j in 0 until rows.size - 1) {
val upper = rows[j]; val lower = rows[j + 1]
for (i in 0 until segments) {
val n = (i + 1) % segments
quad(upper[n], upper[i], lower[i], lower[n])
}
}
val bottom = rows.last()
for (i in 0 until segments) triangle(south, bottom[(i + 1) % segments], bottom[i])
}
}
fun revolveMesh
A lathe: a 2D profile of (radius, height) points swept around axis.
Profile points with radius 0 collapse to a single pole vertex rather than segments coincident
ones, so a profile that starts and ends on the axis produces a watertight solid — that is how
revolveMesh(listOf(vec(0,1), vec(1,0), vec(0,-1)), RevolveAxis.Y, 48) gives a closed lens with χ = 2.
Which axis
There is no default. RevolveAxis.Y suits a Y-up scene (three.js, Field's mesh viewers): the
profile's x is a radius in the XZ plane and its y stays y. RevolveAxis.Z is how
afield.geometry.solid.manifold.Section.revolved and SolidSL's Profile.revolved sweep: the
profile's x is a radius in the XY plane and its y becomes z, so the same profile gives the
same solid down both paths.
@param profile (x = radius >= 0, y), in order along the surface. At least 2 points.
@param segments divisions around the axis (>= 3).
@param turns fraction of a full revolution. 1.0 closes the sweep; anything less leaves it open
(and open sweeps are surfaces, not solids — no caps are added).
@param axis the axis to sweep around: RevolveAxis.Y for a Y-up scene, or RevolveAxis.Z to match
Section.revolved / SolidSL's Profile.revolved, which sweep from +x toward +y.
body — 45 lines, from line 573
fun revolveMesh(
profile: List<Vec2>,
axis: RevolveAxis,
segments: Int = 48,
turns: Double = 1.0,
): TriMesh {
require(profile.size >= 2) { "revolveMesh needs >= 2 profile points, got ${profile.size}" }
require(segments >= 3) { "revolveMesh needs >= 3 segments, got $segments" }
require(profile.all { it.x >= -1e-6f }) { "revolveMesh profile radii must be >= 0" }
val closed = abs(turns - 1.0) < 1e-9
val columns = if (closed) segments else segments + 1
return triMesh {
val grid = Array(columns) { IntArray(profile.size) }
val poles = HashMap<Int, Int>()
for (i in 0 until columns) {
val u = 2.0 * PI * turns * i / segments
val cu = cos(u).toFloat(); val su = (-sin(u)).toFloat()
for (j in profile.indices) {
val rad = profile[j].x
grid[i][j] = if (rad <= 1e-6f) {
poles.getOrPut(j) {
if (axis == RevolveAxis.Y) vertex(0f, profile[j].y, 0f) else vertex(0f, 0f, profile[j].y)
}
} else if (axis == RevolveAxis.Y) {
vertex(rad * cu, profile[j].y, rad * su)
} else {
// The Y sweep turned +90° about X, a rotation, so the winding is unchanged.
vertex(rad * cu, -rad * su, profile[j].y)
}
}
}
for (i in 0 until (if (closed) columns else columns - 1)) {
val i2 = (i + 1) % columns
for (j in 0 until profile.size - 1) {
val a = grid[i][j]; val b = grid[i2][j]; val c = grid[i2][j + 1]; val d = grid[i][j + 1]
// A profile point on the axis collapses to one shared vertex, so one half of the quad
// becomes a repeated-index triangle. Emit only the half that is a real triangle.
val poleBelow = a == b
val poleAbove = c == d
if (!poleBelow) triangle(a, b, c)
if (!poleAbove) triangle(a, c, d)
}
}
}.removeDegenerateTriangles()
}
enum class RevolveAxis
Which axis revolveMesh sweeps a (radius, height) profile around.
enum class RevolveAxis {
Height along Y, radius in the XZ plane: for a Y-up scene.
Y,
Height along Z, radius in the XY plane: Manifold's, so Section.revolved and SolidSL's Profile.revolved.
Z,
}
Extrusion, polygons, tubes
fun triangulatePolygon(polygon: List<Vec2>): List<Int>
Ear-clipping triangulation of a simple (non-self-intersecting) polygon.
Returns indices into polygon, three per triangle, wound counter-clockwise whichever way the
input was wound. Handles concave polygons — which is why extrudeMesh and polygonMesh use it
rather than TriMeshBuilder.polygon's fan. Holes are not supported.
O(n²) in the worst case, which is the standard trade for not needing a sweep-line; a few thousand points is comfortable.
fun triangulatePolygon(polygon: List<Vec2>): List<Int> {
require(polygon.size >= 3) { "a polygon needs >= 3 points, got ${polygon.size}" }
val ccw = polygonSignedArea(polygon) >= 0.0
val order = if (ccw) polygon.indices.toMutableList() else polygon.indices.reversed().toMutableList()
fun p(i: Int) = polygon[order[i]]
fun cross(o: Vec2, a: Vec2, b: Vec2): Double =
(a.x - o.x).toDouble() * (b.y - o.y) - (a.y - o.y).toDouble() * (b.x - o.x)
fun insideTriangle(a: Vec2, b: Vec2, c: Vec2, q: Vec2): Boolean {
val d1 = cross(a, b, q); val d2 = cross(b, c, q); val d3 = cross(c, a, q)
return (d1 >= 0 && d2 >= 0 && d3 >= 0) || (d1 <= 0 && d2 <= 0 && d3 <= 0)
}
val out = mutableListOf<Int>()
var guard = order.size * order.size + 16
while (order.size > 3 && guard-- > 0) {
var clipped = false
for (i in order.indices) {
val prev = (i + order.size - 1) % order.size
val next = (i + 1) % order.size
val a = p(prev); val b = p(i); val c = p(next)
if (cross(a, b, c) <= 0.0) continue // reflex (or collinear) corner: not an ear
var containsOther = false
for (k in order.indices) {
if (k == prev || k == i || k == next) continue
if (insideTriangle(a, b, c, p(k))) { containsOther = true; break }
}
if (containsOther) continue
out.add(order[prev]); out.add(order[i]); out.add(order[next])
order.removeAt(i)
clipped = true
break
}
if (!clipped) break // degenerate or self-intersecting input; emit what is left as a fan
}
if (order.size >= 3) {
for (i in 1 until order.size - 1) { out.add(order[0]); out.add(order[i]); out.add(order[i + 1]) }
}
return out
}
internal fun polygonSignedArea(polygon: List<Vec2>): Double
Twice the signed area of a 2D polygon: positive for counter-clockwise.
internal fun polygonSignedArea(polygon: List<Vec2>): Double {
var sum = 0.0
for (i in polygon.indices) {
val a = polygon[i]
val b = polygon[(i + 1) % polygon.size]
sum += a.x.toDouble() * b.y - b.x.toDouble() * a.y
}
return sum / 2.0
}
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.
fun polygonMesh(polygon: List<Vec2>): TriMesh {
val tri = triangulatePolygon(polygon)
val up = Vec3(0f, 0f, 1f)
return triMesh {
for (q in polygon) vertex(q.x, q.y, 0f, normal = Vec3(up))
for (i in tri.indices step 3) triangle(tri[i], tri[i + 1], tri[i + 2])
}.removeUnusedVertices()
}
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.
Watertight with caps (χ = 2 for a simple polygon), an open tube without them. Concave
polygons are triangulated properly by triangulatePolygon, and the input's winding does not
matter — the result is always wound outward.
No normals, and you need do nothing about it: an extrusion is all creases, and every render bridge
shades an unshaded mesh for you via toCapturedMesh (mesh_field.kt:246). Do not reach for
flatShaded() to "prepare it for rendering" — it un-welds every triangle, and Box.showMesh /
Box.showMeshGrid / toHTML() compute topology on what you hand them and print it as fact, so a
flat-shaded copy makes the caption claim a closed mesh is open. See TextLayout.toMesh.
fun extrudeMesh(polygon: List<Vec2>, height: Number = 1.0, caps: Boolean = true): TriMesh {
require(polygon.size >= 3) { "extrudeMesh needs >= 3 polygon points, got ${polygon.size}" }
val h = height.toFloat()
// Normalise to counter-clockwise so the side windings below are outward without a case analysis.
val ring = if (polygonSignedArea(polygon) >= 0.0) polygon else polygon.reversed()
val n = ring.size
val mesh = triMesh {
val bottom = IntArray(n) { vertex(ring[it].x, ring[it].y, 0f) }
val top = IntArray(n) { vertex(ring[it].x, ring[it].y, h) }
for (i in 0 until n) {
val j = (i + 1) % n
quad(bottom[i], bottom[j], top[j], top[i])
}
if (caps) {
val tri = triangulatePolygon(ring)
for (k in tri.indices step 3) {
triangle(top[tri[k]], top[tri[k + 1]], top[tri[k + 2]])
triangle(bottom[tri[k + 2]], bottom[tri[k + 1]], bottom[tri[k]])
}
}
}
// A negative height flips the sides; rather than case-analyse, let fixWinding sort it out.
return if (h < 0f) mesh.fixWinding() else mesh
}
fun tubeMesh
A round tube swept along a 3D polyline — the mesh you want for a drawn stroke, a trajectory or a graph edge in 3D.
Frames are rotation-minimising (double-reflection parallel transport), which is what stops a
tube from twisting where the path's curvature swings through vertical — the failure a naive
up-based frame has, and the reason this is worth 60 lines rather than 10.
@param path at least 2 points. Consecutive duplicates are dropped.
@param sides divisions around the tube (>= 3).
@param closed treat the path as a loop and join the last ring to the first. Watertight when true
(χ = 0, a genus-1 torus-like surface); when false the tube gets flat caps or is left open.
@param caps close the two ends with a fan when closed is false, giving χ = 2.
body — 89 lines, from line 756
fun tubeMesh(
path: List<Vec3>,
radius: Number = 0.1,
sides: Int = 12,
closed: Boolean = false,
caps: Boolean = true,
): TriMesh {
require(sides >= 3) { "tubeMesh needs >= 3 sides, got $sides" }
val pts = ArrayList<Vec3>(path.size)
for (p in path) if (pts.isEmpty() || pts[pts.size - 1].distance(p) > 1e-9f) pts.add(Vec3(p))
if (closed && pts.size > 1 && pts[0].distance(pts[pts.size - 1]) < 1e-9f) pts.removeAt(pts.size - 1)
require(pts.size >= 2) { "tubeMesh needs >= 2 distinct path points, got ${pts.size}" }
val r = radius.toFloat()
// Tangents: central differences inside, one-sided at the ends (wrapped when closed).
val n = pts.size
val tangents = Array(n) { i ->
val prev = pts[if (i == 0) (if (closed) n - 1 else 0) else i - 1]
val next = pts[if (i == n - 1) (if (closed) 0 else n - 1) else i + 1]
val t = Vec3(next).sub(prev)
if (t.length() < 1e-12f) Vec3(0f, 0f, 1f) else t.normalize()
}
// Rotation-minimising frames by double reflection (Wang et al.), seeded with any perpendicular.
val seed = tangents[0]
val helper = if (abs(seed.x) < 0.9f) Vec3(1f, 0f, 0f) else Vec3(0f, 1f, 0f)
var u = Vec3(helper).cross(seed).normalize()
val frameU = arrayOfNulls<Vec3>(n)
val frameV = arrayOfNulls<Vec3>(n)
for (i in 0 until n) {
if (i > 0) {
// Reflect u across the plane bisecting tangent[i-1] and tangent[i].
val t0 = tangents[i - 1]; val t1 = tangents[i]
val bis = Vec3(t0).add(t1)
val bl = bis.lengthSquared()
u = if (bl < 1e-12f) {
Vec3(u).negate() // a 180-degree turn: reverse rather than divide by zero
} else {
val d = 2f * u.dot(bis) / bl
Vec3(u).sub(bis.x * d, bis.y * d, bis.z * d)
}
// Re-orthogonalise against the new tangent so float drift cannot accumulate.
val along = u.dot(t1)
u.sub(t1.x * along, t1.y * along, t1.z * along)
if (u.length() < 1e-9f) {
val h = if (abs(t1.x) < 0.9f) Vec3(1f, 0f, 0f) else Vec3(0f, 1f, 0f)
u = h.cross(t1)
}
u.normalize()
}
frameU[i] = Vec3(u)
frameV[i] = Vec3(tangents[i]).cross(u).normalize()
}
return triMesh {
val rings = Array(n) { i ->
IntArray(sides) { k ->
val a = 2.0 * PI * k / sides
val ca = cos(a).toFloat(); val sa = sin(a).toFloat()
val fu = frameU[i]!!; val fv = frameV[i]!!
val nx = fu.x * ca + fv.x * sa
val ny = fu.y * ca + fv.y * sa
val nz = fu.z * ca + fv.z * sa
vertex(
pts[i].x + nx * r, pts[i].y + ny * r, pts[i].z + nz * r,
normal = Vec3(nx, ny, nz),
)
}
}
val spans = if (closed) n else n - 1
for (i in 0 until spans) {
val j = (i + 1) % n
for (k in 0 until sides) {
val k2 = (k + 1) % sides
// Around-the-tube first, then along it: the other order gives `tangent x radial`, which
// is the inward normal, and an inside-out tube.
quad(rings[i][k], rings[i][k2], rings[j][k2], rings[j][k])
}
}
if (!closed && caps) {
// A cap hub cannot share the ring's radial normal, so caps make the tube crease-shaded;
// the ring vertices keep their smooth normals and the hub gets the axial one.
val startHub = vertex(pts[0].x, pts[0].y, pts[0].z, normal = Vec3(tangents[0]).negate())
for (k in 0 until sides) triangle(startHub, rings[0][(k + 1) % sides], rings[0][k])
val endHub = vertex(pts[n - 1].x, pts[n - 1].y, pts[n - 1].z, normal = Vec3(tangents[n - 1]))
for (k in 0 until sides) triangle(endHub, rings[n - 1][k], rings[n - 1][(k + 1) % sides])
}
}
}
Heightfields
fun heightfieldMesh
A landscape: a grid in the xz plane whose y comes from height, with computed normals and uvs.
height is called with world (x, z), not grid indices, so the same lambda works at any
resolution — which is the whole point of sampling a field rather than an array.
A disc, not a solid (χ = 1, one boundary loop). Normals are the analytic surface normals of the
triangulated grid, computed by central differences on the sampled heights.
@param nx quads along x (>= 1). @param nz quads along z (>= 1). @param width extent along x, centred on the origin. @param depth extent along z.
fun heightfieldMesh(
nx: Int = 64,
nz: Int = 64,
width: Number = 2.0,
depth: Number = 2.0,
height: (Float, Float) -> Float,
): TriMesh {
require(nx >= 1 && nz >= 1) { "heightfieldMesh needs at least one quad each way, got ${nx}x$nz" }
val w = width.toFloat(); val d = depth.toFloat()
val dx = w / nx; val dz = d / nz
val ys = FloatArray((nx + 1) * (nz + 1))
fun xAt(i: Int) = (i.toFloat() / nx - 0.5f) * w
fun zAt(j: Int) = (j.toFloat() / nz - 0.5f) * d
for (j in 0..nz) for (i in 0..nx) ys[j * (nx + 1) + i] = height(xAt(i), zAt(j))
fun yAt(i: Int, j: Int) = ys[j.coerceIn(0, nz) * (nx + 1) + i.coerceIn(0, nx)]
return triMesh {
for (j in 0..nz) for (i in 0..nx) {
// Central differences give the tangent plane; the cross product of the two tangents is
// the normal. One-sided at the border, which is exactly what coerceIn above arranges.
val dydx = (yAt(i + 1, j) - yAt(i - 1, j)) / ((if (i == 0 || i == nx) 1 else 2) * dx)
val dydz = (yAt(i, j + 1) - yAt(i, j - 1)) / ((if (j == 0 || j == nz) 1 else 2) * dz)
val nrm = Vec3(-dydx, 1f, -dydz).normalize()
vertex(xAt(i), yAt(i, j), zAt(j), normal = nrm, uv = Vec2(i.toFloat() / nx, j.toFloat() / nz))
}
fun at(i: Int, j: Int) = j * (nx + 1) + i
for (j in 0 until nz) for (i in 0 until nx) {
quad(at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j))
}
}
}
fun Img.toHeightfieldMesh
A heightfield sampled from an Img — one grid vertex per pixel by default, with the image's
luminance (or its single channel) as y.
The bridge that lets the imaging family drive the mesh family: blur, threshold, distance-transform or edge-detect an image, then look at the result as a surface.
@param scale multiplies the sampled value into world y.
@param width extent along x; depth defaults to width scaled by the image's aspect ratio.
@param step sample every stepth pixel — 2 on a 1000x1000 image gives a 500x500 grid.
fun Img.toHeightfieldMesh(
scale: Number = 0.25,
width: Number = 2.0,
depth: Number? = null,
step: Int = 1,
): TriMesh {
require(step >= 1) { "step must be >= 1, got $step" }
val src = if (channels == 1) this else luma()
val w = width.toFloat()
val d = depth?.toFloat() ?: (w * src.height / src.width)
val nx = max(1, (src.width - 1) / step)
val nz = max(1, (src.height - 1) / step)
val s = scale.toFloat()
return heightfieldMesh(nx, nz, w, d) { x, z ->
val u = ((x / w) + 0.5f).coerceIn(0f, 1f)
val v = ((z / d) + 0.5f).coerceIn(0f, 1f)
val px = (u * (src.width - 1)).toInt().coerceIn(0, src.width - 1)
val py = (v * (src.height - 1)).toInt().coerceIn(0, src.height - 1)
src.at(px, py) * s
}
}
fun reliefMesh
A relief plaque: heightfieldMesh's surface, closed — skirt walls dropped to baseY and a flat
base under them. Watertight, χ = 2, and therefore the first heightfield you can hand to
afield.geometry.solid.manifold.Solid.
heightfieldMesh deliberately returns a disc — one boundary loop, χ = 1 — because a landscape
has no underside. That is the right answer for rendering and the wrong one for anything that needs
a volume: an exact Boolean, a printed object, a mass. toSolid refuses an open sheet, and capping
the disc's own boundary loop with TriMesh.repairToSolid is not the same shape at all — it fits a
warped lid across the rim and encloses only the sliver between the rim and the surface. This
generator instead drops the rim vertically onto a plane, which is what a lithophane, a relief map
or a stamp actually is.
The vertex and face counts are exact, and the Euler characteristic is 2 at every resolution:
| count | |
|---|---|
perimeter vertices P |
2*nx + 2*nz |
| vertices | (nx+1)*(nz+1) + P + 1 |
| triangles | 2*nx*nz + 3*P |
— the grid, the perimeter ring at baseY, and one centre vertex the base fans from. χ = 2 falls
out of those: the -P/2 the fan and the walls contribute cancels the +nx+nz the grid does.
No normals, following this file's rule: the surface meets its own skirt at a crease, so
.shadedNormals() or .flatShaded() is the caller's choice. signedVolume() is positive.
val plaque = reliefMesh(64, 64, 2.0, 2.0, baseY = -0.2) { x, z -> 0.1f * sin(x * 3f) * cos(z * 3f) }
val solid = plaque.toSolid() // legal, unlike heightfieldMesh's
@param nx quads along x (>= 1). @param nz quads along z (>= 1).
@param width extent along x, centred on the origin. @param depth extent along z.
@param baseY the plane the skirt drops to; must be strictly below every sampled height, and the
check names the height that broke it rather than returning an inverted solid.
@param height called with world (x, z), exactly as in heightfieldMesh.
body — 52 lines, from line 964
fun reliefMesh(
nx: Int = 64,
nz: Int = 64,
width: Number = 2.0,
depth: Number = 2.0,
baseY: Number = -0.25,
height: (Float, Float) -> Float,
): TriMesh {
require(nx >= 1 && nz >= 1) { "reliefMesh needs at least one quad each way, got ${nx}x$nz" }
val w = width.toFloat(); val d = depth.toFloat()
val y0 = baseY.toFloat()
fun xAt(i: Int) = (i.toFloat() / nx - 0.5f) * w
fun zAt(j: Int) = (j.toFloat() / nz - 0.5f) * d
val ys = FloatArray((nx + 1) * (nz + 1))
for (j in 0..nz) for (i in 0..nx) {
val y = height(xAt(i), zAt(j))
require(y > y0) {
"reliefMesh's baseY must sit below the surface: height(${xAt(i)}, ${zAt(j)}) = $y is not above $y0"
}
ys[j * (nx + 1) + i] = y
}
// The rim, walked *clockwise in (x, z)*. That is the direction the grid quads already wind —
// x_hat cross z_hat is -y_hat, so a clockwise loop in these two coordinates is the one whose
// right-hand normal points +y — and matching it is what lets the walls share each rim edge in
// the opposite direction, which is the whole definition of a consistently oriented closed mesh.
val rim = ArrayList<Pair<Int, Int>>(2 * nx + 2 * nz)
for (j in 0..nz) rim.add(0 to j)
for (i in 1..nx) rim.add(i to nz)
for (j in nz - 1 downTo 0) rim.add(nx to j)
for (i in nx - 1 downTo 1) rim.add(i to 0)
return triMesh {
for (j in 0..nz) for (i in 0..nx) vertex(xAt(i), ys[j * (nx + 1) + i], zAt(j))
fun top(i: Int, j: Int) = j * (nx + 1) + i
for (j in 0 until nz) for (i in 0 until nx) {
quad(top(i, j), top(i, j + 1), top(i + 1, j + 1), top(i + 1, j))
}
val base = IntArray(rim.size) { k -> vertex(xAt(rim[k].first), y0, zAt(rim[k].second)) }
val centre = vertex(0f, y0, 0f)
for (k in rim.indices) {
val k1 = (k + 1) % rim.size
val a = top(rim[k].first, rim[k].second)
val b = top(rim[k1].first, rim[k1].second)
// The top traverses this rim edge a -> b, so the wall takes it b -> a.
quad(b, a, base[k], base[k1])
// and the walls traverse the base edge base[k] -> base[k1], so the floor reverses it.
triangle(base[k1], base[k], centre)
}
}
}
fun Img.toReliefMesh
Img.toHeightfieldMesh's closed sibling — an image as a printable relief plaque, via reliefMesh.
This is the hop that was missing between the imaging family and afield.geometry.solid.manifold.Solid: an Img
goes in and a watertight χ = 2 mesh comes out, ready for an exact Boolean. Sampling matches
Img.toHeightfieldMesh exactly — same grid, same nearest-pixel lookup — so the two agree
vertex-for-vertex on the (nx+1)*(nz+1) grid they share, and differ only in being open or closed.
@param scale multiplies the sampled value into world y, as in Img.toHeightfieldMesh.
@param width extent along x; depth defaults to width scaled by the image's aspect ratio.
@param step sample every stepth pixel.
@param baseThickness how far below y = 0 the base plane sits. A non-negative image gives a
plaque this thick where the image is black and baseThickness + scale where it is white, so
this is the number that decides whether the thing survives being picked up.
fun Img.toReliefMesh(
scale: Number = 0.25,
width: Number = 2.0,
depth: Number? = null,
step: Int = 1,
baseThickness: Number = 0.1,
): TriMesh {
require(step >= 1) { "step must be >= 1, got $step" }
require(baseThickness.toDouble() > 0.0) { "baseThickness must be positive, got $baseThickness" }
val src = if (channels == 1) this else luma()
require(src.min() >= 0f) { "toReliefMesh needs a non-negative image; the minimum here is ${src.min()}" }
val w = width.toFloat()
val d = depth?.toFloat() ?: (w * src.height / src.width)
val nx = max(1, (src.width - 1) / step)
val nz = max(1, (src.height - 1) / step)
val s = scale.toFloat()
return reliefMesh(nx, nz, w, d, -baseThickness.toDouble()) { x, z ->
val u = ((x / w) + 0.5f).coerceIn(0f, 1f)
val v = ((z / d) + 0.5f).coerceIn(0f, 1f)
val px = (u * (src.width - 1)).toInt().coerceIn(0, src.width - 1)
val py = (v * (src.height - 1)).toInt().coerceIn(0, src.height - 1)
src.at(px, py) * s
}
}