FLineInterpolation.kt
src/jvmMain/kotlin/afield/geometry/path/FLineInterpolation.kt · 1322 lines
Morphing one FLine into another: align, match and blend two drawings so any in-between shape can be drawn.
interpolatorTo builds a reusable FLineInterpolator from an FLineInterpolationConfig
(alignment, matching and motion settings), and FLineInterpolator.lineAt returns the line at a
given t. byInterpolatingTo is the one-shot form for a single evaluation.
package and imports — 65 lines, from line 9
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 afield.math.linalg.Quaternionf
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
enum class FLineInterpolationScaleMode
Uniform scale normalization used during interpolation setup.
enum class FLineInterpolationScaleMode {
NONE,
RMS_RADIUS,
BOUNDS_DIAGONAL
}
enum class FLineInterpolationRotationMode
Pre-alignment rotation used during interpolation setup.
enum class FLineInterpolationRotationMode {
NONE,
FIT_LINE_2D,
FIT_LINE_3D
}
enum class FLineInterpolationSpinMode
Evaluation-time envelope for spin-style motion modifiers.
enum class FLineInterpolationSpinMode {
Ramp from zero at the start to full value at the end.
LINEAR,
Reach full value at t = 0.5, then return to zero so the final keyframe is hit exactly.
MIDPOINT_RETURN;
fun envelope(t: Float): Float
fun envelope(t: Float): Float {
val tt = t.coerceIn(0f, 1f)
return when (this) {
LINEAR -> tt
MIDPOINT_RETURN -> (1f - abs(2f * tt - 1f)).coerceIn(0f, 1f)
}
}
}
data class FLineInterpolationAlignment
Geometric normalization before matching.
data class FLineInterpolationAlignment(
val translateToCenter: Boolean = true,
val scaleMode: FLineInterpolationScaleMode = FLineInterpolationScaleMode.RMS_RADIUS,
val rotationMode: FLineInterpolationRotationMode = FLineInterpolationRotationMode.NONE
)
data class FLineInterpolationMatching
Setup-time matching and resampling controls.
data class FLineInterpolationMatching(
val denseSampleCount: Int = 256,
val extraAnchorSamplesPerLine: Int = 0,
val minimumBasisCount: Int = 64,
val maximumBasisCount: Int = 1024,
val basisDensityMultiplier: Int = 4,
val unmatchedCost: Double = 4.0,
val assignmentOrderPenalty: Double = 0.15,
val projectionOrderPenalty: Double = 0.25,
val assignmentConstraintWeight: Float = 2f,
val projectionConstraintWeight: Float = 1f,
val boundaryConstraintWeight: Float = 8f,
val allowReverseOrder: Boolean = true,
val allowClosedOrderRotation: Boolean = true,
val closedOrderRotationSteps: Int? = null
)
data class FLineInterpolationMotion
Fast evaluation-time motion controls applied after the cached basis blend.
data class FLineInterpolationMotion(
val spatialSpinAngle: Float = 0f,
val spatialSpinAxis: Vec3 = vec(0f, 0f, 1f),
val spatialSpinMode: FLineInterpolationSpinMode = FLineInterpolationSpinMode.LINEAR,
val orderSpin: Float = 0f,
val orderSpinMode: FLineInterpolationSpinMode = FLineInterpolationSpinMode.LINEAR,
val wrapOrderSpin: Boolean = false
)
data class FLineInterpolationConfig
Configuration for FLineInterpolator.
data class FLineInterpolationConfig(
val alignment: FLineInterpolationAlignment = FLineInterpolationAlignment(),
val matching: FLineInterpolationMatching = FLineInterpolationMatching(),
val motion: FLineInterpolationMotion = FLineInterpolationMotion(),
val samplerA: FLineSampler = FLineSamplers.curvatureAdaptive(gain = 3f, oversample = 4, minBaseSamples = 48),
val samplerB: FLineSampler = samplerA,
val closed: Boolean? = null
)
data class FLineInterpolationOrderTransform
Reparameterization applied to the second line while solving the correspondence.
shift is only meaningful for closed lines and moves the seam in normalized contour space.
data class FLineInterpolationOrderTransform(
val reverse: Boolean = false,
val shift: Float = 0f
) {
fun forward(rawU: Float, closed: Boolean): Float
fun forward(rawU: Float, closed: Boolean): Float {
val oriented = if (reverse) 1f - rawU else rawU
return if (closed) wrap01(oriented - shift) else oriented.coerceIn(0f, 1f)
}
fun inverse(orderedU: Float, closed: Boolean): Float
fun inverse(orderedU: Float, closed: Boolean): Float {
val shifted = if (closed) wrap01(orderedU + shift) else orderedU.coerceIn(0f, 1f)
return if (reverse) 1f - shifted else shifted
}
}
data class FLineInterpolationDiagnostics
Introspection for the chosen interpolation setup.
data class FLineInterpolationDiagnostics(
val orderTransform: FLineInterpolationOrderTransform,
val assignmentCost: Double,
val warpKnots: List<Vec2>,
val basisCount: Int,
val denseSampleCountA: Int,
val denseSampleCountB: Int,
val outputClosed: Boolean
)
class FLineInterpolator
Slow-to-configure, fast-to-evaluate single-contour FLine morphing primitive.
The setup pipeline is: 1. Resample each source line into a dense single-contour polyline. 2. Normalize the two lines for matching (translate / scale / optional rotation). 3. Search over reversal + closed-contour seam shifts using a Hungarian assignment on anchors. 4. Build a monotone parameter warp from matched nodes plus mutual node-to-segment projections. 5. Cache a common basis of paired points for cheap repeated evaluation.
This currently targets one contour only (MoveTo once at most). Multi-contour interpolation can
be built later by composing multiple instances of this primitive.
class FLineInterpolator(
val from: FLine,
val to: FLine,
val config: FLineInterpolationConfig = FLineInterpolationConfig()
) {
private val basisA: Array<Vec3>
private val basisB: Array<Vec3>
private val outputClosed: Boolean
val diagnostics: FLineInterpolationDiagnostics
init
init {
from.requireSingleContour("from")
to.requireSingleContour("to")
val setup = FLineInterpolationSetup.build(from, to, config)
basisA = setup.basisA
basisB = setup.basisB
outputClosed = setup.outputClosed
diagnostics = setup.diagnostics
}
val basisCount: Int
Number of cached basis points used by this interpolator.
val basisCount: Int
get() = basisA.size
fun sourceBasis(): Pair<FLine, FLine>
Return the cached source basis lines used for evaluation.
fun sourceBasis(): Pair<FLine, FLine> {
return polylineFrom(basisA.map { Vec3(it) }, closed = outputClosed) to
polylineFrom(basisB.map { Vec3(it) }, closed = outputClosed)
}
fun pointsAt(t: Float, motion: FLineInterpolationMotion = config.motion): List<Vec3>
Generate the interpolated point sequence at t.
fun pointsAt(t: Float, motion: FLineInterpolationMotion = config.motion): List<Vec3> {
if (basisA.isEmpty() || basisB.isEmpty()) return emptyList()
val tt = t.coerceIn(0f, 1f)
val points = ArrayList<Vec3>(basisA.size)
for (i in basisA.indices) {
points += Vec3(basisA[i]).lerp(basisB[i], tt)
}
val orderSpinEnvelope = motion.orderSpinMode.envelope(tt)
val wrapOrder = outputClosed || motion.wrapOrderSpin
if (wrapOrder && abs(motion.orderSpin) > EPSILON && abs(orderSpinEnvelope) > EPSILON && points.size > 1) {
val shift = motion.orderSpin * orderSpinEnvelope * points.size.toFloat()
rotatePointOrderInPlace(points, shift)
}
val spatialSpinEnvelope = motion.spatialSpinMode.envelope(tt)
if (abs(motion.spatialSpinAngle) > EPSILON && abs(spatialSpinEnvelope) > EPSILON && points.isNotEmpty()) {
val axis = normalizedAxis(motion.spatialSpinAxis)
val center = points.center()
val rotation = Quaternionf().setAngleAxis(
motion.spatialSpinAngle * spatialSpinEnvelope,
axis.x,
axis.y,
axis.z
)
for (i in points.indices) {
points[i].sub(center)
rotation.transform(points[i])
points[i].add(center)
}
}
return points
}
fun writeInto(target: FLine, t: Float, motion: FLineInterpolationMotion = config.motion): FLine
Write the interpolated geometry into target, clearing it first.
fun writeInto(target: FLine, t: Float, motion: FLineInterpolationMotion = config.motion): FLine {
target.nodes.clear()
target.properties.clear()
target.rev++
val points = pointsAt(t, motion)
if (points.isEmpty()) return target
target.properties.putAll(from.properties)
target.properties["interpolationT"] = t.coerceIn(0f, 1f)
target.moveTo(points.first())
for (i in 1 until points.size) {
target.lineTo(points[i])
}
if (outputClosed && points.size > 2) {
target.close()
}
return target
}
fun lineAt(t: Float, motion: FLineInterpolationMotion = config.motion): FLine
Generate a new FLine at t.
fun lineAt(t: Float, motion: FLineInterpolationMotion = config.motion): FLine {
return writeInto(FLine(), t, motion)
}
}
fun FLine.interpolatorTo
Create a reusable interpolator to other.
fun FLine.interpolatorTo(
other: FLine,
config: FLineInterpolationConfig = FLineInterpolationConfig()
): FLineInterpolator = FLineInterpolator(this, other, config)
fun FLine.byInterpolatingTo
One-shot convenience wrapper around FLineInterpolator.
If you are evaluating more than once, prefer interpolatorTo and reuse the returned object.
body — 988 lines, from line 334
fun FLine.byInterpolatingTo(
other: FLine,
t: Float,
config: FLineInterpolationConfig = FLineInterpolationConfig(),
motion: FLineInterpolationMotion = config.motion
): FLine = FLineInterpolator(this, other, config).lineAt(t, motion)
private object FLineInterpolationSetup {
fun build(from: FLine, to: FLine, config: FLineInterpolationConfig): SetupResult {
val fromClosed = from.isExplicitlyClosed()
val toClosed = to.isExplicitlyClosed()
val outputClosed = config.closed ?: (fromClosed && toClosed)
val denseA = DensePolyline.fromLine(from, config.matching.denseSampleCount, config.samplerA, fromClosed)
val denseB = DensePolyline.fromLine(to, config.matching.denseSampleCount, config.samplerB, toClosed)
if (denseA.points.isEmpty() && denseB.points.isEmpty()) {
return SetupResult(
basisA = emptyArray(),
basisB = emptyArray(),
outputClosed = outputClosed,
diagnostics = FLineInterpolationDiagnostics(
orderTransform = FLineInterpolationOrderTransform(),
assignmentCost = 0.0,
warpKnots = listOf(vec(0f, 0f), vec(1f, 1f)),
basisCount = 0,
denseSampleCountA = 0,
denseSampleCountB = 0,
outputClosed = outputClosed
)
)
}
if (denseA.points.isEmpty() || denseB.points.isEmpty()) {
return buildDegenerate(denseA, denseB, outputClosed, config)
}
val anchorsA = extractAnchors(from, denseA, config.matching.extraAnchorSamplesPerLine)
val anchorsB = extractAnchors(to, denseB, config.matching.extraAnchorSamplesPerLine)
val normA = SimilarityNormalization.fromPoints(denseA.points, config.alignment)
val normB = SimilarityNormalization.fromPoints(denseB.points, config.alignment)
val alignedA = denseA.transformed(normA::apply)
val alignedB = denseB.transformed(normB::apply)
val alignedAnchorsA = anchorsA.map { it.withAligned(normA.apply(it.point)) }
val alignedAnchorsB = anchorsB.map { it.withAligned(normB.apply(it.point)) }
val candidate = chooseBestCandidate(
anchorsA = alignedAnchorsA,
anchorsB = alignedAnchorsB,
targetClosed = toClosed,
matching = config.matching
)
val orderedRawB = denseB.reparameterized(candidate.transform, denseB.points.size)
val orderedAlignedB = alignedB.reparameterized(candidate.transform, alignedB.points.size)
val orderedAnchorsB = alignedAnchorsB.map { it.ordered(candidate.transform, toClosed) }
val constraints = buildConstraints(
anchorsA = alignedAnchorsA,
anchorsB = orderedAnchorsB,
alignedA = alignedA,
orderedAlignedB = orderedAlignedB,
candidate = candidate,
matching = config.matching
)
val warp = ParamWarp.fromConstraints(constraints)
val basisCount = chooseBasisCount(denseA, denseB, constraints, config.matching, outputClosed)
val grid = sampleGrid(basisCount, outputClosed)
val basisA = Array(grid.size) { i -> denseA.sample(grid[i]) }
val basisB = Array(grid.size) { i -> orderedRawB.sample(warp.map(grid[i])) }
return SetupResult(
basisA = basisA,
basisB = basisB,
outputClosed = outputClosed,
diagnostics = FLineInterpolationDiagnostics(
orderTransform = candidate.transform,
assignmentCost = candidate.cost,
warpKnots = warp.knots.map { Vec2(it) },
basisCount = basisCount,
denseSampleCountA = denseA.points.size,
denseSampleCountB = denseB.points.size,
outputClosed = outputClosed
)
)
}
private fun buildDegenerate(
denseA: DensePolyline,
denseB: DensePolyline,
outputClosed: Boolean,
config: FLineInterpolationConfig
): SetupResult {
val nonEmpty = if (denseA.points.isNotEmpty()) denseA else denseB
val count = if (nonEmpty.points.size <= 1) 1 else config.matching.minimumBasisCount.coerceAtLeast(2)
val grid = sampleGrid(count, outputClosed && nonEmpty.closed)
val actual = Array(grid.size) { i -> nonEmpty.sample(grid[i]) }
val anchor = actual.firstOrNull()?.let { Vec3(it) } ?: Vec3()
val collapsed = Array(actual.size) { Vec3(anchor) }
val basisA = if (denseA.points.isEmpty()) collapsed else actual
val basisB = if (denseB.points.isEmpty()) collapsed else actual
return SetupResult(
basisA = basisA,
basisB = basisB,
outputClosed = outputClosed && nonEmpty.closed,
diagnostics = FLineInterpolationDiagnostics(
orderTransform = FLineInterpolationOrderTransform(),
assignmentCost = 0.0,
warpKnots = listOf(vec(0f, 0f), vec(1f, 1f)),
basisCount = count,
denseSampleCountA = denseA.points.size,
denseSampleCountB = denseB.points.size,
outputClosed = outputClosed && nonEmpty.closed
)
)
}
}
private data class SetupResult(
val basisA: Array<Vec3>,
val basisB: Array<Vec3>,
val outputClosed: Boolean,
val diagnostics: FLineInterpolationDiagnostics
)
private data class Anchor(
val rawU: Float,
val point: Vec3,
val weight: Float,
val aligned: Vec3 = Vec3()
) {
fun withAligned(value: Vec3): Anchor = copy(aligned = Vec3(value))
fun ordered(transform: FLineInterpolationOrderTransform, closed: Boolean): OrderedAnchor {
return OrderedAnchor(
rawU = rawU,
orderedU = transform.forward(rawU, closed),
point = Vec3(point),
aligned = Vec3(aligned),
weight = weight
)
}
}
private data class OrderedAnchor(
val rawU: Float,
val orderedU: Float,
val point: Vec3,
val aligned: Vec3,
val weight: Float
)
private data class ParamConstraint(
val ua: Float,
val vb: Float,
val weight: Float
)
private data class CandidateSelection(
val transform: FLineInterpolationOrderTransform,
val assignment: IntArray,
val cost: Double
)
private data class PolylineProjection(
val u: Float,
val point: Vec3,
val cost: Double
)
private class DensePolyline(
points: List<Vec3>,
val closed: Boolean
) {
val points: List<Vec3> = canonicalizePoints(points, closed)
private val segmentCount: Int = when {
this.points.size <= 1 -> 0
this.closed -> this.points.size
else -> this.points.size - 1
}
private val cumulative: FloatArray = FloatArray(segmentCount + 1)
private val totalLength: Float
init {
var acc = 0f
cumulative[0] = 0f
for (segment in 0 until segmentCount) {
acc += segmentLength(segment)
cumulative[segment + 1] = acc
}
totalLength = acc
}
fun transformed(by: (Vec3) -> Vec3): DensePolyline {
return DensePolyline(points.map { by(it) }, closed)
}
fun reparameterized(
transform: FLineInterpolationOrderTransform,
sampleCount: Int = points.size
): DensePolyline {
if (points.isEmpty()) return DensePolyline(emptyList(), closed)
if (points.size == 1) return DensePolyline(listOf(Vec3(points.first())), false)
val count = if (closed) sampleCount.coerceAtLeast(3) else sampleCount.coerceAtLeast(2)
val out = ArrayList<Vec3>(count)
if (closed) {
for (i in 0 until count) {
val orderedU = i.toFloat() / count.toFloat()
out += sample(transform.inverse(orderedU, true))
}
} else {
for (i in 0 until count) {
val orderedU = if (count == 1) 0f else i.toFloat() / (count - 1).toFloat()
out += sample(transform.inverse(orderedU, false))
}
}
return DensePolyline(out, closed)
}
fun sample(u: Float): Vec3 {
if (points.isEmpty()) return Vec3()
if (points.size == 1 || segmentCount == 0) return Vec3(points.first())
if (totalLength <= EPSILON) {
return sampleByIndex(u)
}
val normalized = if (closed) wrap01(u) else u.coerceIn(0f, 1f)
val target = normalized * totalLength
if (!closed && target >= totalLength - EPSILON) {
return Vec3(points.last())
}
var segment = 0
while (segment < segmentCount - 1 && cumulative[segment + 1] < target) {
segment++
}
val start = cumulative[segment]
val end = cumulative[segment + 1]
val alpha = if (end <= start + EPSILON) 0f else ((target - start) / (end - start)).coerceIn(0f, 1f)
return pointOnSegment(segment, alpha)
}
fun project(
point: Vec3,
preferredU: Float? = null,
orderPenalty: Double = 0.0
): PolylineProjection {
if (points.isEmpty()) return PolylineProjection(0f, Vec3(), 0.0)
if (points.size == 1 || segmentCount == 0) {
val p = Vec3(points.first())
return PolylineProjection(0f, p, p.distanceSquared(point).toDouble())
}
var best = PolylineProjection(0f, Vec3(points.first()), Double.POSITIVE_INFINITY)
for (segment in 0 until segmentCount) {
val a = points[segment]
val b = points[nextIndex(segment)]
val ab = Vec3(b).sub(a)
val lenSq = ab.lengthSquared()
val alpha = if (lenSq <= EPSILON * EPSILON) {
0f
} else {
(((point.x - a.x) * ab.x) + ((point.y - a.y) * ab.y) + ((point.z - a.z) * ab.z)) / lenSq
}.coerceIn(0f, 1f)
val projected = Vec3(a).lerp(b, alpha)
val spatial = projected.distanceSquared(point).toDouble()
val u = parameterAt(segment, alpha)
val order = if (preferredU == null || orderPenalty <= 0.0) {
0.0
} else {
val delta = (u - preferredU).toDouble()
delta * delta * orderPenalty
}
val cost = spatial + order
if (cost < best.cost) {
best = PolylineProjection(u = u, point = projected, cost = cost)
}
}
return best
}
private fun sampleByIndex(u: Float): Vec3 {
val normalized = if (closed) wrap01(u) else u.coerceIn(0f, 1f)
if (closed) {
val x = normalized * points.size.toFloat()
val index = floor(x).toInt().floorMod(points.size)
val alpha = x - floor(x)
return pointOnSegment(index, alpha)
}
val x = normalized * (points.size - 1).toFloat()
val index = floor(x).toInt().coerceIn(0, points.size - 1)
val next = min(index + 1, points.size - 1)
val alpha = (x - floor(x)).coerceIn(0f, 1f)
return Vec3(points[index]).lerp(points[next], alpha)
}
private fun pointOnSegment(segment: Int, alpha: Float): Vec3 {
val a = points[segment]
val b = points[nextIndex(segment)]
return Vec3(a).lerp(b, alpha)
}
private fun parameterAt(segment: Int, alpha: Float): Float {
if (segmentCount == 0) return 0f
if (totalLength <= EPSILON) {
val denom = if (closed) segmentCount.toFloat() else max(1, segmentCount).toFloat()
return ((segment.toFloat() + alpha) / denom).coerceIn(0f, 1f)
}
val d = cumulative[segment] + (cumulative[segment + 1] - cumulative[segment]) * alpha
return (d / totalLength).coerceIn(0f, 1f)
}
private fun nextIndex(segment: Int): Int {
return if (closed) (segment + 1).floorMod(points.size) else segment + 1
}
private fun segmentLength(segment: Int): Float {
val a = points[segment]
val b = points[nextIndex(segment)]
return a.distance(b)
}
companion object {
fun fromLine(line: FLine, requested: Int, sampler: FLineSampler, closed: Boolean): DensePolyline {
val sampleCount = requested.coerceAtLeast(if (closed) 3 else 2)
val raw = line.samplePoints(sampleCount, sampler = sampler)
val cleaned = if (closed && raw.size > 1 && raw.first().distance(raw.last()) <= 1e-3f) {
raw.dropLast(1)
} else raw
return DensePolyline(cleaned.map { Vec3(it) }, closed)
}
}
}
private data class SimilarityNormalization(
val center: Vec3,
val scale: Float,
val rotation: Quat
) {
fun apply(point: Vec3): Vec3 {
val out = Vec3(point).sub(center)
out.mul(scale)
rotation.transform(out)
return out
}
companion object {
fun fromPoints(points: List<Vec3>, config: FLineInterpolationAlignment): SimilarityNormalization {
val center = if (config.translateToCenter) points.center() else Vec3()
val scale = when (config.scaleMode) {
FLineInterpolationScaleMode.NONE -> 1f
FLineInterpolationScaleMode.RMS_RADIUS -> {
val rms = rmsRadius(points, center)
if (rms <= EPSILON) 1f else 1f / rms
}
FLineInterpolationScaleMode.BOUNDS_DIAGONAL -> {
val diag = points.aabbBounds().size.length()
if (diag <= EPSILON) 1f else 1f / diag
}
}
val rotation = when (config.rotationMode) {
FLineInterpolationRotationMode.NONE -> Quaternionf()
FLineInterpolationRotationMode.FIT_LINE_2D -> {
val dir = principalDirection2D(points)
Quaternionf().setAngleAxis(-atan2(dir.y, dir.x), 0f, 0f, 1f)
}
FLineInterpolationRotationMode.FIT_LINE_3D -> {
val dir = principalDirection3D(points)
Quaternionf().rotateTo(dir, vec(1f, 0f, 0f))
}
}
return SimilarityNormalization(center = center, scale = scale, rotation = rotation)
}
private fun rmsRadius(points: List<Vec3>, center: Vec3): Float {
if (points.isEmpty()) return 1f
val mean = points.sumOf { Vec3(it).sub(center).lengthSquared().toDouble() } / points.size.toDouble()
return sqrt(mean).toFloat()
}
}
}
private class ParamWarp private constructor(val knots: List<Vec2>) {
fun map(u: Float): Float {
if (knots.isEmpty()) return u.coerceIn(0f, 1f)
if (knots.size == 1) return knots.first().y.coerceIn(0f, 1f)
val x = u.coerceIn(0f, 1f)
if (x <= knots.first().x) return knots.first().y.coerceIn(0f, 1f)
for (i in 1 until knots.size) {
val b = knots[i]
if (x <= b.x) {
val a = knots[i - 1]
val dx = b.x - a.x
if (dx <= EPSILON) return b.y.coerceIn(0f, 1f)
val alpha = ((x - a.x) / dx).coerceIn(0f, 1f)
return (a.y + (b.y - a.y) * alpha).coerceIn(0f, 1f)
}
}
return knots.last().y.coerceIn(0f, 1f)
}
companion object {
fun fromConstraints(constraints: List<ParamConstraint>): ParamWarp {
if (constraints.isEmpty()) return ParamWarp(listOf(vec(0f, 0f), vec(1f, 1f)))
val sorted = constraints
.map {
ParamConstraint(
ua = it.ua.coerceIn(0f, 1f),
vb = it.vb.coerceIn(0f, 1f),
weight = it.weight.coerceAtLeast(EPSILON)
)
}
.sortedBy { it.ua }
val epsilon = (1f / max(256, sorted.size * 8)).coerceAtLeast(1e-5f)
val binned = binConstraints(sorted, epsilon)
val values = FloatArray(binned.size) { i -> binned[i].vb }
val weights = FloatArray(binned.size) { i -> binned[i].weight.coerceAtLeast(EPSILON) }
val adjusted = isotonicNondecreasing(values, weights)
val knots = ArrayList<Vec2>(binned.size + 2)
for (i in binned.indices) {
knots += vec(binned[i].ua, adjusted[i].coerceIn(0f, 1f))
}
if (knots.isEmpty() || knots[0].x > epsilon) {
knots.add(0, vec(0f, 0f))
} else {
knots[0].set(0f, 0f)
}
if (knots[knots.lastIndex].x < 1f - epsilon) {
knots += vec(1f, 1f)
} else {
knots[knots.lastIndex].set(1f, 1f)
}
for (i in 1 until knots.size) {
if (knots[i].y < knots[i - 1].y) {
knots[i].y = knots[i - 1].y
}
}
return ParamWarp(condenseKnots(knots, epsilon))
}
private fun binConstraints(sorted: List<ParamConstraint>, epsilon: Float): List<ParamConstraint> {
if (sorted.isEmpty()) return emptyList()
val out = ArrayList<ParamConstraint>()
var uaWeight = 0.0
var vbWeight = 0.0
var weight = 0.0
var currentU = sorted.first().ua
fun flush() {
if (weight <= 0.0) return
out += ParamConstraint(
ua = (uaWeight / weight).toFloat(),
vb = (vbWeight / weight).toFloat(),
weight = weight.toFloat()
)
}
for (constraint in sorted) {
val w = constraint.weight.toDouble()
if (weight > 0.0 && abs(constraint.ua - currentU) > epsilon) {
flush()
uaWeight = 0.0
vbWeight = 0.0
weight = 0.0
}
uaWeight += constraint.ua.toDouble() * w
vbWeight += constraint.vb.toDouble() * w
weight += w
currentU = (uaWeight / weight).toFloat()
}
flush()
return out
}
private fun condenseKnots(knots: List<Vec2>, epsilon: Float): List<Vec2> {
if (knots.isEmpty()) return emptyList()
val out = ArrayList<Vec2>(knots.size)
for (knot in knots) {
if (out.isNotEmpty() && abs(knot.x - out[out.lastIndex].x) <= epsilon) {
out[out.lastIndex].set(knot.x, knot.y)
} else {
out += Vec2(knot)
}
}
return out
}
private fun isotonicNondecreasing(values: FloatArray, weights: FloatArray): FloatArray {
data class Block(var start: Int, var end: Int, var weight: Double, var mean: Double)
val stack = ArrayDeque<Block>()
for (i in values.indices) {
var block = Block(
start = i,
end = i,
weight = weights[i].toDouble().coerceAtLeast(EPSILON.toDouble()),
mean = values[i].toDouble()
)
while (stack.isNotEmpty() && stack.last().mean > block.mean) {
val previous = stack.removeLast()
val total = previous.weight + block.weight
block = Block(
start = previous.start,
end = block.end,
weight = total,
mean = ((previous.mean * previous.weight) + (block.mean * block.weight)) / total
)
}
stack.addLast(block)
}
val out = FloatArray(values.size)
for (block in stack) {
for (i in block.start..block.end) {
out[i] = block.mean.toFloat()
}
}
return out
}
}
}
private fun chooseBestCandidate(
anchorsA: List<Anchor>,
anchorsB: List<Anchor>,
targetClosed: Boolean,
matching: FLineInterpolationMatching
): CandidateSelection {
if (anchorsA.isEmpty() && anchorsB.isEmpty()) {
return CandidateSelection(FLineInterpolationOrderTransform(), IntArray(0), 0.0)
}
val reverseOptions = if (matching.allowReverseOrder) listOf(false, true) else listOf(false)
val shiftOptions = if (targetClosed && matching.allowClosedOrderRotation) {
buildShiftCandidates(anchorsB, matching.closedOrderRotationSteps)
} else {
listOf(0f)
}
var best: CandidateSelection? = null
for (reverse in reverseOptions) {
for (shift in shiftOptions) {
val transform = FLineInterpolationOrderTransform(reverse = reverse, shift = if (targetClosed) shift else 0f)
val orderedB = anchorsB.map { it.ordered(transform, targetClosed) }
val cost = Array(anchorsA.size) { row ->
DoubleArray(orderedB.size) { col ->
assignmentCost(anchorsA[row], orderedB[col], matching.assignmentOrderPenalty)
}
}
val assignment = RectHungarianAssignment.solve(cost, matching.unmatchedCost)
val total = assignmentTotalCost(cost, assignment, orderedB.size, matching.unmatchedCost)
val candidate = CandidateSelection(transform, assignment, total)
val currentBest = best
if (currentBest == null || total < currentBest.cost - 1e-9) {
best = candidate
}
}
}
return best ?: CandidateSelection(FLineInterpolationOrderTransform(), IntArray(anchorsA.size) { -1 }, Double.POSITIVE_INFINITY)
}
private fun buildConstraints(
anchorsA: List<Anchor>,
anchorsB: List<OrderedAnchor>,
alignedA: DensePolyline,
orderedAlignedB: DensePolyline,
candidate: CandidateSelection,
matching: FLineInterpolationMatching
): List<ParamConstraint> {
val constraints = ArrayList<ParamConstraint>()
constraints += ParamConstraint(0f, 0f, matching.boundaryConstraintWeight)
constraints += ParamConstraint(1f, 1f, matching.boundaryConstraintWeight)
for (row in anchorsA.indices) {
val col = candidate.assignment.getOrNull(row) ?: -1
if (col in anchorsB.indices) {
val weight = matching.assignmentConstraintWeight * ((anchorsA[row].weight + anchorsB[col].weight) * 0.5f)
constraints += ParamConstraint(anchorsA[row].rawU, anchorsB[col].orderedU, weight)
}
}
for (anchor in anchorsA) {
val projection = orderedAlignedB.project(
point = anchor.aligned,
preferredU = anchor.rawU,
orderPenalty = matching.projectionOrderPenalty
)
constraints += ParamConstraint(anchor.rawU, projection.u, matching.projectionConstraintWeight * anchor.weight)
}
for (anchor in anchorsB) {
val projection = alignedA.project(
point = anchor.aligned,
preferredU = anchor.orderedU,
orderPenalty = matching.projectionOrderPenalty
)
constraints += ParamConstraint(projection.u, anchor.orderedU, matching.projectionConstraintWeight * anchor.weight)
}
return constraints
}
private fun chooseBasisCount(
denseA: DensePolyline,
denseB: DensePolyline,
constraints: List<ParamConstraint>,
matching: FLineInterpolationMatching,
outputClosed: Boolean
): Int {
val structural = max(denseA.points.size, denseB.points.size)
if (structural <= 1) return 1
val desired = max(
matching.minimumBasisCount,
max(structural * matching.basisDensityMultiplier, constraints.size * 2)
)
val minimum = if (outputClosed) 3 else 2
return desired.coerceAtLeast(minimum).coerceAtMost(matching.maximumBasisCount)
}
private fun extractAnchors(line: FLine, dense: DensePolyline, extraSamples: Int): List<Anchor> {
val rawPoints = line.nodes
.filter { it.storage.isNotEmpty() }
.map { Vec3(it.to) }
val anchors = ArrayList<Anchor>()
dedupePoints(rawPoints, dense.closed).forEach { point ->
val projection = dense.project(point)
anchors += Anchor(rawU = projection.u, point = point, weight = 1f)
}
if (extraSamples > 0 && dense.points.size > 1) {
if (dense.closed) {
for (i in 0 until extraSamples) {
val u = (i.toFloat() + 0.5f) / extraSamples.toFloat()
anchors += Anchor(rawU = u, point = dense.sample(u), weight = 0.5f)
}
} else {
for (i in 0 until extraSamples) {
val u = (i.toFloat() + 1f) / (extraSamples + 1).toFloat()
anchors += Anchor(rawU = u, point = dense.sample(u), weight = 0.5f)
}
}
}
if (anchors.isEmpty() && dense.points.isNotEmpty()) {
anchors += Anchor(rawU = 0f, point = Vec3(dense.points[0]), weight = 1f)
}
return dedupeAnchors(anchors)
}
private fun dedupePoints(points: List<Vec3>, closed: Boolean): List<Vec3> {
if (points.isEmpty()) return emptyList()
val out = ArrayList<Vec3>(points.size)
for (point in points) {
if (out.isEmpty() || out[out.lastIndex].distance(point) > 1e-4f) {
out += Vec3(point)
}
}
if (closed && out.size > 1 && out[0].distance(out[out.lastIndex]) <= 1e-4f) {
out.removeAt(out.lastIndex)
}
return out
}
private fun dedupeAnchors(anchors: List<Anchor>): List<Anchor> {
if (anchors.isEmpty()) return emptyList()
val sorted = anchors.sortedBy { it.rawU }
val out = ArrayList<Anchor>(sorted.size)
val epsilon = 1e-4f
for (anchor in sorted) {
if (out.isEmpty() || abs(anchor.rawU - out.last().rawU) > epsilon) {
out += anchor
}
}
return out
}
private fun buildShiftCandidates(
anchors: List<Anchor>,
steps: Int?
): List<Float> {
if (steps != null) {
val n = steps.coerceAtLeast(1)
return (0 until n).map { i -> i.toFloat() / n.toFloat() }
}
if (anchors.isEmpty()) return listOf(0f)
val sorted = anchors.map { wrap01(it.rawU) }.sorted()
val out = ArrayList<Float>(sorted.size + 1)
out += 0f
for (candidate in sorted) {
if (out.none { abs(it - candidate) <= 1e-4f }) {
out += candidate
}
}
return out
}
private fun assignmentCost(
a: Anchor,
b: OrderedAnchor,
orderPenalty: Double
): Double {
return squaredDistance(a.aligned, b.aligned) +
orderPenalty * (a.rawU - b.orderedU).toDouble() * (a.rawU - b.orderedU).toDouble()
}
private fun assignmentTotalCost(
cost: Array<DoubleArray>,
assignment: IntArray,
columnCount: Int,
unmatchedCost: Double
): Double {
if (cost.isEmpty()) return columnCount * unmatchedCost
var total = 0.0
val used = BooleanArray(columnCount)
for (row in cost.indices) {
val col = assignment.getOrNull(row) ?: -1
if (col in 0 until columnCount) {
total += cost[row][col]
used[col] = true
} else {
total += unmatchedCost
}
}
for (col in 0 until columnCount) {
if (!used[col]) total += unmatchedCost
}
return total
}
private object RectHungarianAssignment {
fun solve(cost: Array<DoubleArray>, unmatchedCost: Double): IntArray {
val rows = cost.size
val cols = cost.firstOrNull()?.size ?: 0
if (rows == 0) return IntArray(0)
if (cols == 0) return IntArray(rows) { -1 }
val size = max(rows, cols)
val square = Array(size) { DoubleArray(size) { unmatchedCost } }
for (row in 0 until rows) {
for (col in 0 until cols) {
square[row][col] = cost[row][col]
}
}
val assignment = solveSquare(square)
return IntArray(rows) { row ->
val col = assignment[row]
if (col in 0 until cols) col else -1
}
}
private fun solveSquare(cost: Array<DoubleArray>): IntArray {
val n = cost.size
val u = DoubleArray(n + 1)
val v = DoubleArray(n + 1)
val p = IntArray(n + 1)
val way = IntArray(n + 1)
for (i in 1..n) {
p[0] = i
var j0 = 0
val minv = DoubleArray(n + 1) { Double.POSITIVE_INFINITY }
val used = BooleanArray(n + 1)
do {
used[j0] = true
val i0 = p[j0]
var delta = Double.POSITIVE_INFINITY
var j1 = 0
for (j in 1..n) {
if (!used[j]) {
val cur = cost[i0 - 1][j - 1] - u[i0] - v[j]
if (cur < minv[j]) {
minv[j] = cur
way[j] = j0
}
if (minv[j] < delta) {
delta = minv[j]
j1 = j
}
}
}
for (j in 0..n) {
if (used[j]) {
u[p[j]] += delta
v[j] -= delta
} else {
minv[j] -= delta
}
}
j0 = j1
} while (p[j0] != 0)
do {
val j1 = way[j0]
p[j0] = p[j1]
j0 = j1
} while (j0 != 0)
}
val assignment = IntArray(n) { -1 }
for (j in 1..n) {
if (p[j] != 0) assignment[p[j] - 1] = j - 1
}
return assignment
}
}
private fun FLine.requireSingleContour(label: String) {
val moveCount = nodes.count { it is FLine.MoveTo }
require(moveCount <= 1) {
"FLineInterpolator currently supports a single contour only; $label has $moveCount MoveTo instructions"
}
}
private fun FLine.isExplicitlyClosed(): Boolean = nodes.lastOrNull() is FLine.Close
private fun sampleGrid(count: Int, closed: Boolean): FloatArray {
if (count <= 0) return FloatArray(0)
if (count == 1) return floatArrayOf(0f)
return if (closed) {
FloatArray(count) { i -> i.toFloat() / count.toFloat() }
} else {
FloatArray(count) { i -> i.toFloat() / (count - 1).toFloat() }
}
}
private fun rotatePointOrderInPlace(points: MutableList<Vec3>, shift: Float) {
if (points.size < 2) return
val wrapped = ((shift % points.size.toFloat()) + points.size.toFloat()) % points.size.toFloat()
val whole = floor(wrapped).toInt()
val fraction = wrapped - floor(wrapped)
val copy = points.map { Vec3(it) }
for (i in points.indices) {
val a = copy[(i + whole).floorMod(copy.size)]
val b = copy[(i + whole + 1).floorMod(copy.size)]
points[i].set(Vec3(a).lerp(b, fraction))
}
}
private fun normalizedAxis(axis: Vec3): Vec3 {
val out = Vec3(axis)
if (out.lengthSquared() <= EPSILON * EPSILON) return vec(0f, 0f, 1f)
return out.normalize()
}
private fun principalDirection2D(points: List<Vec3>): Vec3 {
if (points.size < 2) return vec(1f, 0f, 0f)
val center = points.center()
var sxx = 0f
var sxy = 0f
var syy = 0f
for (point in points) {
val dx = point.x - center.x
val dy = point.y - center.y
sxx += dx * dx
sxy += dx * dy
syy += dy * dy
}
val trace = sxx + syy
val det = sxx * syy - sxy * sxy
val lambda = trace * 0.5f + sqrt((trace * trace * 0.25f - det).coerceAtLeast(0f))
val dir = when {
abs(sxy) > EPSILON -> vec(lambda - syy, sxy, 0f)
sxx >= syy -> vec(1f, 0f, 0f)
else -> vec(0f, 1f, 0f)
}
if (dir.lengthSquared() <= EPSILON * EPSILON) dir.set(1f, 0f, 0f)
dir.normalize()
val order = Vec3(points.last()).sub(points.first())
if (order.lengthSquared() > EPSILON * EPSILON && dir.dot(order) < 0f) {
dir.negate()
}
return dir
}
private fun principalDirection3D(points: List<Vec3>): Vec3 {
if (points.size < 2) return vec(1f, 0f, 0f)
val center = points.center()
var sxx = 0f
var sxy = 0f
var sxz = 0f
var syy = 0f
var syz = 0f
var szz = 0f
for (point in points) {
val dx = point.x - center.x
val dy = point.y - center.y
val dz = point.z - center.z
sxx += dx * dx
sxy += dx * dy
sxz += dx * dz
syy += dy * dy
syz += dy * dz
szz += dz * dz
}
val dir = vec(1f, 1f, 1f).normalize()
repeat(24) {
val x = sxx * dir.x + sxy * dir.y + sxz * dir.z
val y = sxy * dir.x + syy * dir.y + syz * dir.z
val z = sxz * dir.x + syz * dir.y + szz * dir.z
dir.set(x, y, z)
if (dir.lengthSquared() <= EPSILON * EPSILON) {
dir.set(1f, 0f, 0f)
} else {
dir.normalize()
}
}
val order = Vec3(points.last()).sub(points.first())
if (order.lengthSquared() > EPSILON * EPSILON && dir.dot(order) < 0f) {
dir.negate()
}
return dir
}
private fun canonicalizePoints(points: List<Vec3>, closed: Boolean): List<Vec3> {
if (points.isEmpty()) return emptyList()
val out = ArrayList<Vec3>(points.size)
for (point in points) {
if (out.isEmpty() || out[out.lastIndex].distance(point) > 1e-6f) {
out += Vec3(point)
}
}
if (closed && out.size > 1 && out[0].distance(out[out.lastIndex]) <= 1e-3f) {
out.removeAt(out.lastIndex)
}
return out
}
private fun wrap01(value: Float): Float {
var out = value % 1f
if (out < 0f) out += 1f
return out
}
private fun Int.floorMod(modulus: Int): Int {
if (modulus <= 0) return this
val out = this % modulus
return if (out < 0) out + modulus else out
}
private fun squaredDistance(a: Vec3, b: Vec3): Double {
val dx = (a.x - b.x).toDouble()
val dy = (a.y - b.y).toDouble()
val dz = (a.z - b.z).toDouble()
return dx * dx + dy * dy + dz * dz
}
private const val EPSILON = 1e-6f