Skip to content

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.kt reads OBJ and STL straight into TriMesh — the type afield.geometry.mesh.showMesh, TriMesh.renderPng and the inline toHTML() card all render.
  • PlySupport reads PLY (ASCII, binary, both endians, colours, normals, point clouds) into a packed 10-float GPU buffer.
  • GltfSupport reads glTF/GLB — and, being Assimp.aiImportFile underneath, also FBX, DAE, 3DS, OBJ, STL and PLY — into a packed 12-float GPU buffer plus materials.
  • afield.graphics.scene.fabricated.FabricateGltfBridge takes that same model into the PBR StandardRenderer.

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 has loadTextures = true, and that path asks the box context for a Renderer in order to make stub textures — so loadGltfBasic(path) with default arguments throws a NullPointerException from a plain function or test. Materials are dropped here regardless: a TriMesh has positions, normals, uvs and per-vertex colours and no concept of a texture. For textured PBR rendering use afield.graphics.scene.fabricated.FabricateGltfBridge and the StandardRenderer instead.
  • 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)
}