Skip to content

Notation.kt

src/jvmMain/kotlin/afield/ui/notation/Notation.kt · 970 lines

package and imports — 59 lines, from line 1
package afield.ui.notation

import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.frames
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.output.md
import afield.ui.visualization.typed

import afield.app.diagnostics.named
import afield.app.notifications.notify
import afield.app.protocol.Rect
import afield.app.protocol.handle
import afield.app.protocol.handlers
import afield.app.protocol.put
import afield.app.server.SessionInformation
import afield.audio.synth.*
import afield.data.table.get
import afield.data.table.ref
import afield.document.graph.Box
import afield.document.graph.box
import afield.document.graph.registerStopHook
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.kotlin.cache
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.ui.output.templates.filter
import afield.ui.output.update
import afield.ui.visualization.h

import afield.geometry.path.FLine
import afield.document.properties.Delegation
import afield.math.linalg.Vec2
import afield.math.linalg.vec
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min

The box-facing API for the drawing layer: notation reads the document's Excalidraw scene as Marks, writes marks owned by the box through a notation { } block (NotationBuilder), changes existing ones with Notation.edit, and runs callbacks when a person draws.

Marks a box writes are replaced when that box re-runs, so a re-run redraws rather than accumulates. Reactions such as notation.onStroke { } fire only for changes made by hand in a client, not for marks written from code. Coverage is in NotationTest and NotationIntersectionTest; the full API is in dev_docs/NOTATION.md.

val Box.notation: Notation get() = Notation(this)

The document's drawing, as seen from this box — read it, write to it, and react to it.

notation.strokes.forEach { println(it.bounds) }        // what has been drawn
notation { arrow(from = thisBox, to = other) }          // draw from code; re-running replaces it
notation.onStroke { s -> println("drawn: ${s.bounds}") } // react to a hand-drawn stroke

The box is the implicit receiver, so this is just notation. See dev_docs/NOTATION.md.

interface MarkHandle

Something a mark can be referred to by: a Mark read from the scene, or a MarkRef just built.

val id: String

data class MarkRef(override val id: String) : MarkHandle

A mark created in a notation { } block, before it has been read back from the scene.

class Mark internal constructor

One element of the drawing — a stroke, a shape, a text, an arrow — read from the scene.

A Mark is a snapshot: it does not change when the drawing does. Ask Notation again for the current state, and use Notation.edit to change one.

class Mark internal constructor(
    /** The element exactly as Excalidraw stores it. */
    val json: JsonObject,
    private val view: Notation,
) : MarkHandle {

override val id: String get() = json.string("id") ?: ""

val kind: MarkType get() = MarkType.of(json.string("type"))

val bounds: Rect get() = json.worldBounds()

World-space bounds (unrotated), in the same units as a box's frame.

val center: Vec2 get() = bounds.let { vec(it.x + it.w / 2, it.y + it.h / 2) }

val angle: Double get() = json.double("angle") ?: 0.0

val points: List<Vec2> get() = if (kind.isLinear) json.worldPoints() else emptyList()

World-space points of a stroke, line or arrow; empty for other kinds.

val pressures: List<Double> get() = MarkGeometry.of(json).pressures?.toList() ?: emptyList()

The pen pressure Excalidraw recorded at each of points, 0..1, for a stroke drawn with a pressure-sensitive pen (or written from code with pressure =). Empty when the stroke has none: a mouse stroke, whose width Excalidraw simulates from speed instead.

fun pressuresAlong(n: Int): List<Double>

pressures at n stations evenly spaced by distance along the stroke, first point to last. A pen records points closer together where it moves slowly, so the raw list is not evenly spaced; this one is, which is what a PaintCanvas brush's swell and a pencil's pressure profile expect. Empty when the stroke recorded no pressure.

    fun pressuresAlong(n: Int): List<Double> {
        val g = MarkGeometry.of(json)
        val p = g.pressures ?: return emptyList()
        if (n <= 0) return emptyList()
        if (p.size == 1 || n == 1) return List(n) { p[0] }
        val total = g.arc.last()
        var i = 0
        return List(n) { k ->
            val s = total * k / (n - 1)
            while (i < p.size - 2 && g.arc[i + 1] < s) i++
            val span = g.arc[i + 1] - g.arc[i]
            val f = if (span <= 0) 0.0 else ((s - g.arc[i]) / span).coerceIn(0.0, 1.0)
            p[i] + (p[i + 1] - p[i]) * f
        }
    }

val start: Vec2? get() = points.firstOrNull()

val end: Vec2? get() = points.lastOrNull()

val stroke: String get()

The colour code asked for (kept in customData); a hand-drawn mark's stored colour.

    val stroke: String get() = json.obj("customData")?.string("fieldStroke") ?: json.string("strokeColor") ?: "#1e1e1e"

val fill: String get()

    val fill: String get() = json.obj("customData")?.string("fieldFill") ?: json.string("backgroundColor") ?: "transparent"

val strokeWidth: Double get() = json.double("strokeWidth") ?: 1.0

val opacity: Double get() = json.double("opacity") ?: 100.0

val text: String? get() = if (kind == MarkType.TEXT) json.string("text") else null

The text of a text mark.

val boundText: Mark? get()

The text bound inside a shape or on an arrow, if there is one.

    val boundText: Mark? get() = (json["boundElements"] as? JsonArray)
        ?.mapNotNull { (it as? JsonObject)?.takeIf { e -> e.string("type") == "text" }?.string("id") }
        ?.firstNotNullOfOrNull { view[it] }

val label: String? get()

A name given from code (name =), else the mark's own text, else the text bound to it, else — for a frame — the title Excalidraw keeps in the frame's own name field, which is where a frame named by hand has its name.

    val label: String? get() = name ?: text ?: boundText?.text ?: (if (kind == MarkType.FRAME) json.string("name") else null)

val name: String? get() = json.custom("fieldName")?.stringOrNull()

The name given from code with name =, or through Notation.edit.

val owner: Box? get() = ownerId?.let { view.boxById(it) }

The box whose notation { } block wrote this mark; null for a mark drawn by hand.

val ownerId: String? get() = json.custom("fieldOwner")?.stringOrNull()

val handDrawn: Boolean get() = ownerId == null

val box: Box? get() = view.boxAt(center)

The box under this mark's centre, if any (the smallest, when boxes overlap).

val fromBox: Box? get() = start?.let { view.boxAt(it, margin = 8.0) }

For an arrow or line: the box its first point is on.

val toBox: Box? get() = end?.let { view.boxAt(it, margin = 8.0) }

For an arrow or line: the box its last point is on.

val anchoredTo: Box? get()

The box this mark follows when boxes move, if it is anchored.

    val anchoredTo: Box? get() = json.custom("fieldAnchor")?.asObject()?.string("box")?.let { view.boxById(it) }

val frame: Mark? get() = json.string("frameId")?.let { view[it] }

The Excalidraw frame this mark is in, if any.

val groups: List<String> get()

The Excalidraw groups this mark is in, innermost first (Excalidraw's own order).

    val groups: List<String> get() = (json["groupIds"] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()

fun anchorTo(box: Box) = view.anchor(this, box)

Makes this mark follow box when it moves; the same as notation.anchor(this, box).

fun contains(p: Vec2): Boolean

    fun contains(p: Vec2): Boolean = bounds.let { p.x >= it.x && p.x <= it.x + it.w && p.y >= it.y && p.y <= it.y + it.h }

fun toFLine(): FLine

The mark's geometry in world coordinates, rotation included: a stroke's polyline, or a shape's outline.

    fun toFLine(): FLine = FLine().also { f ->
        MarkGeometry.of(json).paths.firstOrNull()?.forEachIndexed { i, p -> if (i == 0) f.moveTo(p.x, p.y) else f.lineTo(p.x, p.y) }
    }

fun yAt(x: Double): List<Double>

Where this mark crosses the vertical line at world x: its y values there (a shape gives its edges).

    fun yAt(x: Double): List<Double> = Probe.crossings(MarkGeometry.of(json), ProbeAxis.VERTICAL, x).map { it.y }.sorted()

fun xAt(y: Double): List<Double>

Where this mark crosses the horizontal line at world y: its x values there.

    fun xAt(y: Double): List<Double> = Probe.crossings(MarkGeometry.of(json), ProbeAxis.HORIZONTAL, y).map { it.x }.sorted()

val length: Double get() = MarkGeometry.of(json).length

The length of the mark's path (a shape's perimeter), in world units.

override fun toString(): String

    override fun toString(): String =
        "Mark(${kind.name.lowercase()} $id${label?.let { " \"$it\"" } ?: ""} at ${bounds.x.toInt()},${bounds.y.toInt()} ${bounds.w.toInt()}×${bounds.h.toInt()})"

