Skip to content

PrincipalCurves.kt

src/jvmMain/kotlin/afield/geometry/curves/principal/PrincipalCurves.kt · 472 lines

package afield.geometry.curves.principal

import afield.geometry.network.Channel
import afield.geometry.network.GeometryCurve
import afield.geometry.network.GeometryNetwork
import afield.geometry.network.GeometryNodes
import afield.geometry.path.FLine
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import java.lang.foreign.MemorySegment
import java.lang.ref.Cleaner
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.math.sqrt

Elastic principal graphs: smooth curves, trees and forests fitted through a 2-D or 3-D point cloud, as a port of ElPiGraph.

PrincipalCurves is the entry point, and what it hands back is not a finished answer but an optimisation state. A PrincipalForest can be fitted again, grown with more nodes under one of the graph grammars Growth offers, have nodes moved and pinned, and be read back at any point along the way: as node positions and edges, as FLines, or as a GeometryNetwork whose channels carry the per-node measurements. So pinning a node, replacing the points under a fitted graph, or adding nodes to it continues from where the fit had reached rather than starting over.

ElasticParameters holds the energy the fit minimises, ElasticEnergy its value split by term, PointAssignments which node and edge each point ended up on, and NodeStatistics the spread of the points around each node, which is what the ribbon widths are drawn from.

The solver is the optional native library field_principal_curves (PrincipalCurvesLibrary), so a forest holds native memory, is closed by the box's next run, and PrincipalCurves.isAvailable reports whether the library loaded at all. The worked account is public/docs/principal_curves.md.

data class ElasticParameters

The weights of the elastic energy and the solver's controls.

Larger lambda and mu give shorter, smoother graphs that fit the points less closely. Both are relative to the mean squared distance from the points to the graph, so they mean the same thing at any scale only if the data's scale is similar; ElPiGraph's defaults (0.01, 0.1) suit data whose spread is a few units to a few hundred.

data class ElasticParameters(
    /** Penalises stretching — the squared length of every edge, and so total length. */
    val lambda: Double = 0.01,
    /**
     * Penalises bending: how far each node of degree two or more sits from the mean of its
     * neighbours.
     */
    val mu: Double = 0.1,
    /**
     * Makes the fit robust: points farther than this from every node stop pulling. 0 turns it off.
     */
    val trimmingRadius: Double = 0.0,
    /**
     * Added per unit of excess degree at branch points when choosing how to grow, so a branch must
     * reduce the energy by more than this to be kept.
     */
    val branchPenalty: Double = 0.0,
    /** EM iterations per fit. */
    val maxIterations: Int = 30,
    /** Convergence threshold, as a relative change of the node positions. */
    val eps: Double = 1e-3,
    /** How many points are sampled to score growth candidates; 0 scores against all of them. */
    val candidateSample: Int = 5000,
    /** Worker threads; 0 uses the hardware concurrency. */
    val threads: Int = 0,
    /** Seed for the candidate sampling and the k-means initialisation. */
    val seed: Long = 1,
)

enum class Growth(internal val flags: Int)

Which graph-grammar operations growth may use.

enum class Growth(internal val flags: Int) {

Split edges and extend leaves: every component stays a curve (or a loop).

    CURVES(PrincipalCurvesNative.BISECT or PrincipalCurvesNative.EXTEND),

Also add nodes to interior nodes, so components can branch into trees.

    TREES(PrincipalCurvesNative.BISECT or PrincipalCurvesNative.EXTEND or PrincipalCurvesNative.BRANCH),

Only split edges: the shape and topology stay, the curve gets more nodes.

    REFINE(PrincipalCurvesNative.BISECT),
}

data class ElasticEnergy(val total: Double, val approximation: Double, val stretching: Double, val bending: Double, val trimmedFraction: Double)

The elastic energy and its parts; trimmedFraction is the share of point weight beyond the trimming radius.

data class ElasticEnergy(val total: Double, val approximation: Double, val stretching: Double, val bending: Double, val trimmedFraction: Double)

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

Where each point went: its nearest node, the nearest edge at that node (-1 if trimmed or if the node has no edges), the parameter t along that edge from its first node, and its distance to the graph.

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

class NodeStatistics

Per-node statistics of the points projected onto the graph. Each point is shared between the two ends of its edge by its parameter.

