Skip to content

someSkiko.kt

src/jvmMain/kotlin/afield/ui/visualization/someSkiko.kt · 879 lines

package and imports — 75 lines, from line 1
package afield.ui.visualization

import afield.graphics.three.ThreeInteractiveObject
import afield.graphics.three.three

import afield.geometry.path.EditableFLineViewState
import afield.geometry.path.FLine
import afield.geometry.path.scale
import afield.geometry.path.start
import afield.document.graph.Box
import afield.examples.graphics.FLineGeometryExamples
import afield.ui.output.registerFLineClickCallback
import afield.ai.learning.*
import afield.ai.retrieval.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.animation.motion.*
import afield.app.commands.*
import afield.app.diagnostics.*
import afield.audio.analysis.*
import afield.audio.synth.*
import afield.data.collections.*
import afield.data.formats.*
import afield.data.graph.*
import afield.data.sorting.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.geometry.mesh.*
import afield.geometry.path.*
import afield.geometry.spatial.*
import afield.graphics.color.*
import afield.graphics.interaction.*
import afield.graphics.render.*
import afield.graphics.shader.*
import afield.graphics.text.*
import afield.graphics.vulkan.buffers.*
import afield.io.osc.*
import afield.io.process.*
import afield.lang.lambda.*
import afield.lang.python.*
import afield.lang.remotejs.*
import afield.lang.stacksl.*
import afield.math.autodiff.*
import afield.math.control.*
import afield.math.estimation.*
import afield.math.functions.*
import afield.math.linalg.*
import afield.math.optimization.*
import afield.math.statistics.*
import afield.math.units.*
import afield.media.image.*
import afield.media.video.*
import afield.runtime.execution.*
import afield.runtime.languages.*
import afield.runtime.lifecycle.*
import afield.simulation.agents.*
import afield.simulation.automata.*
import afield.simulation.particles.*
import afield.study.build.*
import afield.study.evidence.*
import afield.study.search.*
import afield.study.source.*
import afield.study.ui.*
import afield.ui.controls.*
import afield.ui.output.*
import afield.ui.visualization.*
import org.jetbrains.skia.*
import org.jetbrains.skia.svg.SVGCanvas
import java.io.ByteArrayOutputStream
import java.util.Locale
import java.util.UUID
import kotlin.math.max
import kotlin.math.min

Drawing FLines into a box's output as SVG: Box.lines and Box.l display them, linesToHTML and drawToSVG return the markup, and outputTo draws one line onto a Skia Canvas.

Lines carrying FLine.onClick or FLine.hoverColor are drawn as interactive SVG with click callbacks; everything else goes through Skia's SVG canvas.

fun linesToHTML

The exact HTML a non-editable Box.lines would display, without displaying it.

Every lines(...) overload builds this and hands it to display, and the branch it takes is not a choice the caller makes: a line carrying FLine.onClick or FLine.hoverColor gets the interactive SVG (click callbacks registered, topology and picking metadata emitted), everything else gets Skia's SVG canvas. Both come back wrapped in the afield-lines-output div the box output panel measures itself from.

It is a separate function because a value that renders itself — a afield.ui.visualization.ToHTML, and afield.ui.visualization.Chart in particular — has no Box to display into, and had no way to reach this path without reimplementing it and losing the interactive branch. Every drawn figure in Field should be the same markup whether a box drew it or a value drew itself.

@param w Output SVG width in pixels; -1.0 derives it from the geometry's bounds. @param h Output SVG height in pixels; -1.0 derives it from the geometry's bounds. @param fitToSize If true, uniformly scales and offsets the lines to fit inside wxh with padding.

fun linesToHTML(
    lines: List<FLine>,
    w: Double = -1.0,
    h: Double = -1.0,
    fitToSize: Boolean = false,
): String {
    val renderable = lines.renderableLines()
    val fit = computeFitTransform(renderable, w, h, fitToSize)
    return if (renderable.any { it.onClick != null || it.hoverColor != null }) {
        val pairs = renderable.map { it to it.applyFit(fit) }
        wrapLinesOutput(
            buildInteractiveSVG(pairs, w, h, fit),
            pairs.map { it.second }.combinedRenderableBoundsOrNull()
        )
    } else {
        val scaled = renderable.map { it.applyFit(fit) }
        wrapLinesOutput(drawToSVG(scaled, w, h), renderedSvgBoundsFor(scaled, w, h))
    }
}

fun Box.lines

Render one or more FLines to SVG and display them in an output channel. If any FLine has FLine.onClick or FLine.hoverColor set, an interactive SVG is generated instead of using the Skia canvas renderer.

