Skip to content

PrincipalCurvesNative.kt

src/jvmMain/kotlin/afield/geometry/curves/principal/PrincipalCurvesNative.kt · 277 lines

Finding field_principal_curves on this machine, and calling into it once found.

PrincipalCurvesLibrary is the half that answers without loading anything: the library's file name on this OS, the paths it is looked for in, whether one of them exists, and the Gradle task and per-OS shell commands that build it. NativeGeometry registers it as a NativeGeometryCapability, so the welcome page's Geometry extensions panel can report on the library whether or not it is installed. The path is resolved afresh on every read, so a library built during a live session is picked up by the next box run.

The other half, internal to the package, binds the C ABI declared in native/field-principal-curves/src/field_principal_curves.h with java.lang.foreign: one lazy downcall handle per fpc_ entry point, the solver's parameter struct laid out by hand with its padding, and a 512-byte buffer that each call which can fail carries down and reads a message out of when it reports failure. Reading the fit back out (the node and edge arrays, the energies, the per-point assignments, the per-node statistics) takes no error buffer. The first symbol lookup checks fpc_abi_version against the version this binding expects and refuses a library that disagrees, naming the task that rebuilds it.

PrincipalCurves is the API over it, and its only caller.

package afield.geometry.curves.principal

import afield.geometry.network.NativeGeometryCapability
import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemoryLayout
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout.ADDRESS
import java.lang.foreign.ValueLayout.JAVA_BYTE
import java.lang.foreign.ValueLayout.JAVA_DOUBLE
import java.lang.foreign.ValueLayout.JAVA_INT
import java.lang.foreign.ValueLayout.JAVA_LONG
import java.lang.invoke.MethodHandle
import java.nio.file.Path
import kotlin.io.path.exists

object PrincipalCurvesLibrary : NativeGeometryCapability

Where field_principal_curves is, and whether it is there. Field runs without it; describe says how to build it.

PrincipalCurvesLibrary.describe()

override val name = "principalCurves"

override val title = "Principal curves"

override val docsPage = "principal_curves.md"

override val buildTask = "buildPrincipalCurvesNative"

override val install: Map<String, List<String>>

    override val install: Map<String, List<String>> = mapOf(
        "macos" to listOf("xcode-select --install", "brew install cmake", "./gradlew buildPrincipalCurvesNative"),
        "linux" to listOf("sudo apt install build-essential cmake    # or dnf / pacman equivalents", "./gradlew buildPrincipalCurvesNative"),
        "windows" to listOf("winget install -e --id Kitware.CMake    # and Visual Studio C++ Build Tools", ".\\gradlew buildPrincipalCurvesNative"),
    )

    private val os = System.getProperty("os.name").lowercase()

val fileName: String

    val fileName: String
        get() = when {
            os.contains("win") -> "field_principal_curves.dll"
            os.contains("mac") || os.contains("darwin") -> "libfield_principal_curves.dylib"
            else -> "libfield_principal_curves.so"
        }

val searchedPaths: List<Path>

Every path resolvedPath tries, in order.

    val searchedPaths: List<Path>
        get() = listOfNotNull(
            System.getProperty("afield.principalcurves.library")?.takeIf { it.isNotBlank() },
            System.getenv("AFIELD_PRINCIPAL_CURVES_LIBRARY")?.takeIf { it.isNotBlank() },
        ).map(Path::of) + listOf(
            Path.of("build", "field-principal-curves", "build", fileName).toAbsolutePath(),
            Path.of("build", "field-principal-curves", "build", "Release", fileName).toAbsolutePath(),
        )

val resolvedPath: Path? get() = searchedPaths.firstOrNull { it.exists() }

Not cached: a library built during a live session is picked up by the next box run.

override val isAvailable: Boolean get() = resolvedPath != null

val HOW_TO_BUILD: String

    val HOW_TO_BUILD: String
        get() = "Build it with `./gradlew buildPrincipalCurvesNative` (needs cmake and a C++17 compiler, no other " +
            "dependencies), or point -Dafield.principalcurves.library / AFIELD_PRINCIPAL_CURVES_LIBRARY at a $fileName."

override fun describe(): String

    override fun describe(): String = resolvedPath?.let {
        runCatching { "principalCurves: ${PrincipalCurvesNative.version()} at $it" }
            .getOrElse { e -> "principalCurves: found $it but cannot use it: ${e.message}" }
    } ?: "principalCurves: not built. $HOW_TO_BUILD"
}