override fun equals(other: Any?): Boolean

    override fun equals(other: Any?): Boolean = other is Mark && other.id == id && other.json == json

override fun hashCode(): Int = id.hashCode()

class NotationEdit internal constructor

What changed in the drawing, handed to Notation.onChange.

class NotationEdit internal constructor(
    val added: List<Mark>,
    val updated: List<Mark>,
    val removed: List<Mark>,
    /** The drawing as it now stands. */
    val notation: Notation,
)

class Notation internal constructor(private val box: Box)

The drawing, as seen from one box. notation in box code gives you one; it reads the scene when you ask it something, so a Notation kept in a variable still answers with the current drawing.

class Notation internal constructor(private val box: Box) {

    private val info: SessionInformation? get() = if (box.isInsideInitialized()) box.inside else null

internal val scene: NotationScene get()

    internal val scene: NotationScene get() = info?.let { NotationStore.scene(it) } ?: NotationScene.EMPTY

reading

val marks: List<Mark> get() = scene.live.map { Mark(it, this) }

Every mark in the drawing, back to front.

val strokes: List<Mark> get() = marks.filter { it.kind == MarkType.STROKE }

val arrows: List<Mark> get() = marks.filter { it.kind == MarkType.ARROW }

val lines: List<Mark> get() = marks.filter { it.kind == MarkType.LINE }

val texts: List<Mark> get() = marks.filter { it.kind == MarkType.TEXT }

val shapes: List<Mark> get()

    val shapes: List<Mark> get() = marks.filter { it.kind == MarkType.RECTANGLE || it.kind == MarkType.ELLIPSE || it.kind == MarkType.DIAMOND }

val frames: List<Mark> get() = marks.filter { it.kind == MarkType.FRAME }

val mine: List<Mark> get() = marks.filter { it.ownerId == box.id }

The marks this box wrote, from every notation { } block.

val selected: List<Mark> get()

What is selected in the drawing layer right now, in any window showing this document.

    val selected: List<Mark> get() = info?.let { i -> NotationStore.selection(i).mapNotNull { get(it) } } ?: emptyList()

operator fun get(id: String): Mark?

    operator fun get(id: String): Mark? = scene[id]?.takeIf { !it.isDeletedElement() }?.let { Mark(it, this) }

fun named(name: String): Mark?

The first mark labelled name — a code-given name, its own text, or the text bound to it.

    fun named(name: String): Mark? = marks.firstOrNull { it.label?.trim().equals(name.trim(), ignoreCase = true) }

fun allNamed(name: String): List<Mark>

    fun allNamed(name: String): List<Mark> = marks.filter { it.label?.trim().equals(name.trim(), ignoreCase = true) }

fun over(box: Box): List<Mark>

Marks whose centre lies on box.

    fun over(box: Box): List<Mark> = marks.filter { m -> m.center.let { box.frame.containsPoint(it.x.toDouble(), it.y.toDouble()) } }

fun inside(rect: Rect): List<Mark>

Marks entirely inside rect.

    fun inside(rect: Rect): List<Mark> = marks.filter { m ->
        val b = m.bounds
        b.x >= rect.x && b.y >= rect.y && b.x + b.w <= rect.x + rect.w && b.y + b.h <= rect.y + rect.h
    }

fun inside(frame: MarkHandle): List<Mark>

The marks in an Excalidraw frame.

    fun inside(frame: MarkHandle): List<Mark> = marks.filter { it.json.string("frameId") == frame.id }

fun grouped(mark: MarkHandle): List<Mark>

The marks that move with mark when it is selected and dragged: everything sharing its outermost group (itself included), or just mark when it is in no group.