@param channel Output channel name (disclosure panel). @param w Output SVG width in pixels. @param h Output SVG height in pixels. @param fitToSize If true, uniformly scales and offsets the lines to fit inside wxh with padding. @param editable If true, points can be dragged; the returned state's currentLines() reads them. @param onEditCommitted With editable, receives detached copies of the lines each time a drag finishes. Keep it short; it runs on the server's message thread.

@JvmOverloads
fun Box.lines(
    vararg m: FLine,
    channel: String = "lines",
    w: Double = -1.0,
    h: Double = -1.0,
    fitToSize: Boolean = false,
    editable: Boolean = false,
    drawing: Boolean = false,
    liveDrawing: Boolean = false,
    onEditCommitted: ((List<FLine>) -> Unit)? = null,
): EditableFLineViewState? {
    val lines = m.asList().renderableLines()
    if (editable) {
        val fit = computeFitTransform(lines, w, h, fitToSize)
        val pairs = lines.map { it to it.applyFit(fit) }
        val widget = buildEditableSVGWidget(pairs, fit, w, h, drawingEnabled = drawing, liveDrawingEnabled = liveDrawing, onEditCommitted = onEditCommitted)
        display(channel, widget.html)
        return widget.viewState
    }
    display(channel, linesToHTML(lines, w, h, fitToSize))
    return null
}

fun Box.lines

Render a list of FLines to SVG and display them in an output channel. If any FLine has FLine.onClick or FLine.hoverColor set, an interactive SVG is generated instead of using the Skia canvas renderer.

@param channel Output channel name (disclosure panel). @param w Output SVG width in pixels. @param h Output SVG height in pixels. @param fitToSize If true, uniformly scales and offsets the lines to fit inside wxh with padding. @param editable If true, points can be dragged; the returned state's currentLines() reads them. @param onEditCommitted With editable, receives detached copies of the lines each time a drag finishes. Keep it short; it runs on the server's message thread.

@JvmOverloads
fun Box.lines(
    m: List<FLine>,
    channel: String = "lines",
    w: Double = -1.0,
    h: Double = -1.0,
    fitToSize: Boolean = false,
    editable: Boolean = false,
    drawing: Boolean = false,
    liveDrawing: Boolean = false,
    onEditCommitted: ((List<FLine>) -> Unit)? = null,
): EditableFLineViewState? {
    val lines = m.renderableLines()
    if (editable) {
        val fit = computeFitTransform(lines, w, h, fitToSize)
        val pairs = lines.map { it to it.applyFit(fit) }
        val widget = buildEditableSVGWidget(pairs, fit, w, h, drawingEnabled = drawing, liveDrawingEnabled = liveDrawing, onEditCommitted = onEditCommitted)
        display(channel, widget.html)
        return widget.viewState
    }
    display(channel, linesToHTML(lines, w, h, fitToSize))
    return null
}

fun Box.lines

Render FLines from a map, optionally splitting by channel. If any FLine has FLine.onClick or FLine.hoverColor set, an interactive SVG is generated instead of using the Skia canvas renderer.

@param w Output SVG width in pixels. @param h Output SVG height in pixels. @param fitToSize If true, uniformly scales and offsets the lines to fit inside wxh with padding. @param splitByChannel If true, each map entry is displayed in its own channel key. @param channel When splitByChannel is false, all lines are combined and displayed here.