internal object PrincipalCurvesNative

The C ABI in native/field-principal-curves/src/field_principal_curves.h, bound with java.lang.foreign.

const val ABI_VERSION = 2

    private const val ERROR_CAPACITY = 512

const val BISECT = 1

const val EXTEND = 2

const val BRANCH = 4

    private val linker: Linker = Linker.nativeLinker()

fpc_params: four doubles, an int (padded), a double, two ints, a uint64

source — 53 lines, from line 106
    private val PARAMS = MemoryLayout.structLayout(
        JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_INT, MemoryLayout.paddingLayout(4),
        JAVA_DOUBLE, JAVA_INT, JAVA_INT, JAVA_LONG,
    )

    private val lookup: SymbolLookup by lazy {
        val path = PrincipalCurvesLibrary.resolvedPath ?: throw UnsatisfiedLinkError(
            "field_principal_curves is not built. ${PrincipalCurvesLibrary.HOW_TO_BUILD}\nLooked at: " +
                PrincipalCurvesLibrary.searchedPaths.joinToString(", "),
        )
        SymbolLookup.libraryLookup(path, Arena.global()).also { l ->
            val abi = linker.downcallHandle(
                l.find("fpc_abi_version").orElseThrow { UnsatisfiedLinkError("$path has no fpc_abi_version") },
                FunctionDescriptor.of(JAVA_INT),
            ).invokeExact() as Int
            if (abi != ABI_VERSION) throw UnsatisfiedLinkError(
                "$path speaks ABI $abi, Field expects $ABI_VERSION. Rebuild it with ./gradlew buildPrincipalCurvesNative.",
            )
        }
    }

    private fun handle(name: String, descriptor: FunctionDescriptor): MethodHandle = linker.downcallHandle(
        lookup.find(name).orElseThrow {
            UnsatisfiedLinkError("${PrincipalCurvesLibrary.fileName} has no symbol $name; rebuild it with ./gradlew buildPrincipalCurvesNative")
        },
        descriptor,
    )

    private val E = arrayOf(ADDRESS, JAVA_INT)  // error, capacity
    private fun withError(vararg args: java.lang.foreign.MemoryLayout) = (args.toList() + E).toTypedArray()

    private val versionString by lazy { handle("fpc_version_string", FunctionDescriptor.of(ADDRESS)) }
    private val create by lazy { handle("fpc_create", FunctionDescriptor.of(ADDRESS, *withError(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT, ADDRESS))) }
    private val setPoints by lazy { handle("fpc_set_points", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, ADDRESS, ADDRESS, JAVA_INT))) }
    private val free by lazy { handle("fpc_free", FunctionDescriptor.ofVoid(ADDRESS)) }
    private val setParams by lazy { handle("fpc_set_params", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, ADDRESS))) }
    private val setGraph by lazy { handle("fpc_set_graph", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT, ADDRESS, JAVA_INT, ADDRESS))) }
    private val initComponents by lazy { handle("fpc_init_components", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT, JAVA_INT))) }
    private val initTree by lazy { handle("fpc_init_tree", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT))) }
    private val setNode by lazy { handle("fpc_set_node", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT, ADDRESS, JAVA_INT))) }
    private val splitEdge by lazy { handle("fpc_split_edge", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT, JAVA_DOUBLE))) }
    private val fit by lazy { handle("fpc_fit", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT))) }
    private val grow by lazy { handle("fpc_grow", FunctionDescriptor.of(JAVA_INT, *withError(ADDRESS, JAVA_INT, JAVA_INT))) }
    private val nodeCount by lazy { handle("fpc_node_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
    private val edgeCount by lazy { handle("fpc_edge_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
    private val nodes by lazy { handle("fpc_nodes", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS)) }
    private val edges by lazy { handle("fpc_edges", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
    private val energy by lazy { handle("fpc_energy", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
    private val pointCount by lazy { handle("fpc_point_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
    private val assignments by lazy { handle("fpc_assignments", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)) }
    private val nodeStats by lazy {
        handle("fpc_node_stats", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS))
    }

fun version(): String

    fun version(): String = (versionString.invokeExact() as MemorySegment).reinterpret(Long.MAX_VALUE).getString(0)

    private fun params(a: Arena, p: ElasticParameters): MemorySegment = a.allocate(PARAMS).also { s ->
        s.set(JAVA_DOUBLE, 0, p.lambda); s.set(JAVA_DOUBLE, 8, p.mu); s.set(JAVA_DOUBLE, 16, p.trimmingRadius)
        s.set(JAVA_DOUBLE, 24, p.branchPenalty); s.set(JAVA_INT, 32, p.maxIterations); s.set(JAVA_DOUBLE, 40, p.eps)
        s.set(JAVA_INT, 48, p.candidateSample); s.set(JAVA_INT, 52, p.threads); s.set(JAVA_LONG, 56, p.seed)
    }

    private inline fun <T> call(what: String, body: (Arena, MemorySegment) -> T): T = Arena.ofConfined().use { a ->
        val error = a.allocate(JAVA_BYTE, ERROR_CAPACITY.toLong())
        body(a, error)
    }

    private fun check(status: Int, what: String, error: MemorySegment): Int {
        if (status < 0) throw IllegalArgumentException("principal curves $what: " + error.getString(0))
        return status
    }

fun create(dimension: Int, points: DoubleArray, weights: DoubleArray?, p: ElasticParameters): MemorySegment

    fun create(dimension: Int, points: DoubleArray, weights: DoubleArray?, p: ElasticParameters): MemorySegment = call("create") { a, e ->
        val n = points.size / dimension
        val pts = a.allocateFrom(JAVA_DOUBLE, *points)
        val w = weights?.let { a.allocateFrom(JAVA_DOUBLE, *it) } ?: MemorySegment.NULL
        val f = create.invokeExact(dimension, pts, w, n, params(a, p), e, ERROR_CAPACITY) as MemorySegment
        if (f.address() == 0L) throw IllegalArgumentException("principal curves: " + e.getString(0))
        f
    }

fun free(f: MemorySegment) { free.invokeExact(f) }

fun setPoints(f: MemorySegment, dimension: Int, points: DoubleArray, weights: DoubleArray?)

    fun setPoints(f: MemorySegment, dimension: Int, points: DoubleArray, weights: DoubleArray?) = call("points") { a, e ->
        val w = weights?.let { a.allocateFrom(JAVA_DOUBLE, *it) } ?: MemorySegment.NULL
        check(setPoints.invokeExact(f, a.allocateFrom(JAVA_DOUBLE, *points), w, points.size / dimension, e, ERROR_CAPACITY) as Int, "points", e)
    }

fun setParams(f: MemorySegment, p: ElasticParameters)

    fun setParams(f: MemorySegment, p: ElasticParameters) = call("parameters") { a, e ->
        check(setParams.invokeExact(f, params(a, p), e, ERROR_CAPACITY) as Int, "parameters", e)
    }

fun setGraph(f: MemorySegment, nodePositions: DoubleArray, k: Int, edgeList: IntArray)

    fun setGraph(f: MemorySegment, nodePositions: DoubleArray, k: Int, edgeList: IntArray) = call("graph") { a, e ->
        check(setGraph.invokeExact(f, k, a.allocateFrom(JAVA_DOUBLE, *nodePositions), edgeList.size / 2,
            if (edgeList.isEmpty()) MemorySegment.NULL else a.allocateFrom(JAVA_INT, *edgeList), e, ERROR_CAPACITY) as Int, "graph", e)
    }

fun initComponents(f: MemorySegment, components: Int, nodesEach: Int)

    fun initComponents(f: MemorySegment, components: Int, nodesEach: Int) = call("init") { _, e ->
        check(initComponents.invokeExact(f, components, nodesEach, e, ERROR_CAPACITY) as Int, "initialisation", e)
    }

fun initTree(f: MemorySegment, k: Int)

    fun initTree(f: MemorySegment, k: Int) = call("init") { _, e ->
        check(initTree.invokeExact(f, k, e, ERROR_CAPACITY) as Int, "initialisation", e)
    }

fun setNode(f: MemorySegment, node: Int, position: DoubleArray?, fixed: Boolean)

    fun setNode(f: MemorySegment, node: Int, position: DoubleArray?, fixed: Boolean) = call("node") { a, e ->
        check(setNode.invokeExact(f, node, position?.let { a.allocateFrom(JAVA_DOUBLE, *it) } ?: MemorySegment.NULL,
            if (fixed) 1 else 0, e, ERROR_CAPACITY) as Int, "node edit", e)
    }

fun splitEdge(f: MemorySegment, edge: Int, t: Double): Int

    fun splitEdge(f: MemorySegment, edge: Int, t: Double): Int = call("split") { _, e ->
        check(splitEdge.invokeExact(f, edge, t, e, ERROR_CAPACITY) as Int, "split", e)
    }

fun fit(f: MemorySegment, iterations: Int): Int

    fun fit(f: MemorySegment, iterations: Int): Int = call("fit") { _, e ->
        check(fit.invokeExact(f, iterations, e, ERROR_CAPACITY) as Int, "fit", e)
    }

fun grow(f: MemorySegment, target: Int, grammar: Int): Int

    fun grow(f: MemorySegment, target: Int, grammar: Int): Int = call("grow") { _, e ->
        check(grow.invokeExact(f, target, grammar, e, ERROR_CAPACITY) as Int, "grow", e)
    }

class GraphState(val positions: DoubleArray, val fixed: IntArray, val edges: IntArray)

fun graph(f: MemorySegment, dimension: Int): GraphState

    fun graph(f: MemorySegment, dimension: Int): GraphState = Arena.ofConfined().use { a ->
        val k = nodeCount.invokeExact(f) as Int
        val m = edgeCount.invokeExact(f) as Int
        val pos = a.allocate(JAVA_DOUBLE, (k.toLong() * dimension).coerceAtLeast(1))
        val fx = a.allocate(JAVA_INT, k.toLong().coerceAtLeast(1))
        val ed = a.allocate(JAVA_INT, (2L * m).coerceAtLeast(1))
        nodes.invokeExact(f, pos, fx)
        edges.invokeExact(f, ed)
        GraphState(
            pos.asSlice(0, 8L * k * dimension).toArray(JAVA_DOUBLE),
            fx.asSlice(0, 4L * k).toArray(JAVA_INT),
            ed.asSlice(0, 8L * m).toArray(JAVA_INT),
        )
    }

fun energy(f: MemorySegment): DoubleArray

    fun energy(f: MemorySegment): DoubleArray = Arena.ofConfined().use { a ->
        val out = a.allocate(JAVA_DOUBLE, 5)
        energy.invokeExact(f, out)
        out.toArray(JAVA_DOUBLE)
    }

class Assign(val node: IntArray, val edge: IntArray, val t: DoubleArray, val distance: DoubleArray)

    class Assign(val node: IntArray, val edge: IntArray, val t: DoubleArray, val distance: DoubleArray)

fun assignments(f: MemorySegment): Assign

    fun assignments(f: MemorySegment): Assign = Arena.ofConfined().use { a ->
        val n = (pointCount.invokeExact(f) as Int).toLong()
        val nd = a.allocate(JAVA_INT, n); val ed = a.allocate(JAVA_INT, n)
        val t = a.allocate(JAVA_DOUBLE, n); val d = a.allocate(JAVA_DOUBLE, n)
        assignments.invokeExact(f, nd, ed, t, d)
        Assign(nd.toArray(JAVA_INT), ed.toArray(JAVA_INT), t.toArray(JAVA_DOUBLE), d.toArray(JAVA_DOUBLE))
    }

class Stats

    class Stats(
        val support: DoubleArray, val nEff: DoubleArray, val residual: DoubleArray, val spread: DoubleArray,
        val standardError: DoubleArray, val tangent: DoubleArray, val basis: DoubleArray,
    )

fun nodeStats(f: MemorySegment, dimension: Int): Stats

    fun nodeStats(f: MemorySegment, dimension: Int): Stats = Arena.ofConfined().use { a ->
        val k = (nodeCount.invokeExact(f) as Int).toLong().coerceAtLeast(1)
        val spreadWidth = if (dimension == 2) 1 else 3
        val su = a.allocate(JAVA_DOUBLE, k); val ne = a.allocate(JAVA_DOUBLE, k); val re = a.allocate(JAVA_DOUBLE, k)
        val sp = a.allocate(JAVA_DOUBLE, k * spreadWidth); val se = a.allocate(JAVA_DOUBLE, k)
        val ta = a.allocate(JAVA_DOUBLE, k * dimension); val ba = a.allocate(JAVA_DOUBLE, k * 6)
        nodeStats.invokeExact(f, su, ne, re, sp, se, ta, ba)
        Stats(su.toArray(JAVA_DOUBLE), ne.toArray(JAVA_DOUBLE), re.toArray(JAVA_DOUBLE), sp.toArray(JAVA_DOUBLE),
            se.toArray(JAVA_DOUBLE), ta.toArray(JAVA_DOUBLE), if (dimension == 3) ba.toArray(JAVA_DOUBLE) else DoubleArray(0))
    }
}