feat(core): BspPartition pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 21:27:44 -06:00
parent 068df27c7d
commit f95a562927
2 changed files with 354 additions and 0 deletions

View file

@ -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<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));
}
}

View file

@ -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)` | //! | `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 711. //! Submodule declarations are appended here by Tasks 711.
pub mod bsp;
pub use bsp::{BspConfig, BspPartition};