body — 50 lines, from line 208
@JvmOverloads
fun Box.lines(
    linesByChannel: Map<String, List<FLine>>,
    w: Double = -1.0,
    h: Double = -1.0,
    fitToSize: Boolean = false,
    splitByChannel: Boolean = false,
    channel: String = "lines",
    editable: Boolean = false,
    drawing: Boolean = false,
    liveDrawing: Boolean = false,
    onEditCommitted: ((List<FLine>) -> Unit)? = null,
): Map<String, EditableFLineViewState> {
    val filteredLinesByChannel = linesByChannel.mapValues { (_, lines) -> lines.renderableLines() }
    val allLines = filteredLinesByChannel.values.flatten()
    val fit = computeFitTransform(allLines, w, h, fitToSize)
    val states = linkedMapOf<String, EditableFLineViewState>()
    if (splitByChannel) {
        filteredLinesByChannel.forEach { (channelName, lines) ->
            val hasInteractive = lines.any { it.onClick != null || it.hoverColor != null }
            if (editable) {
                val pairs = lines.map { it to it.applyFit(fit) }
                val widget = buildEditableSVGWidget(pairs, fit, w, h, drawingEnabled = drawing, liveDrawingEnabled = liveDrawing, onEditCommitted = onEditCommitted)
                display(channelName, widget.html)
                states[channelName] = widget.viewState
            } else if (hasInteractive) {
                val pairs = lines.map { it to it.applyFit(fit) }
                display(channelName, wrapLinesOutput(buildInteractiveSVG(pairs, w, h, fit), pairs.map { it.second }.combinedRenderableBoundsOrNull()))
            } else {
                val scaled = lines.map { it.applyFit(fit) }
                display(channelName, wrapLinesOutput(drawToSVG(scaled, w, h), renderedSvgBoundsFor(scaled, w, h)))
            }
        }
    } else {
        val hasInteractive = allLines.any { it.onClick != null || it.hoverColor != null }
        if (editable) {
            val pairs = allLines.map { it to it.applyFit(fit) }
            val widget = buildEditableSVGWidget(pairs, fit, w, h, drawingEnabled = drawing, liveDrawingEnabled = liveDrawing, onEditCommitted = onEditCommitted)
            display(channel, widget.html)
            states[channel] = widget.viewState
        } else if (hasInteractive) {
            val pairs = allLines.map { it to it.applyFit(fit) }
            display(channel, wrapLinesOutput(buildInteractiveSVG(pairs, w, h, fit), pairs.map { it.second }.combinedRenderableBoundsOrNull()))
        } else {
            val scaled = allLines.map { it.applyFit(fit) }
            display(channel, wrapLinesOutput(drawToSVG(scaled, w, h), renderedSvgBoundsFor(scaled, w, h)))
        }
    }
    return states
}

fun Box.l

Convenience wrapper for example maps.

Uses smaller defaults and fitToSize true so small demos are easy to view.

body — 42 lines, from line 265
fun Box.l(
    m: Map<String, List<FLine>>,
    w: Double = 200.0,
    h: Double = 200.0,
    fitToSize: Boolean = true
) = lines(m, w = w, h = h, fitToSize = fitToSize)

private fun Map<String, List<FLine>>.namespaced(prefix: String): Map<String, List<FLine>> =
    this.mapKeys { (k, _) -> "$prefix/$k" }

private fun FLine.safeFast2DBoundsOrNull() = runCatching { fast2DBounds() }.getOrNull()?.takeIf {
    it.x.isFinite() && it.y.isFinite() && it.w.isFinite() && it.h.isFinite() && it.w >= 0.0 && it.h >= 0.0
}

private fun Collection<FLine>.renderableLines(): List<FLine> =
    filter { it.safeFast2DBoundsOrNull() != null }

private fun Collection<FLine>.combinedRenderableBoundsOrNull() =
    mapNotNull { it.safeFast2DBoundsOrNull() }.reduceOrNull { a, b -> a.union(b) }

private fun emptySvg(w: Double, h: Double): String {
    val width = if (w > 0.0) w else 1.0
    val height = if (h > 0.0) h else 1.0
    return """<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" style="overflow:visible"/>"""
}

private fun wrapLinesOutput(svg: String, bounds: afield.app.protocol.Rect? = null): String {
    val boundsAttrs = bounds?.let {
        """ data-afield-bounds-x="${jsonN(it.x)}" data-afield-bounds-y="${jsonN(it.y)}" data-afield-bounds-w="${jsonN(it.w)}" data-afield-bounds-h="${jsonN(it.h)}""""
    } ?: ""
    return """<div class="afield-lines-output" data-afield-lines-output="true"$boundsAttrs>$svg</div>"""
}

private fun renderedSvgBoundsFor(lines: Collection<FLine>, w: Double, h: Double): afield.app.protocol.Rect? {
    val bounds = lines.combinedRenderableBoundsOrNull() ?: return null
    return afield.app.protocol.Rect(
        if (w <= 0.0) 0.0 else bounds.x,
        if (h <= 0.0) 0.0 else bounds.y,
        bounds.w,
        bounds.h
    )
}

fun Box.showFLineGeometryExamples(w: Double = 200.0, h: Double = 200.0, fitToSize: Boolean = true)

Showcase: render all FLineGeometryExamples with sensible defaults.

Each example is displayed via l so you can quickly inspect the full set.

