Skip to content

RectSLSoftRaster.kt

src/jvmMain/kotlin/afield/lang/rectsl/RectSLSoftRaster.kt · 717 lines

package afield.lang.rectsl

import afield.math.autodiff.AD
import afield.math.autodiff.ADTape
import afield.math.autodiff.FnAutoDiff
import afield.math.functions.Functions
import afield.math.autodiff.abs
import afield.math.autodiff.clamp
import afield.math.autodiff.cos
import afield.math.functions.cos
import afield.math.functions.extractParameters
import afield.math.autodiff.hypot
import afield.math.autodiff.max
import afield.math.autodiff.min
import afield.math.autodiff.sigmoid
import afield.math.autodiff.sin
import afield.math.functions.sin

The third lowering: a RectSL scene as a differentiable raster, so a picture can be fitted.

Why an image fit needs a rasterizer

Track.fitParameters turns the arrow round for one track: mark a number free-param, say what the track should read at three instants, and the exact gradient of the lowered Fn walks the number to the answer. What it cannot do is take a target image, because the derivative an image fit needs is d(pixel)/d(parameter) and a per-track scalar lowering has no pixels in it. That was recognised on 2026-08-06 and the image fit was abandoned rather than half-landed. This file is the retry, on the terms the abandonment set: a soft rasterizer whose coverage bias is measured. The exact reference that bias is measured against is exactCoverage; the numbers, and where they say this is and is not usable, are in dev_docs/RECTSL_SOFT_RASTER.md.

The one design decision worth arguing about

The obvious plan is "lower the scene to an Fn-valued signed distance field over (x, y, t)". Functions.Fn cannot be that: it is a (Double) -> Double, one input and one output, and every existing consumer of a lowered track — freeParameterSlots, Track.substituteParameters, Track.fitParameters — reads its θ-vector as the track's own leaves. Smuggling x and y in as two more θ leaves would make all of them wrong about what they are solving for, and nothing would report it.

So the differentiable layer here is not Fn, it is AD on an ADTape — which is what Fn's own gradient is built out of anyway. FnAutoDiff.interpret already re-reads an Fn graph in AD arithmetic at an AD-tracked x, so:

  • every track goes through the existing lowering, Track.toFn, unchanged;
  • every track is then interpreted on the tape at the frame's time;
  • the geometry — the ancestor-group composition, the rotate-into-local-frame, the exact box field, the min union — is written in AD directly, transcribed from the GLSL in RectSLGlsl.PRELUDE and the composition in Transform2d.then;
  • the coverage kernel and the image loss go on the same tape.

One backward sweep then yields d(loss)/dθ for every parameter in the scene at once, with nothing finite-differenced anywhere in the chain. That is strictly more than an Fn could have given.

The scene-level θ ledger

Track.fitParameters masks θ within one Fn. A scene has eight tracks per rect and 183 rects in the largest gallery scene, so it has hundreds of independent θ-vectors, and "index i" means nothing until they are concatenated. SceneAdField does that concatenation once: every channel gets a contiguous block, SceneAdField.theta is the whole scene's leaves in block order, and SceneAdField.freeParameterSlots re-maps each free-param name to its global slots — unioned across channels, because one name legitimately reaches several of them. The gradient-summed, step-applied-to-every-slot discipline of Track.fitParameters carries over unchanged; only the index space is new.

Why a frame is a separate object

ADTape records five parallel arrays per operation and never shrinks. A 128² raster over a 30-rect scene, naively taped, is ~14M nodes — half a gigabyte. But a shape's transform depends only on (θ, t) and not on the pixel, so SceneAdField.frameAt evaluates all of it once and SceneAdFrame hands back only what varies per pixel: centre, rotation, half-extent, alpha. The per-pixel cost drops to about twenty tape nodes per rect, and because an image loss is a sum over pixels its gradient is additive — so softRasterLossAndGradient walks the image in row chunks, one tape per chunk, and accumulates. Memory becomes a choice rather than a limit.

Fidelity between the two interpreters

A second interpreter of the same graph is exactly the situation where a slightly different function gets differentiated confidently. autodiff_fn.kt has a name for the guard against that — FnAutoDiff.worstValueDisagreement — and toAdField runs it on every channel it lowers, recording the worst in SceneAdField.worstChannelDisagreement. A channel whose AD reading parts company with its own Fn.eval is refused, not averaged in.

const val RECTSL_INVISIBLE_DISTANCE: Double = 1e9

