Skip to content

LoadPly.kt

src/jvmMain/kotlin/afield/graphics/scene/LoadPly.kt · 371 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")

package afield.graphics.scene

import afield.graphics.render.before

import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer

Reads one element of a PLY file as a flat table of floats, addressed by property name.

This is the column-oriented half of Field's PLY support, and the Gaussian splat path reads through it: gaussianSplats and SplatCloud.read both arrive at readPlyAsFloatTable, then ask for the columns they want by name. Every scalar property is widened to a float whatever it was on disk, and list properties are read past and dropped, so a mesh's faces do not survive the trip. For triangles, and a module to draw them with, load through PlySupport.loadPly instead.

A compressed PlayCanvas splat file is recognised from its header and decompressed on the way through, so a caller sees the same columns either way; see CompressedSplatPly.

data class PlyFloatTable

One PLY element as rows of floats: a shared buffer, a stride in floats, and the column each property name sits at.

Nothing here copies. A column is an offset into a row, so reading a value is one index calculation, and subsetFloatBufferColumns is the one method that allocates.

data class PlyFloatTable(
    /** Column index per property name, in the order the file's header declared them. */
    val offsets: Map<String, Int>,
    /** Floats per row, which is the number of scalar properties the element had. */
    val stride: Int,
    /** Every row end to end, `rowCount` × [stride] floats. */
    val buffer: FloatBuffer
) {

val columns: List<String>

The property names, in column order.

    val columns: List<String>
        get() = offsets.entries.sortedBy { it.value }.map { it.key }

val rowCount: Int

How many of the element's entries the buffer holds. Throws if its length is not a whole number of rows.

    val rowCount: Int
        get() {
            require(stride > 0) { "PLY float table has no scalar properties" }
            val totalFloats = buffer.limit()
            require(totalFloats % stride == 0) {
                "Buffer length ($totalFloats) is not a multiple of stride ($stride)"
            }
            return totalFloats / stride
        }

fun hasColumn(name: String): Boolean = name in offsets

Whether the element carried this property, which is how an optional column is tested for.

fun columnOffset(name: String): Int

This property's column index, or an error naming every column the file does have.

    fun columnOffset(name: String): Int =
        offsets[name] ?: throw IllegalArgumentException("Field '$name' not found. Available fields: ${columns.joinToString()}")

fun floatAt(row: Int, field: String): Float

One value, by row and property name. Bounds-checked on the row, and throws on an unknown name.

    fun floatAt(row: Int, field: String): Float {
        require(row in 0 until rowCount) { "row $row out of bounds for $rowCount-row PLY table" }
        return buffer.get(row * stride + columnOffset(field))
    }

fun floatAtOrNull(row: Int, field: String): Float?

The same, returning null for a column the file does not have rather than throwing.

    fun floatAtOrNull(row: Int, field: String): Float? =
        offsets[field]?.let { buffer.get(row * stride + it) }

fun firstFloatAtOrNull(row: Int, fields: Iterable<String>): Float?

The first of these property names the file has. PLY spells some quantities more than one way, colour as red or diffuse_red and transparency as alpha or diffuse_alpha, and this takes whichever of a list is present.

    fun firstFloatAtOrNull(row: Int, fields: Iterable<String>): Float? {
        for (field in fields) {
            val value = floatAtOrNull(row, field)
            if (value != null) return value
        }
        return null
    }

fun requireColumns(vararg fields: String): PlyFloatTable

Returns this table if it has all of these columns, and otherwise throws naming both the missing ones and the columns the file does have. The splat readers call it as the first thing they do with a table, so a wrong file fails by name rather than by arithmetic.

    fun requireColumns(vararg fields: String): PlyFloatTable {
        val missing = fields.filterNot(offsets::containsKey)
        require(missing.isEmpty()) {
            "PLY table is missing required field(s): ${missing.joinToString()}. Available fields: ${columns.joinToString()}"
        }
        return this
    }

fun subsetFloatBufferColumns(selectedFields: List<String>): FloatBuffer

A fresh buffer holding only these columns, in the order asked for, packed to that narrower stride. Nothing in the tree calls this yet; it is here for narrowing a wide table down to the few columns a GPU buffer wants.

    fun subsetFloatBufferColumns(selectedFields: List<String>): FloatBuffer {
        val rows = rowCount
        val newColCount = selectedFields.size
        val newBytes = rows * newColCount * Float.SIZE_BYTES
        val newBb = ByteBuffer.allocateDirect(newBytes).order(buffer.order())
        val newFb = newBb.asFloatBuffer()

        for (row in 0 until rows) {
            val baseIdx = row * stride
            for (field in selectedFields) {
                val colIdx = columnOffset(field)
                newFb.put(buffer.get(baseIdx + colIdx))
            }
        }

        newFb.flip()
        return newFb
    }
}

internal enum class PlyTableFormat

