Skip to content

PolynomialTreeFitting.kt

src/jvmMain/kotlin/afield/geometry/path/PolynomialTreeFitting.kt · 2260 lines

package afield.geometry.path

import afield.lang.remotejs.JsFun

import afield.graphics.visionos.toFLines
import afield.math.linalg.Vec3
import afield.math.linalg.vec
import kotlin.math.acos
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.random.Random

Fits a List<Vec3> or an FLine with a tree of 3D polynomial curves, splitting a span until each leaf meets its error targets.

The fitPolynomialTree3D extensions take a PolynomialTreeFitConfig (polynomial order, depth and leaf limits, RMS and max-error targets) and returns a PolynomialTreeFitResult. Split, stop and claim rules are pluggable through PolynomialTreeSplitStrategies, PolynomialTreeStopCriteria and PolynomialTreeClaimCriteria; PolynomialLeafFLineStyles holds preset styles for drawing the fitted leaves as FLines.

data class Polynomial3Curve

Parametric 3D polynomial curve: x(t), y(t), z(t) for t in [0, 1].

data class Polynomial3Curve(
    val xCoefficients: DoubleArray,
    val yCoefficients: DoubleArray,
    val zCoefficients: DoubleArray
) {

init

    init {
        require(xCoefficients.isNotEmpty()) { "xCoefficients must not be empty" }
        require(xCoefficients.size == yCoefficients.size && yCoefficients.size == zCoefficients.size) {
            "Coefficient arrays must have equal lengths"
        }
    }

val order: Int

    val order: Int
        get() = xCoefficients.size - 1

fun evaluate(t: Double): Vec3

    fun evaluate(t: Double): Vec3 {
        val tc = t.coerceIn(0.0, 1.0)
        return vec(
            evalPolynomial(xCoefficients, tc).toFloat(),
            evalPolynomial(yCoefficients, tc).toFloat(),
            evalPolynomial(zCoefficients, tc).toFloat()
        )
    }

fun evaluate(t: Float): Vec3 = evaluate(t.toDouble())

fun sample(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): List<Vec3>

    fun sample(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): List<Vec3> {
        val count = samples.coerceAtLeast(2)
        val a = tStart.coerceIn(0.0, 1.0)
        val b = tEnd.coerceIn(0.0, 1.0)
        val out = ArrayList<Vec3>(count)
        for (i in 0 until count) {
            val alpha = if (count == 1) 0.0 else i.toDouble() / (count - 1).toDouble()
            val t = a + (b - a) * alpha
            out += evaluate(t)
        }
        return out
    }

fun toFLine(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): FLine

    fun toFLine(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): FLine {
        val pts = sample(samples = samples, tStart = tStart, tEnd = tEnd)
        val line = FLine()
        if (pts.isEmpty()) return line
        line.moveTo(pts.first())
        for (i in 1 until pts.size) line.lineTo(pts[i])
        return line
    }
}

data class PolynomialTreeNode

data class PolynomialTreeNode(
    val id: Int,
    val depth: Int,
    val sourceRange: IntRange,
    val supportIndices: IntArray,
    val polynomial: Polynomial3Curve,
    val supportRmsError: Double,
    val supportMeanError: Double,
    val supportMaxError: Double,
    val splitPointIndex: Int? = null,
    val stopReason: String,
    val claimedIndices: IntArray = IntArray(0),
    val claimedRmsError: Double? = null,
    val claimedMaxError: Double? = null,
    val left: PolynomialTreeNode? = null,
    val right: PolynomialTreeNode? = null
) {

val isLeaf: Boolean

    val isLeaf: Boolean
        get() = left == null && right == null

fun leaves(): List<PolynomialTreeNode>

    fun leaves(): List<PolynomialTreeNode> {
        if (isLeaf) return listOf(this)
        val out = ArrayList<PolynomialTreeNode>()
        fun walk(node: PolynomialTreeNode) {
            if (node.isLeaf) out += node
            else {
                node.left?.let(::walk)
                node.right?.let(::walk)
            }
        }
        walk(this)
        return out
    }
}

data class PolynomialTreeTraversalContext

data class PolynomialTreeTraversalContext(
    val node: PolynomialTreeNode,
    val depth: Int,
    val indexInLevel: Int,
    val parent: PolynomialTreeNode?
)

data class PolynomialTreeTraversalDecision

data class PolynomialTreeTraversalDecision(
    val emit: Boolean = true,
    val descend: Boolean = true,
    val stopAll: Boolean = false
)

data class PolynomialTreeTraversalLevel

data class PolynomialTreeTraversalLevel(
    val depth: Int,
    val nodes: List<PolynomialTreeTraversalContext>
) {

fun toFLines(samplesPerNode: Int = 64): List<FLine>

    fun toFLines(samplesPerNode: Int = 64): List<FLine> = toFLines { it.node.polynomial.toFLine(samplesPerNode) }

fun toFLines(mapper: (PolynomialTreeTraversalContext) -> FLine?): List<FLine> {

    fun toFLines(mapper: (PolynomialTreeTraversalContext) -> FLine?): List<FLine> {
        val out = ArrayList<FLine>(nodes.size)
        for (node in nodes) {
            val mapped = mapper(node)
            if (mapped != null) out += mapped
        }
        return out
    }
}

fun interface PolynomialTreeTraversalController

fun decide(context: PolynomialTreeTraversalContext): PolynomialTreeTraversalDecision

fun PolynomialTreeNode.levelTraversal

body — 42 lines, from line 149
fun PolynomialTreeNode.levelTraversal(
    controller: PolynomialTreeTraversalController = PolynomialTreeTraversalController { PolynomialTreeTraversalDecision() }
): Sequence<PolynomialTreeTraversalLevel> = sequence {
    data class QueuedNode(
        val node: PolynomialTreeNode,
        val depth: Int,
        val parent: PolynomialTreeNode?
    )

    var frontier = arrayListOf(QueuedNode(node = this@levelTraversal, depth = 0, parent = null))
    while (frontier.isNotEmpty()) {
        val depth = frontier.first().depth
        val emitted = ArrayList<PolynomialTreeTraversalContext>(frontier.size)
        val next = ArrayList<QueuedNode>(frontier.size * 2)
        var stopAll = false

        for ((indexInLevel, queued) in frontier.withIndex()) {
            val context = PolynomialTreeTraversalContext(
                node = queued.node,
                depth = queued.depth,
                indexInLevel = indexInLevel,
                parent = queued.parent
            )
            val decision = controller.decide(context)
            if (decision.emit) emitted += context
            if (decision.stopAll) {
                stopAll = true
                break
            }
            if (decision.descend) {
                queued.node.left?.let { next += QueuedNode(it, queued.depth + 1, queued.node) }
                queued.node.right?.let { next += QueuedNode(it, queued.depth + 1, queued.node) }
            }
        }

        if (emitted.isNotEmpty()) {
            yield(PolynomialTreeTraversalLevel(depth = depth, nodes = emitted))
        }
        if (stopAll) break
        frontier = next
    }
}

fun PolynomialTreeNode.approxCurveLength(samples: Int = 32): Double

fun PolynomialTreeNode.approxCurveLength(samples: Int = 32): Double {
    val pts = polynomial.sample(samples = samples.coerceAtLeast(2))
    if (pts.size < 2) return 0.0
    var length = 0.0
    for (i in 1 until pts.size) {
        length += distance(pts[i - 1], pts[i])
    }
    return length
}

data class PolynomialTreeFitResult

data class PolynomialTreeFitResult(
    val inputPoints: List<Vec3>,
    val config: PolynomialTreeFitConfig,
    val root: PolynomialTreeNode,
    val leaves: List<PolynomialTreeNode>,
    val pointErrors: DoubleArray,
    val nearestLeafIds: IntArray,
    val overallRmsError: Double,
    val overallMeanError: Double,
    val overallMaxError: Double
) {

fun leafById(id: Int): PolynomialTreeNode? = leaves.firstOrNull { it.id == id }

fun levelTraversal

    fun levelTraversal(
        controller: PolynomialTreeTraversalController = PolynomialTreeTraversalController { PolynomialTreeTraversalDecision() }
    ): Sequence<PolynomialTreeTraversalLevel> = root.levelTraversal(controller)

fun toLeafFLines(samplesPerLeaf: Int = 64): List<FLine>

    fun toLeafFLines(samplesPerLeaf: Int = 64): List<FLine> =
        toLeafFLines(PolynomialLeafFLineRenderConfig(samplesPerLeaf = samplesPerLeaf))

fun toLeafFLines(render: PolynomialLeafFLineRenderConfig): List<FLine>

    fun toLeafFLines(render: PolynomialLeafFLineRenderConfig): List<FLine> =
        buildLeafFLines(leaves, render)

fun toLeafFLineMap(samplesPerLeaf: Int = 64): Map<String, List<FLine>>

    fun toLeafFLineMap(samplesPerLeaf: Int = 64): Map<String, List<FLine>> = leaves.associate { leaf ->
        "poly_leaf_${leaf.id}" to listOf(leaf.polynomial.toFLine(samplesPerLeaf))
    }

fun toLeafFLineMap

    fun toLeafFLineMap(
        render: PolynomialLeafFLineRenderConfig,
        keyPrefix: String = "poly_leaf"
    ): Map<String, List<FLine>> {
        val leafRender = render.copy(outputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES)
        val ordered = orderLeaves(leaves, leafRender.orderMode)
        return ordered.associate { leaf ->
            "${keyPrefix}_${leaf.id}" to buildLeafFLines(listOf(leaf), leafRender)
        }
    }

fun incrementalUpdater(points: List<Vec3> = inputPoints): PolynomialTreeIncrementalUpdater

    fun incrementalUpdater(points: List<Vec3> = inputPoints): PolynomialTreeIncrementalUpdater =
        PolynomialTreeIncrementalUpdater(
            config = config,
            templateRoot = root,
            initialPoints = points
        )
}

enum class PolynomialLeafFLineOutputMode

enum class PolynomialLeafFLineOutputMode {
    SEPARATE_LINES,
    SINGLE_MULTIPATH,
    SINGLE_CONNECTED
}

enum class PolynomialLeafOrderMode

enum class PolynomialLeafOrderMode {
    SOURCE_ASCENDING,
    SOURCE_DESCENDING,
    ERROR_DESCENDING,
    ERROR_ASCENDING,
    DEPTH_ASCENDING,
    DEPTH_DESCENDING
}

data class PolynomialLeafGapStyle

data class PolynomialLeafGapStyle(
    val enabled: Boolean = false,
    val absolute: Double = 0.0,
    val fractionOfSpan: Double = 0.06,
    val trimStart: Boolean = true,
    val trimEnd: Boolean = true,
    val minAbsolute: Double = 0.0,
    val maxAbsolute: Double = Double.POSITIVE_INFINITY
) {

companion object

fun none(): PolynomialLeafGapStyle = PolynomialLeafGapStyle(enabled = false)

fun symmetric

        fun symmetric(
            absolute: Double = 0.0,
            fractionOfSpan: Double = 0.06,
            minAbsolute: Double = 0.0,
            maxAbsolute: Double = Double.POSITIVE_INFINITY
        ): PolynomialLeafGapStyle = PolynomialLeafGapStyle(
            enabled = true,
            absolute = absolute,
            fractionOfSpan = fractionOfSpan,
            trimStart = true,
            trimEnd = true,
            minAbsolute = minAbsolute,
            maxAbsolute = maxAbsolute
        )
    }
}