    fun grouped(mark: MarkHandle): List<Mark> {
        val outer = this[mark.id]?.groups?.lastOrNull() ?: return listOfNotNull(this[mark.id])
        return marks.filter { outer in it.groups }
    }

fun near(p: Vec2, radius: Double): List<Mark>

Marks whose bounds come within radius of p, nearest first.

    fun near(p: Vec2, radius: Double): List<Mark> = marks
        .map { it to distanceToRect(p, it.bounds) }
        .filter { it.second <= radius }
        .sortedBy { it.second }
        .map { it.first }

fun arrowsFrom(box: Box): List<Mark>

Arrows (and lines) whose first point is on box.

    fun arrowsFrom(box: Box): List<Mark> = marks.filter { it.kind.let { k -> k == MarkType.ARROW || k == MarkType.LINE } && it.fromBox == box }

fun arrowsTo(box: Box): List<Mark>

Arrows (and lines) whose last point is on box.

    fun arrowsTo(box: Box): List<Mark> = marks.filter { it.kind.let { k -> k == MarkType.ARROW || k == MarkType.LINE } && it.toBox == box }

cutting

fun vertical(x: Double): List<Hit> = cut(marks, ProbeAxis.VERTICAL, x) { it }

Cut the whole drawing with the vertical line at world x; hit values are world y.

fun horizontal(y: Double): List<Hit> = cut(marks, ProbeAxis.HORIZONTAL, y) { it }

Cut the whole drawing with the horizontal line at world y; hit values are world x.

fun intersect(from: Vec2, to: Vec2): List<Hit>

Every place the segment from–to crosses a mark, nearest from first; hit values are 0..1 along the segment.

    fun intersect(from: Vec2, to: Vec2): List<Hit> = marks.flatMap { m ->
        Probe.crossings(MarkGeometry.of(m.json), from, to).map { (t, c) ->
            Hit(m, vec(c.x, c.y), t, null, c.along, 0.0, c.pressure)
        }
    }.sortedBy { it.value }

fun intersect(probe: FLine): List<Hit>

Every place an FLine (a probe of any shape) crosses a mark, in order along the FLine.