internal enum class PlyTableFormat {
    ASCII, BINARY_LITTLE_ENDIAN, BINARY_BIG_ENDIAN
}

internal data class PlyTableProperty

internal data class PlyTableProperty(
    val name: String,
    val type: String,
    val isList: Boolean = false,
    val listCountType: String? = null,
    val listElementType: String? = null
)

internal data class PlyTableElement

internal data class PlyTableElement(
    val name: String,
    val count: Int,
    val properties: List<PlyTableProperty>
)

internal data class PlyTableHeader

internal data class PlyTableHeader(
    val format: PlyTableFormat,
    val elements: List<PlyTableElement>,
    val dataStart: Long
)

fun readPlyAsFloatTable

Read one PLY element into a flat PlyFloatTable, one row per element, every scalar property widened to a float.

PlayCanvas .compressed.ply splat files are recognised by their schema and decompressed transparently when elementName is the default "vertex", so a caller gets the same x/y/z, f_dc_*, opacity, rot_*, scale_* columns it would have got from the uncompressed file. See CompressedSplatPly. Ask for "chunk" or "sh" by name to read a compressed file's raw quantization tables instead.

@param includeSphericalHarmonics only meaningful for compressed splat files — see CompressedSplatPly.readCompressedSplatPly.

body — 54 lines, from line 162
fun readPlyAsFloatTable(
    path: String,
    elementName: String = "vertex",
    includeSphericalHarmonics: Boolean = false
): PlyFloatTable {
    val file = File(path)
    require(file.exists()) { "PLY file not found: $path" }

    RandomAccessFile(file, "r").use { raf ->
        val header = parseFloatTableHeader(raf)

        // A compressed splat file's "vertex" element is four packed 32-bit words, which the float
        // reader below would happily coerce into four meaningless floats. Divert before that.
        if (elementName == "vertex") {
            when (val recognition = CompressedSplatPly.recognise(header)) {
                is CompressedSplatPly.Recognition.Compressed -> return CompressedSplatPly.decompress(
                    raf = raf,
                    header = header,
                    layout = recognition.layout,
                    includeSphericalHarmonics = includeSphericalHarmonics,
                    path = path
                )

                is CompressedSplatPly.Recognition.Malformed ->
                    error("'$path' looks like a compressed splat PLY but ${recognition.reason}")

                CompressedSplatPly.Recognition.Plain -> Unit
            }
        }

        val target = header.elements.find { it.name == elementName }
            ?: error("PLY file '$path' does not contain element '$elementName'")
        val scalarProperties = target.properties.filterNot { it.isList }
        require(scalarProperties.isNotEmpty()) { "PLY element '$elementName' has no scalar properties" }

        val offsets = scalarProperties.mapIndexed { index, property -> property.name to index }.toMap()
        val outBytes = target.count.toLong() * scalarProperties.size.toLong() * Float.SIZE_BYTES.toLong()
        require(outBytes <= Int.MAX_VALUE) {
            "PLY element '$elementName' is too large for a single FloatBuffer: $outBytes bytes"
        }
        val out = ByteBuffer.allocateDirect(outBytes.toInt())
            .order(if (header.format == PlyTableFormat.BINARY_BIG_ENDIAN) ByteOrder.BIG_ENDIAN else ByteOrder.LITTLE_ENDIAN)
            .asFloatBuffer()

        when (header.format) {
            PlyTableFormat.ASCII -> parseAsciiFloatTable(raf, header, elementName, out)
            PlyTableFormat.BINARY_LITTLE_ENDIAN -> parseBinaryFloatTable(raf, header, elementName, out, ByteOrder.LITTLE_ENDIAN)
            PlyTableFormat.BINARY_BIG_ENDIAN -> parseBinaryFloatTable(raf, header, elementName, out, ByteOrder.BIG_ENDIAN)
        }

        out.flip()
        return PlyFloatTable(offsets = offsets, stride = scalarProperties.size, buffer = out)
    }
}

fun testPly(fn: String): PlyFloatTable

Reads a file and prints its columns, row count and float count. A look at a PLY, from a box.

fun testPly(fn: String): PlyFloatTable {
    val table = readPlyAsFloatTable(fn)
    println("Columns (${table.stride}): ${table.columns}")
    println("Rows loaded: ${table.rowCount}")
    println("Total floats loaded: ${table.buffer.limit()}")
    return table
}

internal fun parseFloatTableHeader(raf: RandomAccessFile): PlyTableHeader

