Skip to content

wfc.kt

src/jvmMain/kotlin/afield/simulation/automata/wfc.kt · 1949 lines

package and imports — 68 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Data")

package afield.simulation.automata

import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.clear
import afield.document.properties.compareTo
import afield.document.workspace.path
import afield.geometry.mesh.contains
import afield.geometry.mesh.rotated
import afield.graphics.render.filled
import afield.graphics.render.rangeTo
import afield.math.autodiff.clamp
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.product
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.runtime.execution.start
import afield.runtime.lifecycle.once

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.r
import afield.data.collections.s
import afield.data.graph.rules
import afield.document.workspace.absolutePath
import afield.document.workspace.file
import afield.geometry.mesh.expanded
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.abs
import afield.math.autodiff.maxOf
import afield.math.autodiff.minOf
import afield.math.autodiff.sum
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.x
import afield.math.statistics.noise
import afield.math.statistics.random
import afield.media.image.rotate
import afield.media.video.image
import afield.runtime.execution.cell
import afield.simulation.agents.step
import afield.ui.output.h
import afield.simulation.automata.WfcNeighborhood.Companion.VonNeumann
import java.awt.image.BufferedImage
import java.io.File
import java.util.*
import javax.imageio.ImageIO
import kotlin.math.ln

Single-file, dependency-free Wave Function Collapse for Kotlin/JVM.

What this implements: - Learns unique fixed-size tiles from a source PNG or 2D FloatArray. - Learns adjacency constraints from a configurable symmetric neighborhood. - Learns overlapping N x M patterns from a PNG or 2D FloatArray. - Generates an output tile grid with an explicit step() loop for visualization. - Avoids per-neighbor/per-tile garbage in propagation by using reusable LongArray bitsets.

Notes: - The WFC solver operates only on integer tile IDs and constraints. The PNG/FloatArray simple-tiled and overlapping helpers sit outside it. - WfcSolver implements afield.ui.visualization.ToHTML, so evaluating a solver in a box draws the collapse instead of a reflective property tree. Every pixel of that view lives in wfc_field.kt (afield.simulation.automata.wfcSolverHtml), which this file names once, in WfcSolver.toHTML.

const val WFC_NORTH: Int = 0

const val WFC_EAST: Int = 1

const val WFC_SOUTH: Int = 2

const val WFC_WEST: Int = 3

data class WfcOffset(val dx: Int, val dy: Int)

class WfcNeighborhood(offsets: Iterable<WfcOffset>)

Symmetric set of relative cell offsets used for learning and propagation.

The first four directions of VonNeumann retain the historical WFC_NORTH/EAST/SOUTH/WEST ordering for old code that indexes directional constraints directly.

class WfcNeighborhood(offsets: Iterable<WfcOffset>) {
    private val offsetData: Array<WfcOffset> = offsets.toList().toTypedArray()
    private val oppositeData: IntArray

val directionCount: Int get() = offsetData.size

val offsets: List<WfcOffset> get() = offsetData.asList()

init

    init {
        require(offsetData.isNotEmpty()) { "A WFC neighborhood needs at least one offset" }

        val byOffset = HashMap<WfcOffset, Int>()
        var i = 0
        while (i < offsetData.size) {
            val offset = offsetData[i]
            require(offset.dx != 0 || offset.dy != 0) { "A WFC neighborhood cannot include the zero offset" }
            require(byOffset.put(offset, i) == null) { "Duplicate WFC neighborhood offset $offset" }
            i++
        }

        oppositeData = IntArray(offsetData.size)
        i = 0
        while (i < offsetData.size) {
            val offset = offsetData[i]
            oppositeData[i] = byOffset[WfcOffset(-offset.dx, -offset.dy)]
                ?: error("WFC neighborhood offset $offset is missing its opposite")
            i++
        }
    }

fun offset(direction: Int): WfcOffset

    fun offset(direction: Int): WfcOffset {
        require(direction in 0 until directionCount)
        return offsetData[direction]
    }

fun opposite(direction: Int): Int

    fun opposite(direction: Int): Int {
        require(direction in 0 until directionCount)
        return oppositeData[direction]
    }

fun directionOf(dx: Int, dy: Int): Int

    fun directionOf(dx: Int, dy: Int): Int {
        var i = 0
        while (i < offsetData.size) {
            val offset = offsetData[i]
            if (offset.dx == dx && offset.dy == dy) return i
            i++
        }
        return -1
    }

override fun toString(): String = "WfcNeighborhood(${offsetData.joinToString()})"

companion object

val VonNeumann: WfcNeighborhood

        val VonNeumann: WfcNeighborhood = WfcNeighborhood(
            listOf(
                WfcOffset(0, -1),
                WfcOffset(1, 0),
                WfcOffset(0, 1),
                WfcOffset(-1, 0)
            )
        )

val Moore: WfcNeighborhood

        val Moore: WfcNeighborhood = WfcNeighborhood(
            listOf(
                WfcOffset(0, -1),
                WfcOffset(1, 0),
                WfcOffset(0, 1),
                WfcOffset(-1, 0),
                WfcOffset(1, -1),
                WfcOffset(1, 1),
                WfcOffset(-1, 1),
                WfcOffset(-1, -1)
            )
        )

fun of(vararg offsets: WfcOffset): WfcNeighborhood = WfcNeighborhood(offsets.asList())

fun chebyshev(radius: Int): WfcNeighborhood

        fun chebyshev(radius: Int): WfcNeighborhood {
            require(radius > 0)
            val offsets = ArrayList<WfcOffset>()
            var dy = -radius
            while (dy <= radius) {
                var dx = -radius
                while (dx <= radius) {
                    if (dx != 0 || dy != 0) offsets.add(WfcOffset(dx, dy))
                    dx++
                }
                dy++
            }
            return WfcNeighborhood(offsets)
        }

fun manhattan(radius: Int): WfcNeighborhood

        fun manhattan(radius: Int): WfcNeighborhood {
            require(radius > 0)
            val offsets = ArrayList<WfcOffset>()
            var dy = -radius
            while (dy <= radius) {
                var dx = -radius
                while (dx <= radius) {
                    if ((dx != 0 || dy != 0) && kotlin.math.abs(dx) + kotlin.math.abs(dy) <= radius) {
                        offsets.add(WfcOffset(dx, dy))
                    }
                    dx++
                }
                dy++
            }
            return WfcNeighborhood(offsets)
        }
    }
}

typealias WfcNeighbourhood = WfcNeighborhood

val WFC_VON_NEUMANN_NEIGHBORHOOD: WfcNeighborhood = WfcNeighborhood.VonNeumann

val WFC_MOORE_NEIGHBORHOOD: WfcNeighborhood = WfcNeighborhood.Moore

val WFC_VON_NEUMANN_NEIGHBOURHOOD: WfcNeighbourhood = WfcNeighborhood.VonNeumann

val WFC_MOORE_NEIGHBOURHOOD: WfcNeighbourhood = WfcNeighborhood.Moore

source — 105 lines, from line 217
private fun wordsFor(bits: Int): Int = (bits + 63) ushr 6
private fun wordIndex(bit: Int): Int = bit ushr 6
private fun bitMask(bit: Int): Long = 1L shl (bit and 63)

private fun lastWordMask(bitCount: Int): Long {
    val rem = bitCount and 63
    return if (rem == 0) -1L else (1L shl rem) - 1L
}

private fun LongArray.fillAllBits(bitCount: Int) {
    java.util.Arrays.fill(this, -1L)
    this[this.lastIndex] = this[this.lastIndex] and lastWordMask(bitCount)
}

private fun LongArray.clearAll() {
    java.util.Arrays.fill(this, 0L)
}

private fun LongArray.copyFrom(src: LongArray) {
    System.arraycopy(src, 0, this, 0, size)
}

private fun LongArray.andInPlace(other: LongArray): Boolean {
    var changed = false
    var i = 0
    while (i < size) {
        val old = this[i]
        val next = old and other[i]
        if (next != old) {
            this[i] = next
            changed = true
        }
        i++
    }
    return changed
}

private fun LongArray.orInPlace(other: LongArray) {
    var i = 0
    while (i < size) {
        this[i] = this[i] or other[i]
        i++
    }
}

private fun LongArray.containsBit(bit: Int): Boolean = (this[wordIndex(bit)] and bitMask(bit)) != 0L

private fun LongArray.setBit(bit: Int) {
    this[wordIndex(bit)] = this[wordIndex(bit)] or bitMask(bit)
}

private fun LongArray.keepOnlyBit(bit: Int) {
    java.util.Arrays.fill(this, 0L)
    this[wordIndex(bit)] = bitMask(bit)
}

private fun LongArray.countBits(): Int {
    var c = 0
    var i = 0
    while (i < size) {
        c += java.lang.Long.bitCount(this[i])
        i++
    }
    return c
}

