Skip to content

alignment.kt

src/jvmMain/kotlin/afield/ui/layout/alignment.kt · 1204 lines

package afield.ui.layout

import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.long
import afield.ui.notation.with
import afield.ui.visualization.Align

import afield.app.protocol.Rect
import afield.audio.synth.*
import afield.document.graph.Box
import afield.document.graph.layers
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.lifecycle.RunsAt
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.newRoot
import afield.ui.output.templates.filter
import afield.ui.visualization.h

import afield.app.commands.Action
import afield.math.linalg.Vec2
import afield.app.commands.actions
import afield.math.linalg.vec
import kotlin.math.*

Geometric tidying of box frames on the canvas: aligning, snapping and connection-guided routing over a list of Rects, plus the palette actions that apply them to a selection.

Three entry points, each taking rectangles and returning new ones in the same order:

  • alignAndDistribute lines the rectangles up in bands, vertically or horizontally (Orientation), spaced by LayoutParams.
  • snapAndTidyLocal snaps near-equal sizes and edges together, snaps positions, then pushes overlapping rectangles apart.
  • routeAndTidyLocal also takes the connections between rectangles as EdgeIds. It snaps sizes and edges, arranges the rectangles in layers along those edges (RouteParams), then runs the tidy pass and an optional final columnizing pass.

snapAndTidyLocal and routeAndTidyLocal can translate their result back so the group's centroid (CentroidMode) does not move: preserve for the first, preserveCentroid in RouteParams for the second. installAlignment runs on each new root and adds the align, snap_tidy and route actions, which call these with fixed parameters and write the results to the selected boxes' frames; align also restores the centroid.

enum class Orientation { VERTICAL, HORIZONTAL }

The axis boxes are lined up along.

enum class IntraDistribution { EQUAL_GAPS, PACK_MIN_MOVE }

data class LayoutParams

body — 63 lines, from line 61
data class LayoutParams(
    val intraGap: Double = 0.0,
    val crossGap: Double = 16.0,
    val verticalPreferenceRatio: Double = 0.95,
    val singleBandOverfillTolerance: Double = 0.08,
    val intraDistribution: IntraDistribution = IntraDistribution.EQUAL_GAPS
)

private fun distributeEqualGapsWithinSpan(
    spanMin: Double, spanMax: Double,
    sizes: DoubleArray,
    minGap: Double
): DoubleArray? {
    val n = sizes.size
    val span = spanMax - spanMin
    val total = sizes.sum()
    val base = total + (n - 1) * minGap
    if (span + 1e-9 < base) return null
    val extra = span - base
    val gap = minGap + extra / (n + 1)
    val starts = DoubleArray(n)
    var cursor = spanMin + gap
    for (i in 0 until n) {
        starts[i] = cursor
        cursor += sizes[i] + gap
    }
    return starts
}

private fun <T> partitionSequential(
    items: List<T>,
    size: (T) -> Double,
    available: Double,
    minGap: Double
): List<List<T>> {
    if (items.isEmpty()) return emptyList()
    if (available <= 0.0) return items.map { listOf(it) }

    val bands = mutableListOf<MutableList<T>>()
    var current = mutableListOf<T>()
    var usedSize = 0.0

    fun pushBand() {
        if (current.isNotEmpty()) bands += current
        current = mutableListOf()
        usedSize = 0.0
    }

    for (it in items) {
        val s = size(it)
        val newUsed = if (current.isEmpty()) s else usedSize + minGap + s
        if (newUsed <= available + 1e-9 || current.isEmpty()) {
            current += it
            usedSize = newUsed
        } else {
            pushBand()
            current += it
            usedSize = s
        }
    }
    pushBand()
    return bands
}

fun alignAndDistribute

body — 194 lines, from line 125
fun alignAndDistribute(
    original: List<Rect>,
    params: LayoutParams = LayoutParams()
): List<Rect> {
    if (original.isEmpty()) return emptyList()
    if (original.size == 1) return listOf(original[0].copy())

    val orientation = inferOrientation(original, params.verticalPreferenceRatio)
    return when (orientation) {
        Orientation.VERTICAL -> layoutVertical(original, params)
        Orientation.HORIZONTAL -> layoutHorizontal(original, params)
    }
}

private fun inferOrientation(rects: List<Rect>, verticalPreferenceRatio: Double): Orientation {
    val xs = rects.map { it.cx }
    val ys = rects.map { it.cy }
    val sx = stdev(xs)
    val sy = stdev(ys)
    return if (sx < sy * verticalPreferenceRatio) Orientation.VERTICAL else Orientation.HORIZONTAL
}

private fun stdev(values: List<Double>): Double {
    if (values.isEmpty()) return 0.0
    val mean = values.sum() / values.size
    val varSum = values.sumOf { (it - mean) * (it - mean) }
    return sqrt(varSum / values.size)
}

private fun median(values: List<Double>): Double {
    if (values.isEmpty()) return 0.0
    val s = values.sorted()
    val n = s.size
    return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
}

private data class PackResult(val starts: DoubleArray, val fitsInBounds: Boolean)

