DmxVisual.kt
src/jvmMain/kotlin/afield/io/dmx/DmxVisual.kt · 221 lines
@file:afield.runtime.completion.CompletionCategory("Lighting")
package afield.io.dmx
import afield.document.graph.Box
import afield.ui.output.displayOpen
import afield.geometry.path.FLine
import afield.ui.visualization.lines
import afield.ui.visualization.outputTo
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Surface
import java.io.File
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
Drawings of a DMX rig and its universes as FLine geometry, for a box's output or a PNG.
Two views of the same light. Patch.toFLines and Box.showPatch draw each fixture at its
position in the colour it is set to; Patch.receivedToFLines and Box.showReceived draw the
same layout in the colours decoded from what a DmxInput received, so the sent look and the
arrived one can sit side by side. universeToFLines and Box.showUniverse show a universe in
wire order instead, as a grid of slots or of fixtures read through a FixtureProfile.
renderLinesPng rasterises any of these without a browser.
A closed polygon approximating a circle of radius r about (cx, cy).
private fun disc(cx: Double, cy: Double, r: Double, segments: Int = 20): FLine {
val f = FLine()
for (i in 0..segments) {
val a = 2 * PI * i / segments
if (i == 0) f.moveTo(cx + r * cos(a), cy + r * sin(a)) else f.lineTo(cx + r * cos(a), cy + r * sin(a))
}
return f
}
private fun rectLine(x: Double, y: Double, w: Double, h: Double): FLine =
FLine().moveTo(x, y).lineTo(x + w, y).lineTo(x + w, y + h).lineTo(x, y + h).lineTo(x, y)
fun Patch.toFLines(radius: Double? = null, background: Boolean = true, outline: Boolean = fixtures.size <= 64): List<FLine> =
The patch as drawable geometry: a dark ground, then one filled disc per fixture in its current
colour (dimmer applied), ringed in grey so an unlit fixture is still visible. Positions are the
fixtures' own; y grows downward in box output, as it does for every lines(...) figure.
With outline (the default up to 64 fixtures; a 200-pixel strip would double the SVG for rings
too small to see) each disc is ringed, and the ring colour flips at every universe boundary so
where a long strip changes universe is visible at a glance.
@param radius disc radius in rig units; defaults to 40% of the median spacing between consecutive fixtures.
fun Patch.toFLines(radius: Double? = null, background: Boolean = true, outline: Boolean = fixtures.size <= 64): List<FLine> =
drawFixtures(radius, background, outline) { f ->
val scale = f.dimmer.coerceIn(0.0, 1.0)
vec(f.color.x * scale, f.color.y * scale, f.color.z * scale)
}
fun Patch.receivedToFLines(universeData: (Int) -> ByteArray, radius: Double?
The patch drawn in the colours its fixtures show in the universes universeData returns (a
function from universe number to its 512 slots — input::universe for a DmxInput), decoded by
Fixture.decode. It is toFLines with the wire as the source of colour instead of
Fixture.color: the rig in the room, lit by what arrived, so a received frame can be set beside
the look that was sent. Each universe is fetched once.
fun Patch.receivedToFLines(universeData: (Int) -> ByteArray, radius: Double? = null, background: Boolean = true,
outline: Boolean = fixtures.size <= 64): List<FLine> {
val cache = HashMap<Int, ByteArray>()
return drawFixtures(radius, background, outline) { f -> f.decode(cache.getOrPut(f.universe) { universeData(f.universe) }) }
}
private fun Patch.drawFixtures(radius: Double?, background: Boolean, outline: Boolean, colourOf: (Fixture) -> afield.math.linalg.Vec3): List<FLine> {
val out = ArrayList<FLine>(fixtures.size * 2 + 1)
if (fixtures.isEmpty()) return out
val r = radius ?: run {
val gaps = fixtures.zipWithNext { a, b -> Math.hypot((a.position.x - b.position.x).toDouble(), (a.position.y - b.position.y).toDouble()) }.filter { it > 1e-9 }.sorted()
if (gaps.isEmpty()) 5.0 else gaps[gaps.size / 2] * 0.4
}
val (x, y, w, h) = bounds()
if (background) {
out += rectLine(x - 3 * r, y - 3 * r, w + 6 * r, h + 6 * r).apply {
filled = true; stroked = false; color = vec(0.06, 0.06, 0.07, 1.0)
}
}
val ringColours = listOf(vec(0.45, 0.45, 0.5, 1.0), vec(0.85, 0.65, 0.2, 1.0))
var ring = 0
var lastUniverse = fixtures.first().universe
for (f in fixtures) {
if (f.universe != lastUniverse) { ring = 1 - ring; lastUniverse = f.universe }
val c = colourOf(f)
val px = f.position.x.toDouble()
val py = f.position.y.toDouble()
out += disc(px, py, r).apply {
filled = true; stroked = false
color = vec(c.x, c.y, c.z, 1.0)
}
if (outline) out += disc(px, py, r).apply {
filled = false; stroked = true; thickness = r * 0.18; color = ringColours[ring]
}
}
return out
}
fun universeToFLines
A received (or sent) universe as geometry, in a cols-wide grid of cell-sized squares.
With no profile, each of the 512 slots is one gray cell at its value. With a profile, the
universe is read back as the fixtures that profile describes, packed from slot 1 — one cell per
fixture, coloured from its slots (white adds to all three primaries, amber adds 1 : 0.5 red and
green, a lone dimmer is gray, a master dimmer scales the rest; fine bytes are ignored). So an RGB
strip's universe reads as 170 coloured pixels and an RGBW matrix's as 128. rgb is shorthand for
profile = FixtureProfile.RGB.
fun universeToFLines(
data: ByteArray,
cols: Int = 32,
cell: Double = 12.0,
rgb: Boolean = false,
length: Int = 512,
profile: FixtureProfile? = if (rgb) FixtureProfile.RGB else null,
): List<FLine> {
val out = ArrayList<FLine>()
val footprint = profile?.footprint ?: 1
val cells = length / footprint
val rows = (cells + cols - 1) / cols
out += rectLine(-cell * 0.5, -cell * 0.5, cols * cell + cell, rows * cell + cell).apply {
filled = true; stroked = false; color = vec(0.06, 0.06, 0.07, 1.0)
}
for (i in 0 until cells) {
val cx = (i % cols) * cell
val cy = (i / cols) * cell
val col = if (profile == null) {
val g = (data[i].toInt() and 0xff) / 255.0
vec(g, g, g, 1.0)
} else fixtureColour(data, i * footprint, profile)
out += rectLine(cx + 1, cy + 1, cell - 2, cell - 2).apply { filled = true; stroked = false; color = col }
}
return out
}
The display colour of one profile fixture whose first slot is 0-based at in data.
private fun fixtureColour(data: ByteArray, at: Int, profile: FixtureProfile): afield.math.linalg.Vec4 {
var r = 0.0; var g = 0.0; var b = 0.0; var dim = 1.0; var level = -1.0
profile.slots.forEachIndexed { k, slot ->
val v = (data[at + k].toInt() and 0xff) / 255.0
when (slot) {
Slot.RED -> r += v
Slot.GREEN -> g += v
Slot.BLUE -> b += v
Slot.WHITE -> { r += v; g += v; b += v }
Slot.AMBER -> { r += v; g += 0.5 * v }
Slot.DIMMER -> if (profile.hasColour) dim = v else level = v
else -> {}
}
}
if (level >= 0) { r = level; g = level; b = level }
return vec(minOf(1.0, r * dim), minOf(1.0, g * dim), minOf(1.0, b * dim), 1.0)
}
fun Box.showPatch(patch: Patch, channel: String = "lights", w: Double = 640.0, h: Double = 240.0)
Show the patch in its current colours in this box's channel output.
fun Box.showPatch(patch: Patch, channel: String = "lights", w: Double = 640.0, h: Double = 240.0) {
lines(patch.toFLines(), channel = channel, w = w, h = h, fitToSize = true)
displayOpen(channel)
}
fun Box.showReceived(patch: Patch, input: DmxInput, channel: String = "received", w: Double = 640.0, h: Double = 240.0)
Show patch lit by what input has received — each fixture decoded from its own universe and
address (receivedToFLines) — in this box's channel output. Beside showPatch it is the sent
look and the received one in the same layout; showUniverse is the same data in wire order.
fun Box.showReceived(patch: Patch, input: DmxInput, channel: String = "received", w: Double = 640.0, h: Double = 240.0) {
lines(patch.receivedToFLines({ input.universe(it) }), channel = channel, w = w, h = h, fitToSize = true)
displayOpen(channel)
}
fun Box.showUniverse(input: DmxInput, universe: Int, channel: String = "universe $universe",
Show one universe of input in this box's channel output: as profile fixtures (RGB pixels by
default), or as 512 gray slots with profile = null. See universeToFLines.
fun Box.showUniverse(input: DmxInput, universe: Int, channel: String = "universe $universe",
profile: FixtureProfile? = FixtureProfile.RGB, cols: Int = 32, w: Double = 640.0, h: Double = 240.0) {
lines(universeToFLines(input.universe(universe), cols = cols, profile = profile), channel = channel, w = w, h = h, fitToSize = true)
displayOpen(channel)
}
fun renderLinesPng(lines: List<FLine>, file: File, w: Int = 640, h: Int = 240): File
Rasterise lines into a w × h PNG at file, fitted with a 10 px margin — for looking at a
visualiser without a browser (a headless Field, a test, a docs figure). Returns the file.
fun renderLinesPng(lines: List<FLine>, file: File, w: Int = 640, h: Int = 240): File {
val xs = lines.flatMap { l -> l.nodes.mapNotNull { it.toOrNull?.x?.toDouble() } }
val ys = lines.flatMap { l -> l.nodes.mapNotNull { it.toOrNull?.y?.toDouble() } }
val x0 = xs.minOrNull() ?: 0.0; val x1 = xs.maxOrNull() ?: 1.0
val y0 = ys.minOrNull() ?: 0.0; val y1 = ys.maxOrNull() ?: 1.0
val margin = 10.0
val s = minOf((w - 2 * margin) / maxOf(1e-9, x1 - x0), (h - 2 * margin) / maxOf(1e-9, y1 - y0))
val ox = (w - (x1 - x0) * s) / 2
val oy = (h - (y1 - y0) * s) / 2
val surface = Surface.makeRasterN32Premul(w, h)
try {
val canvas = surface.canvas
canvas.clear(0xff202024.toInt())
for (l in lines) {
val placed = l.transform { p -> vec((p.x - x0) * s + ox, (p.y - y0) * s + oy, p.z) }
l.thickness?.let { placed.thickness = it * s }
outputTo(placed, canvas, antiAlias = true)
}
val png = surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)?.bytes
?: error("Skia declined to encode the PNG")
file.parentFile?.mkdirs()
file.writeBytes(png)
} finally {
runCatching { surface.close() }
}
return file
}