Skip to content

imaging_overlay.kt

src/jvmMain/kotlin/afield/media/image/imaging_overlay.kt · 154 lines

package afield.media.image

import afield.geometry.path.FLine
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

Drawing FLines into an Img, in software and without AWT.

Img.drawFLines is the whole surface. It resamples the image up by a scale factor and blends each line over it, reading color, thickness, pointed, filled and the rest from the line's own properties the way Field draws it on the canvas; round-capped segments and even-odd fills are rasterised here, each with a pixel of antialiasing. What comes back is another Img, so an overlay costs no GPU, no window and no AWT font or toolkit, and showImgGrid can show it wherever a box runs.

fun Img.drawFLines

This image as RGB with lines drawn over it, antialiased and without AWT. Line coordinates are in this image's pixels; the result is scale times larger, so thin curves over a small image stay crisp.

Each line is drawn in its color property (a Vec4 with alpha) at its thickness (in output pixels), or color and thickness when it has none. A line with pointed = true is drawn as dots of pointSize, coloured per node by each node's own color, as Field draws it on the canvas. A line with filled = true is filled in its fill colour (even-odd), and outlined too unless stroked = false.

showImgGrid("separatrices" to photo.drawFLines(result.toFLines() + result.criticalPointsFLine(), scale = 2.0))
body — 45 lines, from line 38
fun Img.drawFLines(
    lines: List<FLine>,
    scale: Double = 1.0,
    color: Vec4 = Vec4(1f, 0.3f, 0.1f, 1f),
    thickness: Double = 1.5,
): Img {
    require(scale > 0) { "scale must be positive, got $scale" }
    val w = ceil(width * scale).toInt()
    val h = ceil(height * scale).toInt()
    val rgb = FloatArray(w * h * 3)
    for (y in 0 until h) for (x in 0 until w) {
        val sx = ((x + 0.5) / scale - 0.5).coerceIn(0.0, width - 1.0)
        val sy = ((y + 0.5) / scale - 0.5).coerceIn(0.0, height - 1.0)
        for (c in 0 until 3) rgb[(y * w + x) * 3 + c] = sampleBilinear(sx, sy, if (channels >= 3) c else 0)
    }
    val out = Img(w, h, 3, rgb)
    lines.forEach { line ->
        if (line.properties["pointed"] == true) {
            val size = (line.properties["pointSize"] as? Number)?.toDouble() ?: 4.0
            var c = (line.properties["color"] as? Vec4) ?: color
            line.nodes.forEach { n ->
                c = (n.properties["color"] as? Vec4) ?: c
                val p = n.toOrNull ?: return@forEach
                out.stampCapsule(p.x * scale, p.y * scale, p.x * scale, p.y * scale, size / 2, c)
            }
        } else if (line.properties["filled"] == true) {
            val c = (line.properties["fill"] as? Vec4) ?: (line.properties["color"] as? Vec4) ?: color
            out.fillPolygons(line.polylines().map { poly -> poly.map { Vec3(it.x * scale.toFloat(), it.y * scale.toFloat(), 0f) } }, c)
            if (line.properties["stroked"] != false) {
                val half = ((line.properties["thickness"] as? Number)?.toDouble() ?: thickness) / 2
                line.polylines().forEach { poly -> for (i in 1 until poly.size) out.stampCapsule(poly[i - 1].x * scale, poly[i - 1].y * scale, poly[i].x * scale, poly[i].y * scale, half, c) }
            }
        } else {
            val c = (line.properties["color"] as? Vec4) ?: color
            val half = ((line.properties["thickness"] as? Number)?.toDouble() ?: thickness) / 2
            line.polylines().forEach { poly ->
                for (i in 1 until poly.size) {
                    val a = poly[i - 1]; val b = poly[i]
                    out.stampCapsule(a.x * scale, a.y * scale, b.x * scale, b.y * scale, half, c)
                }
            }
        }
    }
    return out
}

The subpaths of this line as point lists, with cubics flattened into 12 segments each.

