Skip to content

StrokeVerbs.kt

src/jvmMain/kotlin/afield/ui/notation/StrokeVerbs.kt · 366 lines

package afield.ui.notation

import afield.document.properties.Delegation
import afield.geometry.path.ClassicPerlin2D
import afield.geometry.path.FLine
import afield.geometry.path.byAtomizing
import afield.geometry.path.byTransformingTo
import afield.geometry.path.byWiggling
import afield.geometry.path.catmullRomCentripetal
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.math.linalg.Vec2
import afield.math.linalg.Vec4
import afield.math.linalg.vec
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt

The drawing vocabulary of a living stroke: Perlin noise, curve maths, and the verbs that turn a list of points into an FLine.

StrokeVerbs carries the verbs, and Stroke inherits it, so eachStroke { } code calls them directly. Some are short names over the shared geometry: smooth is catmullRomCentripetal and atomize is FLine.byAtomizing, while wiggle and transformTo have a curve form of their own and a line form that calls FLine.byWiggling and FLine.byTransformingTo. The noise, the resampling and the rest of the curve arithmetic are this file's own.

The three FLine properties here are read by the stroke layer rather than by any verb. FLine.overshoot runs each open subpath of a line past its ends, a node's overshoot overrides that for the subpath it ends, and FLine.ignore keeps a line out of the point index Stroke.closestOutput searches, so a guide or a stitch does not attract the strokes around it.

StrokeVerbsTest pins the resampling, the curve maths and the noise.

var FLine.overshoot by Delegation<Double?>()

How far past each end the stroke layer draws every subpath of this line, along its tangent, in the line's own units: the sketchy overrun of a pen that does not stop exactly where it means to. null (the default) draws none. Only the stroke layer (eachStroke) draws it.

var FLine.Node.overshoot by Delegation<Double?>()

Overshoot for the subpath this node ends, overriding the line's FLine.overshoot at both of that subpath's ends. A line of separate segments (StrokeVerbs.atomize) can so overrun each segment by its own amount.

var FLine.ignore by Delegation<Boolean> { false }

Leave this line out of what other strokes find with Stroke.closestOutput: guides, stitches and other decoration a neighbouring stroke should not be drawn towards. false by default.

open class StrokeVerbs

The vocabulary a living stroke is written in (eachStroke): the madp line demo's noise, curve maths and drawing verbs, under the demo's names.