private fun pack1D(
    desiredStarts: DoubleArray,
    sizes: DoubleArray,
    minGap: Double,
    minBound: Double? = null,
    maxBound: Double? = null
): PackResult {
    val n = desiredStarts.size
    if (n == 0) return PackResult(DoubleArray(0), true)

    val starts = DoubleArray(n)
    for (i in 0 until n) {
        starts[i] = if (i == 0) desiredStarts[0]
        else max(desiredStarts[i], starts[i - 1] + sizes[i - 1] + minGap)
    }

    var deltaOpt = 0.0
    for (i in 0 until n) deltaOpt += (desiredStarts[i] - starts[i])
    deltaOpt /= n.toDouble()

    var deltaMin = Double.NEGATIVE_INFINITY
    var deltaMax = Double.POSITIVE_INFINITY
    if (minBound != null) deltaMin = minBound - starts[0]
    if (maxBound != null) deltaMax = maxBound - (starts[n - 1] + sizes[n - 1])

    val eps = 1e-9
    val feasible = deltaMin <= deltaMax + eps

    val deltaRaw = when {
        feasible -> deltaOpt.coerceIn(deltaMin, deltaMax)
        deltaOpt < deltaMin -> deltaMin
        deltaOpt > deltaMax -> deltaMax
        else -> deltaMin
    }

    val delta = if (abs(deltaRaw) < 1e-6) 0.0 else deltaRaw
    for (i in 0 until n) starts[i] += delta

    return PackResult(starts, feasible)
}

private data class IdxRect(val idx: Int, val r: Rect)

private fun layoutVertical(original: List<Rect>, params: LayoutParams): List<Rect> {
    val n = original.size
    val src = original.mapIndexed { i, r -> IdxRect(i, r.copy()) }

    val minTop = src.minOf { it.r.top }
    val maxBottom = src.maxOf { it.r.bottom }
    val spanY = (maxBottom - minTop).coerceAtLeast(0.0)

    val byY = src.sortedWith(compareBy<IdxRect> { it.r.top }.thenBy { it.idx })

    val sumH = byY.sumOf { it.r.h }
    val reqOne = sumH + (byY.size - 1) * params.intraGap
    val keepSingle = spanY > 0.0 && reqOne <= spanY * (1.0 + params.singleBandOverfillTolerance)

    val columns: List<List<IdxRect>> =
        if (keepSingle) listOf(byY)
        else partitionSequential(byY, { it.r.h }, spanY, params.intraGap)

    val colWidths = columns.map { col -> col.maxOf { it.r.w } }.toDoubleArray()
    val baselineLeft = median(src.map { it.r.left })

    val colDesired = DoubleArray(colWidths.size)
    run {
        var cursor = baselineLeft
        for (i in colDesired.indices) {
            colDesired[i] = cursor
            cursor += colWidths[i] + params.crossGap
        }
    }
    val colLefts = pack1D(colDesired.copyOf(), colWidths, params.crossGap, baselineLeft, null).starts

    val out = Array<Rect?>(n) { null }
    for ((ci, col) in columns.withIndex()) {
        val sizes = col.map { it.r.h }.toDoubleArray()
        val tops = when (params.intraDistribution) {
            IntraDistribution.EQUAL_GAPS -> {
                distributeEqualGapsWithinSpan(minTop, maxBottom, sizes, params.intraGap)
                    ?: run {
                        val desired = col.map { it.r.y }.toDoubleArray()
                        pack1D(desired, sizes, params.intraGap, null, null).starts
                    }
            }
            IntraDistribution.PACK_MIN_MOVE -> {
                val desired = col.map { it.r.y }.toDoubleArray()
                pack1D(desired, sizes, params.intraGap, minTop, maxBottom).starts
            }
        }

        val x = colLefts[ci]
        for (i in col.indices) {
            val ir = col[i]
            out[ir.idx] = Rect(x = x, y = tops[i], w = ir.r.w, h = ir.r.h)
        }
    }
    return out.map { it!! }
}

private fun layoutHorizontal(original: List<Rect>, params: LayoutParams): List<Rect> {
    val n = original.size
    val src = original.mapIndexed { i, r -> IdxRect(i, r.copy()) }

    val minLeft = src.minOf { it.r.left }
    val maxRight = src.maxOf { it.r.right }
    val spanX = (maxRight - minLeft).coerceAtLeast(0.0)

    val byX = src.sortedWith(compareBy<IdxRect> { it.r.left }.thenBy { it.idx })

    val sumW = byX.sumOf { it.r.w }
    val reqOne = sumW + (byX.size - 1) * params.intraGap
    val keepSingle = spanX > 0.0 && reqOne <= spanX * (1.0 + params.singleBandOverfillTolerance)

    val rows: List<List<IdxRect>> =
        if (keepSingle) listOf(byX)
        else partitionSequential(byX, { it.r.w }, spanX, params.intraGap)

    val rowHeights = rows.map { row -> row.maxOf { it.r.h } }.toDoubleArray()
    val baselineTop = median(src.map { it.r.top })

    val rowDesired = DoubleArray(rowHeights.size)
    run {
        var cursor = baselineTop
        for (i in rowDesired.indices) {
            rowDesired[i] = cursor
            cursor += rowHeights[i] + params.crossGap
        }
    }
    val rowTops = pack1D(rowDesired.copyOf(), rowHeights, params.crossGap, baselineTop, null).starts

    val out = Array<Rect?>(n) { null }
    for ((ri, row) in rows.withIndex()) {
        val sizes = row.map { it.r.w }.toDoubleArray()
        val lefts = when (params.intraDistribution) {
            IntraDistribution.EQUAL_GAPS -> {
                distributeEqualGapsWithinSpan(minLeft, maxRight, sizes, params.intraGap)
                    ?: run {
                        val desired = row.map { it.r.x }.toDoubleArray()
                        pack1D(desired, sizes, params.intraGap, null, null).starts
                    }
            }
            IntraDistribution.PACK_MIN_MOVE -> {
                val desired = row.map { it.r.x }.toDoubleArray()
                pack1D(desired, sizes, params.intraGap, minLeft, maxRight).starts
            }
        }

        val top = rowTops[ri]
        for (i in row.indices) {
            val ir = row[i]
            out[ir.idx] = Rect(x = lefts[i], y = top, w = ir.r.w, h = ir.r.h)
        }
    }
    return out.map { it!! }
}