data class PolynomialLeafOvershootStyle

data class PolynomialLeafOvershootStyle(
    val enabled: Boolean = false,
    val startAbsolute: Double = 0.0,
    val endAbsolute: Double = 0.0,
    val startFractionOfSpan: Double = 0.0,
    val endFractionOfSpan: Double = 0.08,
    val maxAbsolute: Double = Double.POSITIVE_INFINITY
) {

companion object

fun none(): PolynomialLeafOvershootStyle = PolynomialLeafOvershootStyle(enabled = false)

fun symmetric

        fun symmetric(
            absolute: Double = 0.0,
            fractionOfSpan: Double = 0.08,
            maxAbsolute: Double = Double.POSITIVE_INFINITY
        ): PolynomialLeafOvershootStyle = PolynomialLeafOvershootStyle(
            enabled = true,
            startAbsolute = absolute,
            endAbsolute = absolute,
            startFractionOfSpan = fractionOfSpan,
            endFractionOfSpan = fractionOfSpan,
            maxAbsolute = maxAbsolute
        )
    }
}

data class PolynomialLeafSketchStyle

data class PolynomialLeafSketchStyle(
    val passes: Int = 1,
    val jitterAmplitude: Double = 0.0,
    val alongTangentFactor: Double = 0.25,
    val keepEndpoints: Boolean = true,
    val seed: Long = 1L
)

data class PolynomialLeafFLineRenderConfig

data class PolynomialLeafFLineRenderConfig(
    val samplesPerLeaf: Int = 64,
    val outputMode: PolynomialLeafFLineOutputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES,
    val orderMode: PolynomialLeafOrderMode = PolynomialLeafOrderMode.SOURCE_ASCENDING,
    val gap: PolynomialLeafGapStyle = PolynomialLeafGapStyle.none(),
    val overshoot: PolynomialLeafOvershootStyle = PolynomialLeafOvershootStyle.none(),
    val sketch: PolynomialLeafSketchStyle = PolynomialLeafSketchStyle()
)

object PolynomialLeafFLineStyles

fun clean

    fun clean(
        samplesPerLeaf: Int = 64,
        outputMode: PolynomialLeafFLineOutputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES
    ): PolynomialLeafFLineRenderConfig = PolynomialLeafFLineRenderConfig(
        samplesPerLeaf = samplesPerLeaf,
        outputMode = outputMode,
        gap = PolynomialLeafGapStyle.none(),
        overshoot = PolynomialLeafOvershootStyle.none(),
        sketch = PolynomialLeafSketchStyle()
    )

fun gapped

    fun gapped(
        samplesPerLeaf: Int = 64,
        gapAbsolute: Double = 0.0,
        gapFractionOfSpan: Double = 0.08,
        outputMode: PolynomialLeafFLineOutputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES
    ): PolynomialLeafFLineRenderConfig = PolynomialLeafFLineRenderConfig(
        samplesPerLeaf = samplesPerLeaf,
        outputMode = outputMode,
        gap = PolynomialLeafGapStyle.symmetric(
            absolute = gapAbsolute,
            fractionOfSpan = gapFractionOfSpan
        )
    )

fun overshooting

    fun overshooting(
        samplesPerLeaf: Int = 64,
        overshootAbsolute: Double = 0.0,
        overshootFractionOfSpan: Double = 0.1,
        outputMode: PolynomialLeafFLineOutputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES
    ): PolynomialLeafFLineRenderConfig = PolynomialLeafFLineRenderConfig(
        samplesPerLeaf = samplesPerLeaf,
        outputMode = outputMode,
        overshoot = PolynomialLeafOvershootStyle.symmetric(
            absolute = overshootAbsolute,
            fractionOfSpan = overshootFractionOfSpan
        )
    )

fun sketchy

    fun sketchy(
        samplesPerLeaf: Int = 72,
        passes: Int = 3,
        jitterAmplitude: Double = 0.25,
        gapFractionOfSpan: Double = 0.05,
        overshootFractionOfSpan: Double = 0.09,
        outputMode: PolynomialLeafFLineOutputMode = PolynomialLeafFLineOutputMode.SEPARATE_LINES
    ): PolynomialLeafFLineRenderConfig = PolynomialLeafFLineRenderConfig(
        samplesPerLeaf = samplesPerLeaf,
        outputMode = outputMode,
        gap = PolynomialLeafGapStyle.symmetric(fractionOfSpan = gapFractionOfSpan),
        overshoot = PolynomialLeafOvershootStyle.symmetric(fractionOfSpan = overshootFractionOfSpan),
        sketch = PolynomialLeafSketchStyle(
            passes = passes,
            jitterAmplitude = jitterAmplitude,
            alongTangentFactor = 0.35
        )
    )
}

fun interface PolynomialTreeSplitCriterion

fun chooseSplit(context: PolynomialTreeSplitContext): Int?

fun interface PolynomialTreeStopCriterion

fun shouldStop(context: PolynomialTreeStopContext): Boolean

fun interface PolynomialTreeClaimCriterion

fun accepts(context: PolynomialTreeClaimContext): Boolean

data class PolynomialTreeSplitContext

data class PolynomialTreeSplitContext(
    val points: List<Vec3>,
    val depth: Int,
    val supportIndices: IntArray,
    val supportRange: IntRange,
    val pointErrors: DoubleArray,
    val nearestT: DoubleArray,
    val rmsError: Double,
    val meanError: Double,
    val maxError: Double,
    val maxErrorLocalIndex: Int
) {

val size: Int

    val size: Int
        get() = supportIndices.size

fun middleLocalIndex(): Int = supportIndices.lastIndex / 2

fun globalIndexAt(localIndex: Int): Int

    fun globalIndexAt(localIndex: Int): Int = supportIndices[localIndex.coerceIn(0, supportIndices.lastIndex)]

fun localIndexOf(globalPointIndex: Int): Int = supportIndices.indexOf(globalPointIndex)

data class PolynomialTreeStopContext

data class PolynomialTreeStopContext(
    val split: PolynomialTreeSplitContext,
    val config: PolynomialTreeFitConfig
)

data class PolynomialTreeClaimContext

data class PolynomialTreeClaimContext(
    val pointIndex: Int,
    val point: Vec3,
    val leafId: Int,
    val leafRange: IntRange,
    val distance: Double,
    val rankByDistance: Int,
    val ownerLeafId: Int,
    val ownerDistance: Double,
    val bestDistance: Double
)

data class PolynomialTreeClaimConfig

data class PolynomialTreeClaimConfig(
    val enabled: Boolean = false,
    val criterion: PolynomialTreeClaimCriterion = PolynomialTreeClaimCriteria.distanceBelow(0.75),
    val includeSequentialOwner: Boolean = false,
    val maxClaimsPerPoint: Int = 1,
    val maxClaimsPerLeaf: Int = Int.MAX_VALUE,
    val refitLeaves: Boolean = false,
    val claimRefitWeight: Double = 0.35
)

data class PolynomialTreeFitConfig

data class PolynomialTreeFitConfig(
    val order: Int = 3,
    val maxDepth: Int = 8,
    val maxLeaves: Int = 64,
    val minPointsPerLeaf: Int = order + 2,
    val targetRmsError: Double = 1.0,
    val targetMaxError: Double = 2.0,
    val splitCriterion: PolynomialTreeSplitCriterion = PolynomialTreeSplitStrategies.maxError(),
    val stopCriterion: PolynomialTreeStopCriterion = PolynomialTreeStopCriteria.byThresholds,
    val distanceSamples: Int = 48,
    val distanceRefinementSteps: Int = 8,
    val regularization: Double = 1e-8,
    val claims: PolynomialTreeClaimConfig = PolynomialTreeClaimConfig()
)

object PolynomialTreeSplitStrategies

fun maxError(): PolynomialTreeSplitCriterion

    fun maxError(): PolynomialTreeSplitCriterion = PolynomialTreeSplitCriterion { ctx ->
        if (ctx.pointErrors.isEmpty()) null else ctx.globalIndexAt(ctx.maxErrorLocalIndex)
    }

fun midpoint(): PolynomialTreeSplitCriterion

    fun midpoint(): PolynomialTreeSplitCriterion = PolynomialTreeSplitCriterion { ctx ->
        if (ctx.supportIndices.isEmpty()) null else ctx.globalIndexAt(ctx.middleLocalIndex())
    }

fun hybrid(errorBias: Double = 0.7): PolynomialTreeSplitCriterion

    fun hybrid(errorBias: Double = 0.7): PolynomialTreeSplitCriterion = PolynomialTreeSplitCriterion { ctx ->
        if (ctx.supportIndices.isEmpty()) return@PolynomialTreeSplitCriterion null
        val e = errorBias.coerceIn(0.0, 1.0)
        val maxI = ctx.maxErrorLocalIndex.toDouble()
        val midI = ctx.middleLocalIndex().toDouble()
        val mixed = (e * maxI + (1.0 - e) * midI).roundToInt()
        ctx.globalIndexAt(mixed)
    }

fun curvatureAware(errorWeight: Double = 0.65, curvatureWeight: Double = 0.35): PolynomialTreeSplitCriterion

    fun curvatureAware(errorWeight: Double = 0.65, curvatureWeight: Double = 0.35): PolynomialTreeSplitCriterion =
        PolynomialTreeSplitCriterion { ctx ->
            if (ctx.supportIndices.size < 3) return@PolynomialTreeSplitCriterion midpoint().chooseSplit(ctx)
            val eW = errorWeight.coerceIn(0.0, 1.0)
            val cW = curvatureWeight.coerceIn(0.0, 1.0)
            val total = (eW + cW).coerceAtLeast(1e-9)
            val nwE = eW / total
            val nwC = cW / total

            val maxErr = ctx.pointErrors.maxOrNull()?.coerceAtLeast(1e-9) ?: 1.0
            var maxCurv = 1e-9
            val curvatures = DoubleArray(ctx.supportIndices.size)
            for (i in 1 until ctx.supportIndices.lastIndex) {
                val prev = ctx.points[ctx.supportIndices[i - 1]]
                val cur = ctx.points[ctx.supportIndices[i]]
                val next = ctx.points[ctx.supportIndices[i + 1]]
                val curvature = turningAngle(prev, cur, next)
                curvatures[i] = curvature
                maxCurv = max(maxCurv, curvature)
            }

            var bestLocal = ctx.middleLocalIndex()
            var bestScore = Double.NEGATIVE_INFINITY
            for (i in 1 until ctx.supportIndices.lastIndex) {
                val e = ctx.pointErrors[i] / maxErr
                val c = curvatures[i] / maxCurv
                val score = nwE * e + nwC * c
                if (score > bestScore) {
                    bestScore = score
                    bestLocal = i
                }
            }
            ctx.globalIndexAt(bestLocal)
        }
}

