Skip to content

PaintCanvas.kt

src/jvmMain/kotlin/afield/graphics/paint/PaintCanvas.kt · 1066 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.paint

import afield.app.protocol.Rect
import afield.geometry.path.FLine
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.media.image.util.PngEncoder
import java.lang.foreign.MemorySegment
import kotlin.math.abs

PaintCanvas, a simulated oil-painting canvas that FLines are painted and drawn onto, with its statistics (PaintStats, PaperInfo) and pngRow for showing several canvases side by side.

The canvas runs on the optional native wrapper native/field-paint; see PaintCanvas for building it. Tests: PaintCanvasTest, GesturePaintReplayTest, StereoTest.

A turn of the path sharper than this, degrees, is a reversal: the hand pushes the brush back instead of turning it (PaintCanvas.gesture).

private const val REVERSAL_DEGREES = 120.0

class PaintCanvas internal constructor

A simulated oil-painting canvas that FLines are painted and drawn onto.

The simulation is the paint crate of claude-paint, by alice (github.com/aliceisjustplaying), MIT licensed and vendored in native/field-paint/vendor/paint: brushes made of individual bristles carrying paint, Kubelka–Munk mixing on the canvas, paint that stays wet, stiffens and dries over simulated minutes, graphite and chalk that catch the tooth of the ground, stippling, glazes, and craquelure. Field reaches it through a small Rust wrapper, native/field-paint, which is optional — build it once with ./gradlew buildPaintNative (needs cargo).

val canvas = PaintCanvas(900, aspect = 1.4)
val brush = canvas.brush(BrushKind.HogFlat, width = 30.0)
brush.load(rgb(0.75, 0.3, 0.1))
canvas.stroke(FLine().moveTo(100, 300).cubicTo(300, 100, 600, 500, 900, 300), brush)
display("main", canvas.png())

Coordinates are the crate's own: the canvas is 1000 units wide and 1000 / aspect tall, with y growing downwards, whatever its pixel width. Set view to paint lines drawn in some other rectangle instead; that rectangle is stretched over the whole canvas.

Methods that take a region (hatch, stipple, work, glaze, erase) read a closed FLine: every subpath is a ring, and a point is inside when it is inside an odd number of them, so a ring inside another makes a hole.

A canvas holds native memory. It is freed when the canvas is garbage-collected, or at once by close.