Snap/Tidy + Route (with aggressive final "columnize" + NEW preserve-centroid offset)

enum class RouteAxis { HORIZONTAL, VERTICAL }

data class RouteParams

data class RouteParams(
    val layerGap: Double = 240.0,
    val nodeGap: Double = 36.0,

    val inertia: Double = 0.15,
    val maxMove: Double = 10000.0,

    val origin: Vec2 = vec(0.0, 0.0),
    val preferNearOrigin: Double = 1.5,
    val preferInDegree0: Double = 3.0,
    val preferOutDegree: Double = 0.25,

    val axis: RouteAxis = RouteAxis.HORIZONTAL,
    val directionFromPositions: Boolean = true,

    val crossingIterations: Int = 12,
    val orthoRelaxIterations: Int = 10,
    val orthoBaryWeight: Double = 0.9,

    val dummyW: Double = 12.0,
    val dummyH: Double = 12.0,

    val finalColumnize: Boolean = true,
    val finalColumnizePasses: Int = 1,

    // NEW: keep overall placement close by restoring centroid at the end
    val preserveCentroid: Boolean = true,
    val centroidMode: CentroidMode = CentroidMode.AREA_WEIGHTED
)

enum class GuideMode1D { MEAN, MEDIAN }

enum class EdgeAnchor { LEFT, RIGHT, TOP, BOTTOM }

data class SizeSnapParams

data class SizeSnapParams(
    val widthSnapTol: Double = 14.0,
    val heightSnapTol: Double = 14.0,
    val mode: GuideMode1D = GuideMode1D.MEDIAN,
    val minW: Double = 8.0,
    val minH: Double = 8.0,
    val maxDeltaPerRect: Double = 1000.0,
    val preserveCenter: Boolean = false,
    val onlyIfClusterAtLeast: Int = 2
)

data class EdgeSnapParams

data class EdgeSnapParams(
    val tol: Double = 14.0,
    val anchors: Set<EdgeAnchor> = setOf(EdgeAnchor.RIGHT, EdgeAnchor.BOTTOM),
    val maxDeltaPerRect: Double = 1000.0,
    val onlyIfClusterAtLeast: Int = 2
)

data class LocalTidyParams

data class LocalTidyParams(
    val posSnapTol: Double = 10.0,
    val minGap: Double = 24.0,
    val overlapPasses: Int = 4
)

data class EdgeId(val fromId: String, val toId: String, val weight: Double = 1.0)

private data class G(
    val nodes: Map<String, Rect>,
    val out: Map<String, List<EdgeId>>,
    val `in`: Map<String, List<EdgeId>>
)

NEW: centroid preservation helpers

enum class CentroidMode { UNWEIGHTED, AREA_WEIGHTED }

private fun centroid(rects: List<Rect>, mode: CentroidMode): Vec2 {
    if (rects.isEmpty()) return vec(0.0, 0.0)
    var sx = 0.0
    var sy = 0.0
    var sw = 0.0
    for (r in rects) {
        val w = when (mode) {
            CentroidMode.UNWEIGHTED -> 1.0
            CentroidMode.AREA_WEIGHTED -> max(1e-9, r.w * r.h)
        }
        sx += r.cx * w
        sy += r.cy * w
        sw += w
    }
    if (sw <= 1e-12) return vec(0.0, 0.0)
    return vec(sx / sw, sy / sw)
}

private fun translate(rects: List<Rect>, dx: Double, dy: Double): List<Rect> {
    if (abs(dx) < 1e-12 && abs(dy) < 1e-12) return rects
    return rects.map { r -> Rect(x = r.x + dx, y = r.y + dy, w = r.w, h = r.h) }
}

private fun restoreCentroid(newRects: List<Rect>, oldRects: List<Rect>, mode: CentroidMode): List<Rect> {
    if (newRects.isEmpty() || oldRects.isEmpty()) return newRects
    val cOld = centroid(oldRects, mode)
    val cNew = centroid(newRects, mode)
    return translate(newRects, (cOld.x - cNew.x).toDouble(), (cOld.y - cNew.y).toDouble())
}

Public APIs

fun snapAndTidyLocal

fun snapAndTidyLocal(
    rects: List<Rect>,
    sizeSnap: SizeSnapParams = SizeSnapParams(),
    edgeSnap: EdgeSnapParams = EdgeSnapParams(),
    tidy: LocalTidyParams = LocalTidyParams(),
    preserve: Boolean = true,
    centroidMode: CentroidMode = CentroidMode.AREA_WEIGHTED
): List<Rect> {
    if (rects.isEmpty()) return emptyList()
    if (rects.size == 1) return listOf(rects[0].copy())

    val before = rects.map { it.copy() }

    var r = snapRectSizes(before, sizeSnap)
    r = snapRectEdges(r, edgeSnap)
    r = snapPositionsXY(r, tidy.posSnapTol)
    r = resolveOverlaps(r, tidy.minGap, tidy.overlapPasses)

    return if (preserve) restoreCentroid(r, before, centroidMode) else r
}

fun routeAndTidyLocal

