Layout Engines

The layout step computes positions for every diagram element. kUML ships two engines and lets third parties register more through a ServiceLoader SPI.

Two built-in engines

Engine Character

elk.layered

Eclipse Layout Kernel Sugiyama-based layered layout. Default for class, component, deployment, and most C4 diagrams. Handles arbitrary graphs well, produces aesthetically reasonable output without user input.

kuml.grid

Pure-Kotlin grid engine, GraalVM-native-image-compatible. Deterministic, hint-driven — you place elements on a coarse grid; the engine respects your placement. Best when you want predictable, hand-tuned layouts.

The engine is selected per render via LayoutEngineRegistry.pickFor(diagramKind, preferred):

  1. If preferred is set (CLI flag, config, or DSL hint), use it — no capability check.

  2. Otherwise, the first registered engine whose capabilities include the diagram type.

  3. Otherwise, the first engine that supports DiagramKind.Generic.

  4. Otherwise, null (registry empty).

Override per render:

kuml render diagram.kuml.kts --engine kuml.grid

DSL hints — taking control

For predictable layouts, place a layout { …​ } block on any element. The hints materialize into kuml.layout.* metadata, the bridge reads them into the engine’s NodeHints, and the engine respects them (if capable).

classDiagram(name = "Domain") {
    val customer = classOf("Customer") {
        layout {
            col = 0; row = 0
        }
    }

    val order = classOf("Order") {
        layout {
            col = 1; row = 0
            colSpan = 2
        }
    }

    val item = classOf("OrderItem") {
        layout {
            below(order)
            sameColAs(order)
        }
    }
}

Hint vocabulary:

Hint Effect

col = N

Pin to column N (0-based).

row = N

Pin to row N (0-based).

colSpan = N

Element spans N columns.

rowSpan = N

Element spans N rows.

pinned = true

Engine must not move this element from its current slot.

above(other)

Place above the referenced element.

below(other)

Place below the referenced element.

leftOf(other)

Place to the left of the referenced element.

rightOf(other)

Place to the right of the referenced element.

sameRowAs(other)

Same row as the referenced element.

sameColAs(other)

Same column as the referenced element.

References take either a typed val (preferred — IDE-checked) or a String ID:

val order = classOf("Order")
classOf("Item") { layout { below(order) } }       // Typed reference
classOf("Item") { layout { below("Order") } }      // String — looser, also works

SysML 2 layout hints

Every SysML 2 diagram accepts a layout { } block with coarse steering hints. There are deliberately no pixel positions and no pairwise relative constraints.

reqDiagram("Requirements") {
    // include(...)
    layout {
        direction = Right      // Down (default), Right, Up, Left
        wrapColumns = 4        // at most 4 nodes per layer
        sameLayer(a, b)        // same layer
        first(root)            // first layer
        last(leaf)             // last layer
    }
}
  • wrapColumns moves the nodes of an over-full layer (declaration order) into additional layers. Layers are computed by longest-path layering; back edges are ignored.

  • first and last use ELK’s layer constraints when no wrap/sameLayer is given and the edges allow it (a first node must have no incoming and a last node no outgoing edge, otherwise the pin is dropped). Together with wrapping they are expressed as layer indices.

  • Layer hints are honoured for nodes that are not inside a compound group. ELK ignores them inside compounds and reports hint.ignored.layerInCompound.

  • Sequence diagrams ignore all hints (lifeline order is the declaration order); kuml validate warns (LAYOUT412).

  • The grid engine and the WebAssembly playground do not evaluate these hints.

  • Swimlane (partition) activity diagrams are drawn top to bottom only; another direction is reported as LAYOUT433.

  • Checks: LAYOUT431 unknown id, LAYOUT432 wrapColumns outside 1..32, LAYOUT434 too many groups/ids, LAYOUT435 id in first and last, LAYOUT436 connected elements in one sameLayer group.

Example: a wide requirement diagram

Without a hint, twelve requirements are laid out in one very wide row:

kUML source
import dev.kuml.sysml2.RequirementPriority
import dev.kuml.sysml2.dsl.sysml2Model

sysml2Model("Platform Requirements") {
    val reqs = listOf(
        "Boot" to "The platform shall boot within 5 seconds.",
        "Update" to "Updates shall install without user interaction.",
        "Rollback" to "A failed update shall roll back automatically.",
        "Telemetry" to "Telemetry shall be opt-in.",
        "Logging" to "Logs shall be kept for 30 days.",
        "Auth" to "Every request shall be authenticated.",
        "Audit" to "Security events shall be audited.",
        "Encrypt" to "Data at rest shall be encrypted.",
        "Backup" to "Backups shall run nightly.",
        "Restore" to "A restore shall complete within one hour.",
        "Scale" to "The platform shall scale to 10 000 users.",
        "Monitor" to "Health shall be monitored continuously.",
    ).mapIndexed { i, (name, text) ->
        requirementDef(name, reqId = "R-${i + 1}", text = text, priority = RequirementPriority.MEDIUM)
    }
    reqDiagram("Platform requirements") {
        reqs.forEach { include(it) }
    }
}
Platform requirements