object PolynomialTreeStopCriteria

val byThresholds

    val byThresholds = PolynomialTreeStopCriterion { ctx ->
        ctx.split.rmsError <= ctx.config.targetRmsError && ctx.split.maxError <= ctx.config.targetMaxError
    }

fun maxErrorOnly(maxError: Double): PolynomialTreeStopCriterion

    fun maxErrorOnly(maxError: Double): PolynomialTreeStopCriterion = PolynomialTreeStopCriterion { ctx ->
        ctx.split.maxError <= maxError
    }

fun rmsOnly(rmsError: Double): PolynomialTreeStopCriterion

    fun rmsOnly(rmsError: Double): PolynomialTreeStopCriterion = PolynomialTreeStopCriterion { ctx ->
        ctx.split.rmsError <= rmsError
    }

val never = PolynomialTreeStopCriterion { false }

object PolynomialTreeClaimCriteria

fun distanceBelow(maxDistance: Double): PolynomialTreeClaimCriterion

    fun distanceBelow(maxDistance: Double): PolynomialTreeClaimCriterion = PolynomialTreeClaimCriterion { ctx ->
        ctx.distance <= maxDistance
    }

fun closeToBest(maxDistance: Double = 1.0, ratioFromBest: Double = 1.2): PolynomialTreeClaimCriterion

    fun closeToBest(maxDistance: Double = 1.0, ratioFromBest: Double = 1.2): PolynomialTreeClaimCriterion =
        PolynomialTreeClaimCriterion { ctx ->
            ctx.distance <= maxDistance && ctx.distance <= ctx.bestDistance * ratioFromBest
        }

fun betterThanOwner(maxDistance: Double = 1.0, minImprovement: Double = 0.25): PolynomialTreeClaimCriterion

    fun betterThanOwner(maxDistance: Double = 1.0, minImprovement: Double = 0.25): PolynomialTreeClaimCriterion =
        PolynomialTreeClaimCriterion { ctx ->
            ctx.distance <= maxDistance && (ctx.ownerDistance - ctx.distance) >= minImprovement
        }
}

fun fitPolynomialTree3DFromPoints

Fit a recursive (possibly unbalanced) tree of 3D polynomial segments to ordered points.

Nodes are fit in parametric form (x(t), y(t), z(t)); split locations and stop conditions are configurable. Error is measured as approximate minimum distance from each support point to the node polynomial.

fun fitPolynomialTree3DFromPoints(
    points: List<Vec3>,
    config: PolynomialTreeFitConfig = PolynomialTreeFitConfig()
): PolynomialTreeFitResult = points.fitPolynomialTree3D(config)

fun List<Vec3>.fitPolynomialTree3D

body — 57 lines, from line 566
fun List<Vec3>.fitPolynomialTree3D(
    config: PolynomialTreeFitConfig = PolynomialTreeFitConfig()
): PolynomialTreeFitResult {
    require(isNotEmpty()) { "fitPolynomialTree3D: points must not be empty" }
    require(config.order >= 1) { "fitPolynomialTree3D: order must be >= 1, found ${config.order}" }
    require(config.maxDepth >= 0) { "fitPolynomialTree3D: maxDepth must be >= 0, found ${config.maxDepth}" }
    require(config.maxLeaves >= 1) { "fitPolynomialTree3D: maxLeaves must be >= 1, found ${config.maxLeaves}" }
    require(config.distanceSamples >= 4) { "fitPolynomialTree3D: distanceSamples must be >= 4" }
    require(config.distanceRefinementSteps >= 0) { "fitPolynomialTree3D: distanceRefinementSteps must be >= 0" }
    require(config.regularization > 0.0) { "fitPolynomialTree3D: regularization must be > 0" }
    require(config.claims.claimRefitWeight >= 0.0) { "fitPolynomialTree3D: claimRefitWeight must be >= 0" }

    val copiedPoints = this.map { Vec3(it) }
    require(copiedPoints.all { it.isFinite() }) { "fitPolynomialTree3D: all points must be finite" }

    val effectiveMinPoints = max(config.minPointsPerLeaf, config.order + 1).coerceAtLeast(2)
    val tunedConfig = config.copy(
        minPointsPerLeaf = effectiveMinPoints,
        distanceSamples = config.distanceSamples.coerceAtLeast(4),
        maxLeaves = config.maxLeaves.coerceAtLeast(1)
    )

    val rootIndices = IntArray(copiedPoints.size) { it }
    val buildState = BuildState(nextId = 1, splitsRemaining = tunedConfig.maxLeaves - 1)
    val root = buildNode(
        points = copiedPoints,
        supportIndices = rootIndices,
        depth = 0,
        config = tunedConfig,
        state = buildState
    )

    if (tunedConfig.claims.enabled) {
        applyNonSequentialClaims(root, copiedPoints, tunedConfig)
    }

    val mutableLeaves = collectLeaves(root)
    val globalSummary = computeGlobalSummary(
        points = copiedPoints,
        leaves = mutableLeaves,
        config = tunedConfig
    )

    val immutableRoot = root.freeze()
    val immutableLeaves = immutableRoot.leaves()
    return PolynomialTreeFitResult(
        inputPoints = copiedPoints,
        config = tunedConfig,
        root = immutableRoot,
        leaves = immutableLeaves,
        pointErrors = globalSummary.errors,
        nearestLeafIds = globalSummary.nearestLeafIds,
        overallRmsError = globalSummary.rms,
        overallMeanError = globalSummary.mean,
        overallMaxError = globalSummary.max
    )
}

fun FLine.fitPolynomialTree3D

Convenience wrapper for fitting a polynomial tree to points sampled from an FLine.

fun FLine.fitPolynomialTree3D(
    config: PolynomialTreeFitConfig = PolynomialTreeFitConfig(),
    samples: Int = 128,
    sampler: FLineSampler? = null
): PolynomialTreeFitResult {
    val pts = samplePoints(samples = samples, sampler = sampler)
    return pts.fitPolynomialTree3D(config)
}

fun PolynomialTreeNode.toFLine(samples: Int = 64): FLine = polynomial.toFLine(samples)

fun PolynomialTreeFitResult.toIncrementalUpdater

fun PolynomialTreeFitResult.toIncrementalUpdater(
    points: List<Vec3> = inputPoints
): PolynomialTreeIncrementalUpdater = incrementalUpdater(points)

fun List<Vec3>.fitPolynomialTree3DIncremental

fun List<Vec3>.fitPolynomialTree3DIncremental(
    config: PolynomialTreeFitConfig = PolynomialTreeFitConfig()
): PolynomialTreeIncrementalUpdater = fitPolynomialTree3D(config).toIncrementalUpdater()

fun FLine.fitPolynomialTree3DIncremental

fun FLine.fitPolynomialTree3DIncremental(
    config: PolynomialTreeFitConfig = PolynomialTreeFitConfig(),
    samples: Int = 128,
    sampler: FLineSampler? = null
): PolynomialTreeIncrementalUpdater = fitPolynomialTree3D(config = config, samples = samples, sampler = sampler).toIncrementalUpdater()

data class PolynomialTreeIncrementalSummary

data class PolynomialTreeIncrementalSummary(
    val pointCount: Int,
    val overallRmsError: Double,
    val overallMeanError: Double,
    val overallMaxError: Double
)

class PolynomialTreeIncrementalUpdater internal constructor

Mutable updater that keeps a fixed tree topology and reusable buffers while refitting against new points.

Node allocations are stable after construction; update reuses existing node objects and resizable buffers.