fun routeAndTidyLocal(
    rects: List<Rect>,
    edges: List<EdgeId>,
    route: RouteParams = RouteParams(),
    sizeSnap: SizeSnapParams = SizeSnapParams(),
    edgeSnap: EdgeSnapParams = EdgeSnapParams(),
    tidy: LocalTidyParams = LocalTidyParams()
): List<Rect> {
    if (rects.isEmpty()) return emptyList()
    if (rects.size == 1) return listOf(rects[0].copy())

    val before = rects.map { it.copy() }

    val snapped = snapRectSizes(before, sizeSnap)
    val snappedEdges = snapRectEdges(snapped, edgeSnap)

    val routed = routePositionGuidedWithDummies(snappedEdges, edges, route)

    var out = snapAndTidyLocal(
        rects = routed,
        sizeSnap = sizeSnap,
        edgeSnap = edgeSnap,
        tidy = tidy,
        preserve = false // we preserve at the very end, after columnize
    )

    if (route.finalColumnize) {
        repeat(route.finalColumnizePasses.coerceAtLeast(1)) {
            out = columnizeFromCurrent(out, edges, route)
        }
        out = snapRectEdges(out, edgeSnap)
    }

    if (route.preserveCentroid) {
        out = restoreCentroid(out, before, route.centroidMode)
    }
    return out
}

Local tidy pieces (no global repack)

source — 85 lines, from line 491
private fun snapPositionsXY(rects: List<Rect>, tol: Double): List<Rect> {
    if (rects.isEmpty()) return emptyList()
    val out = rects.map { it.copy() }.toMutableList()

    fun cluster(values: List<Pair<Int, Double>>, tol0: Double): List<List<Pair<Int, Double>>> {
        val sorted = values.sortedBy { it.second }
        val clusters = mutableListOf<MutableList<Pair<Int, Double>>>()
        for (v in sorted) {
            val last = clusters.lastOrNull()
            if (last == null || abs(v.second - last.last().second) > tol0) clusters += mutableListOf(v)
            else last += v
        }
        return clusters
    }

    fun medianOf(vals: List<Double>): Double {
        val s = vals.sorted()
        val n = s.size
        return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
    }

    run {
        val xs = out.mapIndexed { i, r -> i to r.x }
        for (c in cluster(xs, tol)) {
            if (c.size < 2) continue
            val guide = medianOf(c.map { it.second })
            for ((idx, _) in c) {
                val r = out[idx]
                val dx = guide - r.x
                if (abs(dx) <= tol) out[idx] = Rect(x = r.x + dx, y = r.y, w = r.w, h = r.h)
            }
        }
    }

    run {
        val ys = out.mapIndexed { i, r -> i to r.y }
        for (c in cluster(ys, tol)) {
            if (c.size < 2) continue
            val guide = medianOf(c.map { it.second })
            for ((idx, _) in c) {
                val r = out[idx]
                val dy = guide - r.y
                if (abs(dy) <= tol) out[idx] = Rect(x = r.x, y = r.y + dy, w = r.w, h = r.h)
            }
        }
    }

    return out
}

private fun resolveOverlaps(rects: List<Rect>, minGap: Double, passes: Int): List<Rect> {
    val out = rects.map { it.copy() }.toMutableList()
    if (out.size <= 1) return out

    fun overlap1D(a0: Double, a1: Double, b0: Double, b1: Double): Double =
        min(a1, b1) - max(a0, b0)

    for (pass in 0 until passes) {
        var any = false
        for (i in 0 until out.size) {
            for (j in i + 1 until out.size) {
                val a = out[i]
                val b = out[j]

                val ox = overlap1D(a.left, a.right, b.left, b.right)
                val oy = overlap1D(a.top, a.bottom, b.top, b.bottom)
                if (ox <= 0.0 || oy <= 0.0) continue

                val pushX = ox + minGap
                val pushY = oy + minGap

                if (pushX <= pushY) {
                    val dir = if (b.cx >= a.cx) 1.0 else -1.0
                    out[j] = Rect(x = b.x + dir * pushX, y = b.y, w = b.w, h = b.h)
                } else {
                    val dir = if (b.cy >= a.cy) 1.0 else -1.0
                    out[j] = Rect(x = b.x, y = b.y + dir * pushY, w = b.w, h = b.h)
                }
                any = true
            }
        }
        if (!any) break
    }
    return out
}

Size snap + edge snap

source — 132 lines, from line 581
private fun snapRectSizes(rects: List<Rect>, p: SizeSnapParams): List<Rect> {
    if (rects.isEmpty()) return emptyList()
    val out = rects.map { it.copy() }.toMutableList()

    fun cluster(values: List<Pair<Int, Double>>, tol: Double): List<List<Pair<Int, Double>>> {
        val sorted = values.sortedBy { it.second }
        val clusters = mutableListOf<MutableList<Pair<Int, Double>>>()
        for (v in sorted) {
            val last = clusters.lastOrNull()
            if (last == null || abs(v.second - last.last().second) > tol) clusters += mutableListOf(v)
            else last += v
        }
        return clusters
    }

    fun guideOf(cluster: List<Pair<Int, Double>>): Double {
        val xs = cluster.map { it.second }.sorted()
        return when (p.mode) {
            GuideMode1D.MEAN -> xs.sum() / xs.size
            GuideMode1D.MEDIAN -> if (xs.size % 2 == 1) xs[xs.size / 2] else (xs[xs.size / 2 - 1] + xs[xs.size / 2]) / 2.0
        }
    }

    run {
        val candidates = out.mapIndexed { i, r -> i to max(r.w, p.minW) }
        for (c in cluster(candidates, p.widthSnapTol)) {
            if (c.size < p.onlyIfClusterAtLeast) continue
            val g = max(guideOf(c), p.minW)
            for ((idx, w0) in c) {
                val r = out[idx]
                val dw = (g - w0).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
                if (abs(dw) < 1e-9) continue
                val newW = max(r.w + dw, p.minW)
                out[idx] = if (p.preserveCenter) {
                    val cx = r.cx
                    Rect(x = cx - newW / 2.0, y = r.y, w = newW, h = r.h)
                } else {
                    Rect(x = r.x, y = r.y, w = newW, h = r.h)
                }
            }
        }
    }

    run {
        val candidates = out.mapIndexed { i, r -> i to max(r.h, p.minH) }
        for (c in cluster(candidates, p.heightSnapTol)) {
            if (c.size < p.onlyIfClusterAtLeast) continue
            val g = max(guideOf(c), p.minH)
            for ((idx, h0) in c) {
                val r = out[idx]
                val dh = (g - h0).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
                if (abs(dh) < 1e-9) continue
                val newH = max(r.h + dh, p.minH)
                out[idx] = if (p.preserveCenter) {
                    val cy = r.cy
                    Rect(x = r.x, y = cy - newH / 2.0, w = r.w, h = newH)
                } else {
                    Rect(x = r.x, y = r.y, w = r.w, h = newH)
                }
            }
        }
    }

    return out
}

