Skip to content

FunctionsPlotGallery.kt

src/jvmMain/kotlin/afield/examples/functions/FunctionsPlotGallery.kt · 527 lines

package afield.examples.functions

import afield.ui.visualization.niceTicks
import afield.math.functions.Functions
import afield.math.functions.fitGradient
import afield.ui.visualization.tickLabels
import java.awt.BasicStroke
import java.awt.Color
import java.awt.Font
import java.awt.RenderingHints
import java.awt.geom.Ellipse2D
import java.awt.geom.Line2D
import java.awt.geom.Path2D
import java.awt.geom.Rectangle2D
import java.io.File
import javax.imageio.ImageIO
import java.awt.image.BufferedImage
import java.util.concurrent.TimeUnit
import kotlin.math.abs

FIELD_TEST id: functions.plot-gallery runner: jvm-main entrypoint: afield.examples.functions.FunctionsPlotGallery tags: [smoke, examples, functions, plot, capture, cpu] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "functions plot gallery rendered"

object FunctionsPlotGallery

Headless plot-capture harness for the afield.math.functions.Functions library — the honest vehicle RectSL/StackSL already had and Functions lacked. It renders Functions.Fn results (differentiable-fit before→after, waveshaper transfer curves, waveshaping on a sine, and a composed pipeline) to REAL raster PNGs.

Rendering path: pure-CPU AWT Graphics2D → BufferedImage → ImageIO PNG, run under java.awt.headless=true. This is the same headless path RectSLDataVizProof proves out via run_field_tests.py; there is no browser, GPU, Vulkan or Field window. The pixels are honest: every plotted (x, y) comes from a real Fn.evalSafe(...) evaluation of a genuine Functions graph, and the fit series is the output of a real fitGradient run — only the "draw a line between real samples" step is synthetic (exactly as RectSL CPU-rasterizes its real sampled scene; only the GPU shader is skipped).

As a FIELD_TEST it renders to a temp dir and asserts real correctness (painted fraction, finite samples, that the fit genuinely converges and the fitted curve tracks its target). It writes NO committed assets during the test. Pass an output dir as args[0] (or set FUNCTIONS_PLOT_DIR) to keep the images. The docs page no longer uses them: since 2026-10 its figures are diffstatic_runner box fences drawn with lines(...), so they match the page's other captures.

The harness is parameterized/reusable: add a plot by appending one Plot to PLOTS; each plot builds a PlotSpec of named Series and everything else (axes, gridlines, zero-line, legend, assertions) is shared.

object FunctionsPlotGallery {

enum class Style { LINE, THICK, DASHED, AREA, POINTS }

How a series is drawn.

class Series

One plotted curve: its label, sampled (xs, ys), color and draw style.

    class Series(
        val label: String,
        val xs: DoubleArray,
        val ys: DoubleArray,
        val color: Color,
        val style: Style = Style.LINE
    )

class PlotSpec

A single figure: title, axis labels, series and optional fixed yRange.

    class PlotSpec(
        val title: String,
        val xLabel: String,
        val yLabel: String,
        val series: List<Series>,
        val yRange: Pair<Double, Double>? = null
    )

class Plot(val name: String, val build: Functions.() -> PlotSpec)

A named plot the gallery renders. build runs inside the Functions receiver.

Dark-mode palette shared with the rest of the docs figures (cyan/orange/violet/…).