class PolynomialTreeIncrementalUpdater internal constructor(
    private val config: PolynomialTreeFitConfig,
    templateRoot: PolynomialTreeNode,
    initialPoints: List<Vec3>
) {
    private val points = ArrayList<Vec3>()
    private var pointCountInternal = 0

    private val root: MutableIncrementalTreeNode
    private val nodesInBuildOrder: List<MutableIncrementalTreeNode>
    private val leaves: List<MutableIncrementalTreeNode>
    private val distanceScratch: MutableList<MutableLeafDistance>

    private val sequentialScratch = ArrayList<WeightedSample>()
    private val claimRefitScratch = ArrayList<WeightedSample>()

    private var pointErrorsBuffer = DoubleArray(initialPoints.size.coerceAtLeast(1))
    private var nearestLeafIdsBuffer = IntArray(initialPoints.size.coerceAtLeast(1))

var overallRmsError: Double

    var overallRmsError: Double = 0.0
        private set

var overallMeanError: Double

    var overallMeanError: Double = 0.0
        private set

var overallMaxError: Double

    var overallMaxError: Double = 0.0
        private set

val pointCount: Int

    val pointCount: Int
        get() = pointCountInternal

val leafCount: Int

    val leafCount: Int
        get() = leaves.size

init

    init {
        require(initialPoints.isNotEmpty()) { "PolynomialTreeIncrementalUpdater: initialPoints must not be empty" }
        require(initialPoints.all { it.isFinite() }) { "PolynomialTreeIncrementalUpdater: initialPoints must be finite" }

        val baseLastIndex = (initialPoints.size - 1).coerceAtLeast(1)
        root = buildIncrementalNode(templateRoot, baseLastIndex)
        nodesInBuildOrder = collectIncrementalNodesPreOrder(root)
        leaves = nodesInBuildOrder.filter { it.left == null && it.right == null }
        distanceScratch = leaves.map { MutableLeafDistance(it, 0.0) }.toMutableList()

        update(initialPoints)
    }

fun summary(): PolynomialTreeIncrementalSummary

    fun summary(): PolynomialTreeIncrementalSummary = PolynomialTreeIncrementalSummary(
        pointCount = pointCountInternal,
        overallRmsError = overallRmsError,
        overallMeanError = overallMeanError,
        overallMaxError = overallMaxError
    )

fun pointErrorsCopy(): DoubleArray = pointErrorsBuffer.copyOf(pointCountInternal)

fun nearestLeafIdsCopy(): IntArray = nearestLeafIdsBuffer.copyOf(pointCountInternal)

fun snapshot(): PolynomialTreeFitResult

    fun snapshot(): PolynomialTreeFitResult {
        val frozenRoot = freezeIncrementalNode(root, pointCountInternal)
        return PolynomialTreeFitResult(
            inputPoints = points.subList(0, pointCountInternal).map(::Vec3),
            config = config,
            root = frozenRoot,
            leaves = frozenRoot.leaves(),
            pointErrors = pointErrorsBuffer.copyOf(pointCountInternal),
            nearestLeafIds = nearestLeafIdsBuffer.copyOf(pointCountInternal),
            overallRmsError = overallRmsError,
            overallMeanError = overallMeanError,
            overallMaxError = overallMaxError
        )
    }

fun update(newPoints: List<Vec3>): PolynomialTreeIncrementalSummary

body — 1524 lines, from line 735
    fun update(newPoints: List<Vec3>): PolynomialTreeIncrementalSummary {
        require(newPoints.isNotEmpty()) { "PolynomialTreeIncrementalUpdater.update: points must not be empty" }
        require(newPoints.all { it.isFinite() }) { "PolynomialTreeIncrementalUpdater.update: points must be finite" }

        overwritePoints(newPoints)
        refitAllNodes()
        if (config.claims.enabled) {
            applyNonSequentialClaimsIncremental()
        }
        recomputeGlobalSummary()
        return summary()
    }

    private fun overwritePoints(newPoints: List<Vec3>) {
        val targetCount = newPoints.size
        while (points.size < targetCount) points += vec(0f, 0f, 0f)
        for (i in 0 until targetCount) {
            points[i].set(newPoints[i])
        }
        if (points.size > targetCount) {
            points.subList(targetCount, points.size).clear()
        }
        pointCountInternal = targetCount
        ensureGlobalCapacity(targetCount)
    }

    private fun ensureGlobalCapacity(required: Int) {
        if (pointErrorsBuffer.size < required) {
            pointErrorsBuffer = DoubleArray(nextCapacity(pointErrorsBuffer.size, required))
        }
        if (nearestLeafIdsBuffer.size < required) {
            nearestLeafIdsBuffer = IntArray(nextCapacity(nearestLeafIdsBuffer.size, required))
        }
    }

    private fun refitAllNodes() {
        for (node in nodesInBuildOrder) {
            val range = mapNormalizedRangeToIndices(
                startNorm = node.sourceStartNorm,
                endNorm = node.sourceEndNorm,
                pointCount = pointCountInternal
            )
            node.supportStart = range.first
            node.supportEnd = range.last

            val supportCount = (range.last - range.first + 1).coerceAtLeast(1)
            node.ensureSupportCapacity(supportCount)
            node.clearClaims()

            sequentialScratch.clear()
            buildSequentialSamplesFromRange(
                points = points,
                startIndex = node.supportStart,
                endIndex = node.supportEnd,
                destination = sequentialScratch
            )
            node.polynomial = fitPolynomialCurve(
                samples = sequentialScratch,
                requestedOrder = config.order,
                regularization = config.regularization
            )

            val stats = computeErrorsIntoBuffers(
                points = points,
                startIndex = node.supportStart,
                endIndex = node.supportEnd,
                polynomial = node.polynomial,
                distanceSamples = config.distanceSamples,
                refinementSteps = config.distanceRefinementSteps,
                errorsBuffer = node.supportErrors,
                nearestTBuffer = node.supportNearestT
            )
            node.supportCount = stats.count
            node.supportRmsError = stats.rms
            node.supportMeanError = stats.mean
            node.supportMaxError = stats.max
            node.supportMaxErrorLocalIndex = stats.maxLocalIndex
        }
    }

    private fun applyNonSequentialClaimsIncremental() {
        val claimCfg = config.claims
        if (claimCfg.maxClaimsPerLeaf <= 0 || claimCfg.maxClaimsPerPoint <= 0) return
        if (leaves.size < 2) return

        for (leaf in leaves) leaf.clearClaims()

        for (pointIndex in 0 until pointCountInternal) {
            val point = points[pointIndex]
            for (entry in distanceScratch) {
                val probe = closestDistance(
                    polynomial = entry.leaf.polynomial,
                    point = point,
                    distanceSamples = config.distanceSamples,
                    refinementSteps = config.distanceRefinementSteps
                )
                entry.distance = sqrt(probe.distanceSq)
            }
            distanceScratch.sortBy { it.distance }

            val owner = chooseSequentialOwnerIncremental(pointIndex, distanceScratch)
            val ownerDistance = distanceScratch.firstOrNull { it.leaf.id == owner.id }?.distance ?: distanceScratch.first().distance
            val bestDistance = distanceScratch.first().distance

            var acceptedForPoint = 0
            for ((rank, candidate) in distanceScratch.withIndex()) {
                val leaf = candidate.leaf
                if (!claimCfg.includeSequentialOwner && leaf.id == owner.id) continue
                if (pointIndex in leaf.currentSourceRange()) continue
                if (acceptedForPoint >= claimCfg.maxClaimsPerPoint) break
                if (leaf.claimedCount >= claimCfg.maxClaimsPerLeaf) continue

                val claimContext = PolynomialTreeClaimContext(
                    pointIndex = pointIndex,
                    point = point,
                    leafId = leaf.id,
                    leafRange = leaf.currentSourceRange(),
                    distance = candidate.distance,
                    rankByDistance = rank,
                    ownerLeafId = owner.id,
                    ownerDistance = ownerDistance,
                    bestDistance = bestDistance
                )
                if (claimCfg.criterion.accepts(claimContext)) {
                    leaf.addClaim(pointIndex)
                    acceptedForPoint++
                }
            }
        }

        for (leaf in leaves) {
            if (leaf.claimedCount == 0) {
                leaf.claimedRmsError = null
                leaf.claimedMaxError = null
                continue
            }

            if (claimCfg.refitLeaves && claimCfg.claimRefitWeight > 0.0) {
                refitLeafWithClaimsIncremental(leaf)
            }

            var sumSq = 0.0
            var maxError = 0.0
            for (i in 0 until leaf.claimedCount) {
                val pointIndex = leaf.claimedIndices[i]
                val probe = closestDistance(
                    polynomial = leaf.polynomial,
                    point = points[pointIndex],
                    distanceSamples = config.distanceSamples,
                    refinementSteps = config.distanceRefinementSteps
                )
                val error = sqrt(probe.distanceSq)
                sumSq += error * error
                maxError = max(maxError, error)
            }
            leaf.claimedRmsError = sqrt(sumSq / leaf.claimedCount.toDouble())
            leaf.claimedMaxError = maxError
        }
    }

    private fun refitLeafWithClaimsIncremental(leaf: MutableIncrementalTreeNode) {
        claimRefitScratch.clear()
        buildSequentialSamplesFromRange(
            points = points,
            startIndex = leaf.supportStart,
            endIndex = leaf.supportEnd,
            destination = claimRefitScratch
        )
        for (i in 0 until leaf.claimedCount) {
            val claimedIndex = leaf.claimedIndices[i]
            val probe = closestDistance(
                polynomial = leaf.polynomial,
                point = points[claimedIndex],
                distanceSamples = config.distanceSamples,
                refinementSteps = config.distanceRefinementSteps
            )
            claimRefitScratch += WeightedSample(
                t = probe.t,
                point = points[claimedIndex],
                weight = config.claims.claimRefitWeight
            )
        }

        leaf.polynomial = fitPolynomialCurve(
            samples = claimRefitScratch,
            requestedOrder = config.order,
            regularization = config.regularization
        )

        val stats = computeErrorsIntoBuffers(
            points = points,
            startIndex = leaf.supportStart,
            endIndex = leaf.supportEnd,
            polynomial = leaf.polynomial,
            distanceSamples = config.distanceSamples,
            refinementSteps = config.distanceRefinementSteps,
            errorsBuffer = leaf.supportErrors,
            nearestTBuffer = leaf.supportNearestT
        )
        leaf.supportCount = stats.count
        leaf.supportRmsError = stats.rms
        leaf.supportMeanError = stats.mean
        leaf.supportMaxError = stats.max
        leaf.supportMaxErrorLocalIndex = stats.maxLocalIndex
    }

    private fun recomputeGlobalSummary() {
        var sum = 0.0
        var sumSq = 0.0
        var maxError = 0.0

        for (i in 0 until pointCountInternal) {
            var bestDistance = Double.POSITIVE_INFINITY
            var bestLeafId = -1

            for (leaf in leaves) {
                val probe = closestDistance(
                    polynomial = leaf.polynomial,
                    point = points[i],
                    distanceSamples = config.distanceSamples,
                    refinementSteps = config.distanceRefinementSteps
                )
                val distance = sqrt(probe.distanceSq)
                if (distance < bestDistance) {
                    bestDistance = distance
                    bestLeafId = leaf.id
                }
            }

            pointErrorsBuffer[i] = bestDistance
            nearestLeafIdsBuffer[i] = bestLeafId
            sum += bestDistance
            sumSq += bestDistance * bestDistance
            maxError = max(maxError, bestDistance)
        }

        overallMeanError = if (pointCountInternal == 0) 0.0 else sum / pointCountInternal.toDouble()
        overallRmsError = if (pointCountInternal == 0) 0.0 else sqrt(sumSq / pointCountInternal.toDouble())
        overallMaxError = maxError
    }
}

private data class MutableLeafDistance(
    val leaf: MutableIncrementalTreeNode,
    var distance: Double
)

private class MutableIncrementalTreeNode(
    val id: Int,
    val depth: Int,
    val sourceStartNorm: Double,
    val sourceEndNorm: Double,
    val splitNorm: Double?,
    var polynomial: Polynomial3Curve,
    val stopReason: String
) {
    var left: MutableIncrementalTreeNode? = null
    var right: MutableIncrementalTreeNode? = null

    var supportStart: Int = 0
    var supportEnd: Int = 0
    var supportCount: Int = 0

    var supportErrors: DoubleArray = DoubleArray(0)
    var supportNearestT: DoubleArray = DoubleArray(0)
    var supportRmsError: Double = 0.0
    var supportMeanError: Double = 0.0
    var supportMaxError: Double = 0.0
    var supportMaxErrorLocalIndex: Int = 0

    var claimedIndices: IntArray = IntArray(0)
    var claimedCount: Int = 0
    var claimedRmsError: Double? = null
    var claimedMaxError: Double? = null

    fun currentSourceRange(): IntRange = supportStart..supportEnd

    fun clearClaims() {
        claimedCount = 0
        claimedRmsError = null
        claimedMaxError = null
    }

    fun ensureSupportCapacity(required: Int) {
        if (supportErrors.size < required) {
            val cap = nextCapacity(supportErrors.size, required)
            supportErrors = DoubleArray(cap)
            supportNearestT = DoubleArray(cap)
        } else if (supportNearestT.size < required) {
            supportNearestT = DoubleArray(nextCapacity(supportNearestT.size, required))
        }
    }

    fun ensureClaimCapacity(required: Int) {
        if (claimedIndices.size < required) {
            claimedIndices = IntArray(nextCapacity(claimedIndices.size, required))
        }
    }

    fun addClaim(pointIndex: Int) {
        ensureClaimCapacity(claimedCount + 1)
        claimedIndices[claimedCount++] = pointIndex
    }
}