private fun snapRectEdges(rects: List<Rect>, p: EdgeSnapParams): List<Rect> {
    if (rects.isEmpty()) return emptyList()
    val out = rects.map { it.copy() }.toMutableList()

    fun cluster(values: List<Pair<Int, Double>>, tol: Double): List<List<Pair<Int, Double>>> {
        val sorted = values.sortedBy { it.second }
        val clusters = mutableListOf<MutableList<Pair<Int, Double>>>()
        for (v in sorted) {
            val last = clusters.lastOrNull()
            if (last == null || abs(v.second - last.last().second) > tol) clusters += mutableListOf(v)
            else last += v
        }
        return clusters
    }

    fun medianOf(vals: List<Double>): Double {
        val s = vals.sorted()
        val n = s.size
        return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
    }

    fun applyCluster(values: List<Pair<Int, Double>>, tol: Double, move: (idx: Int, guide: Double) -> Unit) {
        for (c in cluster(values, tol)) {
            if (c.size < p.onlyIfClusterAtLeast) continue
            val guide = medianOf(c.map { it.second })
            for ((idx, _) in c) move(idx, guide)
        }
    }

    if (EdgeAnchor.LEFT in p.anchors) {
        applyCluster(out.mapIndexed { i, r -> i to r.left }, p.tol) { idx, guide ->
            val r = out[idx]
            val dx = (guide - r.x).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
            if (abs(dx) < 1e-9) return@applyCluster
            out[idx] = Rect(x = r.x + dx, y = r.y, w = r.w, h = r.h)
        }
    }
    if (EdgeAnchor.RIGHT in p.anchors) {
        applyCluster(out.mapIndexed { i, r -> i to r.right }, p.tol) { idx, guide ->
            val r = out[idx]
            val targetX = guide - r.w
            val dx = (targetX - r.x).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
            if (abs(dx) < 1e-9) return@applyCluster
            out[idx] = Rect(x = r.x + dx, y = r.y, w = r.w, h = r.h)
        }
    }
    if (EdgeAnchor.TOP in p.anchors) {
        applyCluster(out.mapIndexed { i, r -> i to r.top }, p.tol) { idx, guide ->
            val r = out[idx]
            val dy = (guide - r.y).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
            if (abs(dy) < 1e-9) return@applyCluster
            out[idx] = Rect(x = r.x, y = r.y + dy, w = r.w, h = r.h)
        }
    }
    if (EdgeAnchor.BOTTOM in p.anchors) {
        applyCluster(out.mapIndexed { i, r -> i to r.bottom }, p.tol) { idx, guide ->
            val r = out[idx]
            val targetY = guide - r.h
            val dy = (targetY - r.y).coerceIn(-p.maxDeltaPerRect, p.maxDeltaPerRect)
            if (abs(dy) < 1e-9) return@applyCluster
            out[idx] = Rect(x = r.x, y = r.y + dy, w = r.w, h = r.h)
        }
    }

    return out
}

Routing core with dummy nodes for long edges

source — 310 lines, from line 718
private fun Rect.idOrNull(): String? = null

private fun buildGraph(nodes: Map<String, Rect>, edges: List<EdgeId>): G {
    val out = mutableMapOf<String, MutableList<EdgeId>>()
    val inn = mutableMapOf<String, MutableList<EdgeId>>()
    for (k in nodes.keys) {
        out.putIfAbsent(k, mutableListOf())
        inn.putIfAbsent(k, mutableListOf())
    }
    for (e in edges) {
        if (!nodes.containsKey(e.fromId) || !nodes.containsKey(e.toId)) continue
        out.getOrPut(e.fromId) { mutableListOf() }.add(e)
        inn.getOrPut(e.toId) { mutableListOf() }.add(e)
    }
    return G(nodes, out, inn)
}

private fun routePositionGuidedWithDummies(
    rects: List<Rect>,
    edges: List<EdgeId>,
    p: RouteParams
): List<Rect> {
    val realIds = rects.mapIndexed { i, r -> r.idOrNull() ?: i.toString() }
    val realNodes: Map<String, Rect> = realIds.zip(rects).toMap()
    if (realNodes.isEmpty()) return rects.map { it.copy() }

    val g0 = buildGraph(realNodes, edges)
    val root = chooseRootPositionGuided(g0, p)
    val (axis, sign) = if (p.directionFromPositions) inferForwardSign(g0, p.axis) else (p.axis to +1)

    val layerReal = assignLayersPositionGuided(g0, root, axis, sign, p)

    val expanded = expandLongEdgesWithDummies(
        realNodes = realNodes,
        edges = edges,
        layerOf = layerReal,
        axis = axis,
        sign = sign,
        p = p
    )

    val g = buildGraph(expanded.nodes, expanded.edges)

    val layers = mutableMapOf<Int, MutableList<String>>()
    for ((id, l) in expanded.layerOf) layers.getOrPut(l) { mutableListOf() }.add(id)

    orderWithinLayersAggressive(g, layers, axis, p.crossingIterations)

    val placedExpanded = placeExpandedGraph(
        g = g,
        layers = layers,
        axis = axis,
        sign = sign,
        anchorMajor = expanded.anchorMajor,
        anchorOrtho = expanded.anchorOrtho,
        p = p
    )

    val outById = placedExpanded.filterKeys { it in realNodes.keys }
    return realIds.map { id -> outById[id] ?: realNodes[id]!!.copy() }
}

