feat(core): BspPartition pass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
068df27c7d
commit
f95a562927
2 changed files with 354 additions and 0 deletions
351
reikhelm-core/src/passes/bsp.rs
Normal file
351
reikhelm-core/src/passes/bsp.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Reference in a new issue