spread is the variance of the points across the curve: one value per node in 2D, and in 3D a 2 × 2 covariance (uu, uv, vv) per node in the normal-plane basis basis (u then v, 6 values per node). width is its square root (in 3D, of uu + vv). standardError is how well the node's position is determined, sqrt(spread / nEff). Spread describes the data and standard error the fit: a broad, densely sampled ribbon has a large spread and a small error.

class NodeStatistics(
    val dimension: Int,
    val support: DoubleArray,
    val nEff: DoubleArray,
    val residual: DoubleArray,
    val spread: DoubleArray,
    val standardError: DoubleArray,
    val tangent: DoubleArray,
    val basis: DoubleArray,
) {

val width: DoubleArray

    val width: DoubleArray = DoubleArray(support.size) { i ->
        if (dimension == 2) sqrt(spread[i]) else sqrt(spread[3 * i] + spread[3 * i + 2])
    }
}

class PrincipalForest internal constructor

An elastic principal graph fitted to a point cloud, kept as an optimisation state: refine it, grow it, move or pin nodes, split edges, and read it back at any point.

val forest = PrincipalCurves.fit(points, dimension = 2, curves = 2, nodes = 40)
display("fit", forest.toFLines())
forest.moveNode(3, 120.0, 80.0)          // pin a node where you want it
forest.fit()                             // and let the rest settle around it

The forest holds native memory. One made while a box runs is closed when the box runs again; outside a box run, close it yourself.