    private val BG = Color(20, 22, 28)
    private val PANEL = Color(28, 30, 38)
    private val AXIS = Color(120, 126, 140)
    private val GRID = Color(48, 52, 62)
    private val TEXT = Color(206, 210, 220)
    private val CYAN = Color(72, 202, 228)
    private val ORANGE = Color(240, 156, 74)
    private val VIOLET = Color(176, 142, 240)
    private val GREEN = Color(120, 214, 140)
    private val PINK = Color(232, 120, 168)
    private val GREY = Color(150, 156, 168)

curated plots

val PLOTS: List<Plot>

body — 81 lines, from line 100
    val PLOTS: List<Plot> = listOf(
        Plot("fit-before-after") {
            // Differentiable fit: same graph *structure* as the target, wrong numeric leaves.
            // fitGradient tunes the leaves; the fitted curve should visibly land on the target.
            val target = sin(frequency = 2.0, amplitude = 0.8, offset = 0.12)
            val seed = sin(frequency = 2.0, amplitude = 0.30, offset = -0.15)
            val run = fitGradient(
                target = target,
                seed = seed,
                iterations = 400,
                lr = 0.05,
                samples = 160,
                rngSeed = 0L
            )
            check(run.improved) { "fit did not improve: ${run.initialError} -> ${run.finalError}" }
            val dom = Functions.Domain.of(0.0, 1.0)
            PlotSpec(
                title = "Differentiable fit — before -> after (fitGradient)",
                xLabel = "x", yLabel = "f(x)",
                series = listOf(
                    sample("target", target, dom, ORANGE, Style.THICK),
                    sample("seed (before)", seed, dom, GREY, Style.DASHED),
                    sample("fitted (after)", run.fitted, dom, CYAN, Style.LINE)
                ),
                yRange = -1.1 to 1.1
            )
        },
        Plot("waveshaper-transfer") {
            // Transfer curves: push a rising ramp through each shaper so the plotted result
            // *is* the shape it imposes on its input.
            val dom = Functions.Domain.of(0.0, 1.0)
            val sweep = ramp(-1.5, 1.5) over dom
            PlotSpec(
                title = "Waveshaper transfer curves (input sweep -1.5..1.5)",
                xLabel = "x", yLabel = "shaped(x)",
                series = listOf(
                    sample("input ramp", sweep, dom, GREY, Style.DASHED),
                    sample("fold(0.7)", sweep.fold(0.7), dom, CYAN),
                    sample("softClip(2.5)", sweep.softClip(2.5), dom, ORANGE),
                    sample("chebyshev(3)", sweep.chebyshev(3), dom, VIOLET),
                    sample("rectify(FULL)", sweep.rectify(Functions.RectifyMode.FULL), dom, GREEN)
                ),
                yRange = -1.7 to 1.7
            )
        },
        Plot("waveshaper-on-sine") {
            // The same shapers applied to an actual sine — the audible-shape effect.
            val dom = Functions.Domain.of(0.0, 1.0)
            val tone = sin(frequency = 2.0, amplitude = 1.2)
            PlotSpec(
                title = "Waveshaping a sine (amplitude 1.2, 2 cycles)",
                xLabel = "x", yLabel = "f(x)",
                series = listOf(
                    sample("sine", tone, dom, GREY, Style.DASHED),
                    sample("softClip(2.0)", tone.softClip(2.0), dom, CYAN),
                    sample("fold(0.8)", tone.fold(0.8), dom, ORANGE),
                    sample("clip(0.7)", tone.clip(0.7), dom, PINK)
                ),
                yRange = -1.4 to 1.4
            )
        },
        Plot("composed-pipeline") {
            // A composed pipeline, plotted stage by stage so the composition reads visually.
            val dom = Functions.Domain.of(0.0, 1.0)
            val carrier = sin(frequency = 3.0, amplitude = 1.0)
            val envelope = ramp(0.2, 1.0) over dom
            val stage0 = (carrier * envelope).withName("carrier * envelope")
            val stage1 = stage0.fold(0.75).withName("+ fold(0.75)")
            val stage2 = stage1.softClip(2.0).withName("+ softClip(2.0)")
            PlotSpec(
                title = "Composed Fn pipeline (per-stage)",
                xLabel = "x", yLabel = "f(x)",
                series = listOf(
                    sample("carrier x envelope", stage0, dom, GREY, Style.DASHED),
                    sample("+ fold(0.75)", stage1, dom, ORANGE),
                    sample("+ softClip(2.0)", stage2, dom, CYAN)
                ),
                yRange = -1.3 to 1.3
            )
        }
    )

Sample fn on an even grid across dom into a Series. Honest pixels start here.