A curve is a List<Vec2>: the stroke's points, a resampled copy of them, the result of curve maths. Drawing verbs turn curves into FLines (polyline, smooth, arcs, dots) or work on FLines directly (atomize, wiggle), and a stroke draws whatever list of FLines it returns. A line's colour is its color (a node's color overrides it from that node on, segment by segment, which is how a single line carries a gradient); filled, FLine.overshoot and FLine.ignore finish the demo's grammar.

Inherited by Stroke, so inside eachStroke { } these are plain calls: resample(points, 72).

open class StrokeVerbs {

noise and waves

fun perlin2D(x: Double, y: Double): Double = ClassicPerlin2D.noise(x, y)

Classic 2D Perlin noise, about -1..1, with the madp demos' permutation table.

fun clamp01(v: Double): Double = v.coerceIn(0.0, 1.0)

v clamped to 0..1.

fun triWave(x: Double): Double = 1 - 4 * abs(x - floor(x) - 0.5)

A triangle wave of period 1 in -1..1: -1 at whole numbers, 1 half way between.

fun tri01(x: Double): Double = 0.5 + 0.5 * triWave(x)

triWave in 0..1.

fun heldNoise(seed: Double, t: Double, rate: Double): Double

Noise that holds still and then jumps, rate times a second: broken, stepped motion.

    fun heldNoise(seed: Double, t: Double, rate: Double): Double =
        perlin2D(seed * 0.173 + 11.7, floor(t * rate) * 0.193 + seed * 0.071)

fun driftNoise(seed: Double, t: Double, rate: Double): Double

Noise that drifts smoothly through time at rate: breathing motion.

    fun driftNoise(seed: Double, t: Double, rate: Double): Double =
        perlin2D(seed * 0.173 + t * rate, seed * 0.271 - t * rate * 0.7)

curves

fun length(curve: List<Vec2>): Double

The length of curve, along it.

    fun length(curve: List<Vec2>): Double {
        var total = 0.0
        for (i in 1 until curve.size) total += dist(curve[i - 1], curve[i])
        return total
    }

fun resample(curve: List<Vec2>, n: Int): List<Vec2>

curve resampled to n points evenly spaced along it, ends kept. A curve too short to resample comes back as it is.

    fun resample(curve: List<Vec2>, n: Int): List<Vec2> {
        if (curve.size < 2 || n < 2) return curve
        val d = DoubleArray(curve.size)
        for (i in 1 until curve.size) d[i] = d[i - 1] + dist(curve[i - 1], curve[i])
        val total = d.last()
        if (total < 1e-6) return curve
        val out = ArrayList<Vec2>(n)
        var seg = 0
        for (i in 0 until n) {
            val target = i.toDouble() / (n - 1) * total
            while (seg < curve.size - 2 && d[seg + 1] < target) seg++
            val span = d[seg + 1] - d[seg]
            val f = if (span > 0) ((target - d[seg]) / span).coerceIn(0.0, 1.0) else 0.0
            out += lerp(curve[seg], curve[seg + 1], f)
        }
        return out
    }

fun resample(points: List<StrokePoint>, n: Int): List<Vec2>

A stroke's points, at their current positions, resampled to n points.

    @JvmName("resamplePoints")
    fun resample(points: List<StrokePoint>, n: Int): List<Vec2> = resample(points.map { it.at }, n)

fun warp(curve: List<Vec2>, fn: (Vec2) -> Vec2): List<Vec2> = curve.map(fn)

Every point of curve moved by fn.

fun wiggle(curve: List<Vec2>, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): List<Vec2>

curve pushed about by Perlin noise: amplitude units, frequency cycles per unit, phase slides the field.

    fun wiggle(curve: List<Vec2>, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): List<Vec2> =
        curve.map { p ->
            val x = p.x + perlin2D(p.x * frequency + phase, p.y * frequency + phase + 100) * amplitude
            val y = p.y + perlin2D(x * frequency + phase + 200, p.y * frequency + phase + 300) * amplitude
            vec(x, y)
        }

fun wiggle(line: FLine, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): FLine

Every node of line pushed about by Perlin noise, as wiggle does to a curve.

    fun wiggle(line: FLine, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): FLine =
        line.byWiggling(amplitude, frequency, phase)

fun transformTo(curve: List<Vec2>, start: Vec2, end: Vec2): List<Vec2>

curve moved, rotated and scaled as one shape so its first point lands on start and its last on end.

    fun transformTo(curve: List<Vec2>, start: Vec2, end: Vec2): List<Vec2> {
        if (curve.size < 2) return curve
        val a = curve.first()
        val b = curve.last()
        val sLen = dist(a, b)
        if (sLen < 1e-6) return curve
        val scale = dist(start, end) / sLen
        val rot = atan2((end.y - start.y).toDouble(), (end.x - start.x).toDouble()) - atan2((b.y - a.y).toDouble(), (b.x - a.x).toDouble())
        val c = cos(rot)
        val s = sin(rot)
        return curve.map { p ->
            val dx = (p.x - a.x).toDouble()
            val dy = (p.y - a.y).toDouble()
            vec(start.x + (dx * c - dy * s) * scale, start.y + (dx * s + dy * c) * scale)
        }
    }

fun transformTo(line: FLine, start: Vec2, end: Vec2): FLine = line.byTransformingTo(start, end)

line moved, rotated and scaled so its first node lands on start and its last on end.

fun add(a: List<Vec2>, b: List<Vec2>): List<Vec2>

a displaced by b's shape: both are resampled to their average point count, and each point of a moves by how far the matching point of b is from b's centroid.

    fun add(a: List<Vec2>, b: List<Vec2>): List<Vec2> {
        val (ra, rb) = pair(a, b)
        val cb = centroid(rb)
        return ra.indices.map { vec(ra[it].x + rb[it].x - cb.x, ra[it].y + rb[it].y - cb.y) }
    }

fun subtract(a: List<Vec2>, b: List<Vec2>): List<Vec2>

a displaced against b's shape: add with b's displacements reversed.

    fun subtract(a: List<Vec2>, b: List<Vec2>): List<Vec2> {
        val (ra, rb) = pair(a, b)
        val cb = centroid(rb)
        return ra.indices.map { vec(ra[it].x - (rb[it].x - cb.x), ra[it].y - (rb[it].y - cb.y)) }
    }

fun project(a: List<Vec2>, b: List<Vec2>): List<Vec2>

Each point of a, as a displacement from a's centroid, projected onto the direction of the matching point of b from b's centroid: a squeezed into b's directions.

    fun project(a: List<Vec2>, b: List<Vec2>): List<Vec2> {
        val (ra, rb) = pair(a, b)
        val ca = centroid(ra)
        val cb = centroid(rb)
        return ra.indices.map {
            val dax = (ra[it].x - ca.x).toDouble()
            val day = (ra[it].y - ca.y).toDouble()
            val dbx = (rb[it].x - cb.x).toDouble()
            val dby = (rb[it].y - cb.y).toDouble()
            val lenSq = dbx * dbx + dby * dby
            val s = if (lenSq > 1e-10) (dax * dbx + day * dby) / lenSq else 0.0
            vec(ca.x + dbx * s, ca.y + dby * s)
        }
    }

fun scale(curve: List<Vec2>, factor: Double): List<Vec2>

curve scaled by factor about its centroid.

    fun scale(curve: List<Vec2>, factor: Double): List<Vec2> {
        if (curve.size < 2) return curve
        val c = centroid(curve)
        return curve.map { vec(c.x + (it.x - c.x) * factor, c.y + (it.y - c.y) * factor) }
    }

fun scale(curve: List<Vec2>, by: List<Vec2>): List<Vec2>

curve scaled point by point by how far by's matching point is from by's centroid, relative to its mean radius.

    fun scale(curve: List<Vec2>, by: List<Vec2>): List<Vec2> {
        if (curve.size < 2) return curve
        val (ra, rb) = pair(curve, by)
        val ca = centroid(ra)
        val cb = centroid(rb)
        val mean = rb.sumOf { dist(it, cb) } / rb.size
        val avg = if (mean > 0) mean else 1.0
        return ra.indices.map {
            val s = dist(rb[it], cb) / avg
            vec(ca.x + (ra[it].x - ca.x) * s, ca.y + (ra[it].y - ca.y) * s)
        }
    }

fun normalAt(curve: List<Vec2>, i: Int): Vec2

The unit normal of curve at point i, from its neighbours: the tangent turned a quarter turn.

    fun normalAt(curve: List<Vec2>, i: Int): Vec2 {
        val t = tangentAt(curve, i)
        return vec(-t.y, t.x)
    }

fun tangentAt(curve: List<Vec2>, i: Int): Vec2

The unit tangent of curve at point i, from its neighbours.

    fun tangentAt(curve: List<Vec2>, i: Int): Vec2 {
        val prev = curve[max(0, i - 1)]
        val next = curve[min(curve.size - 1, i + 1)]
        val dx = (next.x - prev.x).toDouble()
        val dy = (next.y - prev.y).toDouble()
        val len = hypot(dx, dy).let { if (it > 0) it else 1.0 }
        return vec(dx / len, dy / len)
    }

fun envelope(i: Int, n: Int): Double

0 at both ends of a curve of n points, 1 in the middle: 1 - |2i/(n-1) - 1|.

    fun envelope(i: Int, n: Int): Double = if (n < 2) 1.0 else 1 - abs(i.toDouble() / (n - 1) * 2 - 1)

drawing

fun polyline(curve: List<Vec2>): FLine

curve as straight segments.

    fun polyline(curve: List<Vec2>): FLine = FLine().also { f ->
        curve.forEachIndexed { i, p -> if (i == 0) f.moveTo(p.x, p.y) else f.lineTo(p.x, p.y) }
    }

fun smooth(curve: List<Vec2>): FLine = catmullRomCentripetal(curve)

curve as a smooth line through every point: a centripetal Catmull-Rom spline (catmullRomCentripetal).

fun arcs(curve: List<Vec2>, segmentsPerArc: Int = 10): FLine

curve as a chain of circular arcs: from its third point on, each point is reached along the circle through it and the two points before it (a straight line where they are in a line).

    fun arcs(curve: List<Vec2>, segmentsPerArc: Int = 10): FLine {
        val f = FLine()
        if (curve.isEmpty()) return f
        f.moveTo(curve[0].x, curve[0].y)
        if (curve.size > 1) f.lineTo(curve[1].x, curve[1].y)
        for (i in 2 until curve.size) {
            val a = curve[i - 2]
            val b = curve[i - 1]
            val c = curve[i]
            val circle = circumcircle(a, b, c)
            if (circle == null) {
                f.lineTo(c.x, c.y)
                continue
            }
            val (cx, cy, r) = circle
            val angB = atan2(b.y - cy, b.x - cx)
            var angC = atan2(c.y - cy, c.x - cx)
            val ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0
            // The canvas sense: ccw draws decreasing angles in y-down coordinates.
            if (ccw) {
                while (angC > angB) angC -= 2 * Math.PI
            } else {
                while (angC < angB) angC += 2 * Math.PI
            }
            for (k in 1..segmentsPerArc) {
                val ang = angB + (angC - angB) * k / segmentsPerArc
                f.lineTo(cx + cos(ang) * r, cy + sin(ang) * r)
            }
        }
        return f
    }

fun dots(curve: List<Vec2>, radius: Double = 2.0): FLine

A dot of radius at every point of curve: an unstroked, pointed line.

    fun dots(curve: List<Vec2>, radius: Double = 2.0): FLine = FLine().also { f ->
        f.pointed = true
        f.stroked = false
        f.pointSize = radius / 3
        curve.forEach { f.moveTo(it.x, it.y) }
    }

fun dots(line: FLine, radius: Double = 2.0): FLine

A dot of radius at every node of line, each keeping its node's colour.

    fun dots(line: FLine, radius: Double = 2.0): FLine = FLine().also { f ->
        f.properties.putAll(line.properties)
        f.pointed = true
        f.stroked = false
        f.pointSize = radius / 3
        for (node in line.nodes) {
            val to = node.toOrNull ?: continue
            f.moveTo(to.x, to.y)
            (node.properties["color"] as? Vec4)?.let { f.here.properties["color"] = it }
        }
    }

fun FLine.dot(x: Number, y: Number, radius: Double, color: Vec4? = null): FLine

Adds a dot of radius at (x, y) to this line, coloured color (the line's colour when null). Make the line pointed and unstroked to draw dots alone.

    fun FLine.dot(x: Number, y: Number, radius: Double, color: Vec4? = null): FLine {
        moveTo(x, y)
        here.properties["pointSize"] = radius / 3
        if (color != null) here.properties["color"] = color
        return this
    }

fun FLine.segment(x0: Number, y0: Number, x1: Number, y1: Number, color: Vec4? = null): FLine

Adds the separate segment (x0, y0) to (x1, y1) to this line, coloured color when given.

    fun FLine.segment(x0: Number, y0: Number, x1: Number, y1: Number, color: Vec4? = null): FLine {
        moveTo(x0, y0)
        lineTo(x1, y1)
        if (color != null) here.properties["color"] = color
        return this
    }

fun atomize(curve: List<Vec2>): FLine = polyline(curve).byAtomizing()

curve as separate segments, one subpath per step between neighbouring points.

fun atomize(line: FLine): FLine = line.byAtomizing()

line as separate segments (byAtomizing).

helpers

    private fun dist(a: Vec2, b: Vec2): Double = hypot((b.x - a.x).toDouble(), (b.y - a.y).toDouble())

    private fun lerp(a: Vec2, b: Vec2, f: Double): Vec2 = vec(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f)

    private fun centroid(curve: List<Vec2>): Vec2 {
        var x = 0.0
        var y = 0.0
        for (p in curve) {
            x += p.x
            y += p.y
        }
        return vec(x / curve.size, y / curve.size)
    }

    private fun pair(a: List<Vec2>, b: List<Vec2>): Pair<List<Vec2>, List<Vec2>> {
        val n = max(2, Math.round((a.size + b.size) / 2.0).toInt())
        return resample(a, n) to resample(b, n)
    }

    private fun circumcircle(p1: Vec2, p2: Vec2, p3: Vec2): Triple<Double, Double, Double>? {
        val ax = p1.x.toDouble(); val ay = p1.y.toDouble()
        val bx = p2.x.toDouble(); val by = p2.y.toDouble()
        val cx = p3.x.toDouble(); val cy = p3.y.toDouble()
        val d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
        if (abs(d) < 1e-6) return null
        val ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
        val uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
        return Triple(ux, uy, sqrt((bx - ux) * (bx - ux) + (by - uy) * (by - uy)))
    }
}