Skip to content

SlugPath.kt

src/jvmMain/kotlin/afield/graphics/text/slug/SlugPath.kt · 129 lines

Compiles a filled, flat FLine into Slughorn atlas data (SlugPath) by running the native field-slug helper, built with ./gradlew buildSlugNative.

SlugPath.compile runs once per geometry edit, not per frame, and refuses paths that leave z=0. The result is immutable and carries no placement; SlugBuffer uploads it to the GPU. SlugPathTest covers it.

package afield.graphics.text.slug

import afield.geometry.path.FLine
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.TimeUnit

class SlugPath private constructor(private val packed: IntArray)

One filled XY path compiled into immutable Slughorn data. Placement is external.

class SlugPath private constructor(private val packed: IntArray) {

val words: IntArray get() = packed.copyOf()

A defensive copy; mutating it cannot change a validated path.

val curveCount: Int get() = packed[20]

val byteSize: Long get() = packed.size * 4L

companion object

    companion object {

fun compile(line: FLine, executable: File, tolerance: Float = 1e-4f): SlugPath

Preparation prototype: runs the native helper once per geometry edit, not per frame. Projects no coordinates: rejects paths outside z=0 to avoid flattening 3D silently. Includes all subpaths, with nonzero winding and implicit fill closure. tolerance is in the FLine's coordinate units.

        fun compile(line: FLine, executable: File, tolerance: Float = 1e-4f): SlugPath {
            require(tolerance.isFinite() && tolerance > 0)
            require(executable.isFile && executable.canExecute()) { "Missing field-slug executable: $executable (build it with ./gradlew buildSlugNative)" }
            val commands = commands(line, tolerance)
            // File redirects prevent pipe deadlock for large binary output. A production JNI
            // bridge can replace this boundary without changing the GPU layout.
            val dir = java.nio.file.Files.createTempDirectory("field-slug-").toFile()
            try {
                val input = File(dir, "path.txt").apply { writeText(commands, Charsets.UTF_8) }
                val output = File(dir, "data.bin")
                val errors = File(dir, "errors.txt")
                val process = ProcessBuilder(executable.absolutePath)
                    .redirectInput(input).redirectOutput(output).redirectError(errors).start()
                try {
                    check(process.waitFor(30, TimeUnit.SECONDS)) { "Slug preparation exceeded 30 seconds" }
                    check(process.exitValue() == 0) { errors.readText() }
                } finally {
                    if (process.isAlive) { process.destroyForcibly(); process.waitFor(5, TimeUnit.SECONDS) }
                }
                return decode(output.readBytes())
            } finally { dir.deleteRecursively() }
        }

internal fun commands(line: FLine, tolerance: Float): String

        internal fun commands(line: FLine, tolerance: Float): String {
            require(tolerance.isFinite() && tolerance > 0) { "Tolerance must be finite and positive" }
            require(line.nodes.any { it is FLine.LineTo || it is FLine.CubicTo }) { "Empty fill geometry" }
            require(line.nodes.firstOrNull() is FLine.MoveTo) { "A filled path must begin with moveTo" }
            return buildString {
                append("T ").append(tolerance).append('\n')
                for (node in line.nodes) {
                    node.storage.forEach { p ->
                        require(p.x.isFinite() && p.y.isFinite() && p.z.isFinite())
                        require(p.z == 0f) { "SlugPath prototype requires an XY FLine; transform the rendered plane afterward" }
                    }
                    when (node) {
                        is FLine.MoveTo -> append("M ${node.to.x} ${node.to.y}\n")
                        is FLine.LineTo -> append("L ${node.to.x} ${node.to.y}\n")
                        is FLine.CubicTo -> append("C ${node.c1.x} ${node.c1.y} ${node.c2.x} ${node.c2.y} ${node.to.x} ${node.to.y}\n")
                        is FLine.Close -> append("Z\n")
                    }
                }
            }
        }

internal fun decode(bytes: ByteArray): SlugPath

body — 53 lines, from line 76
        internal fun decode(bytes: ByteArray): SlugPath {
            require(bytes.size >= 96 && bytes.size % 4 == 0) { "Truncated Slug data" }
            val b = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer()
            val words = IntArray(b.remaining()).also { b.get(it) }
            require(words[0] == 0x534c5547 && words[1] == 1) { "Unsupported Slug format" }
            require(words[2] == 512 && words[4] == 512) { "Prototype shaders require 512-wide atlases" }
            require(words[3] > 0 && words[5] > 0 && words[6] == 24 && words[20] > 0)
            require(words[21].toLong() == words[2].toLong() * words[3] * 16)
            require(words[22].toLong() == words[4].toLong() * words[5] * 4)
            require(words[7].toLong() == 24L + words[21] / 4)
            require(bytes.size.toLong() == 96L + words[21] + words[22])
            // Validate everything the shaders can address before publishing a GPU buffer.
            for (i in 8..15) require(Float.fromBits(words[i]).isFinite()) { "Non-finite Slug metrics" }
            require(Float.fromBits(words[8]) < Float.fromBits(words[10]) &&
                Float.fromBits(words[9]) < Float.fromBits(words[11])) { "Degenerate Slug bounds" }
            require(Float.fromBits(words[12]) > 0 && Float.fromBits(words[13]) > 0) { "Invalid band scale" }
            val curveTexels = words[21] / 16
            val bandTexels = words[22] / 4
            require(words[18] in 0..31 && words[19] in 0..31) { "Invalid band count" }
            require(words[16] in 0 until 512 && words[17] in 0 until words[5]) { "Invalid band origin" }
            val origin = words[17] * 512L + words[16]
            val headers = 64 + words[18] + words[19] + 2
            require(words[16] + headers <= 512 && origin + headers <= bandTexels) { "Band headers exceed atlas" }
            fun band(at: Long): Int {
                require(at in 0 until bandTexels.toLong()) { "Band lookup exceeds atlas" }
                return words[words[7] + at.toInt()]
            }
            for (q in 0 until 32) {
                require((band(origin + q) and 65535) <= words[19]) { "Invalid horizontal indirection" }
                require((band(origin + 32 + q) and 65535) <= words[18]) { "Invalid vertical indirection" }
            }
            for (h in 64 until headers) {
                val header = band(origin + h)
                val count = header and 65535
                val offset = header ushr 16
                require(count <= words[20] && origin + offset + count <= bandTexels) { "Invalid band curve list" }
                if (count > 0) require(offset >= headers) { "Curve list overlaps band headers" }
                for (c in 0 until count) {
                    val xy = band(origin + offset + c)
                    val x = xy and 65535
                    val y = xy ushr 16
                    val texel = y * 512L + x
                    require(x < 512 && texel + 1 < curveTexels) { "Curve lookup exceeds atlas" }
                    val at = words[6] + texel.toInt() * 4
                    for (component in 0 until 6) {
                        require(Float.fromBits(words[at + component]).isFinite()) { "Non-finite curve data" }
                    }
                }
            }
            return SlugPath(words)
        }
    }
}