    private fun sample(
        label: String,
        fn: Functions.Fn,
        dom: Functions.Domain,
        color: Color,
        style: Style = Style.LINE,
        n: Int = 320
    ): Series {
        val xs = DoubleArray(n) { dom.min + dom.length * it / (n - 1).toDouble() }
        val ys = DoubleArray(n) { i -> fn.evalSafe(xs[i], outside = Double.NaN) }
        return Series(label, xs, ys, color, style)
    }

renderer

    private const val W = 900
    private const val H = 540
    private const val ML = 78   // left margin (y labels)
    private const val MR = 24
    private const val MT = 48   // top margin (title)
    private const val MB = 54   // bottom margin (x labels)

fun render(spec: PlotSpec): BufferedImage

body — 174 lines, from line 205
    fun render(spec: PlotSpec): BufferedImage {
        val img = BufferedImage(W, H, BufferedImage.TYPE_INT_RGB)
        val g = img.createGraphics()
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
        g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
        g.color = BG
        g.fillRect(0, 0, W, H)

        val plotX = ML
        val plotY = MT
        val plotW = W - ML - MR
        val plotH = H - MT - MB
        g.color = PANEL
        g.fillRect(plotX, plotY, plotW, plotH)

        // Domain / range.
        var xmin = Double.POSITIVE_INFINITY; var xmax = Double.NEGATIVE_INFINITY
        for (s in spec.series) for (x in s.xs) { if (x < xmin) xmin = x; if (x > xmax) xmax = x }
        if (!xmin.isFinite() || !xmax.isFinite() || xmin == xmax) { xmin = 0.0; xmax = 1.0 }
        val (ymin, ymax) = spec.yRange ?: autoYRange(spec.series)

        fun xToPx(x: Double) = plotX + (x - xmin) / (xmax - xmin) * plotW
        fun yToPx(y: Double) = plotY + plotH - (y - ymin) / (ymax - ymin) * plotH

        // Gridlines + tick labels.
        g.font = Font(Font.SANS_SERIF, Font.PLAIN, 11)
        g.stroke = BasicStroke(1f)
        // Tick selection and labelling come from `charts.kt` (`afield.ui.visualization`) rather than from a private copy
        // here. There were five of those copies in the repo; this one also did not snap its values,
        // so `k * step` drift was only invisible because the old `fmt` rounded to two decimals — a
        // finer axis would have printed 0.30000000000000004. `tickLabels` also gives the whole axis
        // one notation, which the old formatter did not.
        val yTicks = niceTicks(ymin, ymax, 6)
        val yText = tickLabels(yTicks, if (yTicks.size >= 2) yTicks[1] - yTicks[0] else 0.0)
        yTicks.forEachIndexed { i, yt ->
            val py = yToPx(yt).toFloat()
            g.color = GRID
            g.draw(Line2D.Double(plotX.toDouble(), py.toDouble(), (plotX + plotW).toDouble(), py.toDouble()))
            g.color = TEXT
            val s = yText[i]
            g.drawString(s, (plotX - 8 - g.fontMetrics.stringWidth(s)).toFloat(), py + 4)
        }
        val xTicks = niceTicks(xmin, xmax, 6)
        val xText = tickLabels(xTicks, if (xTicks.size >= 2) xTicks[1] - xTicks[0] else 0.0)
        xTicks.forEachIndexed { i, xt ->
            val px = xToPx(xt).toFloat()
            g.color = GRID
            g.draw(Line2D.Double(px.toDouble(), plotY.toDouble(), px.toDouble(), (plotY + plotH).toDouble()))
            g.color = TEXT
            val s = xText[i]
            g.drawString(s, px - g.fontMetrics.stringWidth(s) / 2f, (plotY + plotH + 18).toFloat())
        }

        // Zero line (emphasised) if in range.
        if (0.0 in ymin..ymax) {
            g.color = AXIS
            g.stroke = BasicStroke(1.4f)
            val py = yToPx(0.0)
            g.draw(Line2D.Double(plotX.toDouble(), py, (plotX + plotW).toDouble(), py))
        }

        // Axis frame.
        g.color = AXIS
        g.stroke = BasicStroke(1.2f)
        g.draw(Rectangle2D.Double(plotX.toDouble(), plotY.toDouble(), plotW.toDouble(), plotH.toDouble()))

        // Series.
        val solid = BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
        val thick = BasicStroke(3.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
        val dashed = BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND, 10f, floatArrayOf(7f, 6f), 0f)
        for (s in spec.series) {
            g.color = s.color
            when (s.style) {
                Style.POINTS -> for (i in s.xs.indices) {
                    val y = s.ys[i]; if (!y.isFinite()) continue
                    val px = xToPx(s.xs[i]); val py = yToPx(y)
                    g.fill(Ellipse2D.Double(px - 2.5, py - 2.5, 5.0, 5.0))
                }
                Style.AREA -> {
                    g.stroke = solid
                    val path = Path2D.Double()
                    val base = yToPx(0.0.coerceIn(ymin, ymax))
                    var started = false
                    for (i in s.xs.indices) {
                        val y = s.ys[i]; if (!y.isFinite()) continue
                        val px = xToPx(s.xs[i]); val py = yToPx(y)
                        if (!started) { path.moveTo(px, base); path.lineTo(px, py); started = true }
                        else path.lineTo(px, py)
                    }
                    path.lineTo(xToPx(s.xs.last()), base)
                    g.color = Color(s.color.red, s.color.green, s.color.blue, 70)
                    g.fill(path)
                    g.color = s.color
                    drawPolyline(g, s, ::xToPx, ::yToPx)
                }
                else -> {
                    g.stroke = when (s.style) {
                        Style.THICK -> thick
                        Style.DASHED -> dashed
                        else -> solid
                    }
                    drawPolyline(g, s, ::xToPx, ::yToPx)
                }
            }
        }

        // Legend (top-right, inside plot).
        drawLegend(g, spec.series, plotX + plotW - 8, plotY + 8)

        // Title + axis labels.
        g.color = TEXT
        g.font = Font(Font.SANS_SERIF, Font.BOLD, 15)
        g.drawString(spec.title, ML.toFloat(), 28f)
        g.font = Font(Font.SANS_SERIF, Font.PLAIN, 12)
        g.drawString(spec.xLabel, (plotX + plotW / 2).toFloat(), (H - 12).toFloat())
        // y label, rotated.
        val old = g.transform
        g.rotate(-Math.PI / 2, 18.0, (plotY + plotH / 2).toDouble())
        g.drawString(spec.yLabel, 18f, (plotY + plotH / 2 + 4).toFloat())
        g.transform = old

        g.dispose()
        return img
    }

