From f95a562927080806562455c3d237a213de7c67d7 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 21:27:44 -0600 Subject: [PATCH] feat(core): BspPartition pass Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-core/src/passes/bsp.rs | 351 ++++++++++++++++++++++++++++++++ reikhelm-core/src/passes/mod.rs | 3 + 2 files changed, 354 insertions(+) create mode 100644 reikhelm-core/src/passes/bsp.rs diff --git a/reikhelm-core/src/passes/bsp.rs b/reikhelm-core/src/passes/bsp.rs new file mode 100644 index 0000000..16b8a39 --- /dev/null +++ b/reikhelm-core/src/passes/bsp.rs @@ -0,0 +1,351 @@ +//! 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 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)] +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) { + 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 = 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 = a.regions.iter().map(|r| r.bounds).collect(); + let bounds_b: Vec = 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 = a.regions.iter().map(|r| r.bounds).collect(); + let bounds_b: Vec = 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)); + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 336c20e..a2abb4f 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -22,3 +22,6 @@ //! | `DoorPlacer` | `ctx.tiles` + `ctx.regions` + `ctx.graph` | converts boundary `Wall` cells (room↔corridor) to `Door`; sets the corresponding `edge.at = Some(point)` | //! //! Submodule declarations are appended here by Tasks 7–11. + +pub mod bsp; +pub use bsp::{BspConfig, BspPartition};