MorphoFLines.kt
src/jvmMain/kotlin/afield/lang/morpho/MorphoFLines.kt · 318 lines
package afield.lang.morpho
import afield.app.protocol.Rect
import afield.geometry.path.FLine
import afield.graphics.color.Colormaps
import afield.math.linalg.Vec4
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.render.pointSize
import afield.graphics.render.pointed
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
import kotlin.math.cos
import kotlin.math.sin
Drawing a MorphoHDL circuit with Field's FLines.
This is the half of the bridge morpho does not supply and Field does. Upstream renders with
SwissGL and a Canvas 2D fallback (viewer.js, layout_renderer.js, bloom.js — about 1,900
lines), none of which is vendored. Field already draws graphs, so a circuit becomes one FLine
per wire and one point-set per gate, and everything downstream — SVG export, the plane grid,
animation, the docs capture pipeline — comes along for free.
Colour carries the simulation
By default wires are coloured by signal arrival time, which is what makes the picture worth looking at rather than a hairball: the inputs are one end of the ramp, the critical path is the other, and the difference between a ripple adder and a Brent–Kung is visible as a gradient rather than inferable from a gate count.
fun morphoCircuit
Compile a MorphoHDL design, ready to grow.
A shorthand for MorphoCircuit.compile that takes the signature as varargs, so a box reads
morphoCircuit("brent_kung_adder", 32, 32, 1) rather than spelling out an intArrayOf.
source defaults to morpho's own standard library, which already defines every design named on
this page.
import afield.lang.morpho.*
val c = morphoCircuit("brent_kung_adder", 32, 32, 1)
c.growFully(relaxSteps = 250)
showPlaneGrid(c.summary() to c.flines(), window = morphoPlaneWindow(c.planeBounds()))
fun morphoCircuit(
design: String,
vararg signature: Int,
source: String = MorphoRuntime.standardLibrary,
optimize: Boolean = true,
seed: Int = 1337,
): MorphoCircuit = MorphoCircuit.compile(source, design, signature, optimize, seed)
fun interface MorphoProjector
Flattens a 3D layout position to the plane a drawing lives in.
fun project(x: Float, y: Float, z: Float): Pair<Double, Double>
enum class MorphoProjection : MorphoProjector
The three fixed views worth having names.
enum class MorphoProjection : MorphoProjector {
Drop z. Directly comparable with morpho's own 2D renderer, which pins z to zero anyway.
XY,
Isometric. The default, because the layout is genuinely 3D and XY throws that away.
ISOMETRIC,
Look down the y axis — the view in which a "flat" layout would be a horizontal line.
XZ,
;
override fun project(x: Float, y: Float, z: Float): Pair<Double, Double>
override fun project(x: Float, y: Float, z: Float): Pair<Double, Double> = when (this) {
XY -> x.toDouble() to y.toDouble()
XZ -> x.toDouble() to z.toDouble()
ISOMETRIC -> morphoOrbit(0.7853982).project(x, y, z)
}
}
fun morphoOrbit(azimuth: Double, elevation: Double = 0.5235988): MorphoProjector
A view from an arbitrary angle around the vertical axis.
Turning azimuth over a launched box's frames orbits the camera, which is the only honest way to
show in a still medium that the layout is a solid: a projection of a plane collapses to a line
once per half turn, and a projection of a solid never does.
@param azimuth radians around y. @param elevation how far the camera is lifted; 0 looks along the horizon.
fun morphoOrbit(azimuth: Double, elevation: Double = 0.5235988): MorphoProjector =
MorphoProjector { x, y, z ->
val ca = cos(azimuth)
val sa = sin(azimuth)
val rx = x * ca - z * sa
val rz = x * sa + z * ca
rx to y + rz * sin(elevation)
}
fun MorphoCircuit.planeBounds
Projected bounds of a laid-out circuit as [xMin, yMin, xMax, yMax], square and padded.
Two figures drawn without a shared window are drawn at two different scales, and a comparison
between them is worthless — the same trap afield.data.graph.dynamicGraphWindow exists to avoid.
Pass the result through morphoPlaneWindow and hand that to
afield.geometry.spatial.showPlaneGrid for every panel.
For an animation, union successive calls with morphoWindowUnion so the frame only ever grows: a window recomputed per frame around a circuit that is still expanding jitters on every step.
fun MorphoCircuit.planeBounds(
projection: MorphoProjector = MorphoProjection.ISOMETRIC,
padFraction: Double = 0.08,
): DoubleArray {
val pos = layout.positions()
val n = layout.particleCount
var xMin = Double.POSITIVE_INFINITY
var yMin = Double.POSITIVE_INFINITY
var xMax = Double.NEGATIVE_INFINITY
var yMax = Double.NEGATIVE_INFINITY
for (p in 0 until n) {
val (x, y) = projection.project(pos[p * 3], pos[p * 3 + 1], pos[p * 3 + 2])
if (!x.isFinite() || !y.isFinite()) continue
if (x < xMin) xMin = x
if (x > xMax) xMax = x
if (y < yMin) yMin = y
if (y > yMax) yMax = y
}
if (!xMin.isFinite()) return doubleArrayOf(-1.0, -1.0, 1.0, 1.0)
val cx = 0.5 * (xMin + xMax)
val cy = 0.5 * (yMin + yMax)
// Square, so a panel never stretches the circuit on one axis.
val half = maxOf(1e-9, 0.5 * maxOf(xMax - xMin, yMax - yMin)) * (1.0 + padFraction)
return doubleArrayOf(cx - half, cy - half, cx + half, cy + half)
}
fun morphoWindowUnion(a: DoubleArray?, b: DoubleArray): DoubleArray
The smallest window containing both, for a frame that grows with the circuit but never shrinks.
fun morphoWindowUnion(a: DoubleArray?, b: DoubleArray): DoubleArray {
if (a == null) return b
return doubleArrayOf(minOf(a[0], b[0]), minOf(a[1], b[1]), maxOf(a[2], b[2]), maxOf(a[3], b[3]))
}
fun morphoUnitWindow(): Rect = Rect(-1.08, -1.08, 2.16, 2.16)
The window for panels drawn with fit = true, which normalise themselves into the unit square.
Slightly larger than the unit square so a port dot on the boundary is not clipped in half.
fun morphoPlaneWindow(window: DoubleArray): Rect
A planeBounds result as the Rect afield.geometry.spatial.showPlaneGrid wants.
y is negated to match morphoFLines, which negates it exactly once when it emits geometry.
fun morphoPlaneWindow(window: DoubleArray): Rect {
require(window.size == 4) { "morphoPlaneWindow: window must be [xMin, yMin, xMax, yMax]" }
require(window[2] > window[0] && window[3] > window[1]) {
"morphoPlaneWindow: window must be non-degenerate, got ${window.toList()}"
}
return Rect(window[0], -window[3], window[2] - window[0], window[3] - window[1])
}
fun MorphoCircuit.flines
One FLine per wire, plus one point-set per cell type.
@param colormap sampled by normalised arrival time; "magma" matches the rest of Field's graph
documentation.
@param byDepth colour wires by signal arrival time. With this off every wire is drawn at the
top of the ramp, which is what you want when the picture is about topology alone.
@param minIntensity how dark the earliest wires get. Zero makes the input side vanish into the
background, which reads as a hole rather than as an input.
@param gateSize dot radius for LUT cells, in the units FLine uses for points.
@param portSize dot radius for the design's input and output ports, drawn larger because they
are the handles you reason about.
fun MorphoCircuit.flines(
projection: MorphoProjector = MorphoProjection.ISOMETRIC,
colormap: String = "magma",
byDepth: Boolean = true,
minIntensity: Double = 0.15,
thinWire: Double = 0.7,
thickWire: Double = 1.6,
gateSize: Double = 0.45,
portSize: Double = 1.1,
fit: Boolean = false,
): List<FLine> = morphoFLines(
netlist = netlist,
timing = timing,
positions = layout.positions(),
cellIndices = layout.cellIndices(),
projection = projection,
colormap = colormap,
byDepth = byDepth,
minIntensity = minIntensity,
thinWire = thinWire,
thickWire = thickWire,
gateSize = gateSize,
portSize = portSize,
fit = fit,
)
fun morphoFLines
The same drawing from raw parts, for callers holding a netlist and positions from somewhere other than MorphoCircuit — a recorded layout, say, or Field's own graph systems standing in for the WASM kernel.
body — 108 lines, from line 210
fun morphoFLines(
netlist: MorphoNetlist,
timing: MorphoTiming?,
positions: FloatArray,
cellIndices: IntArray,
projection: MorphoProjector = MorphoProjection.ISOMETRIC,
colormap: String = "magma",
byDepth: Boolean = true,
minIntensity: Double = 0.15,
thinWire: Double = 0.7,
thickWire: Double = 1.6,
gateSize: Double = 0.45,
portSize: Double = 1.1,
fit: Boolean = false,
): List<FLine> {
require(minIntensity in 0.0..1.0) { "morphoFLines: minIntensity must be in 0..1, found $minIntensity" }
require(thinWire > 0.0 && thickWire > 0.0) { "morphoFLines: wire widths must be > 0" }
require(positions.size >= cellIndices.size * 3) {
"morphoFLines: ${cellIndices.size} particles need ${cellIndices.size * 3} coordinates, got ${positions.size}"
}
// Cell index -> plane position. Cells with no particle (inactive) are simply absent.
val plane = HashMap<Int, Pair<Double, Double>>(cellIndices.size * 2)
for (p in cellIndices.indices) {
val cell = cellIndices[p]
// y is negated exactly once, here, to match Field's screen-space convention for figures.
val (x, y) = projection.project(positions[p * 3], positions[p * 3 + 1], positions[p * 3 + 2])
plane[cell] = x to -y
}
// `showPlaneGrid` frames every panel with ONE window, so comparing four circuits of four
// different sizes in one grid means normalising each into the same unit square here — there is
// nowhere downstream to do it per panel. Off by default: rescaling silently destroys the one
// thing a shared window is for, which is making two panels commensurable.
if (fit && plane.isNotEmpty()) {
var xLo = Double.POSITIVE_INFINITY; var xHi = Double.NEGATIVE_INFINITY
var yLo = Double.POSITIVE_INFINITY; var yHi = Double.NEGATIVE_INFINITY
plane.values.forEach { (x, y) ->
if (x < xLo) xLo = x; if (x > xHi) xHi = x
if (y < yLo) yLo = y; if (y > yHi) yHi = y
}
val cx = 0.5 * (xLo + xHi)
val cy = 0.5 * (yLo + yHi)
val half = 0.5 * maxOf(xHi - xLo, yHi - yLo)
if (half > 1e-9) plane.replaceAll { _, v -> (v.first - cx) / half to (v.second - cy) / half }
}
val maxTime = if (byDepth && timing != null) {
netlist.activeCells.maxOfOrNull { timing.last(it) }?.takeIf { it > 0f } ?: 1f
} else 1f
val out = ArrayList<FLine>()
val edges = netlist.edges()
var i = 0
while (i < edges.size) {
val src = edges[i]
val dst = edges[i + 1]
i += 3
val a = plane[src] ?: continue
val b = plane[dst] ?: continue
// A wire's colour is when its *driver* settles, so a wire reads as "this value is known by
// now" rather than as a property of whatever happens to consume it.
val t = if (byDepth && timing != null) {
(timing.last(src) / maxTime).coerceIn(0f, 1f).toDouble()
} else 1.0
val ramp = minIntensity + (1.0 - minIntensity) * t
val c = Colormaps.sample(colormap, ramp)
out.add(
fline {
moveTo(a.first, a.second)
lineTo(b.first, b.second)
this.color = vec(c.r, c.g, c.b, 1.0)
this.thickness = thinWire + (thickWire - thinWire) * t
this.stroked = true
this.filled = false
}
)
}
fun points(cells: List<Int>, size: Double, colour: Vec4) {
if (cells.isEmpty() || size <= 0.0) return
out.add(
fline {
cells.forEach { c -> plane[c]?.let { (x, y) -> moveTo(x, y) } }
this.color = colour
this.pointed = true
this.pointSize = size
this.stroked = false
this.filled = false
}
)
}
val gates = netlist.activeCells.filter { netlist.isLut(it) }
val inputs = netlist.inputCells.filter { netlist.isActive(it) }
val outputs = netlist.outputCells.filter { netlist.isActive(it) }
val top = Colormaps.sample(colormap, 1.0)
points(gates, gateSize, vec(top.r, top.g, top.b, 0.9))
// Ports are drawn off the colormap so they never read as "just another gate at some depth".
points(inputs, portSize, vec(0.35, 0.85, 1.0, 0.95))
points(outputs, portSize, vec(1.0, 0.55, 0.25, 0.95))
return out
}