private data class Expanded(
    val nodes: Map<String, Rect>,
    val edges: List<EdgeId>,
    val layerOf: Map<String, Int>,
    val anchorMajor: Double,
    val anchorOrtho: Double
)

private fun expandLongEdgesWithDummies(
    realNodes: Map<String, Rect>,
    edges: List<EdgeId>,
    layerOf: Map<String, Int>,
    axis: RouteAxis,
    sign: Int,
    p: RouteParams
): Expanded {
    val nodes = realNodes.toMutableMap()
    val newEdges = mutableListOf<EdgeId>()
    val newLayer = layerOf.toMutableMap()

    val anchorMajor = when (axis) {
        RouteAxis.HORIZONTAL -> median(realNodes.values.map { it.x })
        RouteAxis.VERTICAL -> median(realNodes.values.map { it.y })
    }
    val anchorOrtho = when (axis) {
        RouteAxis.HORIZONTAL -> median(realNodes.values.map { it.y })
        RouteAxis.VERTICAL -> median(realNodes.values.map { it.x })
    }

    var dummyCounter = 0
    fun makeDummyId(): String = "__d${dummyCounter++}"

    fun dummyRectAt(from: Rect, to: Rect): Rect {
        val cx = (from.cx + to.cx) * 0.5
        val cy = (from.cy + to.cy) * 0.5
        val w = max(1.0, p.dummyW)
        val h = max(1.0, p.dummyH)
        return Rect(x = cx - w / 2.0, y = cy - h / 2.0, w = w, h = h)
    }

    for (e in edges) {
        val u = e.fromId
        val v = e.toId
        val lu = newLayer[u] ?: 0
        val lv = newLayer[v] ?: 0
        val delta = lv - lu

        if (delta <= 1) {
            newEdges += e
            continue
        }

        var prev = u
        val fromR = nodes[u] ?: continue
        val toR = nodes[v] ?: continue

        for (k in 1 until delta) {
            val did = makeDummyId()
            nodes[did] = dummyRectAt(fromR, toR)
            newLayer[did] = lu + k
            newEdges += EdgeId(prev, did, e.weight)
            prev = did
        }
        newEdges += EdgeId(prev, v, e.weight)
    }

    return Expanded(nodes = nodes, edges = newEdges, layerOf = newLayer, anchorMajor = anchorMajor, anchorOrtho = anchorOrtho)
}

private fun orderWithinLayersAggressive(
    g: G,
    layers: MutableMap<Int, MutableList<String>>,
    axis: RouteAxis,
    iterations: Int
) {
    fun orthoCoord(r: Rect): Double = when (axis) {
        RouteAxis.HORIZONTAL -> r.cy
        RouteAxis.VERTICAL -> r.cx
    }

    for ((l, ids) in layers) {
        layers[l] = ids.sortedBy { orthoCoord(g.nodes[it]!!) }.toMutableList()
    }

    val keys = layers.keys.sorted()

    fun positions(ids: List<String>): Map<String, Double> =
        ids.withIndex().associate { it.value to it.index.toDouble() }

    fun baryFromPrev(id: String, prevPos: Map<String, Double>, g: G): Double {
        val parents = g.`in`[id].orEmpty().map { it.fromId }.filter { it in prevPos }
        if (parents.isEmpty()) return Double.POSITIVE_INFINITY
        return parents.map { prevPos[it]!! }.average()
    }

    fun baryFromNext(id: String, nextPos: Map<String, Double>, g: G): Double {
        val kids = g.out[id].orEmpty().map { it.toId }.filter { it in nextPos }
        if (kids.isEmpty()) return Double.POSITIVE_INFINITY
        return kids.map { nextPos[it]!! }.average()
    }

    for (iter in 0 until iterations) {
        for (i in 1 until keys.size) {
            val prev = layers[keys[i - 1]] ?: continue
            val curKey = keys[i]
            val cur = layers[curKey] ?: continue
            val prevPos = positions(prev)
            layers[curKey] = cur.sortedBy { baryFromPrev(it, prevPos, g) }.toMutableList()
        }
        for (i in keys.size - 2 downTo 0) {
            val next = layers[keys[i + 1]] ?: continue
            val curKey = keys[i]
            val cur = layers[curKey] ?: continue
            val nextPos = positions(next)
            layers[curKey] = cur.sortedBy { baryFromNext(it, nextPos, g) }.toMutableList()
        }
    }
}