fun Box.showFLineGeometryExamples(w: Double = 200.0, h: Double = 200.0, fitToSize: Boolean = true) {
    val examples = listOf(
        "spiralCurvature" to FLineGeometryExamples.spiralCurvatureExample(),
        "offsetShape" to FLineGeometryExamples.offsetShapeExample(),
        "booleanOps" to FLineGeometryExamples.booleanOperationsExample(),
        "smoothSimplify" to FLineGeometryExamples.smoothAndSimplifyExample(),
        "tangentNormal" to FLineGeometryExamples.tangentNormalExample(),
        "morphing" to FLineGeometryExamples.morphingExample(),
        "pattern" to FLineGeometryExamples.patternExample(),
        "transformations3D" to FLineGeometryExamples.transformations3DExample(),
        "convexHull" to FLineGeometryExamples.convexHullExample(),
        "adaptiveSampling" to FLineGeometryExamples.adaptiveSamplingExample(),
        "comprehensiveDemo" to FLineGeometryExamples.comprehensiveDemo(),
        "arcs" to FLineGeometryExamples.arcExample(),
        "noise" to FLineGeometryExamples.noiseComparisonExample(),
        "curveCombination" to FLineGeometryExamples.curveCombinationExample(),
        "parameterization" to FLineGeometryExamples.parameterizationExample(),
        "complexComposition" to FLineGeometryExamples.complexCompositionExample(),
        "smileSpline" to FLineGeometryExamples.smileSplineExample(),
        "smileGaussianProcess" to FLineGeometryExamples.smileGaussianProcessExample(),
        "akimaInterpolation" to FLineGeometryExamples.akimaInterpolationExample()
    )

    examples.forEach { (name, m) ->
        l(m.namespaced(name), w = w, h = h, fitToSize = fitToSize)
    }
}

fun drawToSVG(n: List<FLine>, w: Double = -1.0, h: Double = -1.0): String

Render a list of FLines into an SVG string.

If w or h are -1, the SVG size is derived from the line bounds.

body — 41 lines, from line 346
fun drawToSVG(n: List<FLine>, w: Double = -1.0, h: Double = -1.0): String {
    val lines = n.renderableLines()
    if (lines.isEmpty()) return emptySvg(w, h)

    val ba = ByteArrayOutputStream()
    val o = OutputWStream(ba)

    val bounds = lines.combinedRenderableBoundsOrNull() ?: return emptySvg(w, h)
    val autoWidth = w <= 0.0
    val autoHeight = h <= 0.0
    var we = w
    var he = h
    if (autoWidth) we = max(1.0, bounds.w)
    if (autoHeight) he = max(1.0, bounds.h)

    val renderedLines = if (autoWidth || autoHeight) {
        val tx = if (autoWidth) -bounds.x else 0.0
        val ty = if (autoHeight) -bounds.y else 0.0
        lines.map { line ->
            line.transform { p -> vec(p.x + tx, p.y + ty, p.z) }
        }
    } else {
        lines
    }

    var s: Canvas? = null
    try {
        s = SVGCanvas.make(Rect.makeWH(we.toFloat(), he.toFloat()), o, true, true)

        renderedLines.forEach {
            outputTo(it, s)
        }
    } finally {
        runCatching { s?.close() }
        runCatching { o.close() }
    }

    val hack = "xmlns=\"http://www.w3.org/2000/svg\""
    return ba.toString().replace(hack, " style='overflow:visible' " + hack)

}

internal data class FitTransform(val scale: Float, val offsetX: Float, val offsetY: Float)

private fun computeFitTransform(lines: Collection<FLine>, w: Double, h: Double, enabled: Boolean): FitTransform? {
    if (!enabled) return null
    if (lines.isEmpty()) return null
    if (w <= 0.0 || h <= 0.0) return null

    val bounds = lines.combinedRenderableBoundsOrNull() ?: return null
    if (bounds.w <= 0f || bounds.h <= 0f) return null

    val safety = 20f
    val availableW = (w - safety * 2).toFloat()
    val availableH = (h - safety * 2).toFloat()
    if (availableW <= 0f || availableH <= 0f) return null

    val scale = min(availableW / bounds.w, availableH / bounds.h)
    val offsetX = -bounds.x + safety / scale
    val offsetY = -bounds.y + safety / scale

    return FitTransform(scale.toFloat(), offsetX.toFloat(), offsetY.toFloat())
}

private fun FLine.applyFit(transform: FitTransform?): FLine {
    if (transform == null) return this
    val s = transform.scale
    val sx = transform.offsetX
    val sy = transform.offsetY
    val scaled = this.transform { p ->
        vec((p.x + sx) * s, (p.y + sy) * s, p.z)
    }
    val styleScale = s.toDouble().coerceIn(0.0, 1.0)
    this.thickness?.let { scaled.thickness = it * styleScale }
    scaled.pointSize = this.pointSize * styleScale
    scaled.nodes.forEach { node ->
        val pointSize = node.properties["pointSize"] as? Number ?: return@forEach
        node.properties["pointSize"] = pointSize.toDouble() * styleScale
    }
    return scaled
}

fun outputTo(f: FLine, s: Canvas, antiAlias: Boolean = false)

Draw a single FLine to a Skia Canvas, honoring stroke, fill, and point settings.

