FLineGeometrySmile.kt
src/jvmMain/kotlin/afield/geometry/path/FLineGeometrySmile.kt · 354 lines
package and imports — 59 lines, from line 1
package afield.geometry.path
import afield.ai.learning.*
import afield.ai.retrieval.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.animation.motion.*
import afield.app.commands.*
import afield.app.diagnostics.*
import afield.audio.analysis.*
import afield.audio.synth.*
import afield.data.collections.*
import afield.data.formats.*
import afield.data.graph.*
import afield.data.sorting.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.geometry.mesh.*
import afield.geometry.path.*
import afield.geometry.spatial.*
import afield.graphics.color.*
import afield.graphics.interaction.*
import afield.graphics.render.*
import afield.graphics.shader.*
import afield.graphics.text.*
import afield.graphics.vulkan.buffers.*
import afield.io.osc.*
import afield.io.process.*
import afield.lang.lambda.*
import afield.lang.python.*
import afield.lang.remotejs.*
import afield.lang.stacksl.*
import afield.math.autodiff.*
import afield.math.control.*
import afield.math.estimation.*
import afield.math.functions.*
import afield.math.linalg.*
import afield.math.optimization.*
import afield.math.statistics.*
import afield.math.units.*
import afield.media.image.*
import afield.media.video.*
import afield.runtime.execution.*
import afield.runtime.languages.*
import afield.runtime.lifecycle.*
import afield.simulation.agents.*
import afield.simulation.automata.*
import afield.simulation.particles.*
import afield.study.build.*
import afield.study.evidence.*
import afield.study.search.*
import afield.study.source.*
import afield.study.ui.*
import afield.ui.controls.*
import afield.ui.output.*
import afield.ui.visualization.*
import kotlin.math.abs
Resamples an FLine through a fitted curve: bySmileSplineInterpolating (Smile cubic spline),
bySmileGaussianProcessInterpolating (Gaussian process) and byAkimaInterpolating.
Each one samples the line, fits X, Y and Z separately against normalised arc length, and returns
a new line of samples points, mixed with the original by dryWet.
fun FLine.bySmileSplineInterpolating
Smile cubic-spline interpolation along arc-length, applied independently to XYZ.
fun FLine.bySmileSplineInterpolating(
trainingSamples: Int = 48,
samples: Int = 200,
dryWet: Float = 1f,
wetModulator: ParameterFunction? = null,
trainingSampler: FLineSampler? = null,
outputSampler: FLineSampler? = null
): FLine {
val obs = this.samplePoints(trainingSamples.coerceAtLeast(2), sampler = trainingSampler)
if (obs.size < 2) return this.copy()
val (tObs, pObs) = prepareArcLengthTrainingData(obs) ?: return this.copy()
return try {
val xModel = smileCubicSpline(tObs, DoubleArray(pObs.size) { i -> pObs[i].x.toDouble() })
val yModel = smileCubicSpline(tObs, DoubleArray(pObs.size) { i -> pObs[i].y.toDouble() })
val zModel = smileCubicSpline(tObs, DoubleArray(pObs.size) { i -> pObs[i].z.toDouble() })
val outCount = samples.coerceAtLeast(1)
val base = this.samplePoints(outCount, sampler = outputSampler)
if (base.isEmpty()) return this.copy()
val tQuery = normalizedArcLengthParamsSmile(base)
val predicted = ArrayList<Vec3>(base.size)
for (i in base.indices) {
val t = tQuery[i]
predicted += vec(
xModel.predict(t).toFloat(),
yModel.predict(t).toFloat(),
zModel.predict(t).toFloat()
)
}
mixSamplesSmile(base, predicted, dryWet, wetModulator).toFLineSmile()
} catch (_: Throwable) {
this.copy()
}
}
fun FLine.bySmileGaussianProcessInterpolating
Smile Gaussian-process interpolation along arc-length, applied independently to XYZ.
body — 45 lines, from line 112
fun FLine.bySmileGaussianProcessInterpolating(
trainingSamples: Int = 48,
samples: Int = 200,
lengthScale: Float = 0.15f,
noise: Float = 1e-3f,
normalize: Boolean = true,
tol: Float = 1e-4f,
maxIter: Int = 500,
dryWet: Float = 1f,
wetModulator: ParameterFunction? = null,
trainingSampler: FLineSampler? = null,
outputSampler: FLineSampler? = null
): FLine {
val obs = this.samplePoints(trainingSamples.coerceAtLeast(2), sampler = trainingSampler)
if (obs.size < 2) return this.copy()
val (tObs, pObs) = prepareArcLengthTrainingData(obs) ?: return this.copy()
val options = SmileGpOptions(
lengthScale = lengthScale.toDouble(),
noise = noise.toDouble(),
normalize = normalize,
tol = tol.toDouble(),
maxIter = maxIter
)
val xModel = smileGaussianProcess(tObs, DoubleArray(pObs.size) { i -> pObs[i].x.toDouble() }, options)
val yModel = smileGaussianProcess(tObs, DoubleArray(pObs.size) { i -> pObs[i].y.toDouble() }, options)
val zModel = smileGaussianProcess(tObs, DoubleArray(pObs.size) { i -> pObs[i].z.toDouble() }, options)
val outCount = samples.coerceAtLeast(1)
val base = this.samplePoints(outCount, sampler = outputSampler)
if (base.isEmpty()) return this.copy()
val tQuery = normalizedArcLengthParamsSmile(base)
val predicted = ArrayList<Vec3>(base.size)
for (i in base.indices) {
val t = tQuery[i]
predicted += vec(
xModel.predict(t).toFloat(),
yModel.predict(t).toFloat(),
zModel.predict(t).toFloat()
)
}
return mixSamplesSmile(base, predicted, dryWet, wetModulator).toFLineSmile()
}
fun FLine.byAkimaInterpolating
Custom Akima-style interpolation along arc-length, applied independently to XYZ.
body — 192 lines, from line 161
fun FLine.byAkimaInterpolating(
trainingSamples: Int = 48,
samples: Int = 200,
dryWet: Float = 1f,
wetModulator: ParameterFunction? = null,
trainingSampler: FLineSampler? = null,
outputSampler: FLineSampler? = null
): FLine {
val obs = this.samplePoints(trainingSamples.coerceAtLeast(2), sampler = trainingSampler)
if (obs.size < 2) return this.copy()
val (tObs, pObs) = prepareArcLengthTrainingData(obs) ?: return this.copy()
return try {
val xModel = akimaOrFallback(tObs, DoubleArray(pObs.size) { i -> pObs[i].x.toDouble() })
val yModel = akimaOrFallback(tObs, DoubleArray(pObs.size) { i -> pObs[i].y.toDouble() })
val zModel = akimaOrFallback(tObs, DoubleArray(pObs.size) { i -> pObs[i].z.toDouble() })
val outCount = samples.coerceAtLeast(1)
val base = this.samplePoints(outCount, sampler = outputSampler)
if (base.isEmpty()) return this.copy()
val tQuery = normalizedArcLengthParamsSmile(base)
val predicted = ArrayList<Vec3>(base.size)
for (i in base.indices) {
val t = tQuery[i]
predicted += vec(
xModel.predict(t).toFloat(),
yModel.predict(t).toFloat(),
zModel.predict(t).toFloat()
)
}
mixSamplesSmile(base, predicted, dryWet, wetModulator).toFLineSmile()
} catch (_: Throwable) {
this.copy()
}
}
private interface ScalarPredictor {
fun predict(x: Double): Double
}
private fun akimaOrFallback(xs: DoubleArray, ys: DoubleArray): ScalarPredictor {
return if (xs.size >= 5) {
AkimaInterpolator1D(xs, ys)
} else {
val spline = smileCubicSpline(xs, ys)
object : ScalarPredictor {
override fun predict(x: Double): Double = spline.predict(x)
}
}
}
private class AkimaInterpolator1D(
private val xs: DoubleArray,
private val ys: DoubleArray
) : ScalarPredictor {
private val derivatives: DoubleArray = computeDerivatives(xs, ys)
init {
require(xs.size == ys.size) { "AkimaInterpolator1D: xs.size must match ys.size" }
require(xs.size >= 5) { "AkimaInterpolator1D: requires at least 5 points" }
for (i in 1 until xs.size) {
require(xs[i] > xs[i - 1]) { "AkimaInterpolator1D: xs must be strictly increasing" }
}
}
override fun predict(x: Double): Double {
if (x <= xs.first()) return ys.first()
if (x >= xs.last()) return ys.last()
val interval = findInterval(x)
val x0 = xs[interval]
val x1 = xs[interval + 1]
val y0 = ys[interval]
val y1 = ys[interval + 1]
val d0 = derivatives[interval]
val d1 = derivatives[interval + 1]
val h = x1 - x0
val dx = x - x0
val slope = (y1 - y0) / h
val c = (3.0 * slope - 2.0 * d0 - d1) / h
val d = (d0 + d1 - 2.0 * slope) / (h * h)
return y0 + d0 * dx + c * dx * dx + d * dx * dx * dx
}
private fun findInterval(x: Double): Int {
val raw = xs.binarySearch(x)
return when {
raw >= 0 -> raw.coerceAtMost(xs.lastIndex - 1)
else -> {
val insertion = -raw - 1
(insertion - 1).coerceIn(0, xs.lastIndex - 1)
}
}
}
private fun computeDerivatives(xs: DoubleArray, ys: DoubleArray): DoubleArray {
val n = xs.size
val m = DoubleArray(n - 1) { i ->
(ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i])
}
fun extSlope(index: Int): Double = when {
index == -2 -> 3.0 * m[0] - 2.0 * m[1]
index == -1 -> 2.0 * m[0] - m[1]
index in 0 until (n - 1) -> m[index]
index == n - 1 -> 2.0 * m[n - 2] - m[n - 3]
index == n -> 3.0 * m[n - 2] - 2.0 * m[n - 3]
else -> error("AkimaInterpolator1D: slope index out of bounds ($index)")
}
val d = DoubleArray(n)
for (i in 0 until n) {
val mIm2 = extSlope(i - 2)
val mIm1 = extSlope(i - 1)
val mI = extSlope(i)
val mIp1 = extSlope(i + 1)
val w1 = abs(mIp1 - mI)
val w2 = abs(mIm1 - mIm2)
d[i] = if ((w1 + w2) > 1e-12) {
(w1 * mIm1 + w2 * mI) / (w1 + w2)
} else {
0.5 * (mIm1 + mI)
}
}
return d
}
}
private fun prepareArcLengthTrainingData(points: List<Vec3>): Pair<DoubleArray, List<Vec3>>? {
if (points.size < 2) return null
val t = normalizedArcLengthParamsSmile(points)
val filteredT = ArrayList<Double>(t.size)
val filteredP = ArrayList<Vec3>(points.size)
for (i in t.indices) {
val currentT = t[i]
if (filteredT.isEmpty() || currentT > filteredT.last() + 1e-12) {
filteredT += currentT
filteredP += Vec3(points[i])
}
}
if (filteredT.size < 2) return null
return filteredT.toDoubleArray() to filteredP
}
private fun normalizedArcLengthParamsSmile(points: List<Vec3>): DoubleArray {
val n = points.size
val params = DoubleArray(n)
if (n == 0) return params
var total = 0.0
for (i in 1 until n) {
total += points[i].distance(points[i - 1]).toDouble()
params[i] = total
}
if (total > 0.0) {
for (i in params.indices) {
params[i] /= total
}
}
return params
}
private fun mixSamplesSmile(
base: List<Vec3>,
processed: List<Vec3>,
dryWet: Float,
modulator: ParameterFunction? = null
): List<Vec3> {
val count = minOf(base.size, processed.size)
val out = ArrayList<Vec3>(count)
for (i in 0 until count) {
val t = if (count <= 1) 0f else i.toFloat() / (count - 1)
val wet = modulator?.evaluate(t, processed[i]) ?: dryWet
val dry = 1f - wet
out += Vec3(base[i]).mul(dry).add(Vec3(processed[i]).mul(wet))
}
return out
}
private fun List<Vec3>.toFLineSmile(): FLine {
val out = FLine()
if (isNotEmpty()) {
out.moveTo(this[0])
for (i in 1 until size) {
out.lineTo(this[i])
}
}
return out
}