private data class MutableRangeErrorStats(
    val count: Int,
    val rms: Double,
    val mean: Double,
    val max: Double,
    val maxLocalIndex: Int
)

private fun buildIncrementalNode(
    template: PolynomialTreeNode,
    baseLastIndex: Int
): MutableIncrementalTreeNode {
    val denom = baseLastIndex.toDouble().coerceAtLeast(1.0)
    val sourceStartNorm = template.sourceRange.first.toDouble() / denom
    val sourceEndNorm = template.sourceRange.last.toDouble() / denom
    val splitNorm = template.splitPointIndex?.toDouble()?.div(denom)

    val node = MutableIncrementalTreeNode(
        id = template.id,
        depth = template.depth,
        sourceStartNorm = sourceStartNorm,
        sourceEndNorm = sourceEndNorm,
        splitNorm = splitNorm,
        polynomial = template.polynomial,
        stopReason = template.stopReason
    )
    node.left = template.left?.let { buildIncrementalNode(it, baseLastIndex) }
    node.right = template.right?.let { buildIncrementalNode(it, baseLastIndex) }
    return node
}

private fun collectIncrementalNodesPreOrder(root: MutableIncrementalTreeNode): List<MutableIncrementalTreeNode> {
    val out = ArrayList<MutableIncrementalTreeNode>()
    fun walk(node: MutableIncrementalTreeNode) {
        out += node
        node.left?.let(::walk)
        node.right?.let(::walk)
    }
    walk(root)
    return out
}

private fun chooseSequentialOwnerIncremental(
    pointIndex: Int,
    rankedByDistance: List<MutableLeafDistance>
): MutableIncrementalTreeNode {
    val inRange = rankedByDistance.filter { pointIndex in it.leaf.currentSourceRange() }
    return if (inRange.isNotEmpty()) inRange.minBy { it.distance }.leaf else rankedByDistance.first().leaf
}

private fun freezeIncrementalNode(
    node: MutableIncrementalTreeNode,
    pointCount: Int
): PolynomialTreeNode {
    val supportIndices = IntArray(node.supportCount) { node.supportStart + it }
    val splitIndex = node.splitNorm?.let { norm ->
        val last = (pointCount - 1).coerceAtLeast(0)
        if (pointCount <= 1) 0 else (norm * last.toDouble()).roundToInt().coerceIn(0, last)
    }
    val claimed = node.claimedIndices.copyOf(node.claimedCount)

    return PolynomialTreeNode(
        id = node.id,
        depth = node.depth,
        sourceRange = node.currentSourceRange(),
        supportIndices = supportIndices,
        polynomial = node.polynomial,
        supportRmsError = node.supportRmsError,
        supportMeanError = node.supportMeanError,
        supportMaxError = node.supportMaxError,
        splitPointIndex = splitIndex,
        stopReason = node.stopReason,
        claimedIndices = claimed,
        claimedRmsError = node.claimedRmsError,
        claimedMaxError = node.claimedMaxError,
        left = node.left?.let { freezeIncrementalNode(it, pointCount) },
        right = node.right?.let { freezeIncrementalNode(it, pointCount) }
    )
}

private fun mapNormalizedRangeToIndices(
    startNorm: Double,
    endNorm: Double,
    pointCount: Int
): IntRange {
    if (pointCount <= 1) return 0..0
    val last = pointCount - 1

    var start = floor(startNorm.coerceIn(0.0, 1.0) * last.toDouble()).toInt()
    var end = ceil(endNorm.coerceIn(0.0, 1.0) * last.toDouble()).toInt()
    start = start.coerceIn(0, last)
    end = end.coerceIn(0, last)
    if (end < start) end = start

    if (start == end) {
        if (end < last) end++
        else if (start > 0) start--
    }
    return start..end
}

private fun computeErrorsIntoBuffers(
    points: List<Vec3>,
    startIndex: Int,
    endIndex: Int,
    polynomial: Polynomial3Curve,
    distanceSamples: Int,
    refinementSteps: Int,
    errorsBuffer: DoubleArray,
    nearestTBuffer: DoubleArray
): MutableRangeErrorStats {
    val count = (endIndex - startIndex + 1).coerceAtLeast(1)
    var sum = 0.0
    var sumSq = 0.0
    var maxError = Double.NEGATIVE_INFINITY
    var maxLocal = 0

    var local = 0
    for (pointIndex in startIndex..endIndex) {
        val probe = closestDistance(
            polynomial = polynomial,
            point = points[pointIndex],
            distanceSamples = distanceSamples,
            refinementSteps = refinementSteps
        )
        val error = sqrt(probe.distanceSq)
        errorsBuffer[local] = error
        nearestTBuffer[local] = probe.t

        sum += error
        sumSq += error * error
        if (error > maxError) {
            maxError = error
            maxLocal = local
        }
        local++
    }

    if (maxError == Double.NEGATIVE_INFINITY) maxError = 0.0
    val mean = sum / count.toDouble()
    val rms = sqrt(sumSq / count.toDouble())
    return MutableRangeErrorStats(
        count = count,
        rms = rms,
        mean = mean,
        max = maxError,
        maxLocalIndex = maxLocal
    )
}

private fun buildSequentialSamplesFromRange(
    points: List<Vec3>,
    startIndex: Int,
    endIndex: Int,
    destination: MutableList<WeightedSample>
) {
    destination.clear()
    if (endIndex <= startIndex) {
        destination += WeightedSample(t = 0.0, point = points[startIndex], weight = 1.0)
        return
    }

    val count = endIndex - startIndex + 1
    val cumulative = DoubleArray(count)
    var length = 0.0
    cumulative[0] = 0.0
    for (i in 1 until count) {
        val a = points[startIndex + i - 1]
        val b = points[startIndex + i]
        length += distance(a, b)
        cumulative[i] = length
    }

    if (length <= 1e-9) {
        val denom = (count - 1).toDouble().coerceAtLeast(1.0)
        for (i in 0 until count) {
            destination += WeightedSample(
                t = i.toDouble() / denom,
                point = points[startIndex + i],
                weight = 1.0
            )
        }
    } else {
        for (i in 0 until count) {
            destination += WeightedSample(
                t = cumulative[i] / length,
                point = points[startIndex + i],
                weight = 1.0
            )
        }
    }
}

private fun nextCapacity(current: Int, required: Int): Int {
    if (required <= current) return current
    if (current <= 0) return required.coerceAtLeast(4)
    var cap = current
    while (cap < required) cap = cap * 2
    return cap
}

private data class LeafPolylineRecord(
    val leaf: PolynomialTreeNode,
    val points: List<Vec3>
)

private fun buildLeafFLines(
    leaves: List<PolynomialTreeNode>,
    render: PolynomialLeafFLineRenderConfig
): List<FLine> {
    if (leaves.isEmpty()) return emptyList()

    val orderedLeaves = orderLeaves(leaves, render.orderMode)
    val samplesPerLeaf = render.samplesPerLeaf.coerceAtLeast(2)

    val basePolylines = ArrayList<LeafPolylineRecord>(orderedLeaves.size)
    for (leaf in orderedLeaves) {
        var points = leaf.polynomial.sample(samples = samplesPerLeaf)
        points = applyGapTrim(points, render.gap)
        points = applyOvershoot(points, render.overshoot)
        if (points.size >= 2 && polylineLength(points) > 1e-9) {
            basePolylines += LeafPolylineRecord(leaf = leaf, points = points)
        }
    }

    if (basePolylines.isEmpty()) return emptyList()

    val passes = render.sketch.passes.coerceAtLeast(1)
    return when (render.outputMode) {
        PolynomialLeafFLineOutputMode.SEPARATE_LINES -> {
            val out = ArrayList<FLine>(basePolylines.size * passes)
            for (pass in 0 until passes) {
                for (polyline in basePolylines) {
                    val jittered = applySketchJitter(polyline.points, render.sketch, polyline.leaf.id, pass)
                    out += polylineToFLine(jittered)
                }
            }
            out
        }

        PolynomialLeafFLineOutputMode.SINGLE_MULTIPATH -> {
            val out = ArrayList<FLine>(passes)
            for (pass in 0 until passes) {
                val line = combinePolylines(
                    polylines = basePolylines.map {
                        applySketchJitter(it.points, render.sketch, it.leaf.id, pass)
                    },
                    connected = false
                )
                if (line.nodes.isNotEmpty()) out += line
            }
            out
        }

        PolynomialLeafFLineOutputMode.SINGLE_CONNECTED -> {
            val out = ArrayList<FLine>(passes)
            for (pass in 0 until passes) {
                val line = combinePolylines(
                    polylines = basePolylines.map {
                        applySketchJitter(it.points, render.sketch, it.leaf.id, pass)
                    },
                    connected = true
                )
                if (line.nodes.isNotEmpty()) out += line
            }
            out
        }
    }
}

private fun orderLeaves(
    leaves: List<PolynomialTreeNode>,
    orderMode: PolynomialLeafOrderMode
): List<PolynomialTreeNode> {
    return when (orderMode) {
        PolynomialLeafOrderMode.SOURCE_ASCENDING ->
            leaves.sortedWith(compareBy<PolynomialTreeNode> { it.sourceRange.first }.thenBy { it.id })

        PolynomialLeafOrderMode.SOURCE_DESCENDING ->
            leaves.sortedWith(compareByDescending<PolynomialTreeNode> { it.sourceRange.first }.thenByDescending { it.id })

        PolynomialLeafOrderMode.ERROR_DESCENDING ->
            leaves.sortedWith(compareByDescending<PolynomialTreeNode> { it.supportMaxError }.thenBy { it.sourceRange.first })

        PolynomialLeafOrderMode.ERROR_ASCENDING ->
            leaves.sortedWith(compareBy<PolynomialTreeNode> { it.supportMaxError }.thenBy { it.sourceRange.first })

        PolynomialLeafOrderMode.DEPTH_ASCENDING ->
            leaves.sortedWith(compareBy<PolynomialTreeNode> { it.depth }.thenBy { it.sourceRange.first })

        PolynomialLeafOrderMode.DEPTH_DESCENDING ->
            leaves.sortedWith(compareByDescending<PolynomialTreeNode> { it.depth }.thenBy { it.sourceRange.first })
    }
}