private fun FLine.polylines(): List<List<Vec3>> {
    val out = mutableListOf<MutableList<Vec3>>()
    nodes.forEach { n ->
        when (n) {
            is FLine.MoveTo -> out.add(mutableListOf(n.to))
            is FLine.CubicTo -> {
                val cur = out.lastOrNull() ?: mutableListOf<Vec3>().also { out.add(it) }
                val p0 = cur.lastOrNull() ?: n.to
                val c1 = n.storage[1]; val c2 = n.storage[2]; val p3 = n.storage[0]
                for (k in 1..12) {
                    val t = k / 12f; val u = 1 - t
                    val b0 = u * u * u; val b1 = 3 * u * u * t; val b2 = 3 * u * t * t; val b3 = t * t * t
                    cur.add(Vec3(b0 * p0.x + b1 * c1.x + b2 * c2.x + b3 * p3.x, b0 * p0.y + b1 * c1.y + b2 * c2.y + b3 * p3.y, 0f))
                }
            }
            is FLine.LineTo -> (out.lastOrNull() ?: mutableListOf<Vec3>().also { out.add(it) }).add(n.to)
            // back to the subpath's first point; an interior Close keeps the same list, as drawing does
            is FLine.Close -> out.lastOrNull()?.let { cur -> cur.firstOrNull()?.let { cur.add(it) } }
        }
    }
    return out
}

Blends a round-capped segment of radius r into this RGB image, with one-pixel antialiasing.

private fun Img.stampCapsule(ax: Double, ay: Double, bx: Double, by: Double, r: Double, c: Vec4) {
    val x0 = max(0, floor(min(ax, bx) - r - 1).toInt()); val x1 = min(width - 1, ceil(max(ax, bx) + r + 1).toInt())
    val y0 = max(0, floor(min(ay, by) - r - 1).toInt()); val y1 = min(height - 1, ceil(max(ay, by) + r + 1).toInt())
    val dx = bx - ax; val dy = by - ay
    val len2 = dx * dx + dy * dy
    for (y in y0..y1) for (x in x0..x1) {
        val px = x + 0.5 - ax; val py = y + 0.5 - ay
        val t = if (len2 == 0.0) 0.0 else ((px * dx + py * dy) / len2).coerceIn(0.0, 1.0)
        val ex = px - t * dx; val ey = py - t * dy
        val coverage = (r + 0.5 - sqrt(ex * ex + ey * ey)).coerceIn(0.0, 1.0) * c.w
        if (coverage <= 0) continue
        val i = (y * width + x) * 3
        data[i] += ((c.x - data[i]) * coverage).toFloat()
        data[i + 1] += ((c.y - data[i + 1]) * coverage).toFloat()
        data[i + 2] += ((c.z - data[i + 2]) * coverage).toFloat()
    }
}

Blends the even-odd interior of polys into this RGB image, sampling each pixel at its centre.

private fun Img.fillPolygons(polys: List<List<Vec3>>, c: Vec4) {
    val all = polys.flatten()
    if (all.isEmpty()) return
    val y0 = max(0, floor(all.minOf { it.y }.toDouble()).toInt())
    val y1 = min(height - 1, ceil(all.maxOf { it.y }.toDouble()).toInt())
    val xs = ArrayList<Double>()
    for (y in y0..y1) {
        val sy = y + 0.5
        xs.clear()
        polys.forEach { poly ->
            for (i in poly.indices) {
                val a = poly[i]; val b = poly[(i + 1) % poly.size]
                if ((a.y <= sy) != (b.y <= sy)) xs.add(a.x + (sy - a.y) / (b.y - a.y) * (b.x - a.x))
            }
        }
        xs.sort()
        for (k in 0 until xs.size - 1 step 2) {
            for (x in max(0, ceil(xs[k] - 0.5).toInt())..min(width - 1, floor(xs[k + 1] - 0.5).toInt())) {
                val i = (y * width + x) * 3
                data[i] += ((c.x - data[i]) * c.w)
                data[i + 1] += ((c.y - data[i + 1]) * c.w)
                data[i + 2] += ((c.z - data[i + 2]) * c.w)
            }
        }
    }
}