The sentinel an invisible rect's distance returns, matching the emitted GLSL's 1e9 exactly.

const val RECTSL_CHANNEL_TOLERANCE: Double = 1e-9

How far apart the AD reading of a channel and its own Fn.eval may be before it is refused.

Instants a channel's AD reading is checked against Fn.eval at, as fractions of the window.

private val CHANNEL_CHECK_FRACTIONS: DoubleArray =
    doubleArrayOf(0.0, 0.13, 0.31, 0.5, 0.67, 0.89, 1.0)

internal class LoweredChannel

One channel of one transform level, lowered, with its block of the scene's θ.

indices and size are computed once at lowering time because both are graph walks and both would otherwise happen per pixel.

internal class LoweredChannel(
    val fn: Functions.Fn,
    val offset: Int,
    val size: Int,
    val indices: Map<List<Int>, Int>
) {

fun at(t: AD, theta: List<AD>): AD

This channel's value at t, tracked, reading its own slice of the scene θ.

    fun at(t: AD, theta: List<AD>): AD =
        FnAutoDiff.interpret(fn, t, theta.subList(offset, offset + size), indices, emptyList())
}

internal class LoweredLevel

One level of a rect's transform stack: a group on the way down, or the rect itself.

internal class LoweredLevel(
    val x: LoweredChannel,
    val y: LoweredChannel,
    val rot: LoweredChannel,
    val scaleX: LoweredChannel,
    val scaleY: LoweredChannel,
    val alpha: LoweredChannel
)

class AdShape internal constructor

One rect, with its ancestor groups flattened into an ordered transform stack.

Outermost group first and the rect last, which is the order Transform2d.then composes in.

class AdShape internal constructor(
    /** The [RectNode.id] this came from, so a disagreement can be attributed to a rect. */
    val id: String,
    internal val levels: List<LoweredLevel>,
    internal val w: LoweredChannel,
    internal val h: LoweredChannel
)

class AdShapeFrame internal constructor

A rect's world transform at one instant, on one tape — the part that does not vary per pixel.

halfX and halfY already carry the accumulated scale and alpha is already clamped to 0..1, both matching the SampledRect SceneProgram.sample would have produced.

class AdShapeFrame internal constructor(
    /** The rect this is a frame of. */
    val id: String,
    /** World-space centre x, tracked. */
    val centreX: AD,
    /** World-space centre y, tracked. */
    val centreY: AD,
    /** Accumulated rotation in radians, tracked. */
    val rotation: AD,
    /** Half-width including accumulated scale, tracked and non-negative. */
    val halfX: AD,
    /** Half-height including accumulated scale, tracked and non-negative. */
    val halfY: AD,
    /** Accumulated alpha, clamped to `0..1`, tracked. */
    val alpha: AD
) {

val visible: Boolean get() = alpha.value > 0.0

True when this rect contributes a silhouette at all — the emitted shader's own test.

fun distanceAt(x: Double, y: Double): AD

The exact signed distance from (x, y) to this rect, tracked: positive outside, negative inside, transcribed from rectslRect / rectslBox in RectSLGlsl.PRELUDE.

    fun distanceAt(x: Double, y: Double): AD {
        val qx = -centreX + x
        val qy = -centreY + y
        val c = cos(rotation)
        val s = sin(rotation)
        val localX = c * qx + s * qy
        val localY = -(s * qx) + c * qy
        val dx = abs(localX) - halfX
        val dy = abs(localY) - halfY
        return hypot(max(dx, 0.0), max(dy, 0.0)) + min(max(dx, dy), 0.0)
    }
}

class SceneAdFrame internal constructor

Every rect of a scene, transformed at one instant, on one tape.

Hold one of these for as long as a tape is worth reusing — a row chunk of a raster, or a batch of probe points — and no longer: the tape it belongs to grows monotonically.

