diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index ef29285..1e0c2f6 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -441,6 +441,8 @@ PyObject* PyInit_mcrfpy() /*BSP internal types - returned by BSP methods but not directly instantiable*/ &mcrfpydef::PyBSPNodeType, &mcrfpydef::PyBSPIterType, + &mcrfpydef::PyBSPAdjacencyType, // #210: BSP.adjacency wrapper + &mcrfpydef::PyBSPAdjacentTilesType, // #210: BSPNode.adjacent_tiles wrapper nullptr}; diff --git a/src/PyBSP.cpp b/src/PyBSP.cpp index a387a1d..dd898ef 100644 --- a/src/PyBSP.cpp +++ b/src/PyBSP.cpp @@ -3,9 +3,11 @@ #include "McRFPy_Doc.h" #include "PyPositionHelper.h" #include "PyHeightMap.h" +#include "PyVector.h" // #210: For wall tile Vectors #include #include #include +#include // #210: For std::min, std::max // Static storage for Traversal enum PyObject* PyTraversal::traversal_enum_class = nullptr; @@ -19,6 +21,127 @@ enum TraversalOrder { TRAVERSAL_INVERTED_LEVEL_ORDER = 4, }; +// ==================== Adjacency Helpers (#210) ==================== + +// Check if two BSP leaf nodes share a wall segment (not just a corner) +static bool are_adjacent(TCOD_bsp_t* a, TCOD_bsp_t* b) { + // a is left of b (vertical wall) + if (a->x + a->w == b->x) { + int overlap = std::min(a->y + a->h, b->y + b->h) - std::max(a->y, b->y); + if (overlap > 0) return true; + } + // b is left of a (vertical wall) + if (b->x + b->w == a->x) { + int overlap = std::min(a->y + a->h, b->y + b->h) - std::max(a->y, b->y); + if (overlap > 0) return true; + } + // a is above b (horizontal wall) + if (a->y + a->h == b->y) { + int overlap = std::min(a->x + a->w, b->x + b->w) - std::max(a->x, b->x); + if (overlap > 0) return true; + } + // b is above a (horizontal wall) + if (b->y + b->h == a->y) { + int overlap = std::min(a->x + a->w, b->x + b->w) - std::max(a->x, b->x); + if (overlap > 0) return true; + } + return false; +} + +// Compute wall tiles for two adjacent leaves (from perspective of node a). +// Returns coordinates of tiles on a's boundary that are suitable for corridor placement. +// For a vertical wall (a left of b): returns a's rightmost column in the overlap range. +// For a horizontal wall (a above b): returns a's bottommost row in the overlap range. +// These are the tiles INSIDE leaf a that touch leaf b's boundary. +static std::vector compute_wall_tiles(TCOD_bsp_t* a, TCOD_bsp_t* b) { + std::vector tiles; + + // a is left of b (vertical wall at right edge of a) + if (a->x + a->w == b->x) { + int y_start = std::max(a->y, b->y); + int y_end = std::min(a->y + a->h, b->y + b->h); + int x = a->x + a->w - 1; // Last column of a + for (int y = y_start; y < y_end; y++) { + tiles.push_back({x, y}); + } + return tiles; + } + // b is left of a (vertical wall at left edge of a) + if (b->x + b->w == a->x) { + int y_start = std::max(a->y, b->y); + int y_end = std::min(a->y + a->h, b->y + b->h); + int x = a->x; // First column of a + for (int y = y_start; y < y_end; y++) { + tiles.push_back({x, y}); + } + return tiles; + } + // a is above b (horizontal wall at bottom edge of a) + if (a->y + a->h == b->y) { + int x_start = std::max(a->x, b->x); + int x_end = std::min(a->x + a->w, b->x + b->w); + int y = a->y + a->h - 1; // Last row of a + for (int x = x_start; x < x_end; x++) { + tiles.push_back({x, y}); + } + return tiles; + } + // b is above a (horizontal wall at top edge of a) + if (b->y + b->h == a->y) { + int x_start = std::max(a->x, b->x); + int x_end = std::min(a->x + a->w, b->x + b->w); + int y = a->y; // First row of a + for (int x = x_start; x < x_end; x++) { + tiles.push_back({x, y}); + } + return tiles; + } + + return tiles; // Empty if not adjacent +} + +// Rebuild the adjacency cache for a BSP tree +static void rebuild_adjacency_cache(PyBSPObject* self) { + // Delete old cache if exists + if (self->adjacency_cache) { + delete self->adjacency_cache; + } + + self->adjacency_cache = new BSPAdjacencyCache(); + self->adjacency_cache->generation = self->generation; + + // Collect all leaves in level-order + TCOD_bsp_traverse_level_order(self->root, [](TCOD_bsp_t* node, void* data) -> bool { + auto cache = (BSPAdjacencyCache*)data; + if (TCOD_bsp_is_leaf(node)) { + int idx = (int)cache->leaf_pointers.size(); + cache->leaf_pointers.push_back(node); + cache->ptr_to_index[node] = idx; + cache->graph.push_back({}); // Empty neighbor list + } + return true; + }, self->adjacency_cache); + + // Build adjacency graph (O(n^2) pairwise check) + auto& cache = *self->adjacency_cache; + int n = (int)cache.leaf_pointers.size(); + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + if (are_adjacent(cache.leaf_pointers[i], cache.leaf_pointers[j])) { + cache.graph[i].push_back(j); + cache.graph[j].push_back(i); + } + } + } +} + +// Ensure adjacency cache is valid, rebuild if needed +static void ensure_adjacency_cache(PyBSPObject* self) { + if (!self->adjacency_cache || self->adjacency_cache->generation != self->generation) { + rebuild_adjacency_cache(self); + } +} + // ==================== Traversal Enum ==================== PyObject* PyTraversal::create_enum_class(PyObject* module) { @@ -193,6 +316,8 @@ PyGetSetDef PyBSP::getsetters[] = { MCRF_PROPERTY(size, "Dimensions (width, height). Read-only."), NULL}, {"root", (getter)PyBSP::get_root, NULL, MCRF_PROPERTY(root, "Reference to the root BSPNode. Read-only."), NULL}, + {"adjacency", (getter)PyBSP::get_adjacency, NULL, + MCRF_PROPERTY(adjacency, "Leaf adjacency graph. adjacency[i] returns tuple of neighbor indices. Read-only."), NULL}, {NULL} }; @@ -268,6 +393,16 @@ PyMethodDef PyBSP::methods[] = { MCRF_ARG("value", "Value inside selected regions. Default: 1.0.") MCRF_RETURNS("HeightMap with selected regions filled") )}, + {"get_leaf", (PyCFunction)PyBSP::get_leaf, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(BSP, get_leaf, + MCRF_SIG("(index: int)", "BSPNode"), + MCRF_DESC("Get a leaf node by its index (0 to len(bsp)-1). " + "This is useful when working with adjacency data, which returns leaf indices."), + MCRF_ARGS_START + MCRF_ARG("index", "Leaf index (0 to len(bsp)-1). Negative indices supported.") + MCRF_RETURNS("BSPNode at the specified index") + MCRF_RAISES("IndexError", "If index is out of range") + )}, {NULL} }; @@ -283,6 +418,7 @@ PyObject* PyBSP::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) self->orig_w = 0; self->orig_h = 0; self->generation = 0; + self->adjacency_cache = nullptr; // #210: Lazy-computed } return (PyObject*)self; } @@ -330,6 +466,12 @@ int PyBSP::init(PyBSPObject* self, PyObject* args, PyObject* kwds) TCOD_bsp_delete(self->root); } + // Clean up any existing adjacency cache (#210) + if (self->adjacency_cache) { + delete self->adjacency_cache; + self->adjacency_cache = nullptr; + } + // Create new BSP with size self->root = TCOD_bsp_new_with_size(x, y, w, h); if (!self->root) { @@ -349,6 +491,11 @@ int PyBSP::init(PyBSPObject* self, PyObject* args, PyObject* kwds) void PyBSP::dealloc(PyBSPObject* self) { + // Clean up adjacency cache (#210) + if (self->adjacency_cache) { + delete self->adjacency_cache; + self->adjacency_cache = nullptr; + } if (self->root) { TCOD_bsp_delete(self->root); self->root = nullptr; @@ -422,6 +569,29 @@ PyObject* PyBSP::get_root(PyBSPObject* self, void* closure) return PyBSPNode::create(self->root, (PyObject*)self); } +// Property: adjacency (#210) +PyObject* PyBSP::get_adjacency(PyBSPObject* self, void* closure) +{ + if (!self->root) { + PyErr_SetString(PyExc_RuntimeError, "BSP not initialized"); + return nullptr; + } + + // Ensure cache is valid + ensure_adjacency_cache(self); + + // Create and return adjacency wrapper + PyBSPAdjacencyObject* adj = (PyBSPAdjacencyObject*) + mcrfpydef::PyBSPAdjacencyType.tp_alloc(&mcrfpydef::PyBSPAdjacencyType, 0); + if (!adj) return nullptr; + + adj->bsp_owner = (PyObject*)self; + adj->generation = self->generation; + Py_INCREF(self); + + return (PyObject*)adj; +} + // Method: split_once(horizontal, position) -> BSP PyObject* PyBSP::split_once(PyBSPObject* self, PyObject* args, PyObject* kwds) { @@ -439,8 +609,10 @@ PyObject* PyBSP::split_once(PyBSPObject* self, PyObject* args, PyObject* kwds) return nullptr; } - // Note: split_once only adds children, doesn't remove any nodes - // Root node pointer remains valid, so we don't increment generation + // Increment generation to invalidate adjacency cache and stale BSPNode references + // The tree structure changes, so any cached adjacency graph is now invalid + self->generation++; + TCOD_bsp_split_once(self->root, horizontal ? true : false, position); Py_INCREF(self); @@ -685,6 +857,40 @@ PyObject* PyBSP::find(PyBSPObject* self, PyObject* args, PyObject* kwds) return PyBSPNode::create(found, (PyObject*)self); } +// Method: get_leaf(index) -> BSPNode (#210) +PyObject* PyBSP::get_leaf(PyBSPObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"index", nullptr}; + int index; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "i", const_cast(keywords), &index)) { + return nullptr; + } + + if (!self->root) { + PyErr_SetString(PyExc_RuntimeError, "BSP not initialized"); + return nullptr; + } + + // Ensure adjacency cache is valid (this builds leaf_pointers list) + ensure_adjacency_cache(self); + + int n = (int)self->adjacency_cache->leaf_pointers.size(); + + // Handle negative indexing + if (index < 0) { + index += n; + } + + if (index < 0 || index >= n) { + PyErr_SetString(PyExc_IndexError, "leaf index out of range"); + return nullptr; + } + + TCOD_bsp_t* leaf = self->adjacency_cache->leaf_pointers[index]; + return PyBSPNode::create(leaf, (PyObject*)self); +} + // Method: to_heightmap(...) -> HeightMap PyObject* PyBSP::to_heightmap(PyBSPObject* self, PyObject* args, PyObject* kwds) { @@ -841,6 +1047,13 @@ PyGetSetDef PyBSPNode::getsetters[] = { MCRF_PROPERTY(parent, "Parent node, or None if root. Read-only."), NULL}, {"sibling", (getter)PyBSPNode::get_sibling, NULL, MCRF_PROPERTY(sibling, "Other child of parent, or None. Read-only."), NULL}, + {"leaf_index", (getter)PyBSPNode::get_leaf_index, NULL, + MCRF_PROPERTY(leaf_index, "Leaf index (0..n-1) in adjacency graph, or None if not a leaf. Read-only."), NULL}, + {"adjacent_tiles", (getter)PyBSPNode::get_adjacent_tiles, NULL, + MCRF_PROPERTY(adjacent_tiles, "Mapping of neighbor_index -> tuple of Vector wall tiles. " + "Returns tiles on THIS leaf's boundary suitable for corridor placement. " + "Each Vector has integer coordinates; use .int for (x, y) tuple. " + "Only available for leaf nodes. Read-only."), NULL}, {NULL} }; @@ -1080,6 +1293,69 @@ PyObject* PyBSPNode::get_sibling(PyBSPNodeObject* self, void* closure) } } +// Property: leaf_index (#210) +PyObject* PyBSPNode::get_leaf_index(PyBSPNodeObject* self, void* closure) +{ + if (!checkValid(self)) return nullptr; + + // Only leaves have an index + if (!TCOD_bsp_is_leaf(self->node)) { + Py_RETURN_NONE; + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + + // Ensure cache is valid + ensure_adjacency_cache(bsp); + + // Look up this node's index + auto it = bsp->adjacency_cache->ptr_to_index.find(self->node); + if (it == bsp->adjacency_cache->ptr_to_index.end()) { + // Should not happen if node is valid leaf + PyErr_SetString(PyExc_RuntimeError, "Leaf node not found in adjacency cache"); + return nullptr; + } + + return PyLong_FromLong(it->second); +} + +// Property: adjacent_tiles (#210) +PyObject* PyBSPNode::get_adjacent_tiles(PyBSPNodeObject* self, void* closure) +{ + if (!checkValid(self)) return nullptr; + + // Only leaves have adjacent_tiles + if (!TCOD_bsp_is_leaf(self->node)) { + PyErr_SetString(PyExc_ValueError, "adjacent_tiles is only available for leaf nodes"); + return nullptr; + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + + // Ensure cache is valid + ensure_adjacency_cache(bsp); + + // Look up this node's index + auto it = bsp->adjacency_cache->ptr_to_index.find(self->node); + if (it == bsp->adjacency_cache->ptr_to_index.end()) { + PyErr_SetString(PyExc_RuntimeError, "Leaf node not found in adjacency cache"); + return nullptr; + } + + // Create adjacent_tiles wrapper + PyBSPAdjacentTilesObject* tiles = (PyBSPAdjacentTilesObject*) + mcrfpydef::PyBSPAdjacentTilesType.tp_alloc(&mcrfpydef::PyBSPAdjacentTilesType, 0); + if (!tiles) return nullptr; + + tiles->bsp_owner = self->bsp_owner; + tiles->node = self->node; + tiles->leaf_index = it->second; + tiles->generation = bsp->generation; + Py_INCREF(self->bsp_owner); + + return (PyObject*)tiles; +} + // Method: contains(pos) -> bool PyObject* PyBSPNode::contains(PyBSPNodeObject* self, PyObject* args, PyObject* kwds) { @@ -1167,3 +1443,337 @@ PyObject* PyBSPIter::repr(PyObject* obj) return PyUnicode_FromString(ss.str().c_str()); } + +// ==================== PyBSPAdjacency Implementation (#210) ==================== + +// Static method definitions +PySequenceMethods PyBSPAdjacency::sequence_methods = { + .sq_length = (lenfunc)PyBSPAdjacency::len, + .sq_item = (ssizeargfunc)PyBSPAdjacency::getitem, +}; + +PyMappingMethods PyBSPAdjacency::mapping_methods = { + .mp_length = (lenfunc)PyBSPAdjacency::len, + .mp_subscript = (binaryfunc)PyBSPAdjacency::subscript, +}; + +bool PyBSPAdjacency::checkValid(PyBSPAdjacencyObject* self) +{ + if (!self->bsp_owner) { + PyErr_SetString(PyExc_RuntimeError, "BSPAdjacency has no parent BSP"); + return false; + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + if (self->generation != bsp->generation) { + PyErr_SetString(PyExc_RuntimeError, + "BSPAdjacency is stale: parent BSP was modified. Re-access bsp.adjacency."); + return false; + } + + return true; +} + +void PyBSPAdjacency::dealloc(PyBSPAdjacencyObject* self) +{ + Py_XDECREF(self->bsp_owner); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyBSPAdjacency::repr(PyObject* obj) +{ + PyBSPAdjacencyObject* self = (PyBSPAdjacencyObject*)obj; + + if (!self->bsp_owner) { + return PyUnicode_FromString(""); + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + if (self->generation != bsp->generation) { + return PyUnicode_FromString(""); + } + + ensure_adjacency_cache(bsp); + int n = (int)bsp->adjacency_cache->leaf_pointers.size(); + + std::ostringstream ss; + ss << ""; + return PyUnicode_FromString(ss.str().c_str()); +} + +Py_ssize_t PyBSPAdjacency::len(PyBSPAdjacencyObject* self) +{ + if (!checkValid(self)) return -1; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + return (Py_ssize_t)bsp->adjacency_cache->leaf_pointers.size(); +} + +PyObject* PyBSPAdjacency::getitem(PyBSPAdjacencyObject* self, Py_ssize_t index) +{ + if (!checkValid(self)) return nullptr; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + int n = (int)bsp->adjacency_cache->leaf_pointers.size(); + + // Handle negative indexing + if (index < 0) { + index += n; + } + + if (index < 0 || index >= n) { + PyErr_SetString(PyExc_IndexError, "leaf index out of range"); + return nullptr; + } + + // Get neighbors for this leaf + const auto& neighbors = bsp->adjacency_cache->graph[index]; + + // Build tuple of neighbor indices + PyObject* result = PyTuple_New(neighbors.size()); + if (!result) return nullptr; + + for (size_t i = 0; i < neighbors.size(); i++) { + PyTuple_SET_ITEM(result, i, PyLong_FromLong(neighbors[i])); + } + + return result; +} + +PyObject* PyBSPAdjacency::subscript(PyBSPAdjacencyObject* self, PyObject* key) +{ + if (!PyLong_Check(key)) { + PyErr_SetString(PyExc_TypeError, "adjacency indices must be integers"); + return nullptr; + } + + Py_ssize_t index = PyLong_AsSsize_t(key); + if (index == -1 && PyErr_Occurred()) return nullptr; + + return getitem(self, index); +} + +PyObject* PyBSPAdjacency::iter(PyBSPAdjacencyObject* self) +{ + if (!checkValid(self)) return nullptr; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + // Create a list of tuples for iteration + int n = (int)bsp->adjacency_cache->leaf_pointers.size(); + PyObject* list = PyList_New(n); + if (!list) return nullptr; + + for (int i = 0; i < n; i++) { + const auto& neighbors = bsp->adjacency_cache->graph[i]; + PyObject* tuple = PyTuple_New(neighbors.size()); + if (!tuple) { + Py_DECREF(list); + return nullptr; + } + for (size_t j = 0; j < neighbors.size(); j++) { + PyTuple_SET_ITEM(tuple, j, PyLong_FromLong(neighbors[j])); + } + PyList_SET_ITEM(list, i, tuple); + } + + // Return iterator over the list + PyObject* iter = PyObject_GetIter(list); + Py_DECREF(list); + return iter; +} + +// ==================== PyBSPAdjacentTiles Implementation (#210) ==================== + +// Static method definitions +PyMappingMethods PyBSPAdjacentTiles::mapping_methods = { + .mp_length = (lenfunc)PyBSPAdjacentTiles::len, + .mp_subscript = (binaryfunc)PyBSPAdjacentTiles::subscript, +}; + +// Sequence methods for 'in' operator support +PySequenceMethods PyBSPAdjacentTiles::sequence_methods = { + .sq_length = (lenfunc)PyBSPAdjacentTiles::len, + .sq_contains = (objobjproc)PyBSPAdjacentTiles::contains, +}; + +PyMethodDef PyBSPAdjacentTiles::methods[] = { + {"keys", (PyCFunction)PyBSPAdjacentTiles::keys, METH_NOARGS, + "Return tuple of adjacent neighbor indices."}, + {NULL} +}; + +bool PyBSPAdjacentTiles::checkValid(PyBSPAdjacentTilesObject* self) +{ + if (!self->bsp_owner) { + PyErr_SetString(PyExc_RuntimeError, "BSPAdjacentTiles has no parent BSP"); + return false; + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + if (self->generation != bsp->generation) { + PyErr_SetString(PyExc_RuntimeError, + "BSPAdjacentTiles is stale: parent BSP was modified. Re-access node.adjacent_tiles."); + return false; + } + + return true; +} + +void PyBSPAdjacentTiles::dealloc(PyBSPAdjacentTilesObject* self) +{ + Py_XDECREF(self->bsp_owner); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyBSPAdjacentTiles::repr(PyObject* obj) +{ + PyBSPAdjacentTilesObject* self = (PyBSPAdjacentTilesObject*)obj; + + if (!self->bsp_owner) { + return PyUnicode_FromString(""); + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + if (self->generation != bsp->generation) { + return PyUnicode_FromString(""); + } + + ensure_adjacency_cache(bsp); + const auto& neighbors = bsp->adjacency_cache->graph[self->leaf_index]; + + std::ostringstream ss; + ss << "leaf_index + << " with " << neighbors.size() << " neighbors>"; + return PyUnicode_FromString(ss.str().c_str()); +} + +Py_ssize_t PyBSPAdjacentTiles::len(PyBSPAdjacentTilesObject* self) +{ + if (!checkValid(self)) return -1; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + return (Py_ssize_t)bsp->adjacency_cache->graph[self->leaf_index].size(); +} + +PyObject* PyBSPAdjacentTiles::subscript(PyBSPAdjacentTilesObject* self, PyObject* key) +{ + if (!checkValid(self)) return nullptr; + + if (!PyLong_Check(key)) { + PyErr_SetString(PyExc_TypeError, "adjacent_tiles keys must be integers (neighbor leaf index)"); + return nullptr; + } + + int neighbor_index = (int)PyLong_AsLong(key); + if (neighbor_index == -1 && PyErr_Occurred()) return nullptr; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + // Validate neighbor_index is in range + int n = (int)bsp->adjacency_cache->leaf_pointers.size(); + if (neighbor_index < 0 || neighbor_index >= n) { + PyErr_Format(PyExc_KeyError, "%d", neighbor_index); + return nullptr; + } + + // Check if neighbor_index is actually a neighbor + const auto& neighbors = bsp->adjacency_cache->graph[self->leaf_index]; + bool is_neighbor = false; + for (int ni : neighbors) { + if (ni == neighbor_index) { + is_neighbor = true; + break; + } + } + + if (!is_neighbor) { + PyErr_Format(PyExc_KeyError, "%d (not adjacent to leaf %d)", neighbor_index, self->leaf_index); + return nullptr; + } + + // Get or compute wall tiles + // Key is (self, neighbor) - NOT symmetric! Each direction has different tiles. + auto& cache = *bsp->adjacency_cache; + auto cache_key = std::make_pair(self->leaf_index, neighbor_index); + + auto it = cache.wall_tiles_cache.find(cache_key); + if (it == cache.wall_tiles_cache.end()) { + // Compute and cache - returns tiles on self's edge bordering neighbor + TCOD_bsp_t* this_node = cache.leaf_pointers[self->leaf_index]; + TCOD_bsp_t* other_node = cache.leaf_pointers[neighbor_index]; + cache.wall_tiles_cache[cache_key] = compute_wall_tiles(this_node, other_node); + it = cache.wall_tiles_cache.find(cache_key); + } + + // Build tuple of Vector objects + const auto& tiles = it->second; + PyObject* result = PyTuple_New(tiles.size()); + if (!result) return nullptr; + + for (size_t i = 0; i < tiles.size(); i++) { + // Convert sf::Vector2i to Python Vector (sf::Vector2f) + PyVector vec(sf::Vector2f((float)tiles[i].x, (float)tiles[i].y)); + PyObject* py_vec = vec.pyObject(); + if (!py_vec) { + Py_DECREF(result); + return nullptr; + } + PyTuple_SET_ITEM(result, i, py_vec); + } + + return result; +} + +int PyBSPAdjacentTiles::contains(PyBSPAdjacentTilesObject* self, PyObject* key) +{ + if (!checkValid(self)) return -1; + + if (!PyLong_Check(key)) { + return 0; // Non-integer keys are not contained + } + + int neighbor_index = (int)PyLong_AsLong(key); + if (neighbor_index == -1 && PyErr_Occurred()) { + PyErr_Clear(); + return 0; + } + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + const auto& neighbors = bsp->adjacency_cache->graph[self->leaf_index]; + for (int ni : neighbors) { + if (ni == neighbor_index) { + return 1; + } + } + return 0; +} + +PyObject* PyBSPAdjacentTiles::keys(PyBSPAdjacentTilesObject* self, PyObject* Py_UNUSED(args)) +{ + if (!checkValid(self)) return nullptr; + + PyBSPObject* bsp = (PyBSPObject*)self->bsp_owner; + ensure_adjacency_cache(bsp); + + const auto& neighbors = bsp->adjacency_cache->graph[self->leaf_index]; + + PyObject* result = PyTuple_New(neighbors.size()); + if (!result) return nullptr; + + for (size_t i = 0; i < neighbors.size(); i++) { + PyTuple_SET_ITEM(result, i, PyLong_FromLong(neighbors[i])); + } + + return result; +} diff --git a/src/PyBSP.h b/src/PyBSP.h index 430092b..3c1c2b1 100644 --- a/src/PyBSP.h +++ b/src/PyBSP.h @@ -4,10 +4,26 @@ #include #include #include +#include +#include +#include // Forward declarations class PyBSP; class PyBSPNode; +class PyBSPAdjacency; +class PyBSPAdjacentTiles; + +// Adjacency cache - computed lazily, invalidated on generation change (#210) +struct BSPAdjacencyCache { + std::vector> graph; // graph[i] = neighbor indices for leaf i + std::vector leaf_pointers; // leaf_pointers[i] = TCOD node for leaf i + std::unordered_map ptr_to_index; // Reverse lookup: node ptr -> index + uint64_t generation; // Generation when computed + // Wall tile cache: key=(self_index, neighbor_index) - perspective matters! + // leaf0.adjacent_tiles[1] returns tiles on leaf0's edge, not symmetric with leaf1.adjacent_tiles[0] + std::map, std::vector> wall_tiles_cache; +}; // Maximum recursion depth to prevent memory exhaustion // 2^16 = 65536 potential leaf nodes, which is already excessive @@ -20,6 +36,7 @@ typedef struct { int orig_x, orig_y; // Original bounds for clear() int orig_w, orig_h; uint64_t generation; // Incremented on structural changes (clear, split) + BSPAdjacencyCache* adjacency_cache; // Lazy-computed adjacency graph (#210) } PyBSPObject; // Python object structure for BSPNode (lightweight reference) @@ -39,6 +56,22 @@ typedef struct { uint64_t generation; // Generation at iterator creation } PyBSPIterObject; +// Python object for BSP.adjacency property (#210) +typedef struct { + PyObject_HEAD + PyObject* bsp_owner; // Reference to PyBSPObject + uint64_t generation; // Generation at creation (for validity check) +} PyBSPAdjacencyObject; + +// Python object for BSPNode.adjacent_tiles property (#210) +typedef struct { + PyObject_HEAD + PyObject* bsp_owner; // Reference to PyBSPObject + TCOD_bsp_t* node; // The leaf node this belongs to + int leaf_index; // This leaf's index in adjacency graph + uint64_t generation; // Generation at creation (for validity check) +} PyBSPAdjacentTilesObject; + class PyBSP { public: @@ -53,6 +86,7 @@ public: static PyObject* get_pos(PyBSPObject* self, void* closure); static PyObject* get_size(PyBSPObject* self, void* closure); static PyObject* get_root(PyBSPObject* self, void* closure); + static PyObject* get_adjacency(PyBSPObject* self, void* closure); // #210 // Splitting methods (#202) static PyObject* split_once(PyBSPObject* self, PyObject* args, PyObject* kwds); @@ -65,6 +99,7 @@ public: // Query methods (#205) static PyObject* find(PyBSPObject* self, PyObject* args, PyObject* kwds); + static PyObject* get_leaf(PyBSPObject* self, PyObject* args, PyObject* kwds); // #210 // HeightMap conversion (#206) static PyObject* to_heightmap(PyBSPObject* self, PyObject* args, PyObject* kwds); @@ -106,6 +141,10 @@ public: static PyObject* get_parent(PyBSPNodeObject* self, void* closure); static PyObject* get_sibling(PyBSPNodeObject* self, void* closure); + // Adjacency properties (#210) + static PyObject* get_leaf_index(PyBSPNodeObject* self, void* closure); + static PyObject* get_adjacent_tiles(PyBSPNodeObject* self, void* closure); + // Methods (#203) static PyObject* contains(PyBSPNodeObject* self, PyObject* args, PyObject* kwds); static PyObject* center(PyBSPNodeObject* self, PyObject* Py_UNUSED(args)); @@ -132,6 +171,39 @@ public: static PyObject* repr(PyObject* obj); }; +// BSP Adjacency wrapper class (#210) +class PyBSPAdjacency +{ +public: + static void dealloc(PyBSPAdjacencyObject* self); + static PyObject* repr(PyObject* obj); + static Py_ssize_t len(PyBSPAdjacencyObject* self); + static PyObject* getitem(PyBSPAdjacencyObject* self, Py_ssize_t index); + static PyObject* subscript(PyBSPAdjacencyObject* self, PyObject* key); + static PyObject* iter(PyBSPAdjacencyObject* self); + static bool checkValid(PyBSPAdjacencyObject* self); + + static PySequenceMethods sequence_methods; + static PyMappingMethods mapping_methods; +}; + +// BSP Adjacent Tiles wrapper class (#210) +class PyBSPAdjacentTiles +{ +public: + static void dealloc(PyBSPAdjacentTilesObject* self); + static PyObject* repr(PyObject* obj); + static Py_ssize_t len(PyBSPAdjacentTilesObject* self); + static PyObject* subscript(PyBSPAdjacentTilesObject* self, PyObject* key); + static int contains(PyBSPAdjacentTilesObject* self, PyObject* key); + static PyObject* keys(PyBSPAdjacentTilesObject* self, PyObject* Py_UNUSED(args)); + static bool checkValid(PyBSPAdjacentTilesObject* self); + + static PyMappingMethods mapping_methods; + static PySequenceMethods sequence_methods; // For 'in' operator + static PyMethodDef methods[]; +}; + // Traversal enum creation class PyTraversal { @@ -237,4 +309,53 @@ namespace mcrfpydef { return NULL; }, }; + + // BSP Adjacency - internal type for BSP.adjacency property (#210) + inline PyTypeObject PyBSPAdjacencyType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.BSPAdjacency", + .tp_basicsize = sizeof(PyBSPAdjacencyObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyBSPAdjacency::dealloc, + .tp_repr = PyBSPAdjacency::repr, + .tp_as_sequence = &PyBSPAdjacency::sequence_methods, + .tp_as_mapping = &PyBSPAdjacency::mapping_methods, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "BSPAdjacency - Sequence of leaf neighbor tuples.\n\n" + "Accessed via BSP.adjacency property. adjacency[i] returns a tuple\n" + "of leaf indices that are adjacent to (share a wall with) leaf i.\n" + ), + .tp_iter = (getiterfunc)PyBSPAdjacency::iter, + .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* { + PyErr_SetString(PyExc_TypeError, "BSPAdjacency cannot be instantiated directly"); + return NULL; + }, + }; + + // BSP Adjacent Tiles - internal type for BSPNode.adjacent_tiles property (#210) + inline PyTypeObject PyBSPAdjacentTilesType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.BSPAdjacentTiles", + .tp_basicsize = sizeof(PyBSPAdjacentTilesObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyBSPAdjacentTiles::dealloc, + .tp_repr = PyBSPAdjacentTiles::repr, + .tp_as_sequence = &PyBSPAdjacentTiles::sequence_methods, // For 'in' operator + .tp_as_mapping = &PyBSPAdjacentTiles::mapping_methods, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "BSPAdjacentTiles - Mapping of neighbor index to wall tile coordinates.\n\n" + "Accessed via BSPNode.adjacent_tiles property. adjacent_tiles[j] returns\n" + "a tuple of Vector coordinates representing tiles on THIS leaf's edge that\n" + "border neighbor j. Each Vector has integer x/y coordinates (use .int for tuple).\n" + "Raises KeyError if j is not an adjacent leaf.\n\n" + "Supports 'in' operator: `5 in leaf.adjacent_tiles` checks if leaf 5 is adjacent.\n" + ), + .tp_methods = PyBSPAdjacentTiles::methods, + .tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* { + PyErr_SetString(PyExc_TypeError, "BSPAdjacentTiles cannot be instantiated directly"); + return NULL; + }, + }; } diff --git a/tests/unit/bsp_adjacency_test.py b/tests/unit/bsp_adjacency_test.py new file mode 100644 index 0000000..a9d9e17 --- /dev/null +++ b/tests/unit/bsp_adjacency_test.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Tests for BSP adjacency graph feature (#210)""" + +import mcrfpy +import sys + +def test_adjacency_basic(): + """Test basic adjacency on a simple 2-leaf BSP""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + # Split once - creates 2 leaves + bsp.split_once(horizontal=False, position=50) # Vertical split at x=50 + + leaves = list(bsp.leaves()) + assert len(leaves) == 2, f"Expected 2 leaves, got {len(leaves)}" + + # Access adjacency + adj = bsp.adjacency + assert len(adj) == 2, f"Expected adjacency len 2, got {len(adj)}" + + # Each leaf should be adjacent to the other + neighbors_0 = adj[0] + neighbors_1 = adj[1] + + assert 1 in neighbors_0, f"Leaf 0 should be adjacent to leaf 1, got {neighbors_0}" + assert 0 in neighbors_1, f"Leaf 1 should be adjacent to leaf 0, got {neighbors_1}" + + print(" test_adjacency_basic: PASS") + +def test_leaf_indexing(): + """Test that leaf_index property works correctly""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=2, min_size=(10, 10), seed=42) + + leaves = list(bsp.leaves()) + + # Each leaf should have a valid index + for i, leaf in enumerate(leaves): + assert leaf.leaf_index == i, f"Leaf {i} has index {leaf.leaf_index}" + + # Non-leaves should return None + root = bsp.root + if not root.is_leaf: + assert root.leaf_index is None, "Non-leaf should have leaf_index=None" + + print(" test_leaf_indexing: PASS") + +def test_adjacency_symmetry(): + """Test that adjacency is symmetric: if A adjacent to B, then B adjacent to A""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=3, min_size=(10, 10), seed=42) + + adj = bsp.adjacency + n = len(adj) + + for i in range(n): + for j in adj[i]: + assert i in adj[j], f"Adjacency not symmetric: {i} -> {j} but not {j} -> {i}" + + print(" test_adjacency_symmetry: PASS") + +def test_adjacent_tiles_basic(): + """Test that adjacent_tiles returns Vector tuples""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + bsp.split_once(horizontal=False, position=50) # Vertical split at x=50 + + leaves = list(bsp.leaves()) + assert len(leaves) == 2 + + leaf0 = leaves[0] + neighbors = bsp.adjacency[0] + assert len(neighbors) > 0, "Leaf 0 should have neighbors" + + neighbor_idx = neighbors[0] + wall_tiles = leaf0.adjacent_tiles[neighbor_idx] + + assert len(wall_tiles) > 0, "Should have wall tiles" + + # Check that wall tiles are Vector objects + first_tile = wall_tiles[0] + assert hasattr(first_tile, 'x') and hasattr(first_tile, 'y'), \ + f"Wall tile should be a Vector, got {type(first_tile)}" + + print(" test_adjacent_tiles_basic: PASS") + +def test_adjacent_tiles_keyerror(): + """Test that non-adjacent lookups raise KeyError""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=3, min_size=(10, 10), seed=42) + + leaves = list(bsp.leaves()) + + # Find a non-adjacent pair + adj = bsp.adjacency + for i in range(len(leaves)): + for j in range(len(leaves)): + if i != j and j not in adj[i]: + # i and j are not adjacent + try: + _ = leaves[i].adjacent_tiles[j] + assert False, f"Expected KeyError for non-adjacent pair {i}, {j}" + except KeyError: + pass # Expected + print(" test_adjacent_tiles_keyerror: PASS") + return + + # If we get here, all pairs are adjacent (unlikely with depth 3) + print(" test_adjacent_tiles_keyerror: SKIP (all pairs adjacent)") + +def test_cache_invalidation(): + """Test that cache is invalidated on clear() and split_recursive()""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=2, min_size=(10, 10), seed=42) + + # Access adjacency to populate cache + adj1 = bsp.adjacency + n1 = len(adj1) + + # Clear and re-split + bsp.clear() + bsp.split_recursive(depth=3, min_size=(10, 10), seed=123) + + # Access adjacency again - should be rebuilt + adj2 = bsp.adjacency + n2 = len(adj2) + + # Different seed/depth should give different results + assert n2 > n1 or n2 != n1, "Cache should be invalidated after clear()" + + print(" test_cache_invalidation: PASS") + +def test_wall_tiles_on_boundary(): + """Test that wall tiles are on the correct boundary""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + bsp.split_once(horizontal=False, position=50) # Vertical split at x=50 + + leaves = list(bsp.leaves()) + leaf0 = leaves[0] # Should be left side (x: 0-50) + leaf1 = leaves[1] # Should be right side (x: 50-100) + + # Get wall tiles from leaf0 to leaf1 + wall_tiles = leaf0.adjacent_tiles[1] + + # Wall should be at x=49 (last column of leaf0) for leaf0 + for tile in wall_tiles: + x, y = int(tile.x), int(tile.y) + # Tile should be within leaf0's bounds + assert x == 49, f"Wall tile x should be 49 (boundary), got {x}" + assert 0 <= y < 50, f"Wall tile y should be in range 0-49, got {y}" + + print(" test_wall_tiles_on_boundary: PASS") + +def test_negative_indexing(): + """Test that negative indices work for adjacency""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=2, min_size=(10, 10), seed=42) + + adj = bsp.adjacency + n = len(adj) + + # adj[-1] should be same as adj[n-1] + last_positive = adj[n-1] + last_negative = adj[-1] + + assert last_positive == last_negative, \ + f"Negative indexing failed: adj[-1]={last_negative}, adj[{n-1}]={last_positive}" + + print(" test_negative_indexing: PASS") + +def test_iteration(): + """Test that adjacency can be iterated""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=2, min_size=(10, 10), seed=42) + + adj = bsp.adjacency + + # Should be iterable + count = 0 + for neighbors in adj: + assert isinstance(neighbors, tuple), f"Expected tuple, got {type(neighbors)}" + count += 1 + + assert count == len(adj), f"Iteration count {count} != len {len(adj)}" + + print(" test_iteration: PASS") + +def test_keys_method(): + """Test that adjacent_tiles.keys() works""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + bsp.split_once(horizontal=False, position=50) + + leaves = list(bsp.leaves()) + leaf0 = leaves[0] + + keys = leaf0.adjacent_tiles.keys() + assert isinstance(keys, tuple), f"keys() should return tuple, got {type(keys)}" + assert len(keys) > 0, "Should have at least one neighbor" + assert 1 in keys, f"Leaf 1 should be in keys, got {keys}" + + print(" test_keys_method: PASS") + +def test_split_once_invalidation(): + """Test that split_once invalidates adjacency cache and BSPNode references""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + + # First split - creates 2 leaves + bsp.split_once(horizontal=False, position=50) + + # Access adjacency to build cache + adj1 = bsp.adjacency + n1 = len(adj1) + assert n1 == 2, f"Expected 2 leaves after first split, got {n1}" + + # Get a reference to a leaf before second split + old_leaf = list(bsp.leaves())[0] + old_leaf_index = old_leaf.leaf_index + + # Second split_once on the BSP (note: split_once always works on root) + # After this, the tree structure changes, but old_leaf should be stale + bsp.clear() # Clear and split fresh to get more leaves + bsp.split_once(horizontal=False, position=50) + bsp.split_once(horizontal=True, position=50) # Won't work - split_once only on root + + # The old leaf reference should now be stale + try: + _ = old_leaf.leaf_index + assert False, "Expected RuntimeError for stale BSPNode" + except RuntimeError: + pass # Expected - node is stale after clear() + + # Access adjacency - should reflect new structure (2 leaves again) + adj2 = bsp.adjacency + n2 = len(adj2) + assert n2 == 2, f"Expected 2 leaves after clear+split, got {n2}" + + print(" test_split_once_invalidation: PASS") + +def test_wall_tiles_perspective(): + """Test that wall tiles are from the correct leaf's perspective""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + bsp.split_once(horizontal=False, position=50) # Vertical split at x=50 + + leaves = list(bsp.leaves()) + leaf0 = leaves[0] # Left side: x 0-50 + leaf1 = leaves[1] # Right side: x 50-100 + + # Get tiles from leaf0's perspective (should be at x=49, leaf0's edge) + tiles_0_to_1 = leaf0.adjacent_tiles[1] + for tile in tiles_0_to_1: + assert int(tile.x) == 49, f"Leaf0->Leaf1 tile should be at x=49, got {tile.x}" + + # Get tiles from leaf1's perspective (should be at x=50, leaf1's edge) + tiles_1_to_0 = leaf1.adjacent_tiles[0] + for tile in tiles_1_to_0: + assert int(tile.x) == 50, f"Leaf1->Leaf0 tile should be at x=50, got {tile.x}" + + print(" test_wall_tiles_perspective: PASS") + +def test_get_leaf(): + """Test bsp.get_leaf(index) method""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100)) + bsp.split_recursive(depth=2, min_size=(10, 10), seed=42) + + leaves = list(bsp.leaves()) + n = len(leaves) + + # Test positive indices + for i in range(n): + leaf = bsp.get_leaf(i) + assert leaf.leaf_index == i, f"get_leaf({i}) returned leaf with index {leaf.leaf_index}" + + # Test negative index + last_leaf = bsp.get_leaf(-1) + assert last_leaf.leaf_index == n - 1, f"get_leaf(-1) should return leaf {n-1}, got {last_leaf.leaf_index}" + + # Test out of range + try: + bsp.get_leaf(n) + assert False, "Expected IndexError for out-of-range index" + except IndexError: + pass # Expected + + print(" test_get_leaf: PASS") + +def test_contains_operator(): + """Test 'in' operator for adjacent_tiles""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50)) + bsp.split_once(horizontal=False, position=50) + + leaves = list(bsp.leaves()) + leaf0 = leaves[0] + + # Leaf 1 should be adjacent to leaf 0 + assert 1 in leaf0.adjacent_tiles, "1 should be in leaf0.adjacent_tiles" + + # Some arbitrary index should not be (assuming only 2 leaves) + assert 5 not in leaf0.adjacent_tiles, "5 should not be in leaf0.adjacent_tiles" + + print(" test_contains_operator: PASS") + +def run_all_tests(): + """Run all adjacency tests""" + print("Running BSP adjacency tests (#210)...") + + test_adjacency_basic() + test_leaf_indexing() + test_adjacency_symmetry() + test_adjacent_tiles_basic() + test_adjacent_tiles_keyerror() + test_cache_invalidation() + test_wall_tiles_on_boundary() + test_negative_indexing() + test_iteration() + test_keys_method() + test_split_once_invalidation() + test_wall_tiles_perspective() + test_get_leaf() + test_contains_operator() + + print("\nAll BSP adjacency tests passed!") + return 0 + +if __name__ == "__main__": + sys.exit(run_all_tests())