class PrincipalForest internal constructor(
    val dimension: Int,
    points: DoubleArray,
    handle: MemorySegment,
    parameters: ElasticParameters,
) : AutoCloseable {
    private class Owner(var handle: MemorySegment?) : Runnable {
        override fun run() { synchronized(this) { handle?.let(PrincipalCurvesNative::free); handle = null } }
    }

    private val owner = Owner(handle)
    private val cleanable = CLEANER.register(this, owner)

    private fun <T> native(body: (MemorySegment) -> T): T = synchronized(owner) {
        body(owner.handle ?: throw IllegalStateException("this PrincipalForest has been closed"))
    }

    private var points: DoubleArray = points

val pointCount: Int get() = points.size / dimension

fun setPoints(points: DoubleArray, weights: DoubleArray? = null)

Replaces the points (and weights), keeping the graph, so that refine follows data that moves: a particle system, a stipple that changes, a camera feed's bright pixels.

    fun setPoints(points: DoubleArray, weights: DoubleArray? = null) = apply {
        require(points.size % dimension == 0) { "points must hold $dimension values per point" }
        weights?.let { require(it.size == points.size / dimension) { "one weight per point" } }
        native { PrincipalCurvesNative.setPoints(it, dimension, points, weights) }
        this.points = points.copyOf()
    }

var parameters: ElasticParameters

    var parameters: ElasticParameters = parameters
        set(value) { native { PrincipalCurvesNative.setParams(it, value) }; field = value }

fun initComponents(components: Int = 1, nodesEach: Int = 3)

Replaces the graph with components separate chains of nodesEach nodes. The points are clustered by single linkage (k-means centres joined by their minimum spanning tree, cut at its longest edges), and each chain starts along the longest path through its cluster, so curved strands are followed rather than cut across.

    fun initComponents(components: Int = 1, nodesEach: Int = 3) = apply { native { PrincipalCurvesNative.initComponents(it, components, nodesEach) } }

fun initTree(k: Int) = apply { native { PrincipalCurvesNative.initTree(it, k) } }

Replaces the graph with the minimum spanning tree of k k-means centres.

fun initGraph(nodePositions: DoubleArray, edges: IntArray)

Replaces the graph with one given as node positions (dimension per node) and edges (pairs of node indices).

    fun initGraph(nodePositions: DoubleArray, edges: IntArray) = apply {
        require(nodePositions.size % dimension == 0) { "node positions must be a multiple of $dimension" }
        native { PrincipalCurvesNative.setGraph(it, nodePositions, nodePositions.size / dimension, edges) }
    }

fun initFromFLines(lines: List<FLine>, nodesPerCurve: Int = 8)

Replaces the graph with lines: each subpath becomes a chain of nodesPerCurve nodes evenly spaced along it, closed subpaths become loops, and separate subpaths stay separate. Draw a few strokes, then fit or grow them onto the points.

    fun initFromFLines(lines: List<FLine>, nodesPerCurve: Int = 8) = apply {
        val net = GeometryNetwork.fromFLines(lines, dimension, samplesPerSegment = 16)
        val pos = ArrayList<Double>()
        val edges = ArrayList<Int>()
        net.curves.forEach { c ->
            val samples = resample(c, if (c.closed) nodesPerCurve else nodesPerCurve.coerceAtLeast(2))
            val base = pos.size / dimension
            samples.forEach { pos.add(it) }
            val count = samples.size / dimension
            for (i in 1 until count) { edges.add(base + i - 1); edges.add(base + i) }
            if (c.closed && count > 2) { edges.add(base + count - 1); edges.add(base) }
        }
        require(pos.isNotEmpty()) { "no subpaths with two or more points in these lines" }
        initGraph(pos.toDoubleArray(), edges.toIntArray())
    }

    private fun resample(c: GeometryCurve, count: Int): List<Double> {
        val n = c.vertexCount
        val cum = DoubleArray(n)
        for (i in 1 until n) cum[i] = cum[i - 1] + c.vertex(i).distance(c.vertex(i - 1)).toDouble()
        val total = cum.last() + if (c.closed) c.vertex(0).distance(c.vertex(n - 1)).toDouble() else 0.0
        val out = ArrayList<Double>()
        val steps = if (c.closed) count else count - 1
        for (s in 0 until count) {
            val target = total * s / steps
            var i = cum.indexOfLast { it <= target }.coerceIn(0, n - 1)
            val a = c.vertex(i)
            val b = if (i + 1 < n) c.vertex(i + 1) else c.vertex(0)
            val segment = (if (i + 1 < n) cum[i + 1] else total) - cum[i]
            val u = if (segment > 0) ((target - cum[i]) / segment).toFloat() else 0f
            out.add((a.x + (b.x - a.x) * u).toDouble()); out.add((a.y + (b.y - a.y) * u).toDouble())
            if (dimension == 3) out.add((a.z + (b.z - a.z) * u).toDouble())
        }
        return out
    }

fun fit(iterations: Int = 0): Int = native { PrincipalCurvesNative.fit(it, iterations) }

Fits node positions to the points without changing the topology. Returns the iterations used.

fun refine(iterations: Int = 5): Int = fit(iterations)

fit with a small iteration budget, for refining a little at a time (from a frame loop, say).

fun grow(nodes: Int, growth: Growth = Growth.CURVES): Int

Grows the graph by grammar operations until it has nodes nodes, choosing at each step the operation whose fitted configuration has the lowest energy. Returns the steps applied.

    fun grow(nodes: Int, growth: Growth = Growth.CURVES): Int = native { PrincipalCurvesNative.grow(it, nodes, growth.flags) }

fun moveNode(node: Int, x: Double, y: Double, z: Double = 0.0, fixed: Boolean = true)

Moves node to (x, y, z) and pins it there (unless fixed is false). Call fit to settle the rest.

    fun moveNode(node: Int, x: Double, y: Double, z: Double = 0.0, fixed: Boolean = true) = apply {
        val p = if (dimension == 2) doubleArrayOf(x, y) else doubleArrayOf(x, y, z)
        native { PrincipalCurvesNative.setNode(it, node, p, fixed) }
    }

fun fixNode(node: Int, fixed: Boolean = true)

Pins or frees node where it is.

    fun fixNode(node: Int, fixed: Boolean = true) = apply { native { PrincipalCurvesNative.setNode(it, node, null, fixed) } }

fun splitEdge(edge: Int, t: Double = 0.5): Int

Inserts a node on edge at parameter t (0..1 from its first node). Returns the new node's index.

    fun splitEdge(edge: Int, t: Double = 0.5): Int = native { PrincipalCurvesNative.splitEdge(it, edge, t) }

    private fun state() = native { PrincipalCurvesNative.graph(it, dimension) }

val nodePositions: DoubleArray get() = state().positions

Node positions, dimension doubles per node, in node-index order.

val edges: IntArray get() = state().edges

Edges as pairs of node indices.

val nodeCount: Int get() = state().fixed.size

fun energy(): ElasticEnergy

    fun energy(): ElasticEnergy = native { PrincipalCurvesNative.energy(it) }.let { ElasticEnergy(it[0], it[1], it[2], it[3], it[4]) }

fun assignments(): PointAssignments

    fun assignments(): PointAssignments = native { PrincipalCurvesNative.assignments(it) }.let { PointAssignments(it.node, it.edge, it.t, it.distance) }

fun nodeStatistics(): NodeStatistics

    fun nodeStatistics(): NodeStatistics = native { PrincipalCurvesNative.nodeStats(it, dimension) }.let {
        NodeStatistics(dimension, it.support, it.nEff, it.residual, it.spread, it.standardError, it.tangent, it.basis)
    }

val network: GeometryNetwork get() = buildNetwork(state(), nodeStatistics())

The graph as a GeometryNetwork. Nodes are the graph's branch points and endpoints (node index as id); curves are the chains between them, so a curve's interior vertices are graph nodes of degree two. A component with no endpoints is one closed curve. Per-vertex samples: width, spread, support, nEff, residual, standardError, graphNode, and in 3D covariance (uu, uv, vv). Node channels: degree, fixed.

source — 64 lines, from line 279
    private fun buildNetwork(s: PrincipalCurvesNative.GraphState, stats: NodeStatistics): GeometryNetwork {
        val k = s.fixed.size
        val adj = Array(k) { ArrayList<Pair<Int, Int>>() }  // (neighbour, edge)
        for (e in 0 until s.edges.size / 2) {
            adj[s.edges[2 * e]].add(s.edges[2 * e + 1] to e)
            adj[s.edges[2 * e + 1]].add(s.edges[2 * e] to e)
        }
        val ends = (0 until k).filter { adj[it].size != 2 }
        val used = BooleanArray(s.edges.size / 2)
        val chains = ArrayList<Triple<List<Int>, Int, Boolean>>()  // nodes, first edge, closed
        fun walk(start: Int, firstNeighbour: Int, firstEdge: Int): List<Int> {
            val path = arrayListOf(start)
            var prev = start; var cur = firstNeighbour; var edge = firstEdge
            used[edge] = true
            while (true) {
                path.add(cur)
                if (adj[cur].size != 2 || cur == start) break
                val next = adj[cur].first { it.second != edge }
                if (used[next.second]) break
                used[next.second] = true
                prev = cur; cur = next.first; edge = next.second
            }
            return path
        }
        ends.forEach { a -> adj[a].forEach { (b, e) -> if (!used[e]) chains.add(Triple(walk(a, b, e), e, false)) } }
        for (e in used.indices) if (!used[e]) {  // remaining edges belong to loops
            val a = s.edges[2 * e]; val b = s.edges[2 * e + 1]
            chains.add(Triple(walk(a, b, e), e, true))
        }
        val d = dimension
        val curves = chains.map { (path, firstEdge, closed) ->
            val verts = if (closed) path.dropLast(1) else path
            fun col(f: (Int) -> Double) = Channel.scalar(DoubleArray(verts.size) { f(verts[it]) })
            val samples = linkedMapOf(
                "width" to col { stats.width[it] },
                "support" to col { stats.support[it] },
                "nEff" to col { stats.nEff[it] },
                "residual" to col { stats.residual[it] },
                "standardError" to col { stats.standardError[it] },
                "graphNode" to col { it.toDouble() },
            )
            if (d == 2) samples["spread"] = col { stats.spread[it] }
            else samples["covariance"] = Channel(3, DoubleArray(verts.size * 3) { stats.spread[3 * verts[it / 3] + it % 3] })
            GeometryCurve(
                id = firstEdge, dimension = d,
                coordinates = DoubleArray(verts.size * d) { s.positions[verts[it / d] * d + it % d] },
                startNode = if (closed) null else path.first(), endNode = if (closed) null else path.last(),
                closed = closed, samples = samples,
                attributes = mapOf("meanWidth" to verts.map { stats.width[it] }.average()),
            )
        }
        val nodes = GeometryNodes(
            d, ends.toIntArray(), DoubleArray(ends.size * d) { s.positions[ends[it / d] * d + it % d] },
            mapOf(
                "degree" to Channel.scalar(DoubleArray(ends.size) { adj[ends[it]].size.toDouble() }),
                "fixed" to Channel.scalar(DoubleArray(ends.size) { s.fixed[ends[it]].toDouble() }),
            ),
        )
        val e = energy()
        return GeometryNetwork(d, nodes, curves, mapOf(
            "source" to "principalCurves", "energy" to e.total.toString(), "lambda" to parameters.lambda.toString(),
            "mu" to parameters.mu.toString(), "library" to PrincipalCurvesNative.version(),
        ))
    }

fun toFLines(smooth: Boolean = true, color: Vec4 = Vec4(0.9f, 0.3f, 0.1f, 1f), thickness: Double = 2.0): List<FLine>

The fitted curves as FLines (splines through the nodes with smooth), each carrying geometryNetwork.

    fun toFLines(smooth: Boolean = true, color: Vec4 = Vec4(0.9f, 0.3f, 0.1f, 1f), thickness: Double = 2.0): List<FLine> {
        val net = network
        return net.toFLines(smooth).onEach { it.properties["color"] = color; it.properties["thickness"] = thickness; it.properties["geometryNetwork"] = net }
    }

fun widthFLines(scale: Double = 2.0, color: Vec4 = Vec4(0.95f, 0.55f, 0.2f, 0.3f)): List<FLine>

2D only: a filled ribbon around each curve, scale standard deviations of the transverse spread to each side, so its width shows how spread out the points are along the curve.

    fun widthFLines(scale: Double = 2.0, color: Vec4 = Vec4(0.95f, 0.55f, 0.2f, 0.3f)): List<FLine> {
        require(dimension == 2) { "width ribbons are drawn in 2D; in 3D read nodeStatistics().spread" }
        return network.curves.filter { it.vertexCount >= 2 }.map { c ->
            val w = c.samples.getValue("width")
            val n = c.vertexCount
            fun normal(i: Int): Pair<Double, Double> {
                val a = c.vertex(maxOf(0, i - 1)); val b = c.vertex(minOf(n - 1, i + 1))
                val dx = (b.x - a.x).toDouble(); val dy = (b.y - a.y).toDouble()
                val len = sqrt(dx * dx + dy * dy).coerceAtLeast(1e-12)
                return -dy / len to dx / len
            }
            val left = (0 until n).map { i -> val (nx, ny) = normal(i); Vec3(c.vertex(i).x + (nx * w[i] * scale).toFloat(), c.vertex(i).y + (ny * w[i] * scale).toFloat(), 0f) }
            val right = (0 until n).map { i -> val (nx, ny) = normal(i); Vec3(c.vertex(i).x - (nx * w[i] * scale).toFloat(), c.vertex(i).y - (ny * w[i] * scale).toFloat(), 0f) }
            FLine().also { f ->
                f.moveTo(left[0]); left.drop(1).forEach { f.lineTo(it) }; right.reversed().forEach { f.lineTo(it) }; f.close()
                f.properties["filled"] = true; f.properties["stroked"] = false; f.properties["fill"] = color; f.properties["color"] = color
            }
        }
    }

fun assignmentFLines(every: Int = 1, color: Vec4 = Vec4(0.3f, 0.3f, 0.3f, 0.35f)): FLine

Short lines from every everyth point to where it projects onto the graph, coloured by edge.

    fun assignmentFLines(every: Int = 1, color: Vec4 = Vec4(0.3f, 0.3f, 0.3f, 0.35f)): FLine {
        val a = assignments()
        val s = state()
        val d = dimension
        val f = FLine()
        for (i in 0 until pointCount step every.coerceAtLeast(1)) {
            val e = a.edge[i]
            if (e < 0) continue
            val p = s.edges[2 * e]; val q = s.edges[2 * e + 1]; val t = a.t[i]
            fun at(j: Int) = (s.positions[p * d + j] * (1 - t) + s.positions[q * d + j] * t).toFloat()
            f.moveTo(points[i * d], points[i * d + 1], if (d == 3) points[i * d + 2] else 0.0)
            f.lineTo(at(0), at(1), if (d == 3) at(2) else 0f)
        }
        f.properties["color"] = color
        f.properties["thickness"] = 0.6
        return f
    }

fun pointsFLine(pointSize: Double = 2.0, color: Vec4 = Vec4(0.2f, 0.4f, 0.8f, 0.6f)): FLine

The input points as one FLine of dots, for display.

    fun pointsFLine(pointSize: Double = 2.0, color: Vec4 = Vec4(0.2f, 0.4f, 0.8f, 0.6f)): FLine = FLine().also { f ->
        for (i in 0 until pointCount) f.moveTo(points[i * dimension], points[i * dimension + 1], if (dimension == 3) points[i * dimension + 2] else 0.0)
        f.properties["pointed"] = true; f.properties["stroked"] = false; f.properties["pointSize"] = pointSize; f.properties["color"] = color
    }

val isClosed: Boolean get() = synchronized(owner) { owner.handle == null }

override fun close() = cleanable.clean()

override fun toString()

    override fun toString() = "PrincipalForest(${dimension}D, $pointCount points, $nodeCount nodes, ${edges.size / 2} edges)"

companion object

    companion object {
        private val CLEANER: Cleaner = Cleaner.create()
    }
}