class SceneAdFrame internal constructor(
    /** The tape every value in this frame lives on. */
    val tape: ADTape,
    /** The scene's whole θ-vector as tape variables, in [SceneAdField.theta] order. */
    val theta: List<AD>,
    /** The instant this frame was transformed at. */
    val time: Double,
    /** Every rect, in scene order, visible or not. */
    val shapes: List<AdShapeFrame>
) {

val visible: List<AdShapeFrame> by lazy(LazyThreadSafetyMode.NONE)

The rects not gated invisible at this instant.

    val visible: List<AdShapeFrame> by lazy(LazyThreadSafetyMode.NONE) { shapes.filter { it.visible } }

fun distanceAt(x: Double, y: Double): AD

The scene's signed distance at (x, y): the min over visible rects, or RECTSL_INVISIBLE_DISTANCE when nothing is visible.

min is the union's distance outside the union and an under-estimate of it inside, where the nearest individual boundary can be much closer than the union's own. That is not a defect of this transcription — the emitted GLSL does the same thing — but it is one of the three bias sources the docs page measures, and the one that bites hardest.

    fun distanceAt(x: Double, y: Double): AD {
        val vis = visible
        if (vis.isEmpty()) return tape.constant(RECTSL_INVISIBLE_DISTANCE)
        var d = vis[0].distanceAt(x, y)
        for (i in 1 until vis.size) d = min(d, vis[i].distanceAt(x, y))
        return d
    }
}

class SceneAdField internal constructor

A whole scene, lowered: its shapes, its concatenated θ, and what it had to leave out.

The lowering is partial. A few percent of the gallery's tracks still refuse to lower (atan2, and the pattern queries, which are an interpreter rather than a formula), and a scene containing one is still worth differentiating everywhere else. What is not acceptable is doing that silently, so skipped carries the rect ids and the reasons out, exactly as TrackFitResult.stuckParameters does for a partly stuck fit.

class SceneAdField internal constructor(
    /** The scene this was lowered from. */
    val program: SceneProgram,
    /** The rects that lowered, in scene order. */
    val shapes: List<AdShape>,
    /** Every numeric leaf of every lowered channel, in channel-block order. */
    val theta: DoubleArray,
    /** Each `free-param` name's **global** θ slots, unioned across the channels that use it. */
    val freeParameterSlots: Map<String, List<Int>>,
    /** Rect id to refusal reason, for the rects that could not be lowered. */
    val skipped: Map<String, String>,
    /** The worst `AD`-versus-`Fn.eval` disagreement across every channel that was accepted. */
    val worstChannelDisagreement: Double,
    /** The time window every channel was lowered over. */
    val window: Functions.Domain
) {

val rectCount: Int get() = shapes.size

How many rects lowered.

val freeParameterNames: List<String> get() = freeParameterSlots.keys.toList()

The scene's free-parameter names in first-seen order.

fun frameAt(time: Double, at: DoubleArray = theta): SceneAdFrame

Transforms every shape at time on a fresh tape, with at as the θ-vector.

@param at defaults to this field's own θ. Must be theta-length; the check is code rather than a comment because a short vector would silently leave later channels at their seed values, which reads as a converged fit.

body — 41 lines, from line 253
    fun frameAt(time: Double, at: DoubleArray = theta): SceneAdFrame {
        require(at.size == theta.size) {
            "theta has ${at.size} entries but this scene has ${theta.size} numeric leaves"
        }
        val tape = ADTape()
        val vars = tape.variables(at)
        val t = tape.constant(time)
        val frames = shapes.map { shape ->
            var cx = tape.constant(0.0)
            var cy = tape.constant(0.0)
            var rot = tape.constant(0.0)
            var sx = tape.constant(1.0)
            var sy = tape.constant(1.0)
            var alpha = tape.constant(1.0)
            // Transform2d.then, verbatim: a child's translation is scaled by the parent's scale,
            // rotated by the parent's rotation, and added; rotations, scales and alphas accumulate.
            // Written out rather than folded so the correspondence stays readable.
            for (level in shape.levels) {
                val ox = level.x.at(t, vars) * sx
                val oy = level.y.at(t, vars) * sy
                val c = cos(rot)
                val s = sin(rot)
                cx += c * ox - s * oy
                cy += s * ox + c * oy
                rot += level.rot.at(t, vars)
                sx *= level.scaleX.at(t, vars)
                sy *= level.scaleY.at(t, vars)
                alpha *= level.alpha.at(t, vars)
            }
            AdShapeFrame(
                id = shape.id,
                centreX = cx,
                centreY = cy,
                rotation = rot,
                halfX = abs(shape.w.at(t, vars) * sx * 0.5),
                halfY = abs(shape.h.at(t, vars) * sy * 0.5),
                alpha = alpha.clamp(0.0, 1.0)
            )
        }
        return SceneAdFrame(tape, vars, time, frames)
    }

fun signedDistanceAt(x: Double, y: Double, time: Double, at: DoubleArray = theta): Double

This scene's signed distance at one point and instant, untracked — the shape of call the differential check against SceneProgram.sample makes.

Costs a whole tape, so it is the wrong call inside a loop; frameAt then SceneAdFrame.distanceAt is the right one. Kept because a one-point answer is what a reader, a test and a REPL all reach for first.

    fun signedDistanceAt(x: Double, y: Double, time: Double, at: DoubleArray = theta): Double =
        frameAt(time, at).distanceAt(x, y).value

fun freeParameters(at: DoubleArray = theta): Map<String, Double>

The value of each named free parameter in at, read from its first global slot.

    fun freeParameters(at: DoubleArray = theta): Map<String, Double> =
        freeParameterSlots.mapValues { (_, slots) -> at[slots.first()] }

fun withFreeParameters(values: Map<String, Double>, at: DoubleArray = theta): DoubleArray

A copy of at with each name in values written to every slot it occupies.

Every slot, for the reason freeParameterSlots gives: a lowering may reach one sub-Fn more than once, and moving some occurrences of a variable and not others produces a graph that corresponds to no scene.

    fun withFreeParameters(values: Map<String, Double>, at: DoubleArray = theta): DoubleArray {
        val out = at.copyOf()
        for ((name, value) in values) {
            val slots = freeParameterSlots[name]
                ?: throw RectLangException(
                    "no free parameter named '$name' in this scene; it has " +
                        freeParameterNames.ifEmpty { listOf("none") }
                )
            for (slot in slots) out[slot] = value
        }
        return out
    }
}