@param antiAlias smooth the path's edges. Off by default, which is Skia's own default and what the SVG outputs have always used — an SVG is rasterised by the viewer, so asking Skia to antialias one only fattens the emitted geometry. Turn it on when the canvas is a raster surface and the result is the final image, as afield.rect's PNG figures are; without it a diagonal roof edge comes out as a staircase.

body — 79 lines, from line 437
fun outputTo(f: FLine, s: Canvas, antiAlias: Boolean = false) {
    val p = Path()
    val paint = Paint()
    paint.isAntiAlias = antiAlias
    var circ: Path? = null
    try {
        var first = true
        f.nodes.forEach {
            when (it) {
                is FLine.MoveTo -> p.moveTo(it.to.x, it.to.y)
                is FLine.LineTo -> {
                    if (first) {
                        p.moveTo(it.to.x, it.to.y)
                    } else
                        p.lineTo(it.to.x, it.to.y)
                }

                is FLine.CubicTo -> {
                    if (first) {
                        p.moveTo(it.to.x, it.to.y)
                    } else

                        p.cubicTo(
                            it.c1.x,
                            it.c1.y,
                            it.c2.x,
                            it.c2.y,
                            it.to.x,
                            it.to.y
                        )
                }

                is FLine.Close -> p.closePath()
            }
            first = false
        }

        paint.strokeWidth = f.thickness?.toFloat() ?: 1.0f
        val c = f.color
        paint.color4f = Color4f(c.x, c.y, c.z, c.w)
        val mode =
            if (f.stroked && f.filled)
                PaintMode.STROKE_AND_FILL
            else
                if (f.stroked)
                    PaintMode.STROKE
                else if (f.filled) PaintMode.FILL
                else null

        if (mode != null) {
            paint.mode = mode
            s.drawPath(p, paint)
        }

        fun radiusFor(f: FLine, it: FLine.Node) = (it.pointSize ?: f.pointSize) * 3.0

        if (f.pointed) {
            circ = Path()

            f.nodes.forEach {
                when (it) {
                    is FLine.MoveTo -> circ.addCircle(it.to.x, it.to.y, radiusFor(f, it).toFloat())
                    is FLine.LineTo -> circ.addCircle(it.to.x, it.to.y, radiusFor(f, it).toFloat())
                    is FLine.CubicTo -> circ.addCircle(it.to.x, it.to.y, radiusFor(f, it).toFloat())
                    is FLine.Close -> {}
                }
            }

            paint.color4f = Color4f(c.x, c.y, c.z, c.w)
            paint.mode = PaintMode.FILL
            s.drawPath(circ, paint)
        }
    } finally {
        runCatching { circ?.close() }
        runCatching { paint.close() }
        runCatching { p.close() }
    }

}

Interactive SVG renderer (click callbacks + hover colors)

internal fun buildInteractiveSVG(pairs: List<Pair<FLine, FLine>>, w: Double, h: Double, fit: FitTransform? = null): String

Build an SVG string from pairs of (original, scaled) FLines.

For each original FLine that has FLine.onClick set, a unique callback ID is registered via registerFLineClickCallback and the corresponding <g> element gets an onclick attribute that calls window.sendFLineClick(id, hit).

For each original FLine that has FLine.hoverColor set, the <g> element gets onmouseover / onmouseout attributes that call window.__flineHoverIn / window.__flineHoverOut to swap the geometry colour on pointer entry/exit.

A clickable <g> also carries what the browser needs to say what was hit rather than only "this line was": its index in pairs (data-fline-index), its FLine.label, its topology (data-fline-elements — segments and closed subpaths, with node indices) and the picking tolerances (data-fline-pick). All of it in the original, unfitted coordinate space, like the older data-fline-vertices, because that is the space the user's own numbers are in; the browser inverts fit before it picks. __flineIntersectionFromSvg in sliders.js is the consumer, and FLineHitRecordProbeTest executes that very function against the SVG this builds.