private fun LongArray.firstSetBit(): Int {
    var i = 0
    while (i < size) {
        val w = this[i]
        if (w != 0L) return (i shl 6) + java.lang.Long.numberOfTrailingZeros(w)
        i++
    }
    return -1
}

private fun LongArray.isEmptyBits(): Boolean {
    var i = 0
    while (i < size) {
        if (this[i] != 0L) return false
        i++
    }
    return true
}

private fun wrapIndex(value: Int, size: Int): Int {
    val r = value % size
    return if (r < 0) r + size else r
}

private fun weightsFromSampleIds(tileCount: Int, samplePatternIds: IntArray?): DoubleArray {
    val weights = DoubleArray(tileCount)
    if (samplePatternIds == null) {
        java.util.Arrays.fill(weights, 1.0)
    } else {
        var i = 0
        while (i < samplePatternIds.size) {
            val id = samplePatternIds[i]
            require(id in 0 until tileCount) { "Sample pattern id $id is outside 0 until $tileCount" }
            weights[id] += 1.0
            i++
        }
    }
    return weights
}

Widen every empty allowed[direction](../../../../../../reference/source/jvmMain/afield/study/evidence/StudyEvidence.md#L383) mask to "any tile", keeping the relation symmetric.

WFC's propagation is only sound if adjacency is a symmetric relation — that is, if alloweddcontains `b` exactly when `allowed`opposite(d) contains a. Propagation only ever pushes constraints outward from a cell that changed, so if allowedEASTadmits `b` while `allowed`WEST does not admit a, nothing in the solver will ever notice: the arc is checked in one direction only, and the solver can happily emit a with b to its east — a pair its own rules forbid when read the other way round.

Naively assigning allowedd= every tile therefore introduces that asymmetry, because it says "anything may sit at d from t" without also telling each of those tiles that t may sit on their opposite side. So the widening has to be done in both directions at once, and the set of empty masks has to be collected up front — filling one mask can make another non-empty.

source — 42 lines, from line 338
private fun widenEmptyMasksSymmetrically(
    allowed: Array<Array<LongArray>>,
    neighborhood: WfcNeighborhood,
    tileCount: Int,
    wordCount: Int
): Array<BooleanArray> {
    val all = LongArray(wordCount)
    all.fillAllBits(tileCount)
    val widened = Array(neighborhood.directionCount) { BooleanArray(tileCount) }

    // Collect first: widening allowed[opposite(d)][s] below can un-empty a mask we have not tested.
    val emptyDirections = ArrayList<Int>()
    val emptyTiles = ArrayList<Int>()
    var d = 0
    while (d < neighborhood.directionCount) {
        var t = 0
        while (t < tileCount) {
            if (allowed[d][t].isEmptyBits()) {
                emptyDirections.add(d)
                emptyTiles.add(t)
            }
            t++
        }
        d++
    }

    var i = 0
    while (i < emptyDirections.size) {
        val direction = emptyDirections[i]
        val tile = emptyTiles[i]
        allowed[direction][tile].copyFrom(all)
        widened[direction][tile] = true
        val opposite = neighborhood.opposite(direction)
        var s = 0
        while (s < tileCount) {
            allowed[opposite][s].setBit(tile)
            s++
        }
        i++
    }
    return widened
}

Deep copy of an adjacency table, for keeping the pre-widening state around.

source — 54 lines, from line 382
private fun copyAllowed(allowed: Array<Array<LongArray>>): Array<Array<LongArray>> =
    Array(allowed.size) { d -> Array(allowed[d].size) { t -> allowed[d][t].copyOf() } }

private fun intPatternsOverlap(
    a: IntArray,
    b: IntArray,
    width: Int,
    height: Int,
    dx: Int,
    dy: Int
): Boolean {
    val x0 = maxOf(0, dx)
    val x1 = minOf(width, width + dx)
    val y0 = maxOf(0, dy)
    val y1 = minOf(height, height + dy)
    if (x0 >= x1 || y0 >= y1) return true

    var y = y0
    while (y < y1) {
        var x = x0
        while (x < x1) {
            if (a[x + y * width] != b[(x - dx) + (y - dy) * width]) return false
            x++
        }
        y++
    }
    return true
}

private fun floatPatternsOverlap(
    a: FloatArray,
    b: FloatArray,
    width: Int,
    height: Int,
    dx: Int,
    dy: Int
): Boolean {
    val x0 = maxOf(0, dx)
    val x1 = minOf(width, width + dx)
    val y0 = maxOf(0, dy)
    val y1 = minOf(height, height + dy)
    if (x0 >= x1 || y0 >= y1) return true

    var y = y0
    while (y < y1) {
        var x = x0
        while (x < x1) {
            if (a[x + y * width] != b[(x - dx) + (y - dy) * width]) return false
            x++
        }
        y++
    }
    return true
}

class WfcRules private constructor

The learned model: tile weights plus directional adjacency masks.

class WfcRules private constructor(
    val tileCount: Int,
    val weights: DoubleArray,
    val neighborhood: WfcNeighborhood,
    /** allowed[direction][tile] is a bitset of tile IDs allowed in that direction from tile. */
    val allowed: Array<Array<LongArray>>,
    /**
     * `[direction][tile]` = this mask was empty and got filled in by the widening fallback, or null
     * when no widening happened (the overwhelmingly common case, and then observed == allowed).
     */
    private val widenedMasks: Array<BooleanArray>? = null,
    /** [allowed] as it stood *before* widening, or null when nothing was widened. */
    private val observedAllowed: Array<Array<LongArray>>? = null
) : afield.ui.visualization.ToHTML {

val wordCount: Int = wordsFor(tileCount)

fun allows(direction: Int, a: Int, b: Int): Boolean

May tile b sit at direction from tile a?

The same question allowed answers, without making you index a bitset by hand. Worth reaching for twice: to inspect a learned model — print the adjacency table and you can see what a sample image actually taught the solver, which is usually more informative than looking at the output — and to check one, since every adjacent pair in a solved grid must satisfy this.

    fun allows(direction: Int, a: Int, b: Int): Boolean {
        require(direction in 0 until neighborhood.directionCount) {
            "direction $direction outside 0 until ${neighborhood.directionCount}"
        }
        require(a in 0 until tileCount) { "tile $a outside 0 until $tileCount" }
        require(b in 0 until tileCount) { "tile $b outside 0 until $tileCount" }
        return allowed[direction][a].containsBit(b)
    }

fun findAsymmetry(): String?

The first (direction, a, b) where adjacency is not symmetric, or null when the rules are sound. Returned as a human-readable string because it is a diagnostic, not a control path.

WFC propagates constraints outward from cells that changed, and it does so in one direction per arc. That is only sufficient if allows(d, a, b) == allows(opposite(d), b, a) for every triple: an asymmetric rule set has arcs the solver structurally cannot check, and it will cheerfully emit adjacent pairs its own rules forbid when read the other way round. Every factory in this file maintains the invariant; this is here for rules you build yourself, and for a test to assert against.

    fun findAsymmetry(): String? {
        var d = 0
        while (d < neighborhood.directionCount) {
            val opposite = neighborhood.opposite(d)
            var a = 0
            while (a < tileCount) {
                var b = 0
                while (b < tileCount) {
                    if (allowed[d][a].containsBit(b) != allowed[opposite][b].containsBit(a)) {
                        return "allows($d, $a, $b)=${allowed[d][a].containsBit(b)} but " +
                            "allows($opposite, $b, $a)=${allowed[opposite][b].containsBit(a)}"
                    }
                    b++
                }
                a++
            }
            d++
        }
        return null
    }

fun allowedCount(direction: Int, a: Int): Int

How many tiles may sit at direction from a — the branching factor of the model.

    fun allowedCount(direction: Int, a: Int): Int {
        require(direction in 0 until neighborhood.directionCount)
        require(a in 0 until tileCount)
        return allowed[direction][a].countBits()
    }

Observed vs invented adjacency

fromSampleGrid widens any (direction, tile) mask its sample left empty to "any tile may sit there", because a tile the sample only ever showed at its own edge would otherwise be unplaceable. That fallback is often what you want and it is never what you want silently: it fabricates adjacencies the sample never contained, and — because adjacency has to stay symmetric — filling one mask also tells every other tile that this one may sit on its far side. One empty mask therefore injects 2 x tileCount pairs, so a handful of them can invent most of the table. Measured on the terrain source behind wfc_examples.md: at 1x1 tiles no mask is empty and nothing is invented, while at 2x2 tiles 10 of 144 masks are empty and the resulting table is 282 observed pairs plus 704 invented ones.

None of that is visible in allows, which is the problem. These four are how a caller — and the figure gallery — can hold the model to what the sample actually showed.

fun wasWidened(direction: Int, tile: Int): Boolean

Did the widening fallback fill in allowed[direction](../../../../../../reference/source/jvmMain/afield/study/evidence/StudyEvidence.md#L383) because the sample left it empty?

    fun wasWidened(direction: Int, tile: Int): Boolean {
        require(direction in 0 until neighborhood.directionCount) {
            "direction $direction outside 0 until ${neighborhood.directionCount}"
        }
        require(tile in 0 until tileCount) { "tile $tile outside 0 until $tileCount" }
        return widenedMasks?.get(direction)?.get(tile) ?: false
    }

val widenedMaskCount: Int

How many (direction, tile) masks the widening fallback filled in; 0 when it never fired.

    val widenedMaskCount: Int
        get() = widenedMasks?.sumOf { row -> row.count { it } } ?: 0

fun observedAllows(direction: Int, a: Int, b: Int): Boolean

allows, restricted to what the sample actually contained — false for a pair that exists only because of the widening fallback.

This is the predicate to assert a learned model against. allows is the right question for "is this output legal", since the solver enforces the widened table; this is the right question for "did the output reuse the source's structure", which the widened table cannot answer because it permits pairs the source never showed.

    fun observedAllows(direction: Int, a: Int, b: Int): Boolean {
        val table = observedAllowed ?: return allows(direction, a, b)
        require(direction in 0 until neighborhood.directionCount) {
            "direction $direction outside 0 until ${neighborhood.directionCount}"
        }
        require(a in 0 until tileCount) { "tile $a outside 0 until $tileCount" }
        require(b in 0 until tileCount) { "tile $b outside 0 until $tileCount" }
        return table[direction][a].containsBit(b)
    }

fun observedAndInventedPairCounts(): Pair<Int, Int>

How many allowed pairs exist only because of widening, and how many the sample showed.

Returned as a pair of (observed, invented) counts over all directions, so a caller can print the ratio that says how much of this table is evidence and how much is fallback.

    fun observedAndInventedPairCounts(): Pair<Int, Int> {
        var observed = 0
        var invented = 0
        var d = 0
        while (d < neighborhood.directionCount) {
            var a = 0
            while (a < tileCount) {
                var b = 0
                while (b < tileCount) {
                    if (allows(d, a, b)) {
                        if (observedAllows(d, a, b)) observed++ else invented++
                    }
                    b++
                }
                a++
            }
            d++
        }
        return observed to invented
    }

override fun toString(): String

    override fun toString(): String =
        "WfcRules($tileCount tiles, ${neighborhood.directionCount} directions" +
            (if (findAsymmetry() == null) "" else ", ASYMMETRIC") +
            (if (widenedMaskCount == 0) "" else ", $widenedMaskCount widened mask" +
                (if (widenedMaskCount == 1) "" else "s")) + ")"

override fun toHTML(): String = wfcRulesHtml(this)

The rule set's own inline view: one adjacency matrix per direction plus the weights strip, with the three things that silently break a solve — empty masks, asymmetry, zero weights — marked.

Delegates to afield.simulation.automata.wfcRulesHtml in wfc_field.kt; see its KDoc.

companion object

fun fromSampleGrid

body — 64 lines, from line 601
        fun fromSampleGrid(
            sampleTileIds: IntArray,
            sampleWidth: Int,
            sampleHeight: Int,
            tileCount: Int,
            wrapX: Boolean = false,
            wrapY: Boolean = false,
            neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann
        ): WfcRules {
            require(sampleWidth > 0 && sampleHeight > 0)
            require(sampleTileIds.size == sampleWidth * sampleHeight)
            require(tileCount > 0)

            val wordCount = wordsFor(tileCount)
            val weights = DoubleArray(tileCount)
            val allowed = Array(neighborhood.directionCount) { Array(tileCount) { LongArray(wordCount) } }

            var i = 0
            while (i < sampleTileIds.size) {
                require(sampleTileIds[i] in 0 until tileCount) { "Sample tile id ${sampleTileIds[i]} is outside 0 until $tileCount" }
                weights[sampleTileIds[i]] += 1.0
                i++
            }

            var y = 0
            while (y < sampleHeight) {
                var x = 0
                while (x < sampleWidth) {
                    val a = sampleTileIds[x + y * sampleWidth]
                    var d = 0
                    while (d < neighborhood.directionCount) {
                        val offset = neighborhood.offset(d)
                        var nx = x + offset.dx
                        var ny = y + offset.dy
                        if (nx < 0 || nx >= sampleWidth) {
                            if (wrapX) nx = wrapIndex(nx, sampleWidth) else { d++; continue }
                        }
                        if (ny < 0 || ny >= sampleHeight) {
                            if (wrapY) ny = wrapIndex(ny, sampleHeight) else { d++; continue }
                        }
                        val b = sampleTileIds[nx + ny * sampleWidth]
                        allowed[d][a].setBit(b)
                        allowed[neighborhood.opposite(d)][b].setBit(a)
                        d++
                    }
                    x++
                }
                y++
            }

            // Defensive fallback: if a direction was never observed for a tile, allow all tiles there.
            // This keeps edge-only sample tiles from becoming unusable in unconstrained output regions.
            //
            // Snapshot the table first, but only when the fallback is actually going to fire —
            // keeping the observed table costs a second copy of every bitset, and for the common
            // case (no empty mask, so nothing invented) there is nothing to distinguish it from.
            val anyEmpty = (0 until neighborhood.directionCount).any { d ->
                (0 until tileCount).any { t -> allowed[d][t].isEmptyBits() }
            }
            val observed = if (anyEmpty) copyAllowed(allowed) else null
            val widened = widenEmptyMasksSymmetrically(allowed, neighborhood, tileCount, wordCount)

            return WfcRules(tileCount, weights, neighborhood, allowed, if (anyEmpty) widened else null, observed)
        }

fun fromOverlappingIntPatterns

        fun fromOverlappingIntPatterns(
            patterns: Array<IntArray>,
            patternWidth: Int,
            patternHeight: Int,
            samplePatternIds: IntArray? = null,
            neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann
        ): WfcRules {
            require(patterns.isNotEmpty())
            require(patternWidth > 0 && patternHeight > 0)
            val tileCount = patterns.size
            var i = 0
            while (i < tileCount) {
                require(patterns[i].size == patternWidth * patternHeight)
                i++
            }

            val wordCount = wordsFor(tileCount)
            val weights = weightsFromSampleIds(tileCount, samplePatternIds)
            val allowed = Array(neighborhood.directionCount) { Array(tileCount) { LongArray(wordCount) } }

            var d = 0
            while (d < neighborhood.directionCount) {
                val offset = neighborhood.offset(d)
                var a = 0
                while (a < tileCount) {
                    var b = 0
                    while (b < tileCount) {
                        if (intPatternsOverlap(patterns[a], patterns[b], patternWidth, patternHeight, offset.dx, offset.dy)) {
                            allowed[d][a].setBit(b)
                        }
                        b++
                    }
                    a++
                }
                d++
            }

            return WfcRules(tileCount, weights, neighborhood, allowed)
        }

fun fromOverlappingFloatPatterns

        fun fromOverlappingFloatPatterns(
            patterns: Array<FloatArray>,
            patternWidth: Int,
            patternHeight: Int,
            samplePatternIds: IntArray? = null,
            neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann
        ): WfcRules {
            require(patterns.isNotEmpty())
            require(patternWidth > 0 && patternHeight > 0)
            val tileCount = patterns.size
            var i = 0
            while (i < tileCount) {
                require(patterns[i].size == patternWidth * patternHeight)
                i++
            }

            val wordCount = wordsFor(tileCount)
            val weights = weightsFromSampleIds(tileCount, samplePatternIds)
            val allowed = Array(neighborhood.directionCount) { Array(tileCount) { LongArray(wordCount) } }

            var d = 0
            while (d < neighborhood.directionCount) {
                val offset = neighborhood.offset(d)
                var a = 0
                while (a < tileCount) {
                    var b = 0
                    while (b < tileCount) {
                        if (floatPatternsOverlap(patterns[a], patterns[b], patternWidth, patternHeight, offset.dx, offset.dy)) {
                            allowed[d][a].setBit(b)
                        }
                        b++
                    }
                    a++
                }
                d++
            }

            return WfcRules(tileCount, weights, neighborhood, allowed)
        }

fun build

Assemble rules from hand-authored adjacency, rather than learning them from a sample.

Every other factory here infers what may touch what by looking at a sample grid or a set of overlapping patterns. That is the point of WFC, but it is a bad way to learn WFC: the constraints stay implicit in a picture. This factory is the explicit form — you name the tiles by integer ID and say which pairs may be neighbours:

val rules = wfcRules(tileCount = 3) {
    weight(SEA, 3.0)                  // sea is three times as likely as anything else
    allow(WFC_EAST, SEA, SEA)         // sea may sit east of sea
    allow(WFC_EAST, SEA, SAND)        // ... and sand east of sea
    allow(WFC_EAST, SAND, GRASS)      // but grass never touches sea
}

Unlike fromSampleGrid, nothing is widened for you: a (direction, tile) pair you never mention stays empty, which says "no tile may sit there at all". That is a real constraint (a tile with no legal east neighbour can only occur on the east edge) and sometimes a deliberate one, so it is left exactly as authored — but it is also the most common authoring mistake, and the usual symptom is an immediate WfcStatus.Contradiction. WfcRulesBuilder.allowMissingAnything opts in to the fromSampleGrid fallback if that is what you meant.

@param tileCount how many distinct tile IDs exist; IDs are 0 until tileCount @param neighborhood which relative offsets are constrained, default WfcNeighborhood.VonNeumann

        fun build(
            tileCount: Int,
            neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann,
            block: WfcRulesBuilder.() -> Unit
        ): WfcRules {
            require(tileCount > 0) { "tileCount must be positive, got $tileCount" }
            val builder = WfcRulesBuilder(tileCount, neighborhood)
            builder.block()
            return WfcRules(
                tileCount,
                builder.weightSnapshot(),
                neighborhood,
                builder.allowedSnapshot(),
                builder.widenedSnapshot(),
                builder.observedSnapshot()
            )
        }
    }
}

class WfcRulesBuilder internal constructor

Accumulates hand-authored WFC adjacency; see WfcRules.build, which is how you get one.

Adjacency here is always symmetric, exactly as it is when learned from a sample: saying that b may sit east of a also says that a may sit west of b. You therefore only ever author half the directions of a WfcNeighborhood — for the default von Neumann set, east and south are enough.

class WfcRulesBuilder internal constructor(
    val tileCount: Int,
    val neighborhood: WfcNeighborhood
) {
    private val wordCount = wordsFor(tileCount)
    private val weights = DoubleArray(tileCount) { 1.0 }
    private val allowed = Array(neighborhood.directionCount) { Array(tileCount) { LongArray(wordCount) } }
    private var widened: Array<BooleanArray>? = null
    private var observed: Array<Array<LongArray>>? = null

fun weight(tile: Int, weight: Double)

Set the relative frequency of tile. Weights bias both which tile a collapse picks and the entropy that decides which cell to collapse next; they default to 1.0 (uniform).

    fun weight(tile: Int, weight: Double) {
        requireTile(tile)
        require(weight > 0.0) { "WFC tile weights must be positive, got $weight for tile $tile" }
        weights[tile] = weight
    }

fun weights(vararg values: Double)

Set every tile weight at once, in tile-ID order.

    fun weights(vararg values: Double) {
        require(values.size == tileCount) { "expected $tileCount weights, got ${values.size}" }
        var t = 0
        while (t < values.size) {
            weight(t, values[t])
            t++
        }
    }

fun allow(direction: Int, a: Int, b: Int)

Allow tile b to sit at direction from tile a — and, symmetrically, a at the opposite direction from b.

@param direction an index into neighborhood; for the default von Neumann set that is one of WFC_NORTH, WFC_EAST, WFC_SOUTH, WFC_WEST

    fun allow(direction: Int, a: Int, b: Int) {
        require(direction in 0 until neighborhood.directionCount) {
            "direction $direction outside 0 until ${neighborhood.directionCount}"
        }
        requireTile(a)
        requireTile(b)
        allowed[direction][a].setBit(b)
        allowed[neighborhood.opposite(direction)][b].setBit(a)
    }

fun allow(dx: Int, dy: Int, a: Int, b: Int)

allow, addressing the direction by its offset instead of its index.

    fun allow(dx: Int, dy: Int, a: Int, b: Int) {
        val direction = neighborhood.directionOf(dx, dy)
        require(direction >= 0) { "offset ($dx, $dy) is not in $neighborhood" }
        allow(direction, a, b)
    }

fun allowAny(direction: Int, a: Int, vararg others: Int)

allow(direction, a, b) for every b in others — the common "a tolerates any of" case.

    fun allowAny(direction: Int, a: Int, vararg others: Int) {
        for (b in others) allow(direction, a, b)
    }

fun allowMissingAnything()

Fill in "anything goes" for every (direction, tile) pair still left empty, which is the defensive fallback WfcRules.fromSampleGrid applies to sample tiles it only ever saw at an edge. Off by default for hand-authored rules, because there an empty mask is more often a typo than an intention.

Widening stays symmetric: saying "anything may sit east of t" also tells every one of those tiles that t may sit to their west. Doing only half of that produces rules the solver cannot enforce — see widenEmptyMasksSymmetrically.

What it filled in is recorded, so the finished rules can still be asked what you actually authored: see WfcRules.wasWidened and WfcRules.observedAllows.

    fun allowMissingAnything() {
        if (observed == null) observed = copyAllowed(allowed)
        val justWidened = widenEmptyMasksSymmetrically(allowed, neighborhood, tileCount, wordCount)
        val soFar = widened
        widened = if (soFar == null) justWidened
        else Array(soFar.size) { d -> BooleanArray(tileCount) { t -> soFar[d][t] || justWidened[d][t] } }
    }

    private fun requireTile(tile: Int) {
        require(tile in 0 until tileCount) { "tile $tile outside 0 until $tileCount" }
    }

internal fun weightSnapshot(): DoubleArray = weights.copyOf()

internal fun allowedSnapshot(): Array<Array<LongArray>>

    internal fun allowedSnapshot(): Array<Array<LongArray>> =
        Array(allowed.size) { d -> Array(tileCount) { t -> allowed[d][t].copyOf() } }

internal fun widenedSnapshot(): Array<BooleanArray>?

    internal fun widenedSnapshot(): Array<BooleanArray>? =
        widened?.let { w -> Array(w.size) { d -> w[d].copyOf() } }

internal fun observedSnapshot(): Array<Array<LongArray>>? = observed?.let { copyAllowed(it) }

fun wfcRules

Hand-author WFC adjacency constraints; shorthand for WfcRules.build.

val rules = wfcRules(tileCount = 2) {
    allow(WFC_EAST, 0, 1)
    allow(WFC_SOUTH, 0, 1)
}
fun wfcRules(
    tileCount: Int,
    neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann,
    block: WfcRulesBuilder.() -> Unit
): WfcRules = WfcRules.build(tileCount, neighborhood, block)

How many WFC steps between progress-bar samples, keeping the resolved-tile scan off the hot path.

private const val PROGRESS_STRIDE = 16

enum class WfcStatus { Running, Solved, Contradiction }

data class WfcObservation(val x: Int, val y: Int, val tile: Int)

class WfcSolver

class WfcSolver(
    val width: Int,
    val height: Int,
    val rules: WfcRules,
    seed: Long = 1L,
    private val noise: Double = 1e-6
) : afield.ui.visualization.ToHTML {
    private val random = SplittableRandom(seed)
    private val cellCount = width * height
    private val wordCount = rules.wordCount

wave[cell * wordCount + word] is the possibility bitset for that cell.

    private val wave = LongArray(cellCount * wordCount)
    private val allowedScratch = LongArray(wordCount)

    private var stack = IntArray(cellCount * maxOf(2, rules.neighborhood.directionCount + 1))
    private var stackSize = 0

var status: WfcStatus

    var status: WfcStatus = WfcStatus.Running
        private set

var lastObservation: WfcObservation?

    var lastObservation: WfcObservation? = null
        private set

init

    init {
        require(width > 0 && height > 0)
        reset()
    }

fun reset()

Return every cell to "any tile is still possible" and clear status and lastObservation.

Does not reset the random stream: the solver's SplittableRandom is built once, from seed, and keeps going across resets. That is what makes a retry loop useful — reset() after a WfcStatus.Contradiction gives you a genuinely different attempt rather than replaying the one that just failed. See solveWithRetries.

Reproducibility is therefore a property of the whole session, not of each attempt: the same seed and the same sequence of calls give the same output, but the third attempt after two contradictions is not the same as a fresh solver's first attempt.

    fun reset() {
        val all = LongArray(wordCount)
        all.fillAllBits(rules.tileCount)
        var cell = 0
        while (cell < cellCount) {
            setCellBits(cell, all)
            cell++
        }
        stackSize = 0
        status = WfcStatus.Running
        lastObservation = null
    }

fun possibilitiesAt(x: Int, y: Int, out: IntArray): Int

    fun possibilitiesAt(x: Int, y: Int, out: IntArray): Int {
        val cell = index(x, y)
        var n = 0
        var w = 0
        while (w < wordCount) {
            var bits = wave[cell * wordCount + w]
            while (bits != 0L) {
                val bit = java.lang.Long.numberOfTrailingZeros(bits)
                val id = (w shl 6) + bit
                if (id < rules.tileCount && n < out.size) out[n++] = id
                bits = bits and (bits - 1L)
            }
            w++
        }
        return n
    }

fun possibilityCountAt(x: Int, y: Int): Int = cellBitCount(index(x, y))

fun resolvedTileAt(x: Int, y: Int): Int

    fun resolvedTileAt(x: Int, y: Int): Int {
        val cell = index(x, y)
        return if (cellBitCount(cell) == 1) firstCellBit(cell) else -1
    }

fun forceTile(x: Int, y: Int, tile: Int): Boolean

    fun forceTile(x: Int, y: Int, tile: Int): Boolean {
        require(tile in 0 until rules.tileCount)
        if (status != WfcStatus.Running) return false
        val cell = index(x, y)
        keepOnlyCellBit(cell, tile)
        push(cell)
        propagate()
        return status != WfcStatus.Contradiction
    }

fun step(): WfcStatus

One visualizable WFC iteration: observe one cell, then fully propagate constraints.

    fun step(): WfcStatus {
        if (status != WfcStatus.Running) return status

        val cell = findLowestEntropyCell()
        if (cell < 0) {
            status = WfcStatus.Solved
            return status
        }

        val chosen = chooseWeightedTile(cell)
        keepOnlyCellBit(cell, chosen)
        lastObservation = WfcObservation(cell % width, cell / width, chosen)
        push(cell)
        propagate()
        return status
    }

fun run(maxSteps: Int = Int.MAX_VALUE): WfcStatus

    fun run(maxSteps: Int = Int.MAX_VALUE): WfcStatus {
        // Offer resolved-tiles / total-tiles to whichever box is running us (no-op if none).
        // The count isn't guaranteed monotonic, so we clamp the running bar below 100% and only
        // report completion when the solver itself reaches Solved.
        val progress = afield.ui.output.progressReporter("wfc")
        var steps = 0
        try {
            while (steps < maxSteps && status == WfcStatus.Running) {
                step()
                steps++
                if (steps % PROGRESS_STRIDE == 0) {
                    val resolved = resolvedCellCount().coerceAtMost(cellCount - 1)
                    progress.update(resolved.toLong(), cellCount.toLong())
                }
            }
            if (status == WfcStatus.Solved) progress.update(cellCount.toLong(), cellCount.toLong())
        } finally {
            progress.finish()
        }
        return status
    }

fun resolvedCellCount(): Int

Cells collapsed to a single possibility — the WFC notion of "done" is all of them.

Not monotonic in general: propagation can decide several cells at once, and this is a full scan rather than a maintained counter, so treat it as a progress read-out (see afield.simulation.automata.resolvedFraction) and not as a step count.

    fun resolvedCellCount(): Int {
        var resolved = 0
        var cell = 0
        while (cell < cellCount) {
            if (cellBitCount(cell) == 1) resolved++
            cell++
        }
        return resolved
    }

fun resolvedTileGridOrNull(): IntArray?

    fun resolvedTileGridOrNull(): IntArray? {
        if (status != WfcStatus.Solved) return null
        val out = IntArray(cellCount)
        var cell = 0
        while (cell < cellCount) {
            out[cell] = firstCellBit(cell)
            cell++
        }
        return out
    }

override fun toString(): String

    override fun toString(): String =
        "WfcSolver(${width}x$height, ${rules.tileCount} tiles, $status, " +
            "${resolvedCellCount()}/$cellCount decided)"

override fun toHTML(): String = wfcSolverHtml(this)

The solver's own inline view, so that evaluating it in a box — or solver.h — shows the collapse: remaining possibilities per cell, entropy, what has been decided, and any contradiction, each panel captioned with the colour scale that produced it.

Delegates to afield.simulation.automata.wfcSolverHtml in wfc_field.kt; see its KDoc for the panels and the range policy. This is the only line in this file that looks outside it.

source — 179 lines, from line 1089
    private fun index(x: Int, y: Int): Int {
        require(x in 0 until width && y in 0 until height)
        return x + y * width
    }

    private fun setCellBits(cell: Int, bits: LongArray) {
        System.arraycopy(bits, 0, wave, cell * wordCount, wordCount)
    }

    private fun cellBitCount(cell: Int): Int {
        var c = 0
        var w = 0
        val base = cell * wordCount
        while (w < wordCount) {
            c += java.lang.Long.bitCount(wave[base + w])
            w++
        }
        return c
    }

    private fun firstCellBit(cell: Int): Int {
        var w = 0
        val base = cell * wordCount
        while (w < wordCount) {
            val bits = wave[base + w]
            if (bits != 0L) return (w shl 6) + java.lang.Long.numberOfTrailingZeros(bits)
            w++
        }
        return -1
    }

    private fun keepOnlyCellBit(cell: Int, tile: Int) {
        val base = cell * wordCount
        var w = 0
        while (w < wordCount) {
            wave[base + w] = 0L
            w++
        }
        wave[base + wordIndex(tile)] = bitMask(tile)
    }

    private fun push(cell: Int) {
        if (stackSize == stack.size) stack = stack.copyOf(stack.size * 2)
        stack[stackSize++] = cell
    }

    private fun findLowestEntropyCell(): Int {
        var bestCell = -1
        var bestScore = Double.POSITIVE_INFINITY
        var cell = 0
        while (cell < cellCount) {
            val count = cellBitCount(cell)
            if (count == 0) {
                status = WfcStatus.Contradiction
                return -1
            }
            if (count > 1) {
                var sum = 0.0
                var sumLog = 0.0
                var w = 0
                val base = cell * wordCount
                while (w < wordCount) {
                    var bits = wave[base + w]
                    while (bits != 0L) {
                        val b = java.lang.Long.numberOfTrailingZeros(bits)
                        val tile = (w shl 6) + b
                        if (tile < rules.tileCount) {
                            val weight = rules.weights[tile]
                            sum += weight
                            sumLog += weight * ln(weight)
                        }
                        bits = bits and (bits - 1L)
                    }
                    w++
                }
                val entropy = ln(sum) - sumLog / sum
                val score = entropy + random.nextDouble() * noise
                if (score < bestScore) {
                    bestScore = score
                    bestCell = cell
                }
            }
            cell++
        }
        return bestCell
    }

    private fun chooseWeightedTile(cell: Int): Int {
        var total = 0.0
        var w = 0
        val base = cell * wordCount
        while (w < wordCount) {
            var bits = wave[base + w]
            while (bits != 0L) {
                val b = java.lang.Long.numberOfTrailingZeros(bits)
                val tile = (w shl 6) + b
                if (tile < rules.tileCount) total += rules.weights[tile]
                bits = bits and (bits - 1L)
            }
            w++
        }

        var r = random.nextDouble(total)
        w = 0
        while (w < wordCount) {
            var bits = wave[base + w]
            while (bits != 0L) {
                val b = java.lang.Long.numberOfTrailingZeros(bits)
                val tile = (w shl 6) + b
                if (tile < rules.tileCount) {
                    r -= rules.weights[tile]
                    if (r <= 0.0) return tile
                }
                bits = bits and (bits - 1L)
            }
            w++
        }
        return firstCellBit(cell)
    }

    private fun propagate() {
        while (stackSize > 0 && status == WfcStatus.Running) {
            val cell = stack[--stackSize]
            val x = cell % width
            val y = cell / width

            var d = 0
            while (d < rules.neighborhood.directionCount) {
                val offset = rules.neighborhood.offset(d)
                val nx = x + offset.dx
                val ny = y + offset.dy
                if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                    val neighbor = nx + ny * width
                    buildAllowedFromCell(cell, d, allowedScratch)
                    if (intersectCell(neighbor, allowedScratch)) {
                        if (cellBitCount(neighbor) == 0) {
                            status = WfcStatus.Contradiction
                            return
                        }
                        push(neighbor)
                    }
                }
                d++
            }
        }
    }

    private fun buildAllowedFromCell(cell: Int, direction: Int, out: LongArray) {
        out.clearAll()
        val base = cell * wordCount
        var w = 0
        while (w < wordCount) {
            var bits = wave[base + w]
            while (bits != 0L) {
                val b = java.lang.Long.numberOfTrailingZeros(bits)
                val tile = (w shl 6) + b
                if (tile < rules.tileCount) out.orInPlace(rules.allowed[direction][tile])
                bits = bits and (bits - 1L)
            }
            w++
        }
    }

    private fun intersectCell(cell: Int, mask: LongArray): Boolean {
        var changed = false
        val base = cell * wordCount
        var w = 0
        while (w < wordCount) {
            val old = wave[base + w]
            val next = old and mask[w]
            if (next != old) {
                wave[base + w] = next
                changed = true
            }
            w++
        }
        return changed
    }
}

data class WfcOutcome(val status: WfcStatus, val attempts: Int)

What solveWithRetries ended up doing: the final status and how many attempts it took.

val solved: Boolean get() = status == WfcStatus.Solved

fun WfcSolver.solveWithRetries(maxAttempts: Int = 16, prepare: WfcSolver.() -> Unit

Run the solver to completion, restarting after a contradiction, up to maxAttempts times.

This solver does not backtrack. When propagation empties some cell's possibility set the run is over — there is no undo stack to unwind to the choice that doomed it. The standard remedy, and the one the original WFC used, is simply to start again: contradictions are usually the product of an unlucky early choice, so a fresh attempt on the same rules very often succeeds.

Restarting works because WfcSolver.reset keeps the random stream running (see its KDoc), so each attempt explores a different set of choices. A solver that has exhausted maxAttempts is left in whatever state its last attempt reached.

val outcome = solver.solveWithRetries()
if (!outcome.solved) println("still stuck after ${outcome.attempts} attempts — loosen the rules")

WfcSolver.reset clears the whole wave, boundary conditions included, so anything you pinned with WfcSolver.forceTile has to be re-applied on every attempt. That is what prepare is for — it runs on the fresh solver before each attempt, including the first:

val outcome = solver.solveWithRetries { for (x in 0 until width) forceTile(x, height - 1, groundTile) }

If this needs a large maxAttempts to succeed, that is a signal about the rules, not the seed: over-constrained adjacency, or an output much larger than the sample can consistently tile.

@param prepare boundary conditions to (re-)apply before each attempt; may itself provoke a contradiction, which simply costs that attempt

fun WfcSolver.solveWithRetries(maxAttempts: Int = 16, prepare: WfcSolver.() -> Unit = {}): WfcOutcome {
    require(maxAttempts >= 1) { "maxAttempts must be at least 1, got $maxAttempts" }
    var attempt = 1
    while (true) {
        prepare()
        if (run() == WfcStatus.Solved) return WfcOutcome(WfcStatus.Solved, attempt)
        if (attempt >= maxAttempts) return WfcOutcome(status, attempt)
        attempt++
        reset()
    }
}

enum class WfcSymmetry

Which symmetries of the square a learned pattern set should be closed under.

WFC learns from observed windows, so it learns your source's orientation along with its content. That is right when the source has a real up — sky over soil, text, a tileset drawn with lighting from one side. It is wrong, and quite badly wrong, when the source is a sample of something with no preferred direction, because the model then reproduces whichever orientation the source happened to contain most of and tiles the plane with it.

The classic symptom is in wfc_examples.md §7: a concentric island, sliced into 3×3 windows, generates horizontal stripes. Nothing is broken — the wide top and bottom of a concentric shape contribute far more "changes vertically, constant horizontally" windows than its narrow left and right edges contribute the reverse, so that is the arrangement the model believes in. Closing the pattern set under All takes the anisotropy from 7.7× to 1.0× and turns the stripes into plateaus and basins.

Use None when up matters. Use All for heightfields, masks, noise, terrain — anything where a rotated copy of a legal arrangement is also legal. Rotations and Reflections are there for the in-between cases: a source with a mirror symmetry but a real up wants Reflections alone.

The cost is pattern count, which drives both rule learning (every pattern is compared against every other) and the per-step entropy scan: on that same source, 180 patterns become 541 under All. It is not ×8 — variants that coincide are deduplicated, and a window that is already symmetric contributes nothing new.

@see FloatOverlappingTileSet.expandSymmetry @see PngOverlappingTileSet.expandSymmetry

enum class WfcSymmetry(
    /** How many 90° rotations to include, 1 (identity only) or 4. */
    internal val rotations: Int,
    /** Whether each rotation also contributes its mirror image. */
    internal val reflect: Boolean
) {

Patterns exactly as observed. The default everywhere, and correct when the source has an up.

    None(1, false),

Each pattern plus its mirror image. For a source with left-right symmetry but a real up.

    Reflections(1, true),

Each pattern plus its three 90° rotations.

    Rotations(4, false),

The full symmetry group of the square: four rotations, each with its mirror. Eight variants.

    All(4, true);

val variantCount: Int get() = rotations * (if (reflect) 2 else 1)

How many variants of one pattern this generates, before deduplication.

One 90° clockwise rotation of a square row-major pattern.

private fun rotatePatternInts(p: IntArray, n: Int): IntArray {
    val out = IntArray(n * n)
    for (y in 0 until n) for (x in 0 until n) out[(n - 1 - y) + x * n] = p[x + y * n]
    return out
}

Mirror a row-major pattern left-to-right. Works for non-square patterns too.

private fun mirrorPatternInts(p: IntArray, w: Int, h: Int): IntArray {
    val out = IntArray(w * h)
    for (y in 0 until h) for (x in 0 until w) out[(w - 1 - x) + y * w] = p[x + y * w]
    return out
}

private fun rotatePatternFloats(p: FloatArray, n: Int): FloatArray {
    val out = FloatArray(n * n)
    for (y in 0 until n) for (x in 0 until n) out[(n - 1 - y) + x * n] = p[x + y * n]
    return out
}

private fun mirrorPatternFloats(p: FloatArray, w: Int, h: Int): FloatArray {
    val out = FloatArray(w * h)
    for (y in 0 until h) for (x in 0 until w) out[(w - 1 - x) + y * w] = p[x + y * w]
    return out
}

The shared body of both expandSymmetry implementations, over an opaque pattern type.

Kept generic because the only thing that differs between the int and float cases is how a pattern is rotated, mirrored and hashed — and getting the weight arithmetic wrong in two places independently is exactly the kind of thing that produces a subtly wrong model twice.

Weight handling is the part worth stating. A pattern observed k times contributes k / v to each of its v variants, so the expansion is weight-preserving: the total stays k, and a pattern does not become eight times more likely merely because it has eight distinct orientations. Variants that coincide accumulate, which is why an already-symmetric window keeps its full weight instead of being diluted.

@return the deduplicated variant list, its weights, and oldId -> newId for the identity variants

source — 41 lines, from line 1408
private fun <P> expandPatternsUnderSymmetry(
    patterns: Array<P>,
    baseWeights: DoubleArray,
    symmetry: WfcSymmetry,
    rotate: (P) -> P,
    mirror: (P) -> P,
    key: (P) -> Any
): Triple<List<P>, DoubleArray, IntArray> {
    val index = HashMap<Any, Int>()
    val out = ArrayList<P>()
    val weights = ArrayList<Double>()
    val remap = IntArray(patterns.size)

    for (i in patterns.indices) {
        val variants = ArrayList<P>(symmetry.variantCount)
        var current = patterns[i]
        var r = 0
        while (r < symmetry.rotations) {
            variants.add(current)
            if (symmetry.reflect) variants.add(mirror(current))
            current = rotate(current)
            r++
        }
        val share = baseWeights[i] / variants.size
        for ((v, variant) in variants.withIndex()) {
            val k = key(variant)
            val at = index[k]
            if (at == null) {
                index[k] = out.size
                // The identity variant is always first, so this is where oldId lands.
                if (v == 0) remap[i] = out.size
                out.add(variant)
                weights.add(share)
            } else {
                if (v == 0) remap[i] = at
                weights[at] = weights[at] + share
            }
        }
    }
    return Triple(out, weights.toDoubleArray(), remap)
}

class PngTileSet

A fixed-size tile atlas extracted from a PNG. Pixels are ARGB ints.

class PngTileSet(
    val tileWidth: Int,
    val tileHeight: Int,
    val tiles: Array<IntArray>,
    val sampleIds: IntArray,
    val sampleWidthInTiles: Int,
    val sampleHeightInTiles: Int
) {

val tileCount: Int get() = tiles.size

fun toRules

    fun toRules(
        wrapX: Boolean = false,
        wrapY: Boolean = false,
        neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann
    ): WfcRules =
        WfcRules.fromSampleGrid(sampleIds, sampleWidthInTiles, sampleHeightInTiles, tileCount, wrapX, wrapY, neighborhood)

fun render(tileIds: IntArray, outWidthInTiles: Int, outHeightInTiles: Int): BufferedImage

    fun render(tileIds: IntArray, outWidthInTiles: Int, outHeightInTiles: Int): BufferedImage {
        require(tileIds.size == outWidthInTiles * outHeightInTiles)
        val image = BufferedImage(outWidthInTiles * tileWidth, outHeightInTiles * tileHeight, BufferedImage.TYPE_INT_ARGB)
        var ty = 0
        while (ty < outHeightInTiles) {
            var tx = 0
            while (tx < outWidthInTiles) {
                val tile = tiles[tileIds[tx + ty * outWidthInTiles]]
                var py = 0
                while (py < tileHeight) {
                    var px = 0
                    while (px < tileWidth) {
                        image.setRGB(tx * tileWidth + px, ty * tileHeight + py, tile[px + py * tileWidth])
                        px++
                    }
                    py++
                }
                tx++
            }
            ty++
        }
        return image
    }
}

class PngOverlappingTileSet

Overlapping WFC patterns extracted from a PNG. Each solver cell is a pattern anchor.

class PngOverlappingTileSet(
    val patternWidth: Int,
    val patternHeight: Int,
    val patterns: Array<IntArray>,
    val sampleIds: IntArray,
    val sampleWidthInPatterns: Int,
    val sampleHeightInPatterns: Int,
    /**
     * Explicit per-pattern weights, or null to count occurrences in [sampleIds] as usual.
     *
     * Set by [expandSymmetry], whose extra variants were never *observed* anywhere in the sample and
     * so have no occurrence count to derive a weight from — counting would hand every one of them a
     * zero weight and make them unchoosable with no warning.
     */
    val patternWeights: DoubleArray? = null
) {

val tileCount: Int get() = patterns.size

fun toRules(neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann): WfcRules

    fun toRules(neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann): WfcRules {
        val rules = WfcRules.fromOverlappingIntPatterns(
            patterns, patternWidth, patternHeight,
            if (patternWeights == null) sampleIds else null, neighborhood
        )
        patternWeights?.let { it.copyInto(rules.weights) }
        return rules
    }

fun render

    fun render(
        patternIds: IntArray,
        outWidth: Int,
        outHeight: Int,
        originX: Int = 0,
        originY: Int = 0
    ): BufferedImage {
        require(patternIds.size == outWidth * outHeight)
        val image = BufferedImage(outWidth, outHeight, BufferedImage.TYPE_INT_ARGB)
        val ox = wrapIndex(originX, patternWidth)
        val oy = wrapIndex(originY, patternHeight)
        val origin = ox + oy * patternWidth

        var y = 0
        while (y < outHeight) {
            var x = 0
            while (x < outWidth) {
                val pattern = patterns[patternIds[x + y * outWidth]]
                image.setRGB(x, y, pattern[origin])
                x++
            }
            y++
        }
        return image
    }
}

fun BufferedImage.sliceIntoUniqueTiles(tileWidth: Int, tileHeight: Int): PngTileSet

fun BufferedImage.sliceIntoUniqueTiles(tileWidth: Int, tileHeight: Int): PngTileSet {
    require(tileWidth > 0 && tileHeight > 0)
    require(width % tileWidth == 0 && height % tileHeight == 0) {
        "Image dimensions must be divisible by tile size. image=${width}x$height tile=${tileWidth}x$tileHeight"
    }

    val sw = width / tileWidth
    val sh = height / tileHeight
    val sampleIds = IntArray(sw * sh)
    val prototypes = ArrayList<IntArray>()
    val map = HashMap<IntArrayKey, Int>()

    var ty = 0
    while (ty < sh) {
        var tx = 0
        while (tx < sw) {
            val pixels = IntArray(tileWidth * tileHeight)
            var py = 0
            while (py < tileHeight) {
                var px = 0
                while (px < tileWidth) {
                    pixels[px + py * tileWidth] = getRGB(tx * tileWidth + px, ty * tileHeight + py)
                    px++
                }
                py++
            }
            val key = IntArrayKey(pixels)
            val id = map.getOrPut(key) {
                prototypes.add(pixels)
                prototypes.lastIndex
            }
            sampleIds[tx + ty * sw] = id
            tx++
        }
        ty++
    }

    return PngTileSet(tileWidth, tileHeight, prototypes.toTypedArray(), sampleIds, sw, sh)
}

fun loadPngTiles(file: File, tileWidth: Int, tileHeight: Int): PngTileSet

fun loadPngTiles(file: File, tileWidth: Int, tileHeight: Int): PngTileSet =
    ImageIO.read(file).sliceIntoUniqueTiles(tileWidth, tileHeight)

fun BufferedImage.sliceIntoOverlappingPatterns

body — 50 lines, from line 1591
fun BufferedImage.sliceIntoOverlappingPatterns(
    patternWidth: Int,
    patternHeight: Int,
    wrapX: Boolean = true,
    wrapY: Boolean = true
): PngOverlappingTileSet {
    require(width > 0 && height > 0)
    require(patternWidth > 0 && patternHeight > 0)
    require(wrapX || patternWidth <= width) {
        "Pattern width must fit inside image when wrapX=false. imageWidth=$width patternWidth=$patternWidth"
    }
    require(wrapY || patternHeight <= height) {
        "Pattern height must fit inside image when wrapY=false. imageHeight=$height patternHeight=$patternHeight"
    }

    val sw = if (wrapX) width else width - patternWidth + 1
    val sh = if (wrapY) height else height - patternHeight + 1
    val sampleIds = IntArray(sw * sh)
    val prototypes = ArrayList<IntArray>()
    val map = HashMap<IntArrayKey, Int>()

    var ay = 0
    while (ay < sh) {
        var ax = 0
        while (ax < sw) {
            val pixels = IntArray(patternWidth * patternHeight)
            var py = 0
            while (py < patternHeight) {
                var px = 0
                while (px < patternWidth) {
                    val sx = if (wrapX) wrapIndex(ax + px, width) else ax + px
                    val sy = if (wrapY) wrapIndex(ay + py, height) else ay + py
                    pixels[px + py * patternWidth] = getRGB(sx, sy)
                    px++
                }
                py++
            }
            val key = IntArrayKey(pixels)
            val id = map.getOrPut(key) {
                prototypes.add(pixels)
                prototypes.lastIndex
            }
            sampleIds[ax + ay * sw] = id
            ax++
        }
        ay++
    }

    return PngOverlappingTileSet(patternWidth, patternHeight, prototypes.toTypedArray(), sampleIds, sw, sh)
}

fun loadPngOverlappingPatterns

fun loadPngOverlappingPatterns(
    file: File,
    patternWidth: Int,
    patternHeight: Int,
    wrapX: Boolean = true,
    wrapY: Boolean = true
): PngOverlappingTileSet =
    ImageIO.read(file).sliceIntoOverlappingPatterns(patternWidth, patternHeight, wrapX, wrapY)

fun savePng(image: BufferedImage, file: File)

fun savePng(image: BufferedImage, file: File) {
    ImageIO.write(image, "png", file)
}

class FloatTileSet

A fixed-size tile atlas extracted from a row-major 2D FloatArray.

class FloatTileSet(
    val tileWidth: Int,
    val tileHeight: Int,
    val tiles: Array<FloatArray>,
    val sampleIds: IntArray,
    val sampleWidthInTiles: Int,
    val sampleHeightInTiles: Int
) {

val tileCount: Int get() = tiles.size

fun toRules

    fun toRules(
        wrapX: Boolean = false,
        wrapY: Boolean = false,
        neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann
    ): WfcRules =
        WfcRules.fromSampleGrid(sampleIds, sampleWidthInTiles, sampleHeightInTiles, tileCount, wrapX, wrapY, neighborhood)

fun render(tileIds: IntArray, outWidthInTiles: Int, outHeightInTiles: Int): FloatArray

    fun render(tileIds: IntArray, outWidthInTiles: Int, outHeightInTiles: Int): FloatArray {
        require(tileIds.size == outWidthInTiles * outHeightInTiles)
        val outW = outWidthInTiles * tileWidth
        val outH = outHeightInTiles * tileHeight
        val out = FloatArray(outW * outH)
        var ty = 0
        while (ty < outHeightInTiles) {
            var tx = 0
            while (tx < outWidthInTiles) {
                val tile = tiles[tileIds[tx + ty * outWidthInTiles]]
                var py = 0
                while (py < tileHeight) {
                    var px = 0
                    while (px < tileWidth) {
                        out[(tx * tileWidth + px) + (ty * tileHeight + py) * outW] = tile[px + py * tileWidth]
                        px++
                    }
                    py++
                }
                tx++
            }
            ty++
        }
        return out
    }
}

class FloatOverlappingTileSet

Overlapping WFC patterns extracted from a row-major 2D FloatArray.

class FloatOverlappingTileSet(
    val patternWidth: Int,
    val patternHeight: Int,
    val patterns: Array<FloatArray>,
    val sampleIds: IntArray,
    val sampleWidthInPatterns: Int,
    val sampleHeightInPatterns: Int,
    /** Explicit per-pattern weights, or null to count occurrences in [sampleIds]; see [PngOverlappingTileSet.patternWeights]. */
    val patternWeights: DoubleArray? = null
) {

val tileCount: Int get() = patterns.size

fun toRules(neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann): WfcRules

    fun toRules(neighborhood: WfcNeighborhood = WfcNeighborhood.VonNeumann): WfcRules {
        val rules = WfcRules.fromOverlappingFloatPatterns(
            patterns, patternWidth, patternHeight,
            if (patternWeights == null) sampleIds else null, neighborhood
        )
        patternWeights?.let { it.copyInto(rules.weights) }
        return rules
    }

fun render

    fun render(
        patternIds: IntArray,
        outWidth: Int,
        outHeight: Int,
        originX: Int = 0,
        originY: Int = 0
    ): FloatArray {
        require(patternIds.size == outWidth * outHeight)
        val out = FloatArray(outWidth * outHeight)
        val ox = wrapIndex(originX, patternWidth)
        val oy = wrapIndex(originY, patternHeight)
        val origin = ox + oy * patternWidth

        var y = 0
        while (y < outHeight) {
            var x = 0
            while (x < outWidth) {
                val pattern = patterns[patternIds[x + y * outWidth]]
                out[x + y * outWidth] = pattern[origin]
                x++
            }
            y++
        }
        return out
    }
}

fun FloatArray.sliceIntoUniqueTiles2D

body — 46 lines, from line 1749
fun FloatArray.sliceIntoUniqueTiles2D(
    width: Int,
    height: Int,
    tileWidth: Int,
    tileHeight: Int
): FloatTileSet {
    require(width > 0 && height > 0)
    require(size == width * height)
    require(tileWidth > 0 && tileHeight > 0)
    require(width % tileWidth == 0 && height % tileHeight == 0) {
        "Array dimensions must be divisible by tile size. array=${width}x$height tile=${tileWidth}x$tileHeight"
    }

    val sw = width / tileWidth
    val sh = height / tileHeight
    val sampleIds = IntArray(sw * sh)
    val prototypes = ArrayList<FloatArray>()
    val map = HashMap<FloatArrayKey, Int>()

    var ty = 0
    while (ty < sh) {
        var tx = 0
        while (tx < sw) {
            val values = FloatArray(tileWidth * tileHeight)
            var py = 0
            while (py < tileHeight) {
                var px = 0
                while (px < tileWidth) {
                    values[px + py * tileWidth] = this[(tx * tileWidth + px) + (ty * tileHeight + py) * width]
                    px++
                }
                py++
            }
            val key = FloatArrayKey(values)
            val id = map.getOrPut(key) {
                prototypes.add(values)
                prototypes.lastIndex
            }
            sampleIds[tx + ty * sw] = id
            tx++
        }
        ty++
    }

    return FloatTileSet(tileWidth, tileHeight, prototypes.toTypedArray(), sampleIds, sw, sh)
}

fun FloatArray.sliceIntoOverlappingPatterns2D

body — 53 lines, from line 1796
fun FloatArray.sliceIntoOverlappingPatterns2D(
    width: Int,
    height: Int,
    patternWidth: Int,
    patternHeight: Int,
    wrapX: Boolean = true,
    wrapY: Boolean = true
): FloatOverlappingTileSet {
    require(width > 0 && height > 0)
    require(size == width * height)
    require(patternWidth > 0 && patternHeight > 0)
    require(wrapX || patternWidth <= width) {
        "Pattern width must fit inside array when wrapX=false. arrayWidth=$width patternWidth=$patternWidth"
    }
    require(wrapY || patternHeight <= height) {
        "Pattern height must fit inside array when wrapY=false. arrayHeight=$height patternHeight=$patternHeight"
    }

    val sw = if (wrapX) width else width - patternWidth + 1
    val sh = if (wrapY) height else height - patternHeight + 1
    val sampleIds = IntArray(sw * sh)
    val prototypes = ArrayList<FloatArray>()
    val map = HashMap<FloatArrayKey, Int>()

    var ay = 0
    while (ay < sh) {
        var ax = 0
        while (ax < sw) {
            val values = FloatArray(patternWidth * patternHeight)
            var py = 0
            while (py < patternHeight) {
                var px = 0
                while (px < patternWidth) {
                    val sx = if (wrapX) wrapIndex(ax + px, width) else ax + px
                    val sy = if (wrapY) wrapIndex(ay + py, height) else ay + py
                    values[px + py * patternWidth] = this[sx + sy * width]
                    px++
                }
                py++
            }
            val key = FloatArrayKey(values)
            val id = map.getOrPut(key) {
                prototypes.add(values)
                prototypes.lastIndex
            }
            sampleIds[ax + ay * sw] = id
            ax++
        }
        ay++
    }

    return FloatOverlappingTileSet(patternWidth, patternHeight, prototypes.toTypedArray(), sampleIds, sw, sh)
}

fun FloatOverlappingTileSet.expandSymmetry(symmetry: WfcSymmetry): FloatOverlappingTileSet

Close this pattern set under symmetry, so the model stops caring which way up the source was.

Returns a new set whose patterns are the deduplicated orbit of the originals, with weights shared out so the expansion does not change how likely any observed arrangement is (see WfcSymmetry for what this is for and what it costs). sampleIds is remapped, so the returned set still renders and reports as the original did; patternWeights is populated, so toRules uses the shared-out weights rather than re-counting occurrences.

val patterns = source.sliceIntoOverlappingPatterns2D(w, h, 3, 3)
    .expandSymmetry(WfcSymmetry.All)      // <- terrain has no up

Rotations require square patterns; reflections do not. WfcSymmetry.None returns this.

fun FloatOverlappingTileSet.expandSymmetry(symmetry: WfcSymmetry): FloatOverlappingTileSet {
    if (symmetry == WfcSymmetry.None) return this
    require(symmetry.rotations == 1 || patternWidth == patternHeight) {
        "${symmetry.name} needs square patterns to rotate, got ${patternWidth}x$patternHeight"
    }
    val base = patternWeights ?: weightsFromSampleIds(tileCount, sampleIds)
    val (expanded, weights, remap) = expandPatternsUnderSymmetry(
        patterns, base, symmetry,
        rotate = { rotatePatternFloats(it, patternWidth) },
        mirror = { mirrorPatternFloats(it, patternWidth, patternHeight) },
        key = { FloatArrayKey(it) }
    )
    return FloatOverlappingTileSet(
        patternWidth, patternHeight, expanded.toTypedArray(),
        IntArray(sampleIds.size) { remap[sampleIds[it]] },
        sampleWidthInPatterns, sampleHeightInPatterns, weights
    )
}

fun PngOverlappingTileSet.expandSymmetry(symmetry: WfcSymmetry): PngOverlappingTileSet

FloatOverlappingTileSet.expandSymmetry for ARGB pattern sets; see its KDoc and WfcSymmetry.

fun PngOverlappingTileSet.expandSymmetry(symmetry: WfcSymmetry): PngOverlappingTileSet {
    if (symmetry == WfcSymmetry.None) return this
    require(symmetry.rotations == 1 || patternWidth == patternHeight) {
        "${symmetry.name} needs square patterns to rotate, got ${patternWidth}x$patternHeight"
    }
    val base = patternWeights ?: weightsFromSampleIds(tileCount, sampleIds)
    val (expanded, weights, remap) = expandPatternsUnderSymmetry(
        patterns, base, symmetry,
        rotate = { rotatePatternInts(it, patternWidth) },
        mirror = { mirrorPatternInts(it, patternWidth, patternHeight) },
        key = { IntArrayKey(it) }
    )
    return PngOverlappingTileSet(
        patternWidth, patternHeight, expanded.toTypedArray(),
        IntArray(sampleIds.size) { remap[sampleIds[it]] },
        sampleWidthInPatterns, sampleHeightInPatterns, weights
    )
}

private class IntArrayKey(private val data: IntArray) {
    private val hash = data.contentHashCode()
    override fun hashCode(): Int = hash
    override fun equals(other: Any?): Boolean = other is IntArrayKey && data.contentEquals(other.data)
}

private class FloatArrayKey(private val data: FloatArray) {
    private val hash = data.contentHashCode()
    override fun hashCode(): Int = hash
    override fun equals(other: Any?): Boolean = other is FloatArrayKey && data.contentEquals(other.data)
}

fun main(args: Array<String>)

Tiny CLI demo: java -jar app.jar input.png output.png tileW tileH outTilesW outTilesH seed

fun main(args: Array<String>) {
    if (args.size < 6) {
        println("Usage: input.png output.png tileW tileH outTilesW outTilesH [seed]")
        return
    }

    val input = File(args[0])
    val output = File(args[1])
    val tileW = args[2].toInt()
    val tileH = args[3].toInt()
    val outW = args[4].toInt()
    val outH = args[5].toInt()
    val seed = args.getOrNull(6)?.toLong() ?: 1L

    val tiles = loadPngTiles(input, tileW, tileH)
    val rules = tiles.toRules(wrapX = false, wrapY = false)
    val solver = WfcSolver(outW, outH, rules, seed)

    while (solver.status == WfcStatus.Running) {
        solver.step()
        // Visualization hook: read solver.lastObservation, possibilityCountAt(x, y), resolvedTileAt(x, y), etc.
    }

    if (solver.status == WfcStatus.Solved) {
        val image = tiles.render(solver.resolvedTileGridOrNull()!!, outW, outH)
        savePng(image, output)
        println("Solved. Wrote ${output.absolutePath}")
    } else {
        println("Contradiction. Try another seed or a less restrictive sample/rule setup.")
    }
}