private fun applyGapTrim(points: List<Vec3>, gap: PolynomialLeafGapStyle): List<Vec3> {
    val copy = points.map(::Vec3)
    if (!gap.enabled || copy.size < 2) return copy

    val span = distance(copy.first(), copy.last())
    var trim = gap.absolute + span * gap.fractionOfSpan
    trim = trim.coerceAtLeast(gap.minAbsolute).coerceAtMost(gap.maxAbsolute)

    val startTrim = if (gap.trimStart) trim else 0.0
    val endTrim = if (gap.trimEnd) trim else 0.0
    if (startTrim <= 1e-9 && endTrim <= 1e-9) return copy

    return trimPolyline(copy, startTrim, endTrim)
}

private fun applyOvershoot(points: List<Vec3>, overshoot: PolynomialLeafOvershootStyle): List<Vec3> {
    val out = points.map(::Vec3).toMutableList()
    if (!overshoot.enabled || out.size < 2) return out

    val span = distance(out.first(), out.last())
    var startDistance = overshoot.startAbsolute + span * overshoot.startFractionOfSpan
    var endDistance = overshoot.endAbsolute + span * overshoot.endFractionOfSpan
    startDistance = startDistance.coerceAtLeast(0.0).coerceAtMost(overshoot.maxAbsolute)
    endDistance = endDistance.coerceAtLeast(0.0).coerceAtMost(overshoot.maxAbsolute)

    if (startDistance > 1e-9) {
        val dir = directionFromStart(out)
        if (dir != null) {
            out[0] = Vec3(out[0]).sub(Vec3(dir).mul(startDistance.toFloat()))
        }
    }

    if (endDistance > 1e-9) {
        val dir = directionFromEnd(out)
        if (dir != null) {
            out[out.lastIndex] = Vec3(out.last()).add(Vec3(dir).mul(endDistance.toFloat()))
        }
    }

    return out
}

private fun applySketchJitter(
    points: List<Vec3>,
    sketch: PolynomialLeafSketchStyle,
    leafId: Int,
    pass: Int
): List<Vec3> {
    val amplitude = sketch.jitterAmplitude.coerceAtLeast(0.0)
    if (amplitude <= 0.0) return points.map(::Vec3)

    val random = Random(mixSeed(sketch.seed, leafId, pass))
    val alongFactor = sketch.alongTangentFactor.coerceAtLeast(0.0)
    val out = ArrayList<Vec3>(points.size)

    for (i in points.indices) {
        val isEndpoint = i == 0 || i == points.lastIndex
        if (isEndpoint && sketch.keepEndpoints) {
            out += Vec3(points[i])
            continue
        }

        val tangent = estimateTangent(points, i)
        val normal = randomUnitPerpendicular(tangent, random)

        val offMag = signedUnitRandom(random) * amplitude
        val alongMag = signedUnitRandom(random) * amplitude * alongFactor

        val jitter = Vec3(normal).mul(offMag.toFloat()).add(Vec3(tangent).mul(alongMag.toFloat()))
        out += Vec3(points[i]).add(jitter)
    }

    return out
}

private fun mixSeed(seed: Long, leafId: Int, pass: Int): Long {
    var x = seed
    x = x xor (leafId.toLong() * -7046029254386353131L)
    x = x xor (pass.toLong() * -4417276706812531889L)
    return x
}

private fun signedUnitRandom(random: Random): Double = random.nextDouble() * 2.0 - 1.0

private fun estimateTangent(points: List<Vec3>, index: Int): Vec3 {
    if (points.size < 2) return vec(1f, 0f, 0f)

    val last = points.lastIndex
    val prev = points[max(0, index - 1)]
    val next = points[min(last, index + 1)]
    val tangent = Vec3(next).sub(prev)

    if (tangent.lengthSquared() < 1e-12f && index > 0) {
        tangent.set(Vec3(points[index]).sub(points[index - 1]))
    }
    if (tangent.lengthSquared() < 1e-12f && index < last) {
        tangent.set(Vec3(points[index + 1]).sub(points[index]))
    }
    if (tangent.lengthSquared() < 1e-12f) {
        tangent.set(1f, 0f, 0f)
    } else {
        tangent.normalize()
    }
    return tangent
}

private fun randomUnitPerpendicular(tangentInput: Vec3, random: Random): Vec3 {
    val tangent = Vec3(tangentInput)
    if (tangent.lengthSquared() < 1e-12f) return vec(0f, 1f, 0f)
    tangent.normalize()

    val randomVec = vec(
        signedUnitRandom(random).toFloat(),
        signedUnitRandom(random).toFloat(),
        signedUnitRandom(random).toFloat()
    )
    val projected = Vec3(tangent).mul(randomVec.dot(tangent))
    val normal = Vec3(randomVec).sub(projected)

    if (normal.lengthSquared() < 1e-12f) {
        val axis = if (abs(tangent.x) < 0.8f) vec(1f, 0f, 0f) else vec(0f, 1f, 0f)
        normal.set(Vec3(tangent).cross(axis, Vec3()))
    }
    if (normal.lengthSquared() < 1e-12f) {
        normal.set(0f, 0f, 1f)
    } else {
        normal.normalize()
    }
    return normal
}

private fun directionFromStart(points: List<Vec3>): Vec3? {
    for (i in 1 until points.size) {
        val d = Vec3(points[i]).sub(points[i - 1])
        if (d.lengthSquared() > 1e-12f) return d.normalize()
    }
    return null
}

private fun directionFromEnd(points: List<Vec3>): Vec3? {
    for (i in points.lastIndex downTo 1) {
        val d = Vec3(points[i]).sub(points[i - 1])
        if (d.lengthSquared() > 1e-12f) return d.normalize()
    }
    return null
}

private fun trimPolyline(points: List<Vec3>, startTrim: Double, endTrim: Double): List<Vec3> {
    if (points.size < 2) return points.map(::Vec3)

    val cumulative = DoubleArray(points.size)
    for (i in 1 until points.size) {
        cumulative[i] = cumulative[i - 1] + distance(points[i - 1], points[i])
    }
    val totalLength = cumulative.last()
    if (totalLength <= 1e-9) return points.map(::Vec3)

    val startDistance = startTrim.coerceIn(0.0, totalLength)
    val endDistance = (totalLength - endTrim).coerceIn(0.0, totalLength)

    if (endDistance <= startDistance + 1e-9) {
        val mid = (startDistance + endDistance) * 0.5
        val p = pointAtDistance(points, cumulative, mid)
        return listOf(p, Vec3(p))
    }

    val startPoint = pointAtDistance(points, cumulative, startDistance)
    val endPoint = pointAtDistance(points, cumulative, endDistance)

    val out = ArrayList<Vec3>(points.size)
    out += startPoint
    for (i in 1 until points.lastIndex) {
        val d = cumulative[i]
        if (d > startDistance && d < endDistance) out += Vec3(points[i])
    }
    out += endPoint
    if (out.size < 2) out += Vec3(endPoint)
    return out
}

private fun pointAtDistance(points: List<Vec3>, cumulative: DoubleArray, distanceAlong: Double): Vec3 {
    val total = cumulative.last()
    val d = distanceAlong.coerceIn(0.0, total)
    if (d <= 0.0) return Vec3(points.first())
    if (d >= total) return Vec3(points.last())

    var hi = cumulative.binarySearch(d)
    if (hi >= 0) return Vec3(points[hi])
    hi = -hi - 1
    val lo = (hi - 1).coerceAtLeast(0)

    val d0 = cumulative[lo]
    val d1 = cumulative[hi]
    if (d1 - d0 <= 1e-9) return Vec3(points[lo])

    val alpha = ((d - d0) / (d1 - d0)).toFloat()
    val p0 = points[lo]
    val p1 = points[hi]
    return vec(
        p0.x + (p1.x - p0.x) * alpha,
        p0.y + (p1.y - p0.y) * alpha,
        p0.z + (p1.z - p0.z) * alpha
    )
}

private fun combinePolylines(polylines: List<List<Vec3>>, connected: Boolean): FLine {
    val out = FLine()
    var started = false

    for (points in polylines) {
        if (points.isEmpty()) continue
        if (!started) {
            out.moveTo(points.first())
            started = true
        } else if (connected) {
            out.lineTo(points.first())
        } else {
            out.moveTo(points.first())
        }
        for (i in 1 until points.size) out.lineTo(points[i])
    }

    return out
}

private fun polylineToFLine(points: List<Vec3>): FLine {
    val line = FLine()
    if (points.isEmpty()) return line
    line.moveTo(points.first())
    for (i in 1 until points.size) line.lineTo(points[i])
    return line
}

private fun polylineLength(points: List<Vec3>): Double {
    if (points.size < 2) return 0.0
    var len = 0.0
    for (i in 1 until points.size) {
        len += distance(points[i - 1], points[i])
    }
    return len
}

private data class WeightedSample(val t: Double, val point: Vec3, val weight: Double = 1.0)

private data class ClosestDistanceProbe(val t: Double, val distanceSq: Double)

private data class ErrorStats(
    val errors: DoubleArray,
    val nearestT: DoubleArray,
    val rms: Double,
    val mean: Double,
    val max: Double,
    val maxLocalIndex: Int
)

private data class GlobalSummary(
    val errors: DoubleArray,
    val nearestLeafIds: IntArray,
    val rms: Double,
    val mean: Double,
    val max: Double
)

private data class BuildState(
    var nextId: Int,
    var splitsRemaining: Int
)

private class MutableTreeNode(
    val id: Int,
    val depth: Int,
    val sourceRange: IntRange,
    val supportIndices: IntArray,
    var polynomial: Polynomial3Curve,
    var supportErrors: ErrorStats,
    var splitPointIndex: Int? = null,
    var stopReason: String,
    var claimedIndices: IntArray = IntArray(0),
    var claimedRmsError: Double? = null,
    var claimedMaxError: Double? = null,
    var left: MutableTreeNode? = null,
    var right: MutableTreeNode? = null
) {
    fun freeze(): PolynomialTreeNode = PolynomialTreeNode(
        id = id,
        depth = depth,
        sourceRange = sourceRange,
        supportIndices = supportIndices.copyOf(),
        polynomial = polynomial,
        supportRmsError = supportErrors.rms,
        supportMeanError = supportErrors.mean,
        supportMaxError = supportErrors.max,
        splitPointIndex = splitPointIndex,
        stopReason = stopReason,
        claimedIndices = claimedIndices.copyOf(),
        claimedRmsError = claimedRmsError,
        claimedMaxError = claimedMaxError,
        left = left?.freeze(),
        right = right?.freeze()
    )
}

