Skip to content

FLineEditing.kt

src/jvmMain/kotlin/afield/geometry/path/FLineEditing.kt · 245 lines

package afield.geometry.path

import afield.lang.remotejs.JsFun

import afield.ui.visualization.FitTransform
import afield.ui.visualization.lines

import afield.app.protocol.FLineAddLine
import afield.app.protocol.FLineAppendLine
import afield.app.protocol.FLineBeginLine
import afield.app.protocol.FLineDrawStyle
import afield.app.protocol.FLineEditSpace
import afield.app.protocol.FLineEndLine
import afield.app.protocol.FLineHandleKind
import afield.app.protocol.FLinePoint3
import afield.app.protocol.FLinePointRef
import afield.app.protocol.FLineTranslatePoints
import afield.math.linalg.Vec3
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

The server side of an editable Box.lines(..., editable = true) drawing: an edit session per widget that applies the browser's point drags and new strokes to the live FLines.

Box.lines returns an EditableFLineViewState when editable = true (null otherwise); call EditableFLineViewState.currentLines to read the lines as they are after the user's edits.

class EditableFLineViewState internal constructor

class EditableFLineViewState internal constructor(
    val sessionId: String
) {

fun currentLines(): List<FLine> = getFLineEditSession(sessionId)?.currentLines() ?: emptyList()

internal class FLineEditSession

internal class FLineEditSession(
    val sessionId: String,
    private val lines: MutableList<FLine>,
    private val fitTransform: FitTransform?,
    private val drawStyle: FLineDrawStyle,
    private val onCommit: ((List<FLine>) -> Unit)? = null
) {
    private val draftLines = LinkedHashMap<String, FLine>()

fun currentLines(): List<FLine> = synchronized(this) { lines.toList() }

fun commit()

A completed gesture publishes a detached snapshot, so clients may validate or undo it.

    fun commit() {
        val snapshot = synchronized(this) { lines.map { it.copy() } }
        onCommit?.invoke(snapshot)
    }

fun drawStyle(): FLineDrawStyle = drawStyle.copy(color = drawStyle.color.copyOf())

fun translate(update: FLineTranslatePoints)

    fun translate(update: FLineTranslatePoints) {
        val refs = update.points.distinct()
        if (refs.isEmpty()) return
        val delta = when (update.space) {
            FLineEditSpace.MODEL -> vec(update.dx, update.dy, update.dz)
            FLineEditSpace.VIEW -> viewDeltaToModel(update.dx, update.dy, update.dz)
        }
        if (!delta.isFinite()) return

        val touched = LinkedHashSet<FLine>()
        synchronized(this) {
            refs.forEach { ref ->
                val point = resolve(ref) ?: return@forEach
                point.add(delta)
                touched += lines[ref.lineIndex]
            }
            touched.forEach { it.rev++ }
        }
    }

fun beginLine(update: FLineBeginLine): FLine?

    fun beginLine(update: FLineBeginLine): FLine? {
        val firstPoint = modelPoint(update.point, update.space).takeIf { it.isFinite() } ?: return null
        val line = styledLine(update.style ?: drawStyle).apply {
            moveTo(firstPoint)
        }

        synchronized(this) {
            draftLines.remove(update.lineId)?.let { lines.remove(it) }
            lines += line
            draftLines[update.lineId] = line
        }
        return line
    }

fun appendLine(update: FLineAppendLine)

    fun appendLine(update: FLineAppendLine) {
        val modelPoints = update.points.mapNotNull { modelPoint(it, update.space).takeIf { it.isFinite() } }
        if (modelPoints.isEmpty()) return

        synchronized(this) {
            val line = draftLines[update.lineId] ?: return
            var changed = false
            modelPoints.forEach { point ->
                val last = line.lastAnchorPoint()
                if (last == null || !last.samePosition(point)) {
                    line.lineTo(point)
                    changed = true
                }
            }
            if (changed) line.rev++
        }
    }

fun endLine(update: FLineEndLine)

    fun endLine(update: FLineEndLine) {
        synchronized(this) {
            val line = draftLines.remove(update.lineId) ?: return
            if (line.anchorPointCount() < 2) {
                lines.remove(line)
            } else {
                line.rev++
            }
        }
    }

fun addLine(update: FLineAddLine): FLine?

    fun addLine(update: FLineAddLine): FLine? {
        val sourcePoints = update.points.distinct()
        if (sourcePoints.size < 2) return null

        val modelPoints = sourcePoints.mapNotNull { modelPoint(it, update.space).takeIf { it.isFinite() } }

        if (modelPoints.size < 2) return null

        val line = styledLine(update.style ?: drawStyle).apply {
            moveTo(modelPoints.first())
            modelPoints.drop(1).forEach { lineTo(it) }
            if (update.closed && modelPoints.size >= 3) close()
        }

        synchronized(this) {
            lines += line
        }
        return line
    }

fun clear()

body — 45 lines, from line 146
    fun clear() {
        synchronized(this) {
            draftLines.clear()
            lines.clear()
        }
    }

    private fun viewDeltaToModel(dx: Double, dy: Double, dz: Double): Vec3 {
        val scale = fitTransform?.scale?.takeIf { kotlin.math.abs(it) > 1e-6f } ?: 1f
        return vec(dx / scale, dy / scale, dz / scale)
    }

    private fun viewPointToModel(point: FLinePoint3): Vec3 {
        val scale = fitTransform?.scale?.takeIf { kotlin.math.abs(it) > 1e-6f } ?: 1f
        val offsetX = fitTransform?.offsetX ?: 0f
        val offsetY = fitTransform?.offsetY ?: 0f
        return vec(point.x / scale - offsetX, point.y / scale - offsetY, point.z)
    }

    private fun modelPoint(point: FLinePoint3, space: FLineEditSpace): Vec3 = when (space) {
        FLineEditSpace.MODEL -> vec(point.x, point.y, point.z)
        FLineEditSpace.VIEW -> viewPointToModel(point)
    }

    private fun styledLine(style: FLineDrawStyle) = fline {
        color = style.color.toVec4()
        thickness = style.thickness
        stroked = style.stroked
        filled = style.filled
        pointed = style.pointed
        pointSize = style.pointSize
    }

    private fun resolve(ref: FLinePointRef): Vec3? {
        val line = lines.getOrNull(ref.lineIndex) ?: return null
        val node = line.nodes.getOrNull(ref.nodeIndex) ?: return null
        return when (ref.handle) {
            FLineHandleKind.TO -> if (node is FLine.Close) null else node.to
            FLineHandleKind.C1 -> (node as? FLine.CubicTo)?.c1
            FLineHandleKind.C2 -> (node as? FLine.CubicTo)?.c2
        }
    }
}

private val fLineEditSessions = ConcurrentHashMap<String, FLineEditSession>()

internal fun registerFLineEditSession(lines: List<FLine>, fitTransform: FitTransform?, onCommit: ((List<FLine>) -> Unit)?

internal fun registerFLineEditSession(lines: List<FLine>, fitTransform: FitTransform?, onCommit: ((List<FLine>) -> Unit)? = null): FLineEditSession {
    val mutableLines = lines.toMutableList()
    val session = FLineEditSession(
        sessionId = "fline-edit-${UUID.randomUUID()}",
        lines = mutableLines,
        fitTransform = fitTransform,
        drawStyle = defaultDrawStyleFor(mutableLines),
        onCommit = onCommit
    )
    fLineEditSessions[session.sessionId] = session
    return session
}

internal fun getFLineEditSession(sessionId: String): FLineEditSession?

internal fun getFLineEditSession(sessionId: String): FLineEditSession? = fLineEditSessions[sessionId]

internal fun removeFLineEditSession(sessionId: String) { fLineEditSessions.remove(sessionId) }

private fun defaultDrawStyleFor(lines: List<FLine>): FLineDrawStyle {
    val template = lines.lastOrNull() ?: return FLineDrawStyle()
    val color = template.color
    return FLineDrawStyle(
        color = floatArrayOf(color.x, color.y, color.z, color.w),
        thickness = template.thickness ?: 2.0,
        stroked = true,
        filled = false,
        pointed = template.pointed,
        pointSize = template.pointSize
    )
}

private fun FloatArray.toVec4() = afield.math.linalg.vec(
    getOrElse(0) { 1f },
    getOrElse(1) { 1f },
    getOrElse(2) { 1f },
    getOrElse(3) { 1f }
)


private fun Vec3.samePosition(other: Vec3, epsilon: Float = 1e-5f): Boolean =
    kotlin.math.abs(x - other.x) <= epsilon &&
        kotlin.math.abs(y - other.y) <= epsilon &&
        kotlin.math.abs(z - other.z) <= epsilon

private fun FLine.lastAnchorPoint(): Vec3? = when (val node = nodes.lastOrNull()) {
    is FLine.MoveTo -> node.to
    is FLine.LineTo -> node.to
    is FLine.CubicTo -> node.to
    else -> null
}

private fun FLine.anchorPointCount(): Int = nodes.count {
    it is FLine.MoveTo || it is FLine.LineTo || it is FLine.CubicTo
}