Skip to content

StrokeLayerFrames.kt

src/jvmMain/kotlin/afield/ui/notation/StrokeLayerFrames.kt · 205 lines

package afield.ui.notation

import afield.geometry.path.FLine
import afield.graphics.render.filled
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import kotlin.math.hypot

What a window is sent to draw the stroke layer: every living stroke's lines as compact JSON, and the point index the strokes use to find each other.

StrokeSketch calls both. StrokeLayerFrames.indexedPositions runs once per stroke per physics step, over the lines that stroke has just drawn, and the flat x, y array it returns is what Stroke.closestOutput searches on the step after. StrokeLayerFrames.encode runs once per published frame, and its JSON reaches every window showing the document as stroke-layer-frame; strokeLayer.kt in the frontend draws it.

Only what a canvas needs crosses the wire. Coordinates are world units rounded to a tenth, colours arrive as CSS rather than as vectors, and a per-node colour or dot radius is sent only when some node sets one. Overshoot ticks (FLine.overshoot) are worked out here and sent as finished segments. StrokeVerbsTest asserts the paths, colours, dots and ticks of a frame, and that an ignored line stays out of the index.

object StrokeLayerFrames

One frame of the stroke layer, as the JSON the page draws (strokeLayer.kt in the frontend).

{"trail":0.18,"items":[{"c":"rgba(…)","w":2,"s":1,"f":0,"p":0,"d":[0,x,y,1,x,y,2,x1,y1,x2,y2,x,y,3],
                        "k":[…],"r":[…],"o":[x0,y0,x1,y1,…],"ok":[…]}]}

d is the line's path: 0 moveTo, 1 lineTo, 2 cubicTo, 3 close. k is present when any node has its own colour (one CSS colour or 0 per node), and the page then strokes segment by segment in the colour of the node that ends each. r is the dot radius per node for a pointed line whose nodes set pointSize (radius is pointSize * 3, the FLine convention). o is the overshoot ticks, worked out here, with ok their colours. Coordinates are world units, rounded to a tenth.

object StrokeLayerFrames {

fun encode(trail: Double, strokes: List<Pair<List<FLine>, Double>>): String

The frame for trail and every stroke's lines with its default width.

    fun encode(trail: Double, strokes: List<Pair<List<FLine>, Double>>): String {
        val out = StringBuilder(4096)
        out.append("{\"trail\":").append(num(trail)).append(",\"items\":[")
        var first = true
        for ((lines, width) in strokes) for (line in lines) {
            if (line.nodes.isEmpty()) continue
            if (!first) out.append(',')
            first = false
            item(out, line, width)
        }
        out.append("]}")
        return out.toString()
    }

fun indexedPositions(lines: List<FLine>): FloatArray

The positions Stroke.closestOutput searches: every node end point of lines not marked FLine.ignore, as x, y pairs.

body — 69 lines, from line 60
    fun indexedPositions(lines: List<FLine>): FloatArray {
        var n = 0
        for (l in lines) if (l.properties["ignore"] != true) n += l.nodes.size
        val a = FloatArray(n * 2)
        var i = 0
        for (l in lines) {
            if (l.properties["ignore"] == true) continue
            for (node in l.nodes) {
                val to = node.toOrNull
                if (to == null) {
                    a[i++] = Float.NaN; a[i++] = Float.NaN
                } else {
                    a[i++] = to.x; a[i++] = to.y
                }
            }
        }
        return if (a.any { it.isNaN() }) a.filterNot { it.isNaN() }.toFloatArray() else a
    }