private data class LeafDistance(
    val leaf: MutableTreeNode,
    val distance: Double
)

private fun buildNode(
    points: List<Vec3>,
    supportIndices: IntArray,
    depth: Int,
    config: PolynomialTreeFitConfig,
    state: BuildState
): MutableTreeNode {
    val id = state.nextId++
    val samples = buildSequentialSamples(points, supportIndices)
    val polynomial = fitPolynomialCurve(
        samples = samples,
        requestedOrder = config.order,
        regularization = config.regularization
    )
    val supportErrors = computeErrors(
        points = points,
        supportIndices = supportIndices,
        polynomial = polynomial,
        distanceSamples = config.distanceSamples,
        refinementSteps = config.distanceRefinementSteps
    )
    val node = MutableTreeNode(
        id = id,
        depth = depth,
        sourceRange = supportIndices.first()..supportIndices.last(),
        supportIndices = supportIndices,
        polynomial = polynomial,
        supportErrors = supportErrors,
        stopReason = ""
    )

    val minPoints = config.minPointsPerLeaf
    if (depth >= config.maxDepth) {
        node.stopReason = "maxDepth"
        return node
    }
    if (state.splitsRemaining <= 0) {
        node.stopReason = "maxLeavesReached"
        return node
    }
    if (supportIndices.size < (2 * minPoints - 1)) {
        node.stopReason = "insufficientPointsToSplit"
        return node
    }

    val splitContext = PolynomialTreeSplitContext(
        points = points,
        depth = depth,
        supportIndices = supportIndices,
        supportRange = supportIndices.first()..supportIndices.last(),
        pointErrors = supportErrors.errors,
        nearestT = supportErrors.nearestT,
        rmsError = supportErrors.rms,
        meanError = supportErrors.mean,
        maxError = supportErrors.max,
        maxErrorLocalIndex = supportErrors.maxLocalIndex
    )

    if (config.stopCriterion.shouldStop(PolynomialTreeStopContext(split = splitContext, config = config))) {
        node.stopReason = "errorThreshold"
        return node
    }

    val requestedSplit = config.splitCriterion.chooseSplit(splitContext)
    val splitLocal = chooseValidSplitLocal(
        requestedSplit = requestedSplit,
        context = splitContext,
        minPointsPerLeaf = minPoints
    )
    if (splitLocal == null) {
        node.stopReason = "splitCriterionDeclined"
        return node
    }

    val splitGlobal = supportIndices[splitLocal]
    val leftIndices = supportIndices.copyOfRange(0, splitLocal + 1)
    val rightIndices = supportIndices.copyOfRange(splitLocal, supportIndices.size)
    if (leftIndices.size < minPoints || rightIndices.size < minPoints) {
        node.stopReason = "splitInvalid"
        return node
    }

    state.splitsRemaining--
    node.splitPointIndex = splitGlobal
    node.stopReason = "split"
    node.left = buildNode(points, leftIndices, depth + 1, config, state)
    node.right = buildNode(points, rightIndices, depth + 1, config, state)
    return node
}

private fun chooseValidSplitLocal(
    requestedSplit: Int?,
    context: PolynomialTreeSplitContext,
    minPointsPerLeaf: Int
): Int? {
    val minLocal = minPointsPerLeaf - 1
    val maxLocal = context.supportIndices.size - minPointsPerLeaf
    if (maxLocal < minLocal) return null

    val candidates = ArrayList<Int>(4)
    if (requestedSplit != null) {
        val local = context.localIndexOf(requestedSplit)
        if (local >= 0) candidates += local
    }
    candidates += context.maxErrorLocalIndex
    candidates += context.middleLocalIndex()
    candidates += ((minLocal + maxLocal) / 2)

    for (candidate in candidates) {
        val clamped = candidate.coerceIn(minLocal, maxLocal)
        if (clamped in minLocal..maxLocal) return clamped
    }
    return null
}

private fun applyNonSequentialClaims(
    root: MutableTreeNode,
    points: List<Vec3>,
    config: PolynomialTreeFitConfig
) {
    val claimCfg = config.claims
    if (!claimCfg.enabled) return
    if (claimCfg.maxClaimsPerPoint <= 0 || claimCfg.maxClaimsPerLeaf <= 0) return

    val leaves = collectLeaves(root)
    if (leaves.size < 2) return

    val claimsByLeaf = leaves.associate { it.id to linkedSetOf<Int>() }.toMutableMap()
    for (pointIndex in points.indices) {
        val distances = leaves.map { leaf ->
            val probe = closestDistance(
                polynomial = leaf.polynomial,
                point = points[pointIndex],
                distanceSamples = config.distanceSamples,
                refinementSteps = config.distanceRefinementSteps
            )
            LeafDistance(leaf, sqrt(probe.distanceSq))
        }.sortedBy { it.distance }
        if (distances.isEmpty()) continue

        val ownerLeaf = chooseSequentialOwner(pointIndex, distances)
        val ownerDistance = distances.firstOrNull { it.leaf.id == ownerLeaf.id }?.distance ?: distances.first().distance
        val bestDistance = distances.first().distance

        var acceptedForPoint = 0
        for ((rank, leafDistance) in distances.withIndex()) {
            if (!claimCfg.includeSequentialOwner && leafDistance.leaf.id == ownerLeaf.id) continue
            if (acceptedForPoint >= claimCfg.maxClaimsPerPoint) break

            val claimSet = claimsByLeaf[leafDistance.leaf.id] ?: continue
            if (claimSet.size >= claimCfg.maxClaimsPerLeaf) continue

            val claimContext = PolynomialTreeClaimContext(
                pointIndex = pointIndex,
                point = points[pointIndex],
                leafId = leafDistance.leaf.id,
                leafRange = leafDistance.leaf.sourceRange,
                distance = leafDistance.distance,
                rankByDistance = rank,
                ownerLeafId = ownerLeaf.id,
                ownerDistance = ownerDistance,
                bestDistance = bestDistance
            )

            if (claimCfg.criterion.accepts(claimContext)) {
                claimSet += pointIndex
                acceptedForPoint++
            }
        }
    }

    for (leaf in leaves) {
        val claimed = claimsByLeaf[leaf.id]
            ?.filterNot { it in leaf.sourceRange }
            ?.sorted()
            ?.toIntArray()
            ?: IntArray(0)

        leaf.claimedIndices = claimed
        if (claimed.isEmpty()) {
            leaf.claimedRmsError = null
            leaf.claimedMaxError = null
            continue
        }

        if (claimCfg.refitLeaves && claimCfg.claimRefitWeight > 0.0) {
            refitLeafWithClaims(
                leaf = leaf,
                points = points,
                config = config
            )
        }

        val claimErrors = claimed.map { idx ->
            val probe = closestDistance(
                polynomial = leaf.polynomial,
                point = points[idx],
                distanceSamples = config.distanceSamples,
                refinementSteps = config.distanceRefinementSteps
            )
            sqrt(probe.distanceSq)
        }
        leaf.claimedMaxError = claimErrors.maxOrNull()
        leaf.claimedRmsError = if (claimErrors.isEmpty()) null else {
            sqrt(claimErrors.sumOf { it * it } / claimErrors.size.toDouble())
        }
    }
}

private fun chooseSequentialOwner(pointIndex: Int, rankedByDistance: List<LeafDistance>): MutableTreeNode {
    val inRange = rankedByDistance.filter { pointIndex in it.leaf.sourceRange }
    return if (inRange.isNotEmpty()) inRange.minBy { it.distance }.leaf else rankedByDistance.first().leaf
}

private fun refitLeafWithClaims(
    leaf: MutableTreeNode,
    points: List<Vec3>,
    config: PolynomialTreeFitConfig
) {
    if (leaf.claimedIndices.isEmpty()) return

    val samples = ArrayList<WeightedSample>()
    samples += buildSequentialSamples(points, leaf.supportIndices)
    for (claimedIndex in leaf.claimedIndices) {
        val probe = closestDistance(
            polynomial = leaf.polynomial,
            point = points[claimedIndex],
            distanceSamples = config.distanceSamples,
            refinementSteps = config.distanceRefinementSteps
        )
        samples += WeightedSample(
            t = probe.t,
            point = points[claimedIndex],
            weight = config.claims.claimRefitWeight
        )
    }
    leaf.polynomial = fitPolynomialCurve(
        samples = samples,
        requestedOrder = config.order,
        regularization = config.regularization
    )
    leaf.supportErrors = computeErrors(
        points = points,
        supportIndices = leaf.supportIndices,
        polynomial = leaf.polynomial,
        distanceSamples = config.distanceSamples,
        refinementSteps = config.distanceRefinementSteps
    )
}

private fun collectLeaves(root: MutableTreeNode): List<MutableTreeNode> {
    val out = ArrayList<MutableTreeNode>()
    fun walk(node: MutableTreeNode) {
        val left = node.left
        val right = node.right
        if (left == null && right == null) out += node
        else {
            left?.let(::walk)
            right?.let(::walk)
        }
    }
    walk(root)
    return out
}

private fun computeGlobalSummary(
    points: List<Vec3>,
    leaves: List<MutableTreeNode>,
    config: PolynomialTreeFitConfig
): GlobalSummary {
    val errors = DoubleArray(points.size)
    val nearestLeaf = IntArray(points.size)

    var sum = 0.0
    var sumSq = 0.0
    var maxError = 0.0

    for (i in points.indices) {
        var bestLeafId = leaves.firstOrNull()?.id ?: -1
        var bestDistance = Double.POSITIVE_INFINITY

        for (leaf in leaves) {
            val probe = closestDistance(
                polynomial = leaf.polynomial,
                point = points[i],
                distanceSamples = config.distanceSamples,
                refinementSteps = config.distanceRefinementSteps
            )
            val distance = sqrt(probe.distanceSq)
            if (distance < bestDistance) {
                bestDistance = distance
                bestLeafId = leaf.id
            }
        }

        errors[i] = bestDistance
        nearestLeaf[i] = bestLeafId
        sum += bestDistance
        sumSq += bestDistance * bestDistance
        maxError = max(maxError, bestDistance)
    }

    val mean = if (errors.isEmpty()) 0.0 else sum / errors.size.toDouble()
    val rms = if (errors.isEmpty()) 0.0 else sqrt(sumSq / errors.size.toDouble())
    return GlobalSummary(
        errors = errors,
        nearestLeafIds = nearestLeaf,
        rms = rms,
        mean = mean,
        max = maxError
    )
}

