BSP: add Binary Space Partitioning for procedural dungeon generation

Implements #202, #203, #204, #205; partially implements #206:
- BSP class: core tree structure with bounds, split_once, split_recursive, clear
- BSPNode class: lightweight node reference with bounds, level, is_leaf,
  split_horizontal, split_position; navigation via left/right/parent/sibling;
  contains() and center() methods
- Traversal enum: PRE_ORDER, IN_ORDER, POST_ORDER, LEVEL_ORDER, INVERTED_LEVEL_ORDER
- BSP iteration: leaves() for leaf nodes only, traverse(order) for all nodes
- BSP query: find(pos) returns deepest node containing position
- BSP.to_heightmap(): converts BSP to HeightMap with select, shrink, value options

Note: #206's BSPMap subclass deferred - to_heightmap returns plain HeightMap.
The HeightMap already has all necessary operations (inverse, threshold, etc.)
for procedural generation workflows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-12 07:02:54 -05:00
commit 8699bba9e6
5 changed files with 1703 additions and 0 deletions

View file

@ -22,6 +22,7 @@
#include "PyMouse.h" #include "PyMouse.h"
#include "UIGridPathfinding.h" // AStarPath and DijkstraMap types #include "UIGridPathfinding.h" // AStarPath and DijkstraMap types
#include "PyHeightMap.h" // Procedural generation heightmap (#193) #include "PyHeightMap.h" // Procedural generation heightmap (#193)
#include "PyBSP.h" // Procedural generation BSP (#202-206)
#include "McRogueFaceVersion.h" #include "McRogueFaceVersion.h"
#include "GameEngine.h" #include "GameEngine.h"
#include "ImGuiConsole.h" #include "ImGuiConsole.h"
@ -418,6 +419,7 @@ PyObject* PyInit_mcrfpy()
/*procedural generation (#192)*/ /*procedural generation (#192)*/
&mcrfpydef::PyHeightMapType, &mcrfpydef::PyHeightMapType,
&mcrfpydef::PyBSPType,
nullptr}; nullptr};
@ -434,6 +436,10 @@ PyObject* PyInit_mcrfpy()
/*pathfinding iterator - returned by AStarPath.__iter__() but not directly instantiable*/ /*pathfinding iterator - returned by AStarPath.__iter__() but not directly instantiable*/
&mcrfpydef::PyAStarPathIterType, &mcrfpydef::PyAStarPathIterType,
/*BSP internal types - returned by BSP methods but not directly instantiable*/
&mcrfpydef::PyBSPNodeType,
&mcrfpydef::PyBSPIterType,
nullptr}; nullptr};
// Set up PyWindowType methods and getsetters before PyType_Ready // Set up PyWindowType methods and getsetters before PyType_Ready
@ -448,6 +454,12 @@ PyObject* PyInit_mcrfpy()
mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods; mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods;
mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters; mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters;
// Set up PyBSPType and BSPNode methods and getsetters (#202-206)
mcrfpydef::PyBSPType.tp_methods = PyBSP::methods;
mcrfpydef::PyBSPType.tp_getset = PyBSP::getsetters;
mcrfpydef::PyBSPNodeType.tp_methods = PyBSPNode::methods;
mcrfpydef::PyBSPNodeType.tp_getset = PyBSPNode::getsetters;
// Set up weakref support for all types that need it // Set up weakref support for all types that need it
PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist);
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);
@ -555,6 +567,13 @@ PyObject* PyInit_mcrfpy()
PyErr_Clear(); PyErr_Clear();
} }
// Add Traversal enum class for BSP traversal (uses Python's IntEnum)
PyObject* traversal_class = PyTraversal::create_enum_class(m);
if (!traversal_class) {
// If enum creation fails, continue without it (non-fatal)
PyErr_Clear();
}
// Add Key enum class for keyboard input // Add Key enum class for keyboard input
PyObject* key_class = PyKey::create_enum_class(m); PyObject* key_class = PyKey::create_enum_class(m);
if (!key_class) { if (!key_class) {

1047
src/PyBSP.cpp Normal file

File diff suppressed because it is too large Load diff

201
src/PyBSP.h Normal file
View file

@ -0,0 +1,201 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <libtcod.h>
#include <vector>
// Forward declarations
class PyBSP;
class PyBSPNode;
// Python object structure for BSP tree (root owner)
typedef struct {
PyObject_HEAD
TCOD_bsp_t* root; // libtcod BSP root (owned, will be deleted)
int orig_x, orig_y; // Original bounds for clear()
int orig_w, orig_h;
} PyBSPObject;
// Python object structure for BSPNode (lightweight reference)
typedef struct {
PyObject_HEAD
TCOD_bsp_t* node; // libtcod BSP node (NOT owned)
PyObject* bsp_owner; // Reference to PyBSPObject to prevent dangling
} PyBSPNodeObject;
// BSP iterator for traverse()
typedef struct {
PyObject_HEAD
std::vector<TCOD_bsp_t*>* nodes; // Pre-collected nodes
size_t index;
PyObject* bsp_owner; // Reference to PyBSPObject
} PyBSPIterObject;
class PyBSP
{
public:
// Python type interface
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyBSPObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyBSPObject* self);
static PyObject* repr(PyObject* obj);
// Properties
static PyObject* get_bounds(PyBSPObject* self, void* closure);
static PyObject* get_root(PyBSPObject* self, void* closure);
// Splitting methods (#202)
static PyObject* split_once(PyBSPObject* self, PyObject* args, PyObject* kwds);
static PyObject* split_recursive(PyBSPObject* self, PyObject* args, PyObject* kwds);
static PyObject* clear(PyBSPObject* self, PyObject* Py_UNUSED(args));
// Iteration methods (#204)
static PyObject* leaves(PyBSPObject* self, PyObject* Py_UNUSED(args));
static PyObject* traverse(PyBSPObject* self, PyObject* args, PyObject* kwds);
// Query methods (#205)
static PyObject* find(PyBSPObject* self, PyObject* args, PyObject* kwds);
// HeightMap conversion (#206)
static PyObject* to_heightmap(PyBSPObject* self, PyObject* args, PyObject* kwds);
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
class PyBSPNode
{
public:
// Python type interface
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyBSPNodeObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyBSPNodeObject* self);
static PyObject* repr(PyObject* obj);
// Properties (#203)
static PyObject* get_bounds(PyBSPNodeObject* self, void* closure);
static PyObject* get_level(PyBSPNodeObject* self, void* closure);
static PyObject* get_is_leaf(PyBSPNodeObject* self, void* closure);
static PyObject* get_split_horizontal(PyBSPNodeObject* self, void* closure);
static PyObject* get_split_position(PyBSPNodeObject* self, void* closure);
// Navigation properties (#203)
static PyObject* get_left(PyBSPNodeObject* self, void* closure);
static PyObject* get_right(PyBSPNodeObject* self, void* closure);
static PyObject* get_parent(PyBSPNodeObject* self, void* closure);
static PyObject* get_sibling(PyBSPNodeObject* self, void* closure);
// Methods (#203)
static PyObject* contains(PyBSPNodeObject* self, PyObject* args, PyObject* kwds);
static PyObject* center(PyBSPNodeObject* self, PyObject* Py_UNUSED(args));
// Helper to create a BSPNode from a TCOD_bsp_t*
static PyObject* create(TCOD_bsp_t* node, PyObject* bsp_owner);
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
// BSP Iterator class
class PyBSPIter
{
public:
static void dealloc(PyBSPIterObject* self);
static PyObject* iter(PyObject* self);
static PyObject* next(PyBSPIterObject* self);
static int init(PyBSPIterObject* self, PyObject* args, PyObject* kwds);
static PyObject* repr(PyObject* obj);
};
// Traversal enum creation
class PyTraversal
{
public:
static PyObject* traversal_enum_class;
static PyObject* create_enum_class(PyObject* module);
static int from_arg(PyObject* arg, int* out_order);
};
namespace mcrfpydef {
// BSP - user-facing, exported
inline PyTypeObject PyBSPType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.BSP",
.tp_basicsize = sizeof(PyBSPObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyBSP::dealloc,
.tp_repr = PyBSP::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"BSP(bounds: tuple[tuple[int, int], tuple[int, int]])\n\n"
"Binary Space Partitioning tree for procedural dungeon generation.\n\n"
"BSP recursively divides a rectangular region into smaller sub-regions, "
"creating a tree structure perfect for generating dungeon rooms and corridors.\n\n"
"Args:\n"
" bounds: ((x, y), (w, h)) - Position and size of the root region.\n\n"
"Properties:\n"
" bounds ((x, y), (w, h)): Read-only. Root node bounds.\n"
" root (BSPNode): Read-only. Reference to the root node.\n\n"
"Example:\n"
" bsp = mcrfpy.BSP(bounds=((0, 0), (80, 50)))\n"
" bsp.split_recursive(depth=4, min_size=(8, 8))\n"
" for leaf in bsp.leaves():\n"
" print(f'Room at {leaf.bounds}')\n"
),
.tp_methods = nullptr, // Set in McRFPy_API.cpp
.tp_getset = nullptr, // Set in McRFPy_API.cpp
.tp_init = (initproc)PyBSP::init,
.tp_new = PyBSP::pynew,
};
// BSPNode - internal type (returned by BSP methods)
inline PyTypeObject PyBSPNodeType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.BSPNode",
.tp_basicsize = sizeof(PyBSPNodeObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyBSPNode::dealloc,
.tp_repr = PyBSPNode::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"BSPNode - Lightweight reference to a node in a BSP tree.\n\n"
"BSPNode provides read-only access to node properties and navigation.\n"
"Nodes are created by BSP methods, not directly instantiated.\n\n"
"Properties:\n"
" bounds ((x, y), (w, h)): Position and size of this node.\n"
" level (int): Depth in tree (0 for root).\n"
" is_leaf (bool): True if this node has no children.\n"
" split_horizontal (bool | None): Split orientation, None if leaf.\n"
" split_position (int | None): Split coordinate, None if leaf.\n"
" left (BSPNode | None): Left child, or None if leaf.\n"
" right (BSPNode | None): Right child, or None if leaf.\n"
" parent (BSPNode | None): Parent node, or None if root.\n"
" sibling (BSPNode | None): Other child of parent, or None.\n"
),
.tp_methods = nullptr, // Set in McRFPy_API.cpp
.tp_getset = nullptr, // Set in McRFPy_API.cpp
.tp_init = (initproc)PyBSPNode::init,
.tp_new = PyBSPNode::pynew,
};
// BSP Iterator - internal type
inline PyTypeObject PyBSPIterType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.BSPIter",
.tp_basicsize = sizeof(PyBSPIterObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyBSPIter::dealloc,
.tp_repr = PyBSPIter::repr,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("Iterator for BSP tree traversal."),
.tp_iter = PyBSPIter::iter,
.tp_iternext = (iternextfunc)PyBSPIter::next,
.tp_init = (initproc)PyBSPIter::init,
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* {
PyErr_SetString(PyExc_TypeError, "BSPIter cannot be instantiated directly");
return NULL;
},
};
}

