use crate::rng::{seeded, DetRng};
pub const N: usize = 0;
pub const E: usize = 1;
pub const S: usize = 2;
pub const W: usize = 3;
#[inline]
fn opposite(dir: usize) -> usize {
match dir {
N => S,
S => N,
E => W,
W => E,
_ => unreachable!(),
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CellKind {
Floor,
Solid,
}
#[derive(Clone, Copy, Debug)]
pub struct CellData {
pub kind: CellKind,
pub open: [bool; 4],
}
pub struct WfcResult {
pub width: usize,
pub height: usize,
pub cells: Vec<CellData>,
}
#[derive(Clone, Copy, Debug)]
struct Prototype {
kind: CellKind,
open: [bool; 4],
weight: f64,
}
fn rotate_cw(open: [bool; 4]) -> [bool; 4] {
[open[3], open[0], open[1], open[2]]
}
fn build_prototypes(base: &[f64; 6]) -> Vec<Prototype> {
let bases: &[([bool; 4], CellKind, f64)] = &[
([false, false, false, false], CellKind::Solid, base[0]), ([true, false, false, false], CellKind::Floor, base[1]), ([true, false, true, false], CellKind::Floor, base[2]), ([true, true, false, false], CellKind::Floor, base[3]), ([true, true, true, false], CellKind::Floor, base[4]), ([true, true, true, true], CellKind::Floor, base[5]), ];
let mut protos: Vec<Prototype> = Vec::new();
for &(open, kind, weight) in bases {
let mut cur = open;
for _ in 0..4 {
if !protos.iter().any(|p| p.open == cur && p.kind == kind) {
protos.push(Prototype { kind, open: cur, weight });
}
cur = rotate_cw(cur);
}
}
protos
}
fn build_support(protos: &[Prototype]) -> [Vec<u32>; 4] {
let n = protos.len();
let mut support = [vec![0u32; n], vec![0u32; n], vec![0u32; n], vec![0u32; n]];
for (dir, table) in support.iter_mut().enumerate() {
for (a, proto_a) in protos.iter().enumerate() {
let mut mask = 0u32;
for (b, proto_b) in protos.iter().enumerate() {
if proto_a.open[dir] == proto_b.open[opposite(dir)] {
mask |= 1 << b;
}
}
table[a] = mask;
}
}
support
}
fn boundary_initial(protos: &[Prototype], width: usize, height: usize) -> Vec<u32> {
let n = protos.len();
let full: u32 = if n == 32 { u32::MAX } else { (1u32 << n) - 1 };
let mut initial = vec![full; width * height];
for y in 0..height {
for x in 0..width {
let off = [y == 0, x + 1 == width, y + 1 == height, x == 0];
let mut mask = full;
for (b, proto) in protos.iter().enumerate() {
if (0..4).any(|dir| off[dir] && proto.open[dir]) {
mask &= !(1u32 << b);
}
}
initial[y * width + x] = mask;
}
}
initial
}
pub fn generate(
width: usize,
height: usize,
seed: u64,
max_attempts: u32,
base_weights: &[f64; 6],
) -> WfcResult {
let protos = build_prototypes(base_weights);
let weights: Vec<f64> = protos.iter().map(|p| p.weight).collect();
let support = build_support(&protos);
let initial = boundary_initial(&protos, width, height);
for attempt in 0..max_attempts {
if let Some(picks) = collapse_grid(
width,
height,
&weights,
&support,
&initial,
seed.wrapping_add(attempt as u64),
) {
let cells = picks
.iter()
.map(|&b| CellData {
kind: protos[b].kind,
open: protos[b].open,
})
.collect();
return WfcResult {
width,
height,
cells,
};
}
}
panic!("WFC failed to converge after {max_attempts} attempts (seed {seed})");
}
fn observe_min_entropy(cells: &[u32]) -> Option<Vec<usize>> {
let mut best_count = u32::MAX;
let mut ties: Vec<usize> = Vec::new();
for (i, &mask) in cells.iter().enumerate() {
let count = mask.count_ones();
if count == 0 {
return None; }
if count > 1 {
if count < best_count {
best_count = count;
ties.clear();
ties.push(i);
} else if count == best_count {
ties.push(i);
}
}
}
Some(ties)
}
#[allow(clippy::needless_range_loop)] fn collapse_one(mask: u32, weights: &[f64], rng: &mut impl DetRng) -> Option<usize> {
let n = weights.len();
let total: f64 = (0..n).filter(|&b| mask & (1 << b) != 0).map(|b| weights[b]).sum();
let mut r = rng.unit() * total;
let mut pick = usize::MAX;
for b in 0..n {
if mask & (1 << b) != 0 {
r -= weights[b];
if r <= 0.0 {
pick = b;
break;
}
}
}
if pick == usize::MAX {
pick = (0..n).rev().find(|&b| mask & (1 << b) != 0)?;
}
Some(pick)
}
#[allow(clippy::needless_range_loop)]
pub fn collapse_grid(
width: usize,
height: usize,
weights: &[f64],
support: &[Vec<u32>; 4],
initial: &[u32],
seed: u64,
) -> Option<Vec<usize>> {
let n = weights.len();
assert!(n <= 32, "prototype set must fit in a u32 mask");
assert_eq!(initial.len(), width * height, "initial domain size mismatch");
let mut rng = seeded(seed);
let mut cells = initial.to_vec();
let seed_stack: Vec<usize> = (0..cells.len())
.filter(|&i| (cells[i].count_ones() as usize) < n)
.collect();
if !propagate(&mut cells, width, height, support, seed_stack) {
return None; }
loop {
let ties = observe_min_entropy(&cells)?;
if ties.is_empty() {
break; }
let chosen = ties[rng.below(ties.len())];
let pick = collapse_one(cells[chosen], weights, &mut rng)?;
cells[chosen] = 1 << pick;
if !propagate(&mut cells, width, height, support, vec![chosen]) {
return None;
}
}
let result = cells
.iter()
.map(|&mask| mask.trailing_zeros() as usize)
.collect();
Some(result)
}
#[allow(clippy::needless_range_loop)]
fn propagate(
cells: &mut [u32],
width: usize,
height: usize,
support: &[Vec<u32>; 4],
mut stack: Vec<usize>,
) -> bool {
while let Some(ci) = stack.pop() {
let cx = (ci % width) as i32;
let cy = (ci / width) as i32;
let cmask = cells[ci];
for dir in 0..4 {
let (nx, ny) = match dir {
N => (cx, cy - 1),
E => (cx + 1, cy),
S => (cx, cy + 1),
W => (cx - 1, cy),
_ => unreachable!(),
};
if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
continue;
}
let ni = ny as usize * width + nx as usize;
let mut allowed = 0u32;
let mut m = cmask;
while m != 0 {
let a = m.trailing_zeros() as usize;
m &= m - 1;
allowed |= support[dir][a];
}
let new_mask = cells[ni] & allowed;
if new_mask != cells[ni] {
if new_mask == 0 {
return false; }
cells[ni] = new_mask;
stack.push(ni);
}
}
}
true
}
pub const MAX_DEGREE: usize = 5;
#[inline]
fn n_patterns(degree: usize) -> usize {
1usize << degree
}
#[inline]
fn full_domain(degree: usize) -> u32 {
let p = n_patterns(degree);
if p == 32 {
u32::MAX
} else {
(1u32 << p) - 1
}
}
#[allow(clippy::needless_range_loop)] pub fn collapse_graph(
neighbors: &[Vec<(usize, usize)>],
link_weights: &[f64],
seed: u64,
) -> Option<Vec<usize>> {
if link_weights.is_empty() {
return None; }
let n_nodes = neighbors.len();
let degree: Vec<usize> = neighbors.iter().map(|nb| nb.len()).collect();
if degree.iter().any(|&d| d > MAX_DEGREE) {
return None; }
for ports in neighbors {
for &(ni, q) in ports {
if ni >= n_nodes || q >= degree[ni] {
return None;
}
}
}
let mut set_mask: Vec<Vec<u32>> = vec![Vec::new(); MAX_DEGREE + 1];
let mut clear_mask: Vec<Vec<u32>> = vec![Vec::new(); MAX_DEGREE + 1];
for d in 0..=MAX_DEGREE {
let full = full_domain(d);
for p in 0..d {
let mut s = 0u32;
for pat in 0..n_patterns(d) {
if (pat >> p) & 1 == 1 {
s |= 1 << pat;
}
}
set_mask[d].push(s);
clear_mask[d].push(full & !s);
}
}
let last = link_weights.len() - 1;
let mut weights_by_degree: Vec<Vec<f64>> = vec![Vec::new(); MAX_DEGREE + 1];
for d in 0..=MAX_DEGREE {
for pat in 0..n_patterns(d) {
let k = (pat as u32).count_ones() as usize;
weights_by_degree[d].push(link_weights[k.min(last)]);
}
}
let mut rng = seeded(seed);
let mut cells: Vec<u32> = (0..n_nodes).map(|i| full_domain(degree[i])).collect();
loop {
let ties = observe_min_entropy(&cells)?;
if ties.is_empty() {
break; }
let chosen = ties[rng.below(ties.len())];
let pick = collapse_one(cells[chosen], &weights_by_degree[degree[chosen]], &mut rng)?;
cells[chosen] = 1 << pick;
if !propagate_graph(&mut cells, neighbors, °ree, &set_mask, &clear_mask, vec![chosen]) {
return None; }
}
Some(cells.iter().map(|&m| m.trailing_zeros() as usize).collect())
}
fn propagate_graph(
cells: &mut [u32],
neighbors: &[Vec<(usize, usize)>],
degree: &[usize],
set_mask: &[Vec<u32>],
clear_mask: &[Vec<u32>],
mut stack: Vec<usize>,
) -> bool {
while let Some(ci) = stack.pop() {
let cmask = cells[ci];
let dci = degree[ci];
for p in 0..dci {
let (ni, q) = neighbors[ci][p];
let ci_set = set_mask[dci][p];
let can_link = cmask & ci_set != 0; let can_wall = cmask & !ci_set != 0; let dni = degree[ni];
let mut allowed = 0u32;
if can_link {
allowed |= set_mask[dni][q];
}
if can_wall {
allowed |= clear_mask[dni][q];
}
let new_mask = cells[ni] & allowed;
if new_mask != cells[ni] {
if new_mask == 0 {
return false; }
cells[ni] = new_mask;
stack.push(ni);
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_compatible_alphabet_always_collapses() {
let n = 3;
let full = (1u32 << n) - 1;
let support = [vec![full; n], vec![full; n], vec![full; n], vec![full; n]];
let weights = vec![1.0, 1.0, 1.0];
let picks =
collapse_grid(4, 4, &weights, &support, &[full; 16], 42).expect("all-compatible must collapse");
assert_eq!(picks.len(), 16);
assert!(picks.iter().all(|&p| p < n));
}
#[test]
fn forbidding_every_neighbour_contradicts() {
let n = 2;
let full = (1u32 << n) - 1;
let support = [vec![0u32; n], vec![0u32; n], vec![0u32; n], vec![0u32; n]];
let weights = vec![1.0, 1.0];
assert!(collapse_grid(2, 1, &weights, &support, &[full; 2], 1).is_none());
}
#[test]
fn collapse_is_deterministic_for_a_seed() {
let n = 4;
let full = (1u32 << n) - 1;
let support = [vec![full; n], vec![full; n], vec![full; n], vec![full; n]];
let weights = vec![1.0, 2.0, 0.5, 1.5];
let initial = [full; 25];
let a = collapse_grid(5, 5, &weights, &support, &initial, 123);
let b = collapse_grid(5, 5, &weights, &support, &initial, 123);
assert_eq!(a, b);
}
const GOLDEN_COLLAPSE_GRID: [usize; 25] = [
3, 1, 0, 1, 1, 2, 1, 2, 1, 2, 1, 3, 0, 3, 0, 2, 1, 1, 0, 1, 2, 1, 3, 1, 2,
];
#[test]
fn golden_collapse_grid_draw_order_is_stable() {
let n = 4;
let full = (1u32 << n) - 1;
let support = [vec![full; n], vec![full; n], vec![full; n], vec![full; n]];
let weights = vec![1.0, 2.0, 0.5, 1.5];
let initial = [full; 25];
let picks = collapse_grid(5, 5, &weights, &support, &initial, 123).expect("collapses");
println!("GOLDEN_COLLAPSE_GRID = {picks:?}");
assert_eq!(picks.as_slice(), &GOLDEN_COLLAPSE_GRID, "collapse_grid draw order changed");
}
#[test]
fn room_graph_still_generates_floors() {
let r = generate(9, 9, 0x5C0_9191, 20, &[6.0, 1.2, 2.5, 2.5, 1.2, 0.6]);
assert_eq!(r.cells.len(), 81);
assert!(r.cells.iter().any(|c| matches!(c.kind, CellKind::Floor)));
}
#[test]
fn boundary_links_never_point_off_grid() {
let r = generate(6, 6, 0x5C0_9191, 20, &[6.0, 1.2, 2.5, 2.5, 1.2, 0.6]);
for y in 0..r.height {
for x in 0..r.width {
let c = r.cells[y * r.width + x];
assert!(!(y == 0 && c.open[N]), "cell ({x},{y}) links N off-grid");
assert!(!(x + 1 == r.width && c.open[E]), "cell ({x},{y}) links E off-grid");
assert!(!(y + 1 == r.height && c.open[S]), "cell ({x},{y}) links S off-grid");
assert!(!(x == 0 && c.open[W]), "cell ({x},{y}) links W off-grid");
}
}
}
#[test]
fn initial_domains_restrict_output() {
let n = 3;
let full = (1u32 << n) - 1;
let support = [vec![full; n], vec![full; n], vec![full; n], vec![full; n]];
let weights = vec![1.0, 1.0, 1.0];
let mut initial = [full; 9];
initial[0] = 1 << 2;
let picks = collapse_grid(3, 3, &weights, &support, &initial, 7).expect("must collapse");
assert_eq!(picks[0], 2, "initial domain must pin cell 0 to prototype 2");
}
const LINK_WEIGHTS: [f64; 6] = [0.05, 1.2, 2.5, 1.2, 0.6, 0.6];
fn ring(n: usize) -> Vec<Vec<(usize, usize)>> {
(0..n)
.map(|i| vec![((i + n - 1) % n, 1), ((i + 1) % n, 0)])
.collect()
}
fn assert_link_agreement(neighbors: &[Vec<(usize, usize)>], result: &[usize]) {
for (u, ports) in neighbors.iter().enumerate() {
for (p, &(v, q)) in ports.iter().enumerate() {
let up = (result[u] >> p) & 1;
let vq = (result[v] >> q) & 1;
assert_eq!(up, vq, "edge (node {u} port {p}) <-> (node {v} port {q}) disagrees");
}
}
}
#[test]
fn graph_collapse_is_deterministic() {
let g = ring(12);
let a = collapse_graph(&g, &LINK_WEIGHTS, 4242);
let b = collapse_graph(&g, &LINK_WEIGHTS, 4242);
assert_eq!(a, b, "same graph + seed must reproduce the same collapse");
}
#[test]
fn graph_collapse_never_contradicts_and_agrees() {
for n in [2usize, 3, 5, 8, 13, 20] {
let g = ring(n);
for seed in 0..25u64 {
let result = collapse_graph(&g, &LINK_WEIGHTS, seed)
.unwrap_or_else(|| panic!("ring({n}) seed {seed} contradicted"));
assert_eq!(result.len(), n);
for &pat in &result {
assert!(pat < 4, "pattern {pat} out of a degree-2 node's alphabet");
}
assert_link_agreement(&g, &result);
}
}
}
#[test]
fn graph_degree_over_cap_returns_none() {
let star: Vec<Vec<(usize, usize)>> = vec![
vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0)], vec![(0, 0)],
vec![(0, 1)],
vec![(0, 2)],
vec![(0, 3)],
vec![(0, 4)],
vec![(0, 5)],
];
assert!(collapse_graph(&star, &LINK_WEIGHTS, 1).is_none());
assert!(collapse_graph(&ring(4), &[], 1).is_none(), "empty weight profile is malformed");
}
#[test]
fn graph_isolated_nodes_collapse_to_no_links() {
let g: Vec<Vec<(usize, usize)>> = vec![Vec::new(), Vec::new()];
let result = collapse_graph(&g, &LINK_WEIGHTS, 7).expect("isolated nodes always collapse");
assert_eq!(result, vec![0, 0]);
}
#[test]
fn graph_link_favouring_weights_mostly_make_corridors() {
let g = ring(16);
let with_links = (0..40u64)
.filter(|&s| {
collapse_graph(&g, &LINK_WEIGHTS, s)
.map(|r| r.iter().any(|&p| p != 0))
.unwrap_or(false)
})
.count();
assert!(with_links > 30, "link-favouring weights should mostly produce corridors: {with_links}/40");
}
}