    fun intersect(probe: FLine): List<Hit> {
        val pts = probe.nodes.mapNotNull { it.toOrNull }.map { vec(it.x, it.y) }
        return pts.zipWithNext().flatMapIndexed { i, (a, b) ->
            intersect(a, b).map { h -> Hit(h.mark, h.point, i + h.value, null, h.along, 0.0, h.pressure) }
        }
    }

fun space(region: Rect, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace

A region of the drawing read with its own axes — see NotationSpace. region is the rectangle; x and y are what its left→right and top→bottom mean.

    fun space(region: Rect, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace =
        NotationSpace(this, { region }, x, y)

fun space(region: Box, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace

A region that is a box's frame: move the box and the space moves with it.

    fun space(region: Box, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace =
        NotationSpace(this, { region.frame }, x, y)

fun space(name: String, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace

A region that is the frame (or any mark) labelled name, wherever it is now.

    fun space(name: String, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace =
        NotationSpace(this, { named(name)?.bounds }, x, y, defining = { named(name)?.id })

fun space(mark: MarkHandle, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace

A region that is mark — a frame or shape drawn by hand or returned by notation { } — wherever it is now.

    fun space(mark: MarkHandle, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace =
        NotationSpace(this, { this[mark.id]?.bounds }, x, y, defining = { mark.id })

internal fun cut(among: List<Mark>, axis: ProbeAxis, at: Double, map: (Double) -> Double): List<Hit> {

    internal fun cut(among: List<Mark>, axis: ProbeAxis, at: Double, map: (Double) -> Double): List<Hit> {
        val out = ArrayList<Hit>()
        for (m in among) {
            val g = MarkGeometry.of(m.json)
            val cs = Probe.crossings(g, axis, at)
            if (cs.isEmpty()) continue
            val across = { c: Crossing -> if (axis == ProbeAxis.VERTICAL) c.y else c.x }
            if (g.closed) {
                val sorted = cs.sortedBy(across)
                var i = 0
                while (i + 1 < sorted.size) {
                    val a = map(across(sorted[i])); val b = map(across(sorted[i + 1]))
                    val lo = min(a, b); val hi = max(a, b)
                    val mid = (across(sorted[i]) + across(sorted[i + 1])) / 2
                    val point = if (axis == ProbeAxis.VERTICAL) vec(at, mid) else vec(mid, at)
                    out += Hit(m, point, (lo + hi) / 2, lo..hi, sorted[i].along, 0.0, null)
                    i += 2
                }
            } else {
                for (c in cs) out += Hit(m, vec(c.x, c.y), map(across(c)), null, c.along, c.dAcrossDProbe, c.pressure)
            }
        }
        return out.sortedBy { it.value }
    }

writing

operator fun invoke(key: String = "default", build: NotationBuilder.() -> Unit): List<MarkRef> {

Draws from code. The marks made here belong to this box under key: running the block again replaces them (updating in place, so the drawing does not flicker), and marks drawn by hand are never touched.

    operator fun invoke(key: String = "default", build: NotationBuilder.() -> Unit): List<MarkRef> {
        val i = info ?: return emptyList()
        val builder = NotationBuilder(box, key, this)
        builder.build()
        NotationStore.replaceOwned(i, box.id, key, builder.made, builder.pendingBindings)
        return builder.made.mapNotNull { it.string("id")?.let(::MarkRef) }
    }

fun clear(key: String? = null)

Removes the marks this box drew — under key, or under every key when key is null.

    fun clear(key: String? = null) {
        val i = info ?: return
        NotationStore.update(i) { scene ->
            scene.withElements(scene.live.filter { e ->
                e.custom("fieldOwner")?.stringOrNull() == box.id &&
                    (key == null || e.custom("fieldKey")?.stringOrNull() == key)
            }.map { it.with("isDeleted" to JsonPrimitive(true)) })
        }
    }

fun edit(mark: MarkHandle, block: MarkEditor.() -> Unit): Mark? {

Changes any mark — including one drawn by hand.

    fun edit(mark: MarkHandle, block: MarkEditor.() -> Unit): Mark? {
        val i = info ?: return null
        var result: JsonObject? = null
        NotationStore.update(i) { scene ->
            val current = scene[mark.id]?.takeIf { !it.isDeletedElement() } ?: return@update scene
            val editor = MarkEditor(current)
            editor.block()
            val next = editor.build()
            result = next
            scene.withElement(next)
        }
        return result?.let { NotationStore.scene(i)[it.string("id") ?: ""] }?.let { Mark(it, this) }
    }

fun delete(mark: MarkHandle)

    fun delete(mark: MarkHandle) {
        val i = info ?: return
        NotationStore.update(i) { scene ->
            val current = scene[mark.id] ?: return@update scene
            scene.withElement(current.with("isDeleted" to JsonPrimitive(true)))
        }
    }

fun anchor(mark: MarkHandle, to: Box)

Makes mark follow box when the box moves.

    fun anchor(mark: MarkHandle, to: Box) {
        val i = info ?: return
        NotationStore.update(i) { scene ->
            val e = scene[mark.id] ?: return@update scene
            scene.withElement(NotationAnchors.anchored(e, to))
        }
        NotationAnchors.ensureHooks(i)
    }

fun unanchor(mark: MarkHandle)

    fun unanchor(mark: MarkHandle) {
        val i = info ?: return
        NotationStore.update(i) { scene ->
            val e = scene[mark.id] ?: return@update scene
            scene.withElement(e.withCustom("fieldAnchor", null).withCustom("fieldStart", null).withCustom("fieldEnd", null))
        }
    }

reacting

fun onChange(key: String = "change", handler: (NotationEdit) -> Unit) =

Calls handler whenever someone changes the drawing by hand. Running the box again replaces the handler registered under the same key; stopping the box removes it. Changes this code makes itself do not call it back.

    fun onChange(key: String = "change", handler: (NotationEdit) -> Unit) =
        register(key, NotationHandler { ev -> handler(ev) })

fun onStroke(key: String = "stroke", handler: (Mark) -> Unit) =

Calls handler for each stroke drawn by hand, once it is finished.

    fun onStroke(key: String = "stroke", handler: (Mark) -> Unit) =
        register(key, NotationHandler { ev -> ev.added.filter { it.kind == MarkType.STROKE }.forEach(handler) })

fun onArrow(key: String = "arrow", handler: (Mark) -> Unit) =

Calls handler for each arrow drawn by hand, and again when its ends are moved.

    fun onArrow(key: String = "arrow", handler: (Mark) -> Unit) =
        register(key, NotationHandler { ev -> (ev.added + ev.updated).filter { it.kind == MarkType.ARROW }.forEach(handler) })

fun onText(key: String = "text", handler: (Mark) -> Unit) =

Calls handler for each text typed by hand, and again when it is edited.

    fun onText(key: String = "text", handler: (Mark) -> Unit) =
        register(key, NotationHandler { ev -> (ev.added + ev.updated).filter { it.kind == MarkType.TEXT }.forEach(handler) })

    private fun register(key: String, handler: NotationHandler) {
        box.notationHandlers[key] = handler
        if (box.notationStopHookInstalled != true) {
            box.notationStopHookInstalled = true
            box.registerStopHook {
                box.notationHandlers.clear()
                box.notationStopHookInstalled = false
            }
        }
    }

helpers used by Mark

internal fun boxById(id: String): Box? = info?.boxes?.get(id)

internal fun boxAt(p: Vec2, margin: Double = 0.0): Box?

    internal fun boxAt(p: Vec2, margin: Double = 0.0): Box? = info?.let { NotationAnchors.boxAt(it, p.x.toDouble(), p.y.toDouble(), margin) }

    private fun distanceToRect(p: Vec2, r: Rect): Double {
        val dx = max(max(r.x - p.x, 0.0), p.x - (r.x + r.w))
        val dy = max(max(r.y - p.y, 0.0), p.y - (r.y + r.h))
        return hypot(dx, dy)
    }
}

internal fun Rect.containsPoint(px: Double, py: Double, margin: Double = 0.0): Boolean

internal fun Rect.containsPoint(px: Double, py: Double, margin: Double = 0.0): Boolean =
    px >= x - margin && px <= x + w + margin && py >= y - margin && py <= y + h + margin

internal fun interface NotationHandler

fun handle(event: NotationEdit)

internal var Box.notationHandlers by Delegation<MutableMap<String, NotationHandler>>(defaultValue = { ConcurrentHashMap() })

internal var Box.notationHandlers by Delegation<MutableMap<String, NotationHandler>>(defaultValue = { ConcurrentHashMap() })

internal var Box.notationStopHookInstalled by Delegation<Boolean?>()

internal fun dispatchNotationChange(info: SessionInformation, change: NotationChange)

Runs box's reaction handlers for a change a person made.

@OptIn(DelicateCoroutinesApi::class)
internal fun dispatchNotationChange(info: SessionInformation, change: NotationChange) {
    if (change.isEmpty) return
    for (box in info.boxes.values) {
        val handlers = box.notationHandlers
        if (handlers.isEmpty()) continue
        val view = Notation(box)
        val event = NotationEdit(
            added = change.added.map { Mark(it, view) },
            updated = change.updated.map { Mark(it, view) },
            removed = change.removed.map { Mark(it, view) },
            notation = view,
        )
        for ((key, handler) in handlers) {
            GlobalScope.launch(Box.boxContextElement(box), start = CoroutineStart.UNDISPATCHED) {
                try {
                    handler.handle(event)
                } catch (t: Throwable) {
                    box.output.errorProxy("notation handler '$key': ${t.message ?: t.javaClass.simpleName}", "code", 0)
                }
            }
        }
    }
}

class MarkEditor internal constructor(private val original: JsonObject)

Sets the fields of one mark; see Notation.edit. Unset fields keep their values.

class MarkEditor internal constructor(private val original: JsonObject) {
    private val changes = LinkedHashMap<String, JsonElement>()
    private var custom: JsonObject = original.obj("customData") ?: JsonObject(emptyMap())

var x: Double

    var x: Double
        get() = (changes["x"] as? JsonPrimitive)?.doubleOrNull ?: original.double("x") ?: 0.0
        set(v) { changes["x"] = JsonPrimitive(v) }

var y: Double

    var y: Double
        get() = (changes["y"] as? JsonPrimitive)?.doubleOrNull ?: original.double("y") ?: 0.0
        set(v) { changes["y"] = JsonPrimitive(v) }

var width: Double

    var width: Double
        get() = (changes["width"] as? JsonPrimitive)?.doubleOrNull ?: original.double("width") ?: 0.0
        set(v) { changes["width"] = JsonPrimitive(v) }

var height: Double

    var height: Double
        get() = (changes["height"] as? JsonPrimitive)?.doubleOrNull ?: original.double("height") ?: 0.0
        set(v) { changes["height"] = JsonPrimitive(v) }

var angle: Double

    var angle: Double
        get() = (changes["angle"] as? JsonPrimitive)?.doubleOrNull ?: original.double("angle") ?: 0.0
        set(v) { changes["angle"] = JsonPrimitive(v) }

var stroke: String

The colour asked for; stored compensated for the dark theme (DarkThemeColour).

    var stroke: String
        get() = custom["fieldStroke"]?.stringOrNull() ?: (changes["strokeColor"] as? JsonPrimitive)?.content ?: original.string("strokeColor") ?: "#1e1e1e"
        set(v) {
            changes["strokeColor"] = JsonPrimitive(DarkThemeColour.compensate(v))
            custom = JsonObject(custom + ("fieldStroke" to JsonPrimitive(v)))
        }

var fill: String

    var fill: String
        get() = custom["fieldFill"]?.stringOrNull() ?: (changes["backgroundColor"] as? JsonPrimitive)?.content ?: original.string("backgroundColor") ?: "transparent"
        set(v) {
            changes["backgroundColor"] = JsonPrimitive(DarkThemeColour.compensate(v))
            custom = JsonObject(custom + ("fieldFill" to JsonPrimitive(v)))
            if (v != "transparent") changes.putIfAbsent("fillStyle", JsonPrimitive("solid"))
        }

var strokeWidth: Double

    var strokeWidth: Double
        get() = (changes["strokeWidth"] as? JsonPrimitive)?.doubleOrNull ?: original.double("strokeWidth") ?: 1.0
        set(v) { changes["strokeWidth"] = JsonPrimitive(v) }

var opacity: Double

    var opacity: Double
        get() = (changes["opacity"] as? JsonPrimitive)?.doubleOrNull ?: original.double("opacity") ?: 100.0
        set(v) { changes["opacity"] = JsonPrimitive(v) }

var roughness: Double

How hand-drawn the line looks: 0 architect, 1 artist (the default), 2 cartoonist.

    var roughness: Double
        get() = (changes["roughness"] as? JsonPrimitive)?.doubleOrNull ?: original.double("roughness") ?: 1.0
        set(v) { changes["roughness"] = JsonPrimitive(v) }

var strokeStyle: String

"solid", "dashed" or "dotted".

    var strokeStyle: String
        get() = (changes["strokeStyle"] as? JsonPrimitive)?.content ?: original.string("strokeStyle") ?: "solid"
        set(v) { changes["strokeStyle"] = JsonPrimitive(v) }

var dashed: Boolean

    var dashed: Boolean
        get() = strokeStyle == "dashed"
        set(v) { strokeStyle = if (v) "dashed" else "solid" }

var fillStyle: String

How a fill is painted: "solid", "hachure", "cross-hatch" or "zigzag".

    var fillStyle: String
        get() = (changes["fillStyle"] as? JsonPrimitive)?.content ?: original.string("fillStyle") ?: "hachure"
        set(v) { changes["fillStyle"] = JsonPrimitive(v) }

var text: String?

    var text: String?
        get() = (changes["text"] as? JsonPrimitive)?.content ?: original.string("text")
        set(v) { if (v != null) { changes["text"] = JsonPrimitive(v); changes["originalText"] = JsonPrimitive(v) } }

var name: String?

    var name: String?
        get() = custom["fieldName"]?.stringOrNull()
        set(v) { custom = if (v == null) JsonObject(custom - "fieldName") else JsonObject(custom + ("fieldName" to JsonPrimitive(v))) }

fun moveBy(dx: Double, dy: Double) { x += dx; y += dy }

internal fun build(): JsonObject = JsonObject(original + changes + ("customData" to custom))

class NotationBuilder internal constructor(private val owner: Box, private val key: String, private val view: Notation)

The drawing vocabulary of a notation { } block. Every call makes one mark owned by the box and returns a MarkRef to it. The vars set defaults for the calls that follow them.

internal val made = ArrayList<JsonObject>()

    private var counter = 0

var stroke: String = "#1e1e1e"

var fill: String = "transparent"

var strokeWidth: Double = 1.0

var roughness: Double = 0.0

var opacity: Double = 100.0

var dashed: Boolean = false

var fontSize: Double = 20.0

var anchorTo: Box? = null

When set, the marks made after it follow this box when it moves.

var fillStyle: String? = null

How a non-transparent fill is painted; null means solid. See MarkEditor.fillStyle.

    private var inFrame: String? = null
    private val groupStack = ArrayList<String>()
    private var groupCounter = 0

fun group(contents: NotationBuilder.() -> Unit): String {

Groups the marks made in contents the way Excalidraw's Ctrl+G does: selecting one selects them all, and they drag together. Groups nest. Returns the group's id.

    fun group(contents: NotationBuilder.() -> Unit): String {
        val gid = "${owner.id}~$key~group${groupCounter++}"
        groupStack.add(gid)
        try { contents() } finally { groupStack.removeAt(groupStack.size - 1) }
        return gid
    }

fun rect(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,

    fun rect(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,
             stroke: String = this.stroke, fill: String = this.fill,
             fillStyle: String? = this.fillStyle, opacity: Number = this.opacity): MarkRef =
        shape("rectangle", x, y, w, h, name, label, stroke, fill, fillStyle, opacity.toDouble())

fun ellipse(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,

    fun ellipse(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,
                stroke: String = this.stroke, fill: String = this.fill,
                fillStyle: String? = this.fillStyle, opacity: Number = this.opacity): MarkRef =
        shape("ellipse", x, y, w, h, name, label, stroke, fill, fillStyle, opacity.toDouble())

fun diamond(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,

    fun diamond(x: Number, y: Number, w: Number, h: Number, name: String? = null, label: String? = null,
                stroke: String = this.stroke, fill: String = this.fill,
                fillStyle: String? = this.fillStyle, opacity: Number = this.opacity): MarkRef =
        shape("diamond", x, y, w, h, name, label, stroke, fill, fillStyle, opacity.toDouble())

fun text(text: String, x: Number, y: Number, size: Number = fontSize, name: String? = null,

    fun text(text: String, x: Number, y: Number, size: Number = fontSize, name: String? = null,
             color: String = stroke): MarkRef {
        val id = nextId(name)
        val e = textElement(id, text, x.toDouble(), y.toDouble(), size.toDouble(), color, containerId = null, name = name)
        return add(e)
    }

fun line(points: List<Vec2>, name: String? = null, stroke: String = this.stroke): MarkRef

A straight or bent line through points.

    fun line(points: List<Vec2>, name: String? = null, stroke: String = this.stroke): MarkRef =
        add(linear("line", nextId(name), points, name, stroke))

fun stroke(points: List<Vec2>, name: String? = null, stroke: String = this.stroke, pressure: ((Double) -> Double)?

A freehand stroke — the same kind of mark as one drawn with the pen tool. Give pressure (a function of the fraction of the way along the stroke, 0..1, returning 0..1) to record pen pressure the way a tablet does; without it the stroke is like a mouse stroke, and Mark.pressures reads back empty.

    fun stroke(points: List<Vec2>, name: String? = null, stroke: String = this.stroke, pressure: ((Double) -> Double)? = null): MarkRef {
        val pressures = if (pressure == null || points.isEmpty()) emptyList() else {
            val arc = DoubleArray(points.size)
            for (i in 1 until points.size) arc[i] = arc[i - 1] + hypot((points[i].x - points[i - 1].x).toDouble(), (points[i].y - points[i - 1].y).toDouble())
            val total = arc.last().takeIf { it > 0 } ?: 1.0
            arc.map { JsonPrimitive(pressure(it / total).coerceIn(0.0, 1.0)) }
        }
        val e = linear("freedraw", nextId(name), points, name, stroke)
            .with("pressures" to JsonArray(pressures), "simulatePressure" to JsonPrimitive(pressure == null))
        return add(e)
    }

fun arrow(from: Vec2, to: Vec2, label: String? = null, name: String? = null): MarkRef

An arrow between two points.

    fun arrow(from: Vec2, to: Vec2, label: String? = null, name: String? = null): MarkRef =
        arrowElement(from, to, label, name) { it }

fun arrow(from: Box, to: Box, label: String? = null, name: String? = null): MarkRef

An arrow from one box to another. Its ends sit on the boxes' edges and follow the boxes as they move.

    fun arrow(from: Box, to: Box, label: String? = null, name: String? = null): MarkRef {
        val (s, e) = NotationAnchors.edgePoints(from.frame, to.frame)
        return arrowElement(s, e, label, name) {
            it.withCustom("fieldStart", edgeAnchor(from)).withCustom("fieldEnd", edgeAnchor(to))
        }
    }

fun arrow(from: Box, to: Vec2, label: String? = null, name: String? = null): MarkRef

    fun arrow(from: Box, to: Vec2, label: String? = null, name: String? = null): MarkRef {
        val s = NotationAnchors.edgeToward(from.frame, to.x.toDouble(), to.y.toDouble())
        return arrowElement(s, to, label, name) { it.withCustom("fieldStart", edgeAnchor(from)) }
    }

fun arrow(from: Vec2, to: Box, label: String? = null, name: String? = null): MarkRef

    fun arrow(from: Vec2, to: Box, label: String? = null, name: String? = null): MarkRef {
        val e = NotationAnchors.edgeToward(to.frame, from.x.toDouble(), from.y.toDouble())
        return arrowElement(from, e, label, name) { it.withCustom("fieldEnd", edgeAnchor(to)) }
    }

fun arrow(from: MarkHandle, to: MarkHandle, label: String? = null, name: String? = null): MarkRef

An arrow bound, the way Excalidraw binds them, to two marks: dragging either mark by hand keeps the arrow attached.

    fun arrow(from: MarkHandle, to: MarkHandle, label: String? = null, name: String? = null): MarkRef {
        val a = boundsOf(from.id) ?: error("no mark ${from.id}")
        val b = boundsOf(to.id) ?: error("no mark ${to.id}")
        val (s, e) = NotationAnchors.edgePoints(a, b)
        val ref = arrowElement(s, e, label, name) {
            it.with(
                "startBinding" to binding(from.id),
                "endBinding" to binding(to.id),
            )
        }
        bindTo(from.id, ref.id)
        bindTo(to.id, ref.id)
        return ref
    }

fun frame(name: String, x: Number, y: Number, w: Number, h: Number, contents: (NotationBuilder.() -> Unit)?

An Excalidraw frame: a named region that groups the marks drawn inside it. Marks made in contents belong to the frame — Excalidraw clips them to it and drags them with it, and Notation.inside finds them.

    fun frame(name: String, x: Number, y: Number, w: Number, h: Number, contents: (NotationBuilder.() -> Unit)? = null): MarkRef {
        val id = nextId(name)
        val ref = add(base(id, "frame", x.toDouble(), y.toDouble(), w.toDouble(), h.toDouble(), name, "#bbb", "transparent")
            .with("name" to JsonPrimitive(name), "frameId" to JsonNull, "groupIds" to JsonArray(emptyList())))
        if (contents != null) {
            val outer = inFrame
            inFrame = id
            try { contents() } finally { inFrame = outer }
        }
        return ref
    }

element construction

source — 103 lines, from line 723
    private fun shape(type: String, x: Number, y: Number, w: Number, h: Number, name: String?, label: String?,
                      stroke: String, fill: String, fillStyle: String? = null, opacity: Double = this.opacity): MarkRef {
        val id = nextId(name)
        var e = base(id, type, x.toDouble(), y.toDouble(), w.toDouble(), h.toDouble(), name, stroke, fill)
            .with("opacity" to JsonPrimitive(opacity))
        if (fillStyle != null && fill != "transparent") e = e.with("fillStyle" to JsonPrimitive(fillStyle))
        val ref = add(e)
        if (label != null) addLabel(ref.id, label, e.worldBounds())
        return ref
    }

    private fun addLabel(containerId: String, label: String, b: Rect) {
        val tid = "$containerId-label"
        val size = fontSize
        val (tw, th) = measure(label, size)
        val t = textElement(tid, label, b.x + (b.w - tw) / 2, b.y + (b.h - th) / 2, size, stroke, containerId, null)
            .with("textAlign" to JsonPrimitive("center"), "verticalAlign" to JsonPrimitive("middle"))
        add(t)
        bindTo(containerId, tid, type = "text")
    }

    private fun arrowElement(from: Vec2, to: Vec2, label: String?, name: String?, decorate: (JsonObject) -> JsonObject): MarkRef {
        val id = nextId(name)
        val e = decorate(linear("arrow", id, listOf(from, to), name, stroke)
            .with("startArrowhead" to JsonNull, "endArrowhead" to JsonPrimitive("arrow")))
        val ref = add(e)
        if (label != null) addLabel(ref.id, label, e.worldBounds())
        return ref
    }

    private fun linear(type: String, id: String, points: List<Vec2>, name: String?, stroke: String): JsonObject =
        base(id, type, 0.0, 0.0, 0.0, 0.0, name, stroke, "transparent")
            .with("lastCommittedPoint" to JsonNull, "startBinding" to JsonNull, "endBinding" to JsonNull)
            .withWorldPoints(points.ifEmpty { listOf(vec(0, 0)) })

    private fun textElement(id: String, text: String, x: Double, y: Double, size: Double, color: String,
                            containerId: String?, name: String?): JsonObject {
        val (w, h) = measure(text, size)
        return base(id, "text", x, y, w, h, name, color, "transparent").with(
            "text" to JsonPrimitive(text),
            "originalText" to JsonPrimitive(text),
            "fontSize" to JsonPrimitive(size),
            "fontFamily" to JsonPrimitive(IOSEVKA_FONT_FAMILY),
            "textAlign" to JsonPrimitive("left"),
            "verticalAlign" to JsonPrimitive("top"),
            "containerId" to jsonString(containerId),
            "autoResize" to JsonPrimitive(true),
            "lineHeight" to JsonPrimitive(1.25),
        )
    }

    private fun base(id: String, type: String, x: Double, y: Double, w: Double, h: Double, name: String?,
                     stroke: String, fill: String): JsonObject {
        var custom = buildJsonObject {
            put("fieldOwner", JsonPrimitive(owner.id))
            put("fieldKey", JsonPrimitive(key))
            if (name != null) put("fieldName", JsonPrimitive(name))
            // The colours asked for; the element stores them compensated for the dark theme's filter.
            put("fieldStroke", JsonPrimitive(stroke))
            put("fieldFill", JsonPrimitive(fill))
        }
        val e = buildJsonObject {
            put("id", JsonPrimitive(id))
            put("type", JsonPrimitive(type))
            put("x", JsonPrimitive(x)); put("y", JsonPrimitive(y))
            put("width", JsonPrimitive(w)); put("height", JsonPrimitive(h))
            put("angle", JsonPrimitive(0))
            put("strokeColor", JsonPrimitive(DarkThemeColour.compensate(stroke)))
            put("backgroundColor", JsonPrimitive(DarkThemeColour.compensate(fill)))
            put("fillStyle", JsonPrimitive(if (fill == "transparent") "hachure" else "solid"))
            put("strokeWidth", JsonPrimitive(strokeWidth))
            put("strokeStyle", JsonPrimitive(if (dashed) "dashed" else "solid"))
            put("roughness", JsonPrimitive(roughness))
            put("opacity", JsonPrimitive(opacity))
            put("groupIds", JsonArray(groupStack.asReversed().map { JsonPrimitive(it) }))
            put("frameId", jsonString(inFrame))
            put("roundness", JsonNull)
            put("seed", JsonPrimitive(abs(id.hashCode()) + 1))
            put("isDeleted", JsonPrimitive(false))
            put("boundElements", JsonArray(emptyList()))
            put("link", JsonNull)
            put("locked", JsonPrimitive(false))
            put("customData", custom)
        }
        val a = anchorTo
        return if (a != null && type != "arrow") NotationAnchors.anchored(e, a) else e
    }

    private fun add(e: JsonObject): MarkRef {
        made += e
        return MarkRef(e.string("id")!!)
    }

    private fun nextId(name: String?): String =
        if (name != null) "${owner.id}~$key~$name" else "${owner.id}~$key~${counter++}"

    private fun edgeAnchor(b: Box): JsonObject = buildJsonObject {
        put("box", JsonPrimitive(b.id)); put("edge", JsonPrimitive(true))
    }

    private fun binding(id: String): JsonObject = buildJsonObject {
        put("elementId", JsonPrimitive(id)); put("focus", JsonPrimitive(0)); put("gap", JsonPrimitive(6))
    }

Records childId in containerId's boundElements, whether the container is new or already in the scene.

    private fun bindTo(containerId: String, childId: String, type: String = "arrow") {
        val entry = buildJsonObject { put("id", JsonPrimitive(childId)); put("type", JsonPrimitive(type)) }
        val idx = made.indexOfFirst { it.string("id") == containerId }
        if (idx >= 0) {
            val cur = made[idx]
            val list = (cur["boundElements"] as? JsonArray)?.toMutableList() ?: mutableListOf()
            if (list.none { (it as? JsonObject)?.string("id") == childId }) list += entry
            made[idx] = cur.with("boundElements" to JsonArray(list))
        } else {
            pendingBindings.getOrPut(containerId) { mutableListOf() } += entry
        }
    }

internal val pendingBindings = LinkedHashMap<String, MutableList<JsonObject>>()

Bindings onto marks this block did not create; applied by NotationStore.replaceOwned.

    private fun boundsOf(id: String): Rect? =
        made.firstOrNull { it.string("id") == id }?.worldBounds() ?: view.scene[id]?.worldBounds()

Field's Iosevka, which tools/excalidraw-bundle/build.mjs adds to Excalidraw's font table at id 42. It is monospace: every glyph of the semi-condensed face advances 0.456 em.

    private val IOSEVKA_FONT_FAMILY = 42
    private val IOSEVKA_ADVANCE = 0.456

    private fun measure(text: String, size: Double): Pair<Double, Double> {
        val lines = text.split('\n')
        return (lines.maxOf { it.length } * size * IOSEVKA_ADVANCE).coerceAtLeast(size) to lines.size * size * 1.25
    }

internal fun sample(fline: FLine): List<Vec2>

    internal fun sample(fline: FLine): List<Vec2> {
        val out = ArrayList<Vec2>()
        var last: afield.math.linalg.Vec3? = null
        for (node in fline.nodes) {
            val to = node.toOrNull ?: continue
            if (node.storage.size >= 3 && last != null) {
                // FLine.CubicTo keeps [end, c1, c2]: the end point first, then the two controls.
                val p0 = last; val c1 = node.storage[1]; val c2 = node.storage[2]; val p3 = to
                for (i in 1..24) {
                    val t = i / 24.0; val u = 1 - t
                    val px = u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x
                    val py = u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y
                    out += vec(px, py)
                }
            } else {
                out += vec(to.x, to.y)
            }
            last = to
        }
        return out
    }
}

internal object NotationStore

The authoritative scene for each document, and every read-modify-write of it. The scene lives in the root box's drawing property; this caches its parse and serialises writers per document.

internal object NotationStore {
    private class Cached(val text: String?, val scene: NotationScene)
    private val cache = ConcurrentHashMap<SessionInformation, Cached>()
    private val selections = ConcurrentHashMap<SessionInformation, List<String>>()

fun text(info: SessionInformation): String? = info.root.getPropertyRaw("drawing") as? String

fun scene(info: SessionInformation): NotationScene

    fun scene(info: SessionInformation): NotationScene {
        val t = text(info)
        val c = cache[info]
        if (c != null && c.text === t) return c.scene
        val parsed = NotationScene.parse(t)
        cache[info] = Cached(t, parsed)
        return parsed
    }

fun update(info: SessionInformation, f: (NotationScene) -> NotationScene): Pair<NotationScene, NotationScene> =

Applies f to the current scene and stores the result if anything changed. Returns (before, after).

    fun update(info: SessionInformation, f: (NotationScene) -> NotationScene): Pair<NotationScene, NotationScene> =
        synchronized(info.root) {
            // A drawing that is present but unreadable reads as empty, and is never written over:
            // replacing it would turn a parse problem into lost work.
            if (NotationScene.parseOrNull(text(info)) == null) {
                notify("The document's drawing could not be read, so it was not changed", "drawing")
                return@synchronized scene(info) to scene(info)
            }
            val before = scene(info)
            val after = f(before)
            if (after !== before) {
                val text = after.serialize()
                if (text != text(info)) {
                    info.root.setPropertyRaw("drawing", text)
                    cache[info] = Cached(text, after)
                }
            }
            before to after
        }

fun replaceOwned

Replaces owner's marks under key with made; unchanged marks keep their version.

    fun replaceOwned(
        info: SessionInformation, owner: String, key: String, made: List<JsonObject>,
        bindings: Map<String, List<JsonObject>> = emptyMap(),
    ) {
        update(info) { scene ->
            val newIds = made.mapNotNull { it.string("id") }.toSet()
            val stale = scene.live.filter { e ->
                e.custom("fieldOwner")?.stringOrNull() == owner &&
                    e.custom("fieldKey")?.stringOrNull() == key &&
                    e.string("id") !in newIds
            }.map { it.with("isDeleted" to JsonPrimitive(true)) }
            val changed = made.filter { e ->
                val old = scene[e.string("id") ?: ""]
                old == null || old.isDeletedElement() || !sameContent(old, e)
            }
            // Arrows this block bound to marks it did not make: record them on those marks too, or
            // Excalidraw will not drag the arrow along when the mark is moved by hand.
            val rebound = bindings.mapNotNull { (containerId, entries) ->
                val container = scene[containerId] ?: return@mapNotNull null
                val list = (container["boundElements"] as? JsonArray)?.toMutableList() ?: mutableListOf()
                val have = list.mapNotNull { (it as? JsonObject)?.string("id") }.toSet()
                val add = entries.filter { it.string("id") !in have }
                if (add.isEmpty()) null else container.with("boundElements" to JsonArray(list + add))
            }
            scene.withElements(changed + stale + rebound)
        }
        NotationAnchors.ensureHooks(info)
        NotationAnchors.recompute(info)
    }

    private val volatileKeys = setOf("version", "versionNonce", "updated", "index", "seed")
    private fun sameContent(a: JsonObject, b: JsonObject): Boolean =
        JsonObject(a - volatileKeys) == JsonObject(b - volatileKeys)

fun setSelection(info: SessionInformation, ids: List<String>) { selections[info] = ids }

fun selection(info: SessionInformation): List<String> = selections[info] ?: emptyList()

The FLine overloads are extensions so that a [a, b] literal, which could build either a List or an FLine, resolves to the member List overload instead of being ambiguous: a member that applies wins.

fun NotationBuilder.line(fline: FLine, name: String? = null, stroke: String = this.stroke): MarkRef

NotationBuilder.line through the points of an FLine (curves are sampled).

fun NotationBuilder.line(fline: FLine, name: String? = null, stroke: String = this.stroke): MarkRef =
    line(sample(fline), name, stroke)

fun NotationBuilder.stroke(fline: FLine, name: String? = null, stroke: String = this.stroke, pressure: ((Double) -> Double)?

NotationBuilder.stroke along an FLine (curves are sampled).

fun NotationBuilder.stroke(fline: FLine, name: String? = null, stroke: String = this.stroke, pressure: ((Double) -> Double)? = null): MarkRef =
    stroke(sample(fline), name, stroke, pressure)