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")
}