Skip to content

FLineInstructionTrajectories.kt

src/jvmMain/kotlin/afield/geometry/path/FLineInstructionTrajectories.kt · 397 lines

Traces how the points of a changing drawing move from frame to frame, as one trajectory line per point.

FLineSideways.update takes each frame's line(s) and matches their anchor points to the previous frame's; FLineSideways.trajectories and FLineSideways.trajectoryMap return the tracks drawn so far.

package afield.geometry.path

import afield.geometry.spatial.HungarianAssignment
import afield.lang.remotejs.JsFun

import afield.graphics.render.FLineMap
import afield.ui.visualization.lines

import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import kotlin.math.max

class FLineSideways

Incrementally tracks drawing-instruction anchor points across frames and emits one trajectory FLine per tracked point.

Call update every frame with the current line(s). Matching is done with a small Hungarian assignment solver, which is robust to topology changes where points appear, disappear, or intermediate instructions get dropped. For stable topology, a fast path can skip Hungarian and match by instruction index when all point deltas are under stableTopologyDistanceThreshold.

class FLineSideways(
    val maxMatchDistance: Float = Float.POSITIVE_INFINITY,
    val stableTopologyDistanceThreshold: Float = if (maxMatchDistance.isFinite()) maxMatchDistance else 0f,
    val maxMissedFrames: Int = 8,
    val breakOnGap: Boolean = true,
    val includeCloseInstructions: Boolean = false,
    val instructionIndexWeight: Double = 0.05,
    val lineKeySelector: (lineIndex: Int, line: FLine) -> Any = { lineIndex, _ -> lineIndex }
) {

init

    init {
        require(maxMissedFrames >= 0) { "maxMissedFrames must be >= 0" }
        require(instructionIndexWeight >= 0.0) { "instructionIndexWeight must be >= 0" }
        require(stableTopologyDistanceThreshold >= 0f) { "stableTopologyDistanceThreshold must be >= 0" }
    }

    private data class Observation(
        val lineKey: Any,
        val instructionIndex: Int,
        val position: Vec3,
        val color: Vec4
    )

    private data class Track(
        val id: Long,
        val lineKey: Any,
        val trajectory: FLine,
        var lastPoint: Vec3,
        var lastInstructionIndex: Int,
        var missedFrames: Int = 0,
        var gapPending: Boolean = false
    )

    private val activeTracks = LinkedHashMap<Long, Track>()
    private val archivedTracks = LinkedHashMap<Long, Track>()
    private var nextTrackId = 1L
    private val maxMatchDistanceSq = if (maxMatchDistance.isFinite()) {
        val d = maxMatchDistance.toDouble()
        d * d
    } else {
        null
    }
    private val stableTopologyDistanceThresholdSq = stableTopologyDistanceThreshold.squaredPositiveOrNull()

val activeTrackCount: Int

Number of currently active tracks.

    val activeTrackCount: Int
        get() = activeTracks.size

val totalTrackCount: Int

Number of total tracks (active + archived).

    val totalTrackCount: Int
        get() = activeTracks.size + archivedTracks.size

fun reset()

Clears all state and trajectories.

    fun reset() {
        activeTracks.clear()
        archivedTracks.clear()
        nextTrackId = 1L
    }

fun update(line: FLine): List<FLine> = update(listOf(line))

Update with a single line frame.

fun update(lines: Iterable<FLine>): List<FLine>

Update with a frame of lines.

The default line identity is the input index, but you can change it with lineKeySelector.

    fun update(lines: Iterable<FLine>): List<FLine> {
        val keyed = lines.mapIndexed { i, line -> lineKeySelector(i, line) to line }
        return updateKeyed(keyed)
    }

fun update(lines: Map<*, FLine>): List<FLine>

Update with explicitly keyed lines (handy when source line order can change).

    fun update(lines: Map<*, FLine>): List<FLine> {
        val keyed = lines.entries.map { (key, line) -> (key ?: "_null_key_") to line }
        return updateKeyed(keyed)
    }

fun trajectories(includeArchived: Boolean = true): List<FLine>

Return trajectories ordered by creation id.

    fun trajectories(includeArchived: Boolean = true): List<FLine> {
        val out = ArrayList<Pair<Long, FLine>>(totalTrackCount)
        activeTracks.values.forEach { out.add(it.id to it.trajectory) }
        if (includeArchived) archivedTracks.values.forEach { out.add(it.id to it.trajectory) }
        out.sortBy { it.first }
        return out.map { it.second }
    }

fun trajectoryMap(includeArchived: Boolean = true): FLineMap

Return trajectories as an FLineMap (track_<id> keys).

body — 266 lines, from line 130
    fun trajectoryMap(includeArchived: Boolean = true): FLineMap {
        val out = FLineMap()
        activeTracks.values.sortedBy { it.id }.forEach { out["track_${it.id}"] = it.trajectory }
        if (includeArchived) {
            archivedTracks.values.sortedBy { it.id }.forEach { out["track_${it.id}"] = it.trajectory }
        }
        return out
    }

    private fun updateKeyed(keyedLines: List<Pair<Any, FLine>>): List<FLine> {
        val observations = ArrayList<Observation>()
        keyedLines.forEach { (lineKey, line) ->
            observations.addAll(extractObservations(lineKey, line))
        }

        val groupedObservations = observations.groupBy { it.lineKey }
        val groupedTracks = activeTracks.values.groupBy { it.lineKey }

        val allKeys = LinkedHashSet<Any>()
        allKeys.addAll(groupedTracks.keys)
        allKeys.addAll(groupedObservations.keys)

        val matchedTrackIds = HashSet<Long>()

        for (key in allKeys) {
            val tracks = groupedTracks[key].orEmpty()
            val obs = groupedObservations[key].orEmpty()
            matchGroup(tracks, obs, matchedTrackIds)
        }

        val toArchive = ArrayList<Long>()
        activeTracks.values.forEach { track ->
            if (!matchedTrackIds.contains(track.id)) {
                track.missedFrames += 1
                if (breakOnGap) track.gapPending = true
                if (track.missedFrames > maxMissedFrames) {
                    toArchive.add(track.id)
                }
            }
        }
        toArchive.forEach { id ->
            val removed = activeTracks.remove(id)
            if (removed != null) archivedTracks[id] = removed
        }

        return trajectories()
    }

    private fun matchGroup(
        tracks: List<Track>,
        observations: List<Observation>,
        matchedTrackIds: MutableSet<Long>
    ) {
        if (tracks.isEmpty()) {
            observations.forEach { observation ->
                matchedTrackIds.add(spawnTrack(observation).id)
            }
            return
        }
        if (observations.isEmpty()) return
        if (tryStableTopologyFastPath(tracks, observations, matchedTrackIds)) return

        val n = max(tracks.size, observations.size)
        val unmatchedCost = unmatchedCost()
        val disallowCost = unmatchedCost * 1024.0
        val indexScale = estimateInstructionStepScale(observations)

        val cost = Array(n) { DoubleArray(n) { 0.0 } }
        for (row in 0 until n) {
            for (col in 0 until n) {
                cost[row][col] = when {
                    row < tracks.size && col < observations.size -> pairCost(
                        track = tracks[row],
                        observation = observations[col],
                        indexScale = indexScale,
                        disallowCost = disallowCost
                    )

                    row < tracks.size && col >= observations.size -> unmatchedCost
                    row >= tracks.size && col < observations.size -> unmatchedCost
                    else -> 0.0
                }
            }
        }

        val assignment = HungarianAssignment.solve(cost)
        val usedObservation = BooleanArray(observations.size)

        tracks.indices.forEach { row ->
            val col = assignment[row]
            if (col in observations.indices) {
                val c = cost[row][col]
                if (c <= unmatchedCost) {
                    applyMatch(tracks[row], observations[col])
                    matchedTrackIds.add(tracks[row].id)
                    usedObservation[col] = true
                }
            }
        }

        observations.indices.forEach { col ->
            if (!usedObservation[col]) {
                matchedTrackIds.add(spawnTrack(observations[col]).id)
            }
        }
    }

    private fun tryStableTopologyFastPath(
        tracks: List<Track>,
        observations: List<Observation>,
        matchedTrackIds: MutableSet<Long>
    ): Boolean {
        val thresholdSq = stableTopologyDistanceThresholdSq ?: return false
        if (tracks.size != observations.size || tracks.isEmpty()) return false

        val observationByInstructionIndex = HashMap<Int, Observation>(observations.size * 2)
        for (observation in observations) {
            if (observationByInstructionIndex.put(observation.instructionIndex, observation) != null) {
                return false
            }
        }

        val matchedObservations = ArrayList<Observation>(tracks.size)
        for (track in tracks) {
            if (track.missedFrames != 0) return false

            val observation = observationByInstructionIndex.remove(track.lastInstructionIndex) ?: return false
            val spatial = squaredDistance(track.lastPoint, observation.position)
            if (spatial > thresholdSq) return false
            if (maxMatchDistanceSq != null && spatial > maxMatchDistanceSq) return false
            matchedObservations.add(observation)
        }

        if (observationByInstructionIndex.isNotEmpty()) return false

        for (i in tracks.indices) {
            val track = tracks[i]
            val observation = matchedObservations[i]
            applyMatch(track, observation)
            matchedTrackIds.add(track.id)
        }
        return true
    }

    private fun applyMatch(track: Track, observation: Observation) {
        if (track.trajectory.nodes.isEmpty()) {
            track.trajectory.moveTo(observation.position)
        } else if (track.gapPending && breakOnGap) {
            track.trajectory.moveTo(observation.position)
        } else {
            track.trajectory.lineTo(observation.position)
        }
        applyObservationColor(track.trajectory, observation.color)
        track.lastPoint.set(observation.position)
        track.lastInstructionIndex = observation.instructionIndex
        track.missedFrames = 0
        track.gapPending = false

        track.trajectory.properties["lastInstructionIndex"] = observation.instructionIndex
    }

    private fun spawnTrack(observation: Observation): Track {
        val id = nextTrackId++
        val trajectory = FLine()
        trajectory.moveTo(observation.position)
        applyObservationColor(trajectory, observation.color)
        trajectory.properties["trackId"] = id
        trajectory.properties["lineKey"] = observation.lineKey
        trajectory.properties["firstInstructionIndex"] = observation.instructionIndex
        trajectory.properties["lastInstructionIndex"] = observation.instructionIndex
        return Track(
            id = id,
            lineKey = observation.lineKey,
            trajectory = trajectory,
            lastPoint = Vec3(observation.position),
            lastInstructionIndex = observation.instructionIndex
        ).also { activeTracks[id] = it }
    }

    private fun extractObservations(lineKey: Any, line: FLine): List<Observation> {
        val out = ArrayList<Observation>(line.nodes.size)
        var subpathStart: Vec3? = null
        var currentColor = (line.properties["color"] as? Vec4)?.let { Vec4(it) } ?: Vec4(0.8f, 0.8f, 0.8f, 0.5f)

        line.nodes.forEachIndexed { instructionIndex, node ->
            (node.properties["color"] as? Vec4)?.let { currentColor = Vec4(it) }
            when (node) {
                is FLine.MoveTo -> {
                    val p = Vec3(node.to)
                    out.add(Observation(lineKey, instructionIndex, p, Vec4(currentColor)))
                    subpathStart = Vec3(p)
                }

                is FLine.LineTo -> out.add(Observation(lineKey, instructionIndex, Vec3(node.to), Vec4(currentColor)))
                is FLine.CubicTo -> out.add(Observation(lineKey, instructionIndex, Vec3(node.to), Vec4(currentColor)))
                is FLine.Close -> {
                    if (includeCloseInstructions && subpathStart != null) {
                        out.add(Observation(lineKey, instructionIndex, Vec3(subpathStart), Vec4(currentColor)))
                    }
                }
            }
        }
        return out
    }

    private fun applyObservationColor(trajectory: FLine, color: Vec4) {
        trajectory.properties["color"] = Vec4(color)
        if (trajectory.nodes.isNotEmpty()) {
            trajectory.here.properties["color"] = Vec4(color)
        }
    }

    private fun pairCost(
        track: Track,
        observation: Observation,
        indexScale: Double,
        disallowCost: Double
    ): Double {
        val spatial = squaredDistance(track.lastPoint, observation.position)

        if (maxMatchDistanceSq != null) {
            if (spatial > maxMatchDistanceSq) return disallowCost
        }

        val idxDelta = (track.lastInstructionIndex - observation.instructionIndex).toDouble()
        val indexCost = idxDelta * idxDelta * instructionIndexWeight * indexScale
        return spatial + indexCost
    }

    private fun unmatchedCost(): Double {
        if (maxMatchDistance.isFinite()) {
            val d = maxMatchDistance.toDouble().coerceAtLeast(1e-6)
            return d * d
        }
        return 1e18
    }

    private fun estimateInstructionStepScale(observations: List<Observation>): Double {
        if (observations.size < 2) return 1.0
        val sorted = observations.sortedBy { it.instructionIndex }
        var accum = 0.0
        var count = 0
        for (i in 1 until sorted.size) {
            val step = squaredDistance(sorted[i - 1].position, sorted[i].position)
            if (step > 1e-12) {
                accum += step
                count++
            }
        }
        return if (count == 0) 1.0 else accum / count
    }

    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 fun Float.squaredPositiveOrNull(): Double? {
        if (!isFinite()) return null
        if (this <= 0f) return null
        val d = toDouble()
        return d * d
    }
}