Skip to content

Patch.kt

src/jvmMain/kotlin/afield/io/dmx/Patch.kt · 451 lines

@file:afield.runtime.completion.CompletionCategory("Lighting")

package afield.io.dmx

import afield.ui.visualization.ToHTML
import afield.geometry.path.FLine
import afield.ui.visualization.linesToHTML
import afield.media.image.Img
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.media.image.sampleBilinear
import afield.math.linalg.vec
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt

A rig as data: what each fixture is, where it sits in DMX space, and where it sits in the room.

A Patch holds Fixtures, each a FixtureProfile (which Slot sits at every offset from the start address) at some universe and address, with a position. Addresses are given explicitly or allocated in runs by strip, stripAlong and grid, which roll over to the next universe rather than let a fixture straddle slot 512.

Colour goes in through the mappers (map from a position and a time, mapAlong from distance along the fixture list, mapImage sampled from an Img) or straight onto a fixture, and goes out as slot bytes through render into a DmxOutput. writeInto is where a colour becomes slots: white and amber are taken out of red, green and blue for the profiles that carry them, gamma is applied, and a 16-bit slot gets its coarse and fine bytes. decode inverts that as far as the slots allow, so a look that came back off the wire can be compared with the one sent.

Positions are the room's, in whatever unit the rig is described in; the mappers and the toFLines drawing work in that unit and nothing here fixes it to metres. colorTemperature turns a black-body temperature into a colour to map onto. The user-facing page is Driving Lights: Art-Net and sACN.

enum class Slot

What one DMX slot of a fixture carries. _FINE slots are the low byte of the slot before them.

enum class Slot {
    RED, GREEN, BLUE, WHITE, AMBER,

Intensity. With colour slots present it is the fixture's master Fixture.dimmer; alone, it is dimmer × luminance.

    DIMMER,
    RED_FINE, GREEN_FINE, BLUE_FINE, DIMMER_FINE,

A slot the fixture has but Field leaves at 0 (strobe, macro, mode…).

    ZERO,

A slot held at 255 (a shutter-open or "lamp on" channel).

    FULL,
}

class FixtureProfile

The channel layout of a kind of fixture: which Slot sits at each offset from its start address.

The presets cover the common cases — RGB pixels and pars, GRB (the WS2812 order most pixel controllers expose), RGBW, RGBWA, a plain DIMMER, a 16-bit DIMMER16, RGB16 with coarse/fine pairs, and DRGB (master dimmer then colour). Anything else is FixtureProfile("my par", listOf(Slot.DIMMER, Slot.RED, …)).