body — 145 lines, from line 226
internal fun parseFloatTableHeader(raf: RandomAccessFile): PlyTableHeader {
    val magic = raf.readLine()?.trim() ?: error("Empty PLY file")
    require(magic == "ply") { "File does not start with 'ply' magic number" }

    var format: PlyTableFormat? = null
    val elements = mutableListOf<PlyTableElement>()
    var currentElement: PlyTableElement? = null
    val currentProperties = mutableListOf<PlyTableProperty>()

    fun flushElement() {
        currentElement?.let {
            elements += it.copy(properties = currentProperties.toList())
            currentProperties.clear()
        }
    }

    while (true) {
        val line = raf.readLine()?.trim() ?: throw IOException("Unexpected EOF in PLY header")
        if (line.isEmpty() || line.startsWith("comment") || line.startsWith("obj_info")) continue

        val parts = line.split(Regex("\\s+"))
        when (parts[0]) {
            "format" -> {
                format = when (parts.getOrNull(1)) {
                    "ascii" -> PlyTableFormat.ASCII
                    "binary_little_endian" -> PlyTableFormat.BINARY_LITTLE_ENDIAN
                    "binary_big_endian" -> PlyTableFormat.BINARY_BIG_ENDIAN
                    else -> error("Unknown PLY format: ${parts.getOrNull(1)}")
                }
            }

            "element" -> {
                require(parts.size >= 3) { "Malformed PLY element line: '$line'" }
                flushElement()
                currentElement = PlyTableElement(parts[1], parts[2].toInt(), emptyList())
            }

            "property" -> {
                require(currentElement != null) { "PLY property appears before any element: '$line'" }
                if (parts.getOrNull(1) == "list") {
                    require(parts.size >= 5) { "Malformed PLY list property line: '$line'" }
                    currentProperties += PlyTableProperty(
                        name = parts[4],
                        type = "",
                        isList = true,
                        listCountType = parts[2],
                        listElementType = parts[3]
                    )
                } else {
                    require(parts.size >= 3) { "Malformed PLY property line: '$line'" }
                    currentProperties += PlyTableProperty(name = parts[2], type = parts[1])
                }
            }

            "end_header" -> {
                flushElement()
                return PlyTableHeader(
                    format = format ?: error("No PLY format specified"),
                    elements = elements,
                    dataStart = raf.filePointer
                )
            }
        }
    }
}

private fun parseAsciiFloatTable(
    raf: RandomAccessFile,
    header: PlyTableHeader,
    elementName: String,
    out: FloatBuffer
) {
    raf.seek(header.dataStart)
    for (element in header.elements) {
        repeat(element.count) {
            val line = raf.readLine()?.trim() ?: error("Unexpected EOF while reading '${element.name}'")
            if (line.isEmpty()) return@repeat
            val values = line.split(Regex("\\s+"))
            var valueIndex = 0
            for (property in element.properties) {
                if (property.isList) {
                    val count = parseAsciiScalar(values[valueIndex++], property.listCountType!!).toInt()
                    repeat(count) { valueIndex++ }
                } else {
                    val value = parseAsciiScalar(values[valueIndex++], property.type)
                    if (element.name == elementName) out.put(value)
                }
            }
        }
    }
}

private fun parseBinaryFloatTable(
    raf: RandomAccessFile,
    header: PlyTableHeader,
    elementName: String,
    out: FloatBuffer,
    byteOrder: ByteOrder
) {
    raf.seek(header.dataStart)
    val remainingBytes = raf.length() - header.dataStart
    val bytes = ByteArray(remainingBytes.toInt())
    raf.readFully(bytes)
    val data = ByteBuffer.wrap(bytes).order(byteOrder)

    for (element in header.elements) {
        repeat(element.count) {
            for (property in element.properties) {
                if (property.isList) {
                    val count = readBinaryScalar(data, property.listCountType!!).toInt()
                    repeat(count) { readBinaryScalar(data, property.listElementType!!) }
                } else {
                    val value = readBinaryScalar(data, property.type)
                    if (element.name == elementName) out.put(value)
                }
            }
        }
    }
}

private fun parseAsciiScalar(token: String, type: String): Float =
    when (type) {
        "char", "int8",
        "uchar", "uint8",
        "short", "int16",
        "ushort", "uint16",
        "int", "int32",
        "uint", "uint32" -> token.toLong().toFloat()
        "float", "float32",
        "double", "float64" -> token.toDouble().toFloat()
        else -> error("Unsupported PLY property type: $type")
    }

private fun readBinaryScalar(buffer: ByteBuffer, type: String): Float =
    when (type) {
        "char", "int8" -> buffer.get().toFloat()
        "uchar", "uint8" -> (buffer.get().toInt() and 0xff).toFloat()
        "short", "int16" -> buffer.short.toFloat()
        "ushort", "uint16" -> (buffer.short.toInt() and 0xffff).toFloat()
        "int", "int32" -> buffer.int.toFloat()
        "uint", "uint32" -> (buffer.int.toLong() and 0xffffffffL).toFloat()
        "float", "float32" -> buffer.float
        "double", "float64" -> buffer.double.toFloat()
        else -> error("Unsupported PLY property type: $type")
    }