View file

@ -0,0 +1,28 @@
"""Simple BSP test to identify crash."""
import sys
import mcrfpy
print("Step 1: Import complete")
print("Step 2: Creating BSP...")
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
print("Step 2: BSP created:", bsp)
print("Step 3: Getting bounds...")
bounds = bsp.bounds
print("Step 3: Bounds:", bounds)
print("Step 4: Getting root...")
root = bsp.root
print("Step 4: Root:", root)
print("Step 5: Split once...")
bsp.split_once(horizontal=True, position=40)
print("Step 5: Split complete")
print("Step 6: Get leaves...")
leaves = list(bsp.leaves())
print("Step 6: Leaves count:", len(leaves))
print("PASS")
sys.exit(0)

408
tests/unit/bsp_test.py Normal file
View file

@ -0,0 +1,408 @@
"""Unit tests for BSP (Binary Space Partitioning) procedural generation.
Tests for issues #202-#206:
- #202: BSP core class with splitting
- #203: BSPNode lightweight node reference
- #204: BSP iteration (leaves, traverse) with Traversal enum
- #205: BSP query methods (find)
- #206: BSP.to_heightmap (returns HeightMap; BSPMap subclass deferred)
"""
import sys
import mcrfpy
def test_bsp_construction():
"""Test BSP construction with bounds."""
print("Testing BSP construction...")
# Basic construction
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
assert bsp is not None, "BSP should be created"
# Check bounds property
bounds = bsp.bounds
assert bounds == ((0, 0), (100, 80)), f"Bounds should be ((0, 0), (100, 80)), got {bounds}"
# Check root property
root = bsp.root
assert root is not None, "Root should not be None"
assert root.bounds == ((0, 0), (100, 80)), f"Root bounds mismatch"
# Construction with offset
bsp2 = mcrfpy.BSP(bounds=((10, 20), (50, 40)))
assert bsp2.bounds == ((10, 20), (50, 40)), "Offset bounds not preserved"
print(" BSP construction: PASS")
def test_bsp_split_once():
"""Test single split operation (#202)."""
print("Testing BSP split_once...")
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
# Before split, root should be a leaf
assert bsp.root.is_leaf, "Root should be leaf before split"
# Horizontal split at y=40
result = bsp.split_once(horizontal=True, position=40)
assert result is bsp, "split_once should return self for chaining"
# After split, root should not be a leaf
root = bsp.root
assert not root.is_leaf, "Root should not be leaf after split"
assert root.split_horizontal == True, "Split should be horizontal"
assert root.split_position == 40, "Split position should be 40"
# Check children exist
left = root.left
right = root.right
assert left is not None, "Left child should exist"
assert right is not None, "Right child should exist"
assert left.is_leaf, "Left child should be leaf"
assert right.is_leaf, "Right child should be leaf"
print(" BSP split_once: PASS")
def test_bsp_split_recursive():
"""Test recursive splitting (#202)."""
print("Testing BSP split_recursive...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
# Recursive split with seed for reproducibility
result = bsp.split_recursive(depth=3, min_size=(8, 8), max_ratio=1.5, seed=42)
assert result is bsp, "split_recursive should return self"
# Count leaves
leaves = list(bsp.leaves())
assert len(leaves) > 1, f"Should have multiple leaves, got {len(leaves)}"
assert len(leaves) <= 8, f"Should have at most 2^3=8 leaves, got {len(leaves)}"
# All leaves should be within bounds
for leaf in leaves:
x, y = leaf.bounds[0]
w, h = leaf.bounds[1]
assert x >= 0 and y >= 0, f"Leaf position out of bounds: {leaf.bounds}"
assert x + w <= 80 and y + h <= 60, f"Leaf extends beyond bounds: {leaf.bounds}"
assert w >= 8 and h >= 8, f"Leaf smaller than min_size: {leaf.bounds}"
print(" BSP split_recursive: PASS")
def test_bsp_clear():
"""Test clear operation (#202)."""
print("Testing BSP clear...")
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
bsp.split_recursive(depth=4, min_size=(8, 8), seed=42)
# Should have multiple leaves
leaves_before = len(list(bsp.leaves()))
assert leaves_before > 1, "Should have multiple leaves after split"
# Clear
result = bsp.clear()
assert result is bsp, "clear should return self"
# Should be back to single leaf
leaves_after = list(bsp.leaves())
assert len(leaves_after) == 1, f"Should have 1 leaf after clear, got {len(leaves_after)}"
# Root should be a leaf with original bounds
assert bsp.root.is_leaf, "Root should be leaf after clear"
assert bsp.bounds == ((0, 0), (100, 80)), "Bounds should be restored"
print(" BSP clear: PASS")
def test_bspnode_properties():
"""Test BSPNode properties (#203)."""
print("Testing BSPNode properties...")
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
root = bsp.root
# Root properties
assert root.level == 0, f"Root level should be 0, got {root.level}"
assert root.parent is None, "Root parent should be None"
assert not root.is_leaf, "Split root should not be leaf"
# Split properties (not leaf)
assert root.split_horizontal is not None, "Split horizontal should be bool for non-leaf"
assert root.split_position is not None, "Split position should be int for non-leaf"
# Navigate to a leaf
current = root
while not current.is_leaf:
current = current.left
# Leaf properties
assert current.is_leaf, "Should be a leaf"
assert current.split_horizontal is None, "Leaf split_horizontal should be None"
assert current.split_position is None, "Leaf split_position should be None"
assert current.level > 0, "Leaf level should be > 0"
# Test center method
bounds = current.bounds
cx, cy = current.center()
expected_cx = bounds[0][0] + bounds[1][0] // 2
expected_cy = bounds[0][1] + bounds[1][1] // 2
assert cx == expected_cx, f"Center x mismatch: {cx} != {expected_cx}"
assert cy == expected_cy, f"Center y mismatch: {cy} != {expected_cy}"
print(" BSPNode properties: PASS")
def test_bspnode_navigation():
"""Test BSPNode navigation (#203)."""
print("Testing BSPNode navigation...")
bsp = mcrfpy.BSP(bounds=((0, 0), (100, 80)))
bsp.split_once(horizontal=True, position=40)
root = bsp.root
left = root.left
right = root.right
# Parent navigation
assert left.parent is not None, "Left parent should exist"
assert left.parent.bounds == root.bounds, "Left parent should be root"
# Sibling navigation
assert left.sibling is not None, "Left sibling should exist"
assert left.sibling.bounds == right.bounds, "Left sibling should be right"
assert right.sibling is not None, "Right sibling should exist"
assert right.sibling.bounds == left.bounds, "Right sibling should be left"
# Root has no parent or sibling
assert root.parent is None, "Root parent should be None"
assert root.sibling is None, "Root sibling should be None"
print(" BSPNode navigation: PASS")
def test_bspnode_contains():
"""Test BSPNode contains method (#203)."""
print("Testing BSPNode contains...")
bsp = mcrfpy.BSP(bounds=((10, 20), (50, 40))) # x: 10-60, y: 20-60
root = bsp.root
# Inside
assert root.contains((30, 40)), "Center should be inside"
assert root.contains((10, 20)), "Top-left corner should be inside"
assert root.contains((59, 59)), "Near bottom-right should be inside"
# Outside
assert not root.contains((5, 40)), "Left of bounds should be outside"
assert not root.contains((65, 40)), "Right of bounds should be outside"
assert not root.contains((30, 15)), "Above bounds should be outside"
assert not root.contains((30, 65)), "Below bounds should be outside"
print(" BSPNode contains: PASS")
def test_bsp_leaves_iteration():
"""Test leaves iteration (#204)."""
print("Testing BSP leaves iteration...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
# Iterate leaves
leaves = list(bsp.leaves())
assert len(leaves) > 0, "Should have at least one leaf"
# All should be leaves
for leaf in leaves:
assert leaf.is_leaf, f"Should be leaf: {leaf}"
# Can convert to list multiple times
leaves2 = list(bsp.leaves())
assert len(leaves) == len(leaves2), "Multiple iterations should yield same count"
print(" BSP leaves iteration: PASS")
def test_bsp_traverse():
"""Test traverse with different orders (#204)."""
print("Testing BSP traverse...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
# Test all traversal orders
orders = [
mcrfpy.Traversal.PRE_ORDER,
mcrfpy.Traversal.IN_ORDER,
mcrfpy.Traversal.POST_ORDER,
mcrfpy.Traversal.LEVEL_ORDER,
mcrfpy.Traversal.INVERTED_LEVEL_ORDER,
]
for order in orders:
nodes = list(bsp.traverse(order=order))
assert len(nodes) > 0, f"Should have nodes for {order}"
# All traversals should visit same number of nodes
assert len(nodes) == len(list(bsp.traverse())), f"Node count mismatch for {order}"
# Default should be LEVEL_ORDER
default_nodes = list(bsp.traverse())
level_nodes = list(bsp.traverse(mcrfpy.Traversal.LEVEL_ORDER))
assert len(default_nodes) == len(level_nodes), "Default should match LEVEL_ORDER"
# PRE_ORDER: root first
pre_nodes = list(bsp.traverse(mcrfpy.Traversal.PRE_ORDER))
assert pre_nodes[0].bounds == bsp.root.bounds, "PRE_ORDER should start with root"
# POST_ORDER: root last
post_nodes = list(bsp.traverse(mcrfpy.Traversal.POST_ORDER))
assert post_nodes[-1].bounds == bsp.root.bounds, "POST_ORDER should end with root"
print(" BSP traverse: PASS")
def test_bsp_find():
"""Test find method (#205)."""
print("Testing BSP find...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
# Find a point inside bounds
node = bsp.find((40, 30))
assert node is not None, "Should find node for point inside"
assert node.is_leaf, "Found node should be a leaf (deepest)"
assert node.contains((40, 30)), "Found node should contain the point"
# Find at corner
corner_node = bsp.find((0, 0))
assert corner_node is not None, "Should find node at corner"
assert corner_node.contains((0, 0)), "Corner node should contain (0,0)"
# Find outside bounds
outside = bsp.find((100, 100))
assert outside is None, "Should return None for point outside"
print(" BSP find: PASS")
def test_bsp_to_heightmap():
"""Test to_heightmap conversion (#206)."""
print("Testing BSP to_heightmap...")
bsp = mcrfpy.BSP(bounds=((0, 0), (50, 40)))
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
# Basic conversion
hmap = bsp.to_heightmap()
assert hmap is not None, "Should create heightmap"
assert hmap.size == (50, 40), f"Size should match bounds, got {hmap.size}"
# Check that leaves are filled
leaves = list(bsp.leaves())
for leaf in leaves:
lx, ly = leaf.bounds[0]
val = hmap[lx, ly]
assert val == 1.0, f"Leaf interior should be 1.0, got {val}"
# Test with shrink
hmap_shrink = bsp.to_heightmap(shrink=2)
assert hmap_shrink is not None, "Should create with shrink"
# Test with select='internal'
hmap_internal = bsp.to_heightmap(select='internal')
assert hmap_internal is not None, "Should create with select=internal"
# Test with select='all'
hmap_all = bsp.to_heightmap(select='all')
assert hmap_all is not None, "Should create with select=all"
# Test with custom size
hmap_sized = bsp.to_heightmap(size=(100, 80))
assert hmap_sized.size == (100, 80), f"Custom size should work, got {hmap_sized.size}"
# Test with custom value
hmap_val = bsp.to_heightmap(value=0.5)
leaves = list(bsp.leaves())
leaf = leaves[0]
lx, ly = leaf.bounds[0]
val = hmap_val[lx, ly]
assert val == 0.5, f"Custom value should be 0.5, got {val}"
print(" BSP to_heightmap: PASS")
def test_traversal_enum():
"""Test Traversal enum (#204)."""
print("Testing Traversal enum...")
# Check enum exists
assert hasattr(mcrfpy, 'Traversal'), "Traversal enum should exist"
# Check all members
assert mcrfpy.Traversal.PRE_ORDER.value == 0
assert mcrfpy.Traversal.IN_ORDER.value == 1
assert mcrfpy.Traversal.POST_ORDER.value == 2
assert mcrfpy.Traversal.LEVEL_ORDER.value == 3
assert mcrfpy.Traversal.INVERTED_LEVEL_ORDER.value == 4
print(" Traversal enum: PASS")
def test_bsp_chaining():
"""Test method chaining."""
print("Testing BSP method chaining...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
# Chain multiple operations
result = bsp.split_recursive(depth=2, min_size=(8, 8), seed=42).clear().split_once(True, 30)
assert result is bsp, "Chaining should return self"
print(" BSP chaining: PASS")
def test_bsp_repr():
"""Test repr output."""
print("Testing BSP repr...")
bsp = mcrfpy.BSP(bounds=((0, 0), (80, 60)))
repr_str = repr(bsp)
assert "BSP" in repr_str, f"repr should contain BSP: {repr_str}"
assert "80" in repr_str and "60" in repr_str, f"repr should contain size: {repr_str}"
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
repr_str2 = repr(bsp)
assert "leaves" in repr_str2, f"repr should mention leaves: {repr_str2}"
# BSPNode repr
node_repr = repr(bsp.root)
assert "BSPNode" in node_repr, f"BSPNode repr: {node_repr}"
print(" BSP repr: PASS")
def run_all_tests():
"""Run all BSP tests."""
print("\n=== BSP Unit Tests ===\n")
try:
test_bsp_construction()
test_bsp_split_once()
test_bsp_split_recursive()
test_bsp_clear()
test_bspnode_properties()
test_bspnode_navigation()
test_bspnode_contains()
test_bsp_leaves_iteration()
test_bsp_traverse()
test_bsp_find()
test_bsp_to_heightmap()
test_traversal_enum()
test_bsp_chaining()
test_bsp_repr()
print("\n=== ALL BSP TESTS PASSED ===\n")
sys.exit(0)
except AssertionError as e:
print(f"\nTEST FAILED: {e}")
sys.exit(1)
except Exception as e:
print(f"\nUNEXPECTED ERROR: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
run_all_tests()