    private fun drawPolyline(
        g: java.awt.Graphics2D,
        s: Series,
        xToPx: (Double) -> Double,
        yToPx: (Double) -> Double
    ) {
        val path = Path2D.Double()
        var pen = false
        for (i in s.xs.indices) {
            val y = s.ys[i]
            if (!y.isFinite()) { pen = false; continue }
            val px = xToPx(s.xs[i]); val py = yToPx(y)
            if (!pen) { path.moveTo(px, py); pen = true } else path.lineTo(px, py)
        }
        g.draw(path)
    }

    private fun drawLegend(g: java.awt.Graphics2D, series: List<Series>, right: Int, top: Int) {
        g.font = Font(Font.SANS_SERIF, Font.PLAIN, 12)
        val fm = g.fontMetrics
        val rowH = 18
        val swatch = 20
        val pad = 8
        val textW = series.maxOf { fm.stringWidth(it.label) }
        val boxW = swatch + 8 + textW + pad * 2
        val boxH = series.size * rowH + pad * 2
        val x = right - boxW
        g.color = Color(16, 18, 24, 210)
        g.fill(Rectangle2D.Double(x.toDouble(), top.toDouble(), boxW.toDouble(), boxH.toDouble()))
        g.color = GRID
        g.draw(Rectangle2D.Double(x.toDouble(), top.toDouble(), boxW.toDouble(), boxH.toDouble()))
        series.forEachIndexed { i, s ->
            val cy = top + pad + i * rowH + rowH / 2
            g.color = s.color
            g.stroke = BasicStroke(3f)
            g.draw(Line2D.Double((x + pad).toDouble(), cy.toDouble(), (x + pad + swatch).toDouble(), cy.toDouble()))
            g.color = TEXT
            g.drawString(s.label, (x + pad + swatch + 8).toFloat(), (cy + 4).toFloat())
        }
    }