private fun placeExpandedGraph(
    g: G,
    layers: Map<Int, List<String>>,
    axis: RouteAxis,
    sign: Int,
    anchorMajor: Double,
    anchorOrtho: Double,
    p: RouteParams
): Map<String, Rect> {
    fun orthoCenterOf(r: Rect): Double = when (axis) {
        RouteAxis.HORIZONTAL -> r.cy
        RouteAxis.VERTICAL -> r.cx
    }
    fun orthoSizeOf(r: Rect): Double = when (axis) {
        RouteAxis.HORIZONTAL -> r.h
        RouteAxis.VERTICAL -> r.w
    }

    val ortho = mutableMapOf<String, Double>()
    for ((id, r) in g.nodes) ortho[id] = orthoCenterOf(r)
    fun oc(id: String): Double = ortho[id] ?: anchorOrtho

    val sortedKeys = layers.keys.sorted()

    for (iter in 0 until p.orthoRelaxIterations) {
        for (k in sortedKeys) {
            val ids = layers[k].orEmpty()
            for (id in ids) {
                val parents = g.`in`[id].orEmpty().map { it.fromId }.filter { it in ortho }
                if (parents.isNotEmpty()) {
                    val b = parents.map { oc(it) }.average()
                    ortho[id] = lerp(oc(id), b, p.orthoBaryWeight)
                }
            }
        }
        for (kIdx in sortedKeys.size - 1 downTo 0) {
            val k = sortedKeys[kIdx]
            val ids = layers[k].orEmpty()
            for (id in ids) {
                val kids = g.out[id].orEmpty().map { it.toId }.filter { it in ortho }
                if (kids.isNotEmpty()) {
                    val b = kids.map { oc(it) }.average()
                    ortho[id] = lerp(oc(id), b, p.orthoBaryWeight)
                }
            }
        }
    }

    val placed = g.nodes.mapValues { (_, r) -> r.copy() }.toMutableMap()

    for (layerIdx in sortedKeys) {
        val ids = layers[layerIdx].orEmpty()
        if (ids.isEmpty()) continue

        val major = anchorMajor + sign * layerIdx * p.layerGap

        val sizes = DoubleArray(ids.size)
        val desiredStarts = DoubleArray(ids.size)
        val prefix = DoubleArray(ids.size)

        var acc = 0.0
        for (i in ids.indices) {
            val id = ids[i]
            val r = g.nodes[id]!!
            val s = orthoSizeOf(r)
            sizes[i] = s
            desiredStarts[i] = oc(id) - s / 2.0
            prefix[i] = acc
            acc += s + p.nodeGap
        }

        var start0 = 0.0
        for (i in ids.indices) start0 += (desiredStarts[i] - prefix[i])
        start0 /= ids.size.toDouble()

        val starts = DoubleArray(ids.size)
        for (i in ids.indices) starts[i] = start0 + prefix[i]

        for (i in ids.indices) {
            val id = ids[i]
            val r0 = placed[id] ?: continue
            val orthoStart = starts[i]
            placed[id] = when (axis) {
                RouteAxis.HORIZONTAL -> Rect(x = major, y = orthoStart, w = r0.w, h = r0.h)
                RouteAxis.VERTICAL -> Rect(x = orthoStart, y = major, w = r0.w, h = r0.h)
            }
        }
    }

    val moveT = (1.0 - p.inertia).coerceIn(0.0, 1.0)
    val blended = mutableMapOf<String, Rect>()
    for ((id, rT) in placed) {
        val r = g.nodes[id] ?: rT
        val dx = (rT.x - r.x).coerceIn(-p.maxMove, p.maxMove)
        val dy = (rT.y - r.y).coerceIn(-p.maxMove, p.maxMove)
        blended[id] = Rect(x = r.x + dx * moveT, y = r.y + dy * moveT, w = r.w, h = r.h)
    }
    return blended
}

private fun columnizeFromCurrent(rects: List<Rect>, edges: List<EdgeId>, p: RouteParams): List<Rect> {
    val ids = rects.mapIndexed { i, r -> r.idOrNull() ?: i.toString() }
    val nodes = ids.zip(rects).toMap()
    if (nodes.isEmpty()) return rects.map { it.copy() }

    val g = buildGraph(nodes, edges)
    val root = chooseRootPositionGuided(g, p)
    val (axis, sign) = if (p.directionFromPositions) inferForwardSign(g, p.axis) else (p.axis to +1)
    val layer = assignLayersPositionGuided(g, root, axis, sign, p)

    val layers = mutableMapOf<Int, MutableList<String>>()
    for ((id, l) in layer) layers.getOrPut(l) { mutableListOf() }.add(id)

    orderWithinLayersAggressive(g, layers, axis, max(2, p.crossingIterations / 3))

    val anchorMajor = when (axis) {
        RouteAxis.HORIZONTAL -> median(nodes.values.map { it.x })
        RouteAxis.VERTICAL -> median(nodes.values.map { it.y })
    }
    val anchorOrtho = when (axis) {
        RouteAxis.HORIZONTAL -> median(nodes.values.map { it.y })
        RouteAxis.VERTICAL -> median(nodes.values.map { it.x })
    }

    val placed = placeExpandedGraph(g, layers, axis, sign, anchorMajor, anchorOrtho, p)
        .filterKeys { it in nodes.keys }

    return ids.map { id -> placed[id] ?: nodes[id]!!.copy() }
}

Root + sign + layers

source — 100 lines, from line 1033
private fun chooseRootPositionGuided(g: G, p: RouteParams): String {
    fun distToOrigin(r: Rect): Double {
        val dx = r.cx - p.origin.x
        val dy = r.cy - p.origin.y
        return sqrt(dx * dx + dy * dy)
    }

    fun score(id: String): Double {
        val r = g.nodes[id] ?: return Double.NEGATIVE_INFINITY
        val inDeg = g.`in`[id]?.size ?: 0
        val outDeg = g.out[id]?.size ?: 0
        val originScore = 1.0 / (1.0 + distToOrigin(r))
        val indegBonus = if (inDeg == 0) 1.0 else 0.0
        return p.preferNearOrigin * originScore +
                p.preferInDegree0 * indegBonus +
                p.preferOutDegree * outDeg.toDouble()
    }

    return g.nodes.keys.maxByOrNull { score(it) } ?: g.nodes.keys.first()
}