class FixtureProfile(
    /** What to call this layout. It appears in [toString] and in a [Fixture]'s; nothing matches on it. */
    val name: String,
    /** Which [Slot] sits at each offset from a fixture's start address, in order; 1..512 of them. */
    val slots: List<Slot>,
    /**
     * Exponent applied to every 0..1 level before quantisation; 1.0 is linear. LED drivers are
     * linear in PWM, so 2.2 makes a ramp look even to the eye. [Fixture] `decode` undoes it, so a
     * look read back off the wire is comparable with the one sent whatever the gamma.
     */
    val gamma: Double = 1.0,
) {

init

    init {
        require(slots.isNotEmpty() && slots.size <= 512) { "a fixture has 1..512 slots" }
    }

val footprint: Int get() = slots.size

Number of DMX slots the fixture occupies.

val hasColour: Boolean get()

True if any slot carries colour (so Slot.DIMMER is a master, not the whole light).

    val hasColour: Boolean get() = slots.any { it == Slot.RED || it == Slot.GREEN || it == Slot.BLUE || it == Slot.WHITE || it == Slot.AMBER }

fun withGamma(gamma: Double) = FixtureProfile(name, slots, gamma)

The same layout with a different gamma.

override fun toString() = "FixtureProfile($name, $footprint slots)"

The profile name and how many slots it occupies.

companion object

val RGB = FixtureProfile("RGB", listOf(Slot.RED, Slot.GREEN, Slot.BLUE))

val GRB = FixtureProfile("GRB", listOf(Slot.GREEN, Slot.RED, Slot.BLUE))

val RGBW = FixtureProfile("RGBW", listOf(Slot.RED, Slot.GREEN, Slot.BLUE, Slot.WHITE))

val RGBWA

        val RGBWA = FixtureProfile("RGBWA", listOf(Slot.RED, Slot.GREEN, Slot.BLUE, Slot.WHITE, Slot.AMBER))

val DIMMER = FixtureProfile("Dimmer", listOf(Slot.DIMMER))

val DIMMER16 = FixtureProfile("Dimmer16", listOf(Slot.DIMMER, Slot.DIMMER_FINE))

val RGB16

        val RGB16 = FixtureProfile("RGB16", listOf(Slot.RED, Slot.RED_FINE, Slot.GREEN, Slot.GREEN_FINE, Slot.BLUE, Slot.BLUE_FINE))

val DRGB = FixtureProfile("DRGB", listOf(Slot.DIMMER, Slot.RED, Slot.GREEN, Slot.BLUE))

class Fixture

One patched fixture: a profile at 1-based address in universe, placed at position in whatever 2D units the rig is laid out in (the mappers and the visualiser use the same units).

color is the requested colour, each component 0..1; dimmer a 0..1 master. Nothing reaches the wire until the owning Patch is rendered into a DmxOutput.

class Fixture(
    /**
     * What to call this fixture. The run builders take one name for a whole run and index each
     * member off it: `strip` and `stripAlong` count from zero as `pixel 0`, and `grid` gives column
     * and row as `pixel 0,0`.
     */
    val name: String,
    /** The channel layout: which [Slot] sits at each offset from [address]. */
    val profile: FixtureProfile,
    /** Which universe the fixture is in. A [Patch] renders each one into the matching [DmxUniverse]. */
    val universe: Int,
    /**
     * 1-based start address, 1..512, as a console and a fixture manual give it. The constructor
     * refuses an address whose [FixtureProfile.footprint] would run past slot 512, which is why the
     * run builders roll a long strip over to the next universe rather than straddling the boundary.
     */
    val address: Int,
    /**
     * Where the fixture sits in whatever 2D units the rig is laid out in; the mappers and the
     * drawing use the same ones. Writable, so a patched rig can still be moved.
     */
    var position: Vec2,
) {

init

    init {
        require(address in 1..512) { "DMX addresses are 1..512, got $address" }
        require(address + profile.footprint - 1 <= 512) {
            "$name: ${profile.footprint} slots from address $address runs past slot 512 of universe $universe"
        }
    }

var color: Vec3 = vec(0.0, 0.0, 0.0)

Requested colour, components 0..1.

var dimmer: Double = 1.0

Master intensity, 0..1.

val lastAddress: Int get() = address + profile.footprint - 1

Last 1-based address this fixture occupies.

fun rgb(r: Number, g: Number, b: Number)

Set color from components.

    fun rgb(r: Number, g: Number, b: Number) {
        color = vec(r, g, b)
    }

fun writeInto(data: ByteArray)

Write this fixture's slots into the 0-based universe array data.

Colour decomposition, for profiles that have the slot: white takes min(r, g, b) off all three when Slot.WHITE is present; amber then takes what it can of the remaining red and green in the proportion 1 : 0.5 — the rough sRGB of a 590–600 nm amber emitter. It is a plausible default, not a calibration: a real rig wants measured primaries.

    fun writeInto(data: ByteArray) {
        var r = color.x.toDouble().coerceIn(0.0, 1.0)
        var g = color.y.toDouble().coerceIn(0.0, 1.0)
        var b = color.z.toDouble().coerceIn(0.0, 1.0)
        val slots = profile.slots
        var w = 0.0
        var a = 0.0
        if (Slot.WHITE in slots) {
            w = min(r, min(g, b)); r -= w; g -= w; b -= w
        }
        if (Slot.AMBER in slots) {
            a = min(r, g / 0.5).coerceIn(0.0, 1.0); r -= a; g -= a * 0.5
        }
        val d = dimmer.coerceIn(0.0, 1.0)
        val colourScale = if (profile.hasColour && Slot.DIMMER in slots) 1.0 else d
        val intensity = if (profile.hasColour) d else d * (0.2126 * r + 0.7152 * g + 0.0722 * b)
        for (i in slots.indices) {
            val at = address - 1 + i
            when (slots[i]) {
                Slot.RED -> put16or8(data, at, r * colourScale, slots.getOrNull(i + 1) == Slot.RED_FINE)
                Slot.GREEN -> put16or8(data, at, g * colourScale, slots.getOrNull(i + 1) == Slot.GREEN_FINE)
                Slot.BLUE -> put16or8(data, at, b * colourScale, slots.getOrNull(i + 1) == Slot.BLUE_FINE)
                Slot.WHITE -> put16or8(data, at, w * colourScale, false)
                Slot.AMBER -> put16or8(data, at, a * colourScale, false)
                Slot.DIMMER -> put16or8(data, at, intensity, slots.getOrNull(i + 1) == Slot.DIMMER_FINE)
                Slot.RED_FINE, Slot.GREEN_FINE, Slot.BLUE_FINE, Slot.DIMMER_FINE -> {} // written with the coarse slot
                Slot.ZERO -> data[at] = 0
                Slot.FULL -> data[at] = 255.toByte()
            }
        }
    }

fun decode(data: ByteArray): Vec3

The look this fixture shows, read back from its slots in data (a universe's 512 slots, 0-based, as DmxInput.universe returns them): the inverse of writeInto as far as the slots allow. Fine bytes are used, FixtureProfile.gamma is undone, white is added back to all three primaries and amber to red and green (1 : 0.5), and a master dimmer is multiplied in.

What comes back is color × dimmer, not the two separately — an RGB fixture without a dimmer slot bakes the dimmer into its colour, so nothing on the wire can tell them apart. A lone dimmer channel reads as a grey of its level. For an 8-bit colour slot at gamma 1, the result is within half a level (0.5 / 255) of what was sent; that is all the wire carries.

    fun decode(data: ByteArray): Vec3 {
        val slots = profile.slots
        fun level(i: Int): Double {
            val at = address - 1 + i
            val coarse = data[at].toInt() and 0xff
            val fine = slots.getOrNull(i + 1)?.let { next ->
                val pairs = (slots[i] == Slot.RED && next == Slot.RED_FINE) || (slots[i] == Slot.GREEN && next == Slot.GREEN_FINE) ||
                    (slots[i] == Slot.BLUE && next == Slot.BLUE_FINE) || (slots[i] == Slot.DIMMER && next == Slot.DIMMER_FINE)
                if (pairs) data[at + 1].toInt() and 0xff else null
            }
            val l = if (fine == null) coarse / 255.0 else ((coarse shl 8) or fine) / 65535.0
            return if (profile.gamma == 1.0 || l <= 0.0) l else l.pow(1.0 / profile.gamma)
        }
        var r = 0.0; var g = 0.0; var b = 0.0; var dim = -1.0
        for (i in slots.indices) when (slots[i]) {
            Slot.RED -> r += level(i)
            Slot.GREEN -> g += level(i)
            Slot.BLUE -> b += level(i)
            Slot.WHITE -> level(i).let { r += it; g += it; b += it }
            Slot.AMBER -> level(i).let { r += it; g += 0.5 * it }
            Slot.DIMMER -> dim = level(i)
            else -> {}
        }
        if (!profile.hasColour) return vec(maxOf(dim, 0.0), maxOf(dim, 0.0), maxOf(dim, 0.0))
        val d = if (dim < 0) 1.0 else dim
        return vec(min(1.0, r * d), min(1.0, g * d), min(1.0, b * d))
    }

    private fun put16or8(data: ByteArray, at: Int, level: Double, sixteen: Boolean) {
        val l = if (profile.gamma == 1.0) level else level.pow(profile.gamma)
        if (sixteen) {
            val v = (l * 65535).roundToInt().coerceIn(0, 65535)
            data[at] = (v shr 8).toByte()
            data[at + 1] = v.toByte()
        } else {
            data[at] = (l * 255).roundToInt().coerceIn(0, 255).toByte()
        }
    }

override fun toString() = "Fixture($name, ${profile.name} @ $universe/$address..$lastAddress)"

Name, profile, and the slots occupied as universe/first..last.

class Patch(val startUniverse: Int = 1) : ToHTML

A lighting patch: fixtures, where they sit in DMX space, and where they sit in the room.

Fixtures are added one at a time with add, or in runs with strip, grid and stripAlong. The run builders allocate addresses from a cursor that starts at universe startUniverse, address 1, and roll over to the next universe whenever the next fixture would straddle slot 512 — so an RGB strip holds 170 pixels per universe (510 slots, 2 left unused) and an RGBW strip 128. That is how pixel controllers expect long strips to be laid out.

Colours are set by the mappers — map (a function of position and time), mapAlong (of distance along the fixture list), mapImage (sampled from an Img) — or directly on Fixture.color. render writes every fixture into a DmxOutput; toFLines draws the rig in its current colours, and evaluating a patch in a box shows that drawing inline.

class Patch(val startUniverse: Int = 1) : ToHTML {

val fixtures = mutableListOf<Fixture>()

Every fixture, in the order it was added.

    private var cursorUniverse = startUniverse
    private var cursorAddress = 1
    private val startNanos = System.nanoTime()

val time: Double get() = (System.nanoTime() - startNanos) / 1e9

Seconds since this patch was made; the default t for map and mapAlong.

val universes: List<Int> get() = fixtures.map { it.universe }.distinct().sorted()

Universes any fixture touches, ascending.

val cursor: Pair<Int, Int> get() = cursorUniverse to cursorAddress

Where the next allocated fixture will go, as universe to address.

fun moveCursor(universe: Int, address: Int = 1)

Move the allocation cursor, e.g. to start the next run on a fresh universe.

    fun moveCursor(universe: Int, address: Int = 1) {
        require(address in 1..512)
        cursorUniverse = universe; cursorAddress = address
    }

fun add(profile: FixtureProfile, universe: Int, address: Int, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture

Patch one fixture at an explicit address. Does not move the cursor.

    fun add(profile: FixtureProfile, universe: Int, address: Int, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture =
        Fixture(name, profile, universe, address, position).also { fixtures += it }

fun next(profile: FixtureProfile, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture

Patch one fixture at the cursor, rolling to the next universe if it would not fit.

    fun next(profile: FixtureProfile, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture {
        if (cursorAddress + profile.footprint - 1 > 512) {
            cursorUniverse++; cursorAddress = 1
        }
        val f = add(profile, cursorUniverse, cursorAddress, position, name)
        cursorAddress += profile.footprint
        if (cursorAddress > 512) { cursorUniverse++; cursorAddress = 1 }
        return f
    }

fun strip(count: Int, from: Vec2, to: Vec2, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture>

A straight strip of count fixtures from from to to (inclusive), allocated from the cursor.

    fun strip(count: Int, from: Vec2, to: Vec2, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture> {
        require(count >= 1)
        return (0 until count).map { i ->
            val u = if (count == 1) 0.0 else i / (count - 1.0)
            next(profile, vec(from.x + (to.x - from.x) * u, from.y + (to.y - from.y) * u), "$name $i")
        }
    }

fun stripAlong(line: FLine, count: Int, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture>

A strip of count fixtures laid evenly by arc length along line — an LED tape stuck to a drawn curve. Positions come from FLine.sampleByNumber, so the ends of the line are the ends of the strip.

    fun stripAlong(line: FLine, count: Int, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture> {
        require(count >= 2) { "a strip along a line needs at least 2 pixels" }
        return line.sampleByNumber(count).mapIndexed { i, p -> next(profile, vec(p.x, p.y), "$name $i") }
    }

fun grid(cols: Int, rows: Int, spacing: Double = 10.0, origin: Vec2 = vec(0.0, 0.0),

A cols × rows matrix with spacing between fixtures, top-left at origin, allocated in rows (or in a serpentine, as most LED matrices are wired, when serpentine is true).

    fun grid(cols: Int, rows: Int, spacing: Double = 10.0, origin: Vec2 = vec(0.0, 0.0),
             profile: FixtureProfile = FixtureProfile.RGB, serpentine: Boolean = false, name: String = "pixel"): List<Fixture> {
        require(cols >= 1 && rows >= 1)
        val out = ArrayList<Fixture>(cols * rows)
        for (r in 0 until rows) for (k in 0 until cols) {
            val c = if (serpentine && r % 2 == 1) cols - 1 - k else k
            out += next(profile, vec(origin.x + c * spacing, origin.y + r * spacing), "$name $c,$r")
        }
        return out
    }

fun map(t: Double = time, only: List<Fixture> = fixtures, fn: (x: Double, y: Double, t: Double) -> Vec3): Patch {

Colour every fixture (or only these) from its position and time: fn(x, y, t) returns rgb 0..1.

    fun map(t: Double = time, only: List<Fixture> = fixtures, fn: (x: Double, y: Double, t: Double) -> Vec3): Patch {
        for (f in only) f.color = fn(f.position.x.toDouble(), f.position.y.toDouble(), t)
        return this
    }

fun mapAlong(t: Double = time, only: List<Fixture> = fixtures, fn: (u: Double, t: Double) -> Vec3): Patch {

Colour only (default every fixture) by position along the list: fn(u, t) with u running 0..1 from the first fixture to the last — a gradient or a chase down a strip.

    fun mapAlong(t: Double = time, only: List<Fixture> = fixtures, fn: (u: Double, t: Double) -> Vec3): Patch {
        val n = only.size
        only.forEachIndexed { i, f -> f.color = fn(if (n <= 1) 0.0 else i / (n - 1.0), t) }
        return this
    }

fun mapImage(img: Img, only: List<Fixture> = fixtures, area: DoubleArray? = null): Patch

Colour fixtures by sampling img bilinearly. The rig's bounding box (or area, as x, y, w, h in rig units) is stretched over the whole image, so the fixtures act as a low-resolution screen. Gray images drive r = g = b.

    fun mapImage(img: Img, only: List<Fixture> = fixtures, area: DoubleArray? = null): Patch {
        if (only.isEmpty()) return this
        val (x0, y0, w, h) = area?.toList() ?: bounds(only)
        for (f in only) {
            val u = if (w <= 0) 0.5 else (f.position.x - x0) / w
            val v = if (h <= 0) 0.5 else (f.position.y - y0) / h
            val px = u * (img.width - 1)
            val py = v * (img.height - 1)
            f.color = if (img.channels == 1) {
                val g = img.sampleBilinear(px, py, 0)
                vec(g, g, g)
            } else vec(img.sampleBilinear(px, py, 0), img.sampleBilinear(px, py, 1), img.sampleBilinear(px, py, 2))
        }
        return this
    }

fun bounds(only: List<Fixture> = fixtures): List<Double>

[x, y, w, h] of the fixtures' positions.

    fun bounds(only: List<Fixture> = fixtures): List<Double> {
        if (only.isEmpty()) return listOf(0.0, 0.0, 0.0, 0.0)
        var x0 = Double.MAX_VALUE; var y0 = Double.MAX_VALUE; var x1 = -Double.MAX_VALUE; var y1 = -Double.MAX_VALUE
        for (f in only) {
            x0 = min(x0, f.position.x.toDouble()); y0 = min(y0, f.position.y.toDouble())
            x1 = max(x1, f.position.x.toDouble()); y1 = max(y1, f.position.y.toDouble())
        }
        return listOf(x0, y0, x1 - x0, y1 - y0)
    }

fun renderInto(universeData: (Int) -> ByteArray) {

Write every fixture into universeData — a function from universe number to its 512-byte array — without any networking. render into an output is this plus the frame lock.

    fun renderInto(universeData: (Int) -> ByteArray) {
        for (f in fixtures) f.writeInto(universeData(f.universe))
    }

fun render(out: DmxOutput) = out.frame { renderInto { universe(it).data } }

Write every fixture into out's universes, atomically with respect to its sender thread.

fun animate(out: DmxOutput, fn: Patch.(t: Double) -> Unit): Patch {

Animate this patch on out's sender clock: before every tick, fn runs with seconds since the output started (typically calling map / mapAlong / mapImage), then the patch is rendered into out. Replaces any previous animation on that output, so re-running a box is safe; stops when the output closes (i.e. when the owning box is stopped).

    fun animate(out: DmxOutput, fn: Patch.(t: Double) -> Unit): Patch {
        out.animate { t ->
            fn(t)
            render(out)
        }
        return this
    }

fun blackout(): Patch

Set every fixture to black.

    fun blackout(): Patch {
        fixtures.forEach { it.color = vec(0.0, 0.0, 0.0) }
        return this
    }

override fun toHTML(): String

Evaluating a patch in a box draws it: the fixtures in their current colours (toFLines) above the toString summary.

    override fun toHTML(): String {
        val summary = toString().replace("&", "&amp;").replace("<", "&lt;")
        val picture = if (fixtures.isEmpty()) "" else linesToHTML(toFLines(), 480.0, 180.0, fitToSize = true)
        return "<div class='afield-dmx-patch'>$picture<div style='font-family:monospace;font-size:11px'>$summary</div></div>"
    }

override fun toString(): String

How many fixtures there are, a count per profile name, and the universes they occupy. This is also the caption under the drawing when a patch is evaluated in a box.

    override fun toString(): String {
        val byProfile = fixtures.groupingBy { it.profile.name }.eachCount()
        return "Patch(${fixtures.size} fixtures $byProfile, universes $universes)"
    }
}

fun colorTemperature(kelvin: Double): Vec3

The colour of a black body at kelvin, as sRGB-ish 0..1 components with the largest at 1 — Tanner Helland's curve fit to Mitchell Charity's blackbody table, good from about 1000 K to 40000 K. 2700 K is a warm incandescent, 3200 K tungsten stage light, 5600 K daylight, 6500 K roughly white.

Useful as a mapper target (patch.map { _, _, _ -> colorTemperature(3200.0) }), or to tint white with vec(c.x * level, c.y * level, c.z * level). It is a perceptual convenience, not colorimetry: the fit is to display primaries, and a real fixture's white point differs.

fun colorTemperature(kelvin: Double): Vec3 {
    val t = kelvin.coerceIn(1000.0, 40000.0) / 100.0
    val r = if (t <= 66) 255.0 else 329.698727446 * (t - 60).pow(-0.1332047592)
    val g = if (t <= 66) 99.4708025861 * kotlin.math.ln(t) - 161.1195681661 else 288.1221695283 * (t - 60).pow(-0.0755148492)
    val b = when {
        t >= 66 -> 255.0
        t <= 19 -> 0.0
        else -> 138.5177312231 * kotlin.math.ln(t - 10) - 305.0447927307
    }
    return vec((r / 255).coerceIn(0.0, 1.0), (g / 255).coerceIn(0.0, 1.0), (b / 255).coerceIn(0.0, 1.0))
}