body — 64 lines, from line 540
internal fun buildInteractiveSVG(pairs: List<Pair<FLine, FLine>>, w: Double, h: Double, fit: FitTransform? = null): String {
    val renderablePairs = pairs.withIndex()
        .filter { (_, pair) -> pair.second.safeFast2DBoundsOrNull() != null }
        .map { (index, pair) -> Triple(index, pair.first, pair.second) }
    if (renderablePairs.isEmpty()) return emptySvg(w, h)

    val we: Double
    val he: Double
    if (w <= 0.0 || h <= 0.0) {
        val scaledLines = renderablePairs.map { it.third }
        val bounds = scaledLines.combinedRenderableBoundsOrNull() ?: return emptySvg(w, h)
        we = if (w > 0.0) w else max(1.0, max(bounds.w, bounds.x + bounds.w))
        he = if (h > 0.0) h else max(1.0, max(bounds.h, bounds.y + bounds.h))
    } else {
        we = w
        he = h
    }

    val sb = StringBuilder()
    sb.append("""<svg xmlns="http://www.w3.org/2000/svg" width="$we" height="$he" style="overflow:visible"><g vector-effect="non-scaling-stroke">""")
    sb.append(CALIBRATION_RECT)

    renderablePairs.forEach { (lineIndex, original, scaled) ->
        val onClick = original.onClick
        val hoverColor = original.hoverColor
        val hasInteraction = onClick != null || hoverColor != null

        val callbackId: String? = if (onClick != null) {
            val id = "fline-${UUID.randomUUID()}"
            registerFLineClickCallback(id, onClick)
            id
        } else null

        if (hasInteraction) {
            sb.append("<g")
            if (callbackId != null) {
                sb.append(""" data-fline-callback-id="$callbackId"""")
                sb.append(""" data-fline-vertices='${flineVertexData(original)}'""")
                sb.append(""" data-fline-index="$lineIndex"""")
                original.label?.let { sb.append(""" data-fline-label="${svgAttr(it)}"""") }
                sb.append(""" data-fline-elements='${flineTopologyJson(original)}'""")
                sb.append(""" data-fline-pick='${flinePickData(original, fit)}'""")
                if (fit != null) {
                    sb.append(""" data-fit-scale="${jsonN(fit.scale)}" data-fit-offset-x="${jsonN(fit.offsetX)}" data-fit-offset-y="${jsonN(fit.offsetY)}"""")
                }
                sb.append(""" onclick="window.sendFLineClick&&window.sendFLineClick('$callbackId',window.__flineIntersectionFromSvg&&window.__flineIntersectionFromSvg(event,this))" style="cursor:pointer"""")
            }
            if (hoverColor != null) {
                val hc = vec4ToSvgColor(hoverColor)
                sb.append(""" data-hover-color="$hc"""")
                sb.append(""" onmouseover="window.__flineHoverIn&&window.__flineHoverIn(this)"""")
                sb.append(""" onmouseout="window.__flineHoverOut&&window.__flineHoverOut(this)"""")
            }
            sb.append(">")
        }

        renderFLineToSVG(scaled, sb)

        if (hasInteraction) sb.append("</g>")
    }

    sb.append("</g></svg>")
    return sb.toString()
}

Build compact endpoint metadata for SVG click picking: [x, y, z, originalNodeIndex].

private fun flineVertexData(f: FLine): String {
    val vertices = f.nodes.mapIndexedNotNull { nodeIndex, node ->
        val p = when (node) {
            is FLine.MoveTo -> node.to
            is FLine.LineTo -> node.to
            is FLine.CubicTo -> node.to
            is FLine.Close -> null
        } ?: return@mapIndexedNotNull null
        "[${jsonN(p.x)},${jsonN(p.y)},${jsonN(p.z)},$nodeIndex]"
    }
    return vertices.joinToString(prefix = "[", postfix = "]")
}

private fun jsonN(v: Number): String {
    val d = v.toDouble()
    if (!d.isFinite()) return "0"
    return String.format(Locale.US, "%.6f", d).trimEnd('0').trimEnd('.').ifEmpty { "0" }
}

private fun jsonPoint(p: Vec3): String = "[${jsonN(p.x)},${jsonN(p.y)},${jsonN(p.z)}]"

Escape a string for use inside a double-quoted SVG/HTML attribute.

private fun svgAttr(value: String): String = value
    .replace("&", "&amp;")
    .replace("<", "&lt;")
    .replace(">", "&gt;")
    .replace("\"", "&quot;")

internal fun flineTopologyJson(f: FLine): String

The line's topology, for a browser that has to name the element a click landed on.

One producer for both clickable outputs. The interactive SVG puts this in data-fline-elements and the three.js viewer gets the same string in ThreeInteractiveObject.topology, so the two front ends cannot describe the same line differently — only their picking differs, which it must, because one has a click in a plane and the other has a raycast hit that already knows whether it landed on a line, a fill or a point.

Two lists, both in the line's own coordinates:

  • segments, one per drawn segment, in draw order. k is l (straight), c (cubic) or z (the segment a close draws back to the subpath's first anchor); n is the index in nodes of the node that draws the segment; p is the subpath (moveTo-delimited piece) it belongs to; e is its ordinal among the line's segments; a/b are its ends, na/nb those ends' own node indices (which for a close are the last and the first anchor, not n), and c1/c2 a cubic's controls.
  • faces, one per closed subpath, as the polygon through that subpath's anchors — cubics contribute their endpoint, not their curve, which is what makes the four-corner test below mean "a quad panel" rather than "a shape with four straight sides". n is the close node's index.

A leading lineTo/cubicTo with no moveTo before it starts a subpath and draws nothing, exactly as flinePathData and outputTo treat it — otherwise the browser would name segments the renderer never drew.

body — 99 lines, from line 659
internal fun flineTopologyJson(f: FLine): String {
    val segments = StringBuilder()
    val faces = StringBuilder()
    var segmentCount = 0
    var faceCount = 0
    var subPath = -1
    var current: Vec3? = null
    var currentNode = -1
    var subPathStart: Vec3? = null
    var subPathStartNode = -1
    val anchors = mutableListOf<Vec3>()
    var closeNode = -1

    fun segment(
        kind: String,
        nodeIndex: Int,
        a: Vec3,
        b: Vec3,
        nodeA: Int,
        nodeB: Int,
        c1: Vec3? = null,
        c2: Vec3? = null
    ) {
        if (segmentCount > 0) segments.append(',')
        segments.append("""{"k":"$kind","n":$nodeIndex,"p":$subPath,"e":$segmentCount""")
        segments.append(""","na":$nodeA,"nb":$nodeB""")
        segments.append(""","a":${jsonPoint(a)},"b":${jsonPoint(b)}""")
        if (c1 != null && c2 != null) segments.append(""","c1":${jsonPoint(c1)},"c2":${jsonPoint(c2)}""")
        segments.append('}')
        segmentCount++
    }

    fun face() {
        if (anchors.size < 3) return
        if (faceCount > 0) faces.append(',')
        faces.append("""{"n":$closeNode,"p":$subPath,"e":$faceCount,"pts":[""")
        faces.append(anchors.joinToString(",") { jsonPoint(it) })
        faces.append("]}")
        faceCount++
    }

    fun begin(at: Vec3, nodeIndex: Int) {
        subPath++
        current = at
        currentNode = nodeIndex
        subPathStart = at
        subPathStartNode = nodeIndex
        anchors.clear()
        anchors.add(at)
    }

    f.nodes.forEachIndexed { nodeIndex, node ->
        when (node) {
            is FLine.MoveTo -> begin(node.to, nodeIndex)
            is FLine.LineTo -> {
                val from = current
                if (from == null) begin(node.to, nodeIndex)
                else {
                    segment("l", nodeIndex, from, node.to, currentNode, nodeIndex)
                    current = node.to
                    currentNode = nodeIndex
                    anchors.add(node.to)
                }
            }
            is FLine.CubicTo -> {
                val from = current
                if (from == null) begin(node.to, nodeIndex)
                else {
                    segment("c", nodeIndex, from, node.to, currentNode, nodeIndex, node.c1, node.c2)
                    current = node.to
                    currentNode = nodeIndex
                    anchors.add(node.to)
                }
            }
            is FLine.Close -> {
                val from = current
                val start = subPathStart
                if (from != null && start != null) {
                    closeNode = nodeIndex
                    // A subpath whose last anchor already coincides with its first draws no closing
                    // segment, but it is still a face.
                    if (from.x != start.x || from.y != start.y || from.z != start.z) {
                        segment("z", nodeIndex, from, start, currentNode, subPathStartNode)
                    } else if (anchors.size > 1) {
                        anchors.removeAt(anchors.size - 1)
                    }
                    face()
                }
                current = null
                currentNode = -1
                subPathStart = null
                subPathStartNode = -1
                anchors.clear()
            }
        }
    }

    return """{"segments":[$segments],"faces":[$faces]}"""
}

How close, in the line's own units, a click has to be to count as hitting a node or a segment.

Chosen in screen pixels and divided by the fit scale, because that is where the user's finger is: a figure scaled down to a thumbnail should not need sub-unit precision to click. A node wins over a segment and a segment over a face, so these two numbers are the whole picking policy — PICK_POINT_PX deliberately small (4px) so that clicking the rim of a filled shape still reads as its edge rather than as whichever tessellation vertex happened to be nearest.

private fun flinePickData(f: FLine, fit: FitTransform?): String {
    val scale = fit?.scale?.toDouble()?.takeIf { it > 0.0 } ?: 1.0
    val pointPx = if (f.pointed) max(f.pointSize * 3.0, PICK_POINT_PX) else PICK_POINT_PX
    val edgePx = max((f.thickness ?: 1.0) / 2.0, PICK_EDGE_PX)
    return """{"point":${jsonN(pointPx / scale)},"edge":${jsonN(edgePx / scale)}}"""
}

private const val PICK_POINT_PX = 4.0
private const val PICK_EDGE_PX = 4.0

A painted-nothing square of known size, so the browser can measure how the page is scaling this SVG.

Measured on a live canvas, 2026-08-10: an interactive figure's clicks were arriving in the wrong place — a click in the middle of one panel reconstructed to a point three times too far up the figure, and therefore named the wrong element. The cause is not in the picking: Field's output area gives the emitted <svg> width: 100% and the canvas scales the box with CSS, and SVGSVGElement.getScreenCTM() — which __flineIntersectionFromSvg used on its own — reports the matrix inside the SVG only, so the canvas's own scale was missing from the inverse. Nobody had noticed because until this commit nothing read the position: a click's meaning came entirely from which callback fired, which is right whatever the coordinates say.

getBoundingClientRect() of this rectangle, against its known 100×100 user units, measures the whole chain at once — viewBox, CSS transform, CSS zoom, device pixel ratio — with two divisions and no assumptions. It is pointer-events="none" so it can never take a click, and paints nothing.

private const val CALIBRATION_RECT =
    """<rect data-fline-calibration="100" x="0" y="0" width="100" height="100" fill="none" stroke="none" pointer-events="none"/>"""

internal fun vec4ToSvgColor(c: Vec4): String

Convert a Vec4 RGBA colour (components in [0,1]) to an SVG rgba(...) string.

internal fun vec4ToSvgColor(c: Vec4): String {
    val r = (c.x * 255).toInt().coerceIn(0, 255)
    val g = (c.y * 255).toInt().coerceIn(0, 255)
    val b = (c.z * 255).toInt().coerceIn(0, 255)
    return "rgba($r,$g,$b,${c.w})"
}

Format a floating-point coordinate to a compact SVG number string.

private fun svgN(v: Float): String {
    if (!v.isFinite()) return "0"
    return "%.3f".format(v).trimEnd('0').trimEnd('.')
}

Build the SVG path d attribute from the nodes of f.

private fun flinePathData(f: FLine): String {
    val sb = StringBuilder()
    var first = true
    f.nodes.forEach { node ->
        when (node) {
            is FLine.MoveTo -> {
                sb.append("M${svgN(node.to.x)},${svgN(node.to.y)} ")
                first = false
            }
            is FLine.LineTo -> {
                if (first) sb.append("M${svgN(node.to.x)},${svgN(node.to.y)} ")
                else sb.append("L${svgN(node.to.x)},${svgN(node.to.y)} ")
                first = false
            }
            is FLine.CubicTo -> {
                if (first) sb.append("M${svgN(node.to.x)},${svgN(node.to.y)} ")
                else sb.append(
                    "C${svgN(node.c1.x)},${svgN(node.c1.y)} " +
                    "${svgN(node.c2.x)},${svgN(node.c2.y)} " +
                    "${svgN(node.to.x)},${svgN(node.to.y)} "
                )
                first = false
            }
            is FLine.Close -> sb.append("Z ")
        }
    }
    return sb.toString().trimEnd()
}

Append SVG <path> and/or <circle> elements for f to sb. Uses presentation attributes so that CSS hover rules (applied by the JS helpers in sliders.js) can override stroke/fill without fighting inline style specificity.

private fun renderFLineToSVG(f: FLine, sb: StringBuilder) {
    val color = f.color
    val colorStr = vec4ToSvgColor(color)
    val strokeWidth = svgN((f.thickness ?: 1.0).toFloat())
    val pathData = flinePathData(f)

    if (pathData.isNotEmpty()) {
        when {
            f.stroked && f.filled -> {
                sb.append("""<path d="$pathData" stroke="$colorStr" stroke-width="$strokeWidth" fill="$colorStr" vector-effect="non-scaling-stroke"/>""")
            }
            f.stroked -> {
                sb.append("""<path d="$pathData" stroke="$colorStr" stroke-width="$strokeWidth" fill="none" vector-effect="non-scaling-stroke"/>""")
            }
            f.filled -> {
                sb.append("""<path d="$pathData" stroke="none" fill="$colorStr"/>""")
            }
        }
    }

    if (f.pointed) {
        f.nodes.forEach { node ->
            val to = when (node) {
                is FLine.MoveTo -> node.to
                is FLine.LineTo -> node.to
                is FLine.CubicTo -> node.to
                is FLine.Close -> null
            } ?: return@forEach
            val r = svgN(((node.pointSize ?: f.pointSize) * 3.0).toFloat())
            sb.append("""<circle cx="${svgN(to.x)}" cy="${svgN(to.y)}" r="$r" fill="$colorStr" vector-effect="non-scaling-stroke"/>""")
        }
    }
}