With layout { wrapColumns = 4 } the same diagram wraps into three rows of four (vault source: 03 Bereiche/kUML/Beispiele/52 SysML 2 REQ – Breites Diagramm mit wrapColumns.md):

kUML source
import dev.kuml.sysml2.RequirementPriority
import dev.kuml.sysml2.dsl.sysml2Model

sysml2Model("Platform Requirements") {
    val reqs = listOf(
        "Boot" to "The platform shall boot within 5 seconds.",
        "Update" to "Updates shall install without user interaction.",
        "Rollback" to "A failed update shall roll back automatically.",
        "Telemetry" to "Telemetry shall be opt-in.",
        "Logging" to "Logs shall be kept for 30 days.",
        "Auth" to "Every request shall be authenticated.",
        "Audit" to "Security events shall be audited.",
        "Encrypt" to "Data at rest shall be encrypted.",
        "Backup" to "Backups shall run nightly.",
        "Restore" to "A restore shall complete within one hour.",
        "Scale" to "The platform shall scale to 10 000 users.",
        "Monitor" to "Health shall be monitored continuously.",
    ).mapIndexed { i, (name, text) ->
        requirementDef(name, reqId = "R-${i + 1}", text = text, priority = RequirementPriority.MEDIUM)
    }
    reqDiagram("Platform requirements") {
        reqs.forEach { include(it) }
        layout {
            wrapColumns = 4
        }
    }
}
Platform requirements

All SysML 2 render paths (CLI, Gradle, Web, Desktop, Asciidoc, kuml-ai) take their ELK hints from the single function Sysml2LayoutBridge.layoutHintsFor.

When the engine can’t honour a hint

Conflicts produce a LayoutWarning in LayoutResult.warnings:

Warning code When

hint.conflict.gridSlot

Two elements want the same hard-pinned slot. Loser is shifted right; warning lists the affected node IDs.

hint.unfulfilled.relativeConstraint

Preferred slot from a relative constraint was occupied. Fallback to next free slot.

hint.deferred.relativeConstraint

Anchor element wasn’t yet placed when the constraint was evaluated.

engine.performance.large_graph

Graph exceeds the engine’s recommended max (maxRecommendedNodes). Suggest a different engine.

The CLI prints warnings after a render so you can iterate on hints without staring at SVG diffs.

Edge routing

Four edge styles, all engines support all four:

Style Behaviour

Direct

Straight line between source and target.

OrthogonalRounded (default)

Horizontal-vertical-horizontal (or vertical-horizontal-vertical) route with rounded corners.

TreeRounded

V-shape route with top-down bias — looks tidy for hierarchies. V1.1.12 falls back to OrthogonalRounded; V1.2 adds tree-aware routing proper.

Bezier

Cubic Bezier with two control points offset perpendicular to the direct line.

Set per edge:

association(source = a, target = b) {
    routeStyle = EdgeRouteStyle.Direct
}

Or globally for a diagram:

classDiagram(name = "X") {
    layoutHints {
        defaultEdgeStyle = EdgeRouteStyle.Bezier
    }
    // ...
}

Performance characteristics

Size elk.layered kuml.grid

≤ 100 nodes

< 100 ms

< 10 ms

≤ 500 nodes

< 1 s

< 100 ms

> 500 nodes

Still works, may be slow

Emits engine.performance.large_graph warning; suggests ELK

The grid engine is deterministic — same input + same seed = byte-identical layout. ELK is also seeded but has more internal randomness in its heuristics; outputs are stable but small floating-point differences may appear across JVM versions.

Authoring your own engine

Implement KumlLayoutEngine (pure Kotlin recommended for native-image compatibility), then provide a KumlLayoutEngineProvider:

class CircularLayoutEngine : KumlLayoutEngine {
    override val id = LayoutEngineId("circular")
    override val capabilities = LayoutCapabilities(
        deterministic = true,
        supportedDiagramKinds = setOf(DiagramKind.UmlClass, DiagramKind.Generic),
        // ...
    )
    override fun layout(graph: LayoutGraph, hints: LayoutHints): LayoutResult {
        // ... place nodes on a circle, route edges, return LayoutResult
    }
}

class CircularLayoutEngineProvider : KumlLayoutEngineProvider {
    override val id = LayoutEngineId("circular")
    override fun engine() = CircularLayoutEngine()
}

Register via META-INF/services/dev.kuml.layout.KumlLayoutEngineProvider:

com.example.CircularLayoutEngineProvider

LayoutEngineRegistry.loadFromClasspath() discovers it. CLI flag --engine circular selects it.