enum class CoverageKernel

The three soft-coverage kernels, normalised to be comparable: each reads 0.5 at the surface and has slope 1/beta there.

The normalisation is the whole reason a bias comparison between them means anything. Without it a sigmoid with a free temperature can be made to look like any of the others, and "kernel A is more biased than kernel B" would be a statement about two different band widths.

They differ in exactly one property besides bias, and it is the property that matters to a fit: reach. LINEAR and SMOOTHSTEP are identically zero beyond beta/2 and so hand a fit no gradient at all from a pixel further than that from an edge; SIGMOID never reaches zero. The measured trade-off is in dev_docs/RECTSL_SOFT_RASTER.md.

enum class CoverageKernel {

The clamped ramp clamp(0.5 - d/beta, 0, 1).

At beta = one pixel this is not an approximation at all for an axis-aligned edge: the exact area of a unit square on one side of an axis-aligned line is clamp(0.5 - d). RectSLSoftRasterTest asserts that the measured bias there is at machine precision, which doubles as the premise check for the exact-coverage reference.

    LINEAR,

The ramp put through 3u² - 2u³, so coverage is C¹ at the band edges instead of kinked.

    SMOOTHSTEP,

sigmoid(-4 d / beta) — the factor 4 is what makes its slope at the surface 1/beta, matching the other two.

    SIGMOID;

fun coverage(d: AD, beta: Double): AD

This kernel's coverage for a tracked signed distance d at band width beta.

    fun coverage(d: AD, beta: Double): AD = when (this) {
        LINEAR -> (-(d / beta) + 0.5).clamp(0.0, 1.0)
        SMOOTHSTEP -> {
            val u = (-(d / beta) + 0.5).clamp(0.0, 1.0)
            u * u * (-(u * 2.0) + 3.0)
        }
        SIGMOID -> sigmoid(-(d * (4.0 / beta)))
    }

fun coverage(d: Double, beta: Double): Double

This kernel's coverage for a plain signed distance — the same formula, untracked.

    fun coverage(d: Double, beta: Double): Double = when (this) {
        LINEAR -> (0.5 - d / beta).coerceIn(0.0, 1.0)
        SMOOTHSTEP -> {
            val u = (0.5 - d / beta).coerceIn(0.0, 1.0)
            u * u * (3.0 - 2.0 * u)
        }
        SIGMOID -> 1.0 / (1.0 + kotlin.math.exp(4.0 * d / beta))
    }
}

enum class CoverageComposite

How per-rect coverage becomes one number per pixel.

Two candidates, both defensible, with materially different bias — which is why both are here and both are measured rather than one being chosen in advance.

enum class CoverageComposite {

Coverage of the union distance: kernel(min_i d_i).

What the emitted GLSL implies, and what any SDF consumer would write. Its weakness is structural: min of signed distances is not the union's interior distance, so at a seam between two touching rects the field reads about zero where the union is solidly interior, and coverage collapses towards 0.5 in a region that is fully covered.