object PrincipalCurves

Principal curves, trees and forests: smooth curves fitted through weighted 2D or 3D point clouds, as elastic principal graphs (a port of ElPiGraph). Needs the optional native library field_principal_curves (see PrincipalCurvesLibrary).

val forest = PrincipalCurves.fit(points, dimension = 2, curves = 3, nodes = 60)
display("fit", forest.pointsFLine() + forest.widthFLines() + forest.toFLines())
object PrincipalCurves {
    private val workers = Executors.newFixedThreadPool(2) { r -> Thread(r, "Field-principal-curves").apply { isDaemon = true } }

val isAvailable: Boolean get() = PrincipalCurvesLibrary.isAvailable

fun create(points: DoubleArray, dimension: Int = 2, weights: DoubleArray? = null, parameters: ElasticParameters = ElasticParameters()): PrincipalForest

A forest over points (dimension doubles per point) with no graph yet; initialise it, then fit or grow.

    fun create(points: DoubleArray, dimension: Int = 2, weights: DoubleArray? = null, parameters: ElasticParameters = ElasticParameters()): PrincipalForest {
        require(dimension == 2 || dimension == 3) { "dimension must be 2 or 3" }
        require(points.size % dimension == 0) { "points must hold $dimension values per point" }
        weights?.let { require(it.size == points.size / dimension) { "one weight per point" } }
        val forest = PrincipalForest(dimension, points.copyOf(), PrincipalCurvesNative.create(dimension, points, weights, parameters), parameters)
        RunScope.current()?.own(forest, ReleasePolicy.ACCUMULATING)
        return forest
    }

fun create(points: List<afield.math.linalg.Vec2>, weights: DoubleArray? = null, parameters: ElasticParameters = ElasticParameters()): PrincipalForest

A forest over 2D points.

