Ollin

Watercolor

wet paint on rough paper: a scripted wash, a wet-in-wet charge, a backrun bloom, and a glaze. You can take over from the script with the mouse (watercolor(pigments:))

Run
swift run --package-path Examples Example-Simulation-Watercolor
Source
Examples/Simulation/Watercolor/Sketch.swift
import Ollin

/// Wet paint on rough paper: a `Sim.watercolor` field painted like a real sheet.
/// A short scripted painting runs on its own: a broad ultramarine wash (watch
/// its edge darken as it sits), a rose charged into it wet-in-wet, then the
/// wash is blotted and a held drop of clean water blooms a backrun through the
/// damp paint, and finally the sheet dries and a yellow band glazes across
/// everything, mixing optically where it crosses. Take over any time: drag to
/// paint, keys 1-3 pick a pigment, W is clean water (hold it in a damp wash to
/// bloom), B blots the standing water, D dries the sheet for the next glaze.
@main
final class WatercolorPainting: Sketch {
    private var paint: WatercolorField!
    private var pigment = 0        // current brush: 0-2 = palette, 3 = clean water
    private var tookOver = false   // first click ends the scripted painting

    @Param(0 ... 0.08, icon: "drop") var edgeDarkening = 0.04
    @Param(icon: "cloud.rain") var backruns = true
    @Param(0 ... 0.8, icon: "paintbrush") var dryBrush = 0.0

    override func setup() {
        paint = watercolor(.watercolor(pigments: [.frenchUltramarine, .quinacridoneRose, .cadmiumYellow],
                                       paperSeed: Double(variation)))
    }

    override func draw() {
        paint.sim = .watercolor(pigments: [.frenchUltramarine, .quinacridoneRose, .cadmiumYellow],
                                edgeDarkening: edgeDarkening, backruns: backruns,
                                dryBrush: dryBrush, paperSeed: Double(variation))

        withField(paint) {
            noStroke()   // a stroked mark would ring every stamp with pigment-free water
            if !tookOver { autoPaint() }
            if mouseIsPressed {
                let color = pigment < 3 ? paint.ink(pigment, load: 0.5, water: 0.9)
                                        : paint.water(0.9)
                fill(color)
                drawCircle(mouseX, mouseY, 26)
            }
        }
        drawImage(paint.image, 0, 0)
    }

    override func mousePressed() { tookOver = true }

    override func keyPressed() {
        switch key {
        case "1": pigment = 0
        case "2": pigment = 1
        case "3": pigment = 2
        case "w": pigment = 3
        case "b": paint.blot()
        case "d": paint.dry()
        default: break
        }
    }

    /// The scripted painting: wash, wet-in-wet charge, blot + a held water drop
    /// (the backrun bloom), dry, glaze. Brush touches land on their frames and
    /// the simulation does the rest; the water drop is *held* over a stretch of
    /// frames because that is what blooms (a single tap only nudges).
    private func autoPaint() {
        switch frameCount {
        case 1:
            brushLine(from: Vector2(210, 390), to: Vector2(870, 430), radius: 82,
                      color: paint.ink(0, load: 0.5))
        case 120:
            brushLine(from: Vector2(360, 410), to: Vector2(700, 440), radius: 38,
                      color: paint.ink(1, load: 0.6, water: 0.6))
        case 300:
            paint.blot()
        case 310 ... 360:
            fill(paint.water(0.8))
            drawCircle(520, 400, 40)
        case 560:
            paint.dry()
        case 580:
            brushLine(from: Vector2(470, 140), to: Vector2(520, 940), radius: 58,
                      color: paint.ink(2, load: 0.35))
        default: break
        }
    }

    /// A single brush touch: overlapping stamps along a gently wobbling path.
    private func brushLine(from a: Vector2, to b: Vector2, radius: Double, color: Color) {
        fill(color)
        let steps = max(1, Int(a.distance(to: b) / (radius * 0.35)))
        for i in 0 ... steps {
            let t = Double(i) / Double(steps)
            let p = a.lerp(to: b, t) + Vector2(0, sin(t * .tau * 1.4) * radius * 0.2)
            drawCircle(p.x, p.y, radius)
        }
    }
}

↑↓ move ↩ open