    UNION_SDF,

Alpha-weighted over-composite: 1 - prod_i (1 - a_i · kernel(d_i)).

Closer to what RectSL's raster path actually does (painter's algorithm with per-rect alpha), and it degrades more gracefully at a seam because both rects contribute. It is still not exact there — two half-covered pixels composite to 0.75, not 1 — and the measured gap between the two composites is in the docs page.

    OVER
}

data class SoftRasterSpec

A rasterization window: a pixel grid over a WallSpec, plus the coverage kernel and the band width it is allowed.

The grid is wall-centred, matching RectSLFitFigure and SceneProgram.sample's own coordinates: x runs left to right over -width/2 .. width/2 and y runs up, so image row 0 is the top of the wall. WallSpec.origin is not consulted: it is a layout hint for the GPU path, and honouring it here would put this raster in a different space from the sampler this is differentially checked against.

beta is in wall units, not pixels, because that is the space the SDF speaks. The conversion a reader wants is betaInPixels, and every finding in dev_docs/RECTSL_SOFT_RASTER.md is stated in those terms, because one pixel turns out to be the interesting value.

data class SoftRasterSpec(
    /** The wall the grid covers. */
    val wall: WallSpec,
    /** Pixel columns. */
    val width: Int,
    /** Pixel rows. */
    val height: Int,
    /** Coverage band width, in wall units. */
    val beta: Double,
    /** Which normalised kernel turns distance into coverage. */
    val kernel: CoverageKernel = CoverageKernel.LINEAR,
    /** How per-rect coverage becomes one number. */
    val composite: CoverageComposite = CoverageComposite.UNION_SDF
) {

init

    init {
        require(width > 0 && height > 0) { "a raster needs a positive size, got ${width}x$height" }
        require(beta > 0.0) { "the coverage band must be positive, got $beta" }
        require(wall.width > 0.0 && wall.height > 0.0) { "a raster needs a positive wall" }
    }

val pixelWidth: Double get() = wall.width / width

Wall units across one pixel.

val pixelHeight: Double get() = wall.height / height

Wall units down one pixel.

val pixelCount: Int get() = width * height

How many pixels there are.

val betaInPixels: Double get() = beta / pixelWidth

beta expressed in pixel widths — the unit the bias measurement is reported in.

fun centreX(i: Int): Double = -0.5 * wall.width + (i + 0.5) * pixelWidth

The wall x of pixel column i's centre.

fun centreY(j: Int): Double = 0.5 * wall.height - (j + 0.5) * pixelHeight

The wall y of pixel row j's centre. y grows up in wall space and down in the image.

fun pixelBox(i: Int, j: Int): DoubleArray

The wall-space box of pixel (i, j) as (x0, y0, x1, y1), with y0 below y1.

    fun pixelBox(i: Int, j: Int): DoubleArray {
        val x0 = -0.5 * wall.width + i * pixelWidth
        val y1 = 0.5 * wall.height - j * pixelHeight
        return doubleArrayOf(x0, y1 - pixelHeight, x0 + pixelWidth, y1)
    }
}

fun coverageAt(frame: SceneAdFrame, spec: SoftRasterSpec, i: Int, j: Int): AD

The soft coverage of one pixel of spec, tracked, from an already-transformed frame.

Alpha multiplies coverage under CoverageComposite.OVER and only gates it under CoverageComposite.UNION_SDF, because a union distance has nowhere to put a per-shape weight — a real asymmetry between the two composites, and not an oversight.

fun coverageAt(frame: SceneAdFrame, spec: SoftRasterSpec, i: Int, j: Int): AD {
    val x = spec.centreX(i)
    val y = spec.centreY(j)
    return when (spec.composite) {
        CoverageComposite.UNION_SDF -> spec.kernel.coverage(frame.distanceAt(x, y), spec.beta)
        CoverageComposite.OVER -> {
            val vis = frame.visible
            if (vis.isEmpty()) {
                frame.tape.constant(0.0)
            } else {
                var transmit = frame.tape.constant(1.0)
                for (shape in vis) {
                    val c = spec.kernel.coverage(shape.distanceAt(x, y), spec.beta) * shape.alpha
                    transmit *= (-c + 1.0)
                }
                -transmit + 1.0
            }
        }
    }
}

fun softRaster

The whole soft raster as coverage in 0..1, row-major, untracked.

