assimpBrowsing.kt
src/jvmMain/kotlin/afield/app/server/assimpBrowsing.kt · 582 lines
package afield.app.server
import afield.graphics.scene.GltfSupport.GltfModel
import afield.graphics.scene.GltfSupport.MaterialTextured
import afield.graphics.scene.GltfSupport.MeshBuffers
import afield.graphics.scene.GltfSupport.MeshInstance
import afield.graphics.scene.GltfSupport.TRS
import afield.graphics.scene.GltfSupport.TextureSlot
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import org.lwjgl.assimp.AIAnimation
import org.lwjgl.assimp.AIColor4D
import org.lwjgl.assimp.AIMaterial
import org.lwjgl.assimp.AIMatrix4x4
import org.lwjgl.assimp.AIMesh
import org.lwjgl.assimp.AINode
import org.lwjgl.assimp.AINodeAnim
import org.lwjgl.assimp.AIQuatKey
import org.lwjgl.assimp.AIScene
import org.lwjgl.assimp.AIVectorKey
import org.lwjgl.assimp.Assimp
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max
import kotlin.math.min
private val assimpAdaptersRegistered = AtomicBoolean(false)
fun registerAssimpBrowsingAdapters()
One-time registration of Browsable adapters for AssImp + GLTF helpers.
body — 336 lines, from line 29
fun registerAssimpBrowsingAdapters() {
if (!assimpAdaptersRegistered.compareAndSet(false, true)) return
// GLTF high-level views ------------------------------------------------------------------------------
BrowsableAdapters.register(GltfModel::class) { model, _, title ->
object : Browsable {
override fun heading(): String = title ?: "GLTF Model"
override fun dir(): Map<String, Any?> {
val meshes = linkedMapOf<String, Any?>()
model.meshes.withIndex().forEach { (i, m) ->
meshes["mesh[$i] ${m.name}"] = m
}
return meshes
}
override fun html(): String {
val textured = model.meshes.count { m -> m.material.textures.any { it.valid } }
val animated = model.meshes.count { it.animationSamples != null }
val verts = model.meshes.sumOf { it.buffers.vertexCount }
val tris = model.meshes.sumOf { it.buffers.indexCount / 3 }
val rows = listOf(
"meshes" to model.meshes.size.toString(),
"vertices" to verts.toString(),
"triangles" to tris.toString(),
"textured" to "$textured/${model.meshes.size}",
"animated" to "$animated/${model.meshes.size}"
)
return card(heading(), rows, accent = "#60a5fa")
}
}
}
BrowsableAdapters.register(MeshInstance::class) { mesh, _, title ->
object : Browsable {
override fun heading(): String = title ?: mesh.name.ifBlank { "Mesh ${mesh.meshIndex}" }
override fun dir(): Map<String, Any?> {
val out = linkedMapOf<String, Any?>(
"buffers" to mesh.buffers,
"material" to mesh.material,
"bindPose" to mesh.bindPose
)
mesh.animationSamples?.let { out["animation"] = it }
return out
}
override fun html(): String {
val textured = mesh.material.textures.count { it.valid }
val rows = listOf(
"mesh index" to mesh.meshIndex.toString(),
"name" to mesh.name.ifBlank { "(unnamed)" },
"vertices" to mesh.buffers.vertexCount.toString(),
"triangles" to (mesh.buffers.indexCount / 3).toString(),
"buffers" to "${fmtBytes(mesh.buffers.vertexData.remaining().toLong())} / ${fmtBytes(mesh.buffers.indexData.remaining().toLong() * Int.SIZE_BYTES)}",
"textures" to "$textured/${mesh.material.textures.size}",
"animation" to (mesh.animationSamples?.let { "${it.size} samples" } ?: "none")
)
return card(heading(), rows, accent = "#a78bfa")
}
}
}
BrowsableAdapters.register(MeshBuffers::class) { buffers, _, title ->
object : Browsable {
override fun heading(): String = title ?: "MeshBuffers"
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
val stride = if (buffers.vertexCount > 0) buffers.vertexData.remaining() / buffers.vertexCount else 0
val rows = listOf(
"vertices" to buffers.vertexCount.toString(),
"indices" to buffers.indexCount.toString(),
"vertex bytes" to fmtBytes(buffers.vertexData.remaining().toLong()),
"index bytes" to fmtBytes(buffers.indexData.remaining().toLong() * Int.SIZE_BYTES),
"stride" to "$stride bytes"
)
return card(heading(), rows, accent = "#34d399")
}
}
}
BrowsableAdapters.register(MaterialTextured::class) { mat, _, title ->
object : Browsable {
override fun heading(): String = title ?: "Material"
override fun dir(): Map<String, Any?> = linkedMapOf(
"textures" to mat.textures
)
override fun html(): String {
val active = mat.textures.count { it.valid }
val rows = listOf(
"baseColor" to "rgba(%.2f, %.2f, %.2f, %.2f)".format(
mat.baseColor.x, mat.baseColor.y, mat.baseColor.z, mat.baseColor.w
),
"textures" to "$active/${mat.textures.size}"
)
return card(heading(), rows, accent = "#f59e0b")
}
}
}
BrowsableAdapters.register(TextureSlot::class) { slot, _, title ->
object : Browsable {
override fun heading(): String = title ?: textureTypeName(slot.type)
override fun dir(): Map<String, Any?> = mapOf("texture" to slot.texture)
override fun html(): String {
val rows = listOf(
"type" to textureTypeName(slot.type),
"uv set" to slot.uvSet.toString(),
"valid" to slot.valid.toString()
)
return card(heading(), rows, accent = if (slot.valid) "#38bdf8" else "#9ca3af")
}
}
}
BrowsableAdapters.register(TRS::class) { trs, _, title ->
object : Browsable {
override fun heading(): String = title ?: "Transform"
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
val rows = listOf(
"translate" to vec3(trs.t),
"scale" to vec3(trs.s),
"rotation" to quat(trs.r)
)
return card(heading(), rows, accent = "#67e8f9")
}
}
}
// Raw Assimp views -----------------------------------------------------------------------------------
BrowsableAdapters.register(AIScene::class) { scene, _, title ->
object : Browsable {
override fun heading(): String = title ?: "Assimp Scene"
override fun dir(): Map<String, Any?> {
val meshes = linkedMapOf<String, Any?>()
for (i in 0 until scene.mNumMeshes()) {
meshes["mesh[$i]"] = MeshWithMaterial(
i,
AIMesh.create(scene.mMeshes()!![i]),
scene.materialAtMeshIndex(i)
)
}
val materials = linkedMapOf<String, Any?>()
for (i in 0 until scene.mNumMaterials()) {
materials["material[$i]"] = AIMaterial.create(scene.mMaterials()!![i])
}
val animations = linkedMapOf<String, Any?>()
for (i in 0 until scene.mNumAnimations()) {
animations["anim[$i]"] = AIAnimation.create(scene.mAnimations()!![i])
}
return linkedMapOf(
"root" to scene.mRootNode()?.let { NodeView(it, scene, path = it.mName().dataString().ifBlank { "/" }) },
"meshes" to meshes,
"materials" to materials,
"animations" to animations
).filterValues { it != null }
}
override fun html(): String {
val rows = listOf(
"meshes" to scene.mNumMeshes().toString(),
"materials" to scene.mNumMaterials().toString(),
"animations" to scene.mNumAnimations().toString(),
"textures" to scene.mNumTextures().toString(),
"flags" to "0x${scene.mFlags().toString(16)}"
)
return card(heading(), rows, accent = "#22d3ee")
}
}
}
BrowsableAdapters.register(NodeView::class) { holder, _, title ->
val node = holder.node
val trs = toTrs(node.mTransformation())
object : Browsable {
override fun heading(): String = title ?: node.mName().dataString().ifBlank { holder.path }
override fun dir(): Map<String, Any?> {
val meshMap = linkedMapOf<String, Any?>()
node.mMeshes()?.let { idxBuf ->
for (i in 0 until idxBuf.remaining()) {
val meshIdx = idxBuf[i]
meshMap["mesh[$meshIdx]"] = holder.mesh(meshIdx)
}
}
val childMap = linkedMapOf<String, Any?>()
node.mChildren()?.let { buf ->
for (i in 0 until buf.remaining()) {
val child = AINode.create(buf[i])
val name = child.mName().dataString().ifBlank { "child[$i]" }
childMap[name] = NodeView(child, holder.scene, childPath(holder.path, name))
}
}
val out = linkedMapOf<String, Any?>()
out["transform"] = trs
if (meshMap.isNotEmpty()) out["meshes"] = meshMap
if (childMap.isNotEmpty()) out["children"] = childMap
return out
}
override fun html(): String {
val rows = listOf(
"path" to holder.path,
"meshes" to (node.mMeshes()?.remaining() ?: 0).toString(),
"children" to (node.mChildren()?.remaining() ?: 0).toString(),
"translate" to vec3(trs.t)
)
return card(heading(), rows, accent = "#c084fc")
}
}
}
BrowsableAdapters.register(MeshWithMaterial::class) { wrap, _, title ->
val mesh = wrap.mesh ?: return@register null
object : Browsable {
override fun heading(): String = title ?: mesh.mName().dataString().ifBlank { "mesh[${wrap.meshIndex}]" }
override fun dir(): Map<String, Any?> = linkedMapOf(
"mesh" to mesh,
"material" to wrap.material
)
override fun html(): String {
val indexCount = mesh.indexCount()
val bounds = calcBounds(mesh)
val matIdx = mesh.mMaterialIndex()
val rows = listOf(
"vertices" to mesh.mNumVertices().toString(),
"triangles" to (indexCount / 3).toString(),
"material" to if (wrap.material != null && matIdx >= 0) "material[$matIdx]" else "none",
"attributes" to attributes(mesh),
"bounds" to (bounds?.let { "${vec3(it.min)} – ${vec3(it.max)}" } ?: "n/a")
)
return card(heading(), rows, accent = "#10b981")
}
}
}
BrowsableAdapters.register(AIMesh::class) { mesh, _, title ->
object : Browsable {
override fun heading(): String = title ?: mesh.mName().dataString().ifBlank { "Mesh" }
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
val indexCount = mesh.indexCount()
val bounds = calcBounds(mesh)
val rows = listOf(
"vertices" to mesh.mNumVertices().toString(),
"triangles" to (indexCount / 3).toString(),
"material index" to mesh.mMaterialIndex().toString(),
"attributes" to attributes(mesh),
"bounds" to (bounds?.let { "${vec3(it.min)} – ${vec3(it.max)}" } ?: "n/a")
)
return card(heading(), rows, accent = "#4ade80")
}
}
}
BrowsableAdapters.register(AIMaterial::class) { mat, _, title ->
object : Browsable {
override fun heading(): String = title ?: "Material"
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
val texCounts = textureTypes().map { t ->
textureTypeName(t) to Assimp.aiGetMaterialTextureCount(mat, t).toString()
}
val base = AIColor4D.calloc()
val colorRes = Assimp.aiGetMaterialColor(mat, "\$clr.base", 0, 0, base)
val colorStr = if (colorRes == Assimp.aiReturn_SUCCESS) {
"rgba(%.2f, %.2f, %.2f, %.2f)".format(base.r(), base.g(), base.b(), base.a())
} else {
"n/a"
}
base.free()
val rows = listOf("baseColor" to colorStr) + texCounts
return card(heading(), rows, accent = "#f97316")
}
}
}
BrowsableAdapters.register(AIAnimation::class) { anim, _, title ->
object : Browsable {
override fun heading(): String = title ?: anim.mName().dataString().ifBlank { "Animation" }
override fun dir(): Map<String, Any?> {
val channels = linkedMapOf<String, Any?>()
anim.mChannels()?.let { buf ->
for (i in 0 until anim.mNumChannels()) {
channels["channel[$i]"] = AINodeAnim.create(buf[i])
}
}
return channels
}
override fun html(): String {
val ticks = if (anim.mTicksPerSecond() != 0.0) anim.mTicksPerSecond() else 25.0
val duration = if (ticks > 0.0) anim.mDuration() / ticks else 0.0
val (posTimes, rotTimes, scaleTimes) = aggregateTimes(anim)
val rows = listOf(
"name" to anim.mName().dataString().ifBlank { "(unnamed)" },
"channels" to anim.mNumChannels().toString(),
"duration(s)" to "%.2f".format(duration),
"ticks/s" to "%.2f".format(ticks)
)
val card = card(heading(), rows, accent = "#fb7185")
val timeline = renderTimeline(posTimes, rotTimes, scaleTimes, duration)
return card + (timeline ?: "")
}
}
}
BrowsableAdapters.register(AINodeAnim::class) { chan, _, title ->
object : Browsable {
override fun heading(): String = title ?: chan.mNodeName().dataString().ifBlank { "Channel" }
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
val rows = listOf(
"node" to chan.mNodeName().dataString(),
"positions" to chan.mNumPositionKeys().toString(),
"rotations" to chan.mNumRotationKeys().toString(),
"scales" to chan.mNumScalingKeys().toString()
)
val card = card(heading(), rows, accent = "#60a5fa")
val posTimes = keyTimes(chan.mPositionKeys(), chan.mNumPositionKeys())
val rotTimes = keyTimes(chan.mRotationKeys(), chan.mNumRotationKeys())
val scaleTimes = keyTimes(chan.mScalingKeys(), chan.mNumScalingKeys())
val timeline = renderTimeline(posTimes, rotTimes, scaleTimes, null)
return card + (timeline ?: "")
}
}
}
}
Helpers -----------------------------------------------------------------------------------------------------------
source — 214 lines, from line 368
private data class NodeView(val node: AINode, val scene: AIScene, val path: String)
private data class MeshWithMaterial(val meshIndex: Int, val mesh: AIMesh?, val material: AIMaterial?)
private data class Bounds(val min: Vector3f, val max: Vector3f)
private fun childPath(parent: String, name: String): String = if (parent == "/") "/$name" else "$parent/$name"
private fun AIScene.materialAtMeshIndex(meshIndex: Int): AIMaterial? {
val mesh = AIMesh.create(mMeshes()!![meshIndex])
val idx = mesh.mMaterialIndex()
if (idx < 0 || idx >= mNumMaterials()) return null
return AIMaterial.create(mMaterials()!![idx])
}
private fun NodeView.mesh(idx: Int): MeshWithMaterial {
val mesh = AIMesh.create(scene.mMeshes()!![idx])
val mat = scene.materialAtMeshIndex(idx)
return MeshWithMaterial(idx, mesh, mat)
}
private fun calcBounds(mesh: AIMesh): Bounds? {
val verts = mesh.mVertices()
if (mesh.mNumVertices() <= 0) return null
var minX = Float.POSITIVE_INFINITY
var minY = Float.POSITIVE_INFINITY
var minZ = Float.POSITIVE_INFINITY
var maxX = Float.NEGATIVE_INFINITY
var maxY = Float.NEGATIVE_INFINITY
var maxZ = Float.NEGATIVE_INFINITY
for (i in 0 until mesh.mNumVertices()) {
val v = verts[i]
minX = min(minX, v.x())
minY = min(minY, v.y())
minZ = min(minZ, v.z())
maxX = max(maxX, v.x())
maxY = max(maxY, v.y())
maxZ = max(maxZ, v.z())
}
return Bounds(Vector3f(minX, minY, minZ), Vector3f(maxX, maxY, maxZ))
}
private fun AIMesh.indexCount(): Int {
var indexCount = 0
val faces = mFaces()
for (i in 0 until mNumFaces()) {
indexCount += faces[i].mNumIndices()
}
return indexCount
}
private fun toTrs(m: AIMatrix4x4): TRS {
val mat = Matrix4f(
m.a1(), m.b1(), m.c1(), m.d1(),
m.a2(), m.b2(), m.c2(), m.d2(),
m.a3(), m.b3(), m.c3(), m.d3(),
m.a4(), m.b4(), m.c4(), m.d4()
)
val t = Vector3f()
val r = afield.math.linalg.Quaternionf()
val s = Vector3f()
mat.getTranslation(t)
mat.getUnnormalizedRotation(r)
mat.getScale(s)
return TRS(t, r.normalize(), s)
}
private fun attributes(mesh: AIMesh): String {
val attrs = mutableListOf<String>()
if (mesh.mNormals() != null) attrs += "normals"
if (mesh.mTextureCoords(0) != null) attrs += "uv0"
if (mesh.mColors(0) != null) attrs += "color0"
if (mesh.mTangents() != null && mesh.mBitangents() != null) attrs += "tangent"
if (attrs.isEmpty()) attrs += "position"
return attrs.joinToString(", ")
}
private fun textureTypes(): List<Int> = listOf(
Assimp.aiTextureType_BASE_COLOR,
Assimp.aiTextureType_DIFFUSE,
Assimp.aiTextureType_NORMALS,
Assimp.aiTextureType_EMISSIVE,
Assimp.aiTextureType_METALNESS,
Assimp.aiTextureType_DIFFUSE_ROUGHNESS
)
private fun textureTypeName(type: Int): String = when (type) {
Assimp.aiTextureType_BASE_COLOR -> "baseColor"
Assimp.aiTextureType_DIFFUSE -> "diffuse"
Assimp.aiTextureType_NORMALS -> "normal"
Assimp.aiTextureType_EMISSIVE -> "emissive"
Assimp.aiTextureType_METALNESS -> "metallic"
Assimp.aiTextureType_DIFFUSE_ROUGHNESS -> "roughness"
else -> "type $type"
}
private fun vec3(v: Vector3f): String = "(%.3f, %.3f, %.3f)".format(v.x, v.y, v.z)
private fun quat(q: afield.math.linalg.Quaternionf): String =
"(%.3f, %.3f, %.3f, %.3f)".format(q.x, q.y, q.z, q.w)
private fun fmtBytes(n: Long): String {
if (n < 1024) return "$n B"
val kb = n / 1024.0
if (kb < 1024) return String.format("%.1f KiB", kb)
val mb = kb / 1024.0
return String.format("%.2f MiB", mb)
}
private fun keyTimes(buf: AIVectorKey.Buffer?, count: Int): List<Double> {
if (buf == null || count <= 0) return emptyList()
val out = ArrayList<Double>(count)
for (i in 0 until count) out += buf[i].mTime()
return out
}
private fun keyTimes(buf: AIQuatKey.Buffer?, count: Int): List<Double> {
if (buf == null || count <= 0) return emptyList()
val out = ArrayList<Double>(count)
for (i in 0 until count) out += buf[i].mTime()
return out
}
private fun aggregateTimes(anim: AIAnimation): Triple<List<Double>, List<Double>, List<Double>> {
val pos = mutableListOf<Double>()
val rot = mutableListOf<Double>()
val scale = mutableListOf<Double>()
anim.mChannels()?.let { buf ->
for (i in 0 until anim.mNumChannels()) {
val ch = AINodeAnim.create(buf[i])
pos += keyTimes(ch.mPositionKeys(), ch.mNumPositionKeys())
rot += keyTimes(ch.mRotationKeys(), ch.mNumRotationKeys())
scale += keyTimes(ch.mScalingKeys(), ch.mNumScalingKeys())
}
}
return Triple(pos, rot, scale)
}
private fun renderTimeline(
positionKeys: List<Double>,
rotationKeys: List<Double>,
scaleKeys: List<Double>,
durationSeconds: Double?
): String? {
val all = positionKeys + rotationKeys + scaleKeys
if (all.isEmpty()) return null
val minT = all.minOrNull() ?: 0.0
val maxT = (all.maxOrNull() ?: 0.0).let { m ->
// Prefer provided duration if longer
if (durationSeconds != null && durationSeconds > m) durationSeconds else m
}
val range = (maxT - minT).coerceAtLeast(1e-6)
val tracks = listOf(
Triple("pos", positionKeys, "#38bdf8"),
Triple("rot", rotationKeys, "#a78bfa"),
Triple("scl", scaleKeys, "#f97316")
)
val width = 240.0
val height = 70
val leftPad = 6.0
fun x(t: Double): Double = leftPad + ((t - minT) / range) * width
val legend = tracks.joinToString(" · ") { (label, keys, color) ->
"<span style='color:$color;'>$label:${keys.size}</span>"
}
val trackLines = tracks.mapIndexed { idx, (label, keys, color) ->
val y = 20.0 + idx * 16
val markers = keys.joinToString("") { t ->
val cx = x(t)
"<circle cx='${"%.1f".format(cx)}' cy='${"%.1f".format(y)}' r='3' fill='$color' />"
}
"""
<line x1="${leftPad}" y1="${"%.1f".format(y)}" x2="${leftPad + width}" y2="${"%.1f".format(y)}" stroke="#374151" stroke-width="1"/>
$markers
<text x="${leftPad + width + 6}" y="${"%.1f".format(y + 4)}" fill="#9ca3af" font-size="10" font-family="JetBrains Mono, monospace">$label</text>
""".trimIndent()
}.joinToString("\n")
val startLabel = "%.2f".format(minT)
val endLabel = "%.2f".format(maxT)
return """
<div style="padding:4px 0;">
<div style="color:#9ca3af;font-size:11px;letter-spacing:.05em;text-transform:uppercase;margin-bottom:2px;">timeline · $legend</div>
<svg width="${(leftPad + width + 80).toInt()}" height="$height" viewBox="0 0 ${leftPad + width + 80} $height" style="background:#0b1220;border:1px solid #111827;border-radius:6px;">
<text x="${leftPad}" y="12" fill="#6b7280" font-size="10" font-family="JetBrains Mono, monospace">$startLabel</text>
<text x="${leftPad + width - 26}" y="12" fill="#6b7280" font-size="10" font-family="JetBrains Mono, monospace">$endLabel</text>
$trackLines
</svg>
</div>
""".trimIndent()
}
private fun card(title: String, rows: List<Pair<String, String>>, accent: String = "#9ca3af"): String {
val body = rows.joinToString("") { (k, v) ->
"""
<div style="display:flex;gap:6px;align-items:flex-start;padding:1px 0;">
<div style="flex:0 0 80px;text-align:right;color:#6b7280;font-size:11px;letter-spacing:.05em;text-transform:uppercase;">${escapeHtml(k)}</div>
<div style="flex:0 0 6px;text-align:center;color:$accent;">|</div>
<div style="flex:1;">${ToStringHighlighter.render(v)}</div>
</div>
""".trimIndent()
}
return """
<div style="padding:4px 0;font-family:inherit;">
$body
</div>
""".trimIndent()
}
private fun escapeHtml(s: String): String =
s.replace("&", "&").replace("<", "<").replace(">", ">")