private fun inferForwardSign(g: G, axis: RouteAxis): Pair<RouteAxis, Int> {
    var forward = 0
    for ((u, outs) in g.out) {
        val ru = g.nodes[u] ?: continue
        for (e in outs) {
            val rv = g.nodes[e.toId] ?: continue
            forward += when (axis) {
                RouteAxis.HORIZONTAL -> if (rv.cx >= ru.cx) 1 else -1
                RouteAxis.VERTICAL -> if (rv.cy >= ru.cy) 1 else -1
            }
        }
    }
    val sign = if (forward >= 0) +1 else -1
    return axis to sign
}

private fun assignLayersPositionGuided(
    g: G,
    root: String,
    axis: RouteAxis,
    sign: Int,
    p: RouteParams
): MutableMap<String, Int> {
    val bfs = mutableMapOf<String, Int>()
    bfs[root] = 0
    val q: ArrayDeque<String> = ArrayDeque()
    q.add(root)

    while (q.isNotEmpty()) {
        val u = q.removeFirst()
        val lu = bfs[u]!!
        for (e in g.out[u].orEmpty()) {
            val v = e.toId
            val cand = lu + 1
            val prev = bfs[v]
            if (prev == null || cand < prev) {
                bfs[v] = cand
                q.add(v)
            }
        }
    }

    fun coord(r: Rect): Double = when (axis) {
        RouteAxis.HORIZONTAL -> r.cx
        RouteAxis.VERTICAL -> r.cy
    }

    val rootRect = g.nodes[root] ?: return bfs
    val rootCoord = coord(rootRect)

    val layer = mutableMapOf<String, Int>()
    for (id in g.nodes.keys) {
        val r = g.nodes[id] ?: continue
        val base = bfs[id] ?: 0
        val desired = (sign * (coord(r) - rootCoord)) / p.layerGap
        val blended = lerp(base.toDouble(), desired, p.inertia)
        layer[id] = blended.roundToInt()
    }

    for (iter in 0 until 10) {
        var changed = false
        for ((u, outs) in g.out) {
            val lu = layer[u] ?: continue
            for (e in outs) {
                val v = e.toId
                val lv = layer[v] ?: continue
                if (lv <= lu) {
                    layer[v] = lu + 1
                    changed = true
                }
            }
        }
        if (!changed) break
    }

    val shift = layer[root] ?: 0
    for (k in layer.keys.toList()) layer[k] = layer[k]!! - shift
    return layer
}

Helpers

private fun lerp(a: Double, b: Double, t: Double) = a + (b - a) * t
private fun Double.roundToInt(): Int = round(this).toInt()

Actions installation

fun installAlignment(root: Box)

body — 59 lines, from line 1145
@RunsAt(newRoot)
fun installAlignment(root: Box) {
    root.actions["align"] = Action("Align boxes", "Automatically align selected boxes outlines vertically or horizontally") { boxes ->
        val before = boxes.map { it.frame }
        val after = alignAndDistribute(
            before,
            LayoutParams(intraGap = 16.0, crossGap = 16.0, intraDistribution = IntraDistribution.PACK_MIN_MOVE)
        )
        val stabilized = restoreCentroid(after, before, CentroidMode.AREA_WEIGHTED)
        boxes.zip(stabilized).forEach { (b, r) -> b.frame = r }
    }

    root.actions["snap_tidy"] = Action("Snap + tidy boxes", "Automatically tidy up the arrangement of selected boxes") { boxes ->
        val before = boxes.map { it.frame }
        val out = snapAndTidyLocal(
            rects = before,
            sizeSnap = SizeSnapParams(widthSnapTol = 14.0, heightSnapTol = 14.0, preserveCenter = false),
            edgeSnap = EdgeSnapParams(tol = 14.0, anchors = setOf(EdgeAnchor.RIGHT, EdgeAnchor.BOTTOM)),
            tidy = LocalTidyParams(posSnapTol = 10.0, minGap = 16.0, overlapPasses = 3),
            preserve = true,
            centroidMode = CentroidMode.AREA_WEIGHTED
        )
        boxes.zip(out).forEach { (b, r) -> b.frame = r }
    }

    root.actions["route"] = Action("Route (position-guided)", detail = "Intelligently organize a selection of boxes by how they are connected together") { boxes ->
        val before = boxes.map { it.frame }

        val boxId: Map<Box, String> = boxes.mapIndexed { i, b -> b to i.toString() }.toMap()
        val edges = boxes.flatMap { a ->
            val from = boxId[a] ?: return@flatMap emptyList()
            a.children.mapNotNull { b ->
                val to = boxId[b] ?: return@mapNotNull null
                EdgeId(from, to)
            }
        }

        val out = routeAndTidyLocal(
            rects = before,
            edges = edges,
            route = RouteParams(
                layerGap = 240.0,
                nodeGap = 36.0,
                inertia = 0.15,
                crossingIterations = 12,
                orthoRelaxIterations = 10,
                finalColumnize = true,
                finalColumnizePasses = 1,
                preserveCentroid = true,
                centroidMode = CentroidMode.AREA_WEIGHTED
            ),
            sizeSnap = SizeSnapParams(widthSnapTol = 14.0, heightSnapTol = 14.0, preserveCenter = false),
            edgeSnap = EdgeSnapParams(tol = 14.0, anchors = setOf(EdgeAnchor.RIGHT, EdgeAnchor.BOTTOM)),
            tidy = LocalTidyParams(posSnapTol = 8.0, minGap = 28.0, overlapPasses = 4)
        )

        boxes.zip(out).forEach { (b, r) -> b.frame = r }
    }
}