Walked in row chunks with one tape each, for the reason in the file KDoc: a tape never shrinks, and a raster taped in one go is measured in hundreds of megabytes.

fun softRaster(
    field: SceneAdField,
    spec: SoftRasterSpec,
    time: Double,
    at: DoubleArray = field.theta,
    rowsPerChunk: Int = 8
): DoubleArray {
    require(rowsPerChunk > 0) { "rowsPerChunk must be positive" }
    val out = DoubleArray(spec.pixelCount)
    var j0 = 0
    while (j0 < spec.height) {
        val frame = field.frameAt(time, at)
        val j1 = minOf(j0 + rowsPerChunk, spec.height)
        for (j in j0 until j1) {
            for (i in 0 until spec.width) out[j * spec.width + i] = coverageAt(frame, spec, i, j).value
        }
        j0 = j1
    }
    return out
}

class RasterLoss internal constructor

A raster loss and its exact gradient with respect to the scene's whole θ.

class RasterLoss internal constructor(
    /** Mean squared coverage error over the image. */
    val loss: Double,
    /** `d(loss)/dθ`, one entry per [SceneAdField.theta] slot. */
    val gradient: DoubleArray
) {

val rmse: Double get() = kotlin.math.sqrt(loss)

Root mean squared coverage error — the number worth printing.

fun softRasterLossAndGradient

The mean squared difference between the soft raster and target, and its exact gradient with respect to every numeric leaf in the scene.

This is the function the 2026-08-06 abandonment said a per-track lowering could not produce, and it is written here on top of that same lowering plus AD geometry. The gradient is one backward sweep per row chunk, summed — legitimate because a mean over pixels is a sum over pixels, so d/dθ of the whole is the sum of the parts. RectSLSoftRasterTest checks it against central differences.

@param target coverage in 0..1, row-major, SoftRasterSpec.pixelCount long.

fun softRasterLossAndGradient(
    field: SceneAdField,
    spec: SoftRasterSpec,
    time: Double,
    target: DoubleArray,
    at: DoubleArray = field.theta,
    rowsPerChunk: Int = 8
): RasterLoss {
    require(target.size == spec.pixelCount) {
        "target is ${target.size} pixels but the raster is ${spec.pixelCount}"
    }
    require(rowsPerChunk > 0) { "rowsPerChunk must be positive" }
    val grad = DoubleArray(field.theta.size)
    val pixels = spec.pixelCount.toDouble()
    var sum = 0.0
    var j0 = 0
    while (j0 < spec.height) {
        val frame = field.frameAt(time, at)
        val j1 = minOf(j0 + rowsPerChunk, spec.height)
        var chunk: AD? = null
        for (j in j0 until j1) {
            for (i in 0 until spec.width) {
                val delta = coverageAt(frame, spec, i, j) - target[j * spec.width + i]
                val term = delta * delta
                chunk = chunk?.plus(term) ?: term
            }
        }
        val total = chunk
        if (total != null) {
            sum += total.value
            val adjoint = frame.tape.backward(total)
            for (k in grad.indices) grad[k] += adjoint[frame.theta[k].index] / pixels
        }
        j0 = j1
    }
    return RasterLoss(sum / pixels, grad)
}

fun SceneProgram.toAdField

Lowers every rect of this scene into a SceneAdField, skipping — and reporting — the rects whose channels have no differentiable form.

A rect is all-or-nothing. Half a transform stack is not a shape, and a rect whose width refuses but whose position lowers would silhouette at the wrong size, which is a wrong picture rather than a partial one. A skipped rect's θ block is rolled back so that SceneAdField.theta and SceneAdField.shapes keep corresponding; without that, every index after the hole is a lie.

@param over the window every channel must be valid on. Defaults to the scene's own clock: a lowering valid only on [0, duration] is exactly what a fit over that scene needs, and asking for Functions.Domain.ALL refuses far more. @throws RectLangException when not one rect lowers — a field with no shapes has a gradient of zero everywhere, which is indistinguishable from a converged fit.

body — 114 lines, from line 603
fun SceneProgram.toAdField(
    over: Functions.Domain = Functions.Domain(0.0, maxOf(duration, 1e-9))
): SceneAdField {
    val nodeMap = nodes.associateBy { it.id }
    val parentOf = buildMap {
        nodes.filterIsInstance<GroupNode>().forEach { group ->
            group.children.forEach { child -> put(child, group.id) }
        }
    }

    fun ancestorChain(id: String): List<GroupNode> {
        val chain = mutableListOf<GroupNode>()
        var current = parentOf[id]
        val guard = HashSet<String>()
        while (current != null && guard.add(current)) {
            (nodeMap[current] as? GroupNode)?.let { chain += it }
            current = parentOf[current]
        }
        return chain.reversed()
    }

    val theta = ArrayList<Double>()
    val slots = LinkedHashMap<String, MutableList<Int>>()
    val checkTimes = DoubleArray(CHANNEL_CHECK_FRACTIONS.size) {
        over.min + over.length * CHANNEL_CHECK_FRACTIONS[it]
    }
    var worstDisagreement = 0.0
    val skipped = LinkedHashMap<String, String>()

    // Lowers one track into a θ block, returning null and a reason when it will not. This is the
    // point at which the AD reading is pinned against the channel's own Fn.eval — a second
    // interpreter that computes a subtly different function is the one failure this whole file
    // could not otherwise detect.
    fun channel(track: Track<Double>): Pair<LoweredChannel?, String> {
        val lowering = track.toFn(over)
        val fn = lowering.valueOrNull() ?: return null to (lowering.refusalOrNull() ?: "refused")
        val disagreement = FnAutoDiff.worstValueDisagreement(fn, checkTimes)
        if (!(disagreement <= RECTSL_CHANNEL_TOLERANCE)) {
            return null to (
                "the AD reading of this channel disagrees with its own Fn.eval by $disagreement " +
                    "(> $RECTSL_CHANNEL_TOLERANCE)"
                )
        }
        if (disagreement > worstDisagreement) worstDisagreement = disagreement
        val leaves = fn.extractParameters()
        val offset = theta.size
        for (v in leaves) theta.add(v)
        for ((name, local) in freeParameterSlots(fn)) {
            slots.getOrPut(name) { mutableListOf() } += local.map { it + offset }
        }
        return LoweredChannel(fn, offset, leaves.size, FnAutoDiff.leafIndices(fn)) to ""
    }

    val shapes = mutableListOf<AdShape>()
    for (rect in nodes.filterIsInstance<RectNode>()) {
        val thetaMark = theta.size
        val slotMark = slots.mapValues { it.value.size }
        var refusal: String? = null
        val levels = mutableListOf<LoweredLevel>()
        val sources = ancestorChain(rect.id).map {
            listOf(it.x, it.y, it.rotation, it.scaleX, it.scaleY, it.alpha)
        } + listOf(listOf(rect.x, rect.y, rect.rotation, rect.scaleX, rect.scaleY, rect.alpha))
        for (level in sources) {
            val lowered = level.map { channel(it) }
            val bad = lowered.firstOrNull { it.first == null }
            if (bad != null) {
                refusal = bad.second
                break
            }
            levels += LoweredLevel(
                lowered[0].first!!, lowered[1].first!!, lowered[2].first!!,
                lowered[3].first!!, lowered[4].first!!, lowered[5].first!!
            )
        }
        var w: LoweredChannel? = null
        var h: LoweredChannel? = null
        if (refusal == null) {
            val loweredW = channel(rect.w)
            val loweredH = channel(rect.h)
            w = loweredW.first
            h = loweredH.first
            if (w == null) refusal = loweredW.second
            if (h == null && refusal == null) refusal = loweredH.second
        }
        if (refusal != null || w == null || h == null) {
            skipped[rect.id] = refusal ?: "refused"
            while (theta.size > thetaMark) theta.removeAt(theta.size - 1)
            for ((name, keep) in slotMark) {
                slots[name]?.let { list -> while (list.size > keep) list.removeAt(list.size - 1) }
            }
            slots.entries.removeAll { it.value.isEmpty() }
            continue
        }
        shapes += AdShape(rect.id, levels, w, h)
    }

    if (shapes.isEmpty()) {
        throw RectLangException(
            "no rect of this scene has a differentiable form, so its raster gradient is zero " +
                "everywhere and a fit would report convergence without having moved. Reasons: " +
                skipped.entries.take(3).joinToString("; ") { "${it.key}: ${it.value}" }
        )
    }

    return SceneAdField(
        program = this,
        shapes = shapes,
        theta = theta.toDoubleArray(),
        freeParameterSlots = slots.mapValues { it.value.toList() },
        skipped = skipped,
        worstChannelDisagreement = worstDisagreement,
        window = over
    )
}