mesh.kt
src/jvmMain/kotlin/afield/geometry/mesh/mesh.kt · 671 lines
package and imports — 70 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.geometry.mesh
import afield.animation.keyframes.h
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.geometry.spatial.VoronoiCell3
import afield.geometry.spatial.closest
import afield.geometry.spatial.toFLines
import afield.graphics.color.rgb
import afield.graphics.interaction.PickParts
import afield.graphics.interaction.groups
import afield.graphics.render.rangeTo
import afield.graphics.shader.compute
import afield.graphics.text.layout
import afield.graphics.text.toFLines
import afield.lang.stacksl.evolution.render
import afield.lang.stacksl.h
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.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.rgb
import afield.math.linalg.times
import afield.math.linalg.vec
import afield.math.linalg.xyz
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.toFLines
import afield.media.image.transpose
import afield.runtime.execution.go
import afield.runtime.lifecycle.once
import afield.simulation.agents.toFLines
import afield.ui.output.h
import afield.audio.synth.*
import afield.data.collections.r
import afield.data.collections.s
import afield.graphics.render.color
import afield.graphics.shader.mesh
import afield.graphics.shader.vertex
import afield.graphics.vulkan.buffers.add
import afield.math.linalg.Quat
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.d
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.ia
import afield.math.linalg.l
import afield.math.linalg.set
import afield.math.linalg.x
import afield.ui.visualization.ToHTML
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
TriMesh — the CPU triangle mesh Field's 3D geometry family is built on, plus the builder that makes one and the whole-mesh transforms that move one around.
A TriMesh is not a renderer buffer (that is Field 4's
afield.graphics.render.Mesh) and not a graph of vertex objects. It is flat
FloatArray attribute storage plus an IntArray of triangle indices: the layout every algorithm
in this family wants, the layout a GPU upload wants, and cheap to copy. Positions are required;
normals, uvs and per-vertex colours are optional and either absent or complete.
The reason the vertices are floats-in-an-array rather than List<Vec3> is that Vec3 is a
mutable Vector3f. A mesh holding shared mutable vectors is a mesh where mesh.position(0).y +=
1 silently edits the mesh — or worse, edits three triangles because the vector was shared. So
every accessor here (position, normal, uv, color) returns a fresh vector you own, and
the …Into variants let a hot loop reuse one.
The family
| file | what it adds |
|---|---|
mesh.kt |
this: the type, TriMeshBuilder / triMesh, transforms, concatenation |
mesh_topology.kt |
MeshTopology — adjacency, boundary loops, components, manifoldness, Euler characteristic / genus, TriMesh.weld, TriMesh.fixWinding |
mesh_generate.kt |
boxMesh, uvSphereMesh, icosphereMesh, torusMesh, cylinderMesh, extrudeMesh, revolveMesh, tubeMesh, heightfieldMesh, reliefMesh |
mesh_sdf.kt |
ScalarField3D, the SDF primitives / combinators, and marchingCubes |
mesh_process.kt |
TriMesh.loopSubdivide, TriMesh.laplacianSmooth, TriMesh.taubinSmooth, TriMesh.withVertexNormals, TriMesh.decimate |
mesh_measure.kt |
TriMesh.surfaceArea, TriMesh.signedVolume, curvature, MeshBvh (rays, closest point, inside test) |
mesh_io.kt |
OBJ and STL, read and write |
mesh_field.kt |
the bridges: TriMesh.toFLines, VoronoiCell3.toTriMesh, Box.showMesh |
Conventions
- Winding is counter-clockwise seen from outside, so faceNormal points out of a closed
solid and
signedVolumeof one is positive. Everything in the family agrees on this, andTriMesh.fixWindingrepairs a mesh that does not. - Nothing here mutates: every operation returns a new TriMesh. The arrays are
vals and are not copied on construction, so do not hand a builder's scratch array to the constructor and then keep writing to it — use TriMeshBuilder.build. - No dependencies beyond the Kotlin stdlib and Field's own generated linalg. No GPU, no AWT, no model-format libraries.
val ball = icosphereMesh(subdivisions = 3, radius = 1.0)
val shell = ball.scaled(1.0, 0.6, 1.0).withVertexNormals()
println("${shell.triangleCount} tris, volume ${shell.signedVolume()}")
display(shell.toFLines()) // draw its wireframe
TriMesh — the value type
class TriMesh
An indexed triangle mesh: vertexCount * 3 position floats, triangleCount * 3 indices, and
optional matching normal / uv / colour arrays.
@param positions xyz per vertex, interleaved. Length must be a multiple of 3.
@param indices three vertex indices per triangle, wound counter-clockwise seen from outside.
@param normals xyz per vertex, or null. Not required to be unit length, though everything that
produces them here normalises; withVertexNormals recomputes them from the triangles.
@param uvs uv per vertex (2 floats), or null.
@param colors linear rgb per vertex (3 floats, nominally 0..1), or null.
class TriMesh(
val positions: FloatArray,
val indices: IntArray,
val normals: FloatArray? = null,
val uvs: FloatArray? = null,
val colors: FloatArray? = null,
) : ToHTML {
init
init {
require(positions.size % 3 == 0) { "positions must be a whole number of xyz triples, got ${positions.size} floats" }
require(indices.size % 3 == 0) { "indices must be a whole number of triangles, got ${indices.size}" }
val n = positions.size / 3
normals?.let { require(it.size == n * 3) { "normals must be $n xyz triples, got ${it.size / 3}" } }
uvs?.let { require(it.size == n * 2) { "uvs must be $n uv pairs, got ${it.size / 2}" } }
colors?.let { require(it.size == n * 3) { "colors must be $n rgb triples, got ${it.size / 3}" } }
for (i in indices.indices) {
require(indices[i] in 0 until n) { "indices[$i] = ${indices[i]} is outside the $n vertices" }
}
}
val vertexCount: Int get() = positions.size / 3
Number of vertices.
val triangleCount: Int get() = indices.size / 3
Number of triangles.
val isEmpty: Boolean get() = indices.isEmpty()
True when there are no triangles — an empty mesh is legal and is what a degenerate march returns.
val hasNormals: Boolean get() = normals != null
True when this mesh carries per-vertex normals.
val hasUvs: Boolean get() = uvs != null
True when this mesh carries per-vertex texture coordinates.
val hasColors: Boolean get() = colors != null
True when this mesh carries per-vertex colours.
fun x(i: Int): Float = positions[i * 3]
Vertex i's x, without allocating.
fun y(i: Int): Float = positions[i * 3 + 1]
Vertex i's y, without allocating.
fun z(i: Int): Float = positions[i * 3 + 2]
Vertex i's z, without allocating.
fun position(i: Int): Vec3 = Vec3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2])
A fresh Vec3 holding vertex i's position — mutating it does not touch the mesh.
fun positionInto(i: Int, dest: Vec3): Vec3
position into a vector you already own, for loops that must not allocate. Returns dest.
fun positionInto(i: Int, dest: Vec3): Vec3 = dest.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2])
fun normal(i: Int): Vec3? = normals?.let { Vec3(it[i * 3], it[i * 3 + 1], it[i * 3 + 2]) }
Vertex i's normal, or null when the mesh has none. Fresh vector.
fun uv(i: Int): Vec2? = uvs?.let { Vec2(it[i * 2], it[i * 2 + 1]) }
Vertex i's texture coordinate, or null when the mesh has none. Fresh vector.
fun color(i: Int): Vec3? = colors?.let { Vec3(it[i * 3], it[i * 3 + 1], it[i * 3 + 2]) }
Vertex i's colour, or null when the mesh has none. Fresh vector.
fun ia(t: Int): Int = indices[t * 3]
Triangle t's first vertex index.
fun ib(t: Int): Int = indices[t * 3 + 1]
Triangle t's second vertex index.
fun ic(t: Int): Int = indices[t * 3 + 2]
Triangle t's third vertex index.
fun triangle(t: Int): Triple<Vec3, Vec3, Vec3>
Triangle t's three corner positions, as fresh vectors.
fun triangle(t: Int): Triple<Vec3, Vec3, Vec3> =
Triple(position(ia(t)), position(ib(t)), position(ic(t)))
fun faceNormal(t: Int): Vec3
Triangle t's geometric normal from its winding — normalize((b-a) x (c-a)).
Points out of a correctly wound closed solid. Returns a zero vector for a degenerate
(zero-area) triangle rather than a NaN one, because degenerate triangles are legal (a UV
sphere has 2 * segments of them at its poles) and callers should not have to guard.
fun faceNormal(t: Int): Vec3 {
val a = ia(t) * 3; val b = ib(t) * 3; val c = ic(t) * 3
val ux = positions[b] - positions[a]
val uy = positions[b + 1] - positions[a + 1]
val uz = positions[b + 2] - positions[a + 2]
val vx = positions[c] - positions[a]
val vy = positions[c + 1] - positions[a + 1]
val vz = positions[c + 2] - positions[a + 2]
val nx = uy * vz - uz * vy
val ny = uz * vx - ux * vz
val nz = ux * vy - uy * vx
val l = sqrt(nx * nx + ny * ny + nz * nz)
return if (l < 1e-20f) Vec3(0f, 0f, 0f) else Vec3(nx / l, ny / l, nz / l)
}
fun triangleArea(t: Int): Double
Triangle t's area — half the length of the cross product, in double precision.
fun triangleArea(t: Int): Double {
val a = ia(t) * 3; val b = ib(t) * 3; val c = ic(t) * 3
val ux = (positions[b] - positions[a]).toDouble()
val uy = (positions[b + 1] - positions[a + 1]).toDouble()
val uz = (positions[b + 2] - positions[a + 2]).toDouble()
val vx = (positions[c] - positions[a]).toDouble()
val vy = (positions[c + 1] - positions[a + 1]).toDouble()
val vz = (positions[c + 2] - positions[a + 2]).toDouble()
val nx = uy * vz - uz * vy
val ny = uz * vx - ux * vz
val nz = ux * vy - uy * vx
return 0.5 * sqrt(nx * nx + ny * ny + nz * nz)
}
fun vertices(): List<Vec3> = List(vertexCount) { position(it) }
All vertex positions as fresh vectors — convenient, but allocates one per vertex.
fun triangleIndices(): List<Triple<Int, Int, Int>>
Every triangle's (a, b, c) index triple.
fun triangleIndices(): List<Triple<Int, Int, Int>> = List(triangleCount) { Triple(ia(it), ib(it), ic(it)) }
fun isFinite(): Boolean
True when every stored float is finite — the cheapest "did my generator go wrong" check.
fun isFinite(): Boolean =
positions.all { it.isFinite() } &&
(normals?.all { it.isFinite() } ?: true) &&
(uvs?.all { it.isFinite() } ?: true) &&
(colors?.all { it.isFinite() } ?: true)
fun copy
A copy with some arrays replaced; anything not passed is kept. Passing null for an attribute
drops it, so copy(normals = null) is this mesh without normals.
fun copy(
positions: FloatArray = this.positions,
indices: IntArray = this.indices,
normals: FloatArray? = this.normals,
uvs: FloatArray? = this.uvs,
colors: FloatArray? = this.colors,
): TriMesh = TriMesh(positions, indices, normals, uvs, colors)
fun withoutAttributes(): TriMesh = TriMesh(positions, indices)
Positions and indices only — drops normals, uvs and colours.
fun withNormals(normals: FloatArray): TriMesh
This mesh with normals attached (one xyz per vertex). See withVertexNormals to compute them.
fun withNormals(normals: FloatArray): TriMesh = TriMesh(positions, indices, normals, uvs, colors)
fun withUvs(uvs: FloatArray): TriMesh = TriMesh(positions, indices, normals, uvs, colors)
This mesh with uvs attached (one uv per vertex).
fun withColors(colors: FloatArray): TriMesh = TriMesh(positions, indices, normals, uvs, colors)
This mesh with colors attached (one linear rgb per vertex).
fun withColor(r: Number, g: Number, b: Number): TriMesh
This mesh painted a single flat colour — the fast way to make a preview readable.
fun withColor(r: Number, g: Number, b: Number): TriMesh {
val c = FloatArray(vertexCount * 3)
var i = 0
while (i < c.size) {
c[i] = r.toFloat(); c[i + 1] = g.toFloat(); c[i + 2] = b.toFloat(); i += 3
}
return withColors(c)
}
fun coloredBy(f: (Vec3) -> Vec3): TriMesh {
This mesh with per-vertex colours from a function of position.
fun coloredBy(f: (Vec3) -> Vec3): TriMesh {
val c = FloatArray(vertexCount * 3)
val p = Vec3()
for (v in 0 until vertexCount) {
val col = f(positionInto(v, p))
c[v * 3] = col.x; c[v * 3 + 1] = col.y; c[v * 3 + 2] = col.z
}
return withColors(c)
}
fun transformed(f: (Vec3) -> Vec3): TriMesh {
Every vertex position through f, indices unchanged.
Normals are dropped, not transformed: a general map has no inverse-transpose, and a stale
normal shades wrong in a way that is hard to see and easy to ship. Call withVertexNormals
after. The rigid/uniform helpers (translated, rotated, uniform scaled) keep them,
because for those the correct transform is known.
fun transformed(f: (Vec3) -> Vec3): TriMesh {
val out = FloatArray(positions.size)
val p = Vec3()
for (v in 0 until vertexCount) {
val q = f(positionInto(v, p))
out[v * 3] = q.x; out[v * 3 + 1] = q.y; out[v * 3 + 2] = q.z
}
return TriMesh(out, indices, null, uvs, colors)
}
fun translated(dx: Number, dy: Number, dz: Number): TriMesh
Translated by (dx, dy, dz). Normals survive — translation does not affect them.
fun translated(dx: Number, dy: Number, dz: Number): TriMesh {
val ax = dx.toFloat(); val ay = dy.toFloat(); val az = dz.toFloat()
val out = FloatArray(positions.size)
var i = 0
while (i < out.size) {
out[i] = positions[i] + ax; out[i + 1] = positions[i + 1] + ay; out[i + 2] = positions[i + 2] + az
i += 3
}
return TriMesh(out, indices, normals, uvs, colors)
}
fun translated(d: Vec3): TriMesh = translated(d.x, d.y, d.z)
Translated by a vector.
fun scaled(sx: Number, sy: Number, sz: Number): TriMesh
Scaled about the origin, per axis.
A uniform positive scale keeps the normals (they are scale-invariant once normalised); anything non-uniform, or with a negative factor, drops them — the correct normal transform is the inverse transpose, and a negative factor also flips the winding, which flipped handles.
fun scaled(sx: Number, sy: Number, sz: Number): TriMesh {
val ax = sx.toFloat(); val ay = sy.toFloat(); val az = sz.toFloat()
val out = FloatArray(positions.size)
var i = 0
while (i < out.size) {
out[i] = positions[i] * ax; out[i + 1] = positions[i + 1] * ay; out[i + 2] = positions[i + 2] * az
i += 3
}
val uniform = ax == ay && ay == az && ax > 0f
return TriMesh(out, indices, if (uniform) normals else null, uvs, colors)
}
fun scaled(s: Number): TriMesh = scaled(s, s, s)
Uniformly scaled about the origin.
fun rotated(q: Quat): TriMesh
Rotated by a Quat. Rigid, so the normals are rotated with the positions rather than dropped.
fun rotated(q: Quat): TriMesh {
val out = FloatArray(positions.size)
val p = Vec3()
for (v in 0 until vertexCount) {
val r = rotate(q, positionInto(v, p))
out[v * 3] = r.x; out[v * 3 + 1] = r.y; out[v * 3 + 2] = r.z
}
val n = normals?.let { src ->
val dst = FloatArray(src.size)
for (v in 0 until vertexCount) {
val r = rotate(q, p.set(src[v * 3], src[v * 3 + 1], src[v * 3 + 2]))
dst[v * 3] = r.x; dst[v * 3 + 1] = r.y; dst[v * 3 + 2] = r.z
}
dst
}
return TriMesh(out, indices, n, uvs, colors)
}
fun flipped(): TriMesh
Every triangle wound the other way, and every normal negated — an inside-out copy.
Use it when a generator produced a solid with negative signedVolume, or to make the inner
surface of a shell.
fun flipped(): TriMesh {
val idx = IntArray(indices.size)
for (t in 0 until triangleCount) {
idx[t * 3] = indices[t * 3]
idx[t * 3 + 1] = indices[t * 3 + 2]
idx[t * 3 + 2] = indices[t * 3 + 1]
}
val n = normals?.let { src -> FloatArray(src.size) { -src[it] } }
return TriMesh(positions, idx, n, uvs, colors)
}
operator fun plus(other: TriMesh): TriMesh
Two meshes as one, other's indices shifted past this one's vertices.
The result carries an optional attribute only if both inputs had it — otherwise half the vertices would have an invented normal or colour, which is worse than none.
operator fun plus(other: TriMesh): TriMesh {
val n = vertexCount
val idx = IntArray(indices.size + other.indices.size)
indices.copyInto(idx)
for (i in other.indices.indices) idx[indices.size + i] = other.indices[i] + n
fun join(a: FloatArray?, b: FloatArray?): FloatArray? {
if (a == null || b == null) return null
val out = FloatArray(a.size + b.size)
a.copyInto(out); b.copyInto(out, a.size)
return out
}
return TriMesh(join(positions, other.positions)!!, idx, join(normals, other.normals), join(uvs, other.uvs), join(colors, other.colors))
}
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.
fun filterTriangles(keep: (Int) -> Boolean): TriMesh {
val kept = (0 until triangleCount).filter(keep)
val remap = IntArray(vertexCount) { -1 }
var next = 0
for (t in kept) {
for (k in 0..2) {
val v = indices[t * 3 + k]
if (remap[v] < 0) remap[v] = next++
}
}
val pos = FloatArray(next * 3)
val nor = if (normals != null) FloatArray(next * 3) else null
val tex = if (uvs != null) FloatArray(next * 2) else null
val col = if (colors != null) FloatArray(next * 3) else null
for (v in 0 until vertexCount) {
val d = remap[v]
if (d < 0) continue
for (k in 0..2) {
pos[d * 3 + k] = positions[v * 3 + k]
nor?.set(d * 3 + k, normals!![v * 3 + k])
col?.set(d * 3 + k, colors!![v * 3 + k])
}
if (tex != null) { tex[d * 2] = uvs!![v * 2]; tex[d * 2 + 1] = uvs[v * 2 + 1] }
}
val idx = IntArray(kept.size * 3)
kept.forEachIndexed { j, t ->
idx[j * 3] = remap[indices[t * 3]]
idx[j * 3 + 1] = remap[indices[t * 3 + 1]]
idx[j * 3 + 2] = remap[indices[t * 3 + 2]]
}
return TriMesh(pos, idx, nor, tex, col)
}
override fun toString(): String
override fun toString(): String =
"TriMesh($vertexCount verts, $triangleCount tris" +
(if (hasNormals) ", normals" else "") +
(if (hasUvs) ", uvs" else "") +
(if (hasColors) ", colors" else "") + ")"
override fun toHTML(): String = meshThumbnailHtml(this)
Inline thumbnail for the value visualizer: the mesh actually rasterised, shaded and
depth-buffered, plus a one-line measurement caption. Evaluating a TriMesh in a box (or
mesh.h) shows the shape rather than TriMesh@3f1a2b.
companion object
companion object {
val empty: TriMesh = TriMesh(FloatArray(0), IntArray(0))
The empty mesh — no vertices, no triangles. What a march of an empty field returns.
fun of(vertices: List<Vec3>, indices: List<Int>): TriMesh
Build from vertex list plus flat index list — the shape most hand-written meshes arrive in.
fun of(vertices: List<Vec3>, indices: List<Int>): TriMesh {
val p = FloatArray(vertices.size * 3)
vertices.forEachIndexed { i, v -> p[i * 3] = v.x; p[i * 3 + 1] = v.y; p[i * 3 + 2] = v.z }
return TriMesh(p, indices.toIntArray())
}
fun ofTriangles(triangles: List<Triple<Vec3, Vec3, Vec3>>): TriMesh
Build from a list of independent triangles — welds nothing, so call weld after if you need shared vertices.
fun ofTriangles(triangles: List<Triple<Vec3, Vec3, Vec3>>): TriMesh {
val p = FloatArray(triangles.size * 9)
triangles.forEachIndexed { t, (a, b, c) ->
p[t * 9] = a.x; p[t * 9 + 1] = a.y; p[t * 9 + 2] = a.z
p[t * 9 + 3] = b.x; p[t * 9 + 4] = b.y; p[t * 9 + 5] = b.z
p[t * 9 + 6] = c.x; p[t * 9 + 7] = c.y; p[t * 9 + 8] = c.z
}
return TriMesh(p, IntArray(triangles.size * 3) { it })
}
}
}
p rotated by q, into a fresh vector. Written out because the generated Quat API takes a dest.
private fun rotate(q: Quat, p: Vec3): Vec3 {
// v' = v + 2 * cross(q.xyz, cross(q.xyz, v) + q.w * v)
val qx = q.x; val qy = q.y; val qz = q.z; val qw = q.w
val tx = qy * p.z - qz * p.y + qw * p.x
val ty = qz * p.x - qx * p.z + qw * p.y
val tz = qx * p.y - qy * p.x + qw * p.z
return Vec3(
p.x + 2f * (qy * tz - qz * ty),
p.y + 2f * (qz * tx - qx * tz),
p.z + 2f * (qx * ty - qy * tx),
)
}
Building one
class TriMeshBuilder
The accumulator every generator in this family is written against: append vertices, append triangles that reference them, build.
Attributes are opt-in but must be consistent — the first vertex call decides whether this mesh has normals, and a later call that omits one (or adds one) fails immediately rather than shipping a mesh whose attribute array is half garbage.
val quadStrip = triMesh {
for (i in 0..8) {
val a = vertex(vec(i, 0, 0), normal = vec(0, 0, 1))
val b = vertex(vec(i, 1, 0), normal = vec(0, 0, 1))
if (i > 0) quad(a - 2, a, b, b - 2)
}
}
class TriMeshBuilder {
private val pos = ArrayList<Float>()
private val idx = ArrayList<Int>()
private var nor: ArrayList<Float>? = null
private var tex: ArrayList<Float>? = null
private var col: ArrayList<Float>? = null
private var decided = false
private val marks = ArrayList<Pair<Int, String>>()
val vertexCount: Int get() = pos.size / 3
How many vertices have been appended so far — also the index the next vertex will get.
val triangleCount: Int get() = idx.size / 3
How many triangles have been appended so far.
fun group(name: String)
Name the piece every triangle appended from here on belongs to.
A marker, not a container: it records "triangles from index triangleCount onward are called
this" and nothing else, so it costs one pair per call and does not touch the mesh. The TriMesh
a builder produces has no idea the names exist; they come back out through
afield.graphics.interaction.groups, which turns them into a PickParts so that pointing at the model
reports roof instead of triangle 4471.
Each call starts a new piece even if the name repeats — see afield.graphics.interaction.groups for why.
val house = triMesh {
group("floor"); add(box(4.0, 0.2, 3.0))
group("roof"); add(box(4.2, 0.2, 3.2))
}
fun group(name: String) {
marks += triangleCount to name
}
internal fun groupMarks(): List<Pair<Int, String>> = marks.toList()
The group markers, as (first triangle, name) pairs in call order.
fun vertex(p: Vec3, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int
Append a vertex and return its index.
@param normal supply it for every vertex or for none. @param uv supply it for every vertex or for none. @param color supply it for every vertex or for none.
fun vertex(p: Vec3, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int =
vertex(p.x, p.y, p.z, normal, uv, color)
fun vertex(x: Number, y: Number, z: Number, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int
vertex from loose floats — what the generators use, so they never allocate a Vec3 per vertex.
fun vertex(x: Number, y: Number, z: Number, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int {
if (!decided) {
if (normal != null) nor = ArrayList()
if (uv != null) tex = ArrayList()
if (color != null) col = ArrayList()
decided = true
} else {
require((normal != null) == (nor != null)) {
"vertex ${vertexCount}: this mesh ${if (nor != null) "has" else "does not have"} normals; " +
"supply one for every vertex or for none"
}
require((uv != null) == (tex != null)) { "vertex ${vertexCount}: uvs must be all-or-nothing" }
require((color != null) == (col != null)) { "vertex ${vertexCount}: colors must be all-or-nothing" }
}
val i = vertexCount
pos.add(x.toFloat()); pos.add(y.toFloat()); pos.add(z.toFloat())
normal?.let { nor!!.add(it.x); nor!!.add(it.y); nor!!.add(it.z) }
uv?.let { tex!!.add(it.x); tex!!.add(it.y) }
color?.let { col!!.add(it.x); col!!.add(it.y); col!!.add(it.z) }
return i
}
fun triangle(a: Int, b: Int, c: Int)
Append one triangle. Counter-clockwise seen from outside.
fun triangle(a: Int, b: Int, c: Int) {
require(a in 0 until vertexCount && b in 0 until vertexCount && c in 0 until vertexCount) {
"triangle($a, $b, $c) references a vertex outside the $vertexCount added so far"
}
idx.add(a); idx.add(b); idx.add(c)
}
fun quad(a: Int, b: Int, c: Int, d: Int)
Append a quad as two triangles: (a,b,c) and (a,c,d). Corners in order around the quad.
fun quad(a: Int, b: Int, c: Int, d: Int) {
triangle(a, b, c)
triangle(a, c, d)
}
fun polygon(corners: List<Int>)
Append a convex polygon as a fan from its first corner. Non-convex polygons will self-overlap — extrudeMesh triangulates properly via ear clipping for those.
fun polygon(corners: List<Int>) {
require(corners.size >= 3) { "a polygon needs at least 3 corners, got ${corners.size}" }
for (i in 1 until corners.size - 1) triangle(corners[0], corners[i], corners[i + 1])
}
fun add(mesh: TriMesh): Int
Append a whole mesh, offsetting its indices. Returns the index its vertex 0 landed on.
The attributes must agree with what this builder is already carrying, for the same reason vertex insists on it.
fun add(mesh: TriMesh): Int {
val base = vertexCount
for (v in 0 until mesh.vertexCount) {
vertex(
mesh.x(v), mesh.y(v), mesh.z(v),
mesh.normal(v), mesh.uv(v), mesh.color(v),
)
}
for (i in mesh.indices) idx.add(i + base)
return base
}
fun build(): TriMesh
Freeze into a TriMesh. The builder stays usable; the mesh gets its own arrays.
fun build(): TriMesh = TriMesh(
pos.toFloatArray(),
idx.toIntArray(),
nor?.toFloatArray(),
tex?.toFloatArray(),
col?.toFloatArray(),
)
}
inline fun triMesh(block: TriMeshBuilder.() -> Unit): TriMesh = TriMeshBuilder().apply(block).build()
Run block against a fresh TriMeshBuilder and TriMeshBuilder.build the result.
inline fun triMesh(block: TriMeshBuilder.() -> Unit): TriMesh = TriMeshBuilder().apply(block).build()
Small shared numerics
internal fun TriMesh.boundsArray(): DoubleArray
[minX, minY, minZ, maxX, maxY, maxZ] over the positions; a unit box for an empty mesh.
internal fun TriMesh.boundsArray(): DoubleArray {
if (vertexCount == 0) return doubleArrayOf(-0.5, -0.5, -0.5, 0.5, 0.5, 0.5)
val b = doubleArrayOf(
Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE,
-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE,
)
for (v in 0 until vertexCount) {
val x = x(v).toDouble(); val y = y(v).toDouble(); val z = z(v).toDouble()
b[0] = min(b[0], x); b[1] = min(b[1], y); b[2] = min(b[2], z)
b[3] = max(b[3], x); b[4] = max(b[4], y); b[5] = max(b[5], z)
}
return b
}