MorseSmaleNative.kt
src/jvmMain/kotlin/afield/geometry/topology/morsesmale/MorseSmaleNative.kt · 231 lines
Finding field_morse_smale on this machine, and calling into it once found.
MorseSmaleLibrary 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-morse-smale/src/field_morse_smale.h with java.lang.foreign: one lazy downcall
handle per fms_ entry point, and a 512-byte buffer that building a complex and simplifying one
carry down and read a message out of when they hand back nothing. The counts and read-outs take
no error buffer. The first symbol lookup checks fms_abi_version against the version this
binding expects and refuses a library that disagrees, naming the task that rebuilds it.
Two kinds of native object pass through here, and only one of them is the caller's to free. Building a complex hands back an address that must be freed exactly once, and reading it copies the critical points, the cancellation order, the persistence pairs and the stage timings out into Kotlin arrays in one pass. Simplifying at a threshold builds a second native object, a view; that call copies the surviving nodes, the separatrix arcs with their traced paths, and the basin and peak segmentation out of it, then frees the view before returning.
MorseSmale is the API over it, and its only caller.
package afield.geometry.topology.morsesmale
import afield.geometry.network.NativeGeometryCapability
import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
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_FLOAT
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 MorseSmaleLibrary : NativeGeometryCapability
Where field_morse_smale is, and whether it is there. Field runs without it; describe says how
to build it.
MorseSmaleLibrary.describe()
override val name = "morseSmale"
override val title = "Morse–Smale complexes"
override val docsPage = "morse_smale.md"
override val buildTask = "buildMorseSmaleNative"
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 buildMorseSmaleNative"),
"linux" to listOf("sudo apt install build-essential cmake # or dnf / pacman equivalents", "./gradlew buildMorseSmaleNative"),
"windows" to listOf("winget install -e --id Kitware.CMake # and Visual Studio C++ Build Tools", ".\\gradlew buildMorseSmaleNative"),
)
private val os = System.getProperty("os.name").lowercase()
val fileName: String
val fileName: String
get() = when {
os.contains("win") -> "field_morse_smale.dll"
os.contains("mac") || os.contains("darwin") -> "libfield_morse_smale.dylib"
else -> "libfield_morse_smale.so"
}
val searchedPaths: List<Path>
Every path resolvedPath tries, in order.
val searchedPaths: List<Path>
get() = listOfNotNull(
System.getProperty("afield.morsesmale.library")?.takeIf { it.isNotBlank() },
System.getenv("AFIELD_MORSE_SMALE_LIBRARY")?.takeIf { it.isNotBlank() },
).map(Path::of) + listOf(
Path.of("build", "field-morse-smale", "build", fileName).toAbsolutePath(),
Path.of("build", "field-morse-smale", "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 buildMorseSmaleNative` (needs cmake and a C++17 compiler, no other " +
"dependencies), or point -Dafield.morsesmale.library / AFIELD_MORSE_SMALE_LIBRARY at a $fileName."
override fun describe(): String
override fun describe(): String = resolvedPath?.let {
runCatching { "morseSmale: ${MorseSmaleNative.version()} at $it" }
.getOrElse { e -> "morseSmale: found $it but cannot use it: ${e.message}" }
} ?: "morseSmale: not built. $HOW_TO_BUILD"
}
internal object MorseSmaleNative
The C ABI in native/field-morse-smale/src/field_morse_smale.h, bound with java.lang.foreign.
const val ABI_VERSION = 2
source — 50 lines, from line 103
private const val ERROR_CAPACITY = 512
private val linker: Linker = Linker.nativeLinker()
private val lookup: SymbolLookup by lazy {
val path = MorseSmaleLibrary.resolvedPath ?: throw UnsatisfiedLinkError(
"field_morse_smale is not built. ${MorseSmaleLibrary.HOW_TO_BUILD}\nLooked at: " +
MorseSmaleLibrary.searchedPaths.joinToString(", "),
)
SymbolLookup.libraryLookup(path, Arena.global()).also { l ->
val abi = linker.downcallHandle(
l.find("fms_abi_version").orElseThrow { UnsatisfiedLinkError("$path has no fms_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 buildMorseSmaleNative.",
)
}
}
private fun handle(name: String, descriptor: FunctionDescriptor): MethodHandle = linker.downcallHandle(
lookup.find(name).orElseThrow {
UnsatisfiedLinkError("${MorseSmaleLibrary.fileName} has no symbol $name; rebuild it with ./gradlew buildMorseSmaleNative")
},
descriptor,
)
private val versionString by lazy { handle("fms_version_string", FunctionDescriptor.of(ADDRESS)) }
private val build by lazy {
handle("fms_build", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, JAVA_INT, JAVA_INT, ADDRESS, JAVA_INT))
}
private val free by lazy { handle("fms_free", FunctionDescriptor.ofVoid(ADDRESS)) }
private val timings by lazy { handle("fms_timings", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
private val criticalCount by lazy { handle("fms_critical_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val criticalPoints by lazy {
handle("fms_critical_points", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS))
}
private val cancellationCount by lazy { handle("fms_cancellation_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val cancellations by lazy { handle("fms_cancellations", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS)) }
private val pairCount by lazy { handle("fms_pair_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val pairs by lazy { handle("fms_pairs", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)) }
private val simplify by lazy { handle("fms_simplify", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_DOUBLE, ADDRESS, JAVA_INT)) }
private val freeView by lazy { handle("fms_free_view", FunctionDescriptor.ofVoid(ADDRESS)) }
private val viewNodeCount by lazy { handle("fms_view_node_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val viewNodes by lazy { handle("fms_view_nodes", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
private val viewArcCount by lazy { handle("fms_view_arc_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val viewPathTotal by lazy { handle("fms_view_arc_path_total", FunctionDescriptor.of(JAVA_LONG, ADDRESS)) }
private val viewArcs by lazy { handle("fms_view_arcs", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS)) }
private val viewPaths by lazy { handle("fms_view_arc_paths", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
private val viewSegmentation by lazy { handle("fms_view_segmentation", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS)) }
fun version(): String
fun version(): String = (versionString.invokeExact() as MemorySegment).reinterpret(Long.MAX_VALUE).getString(0)
fun build(values: FloatArray, offset: Int, width: Int, height: Int, pixelStride: Int, rowStride: Int): MemorySegment
Builds a complex; the returned address must be passed to free exactly once.
fun build(values: FloatArray, offset: Int, width: Int, height: Int, pixelStride: Int, rowStride: Int): MemorySegment =
Arena.ofConfined().use { arena ->
val data = arena.allocate(JAVA_FLOAT, (values.size - offset).toLong())
MemorySegment.copy(values, offset, data, JAVA_FLOAT, 0, values.size - offset)
val error = arena.allocate(JAVA_BYTE, ERROR_CAPACITY.toLong())
val c = build.invokeExact(data, width, height, pixelStride, rowStride, error, ERROR_CAPACITY) as MemorySegment
if (c.address() == 0L) throw IllegalArgumentException("Morse–Smale: " + error.getString(0))
c
}
fun free(c: MemorySegment) { free.invokeExact(c) }
class Base
class Base(
val cell: IntArray, val type: IntArray, val value: FloatArray, val cancelledAt: DoubleArray, val xy: FloatArray,
val cancelSaddle: IntArray, val cancelExtremum: IntArray, val cancelPersistence: DoubleArray,
val pairBirth: IntArray, val pairDeath: IntArray, val pairDimension: IntArray, val pairPersistence: DoubleArray,
val timings: DoubleArray,
)
fun read(c: MemorySegment): Base
fun read(c: MemorySegment): Base = Arena.ofConfined().use { a ->
val n = criticalCount.invokeExact(c) as Int
val cell = a.allocate(JAVA_INT, n.toLong()); val type = a.allocate(JAVA_INT, n.toLong())
val value = a.allocate(JAVA_FLOAT, n.toLong()); val at = a.allocate(JAVA_DOUBLE, n.toLong())
val xy = a.allocate(JAVA_FLOAT, 2L * n)
criticalPoints.invokeExact(c, cell, type, value, at, xy)
val k = cancellationCount.invokeExact(c) as Int
val cs = a.allocate(JAVA_INT, k.toLong()); val ce = a.allocate(JAVA_INT, k.toLong()); val cp = a.allocate(JAVA_DOUBLE, k.toLong())
cancellations.invokeExact(c, cs, ce, cp)
val p = pairCount.invokeExact(c) as Int
val pb = a.allocate(JAVA_INT, p.toLong()); val pd = a.allocate(JAVA_INT, p.toLong())
val pk = a.allocate(JAVA_INT, p.toLong()); val pp = a.allocate(JAVA_DOUBLE, p.toLong())
pairs.invokeExact(c, pb, pd, pk, pp)
val t = a.allocate(JAVA_DOUBLE, 4)
timings.invokeExact(c, t)
Base(
cell.toArray(JAVA_INT), type.toArray(JAVA_INT), value.toArray(JAVA_FLOAT), at.toArray(JAVA_DOUBLE), xy.toArray(JAVA_FLOAT),
cs.toArray(JAVA_INT), ce.toArray(JAVA_INT), cp.toArray(JAVA_DOUBLE),
pb.toArray(JAVA_INT), pd.toArray(JAVA_INT), pk.toArray(JAVA_INT), pp.toArray(JAVA_DOUBLE),
t.toArray(JAVA_DOUBLE),
)
}
class View
class View(
val nodes: IntArray, val arcSaddle: IntArray, val arcExtremum: IntArray, val arcKind: IntArray,
val arcLength: IntArray, val paths: FloatArray, val basin: IntArray, val peak: IntArray,
)
fun simplify(c: MemorySegment, threshold: Double, gridSize: Int): View
Simplifies, copies the view out, and frees it before returning.
fun simplify(c: MemorySegment, threshold: Double, gridSize: Int): View = Arena.ofConfined().use { a ->
val error = a.allocate(JAVA_BYTE, ERROR_CAPACITY.toLong())
val v = simplify.invokeExact(c, threshold, error, ERROR_CAPACITY) as MemorySegment
if (v.address() == 0L) throw IllegalArgumentException("Morse–Smale simplify: " + error.getString(0))
try {
val n = viewNodeCount.invokeExact(v) as Int
val nodes = a.allocate(JAVA_INT, n.toLong())
viewNodes.invokeExact(v, nodes)
val m = viewArcCount.invokeExact(v) as Int
val total = viewPathTotal.invokeExact(v) as Long
val s = a.allocate(JAVA_INT, m.toLong()); val e = a.allocate(JAVA_INT, m.toLong())
val k = a.allocate(JAVA_INT, m.toLong()); val l = a.allocate(JAVA_INT, m.toLong())
viewArcs.invokeExact(v, s, e, k, l)
val xy = a.allocate(JAVA_FLOAT, 2 * total)
viewPaths.invokeExact(v, xy)
val basin = a.allocate(JAVA_INT, gridSize.toLong()); val peak = a.allocate(JAVA_INT, gridSize.toLong())
viewSegmentation.invokeExact(v, basin, peak)
View(
nodes.toArray(JAVA_INT), s.toArray(JAVA_INT), e.toArray(JAVA_INT), k.toArray(JAVA_INT), l.toArray(JAVA_INT),
xy.toArray(JAVA_FLOAT), basin.toArray(JAVA_INT), peak.toArray(JAVA_INT),
)
} finally {
freeView.invokeExact(v)
}
}
}