    private fun item(out: StringBuilder, line: FLine, defaultWidth: Double) {
        val color = line.properties["color"] as? Vec4
        val width = (line.properties["thickness"] as? Number)?.toDouble() ?: defaultWidth
        out.append("{\"c\":\"").append(css(color)).append("\",\"w\":").append(num(width))
        out.append(",\"s\":").append(if (line.stroked) 1 else 0)
        out.append(",\"f\":").append(if (line.filled) 1 else 0)
        val pointed = line.pointed
        out.append(",\"p\":").append(if (pointed) 1 else 0)
        if (pointed) {
            val size = (line.properties["pointSize"] as? Number)?.toDouble() ?: 1.0
            out.append(",\"pr\":").append(num(size * 3))
        }
        out.append(",\"d\":[")
        var firstOp = true
        fun op(code: Int, vararg v: Vec3) {
            if (!firstOp) out.append(',')
            firstOp = false
            out.append(code)
            for (p in v) out.append(',').append(num(p.x.toDouble())).append(',').append(num(p.y.toDouble()))
        }
        for (node in line.nodes) when (node) {
            is FLine.MoveTo -> op(0, node.to)
            is FLine.LineTo -> op(1, node.to)
            is FLine.CubicTo -> op(2, node.c1, node.c2, node.to)
            is FLine.Close -> op(3)
        }
        out.append(']')

        val nodeColors = line.nodes.map { it.properties["color"] as? Vec4 }
        val hasNodeColors = nodeColors.any { it != null }
        if (hasNodeColors) {
            out.append(",\"k\":[")
            nodeColors.forEachIndexed { i, c ->
                if (i > 0) out.append(',')
                if (c == null) out.append('0') else out.append('"').append(css(c)).append('"')
            }
            out.append(']')
        }
        if (pointed && line.nodes.any { it.properties["pointSize"] != null }) {
            out.append(",\"r\":[")
            val base = (line.properties["pointSize"] as? Number)?.toDouble() ?: 1.0
            line.nodes.forEachIndexed { i, node ->
                if (i > 0) out.append(',')
                out.append(num(((node.properties["pointSize"] as? Number)?.toDouble() ?: base) * 3))
            }
            out.append(']')
        }
        if (line.stroked) overshoots(out, line, nodeColors, hasNodeColors, color)
        out.append('}')
    }

The overshoot ticks of every open subpath: from each end, straight on along the end's tangent, for the subpath's last node's FLine.Node.overshoot, else the line's FLine.overshoot.

source — 44 lines, from line 134
    private fun overshoots(out: StringBuilder, line: FLine, nodeColors: List<Vec4?>, perNode: Boolean, lineColor: Vec4?) {
        val lineOvershoot = (line.properties["overshoot"] as? Number)?.toDouble()
        val nodes = line.nodes
        val ticks = ArrayList<Double>()
        val tickColors = ArrayList<Vec4?>()
        var start = 0
        while (start < nodes.size) {
            var end = start + 1
            while (end < nodes.size && nodes[end] !is FLine.MoveTo) end++
            // nodes[start until end] is one subpath
            val last = end - 1
            val closed = nodes[last] is FLine.Close
            if (!closed && last > start) {
                val len = (nodes[last].properties["overshoot"] as? Number)?.toDouble() ?: lineOvershoot ?: 0.0
                if (len > 0) {
                    val p0 = nodes[start].to
                    val n1 = nodes[start + 1]
                    val q0 = if (n1 is FLine.CubicTo && n1.c1.distance(p0) > 1e-4f) n1.c1 else n1.to
                    val pn = nodes[last].to
                    val nl = nodes[last]
                    val qn = if (nl is FLine.CubicTo && nl.c2.distance(pn) > 1e-4f) nl.c2 else line.endPointOf(last - 1)
                    val c = if (perNode) nodeColors[last] ?: lineColor else lineColor
                    if (tick(ticks, p0, q0, len)) tickColors += c
                    if (tick(ticks, pn, qn, len)) tickColors += c
                }
            }
            start = end
        }
        if (ticks.isEmpty()) return
        out.append(",\"o\":[")
        ticks.forEachIndexed { i, v ->
            if (i > 0) out.append(',')
            out.append(num(v))
        }
        out.append(']')
        if (perNode) {
            out.append(",\"ok\":[")
            tickColors.forEachIndexed { i, c ->
                if (i > 0) out.append(',')
                out.append('"').append(css(c)).append('"')
            }
            out.append(']')
        }
    }

A tick from p away from q, len long.

    private fun tick(into: MutableList<Double>, p: Vec3, q: Vec3, len: Double): Boolean {
        val dx = (p.x - q.x).toDouble()
        val dy = (p.y - q.y).toDouble()
        val d = hypot(dx, dy)
        if (d < 0.01) return false
        into += p.x.toDouble(); into += p.y.toDouble()
        into += p.x + dx / d * len; into += p.y + dy / d * len
        return true
    }

fun css(c: Vec4?): String

c as a CSS colour; white when null (an FLine's default).

    fun css(c: Vec4?): String {
        if (c == null) return "rgba(255,255,255,1)"
        fun ch(v: Float) = Math.round(v.coerceIn(0f, 1f) * 255)
        return "rgba(${ch(c.x)},${ch(c.y)},${ch(c.z)},${num(c.w.coerceIn(0f, 1f).toDouble(), 3)})"
    }

    private fun num(v: Double, places: Int = 1): String {
        if (!v.isFinite()) return "0"
        val scale = if (places == 1) 10.0 else 1000.0
        val r = Math.round(v * scale) / scale
        val l = r.toLong()
        return if (r == l.toDouble()) l.toString() else r.toString()
    }
}