private fun computeErrors(
    points: List<Vec3>,
    supportIndices: IntArray,
    polynomial: Polynomial3Curve,
    distanceSamples: Int,
    refinementSteps: Int
): ErrorStats {
    val errors = DoubleArray(supportIndices.size)
    val nearestT = DoubleArray(supportIndices.size)

    var sum = 0.0
    var sumSq = 0.0
    var maxError = Double.NEGATIVE_INFINITY
    var maxLocal = 0

    for (local in supportIndices.indices) {
        val point = points[supportIndices[local]]
        val probe = closestDistance(
            polynomial = polynomial,
            point = point,
            distanceSamples = distanceSamples,
            refinementSteps = refinementSteps
        )
        val error = sqrt(probe.distanceSq)
        errors[local] = error
        nearestT[local] = probe.t

        sum += error
        sumSq += error * error
        if (error > maxError) {
            maxError = error
            maxLocal = local
        }
    }

    if (maxError == Double.NEGATIVE_INFINITY) maxError = 0.0
    val mean = if (errors.isEmpty()) 0.0 else sum / errors.size.toDouble()
    val rms = if (errors.isEmpty()) 0.0 else sqrt(sumSq / errors.size.toDouble())
    return ErrorStats(
        errors = errors,
        nearestT = nearestT,
        rms = rms,
        mean = mean,
        max = maxError,
        maxLocalIndex = maxLocal
    )
}

private fun buildSequentialSamples(points: List<Vec3>, supportIndices: IntArray): List<WeightedSample> {
    if (supportIndices.isEmpty()) return emptyList()
    if (supportIndices.size == 1) {
        return listOf(WeightedSample(t = 0.0, point = points[supportIndices[0]], weight = 1.0))
    }

    val cumulative = DoubleArray(supportIndices.size)
    var length = 0.0
    cumulative[0] = 0.0
    for (i in 1 until supportIndices.size) {
        val a = points[supportIndices[i - 1]]
        val b = points[supportIndices[i]]
        length += distance(a, b)
        cumulative[i] = length
    }

    val out = ArrayList<WeightedSample>(supportIndices.size)
    if (length <= 1e-9) {
        for (i in supportIndices.indices) {
            val t = i.toDouble() / supportIndices.lastIndex.toDouble().coerceAtLeast(1.0)
            out += WeightedSample(t = t, point = points[supportIndices[i]], weight = 1.0)
        }
    } else {
        for (i in supportIndices.indices) {
            out += WeightedSample(
                t = cumulative[i] / length,
                point = points[supportIndices[i]],
                weight = 1.0
            )
        }
    }
    return out
}

private fun fitPolynomialCurve(
    samples: List<WeightedSample>,
    requestedOrder: Int,
    regularization: Double
): Polynomial3Curve {
    if (samples.isEmpty()) {
        return Polynomial3Curve(
            xCoefficients = doubleArrayOf(0.0, 0.0),
            yCoefficients = doubleArrayOf(0.0, 0.0),
            zCoefficients = doubleArrayOf(0.0, 0.0)
        )
    }

    val maxFittableOrder = (samples.size - 1).coerceAtLeast(1)
    var activeOrder = requestedOrder.coerceAtMost(maxFittableOrder).coerceAtLeast(1)

    while (activeOrder >= 1) {
        val solved = solvePolynomialOrder(samples, activeOrder, regularization)
        if (solved != null) {
            return Polynomial3Curve(
                xCoefficients = promoteCoefficients(solved.first, requestedOrder),
                yCoefficients = promoteCoefficients(solved.second, requestedOrder),
                zCoefficients = promoteCoefficients(solved.third, requestedOrder)
            )
        }
        activeOrder--
    }

    val first = samples.first()
    val last = samples.last()
    val dt = (last.t - first.t)
    val inv = if (kotlin.math.abs(dt) > 1e-9) 1.0 / dt else 0.0
    val dx = (last.point.x - first.point.x).toDouble() * inv
    val dy = (last.point.y - first.point.y).toDouble() * inv
    val dz = (last.point.z - first.point.z).toDouble() * inv
    val x0 = first.point.x.toDouble() - dx * first.t
    val y0 = first.point.y.toDouble() - dy * first.t
    val z0 = first.point.z.toDouble() - dz * first.t

    val x = DoubleArray(requestedOrder + 1)
    val y = DoubleArray(requestedOrder + 1)
    val z = DoubleArray(requestedOrder + 1)
    x[0] = x0
    y[0] = y0
    z[0] = z0
    x[1] = dx
    y[1] = dy
    z[1] = dz
    return Polynomial3Curve(x, y, z)
}

private fun solvePolynomialOrder(
    samples: List<WeightedSample>,
    order: Int,
    regularization: Double
): Triple<DoubleArray, DoubleArray, DoubleArray>? {
    val terms = order + 1
    val normal = Array(terms) { DoubleArray(terms) }
    val bx = DoubleArray(terms)
    val by = DoubleArray(terms)
    val bz = DoubleArray(terms)

    for (sample in samples) {
        val t = sample.t.coerceIn(0.0, 1.0)
        val w = sample.weight.coerceAtLeast(0.0)
        val powers = DoubleArray(2 * order + 1)
        powers[0] = 1.0
        for (i in 1 until powers.size) powers[i] = powers[i - 1] * t

        for (i in 0 until terms) {
            val wi = w * powers[i]
            bx[i] += wi * sample.point.x.toDouble()
            by[i] += wi * sample.point.y.toDouble()
            bz[i] += wi * sample.point.z.toDouble()

            for (j in 0 until terms) {
                normal[i][j] += w * powers[i + j]
            }
        }
    }

    for (i in 0 until terms) {
        normal[i][i] += regularization
    }

    val x = solveLinearSystem(copyMatrix(normal), bx) ?: return null
    val y = solveLinearSystem(copyMatrix(normal), by) ?: return null
    val z = solveLinearSystem(copyMatrix(normal), bz) ?: return null
    return Triple(x, y, z)
}

private fun closestDistance(
    polynomial: Polynomial3Curve,
    point: Vec3,
    distanceSamples: Int,
    refinementSteps: Int
): ClosestDistanceProbe {
    val samples = distanceSamples.coerceAtLeast(4)
    val step = 1.0 / (samples - 1).toDouble()

    var bestT = 0.0
    var bestSq = Double.POSITIVE_INFINITY
    for (i in 0 until samples) {
        val t = i * step
        val sq = squaredDistanceAt(polynomial, point, t)
        if (sq < bestSq) {
            bestSq = sq
            bestT = t
        }
    }

    var left = max(0.0, bestT - step)
    var right = min(1.0, bestT + step)
    repeat(refinementSteps.coerceAtLeast(0)) {
        val t1 = left + (right - left) / 3.0
        val t2 = right - (right - left) / 3.0
        val d1 = squaredDistanceAt(polynomial, point, t1)
        val d2 = squaredDistanceAt(polynomial, point, t2)
        if (d1 <= d2) right = t2 else left = t1
    }

    val refinedT = (left + right) * 0.5
    val refinedSq = squaredDistanceAt(polynomial, point, refinedT)
    return if (refinedSq < bestSq) {
        ClosestDistanceProbe(t = refinedT, distanceSq = refinedSq)
    } else {
        ClosestDistanceProbe(t = bestT, distanceSq = bestSq)
    }
}

private fun squaredDistanceAt(polynomial: Polynomial3Curve, point: Vec3, t: Double): Double {
    val tt = t.coerceIn(0.0, 1.0)
    val dx = evalPolynomial(polynomial.xCoefficients, tt) - point.x.toDouble()
    val dy = evalPolynomial(polynomial.yCoefficients, tt) - point.y.toDouble()
    val dz = evalPolynomial(polynomial.zCoefficients, tt) - point.z.toDouble()
    return dx * dx + dy * dy + dz * dz
}

private fun solveLinearSystem(matrix: Array<DoubleArray>, rhs: DoubleArray): DoubleArray? {
    val n = matrix.size
    if (rhs.size != n) return null

    val b = rhs.copyOf()
    for (pivot in 0 until n) {
        var maxRow = pivot
        var maxValue = kotlin.math.abs(matrix[pivot][pivot])
        for (row in pivot + 1 until n) {
            val value = kotlin.math.abs(matrix[row][pivot])
            if (value > maxValue) {
                maxValue = value
                maxRow = row
            }
        }
        if (maxValue < 1e-12) return null

        if (maxRow != pivot) {
            val tmp = matrix[pivot]
            matrix[pivot] = matrix[maxRow]
            matrix[maxRow] = tmp
            val tb = b[pivot]
            b[pivot] = b[maxRow]
            b[maxRow] = tb
        }

        val diag = matrix[pivot][pivot]
        for (col in pivot until n) matrix[pivot][col] /= diag
        b[pivot] /= diag

        for (row in 0 until n) {
            if (row == pivot) continue
            val factor = matrix[row][pivot]
            if (kotlin.math.abs(factor) < 1e-18) continue
            for (col in pivot until n) {
                matrix[row][col] -= factor * matrix[pivot][col]
            }
            b[row] -= factor * b[pivot]
        }
    }
    return b
}

private fun copyMatrix(source: Array<DoubleArray>): Array<DoubleArray> {
    return Array(source.size) { row -> source[row].copyOf() }
}

private fun promoteCoefficients(coefficients: DoubleArray, targetOrder: Int): DoubleArray {
    val out = DoubleArray(targetOrder + 1)
    val limit = min(out.size, coefficients.size)
    for (i in 0 until limit) out[i] = coefficients[i]
    return out
}

private fun evalPolynomial(coefficients: DoubleArray, t: Double): Double {
    var acc = 0.0
    for (i in coefficients.lastIndex downTo 0) {
        acc = acc * t + coefficients[i]
    }
    return acc
}

private fun distance(a: Vec3, b: Vec3): Double {
    val dx = a.x - b.x
    val dy = a.y - b.y
    val dz = a.z - b.z
    return sqrt((dx * dx + dy * dy + dz * dz).toDouble())
}

private fun turningAngle(prev: Vec3, cur: Vec3, next: Vec3): Double {
    val ax = (cur.x - prev.x).toDouble()
    val ay = (cur.y - prev.y).toDouble()
    val az = (cur.z - prev.z).toDouble()
    val bx = (next.x - cur.x).toDouble()
    val by = (next.y - cur.y).toDouble()
    val bz = (next.z - cur.z).toDouble()

    val la = sqrt(ax * ax + ay * ay + az * az)
    val lb = sqrt(bx * bx + by * by + bz * bz)
    if (la < 1e-9 || lb < 1e-9) return 0.0

    val cos = ((ax * bx + ay * by + az * bz) / (la * lb)).coerceIn(-1.0, 1.0)
    return acos(cos)
}