    private fun autoYRange(series: List<Series>): Pair<Double, Double> {
        var lo = Double.POSITIVE_INFINITY; var hi = Double.NEGATIVE_INFINITY
        for (s in series) for (y in s.ys) if (y.isFinite()) { if (y < lo) lo = y; if (y > hi) hi = y }
        if (!lo.isFinite() || !hi.isFinite()) { lo = -1.0; hi = 1.0 }
        if (lo == hi) { lo -= 1.0; hi += 1.0 }
        val pad = (hi - lo) * 0.08
        return (lo - pad) to (hi + pad)
    }

Fraction of pixels differing from the background — a cheap "did anything render" guard.

    private fun paintedFraction(img: BufferedImage): Double {
        val bg = BG.rgb and 0xFFFFFF
        val panel = PANEL.rgb and 0xFFFFFF
        var painted = 0; var seen = 0
        var y = 0
        while (y < img.height) {
            var x = 0
            while (x < img.width) {
                val p = img.getRGB(x, y) and 0xFFFFFF
                if (p != bg && p != panel) painted++
                seen++
                x += 2
            }
            y += 2
        }
        return painted.toDouble() / seen.coerceAtLeast(1)
    }

fun main(args: Array<String>)

body — 54 lines, from line 399
    @JvmStatic
    fun main(args: Array<String>) {
        System.setProperty("java.awt.headless", "true")
        val outDir = File(
            args.getOrNull(0) ?: System.getenv("FUNCTIONS_PLOT_DIR")
                ?: File(System.getProperty("java.io.tmpdir"), "functions-plot-gallery").path
        )
        outDir.mkdirs()

        PLOTS.forEach { plot ->
            val spec = plot.build(Functions)
            // Every series must carry finite samples — no all-NaN degeneration.
            for (s in spec.series) {
                check(s.ys.any { it.isFinite() }) { "${plot.name}: series '${s.label}' has no finite samples" }
            }
            val img = render(spec)
            val file = File(outDir, "functions-${plot.name}.png")
            ImageIO.write(img, "png", file)
            val painted = paintedFraction(img)
            check(painted > 0.02) { "${plot.name}: rasterized near-empty (painted=$painted)" }
            println("wrote ${file.absolutePath} (${img.width}x${img.height}, painted=${"%.3f".format(painted)})")
        }

        // Deeper correctness on the fit plot: the fitted curve must genuinely track the target.
        with(Functions) {
            val target = sin(frequency = 2.0, amplitude = 0.8, offset = 0.12)
            val seed = sin(frequency = 2.0, amplitude = 0.30, offset = -0.15)
            val run = fitGradient(target = target, seed = seed, iterations = 400, lr = 0.05, samples = 160, rngSeed = 0L)
            check(run.finalError < run.initialError) {
                "fit did not converge: ${run.initialError} -> ${run.finalError}"
            }
            check(run.finalError < run.initialError * 0.25) {
                "fit barely moved: ${run.initialError} -> ${run.finalError}"
            }
            val dom = Functions.Domain.of(0.0, 1.0)
            var maxDev = 0.0
            for (i in 0..200) {
                val x = dom.min + dom.length * i / 200.0
                maxDev = maxOf(maxDev, abs(run.fitted.evalSafe(x) - target.evalSafe(x)))
            }
            check(maxDev < 0.15) { "fitted curve does not track target: maxDev=$maxDev" }
            println("fit convergence: initial=${"%.4f".format(run.initialError)} " +
                "final=${"%.4f".format(run.finalError)} maxDev=${"%.4f".format(maxDev)}")
        }

        // Optional: an animated fit-convergence mp4 (best-effort — a missing ffmpeg is not a
        // failure, it just skips the video). Each frame is the fitted curve after k real Adam
        // steps; Adam is deterministic in the seed + step count, so re-running to k iterations
        // reproduces the trajectory exactly (honest per-frame state, no faked interpolation).
        runCatching { renderFitConvergenceMp4(outDir) }
            .onFailure { println("fit-convergence mp4 skipped: ${it.message}") }

        println("functions plot gallery rendered to ${outDir.absolutePath} (${PLOTS.size} plots)")
    }

fun renderFitConvergenceMp4(outDir: File): File?

Render functions-fit-convergence.mp4: the fitted curve landing on the target over the descent. Returns the mp4, or null if ffmpeg is unavailable.

body — 71 lines, from line 456
    fun renderFitConvergenceMp4(outDir: File): File? = with(Functions) {
        val ffmpeg = resolveFfmpeg() ?: run {
            println("fit-convergence mp4 skipped: ffmpeg not found")
            return null
        }
        val target = sin(frequency = 2.0, amplitude = 0.8, offset = 0.12)
        val seed = sin(frequency = 2.0, amplitude = 0.30, offset = -0.15)
        val dom = Functions.Domain.of(0.0, 1.0)
        val maxIters = 320
        val stepPerFrame = 8
        val framesDir = File(outDir, "fit-frames").apply { deleteRecursively(); mkdirs() }
        var frame = 0
        var k = 0
        while (k <= maxIters) {
            val run = fitGradient(target = target, seed = seed, iterations = k, lr = 0.05, samples = 160, rngSeed = 0L)
            val spec = PlotSpec(
                title = "fitGradient convergence — iteration $k  (error ${"%.4f".format(run.finalError)})",
                xLabel = "x", yLabel = "f(x)",
                series = listOf(
                    sample("target", target, dom, ORANGE, Style.THICK),
                    sample("fitted @ $k", run.fitted, dom, CYAN, Style.LINE)
                ),
                yRange = -1.1 to 1.1
            )
            ImageIO.write(render(spec), "png", File(framesDir, "frame_%05d.png".format(frame)))
            frame++
            k += stepPerFrame
        }
        // Hold the final frame a beat.
        val last = File(framesDir, "frame_%05d.png".format(frame - 1))
        repeat(12) { ImageIO.write(ImageIO.read(last), "png", File(framesDir, "frame_%05d.png".format(frame++))) }

        val mp4 = File(outDir, "functions-fit-convergence.mp4")
        encodeMp4(ffmpeg, framesDir, "frame_%05d.png", 20, mp4)
        check(mp4.length() > 0L) { "ffmpeg wrote an empty mp4" }
        println("wrote ${mp4.absolutePath} ($frame frames)")
        mp4
    }

    private fun resolveFfmpeg(): String? {
        val candidates = listOfNotNull(
            System.getenv("FFMPEG"),
            "/opt/homebrew/bin/ffmpeg",
            "/usr/local/bin/ffmpeg",
            "/usr/bin/ffmpeg"
        )
        return candidates.firstOrNull { File(it).canExecute() }
    }

    private fun encodeMp4(ffmpeg: String, framesDir: File, pattern: String, fps: Int, output: File) {
        output.delete()
        val process = ProcessBuilder(
            ffmpeg, "-y",
            "-framerate", fps.toString(),
            "-i", pattern,
            "-c:v", "libx264",
            "-pix_fmt", "yuv420p",
            "-crf", "20",
            "-movflags", "+faststart",
            output.absolutePath
        ).directory(framesDir).redirectErrorStream(true).start()
        val log = process.inputStream.bufferedReader().readText()
        if (!process.waitFor(120, TimeUnit.SECONDS)) {
            process.destroyForcibly()
            error("ffmpeg timed out encoding ${output.name}")
        }
        require(process.exitValue() == 0) {
            "ffmpeg failed (${process.exitValue()}) for ${output.name}:\n${log.takeLast(600)}"
        }
    }
}