    fun create(points: List<afield.math.linalg.Vec2>, weights: DoubleArray? = null, parameters: ElasticParameters = ElasticParameters()): PrincipalForest =
        create(DoubleArray(points.size * 2) { if (it % 2 == 0) points[it / 2].x.toDouble() else points[it / 2].y.toDouble() }, 2, weights, parameters)

fun fit

Fits curves curves (separate components, one per k-means cluster to start with) with nodes nodes in all. With allowBranching the components may grow into trees. With from, starts from those strokes instead, keeping their components and closed loops.

    fun fit(
        points: DoubleArray,
        dimension: Int = 2,
        weights: DoubleArray? = null,
        curves: Int = 1,
        nodes: Int = 30,
        allowBranching: Boolean = false,
        parameters: ElasticParameters = ElasticParameters(),
        from: List<FLine>? = null,
    ): PrincipalForest {
        val forest = create(points, dimension, weights, parameters)
        try {
            // Start each chain with half its share of the nodes: a chain of three can have its end
            // captured by a neighbouring strand during the first fit, and growth does not undo that.
            val each = maxOf(3, nodes / curves / 2)
            if (from != null) forest.initFromFLines(from, nodesPerCurve = maxOf(4, each)) else forest.initComponents(curves, each)
            forest.grow(maxOf(nodes, forest.nodeCount), if (allowBranching) Growth.TREES else Growth.CURVES)
        } catch (t: Throwable) {
            forest.close(); throw t
        }
        return forest
    }

fun fitAsync(points: DoubleArray, dimension: Int = 2, weights: DoubleArray? = null, curves: Int = 1, nodes: Int = 30,

fit on a worker thread, for runAsync. A forest made off the box's thread is yours to close.

    fun fitAsync(points: DoubleArray, dimension: Int = 2, weights: DoubleArray? = null, curves: Int = 1, nodes: Int = 30,
                 allowBranching: Boolean = false, parameters: ElasticParameters = ElasticParameters()): CompletableFuture<PrincipalForest> =
        CompletableFuture.supplyAsync({ fit(points, dimension, weights, curves, nodes, allowBranching, parameters) }, workers)
}