MeshFromDisk.kt
src/jvmMain/kotlin/afield/graphics/scene/MeshFromDisk.kt · 310 lines
package afield.graphics.scene
import afield.graphics.render.before
import afield.graphics.render.main
import afield.document.graph.Box
import afield.geometry.mesh.MeshRenderer
import afield.geometry.mesh.MeshView
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.TriMeshBuilder
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Matrix3f
import afield.math.linalg.Vector3f
import afield.geometry.mesh.meshFromFile
import afield.geometry.mesh.showMesh
import org.lwjgl.system.MemoryUtil.memFree
import java.io.File
Connects the loaders that read geometry off disk to the CPU renderer that can look at it.
Field has four things that read geometry from a file, and one of them feeds the CPU preview:
afield.geometry.mesh.mesh_io.ktreads OBJ and STL straight intoTriMesh— the typeafield.geometry.mesh.showMesh, TriMesh.renderPng and the inlinetoHTML()card all render.- PlySupport reads PLY (ASCII, binary, both endians, colours, normals, point clouds) into a packed 10-float GPU buffer.
GltfSupportreads glTF/GLB — and, beingAssimp.aiImportFileunderneath, also FBX, DAE, 3DS, OBJ, STL and PLY — into a packed 12-float GPU buffer plus materials.afield.graphics.scene.fabricated.FabricateGltfBridgetakes that same model into the PBRStandardRenderer.
Both GPU loaders produce plain host memory — memAlloc, not a Vulkan allocation — which is what
lets their output be read back into a TriMesh on the CPU: PlySupport.PlyModel.toTriMesh,
assimpToTriMesh and the dispatcher meshFromAnyFile, plus a Box.showMesh overload that takes
a File.
showMesh(File("/path/to/thing.glb")) // straight to the orbitable viewer
val m = meshFromAnyFile("/path/to/scan.ply") // or keep the mesh and compute with it
println(m.summary())
They live here rather than in afield.stdlib because mesh_io.kt carries no LWJGL dependency and
these need one. afield.graphics.scene.* is in every box's default imports
(see afield.document.graph.intro), so a box can call them without an import anyway.
What is not here, because nothing in the tree can read it: STEP / IGES / BREP. There is no CAD
kernel on the classpath, and those formats need NURBS-to-mesh tessellation rather than a parser —
see public/docs/geometry_from_disk.md for the full list of what does and does not load.
PLY
fun PlySupport.PlyModel.toTriMesh(keepNormals: Boolean = true, keepColors: Boolean = true): TriMesh
This PLY model's packed vertex buffer, unpacked into a TriMesh.
PlySupport.loadPly fills `pos.xyzcolor.rgba— ten floats per vertex — and anIntBuffer` of triangle indices, both in ordinary host memory. This reads them back out.
@param keepNormals PLY files without nx/ny/nz get (0, 0, 1) written for every vertex by the
loader, which is a wrong normal rather than a missing one — it would shade the whole model as
though it faced the camera. When the loader reports no normals in the file this drops them
instead, and the render bridges then crease-shade the mesh for you.
@param keepColors PLY colours default to white when the file has none, which carries no
information and only overrides the renderer's own default shading; an all-white colour array is
dropped whatever this says.
@throws IllegalArgumentException if the model is a point cloud — there are no triangles to make a
mesh out of. Check PlySupport.PlyMeshBuffers.isPointCloud first.
fun PlySupport.PlyModel.toTriMesh(keepNormals: Boolean = true, keepColors: Boolean = true): TriMesh {
val b = buffers
require(!b.isPointCloud) {
"${name} is a point cloud (${b.vertexCount} vertices, no faces); there is no mesh to build. " +
"Use plyPointCloudHere for those, or read the positions out of buffers.vertexData yourself."
}
val floats = b.vertexData.asFloatBuffer()
val useNormals = keepNormals && b.hasNormals
val useColors = keepColors && b.hasColors
val builder = TriMeshBuilder()
var whiteEverywhere = true
val staged = ArrayList<FloatArray>(b.vertexCount)
for (v in 0 until b.vertexCount) {
val o = v * 10
val row = FloatArray(10) { floats.get(o + it) }
if (useColors && (row[6] != 1f || row[7] != 1f || row[8] != 1f)) whiteEverywhere = false
staged.add(row)
}
val emitColors = useColors && !whiteEverywhere
for (row in staged) {
builder.vertex(
row[0], row[1], row[2],
normal = if (useNormals) Vec3(row[3], row[4], row[5]) else null,
color = if (emitColors) Vec3(row[6], row[7], row[8]) else null,
)
}
val indices = b.indexData
if (indices != null) {
var t = 0
while (t + 2 < b.indexCount) {
val a = indices.get(t); val bb = indices.get(t + 1); val c = indices.get(t + 2)
if (a < b.vertexCount && bb < b.vertexCount && c < b.vertexCount) builder.triangle(a, bb, c)
t += 3
}
}
return builder.build()
}
Everything Assimp reads — glTF, GLB, FBX, DAE, 3DS, …
fun assimpToTriMesh(path: String, keepColors: Boolean = true): TriMesh
Read any file Assimp can import — glTF, GLB, FBX, DAE, 3DS, OBJ, STL,
PLY and the rest of its forty-odd formats — into one TriMesh.
Every mesh in the scene is baked through its node's world transform and concatenated, so a
multi-part model arrives assembled rather than as a heap of parts at the origin. Normals come from
the file or from Assimp's aiProcess_GenNormals; uvs and per-vertex colours come across when the
file has them.
This is a bridge, not a loader: it calls GltfSupport.loadGltfBasic and unpacks the twelve floats per vertex it already produces. Two things about that are worth knowing:
- It passes
loadTextures = false, which is what makes this work outside a live renderer. The default GltfSupport.GltfLoadConfig hasloadTextures = true, and that path asks the box context for aRendererin order to make stub textures — soloadGltfBasic(path)with default arguments throws aNullPointerExceptionfrom a plain function or test. Materials are dropped here regardless: aTriMeshhas positions, normals, uvs and per-vertex colours and no concept of a texture. For textured PBR rendering use afield.graphics.scene.fabricated.FabricateGltfBridge and theStandardRendererinstead. - Assimp's loaders allocate off-heap buffers. This frees them before returning, because it loaded them itself and nothing else can reach them afterwards.
@param keepColors glTF meshes with no COLOR_0 get white written for every vertex; an all-white
colour array is dropped whatever this says, so the renderer's own shading shows through.
@throws IllegalStateException with Assimp's own message if the file cannot be read at all.
body — 73 lines, from line 141
fun assimpToTriMesh(path: String, keepColors: Boolean = true): TriMesh {
val model = GltfSupport.loadGltfBasic(
path,
GltfSupport.GltfLoadConfig(loadTextures = false),
)
try {
// Accumulated as a nullable rather than from TriMesh.empty: `plus` keeps an optional attribute
// only when *both* sides have it, and the empty mesh has none, so starting there would throw
// away every normal in the file.
var out: TriMesh? = null
for (instance in model.meshes) {
val b = instance.buffers
if (b.indexCount < 3) continue
// The node's true world matrix, NOT bindPose.toMatrix(): a TRS cannot carry a mirror or a
// shear, and decomposeToTRS silently turns a mirrored node into a pure translation. See
// GltfSupport.MeshInstance.bindMatrix.
val m = instance.bindMatrix ?: instance.bindPose.toMatrix()
// Normals transform by the inverse transpose, not by the matrix. Identical for a rigid or
// uniformly-scaled node -- which is why ToyCar (a uniform 1e-4) never showed the difference
// -- and wrong for anything non-uniform.
val normalMatrix = m.get3x3(Matrix3f()).invert().transpose()
// A negative determinant means the transform mirrors, which reverses triangle winding. Emit
// the corners in the other order so the surface still faces outward afterwards.
val mirrors = m.determinant() < 0f
val floats = b.vertexData.asFloatBuffer()
// Two passes so the all-white test can decide before any vertex is emitted -- the builder
// requires colours to be all-or-nothing.
val rows = ArrayList<FloatArray>(b.vertexCount)
var whiteEverywhere = true
var anyUv = false
for (v in 0 until b.vertexCount) {
val o = v * 12
val row = FloatArray(12) { floats.get(o + it) }
if (row[8] != 1f || row[9] != 1f || row[10] != 1f) whiteEverywhere = false
if (row[6] != 0f || row[7] != 0f) anyUv = true
rows.add(row)
}
val emitColors = keepColors && !whiteEverywhere
val builder = TriMeshBuilder()
val p = Vector3f()
val n = Vector3f()
for (row in rows) {
p.set(row[0], row[1], row[2]); m.transformPosition(p)
n.set(row[3], row[4], row[5]); normalMatrix.transform(n)
val len = kotlin.math.sqrt(n.x * n.x + n.y * n.y + n.z * n.z)
builder.vertex(
p.x, p.y, p.z,
normal = if (len > 1e-12f) Vec3(n.x / len, n.y / len, n.z / len) else Vec3(0f, 1f, 0f),
uv = if (anyUv) Vec2(row[6], row[7]) else null,
color = if (emitColors) Vec3(row[8], row[9], row[10]) else null,
)
}
val indices = b.indexData
var t = 0
while (t + 2 < b.indexCount) {
val a = indices.get(t); val bb = indices.get(t + 1); val c = indices.get(t + 2)
if (a < b.vertexCount && bb < b.vertexCount && c < b.vertexCount) {
if (mirrors) builder.triangle(a, c, bb) else builder.triangle(a, bb, c)
}
t += 3
}
val part = builder.build()
out = out?.plus(part) ?: part
}
return out ?: TriMesh.empty
} finally {
for (instance in model.meshes) {
memFree(instance.buffers.vertexData)
memFree(instance.buffers.indexData)
instance.buffers.tangentData?.let { memFree(it) }
}
}
}
Dispatch, and the one-liner
fun meshFromAnyFile(path: String): TriMesh
Load a mesh from a path, in any format Field can read, and return it as a TriMesh you can
render, measure, weld, decimate or write back out.
The dispatch, and why it is not just one call to Assimp:
| extension | route | why |
|---|---|---|
.obj, .stl |
afield.geometry.mesh.meshFromFile | dependency-free, and its OBJ reader emits vertices in the file's own v order, so a round trip is index-for-index rather than merely shape-for-shape |
.ply |
PlySupport.loadPly then PlySupport.PlyModel.toTriMesh |
keeps the per-vertex colours a scan carries, which is usually the point of a PLY |
| anything else | assimpToTriMesh | glTF, GLB, FBX, DAE, 3DS, and the rest |
An unreadable file throws rather than returning an empty mesh, so a typo in a path does not look like a modelling problem.
val m = meshFromAnyFile("/path/to/thing.glb")
println(m.summary()) // counts, extent, chi, genus, watertightness, area, volume
showMesh(m) // and look at it
fun meshFromAnyFile(path: String): TriMesh {
val file = File(path)
require(file.isFile) { "no such mesh file: $path" }
return when (file.extension.lowercase()) {
"obj", "stl" -> meshFromFile(path)
"ply" -> PlySupport.loadPly(path).let { model ->
try {
model.toTriMesh()
} finally {
memFree(model.buffers.vertexData)
model.buffers.indexData?.let { memFree(it) }
}
}
else -> assimpToTriMesh(path)
}.also { afield.geometry.mesh.requireGeometryFrom(file, it) }
}
fun Box.showMesh
Read a mesh file and push a rendered view of it to one of this box's output channels — the one-liner for "I have a mesh on disk, show me".
showMesh("/path/to/thing.glb")
Every format meshFromAnyFile handles, drawn the way afield.geometry.mesh.showMesh draws a mesh: by
default in the Three.js viewer, shaded and wireframed, which you can turn with the mouse, with the
mesh's measurements underneath. It is the viewer FLines are drawn in too, so a loaded model and its
annotations can share a scene. MeshView.distance is measured in bounding-sphere radii, so a model
in millimetres and a model in metres both frame correctly with no scaling on your part.
For a static picture from a fixed camera instead, with deterministic pixels and no WebGL, pass
MeshRenderer.CPU:
showMesh("/path/to/thing.glb", renderer = MeshRenderer.CPU)
@param title defaults to the file's name, so a grid of these labels itself.
@param renderer MeshRenderer.GPU, the orbitable viewer, or MeshRenderer.CPU, the static figure.
@param height iframe height in pixels. Ignored by MeshRenderer.CPU, which takes its size from
view's width and height instead.
fun Box.showMesh(
path: String,
channel: String = "main",
view: MeshView = MeshView(),
caption: String? = null,
title: String? = File(path).name,
renderer: MeshRenderer = MeshRenderer.GPU,
height: Int? = null,
) {
showMesh(meshFromAnyFile(path), channel, view, caption, title, renderer, height)
}
fun Box.showMesh
showMesh from a File rather than a path string, with the same renderer choice. java.io is
not in a box's default imports, so inside a box the String overload is usually the one you want.
fun Box.showMesh(
file: File,
channel: String = "main",
view: MeshView = MeshView(),
caption: String? = null,
title: String? = file.name,
renderer: MeshRenderer = MeshRenderer.GPU,
height: Int? = null,
) {
showMesh(meshFromAnyFile(file.path), channel, view, caption, title, renderer, height)
}