class PaintCanvas internal constructor(
    internal val handle: MemorySegment,
    /** width / height. */
    val aspect: Double,
    seed: Long,
    /** The rectangle of FLine coordinates that maps onto the canvas, or `null` for canvas units. */
    var view: Rect?,
) : AutoCloseable {

    private val cleanable = PaintNative.cleaner.register(this, PaintNative.Free(handle, PaintNative::canvasFree))

val pixelSize: Pair<Int, Int> = PaintNative.canvasSize(handle)

Pixel width and height of the picture render returns.

val widthPx: Int get() = pixelSize.first

Pixel width.

val heightUnits: Double get() = 1000.0 / aspect

Height in canvas units (the width is always 1000).

    private var nextSeed = seed * 7919 + 1

internal fun seed(): Long = nextSeed++

The seed for the ith native call of one operation: from seed when the caller gives one, so the same seed reaches the same call however many operations came before; otherwise the next of this canvas's own sequence.

    private fun seedFor(seed: Long?, i: Int = 0): Long = if (seed == null) seed() else mix(seed, i)

Key the next native stroke's noise on seed (and its piece i) when the caller gives one.

    private fun keyNoise(seed: Long?, i: Int = 0) {
        if (seed != null) PaintNative.strokeNoise(handle, (mix(seed, i) xor (mix(seed, i) ushr 32)).toInt())
    }

    private fun mix(seed: Long, i: Int): Long {
        var z = seed + 0x9E3779B97F4A7C15uL.toLong() * (i + 1)
        z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
        z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
        return z xor (z ushr 31)
    }

val paintEngine: Int by lazy { PaintNative.canvasEngine(handle) }

The engine version this canvas paints with (PaintCanvas.invoke's engine).

fun brush(kind: BrushKind = BrushKind.Round, width: Double = 20.0, point: Double = 0.0, config: BrushConfig? = null, seed: Long? = null): PaintBrush

A brush held over this canvas, width canvas units wide whatever the view. Load it with PaintBrush.load before stroking. config changes how it is made — how many bristles, how stiff and long, how much it lays and picks up — from the kind's own.

    fun brush(kind: BrushKind = BrushKind.Round, width: Double = 20.0, point: Double = 0.0, config: BrushConfig? = null, seed: Long? = null): PaintBrush =
        PaintBrush(kind, width, point, seedFor(seed), paintEngine, config)

fun knife(width: Double = 60.0): PaintKnife = PaintKnife(PaintNative.knifeNew(width.toFloat()))

A painting knife whose blade is width canvas units long (a 4 cm blade is about 60 units on a 700 mm canvas). Tool sizes are canvas units whatever the view, as a brush's width is.

fun rag(width: Double = 50.0, seed: Long? = null): PaintRag

A clean cotton rag bunched into a pad width canvas units across.

    fun rag(width: Double = 50.0, seed: Long? = null): PaintRag = PaintRag(PaintNative.ragNew(width.toFloat(), seedFor(seed)))

fun pencil(grade: String = "HB"): Pencil

A graphite pencil of grade ("2H", "HB", "4B", ...) for draw, sketch and hatch. Hard grades lay a pale grey and stay sharp; soft ones lay more, darker, and blunt as they draw.

    fun pencil(grade: String = "HB"): Pencil = Pencil(DrawingMedium.Graphite, PaintNative.pencilSoftness(grade))

fun chalk(): Pencil = Pencil(DrawingMedium.Chalk, 6.0)

Natural black chalk: deep, matte, broad and crumbly; it wears fast.

brushes

fun stroke

Drag brush along every subpath of line.

Each subpath is one movement of the hand. Curves are sampled every spacing canvas units (the crate resamples again to ~2 px, so this only has to follow the curve). The brush is not reloaded between subpaths: it runs out the way a real one does, which is most of the look.

@param pressure pressure at the start and end of each movement, 0..1. @param shake hand unsteadiness: 0 mechanically exact, 1 a normal hand. @param along hold the brush's wide axis along the stroke rather than across it. @param swell pressure multipliers at evenly spaced knots along each movement.

    fun stroke(
        line: FLine,
        brush: PaintBrush,
        pressure: ClosedFloatingPointRange<Double> = 0.8..0.8,
        shake: Double = 1.0,
        attack: Double = 0.08,
        release: Double = 0.15,
        along: Boolean = false,
        swell: List<Double> = emptyList(),
        spacing: Double = 2.0,
        seed: Long? = null,
    ): PaintCanvas = apply {
        for ((i, xy) in polylines(line, spacing).withIndex()) {
            keyNoise(seed, i)
            PaintNative.drag(handle, brush.handle, xy, pressure.start.toFloat(), pressure.endInclusive.toFloat(),
                shake.toFloat(), attack.toFloat(), release.toFloat(), along, swell)
        }
    }

fun touch

Touch the brush tip down at (x, y) and lift it, with no path: a dab. Harder pressure makes a bigger, fuller mark; drag moves the hand that far while the hairs are down, twist rolls the handle (radians) and angle turns a flat's wide axis (radians).

    fun touch(
        brush: PaintBrush, x: Double, y: Double, pressure: Double = 0.6,
        drag: Pair<Double, Double> = 0.0 to 0.0, twist: Double = 0.0, angle: Double = 0.0, seed: Long? = null,
    ): PaintCanvas = apply {
        keyNoise(seed)
        val (dx, dy) = drag
        PaintNative.touch(handle, brush.handle, toCanvasX(x).toFloat(), toCanvasY(y).toFloat(), pressure.toFloat(),
            toCanvasDx(dx).toFloat(), toCanvasDy(dy).toFloat(), twist.toFloat(), angle.toFloat())
    }

fun work

Work a whole region with many strokes, the way a painter blocks in an area: the crate plans the strokes (lengths, directions, curvature, broken and restated strokes, trips to the palette) from a Handling preset, and paints each with a simulated brush.

@param angle stroke direction, radians (0 is left to right); null keeps the preset's. @param width brush width in canvas units; null keeps the preset's brush. @param length stroke length range in canvas units, null for the preset's. @param coverage how many layers of strokes cover each point on average. @param cross criss-cross: strokes fall into two families at ± this angle (radians). @param clip stop the bristles exactly at the region's edge, instead of letting strokes overrun it a little. @param feather soften the region's edge by this many canvas units.

    fun work(
        region: FLine,
        color: Vec4,
        handling: Handling = Handling.Body,
        hiding: Double = 0.85,
        stiff: Double = 0.8,
        angle: Double? = null,
        angleJitter: Double? = null,
        width: Double? = null,
        length: ClosedFloatingPointRange<Double>? = null,
        coverage: Double? = null,
        pressure: ClosedFloatingPointRange<Double>? = null,
        cross: Double? = null,
        order: StrokeOrder? = null,
        clip: Boolean? = null,
        feather: Double = 0.0,
    ): PaintCanvas = apply {
        val nan = Float.NaN
        val (orderCode, sweep) = when (order) {
            null -> -1 to 0f
            is StrokeOrder.Passages -> 0 to 0f
            is StrokeOrder.Sweep -> 1 to order.angle.toFloat()
            is StrokeOrder.Scatter -> 2 to 0f
        }
        PaintNative.work(handle, rings(region), toCanvasLength(feather).toFloat(), handling.code,
            linear(color.x), linear(color.y), linear(color.z), hiding.toFloat(), stiff.toFloat(),
            angle?.toFloat() ?: nan, angleJitter?.toFloat() ?: nan,
            length?.start?.let { toCanvasLength(it).toFloat() } ?: nan,
            length?.endInclusive?.let { toCanvasLength(it).toFloat() } ?: nan,
            coverage?.toFloat() ?: nan, pressure?.start?.toFloat() ?: nan, pressure?.endInclusive?.toFloat() ?: nan,
            cross?.toFloat() ?: nan, orderCode, sweep, when (clip) { null -> -1; true -> 1; false -> 0 },
            width?.let { toCanvasLength(it).toFloat() } ?: nan, seed())
    }

fun outline

Draw a hand outline through the rough points of line — each subpath one outline, closed when its last point returns to its first or closed says so — and drag brush along the strokes a hand would use: they lift and restart, overlap or leave gaps, overshoot corners and restate stretches, each with its own pressure. Returns those strokes as an FLine (in the same coordinates as line), so the drawing can be reused; pass brush = null to get the strokes without painting them.

@param amount scales every irregularity of the character; 0 is a clean curve. @param size the hand's scale in canvas units; null takes it from the outline's extent.

    fun outline(
        line: FLine,
        brush: PaintBrush?,
        character: OutlineCharacter = OutlineCharacter.Firm,
        pressure: Double = 0.7,
        amount: Double? = null,
        closed: Boolean? = null,
        size: Double? = null,
    ): FLine {
        val out = FLine()
        for (piece in line.pieces()) {
            val xy = toCanvas(piece.sampleByDistance(toLineUnits(4.0).toFloat()).flatMap { listOf(it.x.toDouble(), it.y.toDouble()) })
            if (xy.size < 4) continue
            val first = piece.nodes.first().to
            val last = piece.nodes.last().toOrNull
            val isClosed = closed ?: (last == null || (last.x - first.x) * (last.x - first.x) + (last.y - first.y) * (last.y - first.y) < 1e-6f)
            val strokes = PaintNative.outline(handle, brush?.handle, xy, isClosed, character.code,
                amount?.toFloat() ?: Float.NaN, pressure.toFloat(), size?.let { toCanvasLength(it).toFloat() } ?: 0f, seed())
            for (s in strokes) {
                for (i in s.indices step 2) {
                    val x = fromCanvasX(s[i].toDouble())
                    val y = fromCanvasY(s[i + 1].toDouble())
                    if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
                }
            }
        }
        return out
    }

fun glaze(color: Vec4, region: FLine? = null, coats: Double = 1.0, hiding: Double = 0.05, feather: Double = 0.0): PaintCanvas

A transparent Kubelka–Munk glaze of color, coats thick, over region (or the whole canvas). It dries at once. Glaze over dry paint: call dry first if the paint under it is still wet.

    fun glaze(color: Vec4, region: FLine? = null, coats: Double = 1.0, hiding: Double = 0.05, feather: Double = 0.0): PaintCanvas = apply {
        PaintNative.glaze(handle, region?.let { rings(it) }, toCanvasLength(feather).toFloat(),
            linear(color.x), linear(color.y), linear(color.z), hiding.toFloat(), coats.toFloat())
    }

fun spatter(brush: PaintBrush, x: Double, y: Double, toward: Pair<Double, Double>, spread: Double = 0.35, force: Double = 0.7): Int

Flick brush at (x, y) towards toward (a direction whose length is how far the paint carries): the paint the hairs can't hold by capillarity flies off in droplets and lands in a cone spread radians either side. Only the loose paint flies — more of it the harder the force (0..1) and the more fluid the paint (thinned or turps-rich paint spatters, stiff tube paint barely leaves the brush) — and a harder flick breaks it into more, smaller drops. Each drop comes off one hair with that hair's paint, so a double-loaded brush spatters both colours. Returns how many droplets landed.

    fun spatter(brush: PaintBrush, x: Double, y: Double, toward: Pair<Double, Double>, spread: Double = 0.35, force: Double = 0.7): Int =
        PaintNative.spatter(handle, brush.handle, toCanvasX(x).toFloat(), toCanvasY(y).toFloat(),
            toCanvasDx(toward.first).toFloat(), toCanvasDy(toward.second).toFloat(), spread.toFloat(), force.toFloat(), seed())

fun knife

Pull knife along every subpath of line, pressed pressure from start to end, the blade across the path or at angle (radians). The flexible blade rests on the high points of the dry surface and stands off them by a gap that closes as it is pressed (a light touch rides about 0.3 mm up). Wet paint above the blade is cut into its bead, or pressed out past its ends into ridges; with lay, below it the bead fills the surface to the blade's level: a flat-topped slab, or paint on the peaks of dry impasto only. When the blade lifts, lift of the bead stays where it was.

    fun knife(
        line: FLine, knife: PaintKnife, pressure: ClosedFloatingPointRange<Double> = 0.6..0.6,
        angle: Double? = null, lay: Boolean = true, lift: Double = 0.3, seed: Long? = null,
    ): PaintCanvas = apply {
        for ((i, xy) in polylines(line, 2.0).withIndex()) {
            keyNoise(seed, i)
            PaintNative.knifeDrag(handle, knife.handle, xy, pressure.start.toFloat(), pressure.endInclusive.toFloat(),
                angle?.toFloat() ?: Float.NaN, lay, lift.toFloat())
        }
    }

fun scrape(line: FLine, knife: PaintKnife, pressure: ClosedFloatingPointRange<Double> = 0.9..0.9, angle: Double? = null, seed: Long? = null): PaintCanvas

Scrape wet paint off along line with knife, pressed hard: knife with nothing laid.

    fun scrape(line: FLine, knife: PaintKnife, pressure: ClosedFloatingPointRange<Double> = 0.9..0.9, angle: Double? = null, seed: Long? = null): PaintCanvas =
        knife(line, knife, pressure, angle, lay = false, lift = 0.0, seed = seed)

fun wipe(line: FLine, rag: PaintRag, pressure: List<Double> = listOf(0.6), seed: Long? = null): Double

Wipe rag along every subpath of line at pressure (one value, or values spread along the path). The pad rests on the tops of the weave and reaches into the hollows as it is pressed, and the cloth touches in creases, so a wipe lifts in streaks. Returns the mm³ of paint lifted: negative where a loaded rag left more than it took up, smearing its paint onto paint that is setting.

    fun wipe(line: FLine, rag: PaintRag, pressure: List<Double> = listOf(0.6), seed: Long? = null): Double =
        polylines(line, 2.0).withIndex().sumOf { (i, xy) -> PaintNative.ragWipe(handle, rag.handle, xy, pressure, seedFor(seed, i)) }

fun blot(rag: PaintRag, x: Double, y: Double, pressure: Double = 0.6, seed: Long? = null): Double

Press rag down at (x, y) and lift it off: a crumpled patch. Returns the mm³ lifted.

    fun blot(rag: PaintRag, x: Double, y: Double, pressure: Double = 0.6, seed: Long? = null): Double =
        PaintNative.ragWipe(handle, rag.handle, floatArrayOf(toCanvasX(x).toFloat(), toCanvasY(y).toFloat()), listOf(pressure), seedFor(seed))

fun dampen(rag: PaintRag, amount: Double = 0.5): PaintCanvas

Dip rag's face in spirits, amount 0..1: damp, it lifts paint nearly to the ground until the spirits evaporate.

    fun dampen(rag: PaintRag, amount: Double = 0.5): PaintCanvas = apply { PaintNative.ragDip(handle, rag.handle, amount.toFloat()) }

fun refold(rag: PaintRag): PaintCanvas = apply { PaintNative.ragRefold(handle, rag.handle) }

Turn a cleaner face of rag outward (no face is cleaner than what has soaked through the cloth).

fun ragState(rag: PaintRag): RagState

How loaded and damp rag is now, on this canvas's clock.

    fun ragState(rag: PaintRag): RagState = PaintNative.ragState(handle, rag.handle).let { RagState(it[0].toDouble(), it[1].toDouble(), it[2].toDouble()) }

fun carried(brush: PaintBrush): Carried

The paint brush carries, in mm³ on this canvas's physical scale, and its mixed colour.

    fun carried(brush: PaintBrush): Carried {
        val v = PaintNative.brushCarried(handle, brush.handle)
        val p = PaintNative.brushPaint(handle, brush.handle)
        return Carried(p?.let { Vec4(srgb(it[0]), srgb(it[1]), srgb(it[2]), 1f) }, v[0], v[1])
    }

fun carried(knife: PaintKnife): Carried

The paint on knife's blade, as carried for a brush.

    fun carried(knife: PaintKnife): Carried {
        val p = PaintNative.knifePaint(handle, knife.handle)
        return Carried(p?.let { Vec4(srgb(it[0]), srgb(it[1]), srgb(it[2]), 1f) }, p?.get(3)?.toDouble() ?: 0.0, 0.0)
    }

watercolour and ink

val mmPerUnit: Double get() = PaintNative.mmPerUnit(handle).toDouble()

Millimetres per canvas unit.

fun waterBrush(width: Double = 30.0, capacityMm3: Double? = null): WaterBrush

A round watercolour brush width canvas units across. Its belly holds about 0.6 d³ mm³ of water for a brush d mm wide (a 6 mm round about 0.13 ml); capacityMm3 sets it instead.

    fun waterBrush(width: Double = 30.0, capacityMm3: Double? = null): WaterBrush =
        WaterBrush(PaintNative.wbrushNew(handle, width.toFloat(), capacityMm3?.toFloat() ?: 0f))

fun gesture

A TimedGesture along every subpath of line (one after another, gap seconds apart), moving at speed mm/s, pressing pressure (one value, a start and end, or a profile at evenly spaced stations), sampled every spacing canvas units. The handle is tilted tilt degrees from upright; with lean it leans toward where it is going (the hairs trailing behind, as a brush is pulled), and a flat's wide axis is held across the path unless roll (degrees) fixes it. Where the path doubles back on itself by more than 120°, as in scrubbing, the handle keeps its lean and the brush is pushed back.

With facets the hand moves in straight chords facets canvas units long instead of following the curve: at each corner the handle turns at once, the hairs whip round, and a curve comes out beaded, a bead a corner. A polyline line does this by itself at its own corners.

body — 46 lines, from line 369
    fun gesture(
        line: FLine, speed: Double = 80.0, pressure: List<Double> = listOf(0.6), gap: Double = 0.3, spacing: Double = 4.0,
        tilt: Double = 0.0, lean: Boolean = true, roll: Double? = null, facets: Double? = null,
    ): TimedGesture {
        require(facets == null || facets > 0.0) { "facets are chords of some length: above 0 canvas units" }
        require(speed > 0.0) { "a gesture moves: speed must be above 0 mm/s" }
        val out = mutableListOf<TimedGesture.Sample>()
        var t = 0.0
        for ((pi, whole) in line.pieces().withIndex()) {
            val piece = facets?.let { f ->
                val corners = whole.sampleByDistance(toLineUnits(f).toFloat())
                if (corners.size < 2) whole else FLine().apply {
                    moveTo(corners[0].x.toDouble(), corners[0].y.toDouble())
                    for (c in corners.drop(1)) lineTo(c.x.toDouble(), c.y.toDouble())
                    // (the samples stop short of the end unless the length divides evenly)
                    val end = whole.cursor().also { it.setD(it.lengthD()) }.position()
                    if (Math.hypot((end.x - corners.last().x).toDouble(), (end.y - corners.last().y).toDouble()) > 1e-3 * f) lineTo(end.x.toDouble(), end.y.toDouble())
                }
            } ?: whole
            val pts = piece.sampleByDistance(toLineUnits(spacing).toFloat())
            if (pts.isEmpty()) continue
            var arc = 0.0
            val arcs = pts.indices.map { i ->
                if (i > 0) arc += Math.hypot(toCanvasDx((pts[i].x - pts[i - 1].x).toDouble()), toCanvasDy((pts[i].y - pts[i - 1].y).toDouble()))
                arc
            }
            if (out.isNotEmpty()) t += gap
            // the way the hand holds the brush: it turns the handle with the path, unwrapped (a path
            // heading due left must not flip between 180° and -180°), but where the path doubles back
            // sharply, scrubbing, it pushes the brush back the way it came rather than swinging the
            // handle round. Each subpath is a fresh hold.
            var hand: Double? = null
            pts.forEachIndexed { i, p ->
                val u = if (arc > 0) arcs[i] / arc else 0.0
                // the way the path goes here, in canvas units, for the handle's lean and a flat's roll
                val (a, b) = if (i + 1 < pts.size) pts[i] to pts[i + 1] else pts[(i - 1).coerceAtLeast(0)] to pts[i]
                val raw = Math.toDegrees(Math.atan2(toCanvasDy((b.y - a.y).toDouble()), toCanvasDx((b.x - a.x).toDouble())))
                val heading = hand?.let { prev -> val turn = (raw - prev + 540.0) % 360.0 - 180.0; if (abs(turn) > REVERSAL_DEGREES) prev else prev + turn } ?: raw
                hand = heading
                out += TimedGesture.Sample(p.x.toDouble(), p.y.toDouble(), t + arcs[i] * mmPerUnit / speed, profile(pressure, u),
                    tilt = tilt, azimuth = if (lean) heading else 0.0, roll = roll ?: (heading + 90.0), s = arcs[i] * mmPerUnit, piece = pi)
            }
            t += arc * mmPerUnit / speed
        }
        return TimedGesture(out)
    }

fun gesture3d

A TimedGesture along a line in three dimensions, as one eye of a stereo pair sees it: project takes a point of line (world units, z included) to this canvas's FLine coordinates. Lengths, speed and the pressure profile are the world's, measured along the line in 3D at mmPerWorld mm to a world unit: each sample carries its world arc (TimedGesture.Sample.s), so the two eyes' gestures press, pause and run out at the same places along the line however foreshortened each sees it. Each subpath is one movement of the hand (gap seconds apart); straight segments are sampled every spacing world units, curves finely enough to follow. The handle's tilt, lean and roll are as in gesture, its heading taken on this canvas.

    fun gesture3d(
        line: FLine, project: (Vec3) -> Vec2, mmPerWorld: Double = 1.0, speed: Double = 80.0,
        pressure: List<Double> = listOf(0.6), gap: Double = 0.3, spacing: Double = 2.0,
        tilt: Double = 0.0, lean: Boolean = true, roll: Double? = null,
    ): TimedGesture {
        require(speed > 0.0) { "a gesture moves: speed must be above 0 mm/s" }
        require(mmPerWorld > 0.0 && spacing > 0.0) { "a world unit and the sampling are some length" }
        val out = mutableListOf<TimedGesture.Sample>()
        var t = 0.0
        for ((pi, pts) in polylines3d(line, spacing).withIndex()) {
            if (pts.size < 2) continue
            val arcs = DoubleArray(pts.size)
            for (i in 1 until pts.size) arcs[i] = arcs[i - 1] + dist3(pts[i], pts[i - 1])
            val total = arcs.last()
            val xy = pts.map(project)
            if (out.isNotEmpty()) t += gap
            var hand: Double? = null
            for (i in pts.indices) {
                val (a, b) = if (i + 1 < xy.size) xy[i] to xy[i + 1] else xy[(i - 1).coerceAtLeast(0)] to xy[i]
                val raw = Math.toDegrees(Math.atan2(toCanvasDy((b.y - a.y).toDouble()), toCanvasDx((b.x - a.x).toDouble())))
                val heading = hand?.let { prev -> val turn = (raw - prev + 540.0) % 360.0 - 180.0; if (abs(turn) > REVERSAL_DEGREES) prev else prev + turn } ?: raw
                hand = heading
                val sMm = arcs[i] * mmPerWorld
                out += TimedGesture.Sample(xy[i].x.toDouble(), xy[i].y.toDouble(), t + sMm / speed, profile(pressure, if (total > 0) arcs[i] / total else 0.0),
                    tilt = tilt, azimuth = if (lean) heading else 0.0, roll = roll ?: (heading + 90.0), s = sMm, piece = pi)
            }
            t += total * mmPerWorld / speed
        }
        require(out.isNotEmpty()) { "a gesture needs a line of some length" }
        return TimedGesture(out)
    }

fun warp(warp: StereoWarp, ground: Boolean = true): PaintCanvas

Make this canvas one eye of a stereo pair whose other eye (the reference) is painted without a warp: from now on texture keyed on the canvas's places (a pencil's grain, tacky paint's grab) is looked up where warp says each place is on the reference. With ground, the ground (its weave, priming and colour, or a watercolour sheet's relief and fibres) is pulled back through it too, as if made on the reference, so give the warp to a fresh canvas, before any paint. warp's canvas units are this canvas's own (1000 across), not its view's.

    fun warp(warp: StereoWarp, ground: Boolean = true): PaintCanvas = apply {
        PaintNative.warp(handle, warp.columns, warp.rows, warp.cell.toFloat(), warp.at, ground)
    }

fun warp(pins: List<StereoWarp.Pin>, cell: Double = 10.0, frame: Boolean = true, ground: Boolean = true): StereoWarp

The StereoWarp through pins (this canvas's FLine coordinates, its view if it has one), solved over this canvas (StereoWarp.solve), and given to it (warp).

    fun warp(pins: List<StereoWarp.Pin>, cell: Double = 10.0, frame: Boolean = true, ground: Boolean = true): StereoWarp {
        val inUnits = pins.map { p -> StereoWarp.Pin(Vec2(toCanvasX(p.here.x.toDouble()).toFloat(), toCanvasY(p.here.y.toDouble()).toFloat()),
            Vec2(toCanvasX(p.there.x.toDouble()).toFloat(), toCanvasY(p.there.y.toDouble()).toFloat())) }
        return StereoWarp.solve(inUnits, aspect, cell, frame).also { warp(it, ground) }
    }

Hand a movement's world arc (from its start, mm, at each sample) to the next native stroke.

    private fun keyArc(piece: List<TimedGesture.Sample>) {
        if (piece.any { !it.s.isFinite() }) return
        val s0 = piece.first().s
        PaintNative.strokeArc(handle, FloatArray(piece.size) { (piece[it].s - s0).toFloat() })
    }

    private fun canvasXy(piece: List<TimedGesture.Sample>): FloatArray = toCanvas(piece.flatMap { listOf(it.x, it.y) })

A movement's pressure at 16 knots evenly spaced along its world arc.

    private fun swellOf(piece: List<TimedGesture.Sample>): List<Double> {
        val s0 = piece.first().s
        val len = (piece.last().s - s0).takeIf { it.isFinite() && it > 0 }
        return List(16) { k ->
            val u = k / 15.0
            if (len == null) piece[(u * (piece.size - 1)).toInt()].pressure
            else {
                val i = piece.indexOfFirst { (it.s - s0) / len >= u }.let { if (it < 1) maxOf(it, 0) else it }
                val a = piece[(i - 1).coerceAtLeast(0)]; val b = piece[i]
                val f = if (b.s > a.s) (((u * len) - (a.s - s0)) / (b.s - a.s)).coerceIn(0.0, 1.0) else 0.0
                a.pressure + (b.pressure - a.pressure) * f
            }
        }
    }

fun stroke(gesture: TimedGesture, brush: PaintBrush, shake: Double = 1.0, attack: Double = 0.08, release: Double = 0.15,

Drag brush through gesture (e.g. from gesture3d): each movement of the hand one stroke, pressed as the gesture presses, and what is laid out along it following its world arc.

    fun stroke(gesture: TimedGesture, brush: PaintBrush, shake: Double = 1.0, attack: Double = 0.08, release: Double = 0.15,
               along: Boolean = false, seed: Long? = null): PaintCanvas = apply {
        for ((i, piece) in gesture.pieces.withIndex()) {
            if (piece.size < 2) continue
            keyNoise(seed, i); keyArc(piece)
            PaintNative.drag(handle, brush.handle, canvasXy(piece), 1f, 1f, shake.toFloat(), attack.toFloat(), release.toFloat(), along, swellOf(piece))
        }
    }

fun knife(gesture: TimedGesture, knife: PaintKnife, angle: Double? = null, lay: Boolean = true, lift: Double = 0.3, seed: Long? = null): PaintCanvas

Pull knife through gesture, as along a line, its tears and pressure following its world arc.

    fun knife(gesture: TimedGesture, knife: PaintKnife, angle: Double? = null, lay: Boolean = true, lift: Double = 0.3, seed: Long? = null): PaintCanvas = apply {
        for ((i, piece) in gesture.pieces.withIndex()) {
            if (piece.size < 2) continue
            keyNoise(seed, i); keyArc(piece)
            PaintNative.knifeDrag(handle, knife.handle, canvasXy(piece), piece.first().pressure.toFloat(), piece.last().pressure.toFloat(),
                angle?.toFloat() ?: Float.NaN, lay, lift.toFloat())
        }
    }

fun wipe(gesture: TimedGesture, rag: PaintRag, seed: Long? = null): Double

Wipe rag through gesture, its creases, wander and pressure following its world arc. Returns the mm³ lifted.

    fun wipe(gesture: TimedGesture, rag: PaintRag, seed: Long? = null): Double =
        gesture.pieces.withIndex().filter { it.value.size >= 2 }.sumOf { (i, piece) ->
            keyArc(piece)
            PaintNative.ragWipe(handle, rag.handle, canvasXy(piece), swellOf(piece), seedFor(seed, i))
        }

fun sketch(gesture: TimedGesture, pencil: Pencil, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas

Sketch along gesture (sketch), its searching strokes laid out along its world arc: a stereo pair's eyes break and wander the strokes at the same places along the line. The pressure is the gesture's mean.

    fun sketch(gesture: TimedGesture, pencil: Pencil, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas = apply {
        for ((i, piece) in gesture.pieces.withIndex()) {
            if (piece.size < 2) continue
            keyArc(piece)
            pencil.worn += PaintNative.sketchLine(handle, pencil, canvasXy(piece), piece.map { it.pressure }.average().toFloat(), passes,
                toCanvasLength(wander).toFloat(), toCanvasLength(tremor).toFloat(), seedFor(seed, i))
        }
    }

fun draw(gesture: TimedGesture, pencil: Pencil, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas

Draw gesture with pencil, its tremor and pressure following its world arc.

    fun draw(gesture: TimedGesture, pencil: Pencil, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas = apply {
        for ((i, piece) in gesture.pieces.withIndex()) {
            if (piece.size < 2) continue
            keyArc(piece)
            pencil.worn += PaintNative.drawLine(handle, pencil, canvasXy(piece), swellOf(piece), toCanvasLength(tremor).toFloat(), !ruler, ruler, seedFor(seed, i))
        }
    }

    private fun profile(p: List<Double>, u: Double): Double = when (p.size) {
        0 -> 0.6
        1 -> p[0]
        else -> {
            val x = u.coerceIn(0.0, 1.0) * (p.size - 1)
            val i = x.toInt().coerceAtMost(p.size - 2)
            p[i] + (p[i + 1] - p[i]) * (x - i)
        }
    }

fun wash(line: FLine, brush: WaterBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, seed: Long? = null): WashReport

Drag a watercolour brush along line at speed mm/s (gesture), the water on the sheet flowing, sinking in and evaporating as it goes. What it laid and took up comes back.

    fun wash(line: FLine, brush: WaterBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, seed: Long? = null): WashReport =
        wash(gesture(line, speed, pressure), brush, seed)

fun wash(gesture: TimedGesture, brush: WaterBrush, seed: Long? = null): WashReport

Drag a watercolour brush through a timed gesture.

    fun wash(gesture: TimedGesture, brush: WaterBrush, seed: Long? = null): WashReport {
        val xytp = gesture.xytp()
        for (i in xytp.indices step 4) {
            xytp[i] = toCanvasX(xytp[i].toDouble()).toFloat(); xytp[i + 1] = toCanvasY(xytp[i + 1].toDouble()).toFloat()
        }
        val r = PaintNative.wbrushStroke(handle, brush.handle, xytp, seedFor(seed))
        return WashReport(r[0], r[1], r[2], r[3], r[4])
    }

fun dynamicBrush(kind: BrushKind = BrushKind.Round, width: Double = 30.0, hairs: Int = 32, segments: Int = 5, config: BrushConfig? = null, seed: Long? = null):

A brush of kind whose hairs move (DynamicBrush): hairs guide hairs of segments segments each, made as config says (its stiffness, length, point and so on).

    fun dynamicBrush(kind: BrushKind = BrushKind.Round, width: Double = 30.0, hairs: Int = 32, segments: Int = 5, config: BrushConfig? = null, seed: Long? = null): DynamicBrush =
        DynamicBrush(PaintNative.dbrushNew(handle, kind.code, width.toFloat(), (config ?: BrushConfig()).values(), hairs, segments, seedFor(seed)), kind)

fun stroke(gesture: TimedGesture, brush: DynamicBrush, seed: Long? = null,

Drag a DynamicBrush through a timed gesture, in oil or watercolour as it was last loaded.

splay, splayFan, splayScatter, cohesion and clumping (as in DynamicBrush.set), when given, vary along the stroke: each is asked its value at every sample of the gesture and eased between them, and the brush has its own values back after. A value keyed on the sample's world length (TimedGesture.Sample.s, as PaintNoise.along is) lies at the same places along a movement in both eyes of a stereo pair.

    fun stroke(gesture: TimedGesture, brush: DynamicBrush, seed: Long? = null,
               splay: ((TimedGesture.Sample) -> Double)? = null, splayFan: ((TimedGesture.Sample) -> Double)? = null,
               splayScatter: ((TimedGesture.Sample) -> Double)? = null, cohesion: ((TimedGesture.Sample) -> Double)? = null,
               clumping: ((TimedGesture.Sample) -> Double)? = null): DynamicReport {
        for ((name, f) in listOf("splay" to splay, "splay fan" to splayFan, "splay scatter" to splayScatter, "cohesion" to cohesion, "clumping" to clumping)) {
            if (f != null) PaintNative.dbrushVary(brush.handle, name, FloatArray(gesture.samples.size) { f(gesture.samples[it]).toFloat() })
        }
        val v = FloatArray(gesture.samples.size * 7)
        gesture.samples.forEachIndexed { i, s ->
            v[7 * i] = toCanvasX(s.x).toFloat(); v[7 * i + 1] = toCanvasY(s.y).toFloat(); v[7 * i + 2] = s.t.toFloat(); v[7 * i + 3] = s.pressure.toFloat()
            v[7 * i + 4] = Math.toRadians(s.tilt).toFloat(); v[7 * i + 5] = Math.toRadians(s.azimuth).toFloat(); v[7 * i + 6] = Math.toRadians(s.roll).toFloat()
        }
        keyNoise(seed)
        keyArc(gesture.samples)
        val r = PaintNative.dbrushStroke(handle, brush.handle, v, seedFor(seed))
        return DynamicReport(r[0].toLong(), r[1], r[2].toLong(), r[3], r[4], r[5], r[6], r[7])
    }

fun stroke(line: FLine, brush: DynamicBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, tilt: Double = 30.0, seed: Long? = null): DynamicReport

Drag a DynamicBrush along line at speed mm/s, tilted tilt degrees and leaning the way it goes (gesture makes the timing and the handle's pose).

    fun stroke(line: FLine, brush: DynamicBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, tilt: Double = 30.0, seed: Long? = null): DynamicReport =
        stroke(gesture(line, speed, pressure, tilt = tilt), brush, seed)

fun contactMap(): FloatArray?

Seconds of hair contact per pixel in the last DynamicBrush stroke (row major), or null before one.

    fun contactMap(): FloatArray? = PaintNative.contactMap(handle, pixelSize.first * pixelSize.second)

fun contactMapPng(): ByteArray

contactMap as a PNG, black where no hair touched and white at the most-touched pixel.

    fun contactMapPng(): ByteArray {
        val v = contactMap() ?: FloatArray(pixelSize.first * pixelSize.second)
        val hi = (v.maxOrNull() ?: 1f).coerceAtLeast(1e-9f)
        val px = IntArray(v.size) { i -> val g = (v[i] / hi * 255).toInt().coerceIn(0, 255); (0xff shl 24) or (g shl 16) or (g shl 8) or g }
        return PngEncoder.encode(px, pixelSize.first, pixelSize.second)
    }

fun hairs(brush: DynamicBrush): List<List<Triple<Double, Double, Double>>>

brush's hairs as they are now: each a polyline of its nodes, x and y in this canvas's coordinates and z the height above the canvas in mm. Draw them with FLines to see the brush.

    fun hairs(brush: DynamicBrush): List<List<Triple<Double, Double, Double>>> {
        val v = PaintNative.dbrushHairs(handle, brush.handle)
        val n = v[0].toInt(); val m = v[1].toInt()
        return List(n) { h -> List(m) { k -> val o = 2 + 3 * (h * m + k); Triple(fromCanvasX(v[o].toDouble()), fromCanvasY(v[o + 1].toDouble()), v[o + 2].toDouble()) } }
    }

fun drop(x: Double, y: Double, paint: WaterPaint, volumeMm3: Double = 20.0): PaintCanvas

Let a drop of volumeMm3 µl of paint fall at (x, y): it spreads to a puddle, then the sheet takes it.

    fun drop(x: Double, y: Double, paint: WaterPaint, volumeMm3: Double = 20.0): PaintCanvas = apply {
        PaintNative.waterDrop(handle, toCanvasX(x).toFloat(), toCanvasY(y).toFloat(), volumeMm3.toFloat(), paint, seed())
    }

fun wet(region: FLine, microns: Double = 100.0, paint: WaterPaint = WaterPaint.Clear, feather: Double = 0.0): PaintCanvas

Lay microns µm of water over region at once, as a wide mop would: clean water to work wet into wet, or a flat wash of paint.

    fun wet(region: FLine, microns: Double = 100.0, paint: WaterPaint = WaterPaint.Clear, feather: Double = 0.0): PaintCanvas = apply {
        PaintNative.waterWash(handle, rings(region), toCanvasLength(feather).toFloat(), microns.toFloat(), paint, seed())
    }

fun tilt(degrees: Double, direction: Double = 90.0): PaintCanvas

Tilt the sheet degrees from flat, falling toward direction degrees (90: down the canvas). Upright is 90°.

    fun tilt(degrees: Double, direction: Double = 90.0): PaintCanvas = apply { PaintNative.tilt(handle, degrees.toFloat(), direction.toFloat(), seed()) }

fun evaporation(micronsPerSecond: Double): PaintCanvas

Set how fast open water evaporates, µm/s: still air at 20 °C and 50 % humidity about 0.15, a warm draught several times that.

    fun evaporation(micronsPerSecond: Double): PaintCanvas = apply { PaintNative.evaporation(handle, micronsPerSecond.toFloat(), seed()) }

fun marangoni(newtonsPerMetre: Double): PaintCanvas

How far paint lowers the surface tension of the water it is in, N/m (0.008 by default; clean water is 0.072). Cleaner water pulls the surface toward it — Marangoni flow — so paint touched into a wet wash spreads into it: wet-in-wet's blooms. 0 turns it off; more spreads faster.

    fun marangoni(newtonsPerMetre: Double): PaintCanvas = apply { PaintNative.marangoni(handle, newtonsPerMetre.toFloat(), seed()) }

fun dispersion(mm2PerSecond: Double): PaintCanvas

An extra spreading of pigment through standing water, mm²/s, 0 by default. Not simulated: a dial for mixing the water model does not resolve, added to the Marangoni spreading.

    fun dispersion(mm2PerSecond: Double): PaintCanvas = apply { PaintNative.dispersion(handle, mm2PerSecond.toFloat(), seed()) }

fun waitSeconds(seconds: Double): PaintCanvas

Let seconds pass: water flows, sinks in and evaporates; oil paint ages with it.

    fun waitSeconds(seconds: Double): PaintCanvas = apply { PaintNative.waitSeconds(handle, seconds.toFloat()) }

val water: WaterLedger? get() = PaintNative.waterStats(handle)

The water's books on this canvas, or null before any water touched it.

val paper: PaperInfo? get()

The paper under the water: its make-up, or null before any water touched the canvas.

    val paper: PaperInfo? get() = PaintNative.paper(handle)?.let { v ->
        PaperInfo(Vec4(srgb(v[0]), srgb(v[1]), srgb(v[2]), 1f), v[3].toDouble(), v[4].toDouble(), v[5].toDouble(), v[6].toDouble(),
            v[7].toDouble(), v[8].toDouble(), v[9].toDouble(), v[10].toDouble(), v[11].toDouble())
    }

fun waterMap(map: WaterMap): FloatArray?

One value per pixel of the water's state (row major, pixelSize); null before any water touched the canvas.

    fun waterMap(map: WaterMap): FloatArray? = PaintNative.waterMap(handle, map.code, pixelSize.first * pixelSize.second)

fun waterMapPng(map: WaterMap, max: Double? = null): ByteArray

A water map as a PNG, black at 0 and white at max (the map's own maximum when null), for looking at what the water is doing.

    fun waterMapPng(map: WaterMap, max: Double? = null): ByteArray {
        val v = waterMap(map) ?: FloatArray(pixelSize.first * pixelSize.second)
        val lo = if (map == WaterMap.Relief) v.minOrNull() ?: 0f else 0f
        val hi = max?.toFloat() ?: (v.maxOrNull() ?: 1f)
        val px = IntArray(v.size) { i ->
            val g = (((v[i] - lo) / (hi - lo).coerceAtLeast(1e-9f)).coerceIn(0f, 1f) * 255).toInt()
            (0xff shl 24) or (g shl 16) or (g shl 8) or g
        }
        return PngEncoder.encode(px, pixelSize.first, pixelSize.second)
    }

graphite and chalk

fun draw(line: FLine, pencil: Pencil, pressure: List<Double> = listOf(0.6), tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas

Draw line with pencil: each subpath one line, smoothed through its points and wavering a little (tremor canvas units), its pressure following pressure (one value, a start and end, or a profile through evenly spaced stations). A light line catches only the tops of the ground's tooth and breaks up into its grain; a heavy one fills in. The pencil wears as it draws. Graphite skips wet paint.

@param ruler a ruled line: straight between the points, no tremor.

    fun draw(line: FLine, pencil: Pencil, pressure: List<Double> = listOf(0.6), tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas = apply {
        for ((i, xy) in polylines(line, 2.0).withIndex()) {
            pencil.worn += PaintNative.drawLine(handle, pencil, xy, pressure, toCanvasLength(tremor).toFloat(), !ruler, ruler, seedFor(seed, i))
        }
    }

fun draw(line: FLine, pencil: Pencil, pressure: Double, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas

draw with one pressure for the whole line.

    fun draw(line: FLine, pencil: Pencil, pressure: Double, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas =
        draw(line, pencil, listOf(pressure), tremor, ruler, seed)

fun sketch(line: FLine, pencil: Pencil, pressure: Double = 0.5, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas

Sketch along line: passes light strokes per subpath, each wandering about wander canvas units off the path and broken into pieces, the way a searching line is drawn.

    fun sketch(line: FLine, pencil: Pencil, pressure: Double = 0.5, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas = apply {
        for ((i, xy) in polylines(line, 2.0).withIndex()) {
            pencil.worn += PaintNative.sketchLine(handle, pencil, xy, pressure.toFloat(), passes,
                toCanvasLength(wander).toFloat(), toCanvasLength(tremor).toFloat(), seedFor(seed, i))
        }
    }

fun hatch(region: FLine, pencil: Pencil, angle: Double = 0.8, spacing: Double = 6.0, length: Double = 60.0, pressure: Double = 0.6): PaintCanvas

Hatch region: short parallel strokes at angle (radians), spacing canvas units apart, each at most length long, bowed a little, heavy where they start and lifting off at the end.

    fun hatch(region: FLine, pencil: Pencil, angle: Double = 0.8, spacing: Double = 6.0, length: Double = 60.0, pressure: Double = 0.6): PaintCanvas = apply {
        pencil.worn += PaintNative.hatch(handle, pencil, rings(region), angle.toFloat(),
            toCanvasLength(spacing).toFloat(), toCanvasLength(length).toFloat(), pressure.toFloat(), seed())
    }

fun erase(region: FLine, strength: Double = 0.8, feather: Double = 0.0): PaintCanvas

A kneaded eraser over region: lifts strength (0..1) of the loose drawing, better from the tops of the tooth than the hollows, and never quite all of it. Fixed or painted-over drawing stays.

    fun erase(region: FLine, strength: Double = 0.8, feather: Double = 0.0): PaintCanvas = apply {
        PaintNative.erase(handle, rings(region), toCanvasLength(feather).toFloat(), strength.toFloat())
    }

fun fixDrawing(): PaintCanvas = apply { PaintNative.fixDrawing(handle) }

Spray fixative over the drawing: an eraser no longer lifts it.

stipple

fun stipple

Stipple region with many separate touches of an upright stippling brush size canvas units wide: coverage touches per point on average, in color. Touches into wet paint pick some of it up and mix with it, like every other brush mark.

@param pressure peak pressure range; harder touches make bigger marks. @param cluster 0 spreads the touches evenly, 1 gathers them in clumps. @param drag how far the tip drifts while down, in canvas units. @param dipEvery touches between trips to the palette; each touch lays a little less. @param feather soften the region's edge by this many units, so the touches thin out there.

    fun stipple(
        region: FLine, color: Vec4, size: Double = 3.0, coverage: Double = 1.0,
        hiding: Double = 0.4, stiff: Double = 0.3, pressure: ClosedFloatingPointRange<Double> = 0.4..0.75,
        cluster: Double = 0.15, drag: Double = 0.0, dipEvery: Int = 24, feather: Double = 0.0,
    ): PaintCanvas = apply {
        PaintNative.stipple(handle, rings(region), toCanvasLength(feather).toFloat(), toCanvasLength(size).toFloat(),
            coverage.toFloat(), linear(color.x), linear(color.y), linear(color.z), hiding.toFloat(), stiff.toFloat(),
            pressure.start.toFloat(), pressure.endInclusive.toFloat(), cluster.toFloat(), toCanvasLength(drag).toFloat(),
            dipEvery, seed())
    }

the surface and time

fun prime

Lay a ground over the whole canvas: microns µm of color at hiding, with body stiff (fluid chalk-glue ≈ 0.2, oil lead white ≈ 0.6), roughened by texture before it levels. Dries the canvas first. absorbent (0..1, engine 4) makes it a chalk and glue ground that draws oil out of the paint laid on it — up to 0.6 coats' worth — so that paint dries leaner, stiffer and matte, until paint seals it.

    fun prime(
        color: Vec4, hiding: Double = 0.85, microns: Double = 60.0, stiff: Double = 0.4, texture: Double = 0.3,
        absorbent: Double = 0.0,
    ): PaintCanvas = apply {
        PaintNative.prime(handle, linear(color.x), linear(color.y), linear(color.z), hiding.toFloat(),
            microns.toFloat(), stiff.toFloat(), texture.toFloat(), seed())
        // engine 4 grounds have a finish: an oil ground's semi-matte, or an absorbent chalk ground
        if (paintEngine >= 4 || absorbent > 0.0) PaintNative.groundFinish(handle, absorbent.toFloat())
    }

fun crack(kind: CrackKind = CrackKind.Aged): PaintCanvas

Age the painting: craquelure grown through paint and ground in a stress field, cupped, with dirt in the cracks. CrackKind.Aged is a hierarchy of cracks developing patchily; CrackKind.Even one uniform network. Fitted to the ground's thickness, so it reads best on an oil ground. Dries the canvas first.

    fun crack(kind: CrackKind = CrackKind.Aged): PaintCanvas = apply { PaintNative.crack(handle, kind.code, seed()) }

fun wait(minutes: Double): PaintCanvas

Let minutes of simulated time pass: wet paint stiffens, levels and sets where it lies.

    fun wait(minutes: Double): PaintCanvas = apply { PaintNative.waitMinutes(handle, minutes.toFloat()) }

fun dry(): PaintCanvas = apply { PaintNative.dry(handle) }

Wait until every film is touch-dry. Paint laid afterwards no longer mixes with it.

val stats: PaintStats get() = PaintNative.statsV2(handle)

Where the painting stands: its clock, the hand time spent, and how much paint is still wet.

looking at it

fun render(relief: Double = 0.0, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): IntArray

The picture as sRGB ARGB pixels, row major, pixelSize wide and high.

By default this renders a copy dried to touch-dry, the way the painting will look; the canvas itself stays wet. wet shows it as it looks now instead, wet paint at its laid thickness (no relief). relief lights the impasto from the upper left (0.3–1 is reasonable) and gloss adds a varnish sheen.

With a light, the surface is seen under that light instead of relief's: relief and wet films cast shadows along it and wet paint shines; wet then chooses between the canvas as it is and the dried copy.

    fun render(relief: Double = 0.0, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): IntArray {
        val n = pixelSize.first * pixelSize.second
        return when {
            light != null -> PaintNative.renderLitArgb(handle, n, light.azimuth.toFloat(), light.elevation.toFloat(), light.strength.toFloat(), !wet)
            wet -> PaintNative.renderWetArgb(handle, n)
            else -> PaintNative.renderArgb(handle, n, relief.toFloat(), gloss.toFloat())
        }
    }

fun png(relief: Double = 0.6, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): ByteArray

render encoded as PNG — what display("main", ...) shows as an image.

    fun png(relief: Double = 0.6, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): ByteArray =
        PngEncoder.encode(render(relief, gloss, wet, light), pixelSize.first, pixelSize.second)

fun save(path: String) = PaintNative.savePng(handle, path)

Save a dried copy with the crate's own dithered PNG writer (no relief).

fun copy(): PaintCanvas = PaintCanvas(PaintNative.canvasCopy(handle), aspect, nextSeed, view)

An independent copy of this canvas, wet paint and all: try something, and keep the original.

fun saveState(path: String) = PaintNative.saveState(handle, path)

Write the whole state, wet paint included, so PaintCanvas.load can carry on from here.

override fun close() = cleanable.clean()

coordinates

    private fun toCanvasX(x: Double) = view?.let { (x - it.x) / it.w * 1000.0 } ?: x
    private fun toCanvasY(y: Double) = view?.let { (y - it.y) / it.h * heightUnits } ?: y
    private fun fromCanvasX(x: Double) = view?.let { it.x + x / 1000.0 * it.w } ?: x
    private fun fromCanvasY(y: Double) = view?.let { it.y + y / heightUnits * it.h } ?: y
    private fun toCanvasDx(d: Double) = view?.let { d / it.w * 1000.0 } ?: d
    private fun toCanvasDy(d: Double) = view?.let { d / it.h * heightUnits } ?: d
    private fun toLineUnits(d: Double) = view?.let { d * it.w / 1000.0 } ?: d
    private fun toCanvasLength(d: Double) = view?.let { d / it.w * 1000.0 } ?: d

    private fun toCanvas(xy: List<Double>): FloatArray =
        FloatArray(xy.size) { i -> (if (i % 2 == 0) toCanvasX(xy[i]) else toCanvasY(xy[i])).toFloat() }

Each subpath of line sampled every spacing canvas units, in canvas units.

    private fun polylines(line: FLine, spacing: Double): List<FloatArray> = line.pieces().mapNotNull { piece ->
        val pts = piece.sampleByDistance(toLineUnits(spacing).toFloat())
        if (pts.size < 2) null else toCanvas(pts.flatMap { listOf(it.x.toDouble(), it.y.toDouble()) })
    }

Each subpath of region as a ring, in canvas units.

    private fun rings(region: FLine): List<FloatArray> = polylines(region, 2.0).filter { it.size >= 6 }.also {
        require(it.isNotEmpty()) { "a region needs at least one closed subpath with some area" }
    }

companion object

    companion object {

operator fun invoke

A new canvas.

@param widthPx pixel width; the height follows from aspect. @param aspect width / height. @param ground what the canvas starts as; PaintGround.Bare (the default) is a flat ground of groundColor. @param groundColor the colour of a PaintGround.Bare or PaintGround.Linen canvas, sRGB; white by default. @param seed seeds the brushes' bristles, the marks' irregularity and an oil ground's linen; the same seed and the same calls paint the same picture. @param view the rectangle of FLine coordinates that maps onto the canvas, or null for canvas units. @param sizeMm the physical width of the canvas in millimetres; null is 700 mm for a bare or linen canvas and each oil style's own size. Every mark has a real size — a pencil point is 0.3 mm, a crack opens a few dozen µm — so a small sheet at a given pixel width shows them larger. A pencil drawing at 800 px reads best at about 200 mm. @param engine the paint engine to paint with; null is the newest (PaintCanvas.engine). An engine is a fixed behaviour: engine 2, what Field painted with before engine 5, paints the same calls to the same pixels. Engine 3 adds thinned paint and raw cloth, 4 stiff impasto, clumping hair, turps, oil, absorbent grounds and gloss, 5 the knife's tears; oil paint dries 2.5 times slower from engine 3 on.

        operator fun invoke(
            widthPx: Int = 800,
            aspect: Double = 1.0,
            ground: PaintGround = PaintGround.Bare,
            groundColor: Vec4 = Vec4(1f, 1f, 1f, 1f),
            seed: Long = 1,
            view: Rect? = null,
            sizeMm: Double? = null,
            engine: Int? = null,
        ): PaintCanvas = PaintCanvas(
            PaintNative.canvasNewV2(widthPx, aspect.toFloat(), ground.code, seed,
                linear(groundColor.x), linear(groundColor.y), linear(groundColor.z), sizeMm?.toFloat() ?: 0f, engine ?: 0),
            aspect, seed, view,
        )

fun paper

A sheet of paper for watercolour and ink, sizeMm wide (560 mm, a half imperial sheet, by default). Its relief is the canvas's surface too, so pencil, oil paint and the relief light feel its tooth.

        fun paper(
            widthPx: Int = 800,
            aspect: Double = 1.4,
            paper: Paper = Paper.ColdPress,
            sizeMm: Double? = null,
            seed: Long = 1,
            view: Rect? = null,
            engine: Int? = null,
        ): PaintCanvas = PaintCanvas(
            PaintNative.canvasNewPaper(widthPx, aspect.toFloat(), paper.id, seed, sizeMm?.toFloat() ?: 0f, engine ?: 0),
            aspect, seed, view,
        )

fun load(path: String, seed: Long = 1, view: Rect? = null): PaintCanvas

A canvas written by saveState, wet paint and all.

        fun load(path: String, seed: Long = 1, view: Rect? = null): PaintCanvas {
            val h = PaintNative.loadState(path)
            val (w, ht) = PaintNative.canvasSize(h)
            return PaintCanvas(h, w.toDouble() / ht, seed, view)
        }

val isAvailable: Boolean get() = PaintLibrary.isAvailable

Whether native/field-paint has been built where Field looks for it.

val engine: Int get() = PaintNative.engine()

The paint engine version the wrapper was built against.

data class PaintStats

Clock, hand time and drying state of a PaintCanvas. Shares are of the canvas's area.

data class PaintStats(
    /** Simulated minutes passed ([PaintCanvas.wait], [PaintCanvas.dry]). */
    val clockMinutes: Double,
    /** An estimate of the hand time the marks so far would take a painter, in minutes. */
    val handMinutes: Double,
    /** Workable wet paint: blends, lifts, levels. */
    val open: Double,
    /** Near its gel point: stiff and sticky, barely blends. */
    val setting: Double,
    /** A set film: doesn't move, grabs the brush. */
    val tacky: Double,
    /** Touch-dry paint, or bare ground. */
    val dry: Double,
    /** Wet oil paint on the canvas, mm³. */
    val wetPaintMm3: Double = 0.0,
    /** Solvent (thinner) still in the open films, mm³. */
    val solventMm3: Double = 0.0,
    /** The engine the canvas paints with. */
    val engine: Int = 0,
)

data class PaperInfo

The make-up of the sheet under a canvas's water (PaintCanvas.paper).

data class PaperInfo(
    /** The sheet's own colour, sRGB: what a wash is laid over and what shows through it. */
    val color: Vec4,
    /** Water it holds saturated, µm. */
    val capacityMicrons: Double,
    /** How fast dry paper takes up standing water, µm/s. */
    val absorbMicronsPerSecond: Double,
    /** In-plane capillary diffusivity at saturation, mm²/s. */
    val wickMm2PerSecond: Double,
    /** Along the grain over across it. */
    val anisotropy: Double,
    /** Fibre length, mm. It is xuan's long fibres that feather a drop out along the grain. */
    val fibreMm: Double,
    val formation: Double,
    /**
     * The tooth: how deep the paper's texture runs, µm. A full brush floods across all of it; a
     * nearly dry one touches only the top, which is drybrush.
     */
    val roughnessMicrons: Double,
    /**
     * How wide the texture's features are, mm. It is a real length on a real sheet, so the same
     * strokes on a smaller sheet look coarser.
     */
    val textureMm: Double,
    /** Depth of water needed to advance onto dry paper, µm. */
    val pinMicrons: Double,
)

fun pngRow(canvases: List<PaintCanvas>, gap: Int = 12, background: Int = 0xffffffff.toInt(), wet: Boolean = false, relief: Double = 0.5): ByteArray

canvases rendered side by side, top-aligned, gap pixels apart on background (ARGB), as one PNG: for comparing papers, brushes or pigments in one picture. wet renders each as it is now rather than dried; relief lights a dried render's surface as PaintCanvas.png does.

fun pngRow(canvases: List<PaintCanvas>, gap: Int = 12, background: Int = 0xffffffff.toInt(), wet: Boolean = false, relief: Double = 0.5): ByteArray {
    require(canvases.isNotEmpty()) { "nothing to lay out" }
    val pics = canvases.map { it.render(relief = relief, wet = wet) to it.pixelSize }
    val w = pics.sumOf { it.second.first } + gap * (pics.size - 1)
    val h = pics.maxOf { it.second.second }
    val out = IntArray(w * h) { background }
    var x0 = 0
    for ((px, size) in pics) {
        val (pw, ph) = size
        for (y in 0 until ph) System.arraycopy(px, y * pw, out, y * w + x0, pw)
        x0 += pw + gap
    }
    return PngEncoder.encode(out, w, h)
}

internal fun dist3(a: Vec3, b: Vec3): Double

internal fun dist3(a: Vec3, b: Vec3): Double = Math.sqrt(((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + (a.z - b.z) * (a.z - b.z)).toDouble())

internal fun lerp3(a: Vec3, b: Vec3, u: Double): Vec3

internal fun lerp3(a: Vec3, b: Vec3, u: Double): Vec3 =
    Vec3((a.x + (b.x - a.x) * u).toFloat(), (a.y + (b.y - a.y) * u).toFloat(), (a.z + (b.z - a.z) * u).toFloat())

internal fun polylines3d(line: FLine, spacing: Double): List<List<Vec3>>

Each subpath of line as points in 3D: straight segments every spacing, curves finely.

internal fun polylines3d(line: FLine, spacing: Double): List<List<Vec3>> {
    val out = mutableListOf<MutableList<Vec3>>()
    var start: Vec3? = null
    fun begin(p: Vec3) { out += mutableListOf(p); start = p }
    fun to(p: Vec3) {
        val from = out.last().last()
        val n = kotlin.math.ceil(dist3(p, from) / spacing).toInt().coerceAtLeast(1)
        for (k in 1..n) out.last() += lerp3(from, p, k.toDouble() / n)
    }
    for (node in line.nodes) {
        if (node is FLine.Close) {
            start?.let { if (out.isNotEmpty()) to(it) }
            continue
        }
        val p = node.storage[0]
        when (node) {
            is FLine.MoveTo -> begin(p)
            is FLine.LineTo -> if (out.isEmpty()) begin(p) else to(p)
            is FLine.CubicTo -> {
                if (out.isEmpty()) { begin(p); continue }
                val p0 = out.last().last(); val c1 = node.storage[1]; val c2 = node.storage[2]
                val n = (kotlin.math.ceil((dist3(c1, p0) + dist3(c2, c1) + dist3(p, c2)) / spacing).toInt() * 2).coerceAtLeast(4)
                for (k in 1..n) {
                    val u = k.toDouble() / n; val v = 1 - u
                    val (w0, w1, w2, w3) = listOf(v * v * v, 3 * v * v * u, 3 * v * u * u, u * u * u)
                    out.last() += Vec3((p0.x * w0 + c1.x * w1 + c2.x * w2 + p.x * w3).toFloat(), (p0.y * w0 + c1.y * w1 + c2.y * w2 + p.y * w3).toFloat(),
                        (p0.z * w0 + c1.z * w1 + c2.z * w2 + p.z * w3).toFloat())
                }
            }
        }
    }
    return out
}