reikhelm/reikhelm-core/src/passes/bsp.rs
Parley Hatch f849d82d31 feat(wasm+web): live browser dungeon playground (WASM core + atmospheric renderer)
Phase 0+1 of the browser viz effort:
- core: drop unused `rand` dep (removes getrandom/libc → clean wasm32 build);
  derive serde on DungeonConfig + 5 sub-configs so the bridge can (de)serialize
- reikhelm-wasm: thin wasm-bindgen cdylib exposing generate(seed, config_json)
  -> JSON envelope (tiles as int codes, regions, edges, per-pass snapshots) and
  default_config_json(); holds no generation logic
- workspace: add reikhelm-wasm member; default-members keeps host cargo
  test/clippy on core+viz only
- reikhelm-web: vanilla-JS + Canvas 2D renderer running the real core live in
  the browser. Atmospheric look — lit stone floor, raised stone walls with
  rim-lit carved edges + top bevel, room light-pooling, amber door thresholds,
  vignette. Controls: seed/reroll, 9 config sliders, stage scrubber, semantic
  overlays (region outlines / graph / grid). window.reikhelm hooks for
  Playwright-driven iteration.

Core stays 115 tests green. Driven + screenshotted via Playwright MCP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:07:24 -06:00

353 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The [`BspPartition`] partitioner pass (spec §4.7).
//!
//! Binary space partitioning recursively cuts the whole-map rectangle into
//! smaller, non-overlapping leaf rectangles. Each cut chooses a split axis and a
//! split position at random (from the pass's dedicated [`Rng`] sub-stream), and
//! recursion stops at a leaf once it is too small to split without violating the
//! minimum leaf size, or once the configured maximum recursion depth is reached.
//!
//! Per the inter-pass data contract (see [`crate::passes`]), this pass is the
//! first in the v1 dungeon recipe: it reads an empty grid and **writes** one
//! placeholder [`Region`](crate::region::Region) of kind
//! [`Room`](crate::region::RegionKind::Room) per leaf — `bounds` set to the leaf
//! rect and `cells` empty. It carves **no** tiles; the grid stays all
//! [`Wall`](crate::map::Tile::Wall). A later `RoomCarver` pass consumes these
//! placeholders, shrinks each to an actual room, and carves floor.
use serde::{Deserialize, Serialize};
use crate::geometry::Rect;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// Configuration for a [`BspPartition`] pass.
///
/// `min_leaf` is the smallest allowed extent (in cells) for any dimension of a
/// leaf rectangle: a cut is only made when both resulting halves keep every
/// dimension at or above this size. `max_depth` caps how many times the
/// recursion may split; `max_depth: 0` produces a single leaf — the whole map.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BspConfig {
/// The minimum allowed extent of any leaf dimension, in cells.
pub min_leaf: i32,
/// The maximum recursion depth. `0` yields a single whole-map leaf.
pub max_depth: u32,
}
impl Default for BspConfig {
/// A sensible default for the v1 dungeon: leaves no smaller than 6 cells on
/// a side, recursing up to 4 levels deep (up to 16 leaves).
fn default() -> Self {
BspConfig {
min_leaf: 6,
max_depth: 4,
}
}
}
/// A binary-space-partition partitioner pass (spec §4.7).
///
/// Splits the map into leaf rectangles and records one placeholder
/// [`Room`](crate::region::RegionKind::Room) region per leaf. Construct one with
/// [`BspPartition::new`]; it implements [`Pass`] with the stable name
/// `"bsp_partition"`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BspPartition {
/// The split limits this pass partitions under.
cfg: BspConfig,
}
impl BspPartition {
/// Creates a partitioner with the given configuration.
pub fn new(cfg: BspConfig) -> Self {
BspPartition { cfg }
}
/// Recursively partitions `rect`, appending each final leaf to `leaves`.
///
/// `depth` counts down from the configured maximum: recursion stops when it
/// reaches `0` or when `rect` is too small to split (neither axis has room
/// for two halves each at least `min_leaf` wide). Randomness — the axis
/// choice and the split offset — is drawn from `rng` in a fixed order, so the
/// resulting leaf set is fully determined by the seed.
fn split(&self, rect: Rect, depth: u32, rng: &mut Rng, leaves: &mut Vec<Rect>) {
let min = self.cfg.min_leaf;
// A leaf must be at least `2 * min` along an axis to be cut into two
// halves that each keep `min`. With `min` non-positive every rect is
// "splittable", so guard against it to keep the recursion well-founded.
let can_split_w = min > 0 && rect.w >= 2 * min;
let can_split_h = min > 0 && rect.h >= 2 * min;
if depth == 0 || (!can_split_w && !can_split_h) {
leaves.push(rect);
return;
}
// Choose the split axis. `true` = vertical cut (split the width into a
// left/right pair); `false` = horizontal cut (top/bottom pair). When only
// one axis is splittable we must take it; when both are, pick at random.
let split_vertical = match (can_split_w, can_split_h) {
(true, false) => true,
(false, true) => false,
_ => rng.chance(0.5),
};
let (first, second) = if split_vertical {
// Offset is the left half's width: in `[min, w - min]` inclusive, so
// both halves are at least `min` wide. `range` is half-open, hence
// the `+ 1` on the upper bound.
let offset = rng.range(min, rect.w - min + 1);
(
Rect::new(rect.x, rect.y, offset, rect.h),
Rect::new(rect.x + offset, rect.y, rect.w - offset, rect.h),
)
} else {
let offset = rng.range(min, rect.h - min + 1);
(
Rect::new(rect.x, rect.y, rect.w, offset),
Rect::new(rect.x, rect.y + offset, rect.w, rect.h - offset),
)
};
self.split(first, depth - 1, rng, leaves);
self.split(second, depth - 1, rng, leaves);
}
}
impl Pass for BspPartition {
fn name(&self) -> &str {
"bsp_partition"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// The whole canvas is the root rectangle to partition.
let map_rect = Rect::new(0, 0, ctx.tiles.width() as i32, ctx.tiles.height() as i32);
let mut leaves = Vec::new();
self.split(map_rect, self.cfg.max_depth, rng, &mut leaves);
// One placeholder Room per leaf: bounds = leaf rect, no cells, no tiles
// carved. `RoomCarver` refines these later.
for leaf in leaves {
ctx.add_region(RegionKind::Room, leaf, Vec::new());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Point;
use crate::grid::Grid;
use crate::map::Tile;
use crate::region::ConnGraph;
/// Builds the precondition this pass requires: a fresh `GenContext` whose
/// grid is all `Wall` and whose regions/graph/blackboard are empty — exactly
/// the state `Pipeline::run` would hand the first pass. Constructed by hand
/// so the test depends on no other pass.
fn empty_ctx(width: u32, height: u32) -> GenContext {
GenContext {
tiles: Grid::new(width, height, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
}
}
/// Runs `BspPartition` with the given config/seed on a fresh `width`×`height`
/// context and returns the resulting context for inspection.
fn run(width: u32, height: u32, cfg: BspConfig, seed: u64) -> GenContext {
let mut ctx = empty_ctx(width, height);
// Mirror the pipeline's keying so we exercise the real sub-stream path.
let mut rng = Rng::from_seed(seed).fork("bsp_partition#0");
BspPartition::new(cfg).apply(&mut ctx, &mut rng);
ctx
}
/// The pass identifies itself with the exact contract name.
#[test]
fn name_is_bsp_partition() {
assert_eq!(BspPartition::new(BspConfig::default()).name(), "bsp_partition");
}
/// After running on an empty context, `regions` is non-empty and every
/// region is a `Room` placeholder (kind = Room, empty `cells`).
#[test]
fn produces_nonempty_room_placeholders() {
let ctx = run(64, 48, BspConfig::default(), 0xABCD);
assert!(!ctx.regions.is_empty(), "partition must yield at least one leaf");
assert!(
ctx.regions.iter().all(|r| r.kind == RegionKind::Room),
"every leaf region must be a Room"
);
assert!(
ctx.regions.iter().all(|r| r.cells.is_empty()),
"placeholders carry no cells"
);
}
/// The pass carves no tiles: the grid stays entirely `Wall`.
#[test]
fn carves_no_tiles() {
let mut ctx = empty_ctx(64, 48);
let mut rng = Rng::from_seed(0xABCD).fork("bsp_partition#0");
BspPartition::new(BspConfig::default()).apply(&mut ctx, &mut rng);
assert!(
ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall),
"partitioner must not carve any tile"
);
}
/// Every leaf lies fully within the map and respects `min_leaf` on both
/// dimensions.
#[test]
fn leaves_in_bounds_and_respect_min_leaf() {
let cfg = BspConfig {
min_leaf: 5,
max_depth: 5,
};
let (w, h) = (80, 50);
let map = Rect::new(0, 0, w as i32, h as i32);
let ctx = run(w, h, cfg, 0x1234_5678);
for r in &ctx.regions {
let b = r.bounds;
// Within bounds.
assert!(
b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(),
"leaf {b:?} escapes the map {map:?}"
);
// Respects the minimum on both axes.
assert!(
b.w >= cfg.min_leaf && b.h >= cfg.min_leaf,
"leaf {b:?} violates min_leaf {}",
cfg.min_leaf
);
}
}
/// Leaves do not overlap and their areas tile the whole map exactly (no gap,
/// no overlap): the sum of leaf areas equals the map area, and no pair of
/// leaves intersects.
#[test]
fn leaves_do_not_overlap_and_cover_the_map() {
let cfg = BspConfig {
min_leaf: 4,
max_depth: 6,
};
let (w, h) = (70, 56);
let ctx = run(w, h, cfg, 0xDEAD_BEEF);
let leaves: Vec<Rect> = ctx.regions.iter().map(|r| r.bounds).collect();
// No two distinct leaves intersect.
for (i, a) in leaves.iter().enumerate() {
for b in &leaves[i + 1..] {
assert!(!a.intersects(b), "leaves overlap: {a:?} and {b:?}");
}
}
// Areas sum to the whole map: a partition leaves no gaps.
let total: i64 = leaves.iter().map(|r| r.w as i64 * r.h as i64).sum();
assert_eq!(
total,
w as i64 * h as i64,
"leaf areas must tile the map exactly"
);
// Stronger check: every map cell is covered by exactly one leaf.
for y in 0..h as i32 {
for x in 0..w as i32 {
let p = Point::new(x, y);
let covering = leaves.iter().filter(|r| r.contains(p)).count();
assert_eq!(covering, 1, "cell {p:?} covered by {covering} leaves");
}
}
}
/// Determinism: the same seed (same sub-stream) yields an identical leaf set
/// (compare region bounds in order).
#[test]
fn same_seed_yields_identical_leaves() {
let cfg = BspConfig::default();
let a = run(96, 64, cfg, 0x5EED);
let b = run(96, 64, cfg, 0x5EED);
let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
assert_eq!(bounds_a, bounds_b, "same seed must reproduce the same leaves");
}
/// Different seeds (overwhelmingly) yield a different partition.
#[test]
fn distinct_seeds_diverge() {
let cfg = BspConfig::default();
let a = run(96, 64, cfg, 1);
let b = run(96, 64, cfg, 2);
let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
assert_ne!(bounds_a, bounds_b);
}
/// `max_depth: 0` yields a single leaf: the whole map.
#[test]
fn max_depth_zero_is_single_whole_map_leaf() {
let cfg = BspConfig {
min_leaf: 4,
max_depth: 0,
};
let (w, h) = (40, 30);
let ctx = run(w, h, cfg, 999);
assert_eq!(ctx.regions.len(), 1, "depth 0 must produce exactly one leaf");
assert_eq!(ctx.regions[0].kind, RegionKind::Room);
assert_eq!(
ctx.regions[0].bounds,
Rect::new(0, 0, w as i32, h as i32),
"the single leaf must be the whole map"
);
}
/// A map too small to split (every extent below `2 * min_leaf`) yields the
/// whole map as one leaf, even with depth budget to spare. Bounds-safety:
/// no panic, no underflow.
#[test]
fn unsplittable_map_yields_single_leaf() {
let cfg = BspConfig {
min_leaf: 10,
max_depth: 8,
};
// 12×12 < 2*10 on both axes, so no cut is possible.
let ctx = run(12, 12, cfg, 42);
assert_eq!(ctx.regions.len(), 1);
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 12, 12));
}
/// Bounds-safety on degenerate inputs: a zero-sized map and a degenerate
/// `min_leaf` must not panic and must still leave the grid all `Wall`.
#[test]
fn degenerate_inputs_do_not_panic() {
// Zero-area map.
let ctx = run(0, 0, BspConfig::default(), 7);
assert_eq!(ctx.regions.len(), 1);
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 0, 0));
// min_leaf == 0: must not enable infinite recursion; treated as
// unsplittable so a single leaf results.
let ctx = run(
32,
32,
BspConfig {
min_leaf: 0,
max_depth: 4,
},
7,
);
assert_eq!(ctx.regions.len(), 1);
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 32, 32));
assert!(ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall));
}
}