diff --git a/.gitmodules b/.gitmodules index 1dccd8f..239bdda 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,9 +14,3 @@ path = modules/libtcod-headless url = git@github.com:jmccardle/libtcod-headless.git branch = 2.2.1-headless -[submodule "modules/RapidXML"] - path = modules/RapidXML - url = https://github.com/Fe-Bell/RapidXML -[submodule "modules/json"] - path = modules/json - url = git@github.com:nlohmann/json.git diff --git a/CMakeLists.txt b/CMakeLists.txt index ed399d8..704b9c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,9 +52,6 @@ include_directories(SYSTEM ${CMAKE_SOURCE_DIR}/deps/libtcod) include_directories(${CMAKE_SOURCE_DIR}/src) include_directories(${CMAKE_SOURCE_DIR}/src/3d) include_directories(${CMAKE_SOURCE_DIR}/src/platform) -include_directories(${CMAKE_SOURCE_DIR}/src/tiled) -include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML) -include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include) # Python includes: use different paths for Windows vs Linux vs Emscripten if(EMSCRIPTEN) diff --git a/modules/RapidXML b/modules/RapidXML deleted file mode 160000 index 3a42082..0000000 --- a/modules/RapidXML +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3a42082084509e9efb58dcef17b1ad5860dab6ac diff --git a/modules/json b/modules/json deleted file mode 160000 index 21b5374..0000000 --- a/modules/json +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 21b53746c9d73d314d5de454e2e7cddd20cbbe5d diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index d9e266c..ec4ffe1 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -37,9 +37,6 @@ #include "3d/Model3D.h" // 3D model resource #include "3d/Billboard.h" // Billboard sprites #include "3d/PyVoxelGrid.h" // Voxel grid for 3D structures (Milestone 9) -#include "tiled/PyTileSetFile.h" // Tiled tileset loading -#include "tiled/PyTileMapFile.h" // Tiled tilemap loading -#include "tiled/PyWangSet.h" // Wang auto-tile sets #include "McRogueFaceVersion.h" #include "GameEngine.h" // ImGui is only available for SFML builds @@ -489,11 +486,6 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyPropertyBindingType, &mcrfpydef::PyCallableBindingType, - /*tiled map/tileset loading*/ - &mcrfpydef::PyTileSetFileType, - &mcrfpydef::PyTileMapFileType, - &mcrfpydef::PyWangSetType, - nullptr}; // Types that are used internally but NOT exported to module namespace (#189) @@ -567,14 +559,6 @@ PyObject* PyInit_mcrfpy() // Set up PyUniformCollectionType methods (#106) mcrfpydef::PyUniformCollectionType.tp_methods = ::PyUniformCollectionType::methods; - // Set up Tiled types methods and getsetters - mcrfpydef::PyTileSetFileType.tp_methods = PyTileSetFile::methods; - mcrfpydef::PyTileSetFileType.tp_getset = PyTileSetFile::getsetters; - mcrfpydef::PyTileMapFileType.tp_methods = PyTileMapFile::methods; - mcrfpydef::PyTileMapFileType.tp_getset = PyTileMapFile::getsetters; - mcrfpydef::PyWangSetType.tp_methods = PyWangSet::methods; - mcrfpydef::PyWangSetType.tp_getset = PyWangSet::getsetters; - // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/PyInputState.cpp b/src/PyInputState.cpp index ea7392f..ea8fe55 100644 --- a/src/PyInputState.cpp +++ b/src/PyInputState.cpp @@ -69,14 +69,6 @@ def _InputState_eq(self, other): return int.__eq__(int(self), other) InputState.__eq__ = _InputState_eq - -def _InputState_ne(self, other): - result = type(self).__eq__(self, other) - if result is NotImplemented: - return result - return not result - -InputState.__ne__ = _InputState_ne InputState.__hash__ = lambda self: hash(int(self)) InputState.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" InputState.__str__ = lambda self: self.name diff --git a/src/PyKey.cpp b/src/PyKey.cpp index 54ea5ea..8cdefb3 100644 --- a/src/PyKey.cpp +++ b/src/PyKey.cpp @@ -217,14 +217,6 @@ def _Key_eq(self, other): return int.__eq__(int(self), other) Key.__eq__ = _Key_eq - -def _Key_ne(self, other): - result = type(self).__eq__(self, other) - if result is NotImplemented: - return result - return not result - -Key.__ne__ = _Key_ne Key.__hash__ = lambda self: hash(int(self)) Key.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" Key.__str__ = lambda self: self.name diff --git a/src/PyMouseButton.cpp b/src/PyMouseButton.cpp index 95b85b4..3f6ca74 100644 --- a/src/PyMouseButton.cpp +++ b/src/PyMouseButton.cpp @@ -89,14 +89,6 @@ def _MouseButton_eq(self, other): return int.__eq__(int(self), other) MouseButton.__eq__ = _MouseButton_eq - -def _MouseButton_ne(self, other): - result = type(self).__eq__(self, other) - if result is NotImplemented: - return result - return not result - -MouseButton.__ne__ = _MouseButton_ne MouseButton.__hash__ = lambda self: hash(int(self)) MouseButton.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" MouseButton.__str__ = lambda self: self.name diff --git a/src/tiled/PyTileMapFile.cpp b/src/tiled/PyTileMapFile.cpp deleted file mode 100644 index f9c6aa1..0000000 --- a/src/tiled/PyTileMapFile.cpp +++ /dev/null @@ -1,332 +0,0 @@ -#include "PyTileMapFile.h" -#include "PyTileSetFile.h" -#include "TiledParse.h" -#include "McRFPy_Doc.h" -#include "GridLayers.h" -#include - -using namespace mcrf::tiled; - -// ============================================================ -// Type lifecycle -// ============================================================ - -PyObject* PyTileMapFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { - auto* self = (PyTileMapFileObject*)type->tp_alloc(type, 0); - if (self) { - new (&self->data) std::shared_ptr(); - } - return (PyObject*)self; -} - -int PyTileMapFile::init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"path", nullptr}; - const char* path = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) - return -1; - - try { - self->data = loadTileMap(path); - } catch (const std::exception& e) { - PyErr_Format(PyExc_IOError, "Failed to load tilemap: %s", e.what()); - return -1; - } - - return 0; -} - -void PyTileMapFile::dealloc(PyTileMapFileObject* self) { - self->data.~shared_ptr(); - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* PyTileMapFile::repr(PyObject* obj) { - auto* self = (PyTileMapFileObject*)obj; - if (!self->data) { - return PyUnicode_FromString(""); - } - return PyUnicode_FromFormat("", - self->data->width, self->data->height, - (int)self->data->tilesets.size(), - (int)self->data->tile_layers.size(), - (int)self->data->object_layers.size()); -} - -// ============================================================ -// Properties -// ============================================================ - -PyObject* PyTileMapFile::get_width(PyTileMapFileObject* self, void*) { - return PyLong_FromLong(self->data->width); -} - -PyObject* PyTileMapFile::get_height(PyTileMapFileObject* self, void*) { - return PyLong_FromLong(self->data->height); -} - -PyObject* PyTileMapFile::get_tile_width(PyTileMapFileObject* self, void*) { - return PyLong_FromLong(self->data->tile_width); -} - -PyObject* PyTileMapFile::get_tile_height(PyTileMapFileObject* self, void*) { - return PyLong_FromLong(self->data->tile_height); -} - -PyObject* PyTileMapFile::get_orientation(PyTileMapFileObject* self, void*) { - return PyUnicode_FromString(self->data->orientation.c_str()); -} - -PyObject* PyTileMapFile::get_properties(PyTileMapFileObject* self, void*) { - return propertiesToPython(self->data->properties); -} - -PyObject* PyTileMapFile::get_tileset_count(PyTileMapFileObject* self, void*) { - return PyLong_FromLong(self->data->tilesets.size()); -} - -PyObject* PyTileMapFile::get_tile_layer_names(PyTileMapFileObject* self, void*) { - PyObject* list = PyList_New(self->data->tile_layers.size()); - if (!list) return NULL; - for (size_t i = 0; i < self->data->tile_layers.size(); i++) { - PyObject* name = PyUnicode_FromString(self->data->tile_layers[i].name.c_str()); - if (!name) { Py_DECREF(list); return NULL; } - PyList_SET_ITEM(list, i, name); - } - return list; -} - -PyObject* PyTileMapFile::get_object_layer_names(PyTileMapFileObject* self, void*) { - PyObject* list = PyList_New(self->data->object_layers.size()); - if (!list) return NULL; - for (size_t i = 0; i < self->data->object_layers.size(); i++) { - PyObject* name = PyUnicode_FromString(self->data->object_layers[i].name.c_str()); - if (!name) { Py_DECREF(list); return NULL; } - PyList_SET_ITEM(list, i, name); - } - return list; -} - -// ============================================================ -// Methods -// ============================================================ - -PyObject* PyTileMapFile::tileset(PyTileMapFileObject* self, PyObject* args) { - int index; - if (!PyArg_ParseTuple(args, "i", &index)) - return NULL; - - if (index < 0 || index >= (int)self->data->tilesets.size()) { - PyErr_Format(PyExc_IndexError, "Tileset index %d out of range (0..%d)", - index, (int)self->data->tilesets.size() - 1); - return NULL; - } - - const auto& ref = self->data->tilesets[index]; - - // Create a TileSetFile wrapping the existing parsed data - auto* ts_type = &mcrfpydef::PyTileSetFileType; - auto* ts = (PyTileSetFileObject*)ts_type->tp_alloc(ts_type, 0); - if (!ts) return NULL; - new (&ts->data) std::shared_ptr(ref.tileset); - - // Return (firstgid, TileSetFile) - PyObject* result = Py_BuildValue("(iN)", ref.firstgid, (PyObject*)ts); - return result; -} - -PyObject* PyTileMapFile::tile_layer_data(PyTileMapFileObject* self, PyObject* args) { - const char* name; - if (!PyArg_ParseTuple(args, "s", &name)) - return NULL; - - for (const auto& tl : self->data->tile_layers) { - if (tl.name == name) { - PyObject* list = PyList_New(tl.global_gids.size()); - if (!list) return NULL; - for (size_t i = 0; i < tl.global_gids.size(); i++) { - PyList_SET_ITEM(list, i, PyLong_FromUnsignedLong(tl.global_gids[i])); - } - return list; - } - } - - PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", name); - return NULL; -} - -PyObject* PyTileMapFile::resolve_gid(PyTileMapFileObject* self, PyObject* args) { - unsigned int gid; - if (!PyArg_ParseTuple(args, "I", &gid)) - return NULL; - - if (gid == 0) { - // GID 0 = empty tile - return Py_BuildValue("(ii)", -1, -1); - } - - // Strip flip flags (top 3 bits of a 32-bit GID) - uint32_t clean_gid = gid & 0x1FFFFFFF; - - // Find which tileset this GID belongs to (tilesets sorted by firstgid) - int ts_index = -1; - for (int i = (int)self->data->tilesets.size() - 1; i >= 0; i--) { - if (clean_gid >= (uint32_t)self->data->tilesets[i].firstgid) { - ts_index = i; - break; - } - } - - if (ts_index < 0) { - return Py_BuildValue("(ii)", -1, -1); - } - - int local_id = clean_gid - self->data->tilesets[ts_index].firstgid; - return Py_BuildValue("(ii)", ts_index, local_id); -} - -PyObject* PyTileMapFile::object_layer(PyTileMapFileObject* self, PyObject* args) { - const char* name; - if (!PyArg_ParseTuple(args, "s", &name)) - return NULL; - - for (const auto& ol : self->data->object_layers) { - if (ol.name == name) { - return jsonToPython(ol.objects); - } - } - - PyErr_Format(PyExc_KeyError, "No object layer named '%s'", name); - return NULL; -} - -PyObject* PyTileMapFile::apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"tile_layer", "layer_name", "tileset_index", nullptr}; - PyObject* tlayer_obj; - const char* layer_name; - int tileset_index = 0; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "Os|i", const_cast(keywords), - &tlayer_obj, &layer_name, &tileset_index)) - return NULL; - - // Validate TileLayer - // Check type by name since PyTileLayerType is static-per-TU - const char* type_name = Py_TYPE(tlayer_obj)->tp_name; - if (!type_name || strcmp(type_name, "mcrfpy.TileLayer") != 0) { - PyErr_SetString(PyExc_TypeError, "First argument must be a TileLayer"); - return NULL; - } - - // Find the tile layer data - const TileLayerData* tld = nullptr; - for (const auto& tl : self->data->tile_layers) { - if (tl.name == layer_name) { - tld = &tl; - break; - } - } - if (!tld) { - PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", layer_name); - return NULL; - } - - if (tileset_index < 0 || tileset_index >= (int)self->data->tilesets.size()) { - PyErr_Format(PyExc_IndexError, "Tileset index %d out of range", tileset_index); - return NULL; - } - - int firstgid = self->data->tilesets[tileset_index].firstgid; - auto* tlayer = (PyTileLayerObject*)tlayer_obj; - - int w = tld->width; - int h = tld->height; - for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { - for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { - uint32_t gid = tld->global_gids[y * w + x]; - if (gid == 0) { - tlayer->data->at(x, y) = -1; // empty - continue; - } - uint32_t clean_gid = gid & 0x1FFFFFFF; - int local_id = static_cast(clean_gid) - firstgid; - if (local_id >= 0) { - tlayer->data->at(x, y) = local_id; - } - } - } - tlayer->data->markDirty(); - - Py_RETURN_NONE; -} - -// ============================================================ -// Method/GetSet tables -// ============================================================ - -PyMethodDef PyTileMapFile::methods[] = { - {"tileset", (PyCFunction)PyTileMapFile::tileset, METH_VARARGS, - MCRF_METHOD(TileMapFile, tileset, - MCRF_SIG("(index: int)", "tuple[int, TileSetFile]"), - MCRF_DESC("Get a referenced tileset by index."), - MCRF_ARGS_START - MCRF_ARG("index", "Tileset index (0-based)") - MCRF_RETURNS("Tuple of (firstgid, TileSetFile).") - )}, - {"tile_layer_data", (PyCFunction)PyTileMapFile::tile_layer_data, METH_VARARGS, - MCRF_METHOD(TileMapFile, tile_layer_data, - MCRF_SIG("(name: str)", "list[int]"), - MCRF_DESC("Get raw global GID data for a tile layer."), - MCRF_ARGS_START - MCRF_ARG("name", "Name of the tile layer") - MCRF_RETURNS("Flat list of global GIDs (0 = empty tile).") - MCRF_RAISES("KeyError", "If no tile layer with that name exists") - )}, - {"resolve_gid", (PyCFunction)PyTileMapFile::resolve_gid, METH_VARARGS, - MCRF_METHOD(TileMapFile, resolve_gid, - MCRF_SIG("(gid: int)", "tuple[int, int]"), - MCRF_DESC("Resolve a global tile ID to tileset index and local tile ID."), - MCRF_ARGS_START - MCRF_ARG("gid", "Global tile ID from tile_layer_data()") - MCRF_RETURNS("Tuple of (tileset_index, local_tile_id). (-1, -1) for empty/invalid.") - )}, - {"object_layer", (PyCFunction)PyTileMapFile::object_layer, METH_VARARGS, - MCRF_METHOD(TileMapFile, object_layer, - MCRF_SIG("(name: str)", "list[dict]"), - MCRF_DESC("Get objects from an object layer as Python dicts."), - MCRF_ARGS_START - MCRF_ARG("name", "Name of the object layer") - MCRF_RETURNS("List of dicts with object properties (id, name, x, y, width, height, etc.).") - MCRF_RAISES("KeyError", "If no object layer with that name exists") - )}, - {"apply_to_tile_layer", (PyCFunction)PyTileMapFile::apply_to_tile_layer, METH_VARARGS | METH_KEYWORDS, - MCRF_METHOD(TileMapFile, apply_to_tile_layer, - MCRF_SIG("(tile_layer: TileLayer, layer_name: str, tileset_index: int = 0)", "None"), - MCRF_DESC("Resolve GIDs and write sprite indices into a TileLayer."), - MCRF_ARGS_START - MCRF_ARG("tile_layer", "Target TileLayer to write into") - MCRF_ARG("layer_name", "Name of the tile layer in this map") - MCRF_ARG("tileset_index", "Which tileset to resolve GIDs against (default 0)") - )}, - {NULL} -}; - -PyGetSetDef PyTileMapFile::getsetters[] = { - {"width", (getter)PyTileMapFile::get_width, NULL, - MCRF_PROPERTY(width, "Map width in tiles (int, read-only)."), NULL}, - {"height", (getter)PyTileMapFile::get_height, NULL, - MCRF_PROPERTY(height, "Map height in tiles (int, read-only)."), NULL}, - {"tile_width", (getter)PyTileMapFile::get_tile_width, NULL, - MCRF_PROPERTY(tile_width, "Tile width in pixels (int, read-only)."), NULL}, - {"tile_height", (getter)PyTileMapFile::get_tile_height, NULL, - MCRF_PROPERTY(tile_height, "Tile height in pixels (int, read-only)."), NULL}, - {"orientation", (getter)PyTileMapFile::get_orientation, NULL, - MCRF_PROPERTY(orientation, "Map orientation, e.g. 'orthogonal' (str, read-only)."), NULL}, - {"properties", (getter)PyTileMapFile::get_properties, NULL, - MCRF_PROPERTY(properties, "Custom map properties as a dict (read-only)."), NULL}, - {"tileset_count", (getter)PyTileMapFile::get_tileset_count, NULL, - MCRF_PROPERTY(tileset_count, "Number of referenced tilesets (int, read-only)."), NULL}, - {"tile_layer_names", (getter)PyTileMapFile::get_tile_layer_names, NULL, - MCRF_PROPERTY(tile_layer_names, "List of tile layer names (read-only)."), NULL}, - {"object_layer_names", (getter)PyTileMapFile::get_object_layer_names, NULL, - MCRF_PROPERTY(object_layer_names, "List of object layer names (read-only)."), NULL}, - {NULL} -}; diff --git a/src/tiled/PyTileMapFile.h b/src/tiled/PyTileMapFile.h deleted file mode 100644 index 36a879c..0000000 --- a/src/tiled/PyTileMapFile.h +++ /dev/null @@ -1,81 +0,0 @@ -#pragma once -#include "Python.h" -#include "TiledTypes.h" -#include - -// Python object structure -typedef struct PyTileMapFileObject { - PyObject_HEAD - std::shared_ptr data; -} PyTileMapFileObject; - -// Python binding class -class PyTileMapFile { -public: - static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); - static int init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); - static void dealloc(PyTileMapFileObject* self); - static PyObject* repr(PyObject* obj); - - // Read-only properties - static PyObject* get_width(PyTileMapFileObject* self, void* closure); - static PyObject* get_height(PyTileMapFileObject* self, void* closure); - static PyObject* get_tile_width(PyTileMapFileObject* self, void* closure); - static PyObject* get_tile_height(PyTileMapFileObject* self, void* closure); - static PyObject* get_orientation(PyTileMapFileObject* self, void* closure); - static PyObject* get_properties(PyTileMapFileObject* self, void* closure); - static PyObject* get_tileset_count(PyTileMapFileObject* self, void* closure); - static PyObject* get_tile_layer_names(PyTileMapFileObject* self, void* closure); - static PyObject* get_object_layer_names(PyTileMapFileObject* self, void* closure); - - // Methods - static PyObject* tileset(PyTileMapFileObject* self, PyObject* args); - static PyObject* tile_layer_data(PyTileMapFileObject* self, PyObject* args); - static PyObject* resolve_gid(PyTileMapFileObject* self, PyObject* args); - static PyObject* object_layer(PyTileMapFileObject* self, PyObject* args); - static PyObject* apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); - - static PyMethodDef methods[]; - static PyGetSetDef getsetters[]; -}; - -// Type definition in mcrfpydef namespace -namespace mcrfpydef { - -inline PyTypeObject PyTileMapFileType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.TileMapFile", - .tp_basicsize = sizeof(PyTileMapFileObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)PyTileMapFile::dealloc, - .tp_repr = PyTileMapFile::repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "TileMapFile(path: str)\n\n" - "Load a Tiled map file (.tmx or .tmj).\n\n" - "Parses the map and its referenced tilesets, providing access to tile layers,\n" - "object layers, and GID resolution.\n\n" - "Args:\n" - " path: Path to the .tmx or .tmj map file.\n\n" - "Properties:\n" - " width (int, read-only): Map width in tiles.\n" - " height (int, read-only): Map height in tiles.\n" - " tile_width (int, read-only): Tile width in pixels.\n" - " tile_height (int, read-only): Tile height in pixels.\n" - " orientation (str, read-only): Map orientation (e.g. 'orthogonal').\n" - " properties (dict, read-only): Custom map properties.\n" - " tileset_count (int, read-only): Number of referenced tilesets.\n" - " tile_layer_names (list, read-only): Names of tile layers.\n" - " object_layer_names (list, read-only): Names of object layers.\n\n" - "Example:\n" - " tm = mcrfpy.TileMapFile('map.tmx')\n" - " data = tm.tile_layer_data('Ground')\n" - " tm.apply_to_tile_layer(my_tile_layer, 'Ground')\n" - ), - .tp_methods = nullptr, // Set before PyType_Ready - .tp_getset = nullptr, // Set before PyType_Ready - .tp_init = (initproc)PyTileMapFile::init, - .tp_new = PyTileMapFile::pynew, -}; - -} // namespace mcrfpydef diff --git a/src/tiled/PyTileSetFile.cpp b/src/tiled/PyTileSetFile.cpp deleted file mode 100644 index 9748aa6..0000000 --- a/src/tiled/PyTileSetFile.cpp +++ /dev/null @@ -1,234 +0,0 @@ -#include "PyTileSetFile.h" -#include "TiledParse.h" -#include "PyWangSet.h" -#include "McRFPy_Doc.h" - -using namespace mcrf::tiled; - -// ============================================================ -// Type lifecycle -// ============================================================ - -PyObject* PyTileSetFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { - auto* self = (PyTileSetFileObject*)type->tp_alloc(type, 0); - if (self) { - new (&self->data) std::shared_ptr(); - } - return (PyObject*)self; -} - -int PyTileSetFile::init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"path", nullptr}; - const char* path = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) - return -1; - - try { - self->data = loadTileSet(path); - } catch (const std::exception& e) { - PyErr_Format(PyExc_IOError, "Failed to load tileset: %s", e.what()); - return -1; - } - - return 0; -} - -void PyTileSetFile::dealloc(PyTileSetFileObject* self) { - self->data.~shared_ptr(); - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* PyTileSetFile::repr(PyObject* obj) { - auto* self = (PyTileSetFileObject*)obj; - if (!self->data) { - return PyUnicode_FromString(""); - } - return PyUnicode_FromFormat("", - self->data->name.c_str(), self->data->tile_count, - self->data->tile_width, self->data->tile_height); -} - -// ============================================================ -// Properties (all read-only) -// ============================================================ - -PyObject* PyTileSetFile::get_name(PyTileSetFileObject* self, void*) { - return PyUnicode_FromString(self->data->name.c_str()); -} - -PyObject* PyTileSetFile::get_tile_width(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->tile_width); -} - -PyObject* PyTileSetFile::get_tile_height(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->tile_height); -} - -PyObject* PyTileSetFile::get_tile_count(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->tile_count); -} - -PyObject* PyTileSetFile::get_columns(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->columns); -} - -PyObject* PyTileSetFile::get_margin(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->margin); -} - -PyObject* PyTileSetFile::get_spacing(PyTileSetFileObject* self, void*) { - return PyLong_FromLong(self->data->spacing); -} - -PyObject* PyTileSetFile::get_image_source(PyTileSetFileObject* self, void*) { - return PyUnicode_FromString(self->data->image_source.c_str()); -} - -PyObject* PyTileSetFile::get_properties(PyTileSetFileObject* self, void*) { - return propertiesToPython(self->data->properties); -} - -PyObject* PyTileSetFile::get_wang_sets(PyTileSetFileObject* self, void*) { - PyObject* list = PyList_New(self->data->wang_sets.size()); - if (!list) return NULL; - - for (size_t i = 0; i < self->data->wang_sets.size(); i++) { - PyObject* ws = PyWangSet::create(self->data, static_cast(i)); - if (!ws) { - Py_DECREF(list); - return NULL; - } - PyList_SET_ITEM(list, i, ws); - } - return list; -} - -// ============================================================ -// Methods -// ============================================================ - -PyObject* PyTileSetFile::to_texture(PyTileSetFileObject* self, PyObject* args) { - // Create a PyTexture using the image source and tile dimensions - // Get the Texture type from the mcrfpy module (safe cross-compilation-unit access) - PyObject* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) return NULL; - - PyObject* tex_type = PyObject_GetAttrString(mcrfpy_module, "Texture"); - Py_DECREF(mcrfpy_module); - if (!tex_type) return NULL; - - PyObject* tex_args = Py_BuildValue("(sii)", - self->data->image_source.c_str(), - self->data->tile_width, - self->data->tile_height); - if (!tex_args) { Py_DECREF(tex_type); return NULL; } - - PyObject* tex = PyObject_Call(tex_type, tex_args, NULL); - Py_DECREF(tex_type); - Py_DECREF(tex_args); - return tex; -} - -PyObject* PyTileSetFile::tile_info(PyTileSetFileObject* self, PyObject* args) { - int tile_id; - if (!PyArg_ParseTuple(args, "i", &tile_id)) - return NULL; - - auto it = self->data->tile_info.find(tile_id); - if (it == self->data->tile_info.end()) { - Py_RETURN_NONE; - } - - const TileInfo& ti = it->second; - PyObject* dict = PyDict_New(); - if (!dict) return NULL; - - // Properties - PyObject* props = propertiesToPython(ti.properties); - if (!props) { Py_DECREF(dict); return NULL; } - PyDict_SetItemString(dict, "properties", props); - Py_DECREF(props); - - // Animation - PyObject* anim_list = PyList_New(ti.animation.size()); - if (!anim_list) { Py_DECREF(dict); return NULL; } - for (size_t i = 0; i < ti.animation.size(); i++) { - PyObject* frame = Py_BuildValue("(ii)", ti.animation[i].tile_id, ti.animation[i].duration_ms); - if (!frame) { Py_DECREF(anim_list); Py_DECREF(dict); return NULL; } - PyList_SET_ITEM(anim_list, i, frame); - } - PyDict_SetItemString(dict, "animation", anim_list); - Py_DECREF(anim_list); - - return dict; -} - -PyObject* PyTileSetFile::wang_set(PyTileSetFileObject* self, PyObject* args) { - const char* name; - if (!PyArg_ParseTuple(args, "s", &name)) - return NULL; - - for (size_t i = 0; i < self->data->wang_sets.size(); i++) { - if (self->data->wang_sets[i].name == name) { - return PyWangSet::create(self->data, static_cast(i)); - } - } - - PyErr_Format(PyExc_KeyError, "No WangSet named '%s'", name); - return NULL; -} - -// ============================================================ -// Method/GetSet tables -// ============================================================ - -PyMethodDef PyTileSetFile::methods[] = { - {"to_texture", (PyCFunction)PyTileSetFile::to_texture, METH_NOARGS, - MCRF_METHOD(TileSetFile, to_texture, - MCRF_SIG("()", "Texture"), - MCRF_DESC("Create a Texture from the tileset image."), - MCRF_RETURNS("A Texture object for use with TileLayer.") - )}, - {"tile_info", (PyCFunction)PyTileSetFile::tile_info, METH_VARARGS, - MCRF_METHOD(TileSetFile, tile_info, - MCRF_SIG("(tile_id: int)", "dict | None"), - MCRF_DESC("Get metadata for a specific tile."), - MCRF_ARGS_START - MCRF_ARG("tile_id", "Local tile ID (0-based)") - MCRF_RETURNS("Dict with 'properties' and 'animation' keys, or None if no metadata.") - )}, - {"wang_set", (PyCFunction)PyTileSetFile::wang_set, METH_VARARGS, - MCRF_METHOD(TileSetFile, wang_set, - MCRF_SIG("(name: str)", "WangSet"), - MCRF_DESC("Look up a WangSet by name."), - MCRF_ARGS_START - MCRF_ARG("name", "Name of the Wang set") - MCRF_RETURNS("The WangSet object.") - MCRF_RAISES("KeyError", "If no WangSet with that name exists") - )}, - {NULL} -}; - -PyGetSetDef PyTileSetFile::getsetters[] = { - {"name", (getter)PyTileSetFile::get_name, NULL, - MCRF_PROPERTY(name, "Tileset name (str, read-only)."), NULL}, - {"tile_width", (getter)PyTileSetFile::get_tile_width, NULL, - MCRF_PROPERTY(tile_width, "Width of each tile in pixels (int, read-only)."), NULL}, - {"tile_height", (getter)PyTileSetFile::get_tile_height, NULL, - MCRF_PROPERTY(tile_height, "Height of each tile in pixels (int, read-only)."), NULL}, - {"tile_count", (getter)PyTileSetFile::get_tile_count, NULL, - MCRF_PROPERTY(tile_count, "Total number of tiles (int, read-only)."), NULL}, - {"columns", (getter)PyTileSetFile::get_columns, NULL, - MCRF_PROPERTY(columns, "Number of columns in tileset image (int, read-only)."), NULL}, - {"margin", (getter)PyTileSetFile::get_margin, NULL, - MCRF_PROPERTY(margin, "Margin around tiles in pixels (int, read-only)."), NULL}, - {"spacing", (getter)PyTileSetFile::get_spacing, NULL, - MCRF_PROPERTY(spacing, "Spacing between tiles in pixels (int, read-only)."), NULL}, - {"image_source", (getter)PyTileSetFile::get_image_source, NULL, - MCRF_PROPERTY(image_source, "Resolved path to the tileset image file (str, read-only)."), NULL}, - {"properties", (getter)PyTileSetFile::get_properties, NULL, - MCRF_PROPERTY(properties, "Custom tileset properties as a dict (read-only)."), NULL}, - {"wang_sets", (getter)PyTileSetFile::get_wang_sets, NULL, - MCRF_PROPERTY(wang_sets, "List of WangSet objects from this tileset (read-only)."), NULL}, - {NULL} -}; diff --git a/src/tiled/PyTileSetFile.h b/src/tiled/PyTileSetFile.h deleted file mode 100644 index ed94985..0000000 --- a/src/tiled/PyTileSetFile.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once -#include "Python.h" -#include "TiledTypes.h" -#include - -// Python object structure -typedef struct PyTileSetFileObject { - PyObject_HEAD - std::shared_ptr data; -} PyTileSetFileObject; - -// Python binding class -class PyTileSetFile { -public: - static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); - static int init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds); - static void dealloc(PyTileSetFileObject* self); - static PyObject* repr(PyObject* obj); - - // Read-only properties - static PyObject* get_name(PyTileSetFileObject* self, void* closure); - static PyObject* get_tile_width(PyTileSetFileObject* self, void* closure); - static PyObject* get_tile_height(PyTileSetFileObject* self, void* closure); - static PyObject* get_tile_count(PyTileSetFileObject* self, void* closure); - static PyObject* get_columns(PyTileSetFileObject* self, void* closure); - static PyObject* get_margin(PyTileSetFileObject* self, void* closure); - static PyObject* get_spacing(PyTileSetFileObject* self, void* closure); - static PyObject* get_image_source(PyTileSetFileObject* self, void* closure); - static PyObject* get_properties(PyTileSetFileObject* self, void* closure); - static PyObject* get_wang_sets(PyTileSetFileObject* self, void* closure); - - // Methods - static PyObject* to_texture(PyTileSetFileObject* self, PyObject* args); - static PyObject* tile_info(PyTileSetFileObject* self, PyObject* args); - static PyObject* wang_set(PyTileSetFileObject* self, PyObject* args); - - static PyMethodDef methods[]; - static PyGetSetDef getsetters[]; -}; - -// Type definition in mcrfpydef namespace -namespace mcrfpydef { - -inline PyTypeObject PyTileSetFileType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.TileSetFile", - .tp_basicsize = sizeof(PyTileSetFileObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)PyTileSetFile::dealloc, - .tp_repr = PyTileSetFile::repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "TileSetFile(path: str)\n\n" - "Load a Tiled tileset file (.tsx or .tsj).\n\n" - "Parses the tileset and provides access to tile metadata, properties,\n" - "Wang sets, and texture creation.\n\n" - "Args:\n" - " path: Path to the .tsx or .tsj tileset file.\n\n" - "Properties:\n" - " name (str, read-only): Tileset name.\n" - " tile_width (int, read-only): Width of each tile in pixels.\n" - " tile_height (int, read-only): Height of each tile in pixels.\n" - " tile_count (int, read-only): Total number of tiles.\n" - " columns (int, read-only): Number of columns in the tileset image.\n" - " image_source (str, read-only): Resolved path to the tileset image.\n" - " properties (dict, read-only): Custom properties from the tileset.\n" - " wang_sets (list, read-only): List of WangSet objects.\n\n" - "Example:\n" - " ts = mcrfpy.TileSetFile('tileset.tsx')\n" - " texture = ts.to_texture()\n" - " print(f'{ts.name}: {ts.tile_count} tiles')\n" - ), - .tp_methods = nullptr, // Set before PyType_Ready - .tp_getset = nullptr, // Set before PyType_Ready - .tp_init = (initproc)PyTileSetFile::init, - .tp_new = PyTileSetFile::pynew, -}; - -} // namespace mcrfpydef diff --git a/src/tiled/PyWangSet.cpp b/src/tiled/PyWangSet.cpp deleted file mode 100644 index 592688c..0000000 --- a/src/tiled/PyWangSet.cpp +++ /dev/null @@ -1,266 +0,0 @@ -#include "PyWangSet.h" -#include "TiledParse.h" -#include "WangResolve.h" -#include "McRFPy_Doc.h" -#include "PyDiscreteMap.h" -#include "GridLayers.h" -#include - -using namespace mcrf::tiled; - -// ============================================================ -// Helper -// ============================================================ - -const WangSet& PyWangSet::getWangSet(PyWangSetObject* self) { - return self->parent->wang_sets[self->wang_set_index]; -} - -// ============================================================ -// Factory -// ============================================================ - -PyObject* PyWangSet::create(std::shared_ptr parent, int index) { - auto* type = &mcrfpydef::PyWangSetType; - auto* self = (PyWangSetObject*)type->tp_alloc(type, 0); - if (!self) return NULL; - new (&self->parent) std::shared_ptr(parent); - self->wang_set_index = index; - return (PyObject*)self; -} - -// ============================================================ -// Type lifecycle -// ============================================================ - -void PyWangSet::dealloc(PyWangSetObject* self) { - self->parent.~shared_ptr(); - Py_TYPE(self)->tp_free((PyObject*)self); -} - -PyObject* PyWangSet::repr(PyObject* obj) { - auto* self = (PyWangSetObject*)obj; - const auto& ws = getWangSet(self); - const char* type_str = "unknown"; - switch (ws.type) { - case WangSetType::Corner: type_str = "corner"; break; - case WangSetType::Edge: type_str = "edge"; break; - case WangSetType::Mixed: type_str = "mixed"; break; - } - return PyUnicode_FromFormat("", - ws.name.c_str(), type_str, (int)ws.colors.size()); -} - -// ============================================================ -// Properties -// ============================================================ - -PyObject* PyWangSet::get_name(PyWangSetObject* self, void*) { - return PyUnicode_FromString(getWangSet(self).name.c_str()); -} - -PyObject* PyWangSet::get_type(PyWangSetObject* self, void*) { - switch (getWangSet(self).type) { - case WangSetType::Corner: return PyUnicode_FromString("corner"); - case WangSetType::Edge: return PyUnicode_FromString("edge"); - case WangSetType::Mixed: return PyUnicode_FromString("mixed"); - } - return PyUnicode_FromString("unknown"); -} - -PyObject* PyWangSet::get_color_count(PyWangSetObject* self, void*) { - return PyLong_FromLong(getWangSet(self).colors.size()); -} - -PyObject* PyWangSet::get_colors(PyWangSetObject* self, void*) { - const auto& ws = getWangSet(self); - PyObject* list = PyList_New(ws.colors.size()); - if (!list) return NULL; - - for (size_t i = 0; i < ws.colors.size(); i++) { - const auto& wc = ws.colors[i]; - PyObject* dict = Py_BuildValue("{s:s, s:i, s:i, s:f}", - "name", wc.name.c_str(), - "index", wc.index, - "tile_id", wc.tile_id, - "probability", (double)wc.probability); - if (!dict) { - Py_DECREF(list); - return NULL; - } - PyList_SET_ITEM(list, i, dict); - } - return list; -} - -// ============================================================ -// Methods -// ============================================================ - -// Convert a name like "Grass Terrain" to "GRASS_TERRAIN" -static std::string toUpperSnakeCase(const std::string& s) { - std::string result; - result.reserve(s.size()); - for (size_t i = 0; i < s.size(); i++) { - char c = s[i]; - if (c == ' ' || c == '-') { - result += '_'; - } else { - result += static_cast(toupper(static_cast(c))); - } - } - return result; -} - -PyObject* PyWangSet::terrain_enum(PyWangSetObject* self, PyObject*) { - const auto& ws = getWangSet(self); - - // Import IntEnum from enum module - PyObject* enum_module = PyImport_ImportModule("enum"); - if (!enum_module) return NULL; - - PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum"); - Py_DECREF(enum_module); - if (!int_enum) return NULL; - - // Build members dict: NONE=0, then each color - PyObject* members = PyDict_New(); - if (!members) { Py_DECREF(int_enum); return NULL; } - - // NONE = 0 (unset terrain) - PyObject* zero = PyLong_FromLong(0); - PyDict_SetItemString(members, "NONE", zero); - Py_DECREF(zero); - - for (const auto& wc : ws.colors) { - std::string key = toUpperSnakeCase(wc.name); - PyObject* val = PyLong_FromLong(wc.index); - PyDict_SetItemString(members, key.c_str(), val); - Py_DECREF(val); - } - - // Create enum class: IntEnum(ws.name, members) - PyObject* name = PyUnicode_FromString(ws.name.c_str()); - PyObject* args = PyTuple_Pack(2, name, members); - Py_DECREF(name); - Py_DECREF(members); - - PyObject* enum_class = PyObject_Call(int_enum, args, NULL); - Py_DECREF(args); - Py_DECREF(int_enum); - - return enum_class; -} - -PyObject* PyWangSet::resolve(PyWangSetObject* self, PyObject* args) { - PyObject* dmap_obj; - if (!PyArg_ParseTuple(args, "O", &dmap_obj)) - return NULL; - - // Check type by name since static types differ per translation unit - const char* dmap_type_name = Py_TYPE(dmap_obj)->tp_name; - if (!dmap_type_name || strcmp(dmap_type_name, "mcrfpy.DiscreteMap") != 0) { - PyErr_SetString(PyExc_TypeError, "Expected a DiscreteMap object"); - return NULL; - } - - auto* dmap = (PyDiscreteMapObject*)dmap_obj; - const auto& ws = getWangSet(self); - - std::vector result = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); - - // Convert to Python list - PyObject* list = PyList_New(result.size()); - if (!list) return NULL; - for (size_t i = 0; i < result.size(); i++) { - PyList_SET_ITEM(list, i, PyLong_FromLong(result[i])); - } - return list; -} - -PyObject* PyWangSet::apply(PyWangSetObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"discrete_map", "tile_layer", nullptr}; - PyObject* dmap_obj; - PyObject* tlayer_obj; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO", const_cast(keywords), - &dmap_obj, &tlayer_obj)) - return NULL; - - // Validate DiscreteMap (check by name since static types differ per TU) - const char* dmap_tn = Py_TYPE(dmap_obj)->tp_name; - if (!dmap_tn || strcmp(dmap_tn, "mcrfpy.DiscreteMap") != 0) { - PyErr_SetString(PyExc_TypeError, "First argument must be a DiscreteMap"); - return NULL; - } - - // Validate TileLayer - const char* tl_tn = Py_TYPE(tlayer_obj)->tp_name; - if (!tl_tn || strcmp(tl_tn, "mcrfpy.TileLayer") != 0) { - PyErr_SetString(PyExc_TypeError, "Second argument must be a TileLayer"); - return NULL; - } - - auto* dmap = (PyDiscreteMapObject*)dmap_obj; - auto* tlayer = (PyTileLayerObject*)tlayer_obj; - const auto& ws = getWangSet(self); - - // Resolve terrain to tile indices - std::vector tile_ids = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); - - // Write into TileLayer - int w = dmap->w; - int h = dmap->h; - for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { - for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { - int tid = tile_ids[y * w + x]; - if (tid >= 0) { - tlayer->data->at(x, y) = tid; - } - } - } - tlayer->data->markDirty(); - - Py_RETURN_NONE; -} - -// ============================================================ -// Method/GetSet tables -// ============================================================ - -PyMethodDef PyWangSet::methods[] = { - {"terrain_enum", (PyCFunction)PyWangSet::terrain_enum, METH_NOARGS, - MCRF_METHOD(WangSet, terrain_enum, - MCRF_SIG("()", "IntEnum"), - MCRF_DESC("Generate a Python IntEnum from this WangSet's terrain colors."), - MCRF_RETURNS("IntEnum class with NONE=0 and one member per color (UPPER_SNAKE_CASE).") - )}, - {"resolve", (PyCFunction)PyWangSet::resolve, METH_VARARGS, - MCRF_METHOD(WangSet, resolve, - MCRF_SIG("(discrete_map: DiscreteMap)", "list[int]"), - MCRF_DESC("Resolve terrain data to tile indices using Wang tile rules."), - MCRF_ARGS_START - MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs matching this WangSet's colors") - MCRF_RETURNS("List of tile IDs (one per cell). -1 means no matching Wang tile.") - )}, - {"apply", (PyCFunction)PyWangSet::apply, METH_VARARGS | METH_KEYWORDS, - MCRF_METHOD(WangSet, apply, - MCRF_SIG("(discrete_map: DiscreteMap, tile_layer: TileLayer)", "None"), - MCRF_DESC("Resolve terrain and write tile indices directly into a TileLayer."), - MCRF_ARGS_START - MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs") - MCRF_ARG("tile_layer", "Target TileLayer to write resolved tiles into") - )}, - {NULL} -}; - -PyGetSetDef PyWangSet::getsetters[] = { - {"name", (getter)PyWangSet::get_name, NULL, - MCRF_PROPERTY(name, "Wang set name (str, read-only)."), NULL}, - {"type", (getter)PyWangSet::get_type, NULL, - MCRF_PROPERTY(type, "Wang set type: 'corner', 'edge', or 'mixed' (str, read-only)."), NULL}, - {"color_count", (getter)PyWangSet::get_color_count, NULL, - MCRF_PROPERTY(color_count, "Number of terrain colors (int, read-only)."), NULL}, - {"colors", (getter)PyWangSet::get_colors, NULL, - MCRF_PROPERTY(colors, "List of color dicts with name, index, tile_id, probability (read-only)."), NULL}, - {NULL} -}; diff --git a/src/tiled/PyWangSet.h b/src/tiled/PyWangSet.h deleted file mode 100644 index 8641f34..0000000 --- a/src/tiled/PyWangSet.h +++ /dev/null @@ -1,72 +0,0 @@ -#pragma once -#include "Python.h" -#include "TiledTypes.h" -#include - -// Python object structure -// Holds a shared_ptr to the parent TileSetData (keeps it alive) + index into wang_sets -typedef struct PyWangSetObject { - PyObject_HEAD - std::shared_ptr parent; - int wang_set_index; -} PyWangSetObject; - -// Python binding class -class PyWangSet { -public: - // Factory: create a PyWangSet from parent tileset + index - static PyObject* create(std::shared_ptr parent, int index); - - static void dealloc(PyWangSetObject* self); - static PyObject* repr(PyObject* obj); - - // Read-only properties - static PyObject* get_name(PyWangSetObject* self, void* closure); - static PyObject* get_type(PyWangSetObject* self, void* closure); - static PyObject* get_color_count(PyWangSetObject* self, void* closure); - static PyObject* get_colors(PyWangSetObject* self, void* closure); - - // Methods - static PyObject* terrain_enum(PyWangSetObject* self, PyObject* args); - static PyObject* resolve(PyWangSetObject* self, PyObject* args); - static PyObject* apply(PyWangSetObject* self, PyObject* args, PyObject* kwds); - - static PyMethodDef methods[]; - static PyGetSetDef getsetters[]; - -private: - // Helper: get the WangSet reference - static const mcrf::tiled::WangSet& getWangSet(PyWangSetObject* self); -}; - -// Type definition in mcrfpydef namespace -namespace mcrfpydef { - -inline PyTypeObject PyWangSetType = { - .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, - .tp_name = "mcrfpy.WangSet", - .tp_basicsize = sizeof(PyWangSetObject), - .tp_itemsize = 0, - .tp_dealloc = (destructor)PyWangSet::dealloc, - .tp_repr = PyWangSet::repr, - .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR( - "WangSet - Wang terrain auto-tile set from a Tiled tileset.\n\n" - "WangSets are obtained from TileSetFile.wang_sets or TileSetFile.wang_set().\n" - "They map abstract terrain types to concrete sprite indices using Tiled's\n" - "Wang tile algorithm.\n\n" - "Properties:\n" - " name (str, read-only): Wang set name.\n" - " type (str, read-only): 'corner', 'edge', or 'mixed'.\n" - " color_count (int, read-only): Number of terrain colors.\n" - " colors (list, read-only): List of color dicts.\n\n" - "Example:\n" - " ws = tileset.wang_set('overworld')\n" - " Terrain = ws.terrain_enum()\n" - " tiles = ws.resolve(discrete_map)\n" - ), - .tp_methods = nullptr, // Set before PyType_Ready - .tp_getset = nullptr, // Set before PyType_Ready -}; - -} // namespace mcrfpydef diff --git a/src/tiled/TiledParse.cpp b/src/tiled/TiledParse.cpp deleted file mode 100644 index 4a9673d..0000000 --- a/src/tiled/TiledParse.cpp +++ /dev/null @@ -1,772 +0,0 @@ -#include "TiledParse.h" -#include "RapidXML/rapidxml.hpp" -#include -#include -#include -#include -#include - -namespace mcrf { -namespace tiled { - -// ============================================================ -// Utility helpers -// ============================================================ - -static std::string readFile(const std::string& path) { - std::ifstream f(path); - if (!f.is_open()) { - throw std::runtime_error("Cannot open file: " + path); - } - std::stringstream ss; - ss << f.rdbuf(); - return ss.str(); -} - -static std::string parentDir(const std::string& path) { - std::filesystem::path p(path); - return p.parent_path().string(); -} - -static std::string resolvePath(const std::string& base_dir, const std::string& relative) { - std::filesystem::path p = std::filesystem::path(base_dir) / relative; - return p.lexically_normal().string(); -} - -static bool endsWith(const std::string& str, const std::string& suffix) { - if (suffix.size() > str.size()) return false; - return str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0; -} - -// Get attribute value or empty string -static std::string xmlAttr(rapidxml::xml_node<>* node, const char* name) { - auto* attr = node->first_attribute(name); - return attr ? std::string(attr->value(), attr->value_size()) : ""; -} - -static int xmlAttrInt(rapidxml::xml_node<>* node, const char* name, int def = 0) { - auto* attr = node->first_attribute(name); - if (!attr) return def; - return std::stoi(std::string(attr->value(), attr->value_size())); -} - -static float xmlAttrFloat(rapidxml::xml_node<>* node, const char* name, float def = 0.0f) { - auto* attr = node->first_attribute(name); - if (!attr) return def; - return std::stof(std::string(attr->value(), attr->value_size())); -} - -// ============================================================ -// Property conversion (Raw → Final) -// ============================================================ - -static PropertyValue convertProperty(const RawProperty& raw) { - if (raw.type == "bool") { - return PropertyValue(raw.value == "true"); - } else if (raw.type == "int") { - return PropertyValue(std::stoi(raw.value)); - } else if (raw.type == "float") { - return PropertyValue(std::stof(raw.value)); - } else { - // Default: string (includes empty type) - return PropertyValue(raw.value); - } -} - -static std::unordered_map convertProperties( - const std::vector& raw_props) { - std::unordered_map result; - for (const auto& rp : raw_props) { - result[rp.name] = convertProperty(rp); - } - return result; -} - -// ============================================================ -// WangSet packing -// ============================================================ - -uint64_t WangSet::packWangId(const std::array& id) { - // Pack 8 values (each 0-255) into 64-bit integer - // Each value gets 8 bits - uint64_t packed = 0; - for (int i = 0; i < 8; i++) { - packed |= (static_cast(id[i] & 0xFF)) << (i * 8); - } - return packed; -} - -// ============================================================ -// XML property parsing (shared by TSX and TMX) -// ============================================================ - -static void parseXmlProperties(rapidxml::xml_node<>* parent, std::vector& out) { - auto* props_node = parent->first_node("properties"); - if (!props_node) return; - for (auto* prop = props_node->first_node("property"); prop; prop = prop->next_sibling("property")) { - RawProperty rp; - rp.name = xmlAttr(prop, "name"); - rp.type = xmlAttr(prop, "type"); - rp.value = xmlAttr(prop, "value"); - // Some properties have value as node text instead of attribute - if (rp.value.empty() && prop->value_size() > 0) { - rp.value = std::string(prop->value(), prop->value_size()); - } - out.push_back(std::move(rp)); - } -} - -// ============================================================ -// TSX parser (XML tileset) -// ============================================================ - -static RawTileSet parseTSX(const std::string& path) { - std::string text = readFile(path); - rapidxml::xml_document<> doc; - doc.parse<0>(text.data()); - - auto* tileset_node = doc.first_node("tileset"); - if (!tileset_node) { - throw std::runtime_error("No element in: " + path); - } - - RawTileSet raw; - raw.name = xmlAttr(tileset_node, "name"); - raw.tile_width = xmlAttrInt(tileset_node, "tilewidth"); - raw.tile_height = xmlAttrInt(tileset_node, "tileheight"); - raw.tile_count = xmlAttrInt(tileset_node, "tilecount"); - raw.columns = xmlAttrInt(tileset_node, "columns"); - raw.margin = xmlAttrInt(tileset_node, "margin"); - raw.spacing = xmlAttrInt(tileset_node, "spacing"); - - // Image element - auto* image_node = tileset_node->first_node("image"); - if (image_node) { - raw.image_source = xmlAttr(image_node, "source"); - raw.image_width = xmlAttrInt(image_node, "width"); - raw.image_height = xmlAttrInt(image_node, "height"); - } - - // Properties - parseXmlProperties(tileset_node, raw.properties); - - // Tile elements (for per-tile properties and animations) - for (auto* tile = tileset_node->first_node("tile"); tile; tile = tile->next_sibling("tile")) { - RawTile rt; - rt.id = xmlAttrInt(tile, "id"); - parseXmlProperties(tile, rt.properties); - - // Animation frames - auto* anim = tile->first_node("animation"); - if (anim) { - for (auto* frame = anim->first_node("frame"); frame; frame = frame->next_sibling("frame")) { - int tid = xmlAttrInt(frame, "tileid"); - int dur = xmlAttrInt(frame, "duration"); - rt.animation_frames.emplace_back(tid, dur); - } - } - raw.tiles.push_back(std::move(rt)); - } - - // Wang sets - auto* wangsets_node = tileset_node->first_node("wangsets"); - if (wangsets_node) { - for (auto* ws = wangsets_node->first_node("wangset"); ws; ws = ws->next_sibling("wangset")) { - RawWangSet rws; - rws.name = xmlAttr(ws, "name"); - rws.type = xmlAttr(ws, "type"); - - // Wang colors (1-indexed by position in list) - int color_idx = 1; - for (auto* wc = ws->first_node("wangcolor"); wc; wc = wc->next_sibling("wangcolor")) { - RawWangColor rwc; - rwc.name = xmlAttr(wc, "name"); - rwc.color_index = color_idx++; - rwc.tile_id = xmlAttrInt(wc, "tile"); - rwc.probability = xmlAttrFloat(wc, "probability", 1.0f); - rws.colors.push_back(std::move(rwc)); - } - - // Wang tiles - for (auto* wt = ws->first_node("wangtile"); wt; wt = wt->next_sibling("wangtile")) { - RawWangTile rwt; - rwt.tile_id = xmlAttrInt(wt, "tileid"); - // Parse wangid: comma-separated 8 integers - std::string wid_str = xmlAttr(wt, "wangid"); - std::array wid = {}; - std::istringstream iss(wid_str); - std::string token; - int idx = 0; - while (std::getline(iss, token, ',') && idx < 8) { - wid[idx++] = std::stoi(token); - } - rwt.wang_id = wid; - rws.tiles.push_back(std::move(rwt)); - } - - raw.wang_sets.push_back(std::move(rws)); - } - } - - return raw; -} - -// ============================================================ -// TSJ parser (JSON tileset) -// ============================================================ - -static void parseJsonProperties(const nlohmann::json& j, std::vector& out) { - if (!j.contains("properties") || !j["properties"].is_array()) return; - for (const auto& prop : j["properties"]) { - RawProperty rp; - rp.name = prop.value("name", ""); - rp.type = prop.value("type", ""); - // Value can be different JSON types - if (prop.contains("value")) { - const auto& val = prop["value"]; - if (val.is_boolean()) { - rp.type = "bool"; - rp.value = val.get() ? "true" : "false"; - } else if (val.is_number_integer()) { - rp.type = "int"; - rp.value = std::to_string(val.get()); - } else if (val.is_number_float()) { - rp.type = "float"; - rp.value = std::to_string(val.get()); - } else if (val.is_string()) { - rp.value = val.get(); - } - } - out.push_back(std::move(rp)); - } -} - -static RawTileSet parseTSJ(const std::string& path) { - std::string text = readFile(path); - nlohmann::json j = nlohmann::json::parse(text); - - RawTileSet raw; - raw.name = j.value("name", ""); - raw.tile_width = j.value("tilewidth", 0); - raw.tile_height = j.value("tileheight", 0); - raw.tile_count = j.value("tilecount", 0); - raw.columns = j.value("columns", 0); - raw.margin = j.value("margin", 0); - raw.spacing = j.value("spacing", 0); - raw.image_source = j.value("image", ""); - raw.image_width = j.value("imagewidth", 0); - raw.image_height = j.value("imageheight", 0); - - parseJsonProperties(j, raw.properties); - - // Tiles - if (j.contains("tiles") && j["tiles"].is_array()) { - for (const auto& tile : j["tiles"]) { - RawTile rt; - rt.id = tile.value("id", 0); - parseJsonProperties(tile, rt.properties); - if (tile.contains("animation") && tile["animation"].is_array()) { - for (const auto& frame : tile["animation"]) { - int tid = frame.value("tileid", 0); - int dur = frame.value("duration", 0); - rt.animation_frames.emplace_back(tid, dur); - } - } - raw.tiles.push_back(std::move(rt)); - } - } - - // Wang sets - if (j.contains("wangsets") && j["wangsets"].is_array()) { - for (const auto& ws : j["wangsets"]) { - RawWangSet rws; - rws.name = ws.value("name", ""); - rws.type = ws.value("type", ""); - - if (ws.contains("colors") && ws["colors"].is_array()) { - int ci = 1; // Tiled wang colors are 1-indexed - for (const auto& wc : ws["colors"]) { - RawWangColor rwc; - rwc.name = wc.value("name", ""); - rwc.color_index = ci++; - rwc.tile_id = wc.value("tile", -1); - rwc.probability = wc.value("probability", 1.0f); - rws.colors.push_back(std::move(rwc)); - } - } - - if (ws.contains("wangtiles") && ws["wangtiles"].is_array()) { - for (const auto& wt : ws["wangtiles"]) { - RawWangTile rwt; - rwt.tile_id = wt.value("tileid", 0); - std::array wid = {}; - if (wt.contains("wangid") && wt["wangid"].is_array()) { - for (int i = 0; i < 8 && i < (int)wt["wangid"].size(); i++) { - wid[i] = wt["wangid"][i].get(); - } - } - rwt.wang_id = wid; - rws.tiles.push_back(std::move(rwt)); - } - } - - raw.wang_sets.push_back(std::move(rws)); - } - } - - return raw; -} - -// ============================================================ -// Builder: RawTileSet → TileSetData -// ============================================================ - -static std::shared_ptr buildTileSet(const RawTileSet& raw, const std::string& source_path) { - auto ts = std::make_shared(); - ts->source_path = source_path; - ts->name = raw.name; - ts->tile_width = raw.tile_width; - ts->tile_height = raw.tile_height; - ts->tile_count = raw.tile_count; - ts->columns = raw.columns; - ts->margin = raw.margin; - ts->spacing = raw.spacing; - ts->image_width = raw.image_width; - ts->image_height = raw.image_height; - - // Resolve image path relative to tileset file - std::string base_dir = parentDir(source_path); - ts->image_source = resolvePath(base_dir, raw.image_source); - - // Convert properties - ts->properties = convertProperties(raw.properties); - - // Convert tile info - for (const auto& rt : raw.tiles) { - TileInfo ti; - ti.id = rt.id; - ti.properties = convertProperties(rt.properties); - for (const auto& [tid, dur] : rt.animation_frames) { - ti.animation.push_back({tid, dur}); - } - ts->tile_info[ti.id] = std::move(ti); - } - - // Convert wang sets - for (const auto& rws : raw.wang_sets) { - WangSet ws; - ws.name = rws.name; - if (rws.type == "corner") ws.type = WangSetType::Corner; - else if (rws.type == "edge") ws.type = WangSetType::Edge; - else ws.type = WangSetType::Mixed; - - for (const auto& rwc : rws.colors) { - WangColor wc; - wc.name = rwc.name; - wc.index = rwc.color_index; - wc.tile_id = rwc.tile_id; - wc.probability = rwc.probability; - ws.colors.push_back(std::move(wc)); - } - - // Build lookup table - for (const auto& rwt : rws.tiles) { - uint64_t key = WangSet::packWangId(rwt.wang_id); - ws.wang_lookup[key] = rwt.tile_id; - } - - ts->wang_sets.push_back(std::move(ws)); - } - - return ts; -} - -// ============================================================ -// TMX parser (XML tilemap) -// ============================================================ - -static RawTileMap parseTMX(const std::string& path) { - std::string text = readFile(path); - rapidxml::xml_document<> doc; - doc.parse<0>(text.data()); - - auto* map_node = doc.first_node("map"); - if (!map_node) { - throw std::runtime_error("No element in: " + path); - } - - RawTileMap raw; - raw.width = xmlAttrInt(map_node, "width"); - raw.height = xmlAttrInt(map_node, "height"); - raw.tile_width = xmlAttrInt(map_node, "tilewidth"); - raw.tile_height = xmlAttrInt(map_node, "tileheight"); - raw.orientation = xmlAttr(map_node, "orientation"); - - parseXmlProperties(map_node, raw.properties); - - // Tileset references - for (auto* ts = map_node->first_node("tileset"); ts; ts = ts->next_sibling("tileset")) { - RawTileSetRef ref; - ref.firstgid = xmlAttrInt(ts, "firstgid"); - ref.source = xmlAttr(ts, "source"); - raw.tileset_refs.push_back(std::move(ref)); - } - - // Layers - for (auto* child = map_node->first_node(); child; child = child->next_sibling()) { - std::string node_name(child->name(), child->name_size()); - - if (node_name == "layer") { - RawLayer layer; - layer.name = xmlAttr(child, "name"); - layer.type = "tilelayer"; - layer.width = xmlAttrInt(child, "width"); - layer.height = xmlAttrInt(child, "height"); - std::string vis = xmlAttr(child, "visible"); - layer.visible = vis.empty() || vis != "0"; - layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); - parseXmlProperties(child, layer.properties); - - // Parse CSV tile data - auto* data_node = child->first_node("data"); - if (data_node) { - std::string encoding = xmlAttr(data_node, "encoding"); - if (!encoding.empty() && encoding != "csv") { - throw std::runtime_error("Unsupported tile data encoding: " + encoding + - " (only CSV supported). File: " + path); - } - std::string csv(data_node->value(), data_node->value_size()); - std::istringstream iss(csv); - std::string token; - while (std::getline(iss, token, ',')) { - // Trim whitespace - auto start = token.find_first_not_of(" \t\r\n"); - if (start == std::string::npos) continue; - auto end = token.find_last_not_of(" \t\r\n"); - token = token.substr(start, end - start + 1); - if (!token.empty()) { - layer.tile_data.push_back(static_cast(std::stoul(token))); - } - } - } - - raw.layers.push_back(std::move(layer)); - } - else if (node_name == "objectgroup") { - RawLayer layer; - layer.name = xmlAttr(child, "name"); - layer.type = "objectgroup"; - std::string vis = xmlAttr(child, "visible"); - layer.visible = vis.empty() || vis != "0"; - layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); - parseXmlProperties(child, layer.properties); - - // Convert XML objects to JSON for uniform Python interface - nlohmann::json objects_arr = nlohmann::json::array(); - for (auto* obj = child->first_node("object"); obj; obj = obj->next_sibling("object")) { - nlohmann::json obj_json; - std::string id_str = xmlAttr(obj, "id"); - if (!id_str.empty()) obj_json["id"] = std::stoi(id_str); - std::string name = xmlAttr(obj, "name"); - if (!name.empty()) obj_json["name"] = name; - std::string type = xmlAttr(obj, "type"); - if (!type.empty()) obj_json["type"] = type; - std::string x_str = xmlAttr(obj, "x"); - if (!x_str.empty()) obj_json["x"] = std::stof(x_str); - std::string y_str = xmlAttr(obj, "y"); - if (!y_str.empty()) obj_json["y"] = std::stof(y_str); - std::string w_str = xmlAttr(obj, "width"); - if (!w_str.empty()) obj_json["width"] = std::stof(w_str); - std::string h_str = xmlAttr(obj, "height"); - if (!h_str.empty()) obj_json["height"] = std::stof(h_str); - std::string rot_str = xmlAttr(obj, "rotation"); - if (!rot_str.empty()) obj_json["rotation"] = std::stof(rot_str); - std::string visible_str = xmlAttr(obj, "visible"); - if (!visible_str.empty()) obj_json["visible"] = (visible_str != "0"); - - // Object properties - std::vector obj_props; - parseXmlProperties(obj, obj_props); - if (!obj_props.empty()) { - nlohmann::json props_json; - for (const auto& rp : obj_props) { - if (rp.type == "bool") props_json[rp.name] = (rp.value == "true"); - else if (rp.type == "int") props_json[rp.name] = std::stoi(rp.value); - else if (rp.type == "float") props_json[rp.name] = std::stof(rp.value); - else props_json[rp.name] = rp.value; - } - obj_json["properties"] = props_json; - } - - // Check for point/ellipse/polygon sub-elements - if (obj->first_node("point")) { - obj_json["point"] = true; - } - if (obj->first_node("ellipse")) { - obj_json["ellipse"] = true; - } - auto* polygon_node = obj->first_node("polygon"); - if (polygon_node) { - std::string points_str = xmlAttr(polygon_node, "points"); - nlohmann::json points_arr = nlohmann::json::array(); - std::istringstream pss(points_str); - std::string pt; - while (pss >> pt) { - auto comma = pt.find(','); - if (comma != std::string::npos) { - nlohmann::json point; - point["x"] = std::stof(pt.substr(0, comma)); - point["y"] = std::stof(pt.substr(comma + 1)); - points_arr.push_back(point); - } - } - obj_json["polygon"] = points_arr; - } - - objects_arr.push_back(std::move(obj_json)); - } - layer.objects_json = objects_arr; - - raw.layers.push_back(std::move(layer)); - } - } - - return raw; -} - -// ============================================================ -// TMJ parser (JSON tilemap) -// ============================================================ - -static RawTileMap parseTMJ(const std::string& path) { - std::string text = readFile(path); - nlohmann::json j = nlohmann::json::parse(text); - - RawTileMap raw; - raw.width = j.value("width", 0); - raw.height = j.value("height", 0); - raw.tile_width = j.value("tilewidth", 0); - raw.tile_height = j.value("tileheight", 0); - raw.orientation = j.value("orientation", "orthogonal"); - - parseJsonProperties(j, raw.properties); - - // Tileset references - if (j.contains("tilesets") && j["tilesets"].is_array()) { - for (const auto& ts : j["tilesets"]) { - RawTileSetRef ref; - ref.firstgid = ts.value("firstgid", 0); - ref.source = ts.value("source", ""); - raw.tileset_refs.push_back(std::move(ref)); - } - } - - // Layers - if (j.contains("layers") && j["layers"].is_array()) { - for (const auto& layer_json : j["layers"]) { - RawLayer layer; - layer.name = layer_json.value("name", ""); - layer.type = layer_json.value("type", ""); - layer.width = layer_json.value("width", 0); - layer.height = layer_json.value("height", 0); - layer.visible = layer_json.value("visible", true); - layer.opacity = layer_json.value("opacity", 1.0f); - - parseJsonProperties(layer_json, layer.properties); - - if (layer.type == "tilelayer") { - if (layer_json.contains("data") && layer_json["data"].is_array()) { - for (const auto& val : layer_json["data"]) { - layer.tile_data.push_back(val.get()); - } - } - } - else if (layer.type == "objectgroup") { - if (layer_json.contains("objects")) { - layer.objects_json = layer_json["objects"]; - } - } - - raw.layers.push_back(std::move(layer)); - } - } - - return raw; -} - -// ============================================================ -// Builder: RawTileMap → TileMapData -// ============================================================ - -static std::shared_ptr buildTileMap(const RawTileMap& raw, const std::string& source_path) { - auto tm = std::make_shared(); - tm->source_path = source_path; - tm->width = raw.width; - tm->height = raw.height; - tm->tile_width = raw.tile_width; - tm->tile_height = raw.tile_height; - tm->orientation = raw.orientation; - tm->properties = convertProperties(raw.properties); - - // Load referenced tilesets - std::string base_dir = parentDir(source_path); - for (const auto& ref : raw.tileset_refs) { - TileMapData::TileSetRef ts_ref; - ts_ref.firstgid = ref.firstgid; - std::string ts_path = resolvePath(base_dir, ref.source); - ts_ref.tileset = loadTileSet(ts_path); - tm->tilesets.push_back(std::move(ts_ref)); - } - - // Separate tile layers from object layers - for (const auto& rl : raw.layers) { - if (rl.type == "tilelayer") { - TileLayerData tld; - tld.name = rl.name; - tld.width = rl.width; - tld.height = rl.height; - tld.visible = rl.visible; - tld.opacity = rl.opacity; - tld.global_gids = rl.tile_data; - tm->tile_layers.push_back(std::move(tld)); - } - else if (rl.type == "objectgroup") { - ObjectLayerData old; - old.name = rl.name; - old.visible = rl.visible; - old.opacity = rl.opacity; - old.objects = rl.objects_json; - old.properties = convertProperties(rl.properties); - tm->object_layers.push_back(std::move(old)); - } - } - - return tm; -} - -// ============================================================ -// Public API: auto-detect and load -// ============================================================ - -std::shared_ptr loadTileSet(const std::string& path) { - std::string abs_path = std::filesystem::absolute(path).string(); - RawTileSet raw; - if (endsWith(abs_path, ".tsx")) { - raw = parseTSX(abs_path); - } else if (endsWith(abs_path, ".tsj") || endsWith(abs_path, ".json")) { - raw = parseTSJ(abs_path); - } else { - throw std::runtime_error("Unknown tileset format (expected .tsx or .tsj): " + path); - } - return buildTileSet(raw, abs_path); -} - -std::shared_ptr loadTileMap(const std::string& path) { - std::string abs_path = std::filesystem::absolute(path).string(); - RawTileMap raw; - if (endsWith(abs_path, ".tmx")) { - raw = parseTMX(abs_path); - } else if (endsWith(abs_path, ".tmj") || endsWith(abs_path, ".json")) { - raw = parseTMJ(abs_path); - } else { - throw std::runtime_error("Unknown tilemap format (expected .tmx or .tmj): " + path); - } - return buildTileMap(raw, abs_path); -} - -// ============================================================ -// JSON → Python conversion (for object layers) -// ============================================================ - -PyObject* jsonToPython(const nlohmann::json& j) { - if (j.is_null()) { - Py_RETURN_NONE; - } - if (j.is_boolean()) { - return PyBool_FromLong(j.get()); - } - if (j.is_number_integer()) { - return PyLong_FromLongLong(j.get()); - } - if (j.is_number_float()) { - return PyFloat_FromDouble(j.get()); - } - if (j.is_string()) { - const std::string& s = j.get_ref(); - return PyUnicode_FromStringAndSize(s.c_str(), s.size()); - } - if (j.is_array()) { - PyObject* list = PyList_New(j.size()); - if (!list) return NULL; - for (size_t i = 0; i < j.size(); i++) { - PyObject* item = jsonToPython(j[i]); - if (!item) { - Py_DECREF(list); - return NULL; - } - PyList_SET_ITEM(list, i, item); // steals ref - } - return list; - } - if (j.is_object()) { - PyObject* dict = PyDict_New(); - if (!dict) return NULL; - for (auto it = j.begin(); it != j.end(); ++it) { - PyObject* val = jsonToPython(it.value()); - if (!val) { - Py_DECREF(dict); - return NULL; - } - if (PyDict_SetItemString(dict, it.key().c_str(), val) < 0) { - Py_DECREF(val); - Py_DECREF(dict); - return NULL; - } - Py_DECREF(val); - } - return dict; - } - Py_RETURN_NONE; -} - -// ============================================================ -// PropertyValue → Python conversion -// ============================================================ - -PyObject* propertyValueToPython(const PropertyValue& val) { - return std::visit([](auto&& arg) -> PyObject* { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return PyBool_FromLong(arg); - } else if constexpr (std::is_same_v) { - return PyLong_FromLong(arg); - } else if constexpr (std::is_same_v) { - return PyFloat_FromDouble(arg); - } else if constexpr (std::is_same_v) { - return PyUnicode_FromStringAndSize(arg.c_str(), arg.size()); - } - Py_RETURN_NONE; - }, val); -} - -PyObject* propertiesToPython(const std::unordered_map& props) { - PyObject* dict = PyDict_New(); - if (!dict) return NULL; - for (const auto& [key, val] : props) { - PyObject* py_val = propertyValueToPython(val); - if (!py_val) { - Py_DECREF(dict); - return NULL; - } - if (PyDict_SetItemString(dict, key.c_str(), py_val) < 0) { - Py_DECREF(py_val); - Py_DECREF(dict); - return NULL; - } - Py_DECREF(py_val); - } - return dict; -} - -} // namespace tiled -} // namespace mcrf diff --git a/src/tiled/TiledParse.h b/src/tiled/TiledParse.h deleted file mode 100644 index 02233f4..0000000 --- a/src/tiled/TiledParse.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include "TiledTypes.h" -#include - -namespace mcrf { -namespace tiled { - -// Load a tileset from .tsx or .tsj (auto-detect by extension) -std::shared_ptr loadTileSet(const std::string& path); - -// Load a tilemap from .tmx or .tmj (auto-detect by extension) -std::shared_ptr loadTileMap(const std::string& path); - -// Convert nlohmann::json to Python object (for object layers) -PyObject* jsonToPython(const nlohmann::json& j); - -// Convert PropertyValue to Python object -PyObject* propertyValueToPython(const PropertyValue& val); - -// Convert a properties map to Python dict -PyObject* propertiesToPython(const std::unordered_map& props); - -} // namespace tiled -} // namespace mcrf diff --git a/src/tiled/TiledTypes.h b/src/tiled/TiledTypes.h deleted file mode 100644 index 0c30fbf..0000000 --- a/src/tiled/TiledTypes.h +++ /dev/null @@ -1,186 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include -#include -#include - -namespace mcrf { -namespace tiled { - -// ============================================================ -// Raw intermediate structs — populated by thin XML/JSON parsers -// ============================================================ - -struct RawProperty { - std::string name; - std::string type; // "bool", "int", "float", "string" (or empty = string) - std::string value; -}; - -struct RawTile { - int id; - std::vector properties; - std::vector> animation_frames; // (tile_id, duration_ms) -}; - -struct RawWangColor { - std::string name; - int color_index; - int tile_id; - float probability; -}; - -struct RawWangTile { - int tile_id; - std::array wang_id; -}; - -struct RawWangSet { - std::string name; - std::string type; // "corner", "edge", "mixed" - std::vector colors; - std::vector tiles; -}; - -struct RawTileSet { - std::string name; - std::string image_source; - int tile_width = 0; - int tile_height = 0; - int tile_count = 0; - int columns = 0; - int margin = 0; - int spacing = 0; - int image_width = 0; - int image_height = 0; - std::vector properties; - std::vector tiles; - std::vector wang_sets; -}; - -struct RawTileSetRef { - int firstgid; - std::string source; -}; - -struct RawLayer { - std::string name; - std::string type; // "tilelayer", "objectgroup" - int width = 0; - int height = 0; - bool visible = true; - float opacity = 1.0f; - std::vector properties; - std::vector tile_data; - nlohmann::json objects_json; -}; - -struct RawTileMap { - int width = 0; - int height = 0; - int tile_width = 0; - int tile_height = 0; - std::string orientation; // "orthogonal", etc. - std::vector properties; - std::vector tileset_refs; - std::vector layers; -}; - -// ============================================================ -// Final (built) types — what Python bindings expose -// ============================================================ - -using PropertyValue = std::variant; - -struct KeyFrame { - int tile_id; - int duration_ms; -}; - -struct TileInfo { - int id; - std::unordered_map properties; - std::vector animation; -}; - -enum class WangSetType { - Corner, - Edge, - Mixed -}; - -struct WangColor { - std::string name; - int index; - int tile_id; - float probability; -}; - -struct WangSet { - std::string name; - WangSetType type; - std::vector colors; - // Maps packed wang_id → tile_id for O(1) lookup - std::unordered_map wang_lookup; - - static uint64_t packWangId(const std::array& id); -}; - -struct TileSetData { - std::string name; - std::string source_path; // Filesystem path of the .tsx/.tsj file - std::string image_source; // Resolved path to image file - int tile_width = 0; - int tile_height = 0; - int tile_count = 0; - int columns = 0; - int margin = 0; - int spacing = 0; - int image_width = 0; - int image_height = 0; - std::unordered_map properties; - std::unordered_map tile_info; - std::vector wang_sets; -}; - -struct TileLayerData { - std::string name; - int width = 0; - int height = 0; - bool visible = true; - float opacity = 1.0f; - std::vector global_gids; -}; - -struct ObjectLayerData { - std::string name; - bool visible = true; - float opacity = 1.0f; - nlohmann::json objects; - std::unordered_map properties; -}; - -struct TileMapData { - std::string source_path; // Filesystem path of the .tmx/.tmj file - int width = 0; - int height = 0; - int tile_width = 0; - int tile_height = 0; - std::string orientation; - std::unordered_map properties; - - struct TileSetRef { - int firstgid; - std::shared_ptr tileset; - }; - std::vector tilesets; - std::vector tile_layers; - std::vector object_layers; -}; - -} // namespace tiled -} // namespace mcrf diff --git a/src/tiled/WangResolve.cpp b/src/tiled/WangResolve.cpp deleted file mode 100644 index 5970502..0000000 --- a/src/tiled/WangResolve.cpp +++ /dev/null @@ -1,142 +0,0 @@ -#include "WangResolve.h" -#include - -namespace mcrf { -namespace tiled { - -// Helper: get terrain at (x, y), return 0 for out-of-bounds -static inline int getTerrain(const uint8_t* data, int w, int h, int x, int y) { - if (x < 0 || x >= w || y < 0 || y >= h) return 0; - return data[y * w + x]; -} - -// For corner wang sets: each corner is at the junction of 4 cells. -// The corner terrain is the max index among those cells (standard Tiled convention: -// higher-index terrain "wins" at shared corners). -static inline int cornerTerrain(int a, int b, int c, int d) { - int m = a; - if (b > m) m = b; - if (c > m) m = c; - if (d > m) m = d; - return m; -} - -std::vector resolveWangTerrain( - const uint8_t* terrain_data, int width, int height, - const WangSet& wang_set) -{ - std::vector result(width * height, -1); - - if (wang_set.type == WangSetType::Corner) { - // Corner set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] - // For corner sets, only even indices matter: [_, TR, _, BR, _, BL, _, TL] - // i.e. indices 1, 3, 5, 7 - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - // Top-left corner: junction of (x-1,y-1), (x,y-1), (x-1,y), (x,y) - int tl = cornerTerrain( - getTerrain(terrain_data, width, height, x-1, y-1), - getTerrain(terrain_data, width, height, x, y-1), - getTerrain(terrain_data, width, height, x-1, y), - getTerrain(terrain_data, width, height, x, y)); - - // Top-right corner: junction of (x,y-1), (x+1,y-1), (x,y), (x+1,y) - int tr = cornerTerrain( - getTerrain(terrain_data, width, height, x, y-1), - getTerrain(terrain_data, width, height, x+1, y-1), - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x+1, y)); - - // Bottom-right corner: junction of (x,y), (x+1,y), (x,y+1), (x+1,y+1) - int br = cornerTerrain( - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x+1, y), - getTerrain(terrain_data, width, height, x, y+1), - getTerrain(terrain_data, width, height, x+1, y+1)); - - // Bottom-left corner: junction of (x-1,y), (x,y), (x-1,y+1), (x,y+1) - int bl = cornerTerrain( - getTerrain(terrain_data, width, height, x-1, y), - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x-1, y+1), - getTerrain(terrain_data, width, height, x, y+1)); - - // Pack: [0, TR, 0, BR, 0, BL, 0, TL] - std::array wid = {0, tr, 0, br, 0, bl, 0, tl}; - uint64_t key = WangSet::packWangId(wid); - - auto it = wang_set.wang_lookup.find(key); - if (it != wang_set.wang_lookup.end()) { - result[y * width + x] = it->second; - } - } - } - } - else if (wang_set.type == WangSetType::Edge) { - // Edge set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] - // For edge sets, only even indices matter: [top, _, right, _, bottom, _, left, _] - // i.e. indices 0, 2, 4, 6 - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - int top = getTerrain(terrain_data, width, height, x, y-1); - int right = getTerrain(terrain_data, width, height, x+1, y); - int bottom = getTerrain(terrain_data, width, height, x, y+1); - int left = getTerrain(terrain_data, width, height, x-1, y); - - // Pack: [top, 0, right, 0, bottom, 0, left, 0] - std::array wid = {top, 0, right, 0, bottom, 0, left, 0}; - uint64_t key = WangSet::packWangId(wid); - - auto it = wang_set.wang_lookup.find(key); - if (it != wang_set.wang_lookup.end()) { - result[y * width + x] = it->second; - } - } - } - } - else { - // Mixed: use all 8 values (both edges and corners) - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - int top = getTerrain(terrain_data, width, height, x, y-1); - int right = getTerrain(terrain_data, width, height, x+1, y); - int bottom = getTerrain(terrain_data, width, height, x, y+1); - int left = getTerrain(terrain_data, width, height, x-1, y); - - int tl = cornerTerrain( - getTerrain(terrain_data, width, height, x-1, y-1), - getTerrain(terrain_data, width, height, x, y-1), - getTerrain(terrain_data, width, height, x-1, y), - getTerrain(terrain_data, width, height, x, y)); - int tr = cornerTerrain( - getTerrain(terrain_data, width, height, x, y-1), - getTerrain(terrain_data, width, height, x+1, y-1), - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x+1, y)); - int br = cornerTerrain( - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x+1, y), - getTerrain(terrain_data, width, height, x, y+1), - getTerrain(terrain_data, width, height, x+1, y+1)); - int bl = cornerTerrain( - getTerrain(terrain_data, width, height, x-1, y), - getTerrain(terrain_data, width, height, x, y), - getTerrain(terrain_data, width, height, x-1, y+1), - getTerrain(terrain_data, width, height, x, y+1)); - - std::array wid = {top, tr, right, br, bottom, bl, left, tl}; - uint64_t key = WangSet::packWangId(wid); - - auto it = wang_set.wang_lookup.find(key); - if (it != wang_set.wang_lookup.end()) { - result[y * width + x] = it->second; - } - } - } - } - - return result; -} - -} // namespace tiled -} // namespace mcrf diff --git a/src/tiled/WangResolve.h b/src/tiled/WangResolve.h deleted file mode 100644 index b2aad82..0000000 --- a/src/tiled/WangResolve.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include "TiledTypes.h" -#include -#include - -namespace mcrf { -namespace tiled { - -// Resolve terrain data (from DiscreteMap) to tile indices using a WangSet. -// Returns a vector of tile IDs (one per cell). -1 means no matching tile found. -// terrain_data: row-major uint8 array, width*height elements -std::vector resolveWangTerrain( - const uint8_t* terrain_data, int width, int height, - const WangSet& wang_set); - -} // namespace tiled -} // namespace mcrf diff --git a/tests/cookbook/cookbook_main.py b/tests/cookbook/cookbook_main.py index b21150a..c48c65b 100644 --- a/tests/cookbook/cookbook_main.py +++ b/tests/cookbook/cookbook_main.py @@ -212,26 +212,26 @@ class CookbookLauncher: category = self.categories[self.selected_category] items = self.DEMOS[category] - if key == mcrfpy.Key.ESCAPE: + if key == "Escape": sys.exit(0) - elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A: + elif key == "Left": self.selected_category = (self.selected_category - 1) % len(self.categories) # Clamp item selection to new category new_category = self.categories[self.selected_category] self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) self._update_selection() - elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D: + elif key == "Right": self.selected_category = (self.selected_category + 1) % len(self.categories) new_category = self.categories[self.selected_category] self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) self._update_selection() - elif key == mcrfpy.Key.UP or key == mcrfpy.Key.W: + elif key == "Up": self.selected_item = (self.selected_item - 1) % len(items) self._update_selection() - elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S: + elif key == "Down": self.selected_item = (self.selected_item + 1) % len(items) self._update_selection() - elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE: + elif key == "Enter": self._run_selected_demo() def activate(self): diff --git a/tests/demo/screens/tiled_analysis.py b/tests/demo/screens/tiled_analysis.py deleted file mode 100644 index 3bc9e09..0000000 --- a/tests/demo/screens/tiled_analysis.py +++ /dev/null @@ -1,167 +0,0 @@ -# tiled_analysis.py - Wang set adjacency analysis utility -# Prints adjacency graph, terrain chains, and valid/invalid pair counts -# for exploring tileset Wang transition rules. -# -# Usage: -# cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_analysis.py - -import mcrfpy -import sys -from collections import defaultdict - -# -- Configuration -------------------------------------------------------- -PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" -TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" -WANG_SET_NAME = "overworld" - - -def analyze_wang_set(tileset, wang_set_name): - """Analyze a Wang set and print adjacency information.""" - ws = tileset.wang_set(wang_set_name) - T = ws.terrain_enum() - - print("=" * 60) - print(f"Wang Set Analysis: {ws.name}") - print(f" Type: {ws.type}") - print(f" Colors: {ws.color_count}") - print("=" * 60) - - # List all terrains - terrains = [t for t in T if t != T.NONE] - print(f"\nTerrains ({len(terrains)}):") - for t in terrains: - print(f" {t.value:3d}: {t.name}") - - # Test all pairs for valid Wang transitions using 2x2 grids - adjacency = defaultdict(set) # terrain -> set of valid neighbors - valid_pairs = [] - invalid_pairs = [] - - for a in terrains: - for b in terrains: - if b.value <= a.value: - continue - # Create a 2x2 map: columns of A and B - dm = mcrfpy.DiscreteMap((2, 2)) - dm.set(0, 0, a) - dm.set(1, 0, b) - dm.set(0, 1, a) - dm.set(1, 1, b) - results = ws.resolve(dm) - has_invalid = any(r == -1 for r in results) - if not has_invalid: - valid_pairs.append((a, b)) - adjacency[a.name].add(b.name) - adjacency[b.name].add(a.name) - else: - invalid_pairs.append((a, b)) - - # Print adjacency graph - print(f"\nAdjacency Graph ({len(valid_pairs)} valid pairs):") - print("-" * 40) - for t in terrains: - neighbors = sorted(adjacency.get(t.name, set())) - if neighbors: - print(f" {t.name}") - for n in neighbors: - print(f" <-> {n}") - else: - print(f" {t.name} (ISOLATED - no valid neighbors)") - - # Find terrain chains (connected components) - print(f"\nTerrain Chains (connected components):") - print("-" * 40) - visited = set() - chains = [] - - def bfs(start): - chain = [] - queue = [start] - while queue: - node = queue.pop(0) - if node in visited: - continue - visited.add(node) - chain.append(node) - for neighbor in sorted(adjacency.get(node, set())): - if neighbor not in visited: - queue.append(neighbor) - return chain - - for t in terrains: - if t.name not in visited: - chain = bfs(t.name) - if chain: - chains.append(chain) - - for i, chain in enumerate(chains): - print(f"\n Chain {i+1}: {len(chain)} terrains") - for name in chain: - neighbors = sorted(adjacency.get(name, set())) - connections = ", ".join(neighbors) if neighbors else "(none)" - print(f" {name} -> [{connections}]") - - # Find linear paths within chains - print(f"\nLinear Paths (degree-1 endpoints to degree-1 endpoints):") - print("-" * 40) - for chain in chains: - # Find nodes with degree 1 (endpoints) or degree > 2 (hubs) - endpoints = [n for n in chain if len(adjacency.get(n, set())) == 1] - hubs = [n for n in chain if len(adjacency.get(n, set())) > 2] - - if endpoints: - print(f" Endpoints: {', '.join(endpoints)}") - if hubs: - print(f" Hubs: {', '.join(hubs)} (branch points)") - - # Trace from each endpoint - for ep in endpoints: - path = [ep] - current = ep - prev = None - while True: - neighbors = adjacency.get(current, set()) - {prev} if prev else adjacency.get(current, set()) - if len(neighbors) == 0: - break - if len(neighbors) > 1: - path.append(f"({current} branches)") - break - nxt = list(neighbors)[0] - path.append(nxt) - prev = current - current = nxt - if len(adjacency.get(current, set())) != 2: - break # reached endpoint or hub - print(f" Path: {' -> '.join(path)}") - - # Summary statistics - total_possible = len(terrains) * (len(terrains) - 1) // 2 - print(f"\nSummary:") - print(f" Total terrain types: {len(terrains)}") - print(f" Valid transitions: {len(valid_pairs)} / {total_possible} " - f"({100*len(valid_pairs)/total_possible:.1f}%)") - print(f" Invalid transitions: {len(invalid_pairs)}") - print(f" Connected components: {len(chains)}") - - # Print invalid pairs for reference - if invalid_pairs: - print(f"\nInvalid Pairs ({len(invalid_pairs)}):") - for a, b in invalid_pairs: - print(f" {a.name} X {b.name}") - - return valid_pairs, invalid_pairs, chains - - -def main(): - print("Loading tileset...") - tileset = mcrfpy.TileSetFile(TSX_PATH) - print(f" {tileset.name}: {tileset.tile_count} tiles " - f"({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") - - analyze_wang_set(tileset, WANG_SET_NAME) - print("\nDone!") - - -if __name__ == "__main__": - main() - sys.exit(0) diff --git a/tests/demo/screens/tiled_demo.py b/tests/demo/screens/tiled_demo.py deleted file mode 100644 index e59eb48..0000000 --- a/tests/demo/screens/tiled_demo.py +++ /dev/null @@ -1,504 +0,0 @@ -# tiled_demo.py - Visual demo of Tiled integration -# Shows premade maps, Wang auto-tiling, and procgen terrain -# -# Usage: -# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_demo.py -# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/tiled_demo.py - -import mcrfpy -from mcrfpy import automation -import sys - -# -- Asset Paths ------------------------------------------------------- -PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" -TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" - -# -- Load Shared Assets ------------------------------------------------ -print("Loading Puny World tileset...") -tileset = mcrfpy.TileSetFile(TSX_PATH) -texture = tileset.to_texture() -overworld_ws = tileset.wang_set("overworld") -Terrain = overworld_ws.terrain_enum() - -print(f" Tileset: {tileset.name}") -print(f" Tiles: {tileset.tile_count} ({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") -print(f" Wang set: {overworld_ws.name} ({overworld_ws.type}, {overworld_ws.color_count} colors)") -print(f" Terrain enum members: {[t.name for t in Terrain]}") - -# -- Helper: Iterative terrain expansion ---------------------------------- -def iterative_terrain(hm, wang_set, width, height, passes): - """Build a DiscreteMap by iteratively splitting terrains outward from - a valid binary map. Each pass splits one terrain on each end of the - chain, validates with wang_set.resolve(), and reverts invalid cells - to their previous value. - - hm: HeightMap (normalized 0-1) - passes: list of (threshold, lo_old, lo_new, hi_old, hi_new) tuples. - Each pass says: cells currently == lo_old with height < threshold - become lo_new; cells currently == hi_old with height >= threshold - become hi_new. - - Returns (DiscreteMap, stats_dict). - """ - dm = mcrfpy.DiscreteMap((width, height)) - - # Pass 0: binary split - everything is one of two terrains - p0 = passes[0] - thresh, lo_terrain, hi_terrain = p0 - for y in range(height): - for x in range(width): - if hm.get(x, y) < thresh: - dm.set(x, y, int(lo_terrain)) - else: - dm.set(x, y, int(hi_terrain)) - - # Validate pass 0 and fix any invalid cells (rare edge cases like - # checkerboard patterns at the binary boundary) - results = wang_set.resolve(dm) - inv = sum(1 for r in results if r == -1) - if inv > 0: - # Fix by flipping invalid cells to the other terrain - for y in range(height): - for x in range(width): - if results[y * width + x] == -1: - val = dm.get(x, y) - if val == int(lo_terrain): - dm.set(x, y, int(hi_terrain)) - else: - dm.set(x, y, int(lo_terrain)) - results2 = wang_set.resolve(dm) - inv = sum(1 for r in results2 if r == -1) - stats = {"pass0_invalid": inv} - - # Subsequent passes: split outward - for pi, (thresh, lo_old, lo_new, hi_old, hi_new) in enumerate(passes[1:], 1): - # Save current state so we can revert invalid cells - prev = [dm.get(x, y) for y in range(height) for x in range(width)] - - # Track which cells were changed this pass - changed = set() - for y in range(height): - for x in range(width): - val = dm.get(x, y) - h = hm.get(x, y) - if val == int(lo_old) and h < thresh: - dm.set(x, y, int(lo_new)) - changed.add((x, y)) - elif val == int(hi_old) and h >= thresh: - dm.set(x, y, int(hi_new)) - changed.add((x, y)) - - # Iteratively revert changed cells that cause invalid tiles. - # A changed cell should be reverted if: - # - It is itself invalid, OR - # - It is a neighbor of an invalid UN-changed cell (it broke - # a pre-existing valid cell by being placed next to it) - dirs8 = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)] - total_reverted = 0 - for revert_round in range(30): - results = wang_set.resolve(dm) - to_revert = set() - for y in range(height): - for x in range(width): - if results[y * width + x] != -1: - continue - if (x, y) in changed: - # This changed cell is invalid - revert it - to_revert.add((x, y)) - else: - # Pre-existing cell is now invalid - revert its - # changed neighbors to restore it - for dx, dy in dirs8: - nx, ny = x+dx, y+dy - if (nx, ny) in changed: - to_revert.add((nx, ny)) - - if not to_revert: - break - - for (x, y) in to_revert: - dm.set(x, y, prev[y * width + x]) - changed.discard((x, y)) - total_reverted += 1 - - results_final = wang_set.resolve(dm) - remaining = sum(1 for r in results_final if r == -1) - stats[f"pass{pi}_kept"] = len(changed) - stats[f"pass{pi}_reverted"] = total_reverted - stats[f"pass{pi}_remaining"] = remaining - - return dm, stats - - -# -- Helper: Info Panel ------------------------------------------------- -def make_info_panel(scene, lines, x=560, y=60, w=220, h=None): - """Create a semi-transparent info panel with text lines.""" - if h is None: - h = len(lines) * 22 + 20 - panel = mcrfpy.Frame(pos=(x, y), size=(w, h), - fill_color=mcrfpy.Color(20, 20, 30, 220), - outline_color=mcrfpy.Color(80, 80, 120), - outline=1.5) - scene.children.append(panel) - for i, text in enumerate(lines): - cap = mcrfpy.Caption(text=text, pos=(10, 10 + i * 22)) - cap.fill_color = mcrfpy.Color(200, 200, 220) - panel.children.append(cap) - return panel - - -# ====================================================================== -# SCREEN 1: Premade Tiled Map -# ====================================================================== -print("\nSetting up Screen 1: Premade Map...") -scene1 = mcrfpy.Scene("tiled_premade") - -bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) -scene1.children.append(bg1) - -title1 = mcrfpy.Caption(text="Premade Tiled Map (50x50, 3 layers)", pos=(20, 10)) -title1.fill_color = mcrfpy.Color(255, 255, 255) -scene1.children.append(title1) - -# Load samplemap1 -tm1 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap1.tmj") -print(f" Map: {tm1.width}x{tm1.height}, layers: {tm1.tile_layer_names}") - -grid1 = mcrfpy.Grid(grid_size=(tm1.width, tm1.height), - pos=(20, 50), size=(520, 520), layers=[]) -grid1.fill_color = mcrfpy.Color(30, 30, 50) - -# Add a tile layer for each map layer, bottom-up z ordering -layer_names_1 = tm1.tile_layer_names -for i, name in enumerate(layer_names_1): - z = -(len(layer_names_1) - i) - layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) - grid1.add_layer(layer) - tm1.apply_to_tile_layer(layer, name, tileset_index=0) - print(f" Applied layer '{name}' (z_index={z})") - -# Center camera on map center (pixels = tiles * tile_size) -grid1.center = (tm1.width * tileset.tile_width // 2, - tm1.height * tileset.tile_height // 2) -scene1.children.append(grid1) - -make_info_panel(scene1, [ - f"Tileset: {tileset.name}", - f"Tile size: {tileset.tile_width}x{tileset.tile_height}", - f"Tile count: {tileset.tile_count}", - f"Map size: {tm1.width}x{tm1.height}", - "", - "Layers:", -] + [f" {name}" for name in layer_names_1] + [ - "", - "Wang sets:", - f" {overworld_ws.name} ({overworld_ws.type})", - f" pathways (edge)", -]) - -nav1 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) -nav1.fill_color = mcrfpy.Color(120, 120, 150) -scene1.children.append(nav1) - - -# ====================================================================== -# SCREEN 2: Procedural Wang Auto-Tile (2-layer approach) -# ====================================================================== -print("\nSetting up Screen 2: Procgen Wang Terrain (2-layer)...") -scene2 = mcrfpy.Scene("tiled_procgen") - -bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) -scene2.children.append(bg2) - -title2 = mcrfpy.Caption(text="Procgen Wang Auto-Tile (60x60, 2 layers)", pos=(20, 10)) -title2.fill_color = mcrfpy.Color(255, 255, 255) -scene2.children.append(title2) - -W, H = 60, 60 -T = Terrain # shorthand - -# Generate terrain heightmap using NoiseSource -noise = mcrfpy.NoiseSource(dimensions=2, seed=42) -hm = noise.sample(size=(W, H), mode="fbm", octaves=4, world_size=(4.0, 4.0)) -hm.normalize(0.0, 1.0) - -# -- Base terrain: iterative expansion from binary map -- -# Pass 0: binary split at median -> SEAWATER_LIGHT / SAND -# Pass 1: split outward -> SEAWATER_MEDIUM from LIGHT, GRASS from SAND -# Pass 2: split outward -> SEAWATER_DEEP from MEDIUM, CLIFF from GRASS -base_passes = [ - # Pass 0: (threshold, lo_terrain, hi_terrain) - (0.45, T.SEAWATER_LIGHT, T.SAND), - # Pass 1+: (threshold, lo_old, lo_new, hi_old, hi_new) - (0.30, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), - (0.20, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), -] -base_dm, base_stats = iterative_terrain(hm, overworld_ws, W, H, base_passes) -base_dm.enum_type = T -print(f" Base terrain stats: {base_stats}") - -# -- Tree overlay: separate noise, binary TREES/AIR -- -tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=999) -tree_hm = tree_noise.sample(size=(W, H), mode="fbm", octaves=3, world_size=(6.0, 6.0)) -tree_hm.normalize(0.0, 1.0) - -overlay_dm = mcrfpy.DiscreteMap((W, H)) -overlay_dm.enum_type = T -for y in range(H): - for x in range(W): - base_val = base_dm.get(x, y) - tree_h = tree_hm.get(x, y) - # Trees only on GRASS, driven by separate noise - if base_val == int(T.GRASS) and tree_h > 0.45: - overlay_dm.set(x, y, int(T.TREES)) - else: - overlay_dm.set(x, y, int(T.AIR)) - -# Validate overlay and revert invalid to AIR -overlay_results = overworld_ws.resolve(overlay_dm) -overlay_reverted = 0 -for y in range(H): - for x in range(W): - if overlay_results[y * W + x] == -1: - overlay_dm.set(x, y, int(T.AIR)) - overlay_reverted += 1 -print(f" Overlay: {overlay_reverted} tree cells reverted to AIR") - -# Count terrain distribution -terrain_counts = {} -for t in T: - if t == T.NONE: - continue - c = base_dm.count(int(t)) - if c > 0: - terrain_counts[t.name] = c -tree_count = overlay_dm.count(int(T.TREES)) -terrain_counts["TREES(overlay)"] = tree_count - -print(f" Terrain distribution: {terrain_counts}") - -# Create grid with 2 layers and apply Wang auto-tiling -grid2 = mcrfpy.Grid(grid_size=(W, H), pos=(20, 50), size=(520, 520), layers=[]) -grid2.fill_color = mcrfpy.Color(30, 30, 50) - -base_layer2 = mcrfpy.TileLayer(name="base", z_index=-2, texture=texture) -grid2.add_layer(base_layer2) -overworld_ws.apply(base_dm, base_layer2) - -overlay_layer2 = mcrfpy.TileLayer(name="trees", z_index=-1, texture=texture) -grid2.add_layer(overlay_layer2) -overworld_ws.apply(overlay_dm, overlay_layer2) - -# Post-process overlay: AIR resolves to an opaque tile, set to -1 (transparent) -for y in range(H): - for x in range(W): - if overlay_dm.get(x, y) == int(T.AIR): - overlay_layer2.set((x, y), -1) - -grid2.center = (W * tileset.tile_width // 2, H * tileset.tile_height // 2) -scene2.children.append(grid2) - -# Info panel -info_lines = [ - "Iterative terrain expansion", - f"Seed: 42 (base), 999 (trees)", - f"Grid: {W}x{H}, 2 layers", - "", - "Base (3 passes):", -] -for name in ["SEAWATER_DEEP", "SEAWATER_MEDIUM", "SEAWATER_LIGHT", - "SAND", "GRASS", "CLIFF"]: - count = terrain_counts.get(name, 0) - info_lines.append(f" {name}: {count}") -info_lines.append("") -info_lines.append("Tree Overlay:") -info_lines.append(f" TREES: {tree_count}") -info_lines.append(f" reverted: {overlay_reverted}") - -make_info_panel(scene2, info_lines) - -nav2 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) -nav2.fill_color = mcrfpy.Color(120, 120, 150) -scene2.children.append(nav2) - - -# ====================================================================== -# SCREEN 3: Side-by-Side Comparison -# ====================================================================== -print("\nSetting up Screen 3: Side-by-Side...") -scene3 = mcrfpy.Scene("tiled_compare") - -bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) -scene3.children.append(bg3) - -title3 = mcrfpy.Caption(text="Premade vs Procedural", pos=(20, 10)) -title3.fill_color = mcrfpy.Color(255, 255, 255) -scene3.children.append(title3) - -# Left: Premade map (samplemap2, 30x30) -tm2 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap2.tmj") -print(f" Map2: {tm2.width}x{tm2.height}, layers: {tm2.tile_layer_names}") - -left_label = mcrfpy.Caption(text="Premade (samplemap2)", pos=(20, 38)) -left_label.fill_color = mcrfpy.Color(180, 220, 255) -scene3.children.append(left_label) - -grid_left = mcrfpy.Grid(grid_size=(tm2.width, tm2.height), - pos=(20, 60), size=(380, 380), layers=[]) -grid_left.fill_color = mcrfpy.Color(30, 30, 50) - -for i, name in enumerate(tm2.tile_layer_names): - z = -(len(tm2.tile_layer_names) - i) - layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) - grid_left.add_layer(layer) - tm2.apply_to_tile_layer(layer, name, tileset_index=0) - -grid_left.center = (tm2.width * tileset.tile_width // 2, - tm2.height * tileset.tile_height // 2) -scene3.children.append(grid_left) - -# Right: Procgen island -right_label = mcrfpy.Caption(text="Procgen Island (2-layer Wang)", pos=(420, 38)) -right_label.fill_color = mcrfpy.Color(180, 255, 220) -scene3.children.append(right_label) - -IW, IH = 30, 30 -island_noise = mcrfpy.NoiseSource(dimensions=2, seed=7777) -island_hm = island_noise.sample(size=(IW, IH), mode="fbm", octaves=3, world_size=(3.0, 3.0)) -island_hm.normalize(0.0, 1.0) - -# Create island shape: attenuate edges with radial gradient -for y in range(IH): - for x in range(IW): - dx = (x - IW / 2.0) / (IW / 2.0) - dy = (y - IH / 2.0) / (IH / 2.0) - dist = (dx * dx + dy * dy) ** 0.5 - falloff = max(0.0, 1.0 - dist * 1.2) - h = island_hm.get(x, y) * falloff - island_hm[x, y] = h - -island_hm.normalize(0.0, 1.0) - -# Iterative base terrain expansion (same technique as Screen 2) -island_passes_def = [ - (0.40, T.SEAWATER_LIGHT, T.SAND), - (0.25, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), - (0.15, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), -] -island_base_dm, island_stats = iterative_terrain( - island_hm, overworld_ws, IW, IH, island_passes_def) -island_base_dm.enum_type = T -print(f" Island base stats: {island_stats}") - -# Tree overlay with separate noise -island_tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=8888) -island_tree_hm = island_tree_noise.sample( - size=(IW, IH), mode="fbm", octaves=3, world_size=(4.0, 4.0)) -island_tree_hm.normalize(0.0, 1.0) - -island_overlay_dm = mcrfpy.DiscreteMap((IW, IH)) -island_overlay_dm.enum_type = T -for y in range(IH): - for x in range(IW): - base_val = island_base_dm.get(x, y) - tree_h = island_tree_hm.get(x, y) - if base_val == int(T.GRASS) and tree_h > 0.50: - island_overlay_dm.set(x, y, int(T.TREES)) - else: - island_overlay_dm.set(x, y, int(T.AIR)) - -# Validate overlay -island_ov_results = overworld_ws.resolve(island_overlay_dm) -for y in range(IH): - for x in range(IW): - if island_ov_results[y * IW + x] == -1: - island_overlay_dm.set(x, y, int(T.AIR)) - -grid_right = mcrfpy.Grid(grid_size=(IW, IH), - pos=(420, 60), size=(380, 380), layers=[]) -grid_right.fill_color = mcrfpy.Color(30, 30, 50) - -island_base_layer = mcrfpy.TileLayer(name="island_base", z_index=-2, texture=texture) -grid_right.add_layer(island_base_layer) -overworld_ws.apply(island_base_dm, island_base_layer) - -island_overlay_layer = mcrfpy.TileLayer(name="island_trees", z_index=-1, texture=texture) -grid_right.add_layer(island_overlay_layer) -overworld_ws.apply(island_overlay_dm, island_overlay_layer) - -# Post-process: make AIR cells transparent -for y in range(IH): - for x in range(IW): - if island_overlay_dm.get(x, y) == int(T.AIR): - island_overlay_layer.set((x, y), -1) - -grid_right.center = (IW * tileset.tile_width // 2, IH * tileset.tile_height // 2) -scene3.children.append(grid_right) - -# Info for both -make_info_panel(scene3, [ - "Left: Premade Map", - f" samplemap2.tmj", - f" {tm2.width}x{tm2.height}, {len(tm2.tile_layer_names)} layers", - "", - "Right: Procgen Island", - f" {IW}x{IH}, seed=7777", - " Iterative terrain expansion", - " 2-layer Wang auto-tile", - "", - "Same tileset, same engine", - "Different workflows", -], x=200, y=460, w=400, h=None) - -nav3 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) -nav3.fill_color = mcrfpy.Color(120, 120, 150) -scene3.children.append(nav3) - - -# ====================================================================== -# Navigation & Screenshots -# ====================================================================== -scenes = [scene1, scene2, scene3] -scene_names = ["premade", "procgen", "compare"] - -# Keyboard navigation (all scenes share the same handler) -def on_key(key, action): - if action != mcrfpy.InputState.PRESSED: - return - if key == mcrfpy.Key.NUM_1: - mcrfpy.current_scene = scene1 - elif key == mcrfpy.Key.NUM_2: - mcrfpy.current_scene = scene2 - elif key == mcrfpy.Key.NUM_3: - mcrfpy.current_scene = scene3 - elif key == mcrfpy.Key.ESCAPE: - mcrfpy.exit() - -for s in scenes: - s.on_key = on_key - -# Detect headless mode and take screenshots synchronously -is_headless = False -try: - win = mcrfpy.Window.get() - is_headless = "headless" in str(win).lower() -except: - is_headless = True - -if is_headless: - # Headless: use step() to advance simulation and take screenshots directly - for i, (sc, name) in enumerate(zip(scenes, scene_names)): - mcrfpy.current_scene = sc - # Step a few frames to let the scene render - for _ in range(3): - mcrfpy.step(0.016) - fname = f"tiled_demo_{name}.png" - automation.screenshot(fname) - print(f" Screenshot: {fname}") - print("\nAll screenshots captured. Done!") - sys.exit(0) -else: - # Interactive: start on screen 1 - mcrfpy.current_scene = scene1 - print("\nTiled Demo ready!") - print("Press [1] [2] [3] to switch screens, [ESC] to quit") diff --git a/tests/unit/animated_model_test.py b/tests/unit/animated_model_test.py deleted file mode 100644 index 9bf6347..0000000 --- a/tests/unit/animated_model_test.py +++ /dev/null @@ -1,98 +0,0 @@ -# animated_model_test.py - Test loading actual animated glTF models -# Tests skeleton and animation data loading from real files - -import mcrfpy -import sys -import os - -def test_rigged_simple(): - """Test loading RiggedSimple - a cylinder with 2 bones""" - print("Loading RiggedSimple.glb...") - model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb") - - print(f" has_skeleton: {model.has_skeleton}") - print(f" bone_count: {model.bone_count}") - print(f" animation_clips: {model.animation_clips}") - print(f" vertex_count: {model.vertex_count}") - print(f" triangle_count: {model.triangle_count}") - print(f" mesh_count: {model.mesh_count}") - - assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}" - assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}" - assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}" - - print("[PASS] test_rigged_simple") - -def test_cesium_man(): - """Test loading CesiumMan - animated humanoid figure""" - print("Loading CesiumMan.glb...") - model = mcrfpy.Model3D("../assets/models/CesiumMan.glb") - - print(f" has_skeleton: {model.has_skeleton}") - print(f" bone_count: {model.bone_count}") - print(f" animation_clips: {model.animation_clips}") - print(f" vertex_count: {model.vertex_count}") - print(f" triangle_count: {model.triangle_count}") - print(f" mesh_count: {model.mesh_count}") - - assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}" - assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}" - assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}" - - print("[PASS] test_cesium_man") - -def test_entity_with_animated_model(): - """Test Entity3D with an animated model attached""" - print("Testing Entity3D with animated model...") - - model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb") - entity = mcrfpy.Entity3D() - entity.model = model - - # Check animation clips are available - clips = model.animation_clips - print(f" Available clips: {clips}") - - if clips: - # Set animation clip - entity.anim_clip = clips[0] - assert entity.anim_clip == clips[0], f"Expected clip '{clips[0]}', got '{entity.anim_clip}'" - - # Test animation time progression - entity.anim_time = 0.5 - assert abs(entity.anim_time - 0.5) < 0.001, f"Expected anim_time~=0.5, got {entity.anim_time}" - - # Test speed - entity.anim_speed = 2.0 - assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}" - - print("[PASS] test_entity_with_animated_model") - -def run_all_tests(): - """Run all animated model tests""" - tests = [ - test_rigged_simple, - test_cesium_man, - test_entity_with_animated_model, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/animation_test.py b/tests/unit/animation_test.py deleted file mode 100644 index 52e833b..0000000 --- a/tests/unit/animation_test.py +++ /dev/null @@ -1,159 +0,0 @@ -# animation_test.py - Unit tests for Entity3D skeletal animation - -import mcrfpy -import sys - -def test_entity3d_animation_defaults(): - """Test Entity3D animation property defaults""" - entity = mcrfpy.Entity3D() - - # Default animation state - assert entity.anim_clip == "", f"Expected empty anim_clip, got '{entity.anim_clip}'" - assert entity.anim_time == 0.0, f"Expected anim_time=0.0, got {entity.anim_time}" - assert entity.anim_speed == 1.0, f"Expected anim_speed=1.0, got {entity.anim_speed}" - assert entity.anim_loop == True, f"Expected anim_loop=True, got {entity.anim_loop}" - assert entity.anim_paused == False, f"Expected anim_paused=False, got {entity.anim_paused}" - assert entity.anim_frame == 0, f"Expected anim_frame=0, got {entity.anim_frame}" - - # Auto-animate defaults - assert entity.auto_animate == True, f"Expected auto_animate=True, got {entity.auto_animate}" - assert entity.walk_clip == "walk", f"Expected walk_clip='walk', got '{entity.walk_clip}'" - assert entity.idle_clip == "idle", f"Expected idle_clip='idle', got '{entity.idle_clip}'" - - print("[PASS] test_entity3d_animation_defaults") - -def test_entity3d_animation_properties(): - """Test setting Entity3D animation properties""" - entity = mcrfpy.Entity3D() - - # Set animation clip - entity.anim_clip = "test_anim" - assert entity.anim_clip == "test_anim", f"Expected 'test_anim', got '{entity.anim_clip}'" - - # Set animation time - entity.anim_time = 1.5 - assert abs(entity.anim_time - 1.5) < 0.001, f"Expected anim_time~=1.5, got {entity.anim_time}" - - # Set animation speed - entity.anim_speed = 2.0 - assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}" - - # Set loop - entity.anim_loop = False - assert entity.anim_loop == False, f"Expected anim_loop=False, got {entity.anim_loop}" - - # Set paused - entity.anim_paused = True - assert entity.anim_paused == True, f"Expected anim_paused=True, got {entity.anim_paused}" - - print("[PASS] test_entity3d_animation_properties") - -def test_entity3d_auto_animate(): - """Test Entity3D auto-animate settings""" - entity = mcrfpy.Entity3D() - - # Disable auto-animate - entity.auto_animate = False - assert entity.auto_animate == False - - # Set custom clip names - entity.walk_clip = "run" - entity.idle_clip = "stand" - assert entity.walk_clip == "run" - assert entity.idle_clip == "stand" - - print("[PASS] test_entity3d_auto_animate") - -def test_entity3d_animation_callback(): - """Test Entity3D animation complete callback""" - entity = mcrfpy.Entity3D() - callback_called = [False] - callback_args = [None, None] - - def on_complete(ent, clip_name): - callback_called[0] = True - callback_args[0] = ent - callback_args[1] = clip_name - - # Set callback - entity.on_anim_complete = on_complete - assert entity.on_anim_complete is not None - - # Clear callback - entity.on_anim_complete = None - # Should not raise error even though callback is None - - print("[PASS] test_entity3d_animation_callback") - -def test_entity3d_animation_callback_invalid(): - """Test that non-callable is rejected for animation callback""" - entity = mcrfpy.Entity3D() - - try: - entity.on_anim_complete = "not a function" - assert False, "Should have raised TypeError" - except TypeError: - pass - - print("[PASS] test_entity3d_animation_callback_invalid") - -def test_entity3d_with_model(): - """Test Entity3D animation with a non-skeletal model""" - entity = mcrfpy.Entity3D() - cube = mcrfpy.Model3D.cube() - - entity.model = cube - - # Setting animation clip on non-skeletal model should not crash - entity.anim_clip = "walk" # Should just do nothing gracefully - assert entity.anim_clip == "walk" # The property is set even if model has no animation - - # Frame should be 0 since there's no skeleton - assert entity.anim_frame == 0 - - print("[PASS] test_entity3d_with_model") - -def test_entity3d_animation_negative_speed(): - """Test that animation speed can be negative (reverse playback)""" - entity = mcrfpy.Entity3D() - - entity.anim_speed = -1.0 - assert abs(entity.anim_speed - (-1.0)) < 0.001 - - entity.anim_speed = 0.0 - assert entity.anim_speed == 0.0 - - print("[PASS] test_entity3d_animation_negative_speed") - -def run_all_tests(): - """Run all animation tests""" - tests = [ - test_entity3d_animation_defaults, - test_entity3d_animation_properties, - test_entity3d_auto_animate, - test_entity3d_animation_callback, - test_entity3d_animation_callback_invalid, - test_entity3d_with_model, - test_entity3d_animation_negative_speed, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/billboard_test.py b/tests/unit/billboard_test.py deleted file mode 100644 index d7d499e..0000000 --- a/tests/unit/billboard_test.py +++ /dev/null @@ -1,260 +0,0 @@ -# billboard_test.py - Unit test for Billboard 3D camera-facing sprites - -import mcrfpy -import sys - -def test_billboard_creation(): - """Test basic Billboard creation and default properties""" - bb = mcrfpy.Billboard() - - # Default sprite index - assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}" - - # Default position - assert bb.pos == (0.0, 0.0, 0.0), f"Expected pos=(0,0,0), got {bb.pos}" - - # Default scale - assert bb.scale == 1.0, f"Expected scale=1.0, got {bb.scale}" - - # Default facing mode - assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}" - - # Default theta/phi (for fixed mode) - assert bb.theta == 0.0, f"Expected theta=0.0, got {bb.theta}" - assert bb.phi == 0.0, f"Expected phi=0.0, got {bb.phi}" - - # Default opacity and visibility - assert bb.opacity == 1.0, f"Expected opacity=1.0, got {bb.opacity}" - assert bb.visible == True, f"Expected visible=True, got {bb.visible}" - - print("[PASS] test_billboard_creation") - -def test_billboard_with_kwargs(): - """Test Billboard creation with keyword arguments""" - bb = mcrfpy.Billboard( - sprite_index=5, - pos=(10.0, 5.0, -3.0), - scale=2.5, - facing="camera", - opacity=0.8 - ) - - assert bb.sprite_index == 5, f"Expected sprite_index=5, got {bb.sprite_index}" - assert bb.pos == (10.0, 5.0, -3.0), f"Expected pos=(10,5,-3), got {bb.pos}" - assert bb.scale == 2.5, f"Expected scale=2.5, got {bb.scale}" - assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}" - assert abs(bb.opacity - 0.8) < 0.001, f"Expected opacity~=0.8, got {bb.opacity}" - - print("[PASS] test_billboard_with_kwargs") - -def test_billboard_facing_modes(): - """Test all Billboard facing modes""" - bb = mcrfpy.Billboard() - - # Test camera mode (full rotation to face camera) - bb.facing = "camera" - assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}" - - # Test camera_y mode (only Y-axis rotation, stays upright) - bb.facing = "camera_y" - assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}" - - # Test fixed mode (uses theta/phi angles) - bb.facing = "fixed" - assert bb.facing == "fixed", f"Expected facing='fixed', got {bb.facing}" - - print("[PASS] test_billboard_facing_modes") - -def test_billboard_fixed_rotation(): - """Test fixed mode rotation angles (theta/phi)""" - bb = mcrfpy.Billboard(facing="fixed") - - # Set theta (horizontal rotation) - bb.theta = 1.5708 # ~90 degrees - assert abs(bb.theta - 1.5708) < 0.001, f"Expected theta~=1.5708, got {bb.theta}" - - # Set phi (vertical tilt) - bb.phi = 0.7854 # ~45 degrees - assert abs(bb.phi - 0.7854) < 0.001, f"Expected phi~=0.7854, got {bb.phi}" - - print("[PASS] test_billboard_fixed_rotation") - -def test_billboard_property_modification(): - """Test modifying Billboard properties after creation""" - bb = mcrfpy.Billboard() - - # Modify position - bb.pos = (5.0, 10.0, 15.0) - assert bb.pos == (5.0, 10.0, 15.0), f"Expected pos=(5,10,15), got {bb.pos}" - - # Modify sprite index - bb.sprite_index = 42 - assert bb.sprite_index == 42, f"Expected sprite_index=42, got {bb.sprite_index}" - - # Modify scale - bb.scale = 0.5 - assert bb.scale == 0.5, f"Expected scale=0.5, got {bb.scale}" - - # Modify opacity - bb.opacity = 0.25 - assert abs(bb.opacity - 0.25) < 0.001, f"Expected opacity~=0.25, got {bb.opacity}" - - # Modify visibility - bb.visible = False - assert bb.visible == False, f"Expected visible=False, got {bb.visible}" - - print("[PASS] test_billboard_property_modification") - -def test_billboard_opacity_clamping(): - """Test that opacity is clamped to 0-1 range""" - bb = mcrfpy.Billboard() - - # Test upper clamp - bb.opacity = 2.0 - assert bb.opacity == 1.0, f"Expected opacity=1.0 after clamping, got {bb.opacity}" - - # Test lower clamp - bb.opacity = -0.5 - assert bb.opacity == 0.0, f"Expected opacity=0.0 after clamping, got {bb.opacity}" - - print("[PASS] test_billboard_opacity_clamping") - -def test_billboard_repr(): - """Test Billboard string representation""" - bb = mcrfpy.Billboard(pos=(1.0, 2.0, 3.0), sprite_index=7, facing="camera") - repr_str = repr(bb) - - # Check that repr contains expected information - assert "Billboard" in repr_str, f"Expected 'Billboard' in repr, got {repr_str}" - - print("[PASS] test_billboard_repr") - -def test_billboard_with_texture(): - """Test Billboard with texture assignment""" - # Use default_texture which is always available - tex = mcrfpy.default_texture - bb = mcrfpy.Billboard(texture=tex, sprite_index=0) - - # Verify texture is assigned - assert bb.texture is not None, "Expected texture to be assigned" - assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}" - - # Change sprite index - bb.sprite_index = 10 - assert bb.sprite_index == 10, f"Expected sprite_index=10, got {bb.sprite_index}" - - # Test assigning texture via property - bb2 = mcrfpy.Billboard() - assert bb2.texture is None, "Expected no texture initially" - bb2.texture = tex - assert bb2.texture is not None, "Expected texture after assignment" - - # Test setting texture to None - bb2.texture = None - assert bb2.texture is None, "Expected None after clearing texture" - - print("[PASS] test_billboard_with_texture") - -def test_viewport3d_billboard_methods(): - """Test Viewport3D billboard management methods""" - vp = mcrfpy.Viewport3D() - - # Initial count should be 0 - assert vp.billboard_count() == 0, f"Expected 0, got {vp.billboard_count()}" - - # Add billboards - bb1 = mcrfpy.Billboard(pos=(1, 0, 1), scale=1.0) - vp.add_billboard(bb1) - assert vp.billboard_count() == 1, f"Expected 1, got {vp.billboard_count()}" - - bb2 = mcrfpy.Billboard(pos=(2, 0, 2), scale=0.5) - vp.add_billboard(bb2) - assert vp.billboard_count() == 2, f"Expected 2, got {vp.billboard_count()}" - - # Get billboard by index - retrieved = vp.get_billboard(0) - assert retrieved.pos == (1.0, 0.0, 1.0), f"Expected (1,0,1), got {retrieved.pos}" - - # Modify retrieved billboard - retrieved.pos = (5, 1, 5) - assert retrieved.pos == (5.0, 1.0, 5.0), f"Expected (5,1,5), got {retrieved.pos}" - - # Clear all billboards - vp.clear_billboards() - assert vp.billboard_count() == 0, f"Expected 0 after clear, got {vp.billboard_count()}" - - print("[PASS] test_viewport3d_billboard_methods") - -def test_viewport3d_billboard_index_bounds(): - """Test get_billboard index bounds checking""" - vp = mcrfpy.Viewport3D() - - # Empty viewport - any index should fail - try: - vp.get_billboard(0) - assert False, "Should have raised IndexError" - except IndexError: - pass - - # Add one billboard - bb = mcrfpy.Billboard() - vp.add_billboard(bb) - - # Index 0 should work - vp.get_billboard(0) - - # Index 1 should fail - try: - vp.get_billboard(1) - assert False, "Should have raised IndexError" - except IndexError: - pass - - # Negative index should fail - try: - vp.get_billboard(-1) - assert False, "Should have raised IndexError" - except IndexError: - pass - - print("[PASS] test_viewport3d_billboard_index_bounds") - -def run_all_tests(): - """Run all Billboard tests""" - tests = [ - test_billboard_creation, - test_billboard_with_kwargs, - test_billboard_facing_modes, - test_billboard_fixed_rotation, - test_billboard_property_modification, - test_billboard_opacity_clamping, - test_billboard_repr, - test_billboard_with_texture, - test_viewport3d_billboard_methods, - test_viewport3d_billboard_index_bounds, - ] - - passed = 0 - failed = 0 - skipped = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - if "[SKIP]" in str(e): - skipped += 1 - else: - print(f"[ERROR] {test.__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed, {skipped} skipped ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/entity3d_test.py b/tests/unit/entity3d_test.py deleted file mode 100644 index 21da752..0000000 --- a/tests/unit/entity3d_test.py +++ /dev/null @@ -1,293 +0,0 @@ -# entity3d_test.py - Unit test for Entity3D 3D game entities - -import mcrfpy -import sys - -def test_entity3d_creation(): - """Test basic Entity3D creation and default properties""" - e = mcrfpy.Entity3D() - - # Default grid position (0, 0) - assert e.pos == (0, 0), f"Expected pos=(0, 0), got {e.pos}" - assert e.grid_pos == (0, 0), f"Expected grid_pos=(0, 0), got {e.grid_pos}" - - # Default world position (at origin) - wp = e.world_pos - assert len(wp) == 3, f"Expected 3-tuple for world_pos, got {wp}" - assert wp[0] == 0.0, f"Expected world_pos.x=0, got {wp[0]}" - assert wp[2] == 0.0, f"Expected world_pos.z=0, got {wp[2]}" - - # Default rotation - assert e.rotation == 0.0, f"Expected rotation=0, got {e.rotation}" - - # Default scale - assert e.scale == 1.0, f"Expected scale=1.0, got {e.scale}" - - # Default visibility - assert e.visible == True, f"Expected visible=True, got {e.visible}" - - # Default color (orange: 200, 100, 50) - c = e.color - assert c.r == 200, f"Expected color.r=200, got {c.r}" - assert c.g == 100, f"Expected color.g=100, got {c.g}" - assert c.b == 50, f"Expected color.b=50, got {c.b}" - - print("[PASS] test_entity3d_creation") - -def test_entity3d_with_pos(): - """Test Entity3D creation with position argument""" - e = mcrfpy.Entity3D(pos=(5, 10)) - - assert e.pos == (5, 10), f"Expected pos=(5, 10), got {e.pos}" - assert e.grid_pos == (5, 10), f"Expected grid_pos=(5, 10), got {e.grid_pos}" - - print("[PASS] test_entity3d_with_pos") - -def test_entity3d_with_kwargs(): - """Test Entity3D creation with keyword arguments""" - e = mcrfpy.Entity3D( - pos=(3, 7), - rotation=90.0, - scale=2.0, - visible=False, - color=mcrfpy.Color(255, 0, 0) - ) - - assert e.pos == (3, 7), f"Expected pos=(3, 7), got {e.pos}" - assert e.rotation == 90.0, f"Expected rotation=90, got {e.rotation}" - assert e.scale == 2.0, f"Expected scale=2.0, got {e.scale}" - assert e.visible == False, f"Expected visible=False, got {e.visible}" - assert e.color.r == 255, f"Expected color.r=255, got {e.color.r}" - assert e.color.g == 0, f"Expected color.g=0, got {e.color.g}" - - print("[PASS] test_entity3d_with_kwargs") - -def test_entity3d_property_modification(): - """Test modifying Entity3D properties after creation""" - e = mcrfpy.Entity3D() - - # Modify rotation - e.rotation = 180.0 - assert e.rotation == 180.0, f"Expected rotation=180, got {e.rotation}" - - # Modify scale - e.scale = 0.5 - assert e.scale == 0.5, f"Expected scale=0.5, got {e.scale}" - - # Modify visibility - e.visible = False - assert e.visible == False, f"Expected visible=False, got {e.visible}" - e.visible = True - assert e.visible == True, f"Expected visible=True, got {e.visible}" - - # Modify color - e.color = mcrfpy.Color(0, 255, 128) - assert e.color.r == 0, f"Expected color.r=0, got {e.color.r}" - assert e.color.g == 255, f"Expected color.g=255, got {e.color.g}" - assert e.color.b == 128, f"Expected color.b=128, got {e.color.b}" - - print("[PASS] test_entity3d_property_modification") - -def test_entity3d_teleport(): - """Test Entity3D teleport method""" - e = mcrfpy.Entity3D(pos=(0, 0)) - - # Teleport to new position - e.teleport(15, 20) - - assert e.pos == (15, 20), f"Expected pos=(15, 20), got {e.pos}" - assert e.grid_pos == (15, 20), f"Expected grid_pos=(15, 20), got {e.grid_pos}" - - # World position should also update - wp = e.world_pos - # World position is grid * cell_size, but we don't know cell size here - # Just verify it changed from origin - assert wp[0] != 0.0 or wp[2] != 0.0, f"Expected world_pos to change, got {wp}" - - print("[PASS] test_entity3d_teleport") - -def test_entity3d_pos_setter(): - """Test setting position via pos property""" - e = mcrfpy.Entity3D(pos=(0, 0)) - - # Set position (this should trigger animated movement when in a viewport) - e.pos = (8, 12) - - # The grid position should update - assert e.pos == (8, 12), f"Expected pos=(8, 12), got {e.pos}" - - print("[PASS] test_entity3d_pos_setter") - -def test_entity3d_repr(): - """Test Entity3D string representation""" - e = mcrfpy.Entity3D(pos=(5, 10)) - e.rotation = 45.0 - repr_str = repr(e) - - assert "Entity3D" in repr_str, f"Expected 'Entity3D' in repr, got {repr_str}" - assert "5" in repr_str, f"Expected grid_x in repr, got {repr_str}" - assert "10" in repr_str, f"Expected grid_z in repr, got {repr_str}" - - print("[PASS] test_entity3d_repr") - -def test_entity3d_viewport_integration(): - """Test adding Entity3D to a Viewport3D""" - # Create viewport with navigation grid - vp = mcrfpy.Viewport3D() - vp.set_grid_size(32, 32) - - # Create entity - e = mcrfpy.Entity3D(pos=(5, 5)) - - # Verify entity has no viewport initially - assert e.viewport is None, f"Expected viewport=None before adding, got {e.viewport}" - - # Add to viewport - vp.entities.append(e) - - # Verify entity count - assert len(vp.entities) == 1, f"Expected 1 entity, got {len(vp.entities)}" - - # Verify entity was linked to viewport - # Note: viewport property may not be set until render cycle - # For now, just verify the entity is in the collection - retrieved = vp.entities[0] - assert retrieved.pos == (5, 5), f"Expected retrieved entity at (5, 5), got {retrieved.pos}" - - print("[PASS] test_entity3d_viewport_integration") - -def test_entitycollection3d_operations(): - """Test EntityCollection3D sequence operations""" - vp = mcrfpy.Viewport3D() - vp.set_grid_size(20, 20) - - # Initially empty - assert len(vp.entities) == 0, f"Expected 0 entities initially, got {len(vp.entities)}" - - # Add multiple entities - e1 = mcrfpy.Entity3D(pos=(1, 1)) - e2 = mcrfpy.Entity3D(pos=(5, 5)) - e3 = mcrfpy.Entity3D(pos=(10, 10)) - - vp.entities.append(e1) - vp.entities.append(e2) - vp.entities.append(e3) - - assert len(vp.entities) == 3, f"Expected 3 entities, got {len(vp.entities)}" - - # Access by index - assert vp.entities[0].pos == (1, 1), f"Expected entities[0] at (1,1)" - assert vp.entities[1].pos == (5, 5), f"Expected entities[1] at (5,5)" - assert vp.entities[2].pos == (10, 10), f"Expected entities[2] at (10,10)" - - # Negative indexing - assert vp.entities[-1].pos == (10, 10), f"Expected entities[-1] at (10,10)" - - # Contains check - assert e2 in vp.entities, "Expected e2 in entities" - - # Iteration - positions = [e.pos for e in vp.entities] - assert (1, 1) in positions, "Expected (1,1) in iterated positions" - assert (5, 5) in positions, "Expected (5,5) in iterated positions" - - # Remove - vp.entities.remove(e2) - assert len(vp.entities) == 2, f"Expected 2 entities after remove, got {len(vp.entities)}" - assert e2 not in vp.entities, "Expected e2 not in entities after remove" - - # Clear - vp.entities.clear() - assert len(vp.entities) == 0, f"Expected 0 entities after clear, got {len(vp.entities)}" - - print("[PASS] test_entitycollection3d_operations") - -def test_entity3d_scene_integration(): - """Test Entity3D works when viewport is in a scene""" - scene = mcrfpy.Scene("entity3d_test") - - vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) - vp.set_grid_size(32, 32) - - # Add viewport to scene - scene.children.append(vp) - - # Add entity to viewport - e = mcrfpy.Entity3D(pos=(16, 16), rotation=45.0, color=mcrfpy.Color(0, 255, 0)) - vp.entities.append(e) - - # Verify everything is connected - assert len(scene.children) == 1, "Expected 1 child in scene" - - viewport_from_scene = scene.children[0] - assert type(viewport_from_scene).__name__ == "Viewport3D" - assert len(viewport_from_scene.entities) == 1, "Expected 1 entity in viewport" - - entity_from_vp = viewport_from_scene.entities[0] - assert entity_from_vp.pos == (16, 16), f"Expected entity at (16, 16), got {entity_from_vp.pos}" - assert entity_from_vp.rotation == 45.0, f"Expected rotation=45, got {entity_from_vp.rotation}" - - print("[PASS] test_entity3d_scene_integration") - -def test_entity3d_multiple_entities(): - """Test multiple entities in a viewport""" - vp = mcrfpy.Viewport3D() - vp.set_grid_size(50, 50) - - # Create a grid of entities - entities = [] - for x in range(0, 50, 10): - for z in range(0, 50, 10): - e = mcrfpy.Entity3D(pos=(x, z)) - e.color = mcrfpy.Color(x * 5, z * 5, 128) - entities.append(e) - vp.entities.append(e) - - expected_count = 5 * 5 # 0, 10, 20, 30, 40 for both x and z - assert len(vp.entities) == expected_count, f"Expected {expected_count} entities, got {len(vp.entities)}" - - # Verify we can access all entities - found_positions = set() - for e in vp.entities: - found_positions.add(e.pos) - - assert len(found_positions) == expected_count, f"Expected {expected_count} unique positions" - - print("[PASS] test_entity3d_multiple_entities") - -def run_all_tests(): - """Run all Entity3D tests""" - tests = [ - test_entity3d_creation, - test_entity3d_with_pos, - test_entity3d_with_kwargs, - test_entity3d_property_modification, - test_entity3d_teleport, - test_entity3d_pos_setter, - test_entity3d_repr, - test_entity3d_viewport_integration, - test_entitycollection3d_operations, - test_entity3d_scene_integration, - test_entity3d_multiple_entities, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/fov_3d_test.py b/tests/unit/fov_3d_test.py deleted file mode 100644 index 186c7e4..0000000 --- a/tests/unit/fov_3d_test.py +++ /dev/null @@ -1,198 +0,0 @@ -# fov_3d_test.py - Unit tests for 3D field of view -# Tests FOV computation on VoxelPoint navigation grid - -import mcrfpy -import sys - -def test_basic_fov(): - """Test basic FOV computation""" - print("Testing basic FOV...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (20, 20) - - # Compute FOV from center - visible = viewport.compute_fov((10, 10), radius=5) - - # Should have visible cells - assert len(visible) > 0, "Expected visible cells" - - # Origin should be visible - assert (10, 10) in visible, "Origin should be visible" - - # Cells within radius should be visible - assert (10, 11) in visible, "(10, 11) should be visible" - assert (11, 10) in visible, "(11, 10) should be visible" - - # Cells outside radius should not be visible - assert (10, 20) not in visible, "(10, 20) should not be visible" - assert (0, 0) not in visible, "(0, 0) should not be visible" - - print(f" PASS: Basic FOV ({len(visible)} cells visible)") - - -def test_fov_with_walls(): - """Test FOV blocked by opaque cells""" - print("Testing FOV with walls...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (20, 20) - - # Create a wall blocking line of sight - # Wall at x=12 - for z in range(5, 16): - viewport.at(12, z).transparent = False - - # Compute FOV from (10, 10) - visible = viewport.compute_fov((10, 10), radius=10) - - # Origin should be visible - assert (10, 10) in visible, "Origin should be visible" - - # Cells before wall should be visible - assert (11, 10) in visible, "Cell before wall should be visible" - - # Wall cells themselves might be visible (at the edge) - # But cells behind wall should NOT be visible - # Note: Exact behavior depends on FOV algorithm - - # Cells well behind the wall should not be visible - # (18, 10) is 6 cells behind the wall - assert (18, 10) not in visible, "Cell behind wall should not be visible" - - print(f" PASS: FOV with walls ({len(visible)} cells visible)") - - -def test_fov_radius(): - """Test FOV respects radius""" - print("Testing FOV radius...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (30, 30) - - # Compute small FOV - visible_small = viewport.compute_fov((15, 15), radius=3) - - # Compute larger FOV - visible_large = viewport.compute_fov((15, 15), radius=8) - - # Larger radius should reveal more cells - assert len(visible_large) > len(visible_small), \ - f"Larger radius should reveal more cells ({len(visible_large)} vs {len(visible_small)})" - - # Small FOV cells should be subset of large FOV - for cell in visible_small: - assert cell in visible_large, f"{cell} in small FOV but not in large FOV" - - print(f" PASS: FOV radius (small={len(visible_small)}, large={len(visible_large)})") - - -def test_is_in_fov(): - """Test is_in_fov() method""" - print("Testing is_in_fov...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (20, 20) - - # Compute FOV - viewport.compute_fov((10, 10), radius=5) - - # Check is_in_fov matches compute_fov results - assert viewport.is_in_fov(10, 10) == True, "Origin should be in FOV" - assert viewport.is_in_fov(10, 11) == True, "Adjacent cell should be in FOV" - assert viewport.is_in_fov(0, 0) == False, "Distant cell should not be in FOV" - - print(" PASS: is_in_fov method") - - -def test_fov_corner(): - """Test FOV from corner position""" - print("Testing FOV from corner...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (20, 20) - - # Compute FOV from corner - visible = viewport.compute_fov((0, 0), radius=5) - - # Origin should be visible - assert (0, 0) in visible, "Origin should be visible" - - # Cells in direction of grid should be visible - assert (1, 0) in visible, "(1, 0) should be visible" - assert (0, 1) in visible, "(0, 1) should be visible" - - # Should handle edge of grid gracefully - # Shouldn't crash or have negative coordinates - - print(f" PASS: FOV from corner ({len(visible)} cells visible)") - - -def test_fov_empty_grid(): - """Test FOV on uninitialized grid""" - print("Testing FOV on empty grid...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Grid size is 0x0 by default - # Compute FOV should return empty list or handle gracefully - visible = viewport.compute_fov((0, 0), radius=5) - - assert len(visible) == 0, "FOV on empty grid should return empty list" - - print(" PASS: FOV on empty grid") - - -def test_multiple_fov_calls(): - """Test that multiple FOV calls work correctly""" - print("Testing multiple FOV calls...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (20, 20) - - # First FOV from (5, 5) - visible1 = viewport.compute_fov((5, 5), radius=4) - assert (5, 5) in visible1, "First origin should be visible" - - # Second FOV from (15, 15) - visible2 = viewport.compute_fov((15, 15), radius=4) - assert (15, 15) in visible2, "Second origin should be visible" - - # is_in_fov should reflect the LAST computed FOV - assert viewport.is_in_fov(15, 15) == True, "Last origin should be in FOV" - # Note: (5, 5) might not be in FOV anymore depending on radius - - print(" PASS: Multiple FOV calls") - - -def run_all_tests(): - """Run all unit tests""" - print("=" * 60) - print("3D FOV Unit Tests") - print("=" * 60) - - try: - test_basic_fov() - test_fov_with_walls() - test_fov_radius() - test_is_in_fov() - test_fov_corner() - test_fov_empty_grid() - test_multiple_fov_calls() - - print("=" * 60) - print("ALL TESTS PASSED") - print("=" * 60) - sys.exit(0) - except AssertionError as e: - print(f"FAIL: {e}") - sys.exit(1) - except Exception as e: - print(f"ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -# Run tests -run_all_tests() diff --git a/tests/unit/integration_api_test.py b/tests/unit/integration_api_test.py deleted file mode 100644 index e1f6f74..0000000 --- a/tests/unit/integration_api_test.py +++ /dev/null @@ -1,78 +0,0 @@ -# integration_api_test.py - Test Milestone 8 API additions -# Tests: Entity3D.follow_path, .is_moving, .clear_path -# Viewport3D.screen_to_world, .follow - -import mcrfpy -import sys - -print("Testing Milestone 8 API additions...") - -# Create test scene -scene = mcrfpy.Scene("test") - -# Create viewport -viewport = mcrfpy.Viewport3D( - pos=(0, 0), - size=(800, 600), - render_resolution=(320, 240), - fov=60.0, - camera_pos=(10.0, 10.0, 10.0), - camera_target=(5.0, 0.0, 5.0) -) -scene.children.append(viewport) - -# Set up navigation grid -viewport.set_grid_size(20, 20) - -# Create entity -entity = mcrfpy.Entity3D(pos=(5, 5)) -viewport.entities.append(entity) - -# Test 1: is_moving property (should be False initially) -print(f"Test 1: is_moving = {entity.is_moving}") -assert entity.is_moving == False, "Entity should not be moving initially" -print(" PASS: is_moving is False initially") - -# Test 2: follow_path method -path = [(6, 5), (7, 5), (8, 5)] -entity.follow_path(path) -print(f"Test 2: follow_path({path})") -# After follow_path, entity should be moving (or at least have queued moves) -print(f" is_moving after follow_path = {entity.is_moving}") -assert entity.is_moving == True, "Entity should be moving after follow_path" -print(" PASS: follow_path queued movement") - -# Test 3: clear_path method -entity.clear_path() -print("Test 3: clear_path()") -print(f" is_moving after clear_path = {entity.is_moving}") -# Note: is_moving may still be True if animation is in progress -print(" PASS: clear_path executed without error") - -# Test 4: screen_to_world -world_pos = viewport.screen_to_world(400, 300) -print(f"Test 4: screen_to_world(400, 300) = {world_pos}") -if world_pos is None: - print(" WARNING: screen_to_world returned None (ray missed ground)") -else: - assert len(world_pos) == 3, "Should return (x, y, z) tuple" - print(f" PASS: Got world position {world_pos}") - -# Test 5: follow method -viewport.follow(entity, distance=8.0, height=5.0) -print("Test 5: follow(entity, distance=8, height=5)") -cam_pos = viewport.camera_pos -print(f" Camera position after follow: {cam_pos}") -print(" PASS: follow executed without error") - -# Test 6: path_to (existing method) -path = entity.path_to(10, 10) -print(f"Test 6: path_to(10, 10) = {path[:3]}..." if len(path) > 3 else f"Test 6: path_to(10, 10) = {path}") -print(" PASS: path_to works") - -print() -print("=" * 50) -print("All Milestone 8 API tests PASSED!") -print("=" * 50) - -sys.exit(0) diff --git a/tests/unit/mesh_instance_test.py b/tests/unit/mesh_instance_test.py deleted file mode 100644 index 05ac45a..0000000 --- a/tests/unit/mesh_instance_test.py +++ /dev/null @@ -1,182 +0,0 @@ -# mesh_instance_test.py - Unit test for MeshLayer mesh instances and Viewport3D mesh APIs - -import mcrfpy -import sys - -def test_viewport3d_add_mesh(): - """Test adding meshes to Viewport3D layers""" - vp = mcrfpy.Viewport3D() - - # Add a mesh layer first - vp.add_layer("ground", z_index=0) - - # Create a model to place (simple cube primitive) - model = mcrfpy.Model3D() - - # Add mesh instance at position - result = vp.add_mesh("ground", model, pos=(5.0, 0.0, 5.0)) - - # Should return the index of the added mesh - assert result is not None, "Expected add_mesh to return something" - assert isinstance(result, int), f"Expected int index, got {type(result)}" - assert result == 0, f"Expected first mesh index 0, got {result}" - - print("[PASS] test_viewport3d_add_mesh") - -def test_viewport3d_add_mesh_with_transform(): - """Test adding meshes with rotation and scale""" - vp = mcrfpy.Viewport3D() - vp.add_layer("buildings", z_index=0) - - model = mcrfpy.Model3D() - - # Add with rotation (in degrees as per API) - idx1 = vp.add_mesh("buildings", model, pos=(10.0, 0.0, 10.0), rotation=90) - assert idx1 == 0, f"Expected first mesh index 0, got {idx1}" - - # Add with scale - idx2 = vp.add_mesh("buildings", model, pos=(15.0, 0.0, 15.0), scale=2.0) - assert idx2 == 1, f"Expected second mesh index 1, got {idx2}" - - # Add with both rotation and scale - idx3 = vp.add_mesh("buildings", model, pos=(5.0, 0.0, 5.0), rotation=45, scale=0.5) - assert idx3 == 2, f"Expected third mesh index 2, got {idx3}" - - print("[PASS] test_viewport3d_add_mesh_with_transform") - -def test_viewport3d_clear_meshes(): - """Test clearing meshes from a layer""" - vp = mcrfpy.Viewport3D() - vp.add_layer("objects", z_index=0) - - model = mcrfpy.Model3D() - - # Add several meshes - vp.add_mesh("objects", model, pos=(1.0, 0.0, 1.0)) - vp.add_mesh("objects", model, pos=(2.0, 0.0, 2.0)) - vp.add_mesh("objects", model, pos=(3.0, 0.0, 3.0)) - - # Clear meshes from layer - vp.clear_meshes("objects") - - # Add a new mesh - should get index 0 since list was cleared - idx = vp.add_mesh("objects", model, pos=(0.0, 0.0, 0.0)) - assert idx == 0, f"Expected index 0 after clear, got {idx}" - - print("[PASS] test_viewport3d_clear_meshes") - -def test_viewport3d_place_blocking(): - """Test placing blocking information on the navigation grid""" - vp = mcrfpy.Viewport3D() - - # Initialize navigation grid first - vp.set_grid_size(width=16, depth=16) - - # Place blocking cell (unwalkable, non-transparent) - vp.place_blocking(grid_pos=(5, 5), footprint=(1, 1)) - - # Place larger blocking footprint - vp.place_blocking(grid_pos=(10, 10), footprint=(2, 2)) - - # Place blocking with custom walkability - vp.place_blocking(grid_pos=(0, 0), footprint=(3, 3), walkable=False, transparent=True) - - # Verify the cells were marked (check via VoxelPoint) - cell = vp.at(5, 5) - assert cell.walkable == False, f"Expected cell (5,5) unwalkable, got walkable={cell.walkable}" - - cell_transparent = vp.at(0, 0) - assert cell_transparent.transparent == True, f"Expected cell (0,0) transparent" - - print("[PASS] test_viewport3d_place_blocking") - -def test_viewport3d_mesh_layer_operations(): - """Test various mesh layer operations""" - vp = mcrfpy.Viewport3D() - - # Create multiple layers - vp.add_layer("floor", z_index=0) - vp.add_layer("walls", z_index=1) - vp.add_layer("props", z_index=2) - - model = mcrfpy.Model3D() - - # Add meshes to different layers - vp.add_mesh("floor", model, pos=(0.0, 0.0, 0.0)) - vp.add_mesh("walls", model, pos=(1.0, 1.0, 0.0), rotation=0, scale=1.5) - vp.add_mesh("props", model, pos=(2.0, 0.0, 2.0), scale=0.25) - - # Clear only one layer - vp.clear_meshes("walls") - - # Other layers should be unaffected - # (Can verify by adding to them and checking indices) - idx_floor = vp.add_mesh("floor", model, pos=(5.0, 0.0, 5.0)) - assert idx_floor == 1, f"Expected floor mesh index 1, got {idx_floor}" - - idx_walls = vp.add_mesh("walls", model, pos=(5.0, 0.0, 5.0)) - assert idx_walls == 0, f"Expected walls mesh index 0 after clear, got {idx_walls}" - - print("[PASS] test_viewport3d_mesh_layer_operations") - -def test_auto_layer_creation(): - """Test that add_mesh auto-creates layers if they don't exist""" - vp = mcrfpy.Viewport3D() - model = mcrfpy.Model3D() - - # Add mesh to a layer that doesn't exist yet - should auto-create it - idx = vp.add_mesh("auto_created", model, pos=(0.0, 0.0, 0.0)) - assert idx == 0, f"Expected index 0 for auto-created layer, got {idx}" - - # Verify the layer was created - layer = vp.get_layer("auto_created") - assert layer is not None, "Expected auto_created layer to exist" - - print("[PASS] test_auto_layer_creation") - -def test_invalid_layer_clear(): - """Test error handling for clearing non-existent layers""" - vp = mcrfpy.Viewport3D() - - # Try to clear meshes from non-existent layer - try: - vp.clear_meshes("nonexistent") - # If it doesn't raise, it might just silently succeed (which is fine too) - print("[PASS] test_invalid_layer_clear (no exception)") - return - except (ValueError, KeyError, RuntimeError): - print("[PASS] test_invalid_layer_clear (exception raised)") - return - -def run_all_tests(): - """Run all mesh instance tests""" - tests = [ - test_viewport3d_add_mesh, - test_viewport3d_add_mesh_with_transform, - test_viewport3d_clear_meshes, - test_viewport3d_place_blocking, - test_viewport3d_mesh_layer_operations, - test_auto_layer_creation, - test_invalid_layer_clear, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/meshlayer_test.py b/tests/unit/meshlayer_test.py deleted file mode 100644 index b734c88..0000000 --- a/tests/unit/meshlayer_test.py +++ /dev/null @@ -1,202 +0,0 @@ -# meshlayer_test.py - Unit tests for MeshLayer terrain system -# Tests HeightMap to 3D mesh conversion via Viewport3D - -import mcrfpy -import sys - -def test_viewport3d_layer_creation(): - """Test that layers can be created and managed""" - print("Testing Viewport3D layer creation...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Initial layer count should be 0 - assert viewport.layer_count() == 0, f"Expected 0 layers, got {viewport.layer_count()}" - - # Add a layer - layer_info = viewport.add_layer("test_layer", z_index=5) - assert layer_info is not None, "add_layer returned None" - assert layer_info["name"] == "test_layer", f"Layer name mismatch: {layer_info['name']}" - assert layer_info["z_index"] == 5, f"Z-index mismatch: {layer_info['z_index']}" - - # Layer count should be 1 - assert viewport.layer_count() == 1, f"Expected 1 layer, got {viewport.layer_count()}" - - # Get the layer - retrieved = viewport.get_layer("test_layer") - assert retrieved is not None, "get_layer returned None" - assert retrieved["name"] == "test_layer" - - # Get non-existent layer - missing = viewport.get_layer("nonexistent") - assert missing is None, "Expected None for missing layer" - - # Remove the layer - removed = viewport.remove_layer("test_layer") - assert removed == True, "remove_layer should return True" - assert viewport.layer_count() == 0, "Layer count should be 0 after removal" - - # Remove non-existent layer - removed_again = viewport.remove_layer("test_layer") - assert removed_again == False, "remove_layer should return False for missing layer" - - print(" PASS: Layer creation and management") - -def test_terrain_from_heightmap(): - """Test building terrain mesh from HeightMap""" - print("Testing terrain mesh from HeightMap...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Create a small heightmap - hm = mcrfpy.HeightMap((10, 10)) - hm.fill(0.5) # Flat terrain at 0.5 height - - # Build terrain - vertex_count = viewport.build_terrain( - layer_name="terrain", - heightmap=hm, - y_scale=2.0, - cell_size=1.0 - ) - - # Expected vertices: (10-1) x (10-1) quads x 2 triangles x 3 vertices = 9 * 9 * 6 = 486 - expected_verts = 9 * 9 * 6 - assert vertex_count == expected_verts, f"Expected {expected_verts} vertices, got {vertex_count}" - - # Verify layer exists - layer = viewport.get_layer("terrain") - assert layer is not None, "Terrain layer not found" - assert layer["vertex_count"] == expected_verts - - print(f" PASS: Built terrain with {vertex_count} vertices") - -def test_heightmap_terrain_generation(): - """Test that HeightMap generation methods work with terrain""" - print("Testing HeightMap generation methods...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Test midpoint displacement - hm = mcrfpy.HeightMap((17, 17)) # Power of 2 + 1 for midpoint displacement - hm.mid_point_displacement(0.5, seed=123) - hm.normalize(0.0, 1.0) - - min_h, max_h = hm.min_max() - assert min_h >= 0.0, f"Min height should be >= 0, got {min_h}" - assert max_h <= 1.0, f"Max height should be <= 1, got {max_h}" - - vertex_count = viewport.build_terrain("terrain", hm, y_scale=5.0, cell_size=1.0) - assert vertex_count > 0, "Should have vertices" - - print(f" PASS: Midpoint displacement terrain with {vertex_count} vertices") - -def test_orbit_camera(): - """Test camera orbit helper""" - print("Testing camera orbit...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Test orbit at different angles - import math - - viewport.orbit_camera(angle=0, distance=10, height=5) - pos = viewport.camera_pos - assert abs(pos[0] - 10.0) < 0.01, f"X should be 10 at angle=0, got {pos[0]}" - assert abs(pos[1] - 5.0) < 0.01, f"Y (height) should be 5, got {pos[1]}" - assert abs(pos[2]) < 0.01, f"Z should be 0 at angle=0, got {pos[2]}" - - viewport.orbit_camera(angle=math.pi/2, distance=10, height=5) - pos = viewport.camera_pos - assert abs(pos[0]) < 0.01, f"X should be 0 at angle=pi/2, got {pos[0]}" - assert abs(pos[2] - 10.0) < 0.01, f"Z should be 10 at angle=pi/2, got {pos[2]}" - - print(" PASS: Camera orbit positioning") - -def test_large_terrain(): - """Test larger terrain (performance check)""" - print("Testing larger terrain mesh...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # 80x45 is mentioned in the milestone doc - hm = mcrfpy.HeightMap((80, 45)) - hm.mid_point_displacement(0.5, seed=999) - hm.normalize(0.0, 1.0) - - vertex_count = viewport.build_terrain("large_terrain", hm, y_scale=4.0, cell_size=1.0) - - # Expected: 79 * 44 * 6 = 20,856 vertices - expected = 79 * 44 * 6 - assert vertex_count == expected, f"Expected {expected} vertices, got {vertex_count}" - - print(f" PASS: Large terrain ({80}x{45} heightmap) with {vertex_count} vertices") - -def test_terrain_color_map(): - """Test applying RGB color maps to terrain""" - print("Testing terrain color map...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Create small terrain - hm = mcrfpy.HeightMap((10, 10)) - hm.fill(0.5) - viewport.build_terrain("colored_terrain", hm, y_scale=2.0, cell_size=1.0) - - # Create RGB color maps - r_map = mcrfpy.HeightMap((10, 10)) - g_map = mcrfpy.HeightMap((10, 10)) - b_map = mcrfpy.HeightMap((10, 10)) - - # Fill with test colors (red terrain) - r_map.fill(1.0) - g_map.fill(0.0) - b_map.fill(0.0) - - # Apply colors - should not raise - viewport.apply_terrain_colors("colored_terrain", r_map, g_map, b_map) - - # Test with mismatched dimensions (should fail silently or raise) - wrong_size = mcrfpy.HeightMap((5, 5)) - wrong_size.fill(0.5) - # This should not crash, just do nothing due to dimension mismatch - viewport.apply_terrain_colors("colored_terrain", wrong_size, wrong_size, wrong_size) - - # Test with non-existent layer - try: - viewport.apply_terrain_colors("nonexistent", r_map, g_map, b_map) - assert False, "Should have raised ValueError for non-existent layer" - except ValueError: - pass # Expected - - print(" PASS: Terrain color map application") - -def run_all_tests(): - """Run all unit tests""" - print("=" * 60) - print("MeshLayer Unit Tests") - print("=" * 60) - - try: - test_viewport3d_layer_creation() - test_terrain_from_heightmap() - test_heightmap_terrain_generation() - test_orbit_camera() - test_large_terrain() - test_terrain_color_map() - - print("=" * 60) - print("ALL TESTS PASSED") - print("=" * 60) - sys.exit(0) - except AssertionError as e: - print(f"FAIL: {e}") - sys.exit(1) - except Exception as e: - print(f"ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - -# Run tests -run_all_tests() diff --git a/tests/unit/model3d_test.py b/tests/unit/model3d_test.py deleted file mode 100644 index 537dfa7..0000000 --- a/tests/unit/model3d_test.py +++ /dev/null @@ -1,219 +0,0 @@ -# model3d_test.py - Unit test for Model3D 3D model resource - -import mcrfpy -import sys - -def test_model3d_cube(): - """Test Model3D.cube() creates valid model""" - cube = mcrfpy.Model3D.cube(2.0) - - assert cube.name == "cube", f"Expected name='cube', got '{cube.name}'" - assert cube.vertex_count == 24, f"Expected 24 vertices, got {cube.vertex_count}" - assert cube.triangle_count == 12, f"Expected 12 triangles, got {cube.triangle_count}" - assert cube.has_skeleton == False, f"Expected has_skeleton=False, got {cube.has_skeleton}" - assert cube.mesh_count == 1, f"Expected 1 mesh, got {cube.mesh_count}" - - # Check bounds for size=2.0 cube - bounds = cube.bounds - assert bounds is not None, "Bounds should not be None" - min_b, max_b = bounds - assert min_b == (-1.0, -1.0, -1.0), f"Expected min=(-1,-1,-1), got {min_b}" - assert max_b == (1.0, 1.0, 1.0), f"Expected max=(1,1,1), got {max_b}" - - print("[PASS] test_model3d_cube") - -def test_model3d_cube_default_size(): - """Test Model3D.cube() with default size""" - cube = mcrfpy.Model3D.cube() - - # Default size is 1.0, so bounds should be -0.5 to 0.5 - bounds = cube.bounds - min_b, max_b = bounds - assert abs(min_b[0] - (-0.5)) < 0.001, f"Expected min.x=-0.5, got {min_b[0]}" - assert abs(max_b[0] - 0.5) < 0.001, f"Expected max.x=0.5, got {max_b[0]}" - - print("[PASS] test_model3d_cube_default_size") - -def test_model3d_plane(): - """Test Model3D.plane() creates valid model""" - plane = mcrfpy.Model3D.plane(4.0, 2.0, 2) - - assert plane.name == "plane", f"Expected name='plane', got '{plane.name}'" - # 2 segments = 3x3 grid = 9 vertices - assert plane.vertex_count == 9, f"Expected 9 vertices, got {plane.vertex_count}" - # 2x2 quads = 8 triangles - assert plane.triangle_count == 8, f"Expected 8 triangles, got {plane.triangle_count}" - assert plane.has_skeleton == False, f"Expected has_skeleton=False" - - # Bounds should be width/2, 0, depth/2 - bounds = plane.bounds - min_b, max_b = bounds - assert abs(min_b[0] - (-2.0)) < 0.001, f"Expected min.x=-2, got {min_b[0]}" - assert abs(max_b[0] - 2.0) < 0.001, f"Expected max.x=2, got {max_b[0]}" - assert abs(min_b[2] - (-1.0)) < 0.001, f"Expected min.z=-1, got {min_b[2]}" - assert abs(max_b[2] - 1.0) < 0.001, f"Expected max.z=1, got {max_b[2]}" - - print("[PASS] test_model3d_plane") - -def test_model3d_plane_default(): - """Test Model3D.plane() with default parameters""" - plane = mcrfpy.Model3D.plane() - - # Default is 1x1 with 1 segment = 4 vertices, 2 triangles - assert plane.vertex_count == 4, f"Expected 4 vertices, got {plane.vertex_count}" - assert plane.triangle_count == 2, f"Expected 2 triangles, got {plane.triangle_count}" - - print("[PASS] test_model3d_plane_default") - -def test_model3d_sphere(): - """Test Model3D.sphere() creates valid model""" - sphere = mcrfpy.Model3D.sphere(1.0, 8, 6) - - assert sphere.name == "sphere", f"Expected name='sphere', got '{sphere.name}'" - # vertices = (segments+1) * (rings+1) = 9 * 7 = 63 - assert sphere.vertex_count == 63, f"Expected 63 vertices, got {sphere.vertex_count}" - # triangles = 2 * segments * rings = 2 * 8 * 6 = 96 - assert sphere.triangle_count == 96, f"Expected 96 triangles, got {sphere.triangle_count}" - - # Bounds should be radius in all directions - bounds = sphere.bounds - min_b, max_b = bounds - assert abs(min_b[0] - (-1.0)) < 0.001, f"Expected min.x=-1, got {min_b[0]}" - assert abs(max_b[0] - 1.0) < 0.001, f"Expected max.x=1, got {max_b[0]}" - - print("[PASS] test_model3d_sphere") - -def test_model3d_sphere_default(): - """Test Model3D.sphere() with default parameters""" - sphere = mcrfpy.Model3D.sphere() - - # Default radius=0.5, segments=16, rings=12 - # vertices = 17 * 13 = 221 - assert sphere.vertex_count == 221, f"Expected 221 vertices, got {sphere.vertex_count}" - # triangles = 2 * 16 * 12 = 384 - assert sphere.triangle_count == 384, f"Expected 384 triangles, got {sphere.triangle_count}" - - print("[PASS] test_model3d_sphere_default") - -def test_model3d_empty(): - """Test creating empty Model3D""" - empty = mcrfpy.Model3D() - - assert empty.name == "unnamed", f"Expected name='unnamed', got '{empty.name}'" - assert empty.vertex_count == 0, f"Expected 0 vertices, got {empty.vertex_count}" - assert empty.triangle_count == 0, f"Expected 0 triangles, got {empty.triangle_count}" - assert empty.mesh_count == 0, f"Expected 0 meshes, got {empty.mesh_count}" - - print("[PASS] test_model3d_empty") - -def test_model3d_repr(): - """Test Model3D string representation""" - cube = mcrfpy.Model3D.cube() - repr_str = repr(cube) - - assert "Model3D" in repr_str, f"Expected 'Model3D' in repr, got {repr_str}" - assert "cube" in repr_str, f"Expected 'cube' in repr, got {repr_str}" - assert "24" in repr_str, f"Expected vertex count in repr, got {repr_str}" - - print("[PASS] test_model3d_repr") - -def test_entity3d_model_property(): - """Test Entity3D.model property""" - e = mcrfpy.Entity3D(pos=(0, 0)) - - # Initially no model - assert e.model is None, f"Expected model=None, got {e.model}" - - # Assign model - cube = mcrfpy.Model3D.cube() - e.model = cube - assert e.model is not None, "Expected model to be set" - assert e.model.name == "cube", f"Expected model.name='cube', got {e.model.name}" - - # Swap model - sphere = mcrfpy.Model3D.sphere() - e.model = sphere - assert e.model.name == "sphere", f"Expected model.name='sphere', got {e.model.name}" - - # Clear model - e.model = None - assert e.model is None, f"Expected model=None after clearing" - - print("[PASS] test_entity3d_model_property") - -def test_entity3d_model_type_error(): - """Test Entity3D.model raises TypeError for invalid input""" - e = mcrfpy.Entity3D() - - try: - e.model = "not a model" - print("[FAIL] test_entity3d_model_type_error: Expected TypeError") - return - except TypeError: - pass - - try: - e.model = 123 - print("[FAIL] test_entity3d_model_type_error: Expected TypeError") - return - except TypeError: - pass - - print("[PASS] test_entity3d_model_type_error") - -def test_entity3d_with_model_in_viewport(): - """Test Entity3D with model in a Viewport3D""" - vp = mcrfpy.Viewport3D() - vp.set_grid_size(16, 16) - - # Create entity with model - cube = mcrfpy.Model3D.cube(0.5) - e = mcrfpy.Entity3D(pos=(8, 8)) - e.model = cube - - # Add to viewport - vp.entities.append(e) - - # Verify model is preserved - retrieved = vp.entities[0] - assert retrieved.model is not None, "Expected model to be preserved" - assert retrieved.model.name == "cube", f"Expected model.name='cube', got {retrieved.model.name}" - - print("[PASS] test_entity3d_with_model_in_viewport") - -def run_all_tests(): - """Run all Model3D tests""" - tests = [ - test_model3d_cube, - test_model3d_cube_default_size, - test_model3d_plane, - test_model3d_plane_default, - test_model3d_sphere, - test_model3d_sphere_default, - test_model3d_empty, - test_model3d_repr, - test_entity3d_model_property, - test_entity3d_model_type_error, - test_entity3d_with_model_in_viewport, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/pathfinding_3d_test.py b/tests/unit/pathfinding_3d_test.py deleted file mode 100644 index 8985b30..0000000 --- a/tests/unit/pathfinding_3d_test.py +++ /dev/null @@ -1,208 +0,0 @@ -# pathfinding_3d_test.py - Unit tests for 3D pathfinding -# Tests A* pathfinding on VoxelPoint navigation grid - -import mcrfpy -import sys - -def test_simple_path(): - """Test pathfinding on an open grid""" - print("Testing simple pathfinding...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Find path from corner to corner - path = viewport.find_path((0, 0), (9, 9)) - - # Should find a path - assert len(path) > 0, "Expected a path, got empty list" - - # Path should end at destination (start is not included) - assert path[-1] == (9, 9), f"Path should end at (9, 9), got {path[-1]}" - - # Path length should be reasonable (diagonal allows shorter paths) - # Manhattan distance is 18, but with diagonals it can be ~9-14 steps - assert len(path) >= 9 and len(path) <= 18, f"Path length {len(path)} is unexpected" - - print(f" PASS: Simple pathfinding ({len(path)} steps)") - - -def test_path_with_obstacles(): - """Test pathfinding around obstacles""" - print("Testing pathfinding with obstacles...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Create a wall blocking direct path - # Wall from (4, 0) to (4, 8) - for z in range(9): - viewport.at(4, z).walkable = False - - # Find path from left side to right side - path = viewport.find_path((2, 5), (7, 5)) - - # Should find a path (going around the wall via z=9) - assert len(path) > 0, "Expected a path around the wall" - - # Verify path doesn't go through wall - for x, z in path: - if x == 4 and z < 9: - assert False, f"Path goes through wall at ({x}, {z})" - - print(f" PASS: Pathfinding with obstacles ({len(path)} steps)") - - -def test_no_path(): - """Test pathfinding when no path exists""" - print("Testing no path scenario...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Create a complete wall blocking all paths - # Wall from (5, 0) to (5, 9) - blocks entire grid - for z in range(10): - viewport.at(5, z).walkable = False - - # Try to find path from left to right - path = viewport.find_path((2, 5), (7, 5)) - - # Should return empty list (no path) - assert len(path) == 0, f"Expected empty path, got {len(path)} steps" - - print(" PASS: No path returns empty list") - - -def test_start_equals_end(): - """Test pathfinding when start equals end""" - print("Testing start equals end...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Find path to same location - path = viewport.find_path((5, 5), (5, 5)) - - # Should return empty path (already there) - assert len(path) == 0, f"Expected empty path for start==end, got {len(path)} steps" - - print(" PASS: Start equals end") - - -def test_adjacent_path(): - """Test pathfinding to adjacent cell""" - print("Testing adjacent cell pathfinding...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Find path to adjacent cell - path = viewport.find_path((5, 5), (5, 6)) - - # Should be a single step - assert len(path) == 1, f"Expected 1 step, got {len(path)}" - assert path[0] == (5, 6), f"Expected (5, 6), got {path[0]}" - - print(" PASS: Adjacent cell pathfinding") - - -def test_heightmap_threshold(): - """Test apply_threshold sets walkability""" - print("Testing HeightMap threshold...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Create a heightmap - hm = mcrfpy.HeightMap((10, 10)) - - # Set heights: left half low (0.2), right half high (0.8) - for z in range(10): - for x in range(5): - hm[x, z] = 0.2 - for x in range(5, 10): - hm[x, z] = 0.8 - - # Initialize grid - viewport.grid_size = (10, 10) - - # Apply threshold: mark high areas (>0.6) as unwalkable - viewport.apply_threshold(hm, 0.6, 1.0, walkable=False) - - # Check left side is walkable - assert viewport.at(2, 5).walkable == True, "Left side should be walkable" - - # Check right side is unwalkable - assert viewport.at(7, 5).walkable == False, "Right side should be unwalkable" - - # Pathfinding should fail to cross - path = viewport.find_path((2, 5), (7, 5)) - assert len(path) == 0, "Path should not exist through unwalkable terrain" - - print(" PASS: HeightMap threshold") - - -def test_slope_cost(): - """Test slope cost calculation""" - print("Testing slope cost...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Create terrain with a steep slope - # Set heights manually - for z in range(10): - for x in range(10): - viewport.at(x, z).height = 0.0 - - # Create a cliff at x=5 - for z in range(10): - for x in range(5, 10): - viewport.at(x, z).height = 2.0 # 2.0 units high - - # Apply slope cost: max slope 0.5, mark steeper as unwalkable - viewport.set_slope_cost(max_slope=0.5, cost_multiplier=2.0) - - # Check that cells at the cliff edge are marked unwalkable - # Cell at (4, 5) borders (5, 5) which is 2.0 higher - assert viewport.at(4, 5).walkable == False, "Cliff edge should be unwalkable" - assert viewport.at(5, 5).walkable == False, "Cliff top edge should be unwalkable" - - # Cells away from cliff should still be walkable - assert viewport.at(0, 5).walkable == True, "Flat area should be walkable" - assert viewport.at(9, 5).walkable == True, "Flat high area should be walkable" - - print(" PASS: Slope cost") - - -def run_all_tests(): - """Run all unit tests""" - print("=" * 60) - print("3D Pathfinding Unit Tests") - print("=" * 60) - - try: - test_simple_path() - test_path_with_obstacles() - test_no_path() - test_start_equals_end() - test_adjacent_path() - test_heightmap_threshold() - test_slope_cost() - - print("=" * 60) - print("ALL TESTS PASSED") - print("=" * 60) - sys.exit(0) - except AssertionError as e: - print(f"FAIL: {e}") - sys.exit(1) - except Exception as e: - print(f"ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -# Run tests -run_all_tests() diff --git a/tests/unit/procgen_interactive_test.py b/tests/unit/procgen_interactive_test.py deleted file mode 100644 index 69fb8a4..0000000 --- a/tests/unit/procgen_interactive_test.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for the Interactive Procedural Generation Demo System. - -Tests: -- Demo creation and initialization -- Step execution (forward/backward) -- Parameter changes and regeneration -- Layer visibility toggling -- State snapshot capture/restore -""" - -import sys -import os - -# Add tests directory to path -tests_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if tests_dir not in sys.path: - sys.path.insert(0, tests_dir) - -import mcrfpy -from procgen_interactive.demos.cave_demo import CaveDemo -from procgen_interactive.demos.dungeon_demo import DungeonDemo -from procgen_interactive.demos.terrain_demo import TerrainDemo -from procgen_interactive.demos.town_demo import TownDemo - - -def test_cave_demo(): - """Test Cave demo creation and stepping.""" - print("Testing CaveDemo...") - - demo = CaveDemo() - demo.activate() - - # Run all steps - for i in range(len(demo.steps)): - demo.advance_step() - assert demo.current_step == i + 1, f"Step count mismatch: {demo.current_step} != {i + 1}" - - # Test backward navigation - demo.reverse_step() - assert demo.current_step == len(demo.steps) - 1, "Reverse step failed" - - print(" CaveDemo OK") - return True - - -def test_dungeon_demo(): - """Test Dungeon demo creation and stepping.""" - print("Testing DungeonDemo...") - - demo = DungeonDemo() - demo.activate() - - # Run all steps - for i in range(len(demo.steps)): - demo.advance_step() - - assert demo.current_step == len(demo.steps), "Step count mismatch" - print(" DungeonDemo OK") - return True - - -def test_terrain_demo(): - """Test Terrain demo creation and stepping.""" - print("Testing TerrainDemo...") - - demo = TerrainDemo() - demo.activate() - - # Run all steps - for i in range(len(demo.steps)): - demo.advance_step() - - assert demo.current_step == len(demo.steps), "Step count mismatch" - print(" TerrainDemo OK") - return True - - -def test_town_demo(): - """Test Town demo creation and stepping.""" - print("Testing TownDemo...") - - demo = TownDemo() - demo.activate() - - # Run all steps - for i in range(len(demo.steps)): - demo.advance_step() - - assert demo.current_step == len(demo.steps), "Step count mismatch" - print(" TownDemo OK") - return True - - -def test_parameter_change(demo=None): - """Test that parameter changes trigger regeneration.""" - print("Testing parameter changes...") - - # Reuse existing demo if provided (to avoid scene name conflict) - if demo is None: - demo = CaveDemo() - demo.activate() - - # Change a parameter - seed_param = demo.parameters["seed"] - original_seed = seed_param.value - - # Test parameter value change - seed_param.value = original_seed + 1 - assert seed_param.value == original_seed + 1, "Parameter value not updated" - - # Test parameter bounds - seed_param.value = -10 # Should clamp to min (0) - assert seed_param.value >= 0, "Parameter min bound not enforced" - - # Test increment/decrement - seed_param.value = 100 - old_val = seed_param.value - seed_param.increment() - assert seed_param.value > old_val, "Increment failed" - - seed_param.decrement() - assert seed_param.value == old_val, "Decrement failed" - - print(" Parameter changes OK") - return True - - -def test_layer_visibility(demo=None): - """Test layer visibility toggling.""" - print("Testing layer visibility...") - - # Reuse existing demo if provided (to avoid scene name conflict) - if demo is None: - demo = CaveDemo() - demo.activate() - - # Get a layer - final_layer = demo.get_layer("final") - assert final_layer is not None, "Layer not found" - - # Test visibility toggle - original_visible = final_layer.visible - final_layer.visible = not original_visible - assert final_layer.visible == (not original_visible), "Visibility not toggled" - - # Toggle back - final_layer.visible = original_visible - assert final_layer.visible == original_visible, "Visibility not restored" - - print(" Layer visibility OK") - return True - - -def main(): - """Run all tests.""" - print("=" * 50) - print("Interactive Procgen Demo System Tests") - print("=" * 50) - print() - - passed = 0 - failed = 0 - - # Demo creation tests - demo_tests = [ - ("test_cave_demo", test_cave_demo), - ("test_dungeon_demo", test_dungeon_demo), - ("test_terrain_demo", test_terrain_demo), - ("test_town_demo", test_town_demo), - ] - - # Create a fresh cave demo for parameter/layer tests - cave_demo = None - - for name, test in demo_tests: - try: - if test(): - passed += 1 - # Save cave demo for later tests - if name == "test_cave_demo": - cave_demo = CaveDemo.__last_instance__ if hasattr(CaveDemo, '__last_instance__') else None - else: - failed += 1 - print(f" FAILED: {name}") - except Exception as e: - failed += 1 - print(f" ERROR in {name}: {e}") - import traceback - traceback.print_exc() - - # Parameter and layer tests use the last cave demo created - # (or create a new one if cave test didn't run) - try: - # These tests are about the parameter/layer system, not demo creation - # We test with the first cave demo's parameters and layers - from procgen_interactive.core.parameter import Parameter - - print("Testing parameter system...") - p = Parameter(name="test", display="Test", type="int", default=50, min_val=0, max_val=100) - p.value = 75 - assert p.value == 75, "Parameter set failed" - p.increment() - assert p.value == 76, "Increment failed" - p.value = -10 - assert p.value == 0, "Min bound not enforced" - p.value = 200 - assert p.value == 100, "Max bound not enforced" - print(" Parameter system OK") - passed += 1 - - print("Testing float parameter...") - p = Parameter(name="test", display="Test", type="float", default=0.5, min_val=0.0, max_val=1.0, step=0.1) - p.value = 0.7 - assert abs(p.value - 0.7) < 0.001, "Float parameter set failed" - p.increment() - assert abs(p.value - 0.8) < 0.001, "Float increment failed" - print(" Float parameter OK") - passed += 1 - - except Exception as e: - failed += 1 - print(f" ERROR in parameter tests: {e}") - import traceback - traceback.print_exc() - - print() - print("=" * 50) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 50) - - if failed == 0: - print("PASS") - sys.exit(0) - else: - print("FAIL") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/tests/unit/skeleton_test.py b/tests/unit/skeleton_test.py deleted file mode 100644 index 6b96a8e..0000000 --- a/tests/unit/skeleton_test.py +++ /dev/null @@ -1,80 +0,0 @@ -# skeleton_test.py - Unit tests for skeletal animation in Model3D - -import mcrfpy -import sys - -def test_model_skeleton_default(): - """Test that procedural models don't have skeletons""" - cube = mcrfpy.Model3D.cube(1.0) - - assert cube.has_skeleton == False, f"Expected cube.has_skeleton=False, got {cube.has_skeleton}" - assert cube.bone_count == 0, f"Expected cube.bone_count=0, got {cube.bone_count}" - assert cube.animation_clips == [], f"Expected empty animation_clips, got {cube.animation_clips}" - - print("[PASS] test_model_skeleton_default") - -def test_model_animation_clips_empty(): - """Test that models without skeleton have no animation clips""" - sphere = mcrfpy.Model3D.sphere(0.5) - - clips = sphere.animation_clips - assert isinstance(clips, list), f"Expected list, got {type(clips)}" - assert len(clips) == 0, f"Expected 0 clips, got {len(clips)}" - - print("[PASS] test_model_animation_clips_empty") - -def test_model_properties(): - """Test Model3D skeleton-related property access""" - plane = mcrfpy.Model3D.plane(2.0, 2.0) - - # These should all work without error - _ = plane.has_skeleton - _ = plane.bone_count - _ = plane.animation_clips - _ = plane.name - _ = plane.vertex_count - _ = plane.triangle_count - _ = plane.mesh_count - _ = plane.bounds - - print("[PASS] test_model_properties") - -def test_model_repr_no_skeleton(): - """Test Model3D repr for non-skeletal model""" - cube = mcrfpy.Model3D.cube() - r = repr(cube) - - assert "Model3D" in r, f"Expected 'Model3D' in repr, got {r}" - assert "skeletal" not in r, f"Non-skeletal model should not say 'skeletal' in repr" - - print("[PASS] test_model_repr_no_skeleton") - -def run_all_tests(): - """Run all skeleton tests""" - tests = [ - test_model_skeleton_default, - test_model_animation_clips_empty, - test_model_properties, - test_model_repr_no_skeleton, - ] - - passed = 0 - failed = 0 - - for test in tests: - try: - test() - passed += 1 - except AssertionError as e: - print(f"[FAIL] {test.__name__}: {e}") - failed += 1 - except Exception as e: - print(f"[ERROR] {test.__name__}: {e}") - failed += 1 - - print(f"\n=== Results: {passed} passed, {failed} failed ===") - return failed == 0 - -if __name__ == "__main__": - success = run_all_tests() - sys.exit(0 if success else 1) diff --git a/tests/unit/tilemap_file_test.py b/tests/unit/tilemap_file_test.py deleted file mode 100644 index 2aad7d7..0000000 --- a/tests/unit/tilemap_file_test.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Unit tests for mcrfpy.TileMapFile - Tiled tilemap loading""" -import mcrfpy -import sys - -PASS_COUNT = 0 -FAIL_COUNT = 0 - -def check(condition, msg): - global PASS_COUNT, FAIL_COUNT - if condition: - PASS_COUNT += 1 - print(f" PASS: {msg}") - else: - FAIL_COUNT += 1 - print(f" FAIL: {msg}") - -def test_tmx_loading(): - """Test loading a .tmx map""" - print("=== TMX Loading ===") - tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmx") - check(tm.width == 4, f"width = {tm.width}") - check(tm.height == 4, f"height = {tm.height}") - check(tm.tile_width == 16, f"tile_width = {tm.tile_width}") - check(tm.tile_height == 16, f"tile_height = {tm.tile_height}") - check(tm.orientation == "orthogonal", f"orientation = '{tm.orientation}'") - return tm - -def test_tmj_loading(): - """Test loading a .tmj map""" - print("\n=== TMJ Loading ===") - tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmj") - check(tm.width == 4, f"width = {tm.width}") - check(tm.height == 4, f"height = {tm.height}") - check(tm.tile_width == 16, f"tile_width = {tm.tile_width}") - check(tm.tile_height == 16, f"tile_height = {tm.tile_height}") - return tm - -def test_map_properties(tm): - """Test map properties""" - print("\n=== Map Properties ===") - props = tm.properties - check(isinstance(props, dict), f"properties is dict: {type(props)}") - check(props.get("map_name") == "test", f"map_name = '{props.get('map_name')}'") - -def test_tileset_references(tm): - """Test tileset references""" - print("\n=== Tileset References ===") - check(tm.tileset_count == 1, f"tileset_count = {tm.tileset_count}") - - firstgid, ts = tm.tileset(0) - check(firstgid == 1, f"firstgid = {firstgid}") - check(isinstance(ts, mcrfpy.TileSetFile), f"tileset is TileSetFile: {type(ts)}") - check(ts.name == "test_tileset", f"tileset name = '{ts.name}'") - check(ts.tile_count == 16, f"tileset tile_count = {ts.tile_count}") - -def test_tile_layer_names(tm): - """Test tile layer name listing""" - print("\n=== Layer Names ===") - names = tm.tile_layer_names - check(len(names) == 2, f"tile_layer count = {len(names)}") - check("Ground" in names, f"'Ground' in names: {names}") - check("Overlay" in names, f"'Overlay' in names: {names}") - - obj_names = tm.object_layer_names - check(len(obj_names) == 1, f"object_layer count = {len(obj_names)}") - check("Objects" in obj_names, f"'Objects' in obj_names: {obj_names}") - -def test_tile_layer_data(tm): - """Test raw tile layer data access""" - print("\n=== Tile Layer Data ===") - ground = tm.tile_layer_data("Ground") - check(len(ground) == 16, f"Ground layer length = {len(ground)}") - # First row: 1,2,1,1 (GIDs) - check(ground[0] == 1, f"ground[0] = {ground[0]}") - check(ground[1] == 2, f"ground[1] = {ground[1]}") - check(ground[2] == 1, f"ground[2] = {ground[2]}") - check(ground[3] == 1, f"ground[3] = {ground[3]}") - - overlay = tm.tile_layer_data("Overlay") - check(len(overlay) == 16, f"Overlay layer length = {len(overlay)}") - # First row all zeros (empty) - check(overlay[0] == 0, f"overlay[0] = {overlay[0]} (empty)") - # Second row: 0,9,10,0 - check(overlay[5] == 9, f"overlay[5] = {overlay[5]}") - - try: - tm.tile_layer_data("nonexistent") - check(False, "tile_layer_data('nonexistent') should raise KeyError") - except KeyError: - check(True, "tile_layer_data('nonexistent') raises KeyError") - -def test_resolve_gid(tm): - """Test GID resolution""" - print("\n=== GID Resolution ===") - # GID 0 = empty - ts_idx, local_id = tm.resolve_gid(0) - check(ts_idx == -1, f"GID 0: ts_idx = {ts_idx}") - check(local_id == -1, f"GID 0: local_id = {local_id}") - - # GID 1 = first tileset (firstgid=1), local_id=0 - ts_idx, local_id = tm.resolve_gid(1) - check(ts_idx == 0, f"GID 1: ts_idx = {ts_idx}") - check(local_id == 0, f"GID 1: local_id = {local_id}") - - # GID 2 = first tileset, local_id=1 - ts_idx, local_id = tm.resolve_gid(2) - check(ts_idx == 0, f"GID 2: ts_idx = {ts_idx}") - check(local_id == 1, f"GID 2: local_id = {local_id}") - - # GID 9 = first tileset, local_id=8 - ts_idx, local_id = tm.resolve_gid(9) - check(ts_idx == 0, f"GID 9: ts_idx = {ts_idx}") - check(local_id == 8, f"GID 9: local_id = {local_id}") - -def test_object_layer(tm): - """Test object layer access""" - print("\n=== Object Layer ===") - objects = tm.object_layer("Objects") - check(isinstance(objects, list), f"objects is list: {type(objects)}") - check(len(objects) == 2, f"object count = {len(objects)}") - - # Find spawn point - spawn = None - trigger = None - for obj in objects: - if obj.get("name") == "spawn": - spawn = obj - elif obj.get("name") == "trigger_zone": - trigger = obj - - check(spawn is not None, "spawn object found") - if spawn: - check(spawn.get("x") == 32, f"spawn x = {spawn.get('x')}") - check(spawn.get("y") == 32, f"spawn y = {spawn.get('y')}") - check(spawn.get("point") == True, f"spawn is point") - props = spawn.get("properties", {}) - check(props.get("player_start") == True, f"player_start = {props.get('player_start')}") - - check(trigger is not None, "trigger_zone object found") - if trigger: - check(trigger.get("width") == 64, f"trigger width = {trigger.get('width')}") - check(trigger.get("height") == 64, f"trigger height = {trigger.get('height')}") - props = trigger.get("properties", {}) - check(props.get("zone_id") == 42, f"zone_id = {props.get('zone_id')}") - - try: - tm.object_layer("nonexistent") - check(False, "object_layer('nonexistent') should raise KeyError") - except KeyError: - check(True, "object_layer('nonexistent') raises KeyError") - -def test_error_handling(): - """Test error cases""" - print("\n=== Error Handling ===") - try: - mcrfpy.TileMapFile("nonexistent.tmx") - check(False, "Missing file should raise IOError") - except IOError: - check(True, "Missing file raises IOError") - -def test_repr(tm): - """Test repr""" - print("\n=== Repr ===") - r = repr(tm) - check("TileMapFile" in r, f"repr contains 'TileMapFile': {r}") - check("4x4" in r, f"repr contains dimensions: {r}") - -def main(): - tm_tmx = test_tmx_loading() - tm_tmj = test_tmj_loading() - test_map_properties(tm_tmx) - test_tileset_references(tm_tmx) - test_tile_layer_names(tm_tmx) - test_tile_layer_data(tm_tmx) - test_resolve_gid(tm_tmx) - test_object_layer(tm_tmx) - test_error_handling() - test_repr(tm_tmx) - - print(f"\n{'='*40}") - print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") - if FAIL_COUNT > 0: - sys.exit(1) - else: - print("ALL TESTS PASSED") - sys.exit(0) - -if __name__ == "__main__": - main() diff --git a/tests/unit/tileset_file_test.py b/tests/unit/tileset_file_test.py deleted file mode 100644 index 83d3ac7..0000000 --- a/tests/unit/tileset_file_test.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Unit tests for mcrfpy.TileSetFile - Tiled tileset loading""" -import mcrfpy -import sys -import os - -PASS_COUNT = 0 -FAIL_COUNT = 0 - -def check(condition, msg): - global PASS_COUNT, FAIL_COUNT - if condition: - PASS_COUNT += 1 - print(f" PASS: {msg}") - else: - FAIL_COUNT += 1 - print(f" FAIL: {msg}") - -def test_tsx_loading(): - """Test loading a .tsx tileset""" - print("=== TSX Loading ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - check(ts.name == "test_tileset", f"name = '{ts.name}'") - check(ts.tile_width == 16, f"tile_width = {ts.tile_width}") - check(ts.tile_height == 16, f"tile_height = {ts.tile_height}") - check(ts.tile_count == 16, f"tile_count = {ts.tile_count}") - check(ts.columns == 4, f"columns = {ts.columns}") - check(ts.margin == 0, f"margin = {ts.margin}") - check(ts.spacing == 0, f"spacing = {ts.spacing}") - check("test_tileset.png" in ts.image_source, f"image_source contains PNG: {ts.image_source}") - return ts - -def test_tsj_loading(): - """Test loading a .tsj tileset""" - print("\n=== TSJ Loading ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsj") - check(ts.name == "test_tileset", f"name = '{ts.name}'") - check(ts.tile_width == 16, f"tile_width = {ts.tile_width}") - check(ts.tile_height == 16, f"tile_height = {ts.tile_height}") - check(ts.tile_count == 16, f"tile_count = {ts.tile_count}") - check(ts.columns == 4, f"columns = {ts.columns}") - return ts - -def test_properties(ts): - """Test tileset properties""" - print("\n=== Properties ===") - props = ts.properties - check(isinstance(props, dict), f"properties is dict: {type(props)}") - check(props.get("author") == "test", f"author = '{props.get('author')}'") - check(props.get("version") == 1, f"version = {props.get('version')}") - -def test_tile_info(ts): - """Test per-tile metadata""" - print("\n=== Tile Info ===") - info = ts.tile_info(0) - check(info is not None, "tile_info(0) exists") - check("properties" in info, "has 'properties' key") - check("animation" in info, "has 'animation' key") - check(info["properties"].get("terrain") == "grass", f"terrain = '{info['properties'].get('terrain')}'") - check(info["properties"].get("walkable") == True, f"walkable = {info['properties'].get('walkable')}") - check(len(info["animation"]) == 2, f"animation frames = {len(info['animation'])}") - check(info["animation"][0] == (0, 500), f"frame 0 = {info['animation'][0]}") - check(info["animation"][1] == (4, 500), f"frame 1 = {info['animation'][1]}") - - info1 = ts.tile_info(1) - check(info1 is not None, "tile_info(1) exists") - check(info1["properties"].get("terrain") == "dirt", f"terrain = '{info1['properties'].get('terrain')}'") - check(len(info1["animation"]) == 0, "tile 1 has no animation") - - info_none = ts.tile_info(5) - check(info_none is None, "tile_info(5) returns None (no metadata)") - -def test_wang_sets(ts): - """Test Wang set access""" - print("\n=== Wang Sets ===") - wang_sets = ts.wang_sets - check(len(wang_sets) == 1, f"wang_sets count = {len(wang_sets)}") - - ws = wang_sets[0] - check(ws.name == "terrain", f"wang set name = '{ws.name}'") - check(ws.type == "corner", f"wang set type = '{ws.type}'") - check(ws.color_count == 2, f"color_count = {ws.color_count}") - - colors = ws.colors - check(len(colors) == 2, f"colors length = {len(colors)}") - check(colors[0]["name"] == "Grass", f"color 0 name = '{colors[0]['name']}'") - check(colors[0]["index"] == 1, f"color 0 index = {colors[0]['index']}") - check(colors[1]["name"] == "Dirt", f"color 1 name = '{colors[1]['name']}'") - check(colors[1]["index"] == 2, f"color 1 index = {colors[1]['index']}") - -def test_wang_set_lookup(ts): - """Test wang_set() method""" - print("\n=== Wang Set Lookup ===") - ws = ts.wang_set("terrain") - check(ws.name == "terrain", "wang_set('terrain') found") - - try: - ts.wang_set("nonexistent") - check(False, "wang_set('nonexistent') should raise KeyError") - except KeyError: - check(True, "wang_set('nonexistent') raises KeyError") - -def test_to_texture(ts): - """Test texture creation""" - print("\n=== to_texture ===") - tex = ts.to_texture() - check(tex is not None, "to_texture() returns a Texture") - check(isinstance(tex, mcrfpy.Texture), f"is Texture: {type(tex)}") - -def test_error_handling(): - """Test error cases""" - print("\n=== Error Handling ===") - try: - mcrfpy.TileSetFile("nonexistent.tsx") - check(False, "Missing file should raise IOError") - except IOError: - check(True, "Missing file raises IOError") - -def test_repr(ts): - """Test repr""" - print("\n=== Repr ===") - r = repr(ts) - check("TileSetFile" in r, f"repr contains 'TileSetFile': {r}") - check("test_tileset" in r, f"repr contains name: {r}") - -def main(): - ts_tsx = test_tsx_loading() - ts_tsj = test_tsj_loading() - test_properties(ts_tsx) - test_tile_info(ts_tsx) - test_wang_sets(ts_tsx) - test_wang_set_lookup(ts_tsx) - test_to_texture(ts_tsx) - test_error_handling() - test_repr(ts_tsx) - - print(f"\n{'='*40}") - print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") - if FAIL_COUNT > 0: - sys.exit(1) - else: - print("ALL TESTS PASSED") - sys.exit(0) - -if __name__ == "__main__": - main() diff --git a/tests/unit/voxel_bulk_ops_test.py b/tests/unit/voxel_bulk_ops_test.py deleted file mode 100644 index 614dcf6..0000000 --- a/tests/unit/voxel_bulk_ops_test.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for VoxelGrid bulk operations (Milestone 11) - -Tests: -- fill_box_hollow: Verify shell only, interior empty -- fill_sphere: Volume roughly matches (4/3)πr³ -- fill_cylinder: Volume roughly matches πr²h -- fill_noise: Higher threshold = fewer voxels -- copy_region/paste_region: Round-trip verification -- skip_air option for paste -""" -import sys -import math - -# Track test results -passed = 0 -failed = 0 - -def test(name, condition, detail=""): - """Record test result""" - global passed, failed - if condition: - print(f"[PASS] {name}") - passed += 1 - else: - print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) - failed += 1 - -def test_fill_box_hollow_basic(): - """fill_box_hollow creates correct shell""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Create hollow 6x6x6 box with thickness 1 - vg.fill_box_hollow((2, 2, 2), (7, 7, 7), stone, thickness=1) - - # Total box = 6x6x6 = 216 - # Interior = 4x4x4 = 64 - # Shell = 216 - 64 = 152 - expected = 152 - actual = vg.count_non_air() - test("Hollow box: shell has correct voxel count", actual == expected, - f"got {actual}, expected {expected}") - - # Verify interior is empty (center should be air) - test("Hollow box: interior is air", vg.get(4, 4, 4) == 0) - test("Hollow box: interior is air (another point)", vg.get(5, 5, 5) == 0) - - # Verify shell exists - test("Hollow box: corner is filled", vg.get(2, 2, 2) == stone) - test("Hollow box: edge is filled", vg.get(4, 2, 2) == stone) - -def test_fill_box_hollow_thick(): - """fill_box_hollow with thickness > 1""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(12, 12, 12)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Create hollow 10x10x10 box with thickness 2 - vg.fill_box_hollow((1, 1, 1), (10, 10, 10), stone, thickness=2) - - # Total box = 10x10x10 = 1000 - # Interior = 6x6x6 = 216 - # Shell = 1000 - 216 = 784 - expected = 784 - actual = vg.count_non_air() - test("Thick hollow box: correct voxel count", actual == expected, - f"got {actual}, expected {expected}") - - # Verify interior is empty - test("Thick hollow box: center is air", vg.get(5, 5, 5) == 0) - -def test_fill_sphere_volume(): - """fill_sphere produces roughly spherical shape""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(30, 30, 30)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill sphere with radius 8 - radius = 8 - vg.fill_sphere((15, 15, 15), radius, stone) - - # Expected volume ≈ (4/3)πr³ - expected_vol = (4.0 / 3.0) * math.pi * (radius ** 3) - actual = vg.count_non_air() - - # Voxel sphere should be within 20% of theoretical volume - ratio = actual / expected_vol - test("Sphere volume: within 20% of (4/3)πr³", - 0.8 <= ratio <= 1.2, - f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}") - -def test_fill_sphere_carve(): - """fill_sphere with material 0 carves out voxels""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(20, 20, 20)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill entire grid with stone - vg.fill(stone) - initial = vg.count_non_air() - test("Sphere carve: initial fill", initial == 8000) # 20x20x20 - - # Carve out a sphere (material 0) - vg.fill_sphere((10, 10, 10), 5, 0) # Air - - final = vg.count_non_air() - test("Sphere carve: voxels removed", final < initial) - -def test_fill_cylinder_volume(): - """fill_cylinder produces roughly cylindrical shape""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(30, 30, 30)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill cylinder with radius 5, height 10 - radius = 5 - height = 10 - vg.fill_cylinder((15, 5, 15), radius, height, stone) - - # Expected volume ≈ πr²h - expected_vol = math.pi * (radius ** 2) * height - actual = vg.count_non_air() - - # Voxel cylinder should be within 20% of theoretical volume - ratio = actual / expected_vol - test("Cylinder volume: within 20% of πr²h", - 0.8 <= ratio <= 1.2, - f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}") - -def test_fill_cylinder_bounds(): - """fill_cylinder respects grid bounds""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Cylinder partially outside grid - vg.fill_cylinder((2, 0, 2), 3, 15, stone) # height extends beyond grid - - # Should not crash, and have some voxels - count = vg.count_non_air() - test("Cylinder bounds: handles out-of-bounds gracefully", count > 0) - test("Cylinder bounds: limited by grid height", count < 3.14 * 9 * 15) - -def test_fill_noise_threshold(): - """fill_noise: higher threshold = fewer voxels""" - import mcrfpy - - vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16)) - vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16)) - stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Same seed, different thresholds - vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.3, scale=0.15, seed=12345) - vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.7, scale=0.15, seed=12345) - - count1 = vg1.count_non_air() - count2 = vg2.count_non_air() - - # Higher threshold should produce fewer voxels - test("Noise threshold: high threshold produces fewer voxels", - count2 < count1, - f"threshold=0.3 gave {count1}, threshold=0.7 gave {count2}") - -def test_fill_noise_seed(): - """fill_noise: same seed produces same result""" - import mcrfpy - - vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16)) - vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16)) - stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Same parameters - vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42) - vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42) - - # Should produce identical results - count1 = vg1.count_non_air() - count2 = vg2.count_non_air() - - test("Noise seed: same seed produces same count", count1 == count2, - f"got {count1} vs {count2}") - - # Check a few sample points - same_values = True - for x, y, z in [(0, 0, 0), (8, 8, 8), (15, 15, 15), (3, 7, 11)]: - if vg1.get(x, y, z) != vg2.get(x, y, z): - same_values = False - break - - test("Noise seed: same seed produces identical voxels", same_values) - -def test_copy_paste_basic(): - """copy_region and paste_region round-trip""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - brick = vg.add_material("brick", color=mcrfpy.Color(165, 42, 42)) - - # Create a small structure - vg.fill_box((2, 0, 2), (5, 3, 5), stone) - vg.set(3, 1, 3, brick) # Add a different material - - # Copy the region - prefab = vg.copy_region((2, 0, 2), (5, 3, 5)) - - # Verify VoxelRegion properties - test("Copy region: correct width", prefab.width == 4) - test("Copy region: correct height", prefab.height == 4) - test("Copy region: correct depth", prefab.depth == 4) - test("Copy region: size tuple", prefab.size == (4, 4, 4)) - - # Paste elsewhere - vg.paste_region(prefab, (10, 0, 10)) - - # Verify paste - test("Paste region: stone at corner", vg.get(10, 0, 10) == stone) - test("Paste region: brick inside", vg.get(11, 1, 11) == brick) - -def test_copy_paste_skip_air(): - """paste_region with skip_air=True doesn't overwrite""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0)) - - # Create prefab with air gaps - vg.fill_box((0, 0, 0), (3, 3, 3), stone) - vg.set(1, 1, 1, 0) # Air hole - vg.set(2, 2, 2, 0) # Another air hole - - # Copy it - prefab = vg.copy_region((0, 0, 0), (3, 3, 3)) - - # Place gold in destination - vg.set(11, 1, 11, gold) # Where air hole will paste - vg.set(12, 2, 12, gold) # Where another air hole will paste - - # Paste with skip_air=True (default) - vg.paste_region(prefab, (10, 0, 10), skip_air=True) - - # Gold should still be there (air didn't overwrite) - test("Skip air: preserves existing material", vg.get(11, 1, 11) == gold) - test("Skip air: preserves at other location", vg.get(12, 2, 12) == gold) - -def test_copy_paste_overwrite(): - """paste_region with skip_air=False overwrites""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0)) - - # Create prefab with air gap - vg.fill_box((0, 0, 0), (3, 3, 3), stone) - vg.set(1, 1, 1, 0) # Air hole - - # Copy it - prefab = vg.copy_region((0, 0, 0), (3, 3, 3)) - - # Clear and place gold in destination - vg.clear() - vg.set(11, 1, 11, gold) - - # Paste with skip_air=False - vg.paste_region(prefab, (10, 0, 10), skip_air=False) - - # Gold should be overwritten with air - test("Overwrite air: replaces existing material", vg.get(11, 1, 11) == 0) - -def test_voxel_region_repr(): - """VoxelRegion has proper repr""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg.fill_box((0, 0, 0), (4, 4, 4), stone) - - prefab = vg.copy_region((0, 0, 0), (4, 4, 4)) - rep = repr(prefab) - - test("VoxelRegion repr: contains dimensions", "5x5x5" in rep) - test("VoxelRegion repr: is VoxelRegion", "VoxelRegion" in rep) - -def main(): - """Run all bulk operation tests""" - print("=" * 60) - print("VoxelGrid Bulk Operations Tests (Milestone 11)") - print("=" * 60) - print() - - test_fill_box_hollow_basic() - print() - test_fill_box_hollow_thick() - print() - test_fill_sphere_volume() - print() - test_fill_sphere_carve() - print() - test_fill_cylinder_volume() - print() - test_fill_cylinder_bounds() - print() - test_fill_noise_threshold() - print() - test_fill_noise_seed() - print() - test_copy_paste_basic() - print() - test_copy_paste_skip_air() - print() - test_copy_paste_overwrite() - print() - test_voxel_region_repr() - print() - - print("=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - return 0 if failed == 0 else 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/unit/voxel_greedy_meshing_test.py b/tests/unit/voxel_greedy_meshing_test.py deleted file mode 100644 index c2746fa..0000000 --- a/tests/unit/voxel_greedy_meshing_test.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for Milestone 13: Greedy Meshing - -Tests that greedy meshing produces correct mesh geometry with reduced vertex count. -""" - -import mcrfpy -import sys - -# Test counters -tests_passed = 0 -tests_failed = 0 - -def test(name, condition): - """Simple test helper""" - global tests_passed, tests_failed - if condition: - tests_passed += 1 - print(f" PASS: {name}") - else: - tests_failed += 1 - print(f" FAIL: {name}") - -# ============================================================================= -# Test greedy meshing property -# ============================================================================= - -print("\n=== Testing greedy_meshing property ===") - -vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) -test("Default greedy_meshing is False", vg.greedy_meshing == False) - -vg.greedy_meshing = True -test("Can enable greedy_meshing", vg.greedy_meshing == True) - -vg.greedy_meshing = False -test("Can disable greedy_meshing", vg.greedy_meshing == False) - -# ============================================================================= -# Test vertex count reduction -# ============================================================================= - -print("\n=== Testing vertex count reduction ===") - -# Create a solid 4x4x4 cube - this should benefit greatly from greedy meshing -# Non-greedy: 6 faces per voxel for exposed faces = many quads -# Greedy: 6 large quads (one per side) - -vg2 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) -stone = vg2.add_material("stone", (128, 128, 128)) -vg2.fill((stone)) # Fill entire grid - -# Get vertex count with standard meshing -vg2.greedy_meshing = False -vg2.rebuild_mesh() -standard_vertices = vg2.vertex_count -print(f" Standard meshing: {standard_vertices} vertices") - -# Get vertex count with greedy meshing -vg2.greedy_meshing = True -vg2.rebuild_mesh() -greedy_vertices = vg2.vertex_count -print(f" Greedy meshing: {greedy_vertices} vertices") - -# For a solid 4x4x4 cube, standard meshing creates: -# Each face of the cube is 4x4 = 16 voxel faces -# 6 cube faces * 16 faces/side * 6 vertices/face = 576 vertices -# Greedy meshing creates: -# 6 cube faces * 1 merged quad * 6 vertices/quad = 36 vertices - -test("Greedy meshing reduces vertex count", greedy_vertices < standard_vertices) -test("Solid cube greedy: 36 vertices (6 faces * 6 verts)", greedy_vertices == 36) - -# ============================================================================= -# Test larger solid block -# ============================================================================= - -print("\n=== Testing larger solid block ===") - -vg3 = mcrfpy.VoxelGrid((16, 16, 16), cell_size=1.0) -stone3 = vg3.add_material("stone", (128, 128, 128)) -vg3.fill(stone3) - -vg3.greedy_meshing = False -vg3.rebuild_mesh() -standard_verts_large = vg3.vertex_count -print(f" Standard: {standard_verts_large} vertices") - -vg3.greedy_meshing = True -vg3.rebuild_mesh() -greedy_verts_large = vg3.vertex_count -print(f" Greedy: {greedy_verts_large} vertices") - -# 16x16 faces = 256 quads per side -> 1 quad per side with greedy -# Reduction factor should be significant -reduction_factor = standard_verts_large / greedy_verts_large if greedy_verts_large > 0 else 0 -print(f" Reduction factor: {reduction_factor:.1f}x") - -test("Large block greedy: still 36 vertices", greedy_verts_large == 36) -test("Significant vertex reduction (>10x)", reduction_factor > 10) - -# ============================================================================= -# Test checkerboard pattern (worst case for greedy) -# ============================================================================= - -print("\n=== Testing checkerboard pattern (greedy stress test) ===") - -vg4 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) -stone4 = vg4.add_material("stone", (128, 128, 128)) - -# Create checkerboard pattern - no adjacent same-material voxels -for z in range(4): - for y in range(4): - for x in range(4): - if (x + y + z) % 2 == 0: - vg4.set(x, y, z, stone4) - -vg4.greedy_meshing = False -vg4.rebuild_mesh() -standard_checker = vg4.vertex_count -print(f" Standard: {standard_checker} vertices") - -vg4.greedy_meshing = True -vg4.rebuild_mesh() -greedy_checker = vg4.vertex_count -print(f" Greedy: {greedy_checker} vertices") - -# In checkerboard, greedy meshing can't merge much, so counts should be similar -test("Checkerboard: greedy meshing works (produces vertices)", greedy_checker > 0) -# Greedy might still reduce a bit due to row merging -test("Checkerboard: greedy <= standard", greedy_checker <= standard_checker) - -# ============================================================================= -# Test different materials (no cross-material merging) -# ============================================================================= - -print("\n=== Testing multi-material (no cross-material merging) ===") - -vg5 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) -red = vg5.add_material("red", (255, 0, 0)) -blue = vg5.add_material("blue", (0, 0, 255)) - -# Half red, half blue -vg5.fill_box((0, 0, 0), (1, 3, 3), red) -vg5.fill_box((2, 0, 0), (3, 3, 3), blue) - -vg5.greedy_meshing = True -vg5.rebuild_mesh() -multi_material_verts = vg5.vertex_count -print(f" Multi-material greedy: {multi_material_verts} vertices") - -# Should have 6 quads per material half = 12 quads = 72 vertices -# But there's a shared face between them that gets culled -# Actually: each 2x4x4 block has 5 exposed faces (not the shared internal face) -# So 5 + 5 = 10 quads = 60 vertices, but may be more due to the contact face -test("Multi-material produces vertices", multi_material_verts > 0) - -# ============================================================================= -# Test hollow box (interior faces) -# ============================================================================= - -print("\n=== Testing hollow box ===") - -vg6 = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) -stone6 = vg6.add_material("stone", (128, 128, 128)) -vg6.fill_box_hollow((0, 0, 0), (7, 7, 7), stone6, thickness=1) - -vg6.greedy_meshing = False -vg6.rebuild_mesh() -standard_hollow = vg6.vertex_count -print(f" Standard: {standard_hollow} vertices") - -vg6.greedy_meshing = True -vg6.rebuild_mesh() -greedy_hollow = vg6.vertex_count -print(f" Greedy: {greedy_hollow} vertices") - -# Hollow box has 6 outer faces and 6 inner faces -# Greedy should merge each face into one quad -# Expected: 12 quads * 6 verts = 72 vertices -test("Hollow box: greedy reduces vertices", greedy_hollow < standard_hollow) - -# ============================================================================= -# Test floor slab (single layer) -# ============================================================================= - -print("\n=== Testing floor slab (single layer) ===") - -vg7 = mcrfpy.VoxelGrid((10, 1, 10), cell_size=1.0) -floor_mat = vg7.add_material("floor", (100, 80, 60)) -vg7.fill(floor_mat) - -vg7.greedy_meshing = False -vg7.rebuild_mesh() -standard_floor = vg7.vertex_count -print(f" Standard: {standard_floor} vertices") - -vg7.greedy_meshing = True -vg7.rebuild_mesh() -greedy_floor = vg7.vertex_count -print(f" Greedy: {greedy_floor} vertices") - -# Floor slab: 10x10 top face + 10x10 bottom face + 4 edge faces (10x1 each) -# Greedy: 6 quads = 36 vertices -test("Floor slab: greedy = 36 vertices", greedy_floor == 36) - -# ============================================================================= -# Test that mesh is marked dirty when property changes -# ============================================================================= - -print("\n=== Testing dirty flag behavior ===") - -vg8 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) -stone8 = vg8.add_material("stone", (128, 128, 128)) -vg8.fill(stone8) - -# Build mesh first -vg8.greedy_meshing = False -vg8.rebuild_mesh() -first_count = vg8.vertex_count - -# Change greedy_meshing - mesh should be marked dirty -vg8.greedy_meshing = True -# Rebuild -vg8.rebuild_mesh() -second_count = vg8.vertex_count - -test("Changing greedy_meshing affects vertex count", first_count != second_count) - -# ============================================================================= -# Summary -# ============================================================================= - -print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===") - -if tests_failed > 0: - sys.exit(1) -else: - print("All tests passed!") - sys.exit(0) diff --git a/tests/unit/voxel_meshing_test.py b/tests/unit/voxel_meshing_test.py deleted file mode 100644 index 1d7130d..0000000 --- a/tests/unit/voxel_meshing_test.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for VoxelGrid mesh generation (Milestone 10) - -Tests: -- Single voxel produces 36 vertices (6 faces x 6 verts) -- Two adjacent voxels share a face (60 verts instead of 72) -- Hollow cube only has outer faces -- fill_box works correctly -- Mesh dirty flag triggers rebuild -- Vertex positions are in correct local space -""" -import sys - -# Track test results -passed = 0 -failed = 0 - -def test(name, condition, detail=""): - """Record test result""" - global passed, failed - if condition: - print(f"[PASS] {name}") - passed += 1 - else: - print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) - failed += 1 - -def test_single_voxel(): - """Single voxel should produce 6 faces = 36 vertices""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Initially no vertices (empty grid) - test("Single voxel: initial vertex_count is 0", vg.vertex_count == 0) - - # Add one voxel - vg.set(4, 4, 4, stone) - vg.rebuild_mesh() - - # One voxel = 6 faces, each face = 2 triangles = 6 vertices - expected = 6 * 6 - test("Single voxel: produces 36 vertices", vg.vertex_count == expected, - f"got {vg.vertex_count}, expected {expected}") - -def test_two_adjacent(): - """Two adjacent voxels should share a face, producing 60 vertices instead of 72""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Add two adjacent voxels (share one face) - vg.set(4, 4, 4, stone) - vg.set(5, 4, 4, stone) # Adjacent in X - vg.rebuild_mesh() - - # Two separate voxels would be 72 vertices - # Shared face is culled: 2 * 36 - 2 * 6 = 72 - 12 = 60 - expected = 60 - test("Two adjacent: shared face culled", vg.vertex_count == expected, - f"got {vg.vertex_count}, expected {expected}") - -def test_hollow_cube(): - """Hollow 3x3x3 cube should have much fewer vertices than solid""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Create hollow 3x3x3 cube (only shell voxels) - # Solid 3x3x3 = 27 voxels, Hollow = 26 voxels (remove center) - for x in range(3): - for y in range(3): - for z in range(3): - # Skip center voxel - if x == 1 and y == 1 and z == 1: - continue - vg.set(x, y, z, stone) - - test("Hollow cube: 26 voxels placed", vg.count_non_air() == 26) - - vg.rebuild_mesh() - - # The hollow center creates inner faces facing the air void - # Outer surface = 6 sides * 9 faces = 54 faces - # Inner surface = 6 faces touching the center void - # Total = 60 faces = 360 vertices - expected = 360 - test("Hollow cube: outer + inner void faces", vg.vertex_count == expected, - f"got {vg.vertex_count}, expected {expected}") - -def test_fill_box(): - """fill_box should fill a rectangular region""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill a 4x3x5 box - vg.fill_box((2, 1, 3), (5, 3, 7), stone) - - # Count: (5-2+1) * (3-1+1) * (7-3+1) = 4 * 3 * 5 = 60 - expected = 60 - test("fill_box: correct voxel count", vg.count_non_air() == expected, - f"got {vg.count_non_air()}, expected {expected}") - - # Verify specific cells - test("fill_box: corner (2,1,3) is filled", vg.get(2, 1, 3) == stone) - test("fill_box: corner (5,3,7) is filled", vg.get(5, 3, 7) == stone) - test("fill_box: outside (1,1,3) is empty", vg.get(1, 1, 3) == 0) - test("fill_box: outside (6,1,3) is empty", vg.get(6, 1, 3) == 0) - -def test_fill_box_reversed(): - """fill_box should handle reversed coordinates""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill with reversed coordinates (max before min) - vg.fill_box((5, 3, 7), (2, 1, 3), stone) - - # Should still fill 4x3x5 = 60 voxels - expected = 60 - test("fill_box reversed: correct voxel count", vg.count_non_air() == expected, - f"got {vg.count_non_air()}, expected {expected}") - -def test_fill_box_clamping(): - """fill_box should clamp to grid bounds""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill beyond grid bounds - vg.fill_box((-5, -5, -5), (100, 100, 100), stone) - - # Should fill entire 8x8x8 grid = 512 voxels - expected = 512 - test("fill_box clamping: fills entire grid", vg.count_non_air() == expected, - f"got {vg.count_non_air()}, expected {expected}") - -def test_mesh_dirty(): - """Modifying voxels should mark mesh dirty; rebuild_mesh updates vertex count""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Initial state - vg.set(4, 4, 4, stone) - vg.rebuild_mesh() - initial_count = vg.vertex_count - - test("Mesh dirty: initial vertex count correct", initial_count == 36) - - # Modify voxel - marks dirty but doesn't auto-rebuild - vg.set(4, 4, 5, stone) - - # vertex_count doesn't auto-trigger rebuild (returns stale value) - stale_count = vg.vertex_count - test("Mesh dirty: vertex_count before rebuild is stale", stale_count == 36) - - # Explicit rebuild updates the mesh - vg.rebuild_mesh() - new_count = vg.vertex_count - - # Two adjacent voxels = 60 vertices - test("Mesh dirty: rebuilt after explicit rebuild_mesh()", new_count == 60, - f"got {new_count}, expected 60") - -def test_vertex_positions(): - """Vertices should be in correct local space positions""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8), cell_size=2.0) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Place voxel at (0,0,0) - vg.set(0, 0, 0, stone) - vg.rebuild_mesh() - - # With cell_size=2.0, the voxel center is at (1, 1, 1) - # Vertices should be at corners: (0,0,0) to (2,2,2) - # The vertex_count should still be 36 - test("Vertex positions: correct vertex count", vg.vertex_count == 36) - -def test_empty_grid(): - """Empty grid should produce no vertices""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - vg.rebuild_mesh() - - test("Empty grid: zero vertices", vg.vertex_count == 0) - -def test_all_air(): - """Grid filled with air produces no vertices""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill with stone, then fill with air - vg.fill(stone) - vg.fill(0) # Air - vg.rebuild_mesh() - - test("All air: zero vertices", vg.vertex_count == 0) - -def test_large_solid_cube(): - """Large solid cube should have face culling efficiency""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill entire grid - vg.fill(stone) - vg.rebuild_mesh() - - # Without culling: 512 voxels * 6 faces * 6 verts = 18432 - # With culling: only outer shell faces - # 6 faces of cube, each 8x8 = 64 faces per side = 384 faces - # 384 * 6 verts = 2304 vertices - expected = 2304 - test("Large solid cube: face culling efficiency", - vg.vertex_count == expected, - f"got {vg.vertex_count}, expected {expected}") - - # Verify massive reduction - no_cull = 512 * 6 * 6 - reduction = (no_cull - vg.vertex_count) / no_cull * 100 - test("Large solid cube: >85% vertex reduction", - reduction > 85, - f"got {reduction:.1f}% reduction") - -def test_transparent_material(): - """Faces between solid and transparent materials should be generated""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - glass = vg.add_material("glass", color=mcrfpy.Color(200, 200, 255, 128), - transparent=True) - - # Place stone with glass neighbor - vg.set(4, 4, 4, stone) - vg.set(5, 4, 4, glass) - vg.rebuild_mesh() - - # Stone has 6 faces (all exposed - glass is transparent) - # Glass has 5 faces (face towards stone not generated - stone is solid) - # Total = 36 + 30 = 66 vertices - expected = 66 - test("Transparent material: correct face culling", vg.vertex_count == expected, - f"got {vg.vertex_count}, expected {expected}") - -def main(): - """Run all mesh generation tests""" - print("=" * 60) - print("VoxelGrid Mesh Generation Tests (Milestone 10)") - print("=" * 60) - print() - - test_single_voxel() - print() - test_two_adjacent() - print() - test_hollow_cube() - print() - test_fill_box() - print() - test_fill_box_reversed() - print() - test_fill_box_clamping() - print() - test_mesh_dirty() - print() - test_vertex_positions() - print() - test_empty_grid() - print() - test_all_air() - print() - test_large_solid_cube() - print() - test_transparent_material() - print() - - print("=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - return 0 if failed == 0 else 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/unit/voxel_navigation_test.py b/tests/unit/voxel_navigation_test.py deleted file mode 100644 index 73e497c..0000000 --- a/tests/unit/voxel_navigation_test.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for Milestone 12: VoxelGrid Navigation Projection - -Tests VoxelGrid.project_column() and Viewport3D voxel-to-nav projection methods. -""" - -import mcrfpy -import sys - -# Test counters -tests_passed = 0 -tests_failed = 0 - -def test(name, condition): - """Simple test helper""" - global tests_passed, tests_failed - if condition: - tests_passed += 1 - print(f" PASS: {name}") - else: - tests_failed += 1 - print(f" FAIL: {name}") - -def approx_eq(a, b, epsilon=0.001): - """Approximate floating-point equality""" - return abs(a - b) < epsilon - -# ============================================================================= -# Test projectColumn() on VoxelGrid -# ============================================================================= - -print("\n=== Testing VoxelGrid.project_column() ===") - -# Test 1: Empty grid - all air -vg = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -nav = vg.project_column(5, 5) -test("Empty grid - height is 0", approx_eq(nav['height'], 0.0)) -test("Empty grid - not walkable (no floor)", nav['walkable'] == False) -test("Empty grid - transparent", nav['transparent'] == True) -test("Empty grid - default path cost", approx_eq(nav['path_cost'], 1.0)) - -# Test 2: Simple floor -vg2 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -stone = vg2.add_material("stone", (128, 128, 128)) -vg2.fill_box((0, 0, 0), (9, 0, 9), stone) # Floor at y=0 -nav2 = vg2.project_column(5, 5) -test("Floor at y=0 - height is 1.0 (top of floor voxel)", approx_eq(nav2['height'], 1.0)) -test("Floor at y=0 - walkable", nav2['walkable'] == True) -test("Floor at y=0 - not transparent (has solid voxel)", nav2['transparent'] == False) - -# Test 3: Solid column extending to top - no headroom at boundary -vg3 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -stone3 = vg3.add_material("stone", (128, 128, 128)) -vg3.fill_box((0, 0, 0), (9, 0, 9), stone3) # Floor at y=0 -vg3.fill_box((0, 2, 0), (9, 9, 9), stone3) # Solid block from y=2 to y=9 -nav3 = vg3.project_column(5, 5, headroom=2) -# Scan finds y=9 as topmost floor (boundary has "air above" but no actual headroom) -# Height = 10.0 (top of y=9 voxel), no air above means airCount=0, so not walkable -test("Top boundary floor - height at top", approx_eq(nav3['height'], 10.0)) -test("Top boundary floor - not walkable (no headroom)", nav3['walkable'] == False) - -# Test 4: Single floor slab with plenty of headroom -vg4 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -stone4 = vg4.add_material("stone", (128, 128, 128)) -vg4.fill_box((0, 2, 0), (9, 2, 9), stone4) # Floor slab at y=2 (air below, 7 voxels air above) -nav4 = vg4.project_column(5, 5, headroom=2) -test("Floor slab at y=2 - height is 3.0", approx_eq(nav4['height'], 3.0)) -test("Floor slab - walkable (7 voxels headroom)", nav4['walkable'] == True) - -# Test 5: Custom headroom thresholds -nav4_h1 = vg4.project_column(5, 5, headroom=1) -test("Headroom=1 - walkable", nav4_h1['walkable'] == True) -nav4_h7 = vg4.project_column(5, 5, headroom=7) -test("Headroom=7 - walkable (exactly 7 air voxels)", nav4_h7['walkable'] == True) -nav4_h8 = vg4.project_column(5, 5, headroom=8) -test("Headroom=8 - not walkable (only 7 air)", nav4_h8['walkable'] == False) - -# Test 6: Multi-level floor (finds topmost walkable) -vg5 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -stone5 = vg5.add_material("stone", (128, 128, 128)) -vg5.fill_box((0, 0, 0), (9, 0, 9), stone5) # Bottom floor at y=0 -vg5.fill_box((0, 5, 0), (9, 5, 9), stone5) # Upper floor at y=5 -nav5 = vg5.project_column(5, 5) -test("Multi-level - finds top floor", approx_eq(nav5['height'], 6.0)) -test("Multi-level - walkable", nav5['walkable'] == True) - -# Test 7: Transparent material -vg6 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -glass = vg6.add_material("glass", (200, 200, 255), transparent=True) -vg6.set(5, 5, 5, glass) -nav6 = vg6.project_column(5, 5) -test("Transparent voxel - column is transparent", nav6['transparent'] == True) - -# Test 8: Non-transparent material -vg7 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -wall = vg7.add_material("wall", (100, 100, 100), transparent=False) -vg7.set(5, 5, 5, wall) -nav7 = vg7.project_column(5, 5) -test("Opaque voxel - column not transparent", nav7['transparent'] == False) - -# Test 9: Path cost from material -vg8 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0) -mud = vg8.add_material("mud", (139, 90, 43), path_cost=2.0) -vg8.fill_box((0, 0, 0), (9, 0, 9), mud) # Floor of mud -nav8 = vg8.project_column(5, 5) -test("Mud floor - path cost is 2.0", approx_eq(nav8['path_cost'], 2.0)) - -# Test 10: Cell size affects height -vg9 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=2.0) -stone9 = vg9.add_material("stone", (128, 128, 128)) -vg9.fill_box((0, 0, 0), (9, 0, 9), stone9) # Floor at y=0 -nav9 = vg9.project_column(5, 5) -test("Cell size 2.0 - height is 2.0", approx_eq(nav9['height'], 2.0)) - -# Test 11: Out of bounds returns default -nav_oob = vg.project_column(-1, 5) -test("Out of bounds - not walkable", nav_oob['walkable'] == False) -test("Out of bounds - height 0", approx_eq(nav_oob['height'], 0.0)) - -# ============================================================================= -# Test Viewport3D voxel-to-nav projection -# ============================================================================= - -print("\n=== Testing Viewport3D voxel-to-nav projection ===") - -# Create viewport with navigation grid -vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) -vp.set_grid_size(20, 20) -vp.cell_size = 1.0 - -# Test 12: Initial nav grid state -cell = vp.at(10, 10) -test("Initial nav cell - walkable", cell.walkable == True) -test("Initial nav cell - transparent", cell.transparent == True) -test("Initial nav cell - height 0", approx_eq(cell.height, 0.0)) -test("Initial nav cell - cost 1", approx_eq(cell.cost, 1.0)) - -# Test 13: Project simple voxel grid -vg_nav = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0) -stone_nav = vg_nav.add_material("stone", (128, 128, 128)) -vg_nav.fill_box((0, 0, 0), (9, 0, 9), stone_nav) # Floor -vg_nav.offset = (5, 0, 5) # Position grid at (5, 0, 5) in world - -vp.add_voxel_layer(vg_nav) -vp.project_voxel_to_nav(vg_nav, headroom=2) - -# Check cell within grid footprint -cell_in = vp.at(10, 10) # World (10, 10) = voxel grid local (5, 5) -test("Projected cell - walkable (floor present)", cell_in.walkable == True) -test("Projected cell - height is 1.0", approx_eq(cell_in.height, 1.0)) -test("Projected cell - not transparent", cell_in.transparent == False) - -# Check cell outside grid footprint (unchanged) -cell_out = vp.at(0, 0) # Outside voxel grid area -test("Outside cell - still walkable (unchanged)", cell_out.walkable == True) -test("Outside cell - height still 0", approx_eq(cell_out.height, 0.0)) - -# Test 14: Clear voxel nav region -vp.clear_voxel_nav_region(vg_nav) -cell_cleared = vp.at(10, 10) -test("Cleared cell - walkable reset to true", cell_cleared.walkable == True) -test("Cleared cell - height reset to 0", approx_eq(cell_cleared.height, 0.0)) -test("Cleared cell - transparent reset to true", cell_cleared.transparent == True) - -# Test 15: Project with walls (blocking) -vg_wall = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0) -stone_wall = vg_wall.add_material("stone", (128, 128, 128)) -vg_wall.fill_box((0, 0, 0), (9, 4, 9), stone_wall) # Solid block (no air above floor) -vg_wall.offset = (0, 0, 0) - -vp2 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) -vp2.set_grid_size(20, 20) -vp2.add_voxel_layer(vg_wall) -vp2.project_voxel_to_nav(vg_wall) - -cell_wall = vp2.at(5, 5) -test("Solid block - height at top", approx_eq(cell_wall.height, 5.0)) -test("Solid block - not transparent", cell_wall.transparent == False) - -# Test 16: project_all_voxels_to_nav with multiple layers -vp3 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) -vp3.set_grid_size(20, 20) - -# First layer - lower priority -vg_layer1 = mcrfpy.VoxelGrid((20, 5, 20), cell_size=1.0) -dirt = vg_layer1.add_material("dirt", (139, 90, 43)) -vg_layer1.fill_box((0, 0, 0), (19, 0, 19), dirt) # Floor everywhere - -# Second layer - higher priority, partial coverage -vg_layer2 = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0) -stone_l2 = vg_layer2.add_material("stone", (128, 128, 128)) -vg_layer2.fill_box((0, 0, 0), (4, 2, 4), stone_l2) # Higher floor -vg_layer2.offset = (5, 0, 5) - -vp3.add_voxel_layer(vg_layer1, z_index=0) -vp3.add_voxel_layer(vg_layer2, z_index=1) -vp3.project_all_voxels_to_nav() - -cell_dirt = vp3.at(0, 0) # Only dirt layer -cell_stone = vp3.at(7, 7) # Stone layer overlaps (higher z_index) -test("Multi-layer - dirt area height is 1", approx_eq(cell_dirt.height, 1.0)) -test("Multi-layer - stone area height is 3 (higher layer)", approx_eq(cell_stone.height, 3.0)) - -# Test 17: Viewport projection with different headroom values -vg_low = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0) -stone_low = vg_low.add_material("stone", (128, 128, 128)) -vg_low.fill_box((0, 0, 0), (9, 0, 9), stone_low) # Floor at y=0 -# Grid has height=5, so floor at y=0 has 4 air voxels above (y=1,2,3,4) - -vp4 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) -vp4.set_grid_size(20, 20) -vp4.add_voxel_layer(vg_low) - -vp4.project_voxel_to_nav(vg_low, headroom=1) -test("Headroom 1 - walkable (4 air voxels)", vp4.at(5, 5).walkable == True) - -vp4.project_voxel_to_nav(vg_low, headroom=4) -test("Headroom 4 - walkable (exactly 4 air)", vp4.at(5, 5).walkable == True) - -vp4.project_voxel_to_nav(vg_low, headroom=5) -test("Headroom 5 - not walkable (only 4 air)", vp4.at(5, 5).walkable == False) - -# Test 18: Grid offset in world space -vg_offset = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0) -stone_off = vg_offset.add_material("stone", (128, 128, 128)) -vg_offset.fill_box((0, 0, 0), (4, 0, 4), stone_off) -vg_offset.offset = (10, 5, 10) # Y offset = 5 - -vp5 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) -vp5.set_grid_size(20, 20) -vp5.add_voxel_layer(vg_offset) -vp5.project_voxel_to_nav(vg_offset) - -cell_off = vp5.at(12, 12) -test("Y-offset grid - height includes offset", approx_eq(cell_off.height, 6.0)) # floor 1 + offset 5 - -# ============================================================================= -# Summary -# ============================================================================= - -print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===") - -if tests_failed > 0: - sys.exit(1) -else: - print("All tests passed!") - sys.exit(0) diff --git a/tests/unit/voxel_rendering_test.py b/tests/unit/voxel_rendering_test.py deleted file mode 100644 index 3f9ab5a..0000000 --- a/tests/unit/voxel_rendering_test.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for VoxelGrid rendering integration (Milestone 10) - -Tests: -- Adding voxel layer to viewport -- Removing voxel layer from viewport -- Voxel layer count tracking -- Screenshot verification (visual rendering) -""" -import sys - -# Track test results -passed = 0 -failed = 0 - -def test(name, condition, detail=""): - """Record test result""" - global passed, failed - if condition: - print(f"[PASS] {name}") - passed += 1 - else: - print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) - failed += 1 - -def test_add_to_viewport(): - """Test adding a voxel layer to viewport""" - import mcrfpy - - # Create viewport - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Create voxel grid - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg.set(4, 4, 4, stone) - - # Initial layer count - test("Add to viewport: initial count is 0", viewport.voxel_layer_count() == 0) - - # Add voxel layer - viewport.add_voxel_layer(vg, z_index=1) - - test("Add to viewport: count increases to 1", viewport.voxel_layer_count() == 1) - -def test_add_multiple_layers(): - """Test adding multiple voxel layers""" - import mcrfpy - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4)) - vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4)) - vg3 = mcrfpy.VoxelGrid(size=(4, 4, 4)) - - viewport.add_voxel_layer(vg1, z_index=0) - viewport.add_voxel_layer(vg2, z_index=1) - viewport.add_voxel_layer(vg3, z_index=2) - - test("Multiple layers: count is 3", viewport.voxel_layer_count() == 3) - -def test_remove_from_viewport(): - """Test removing a voxel layer from viewport""" - import mcrfpy - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4)) - vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4)) - - viewport.add_voxel_layer(vg1, z_index=0) - viewport.add_voxel_layer(vg2, z_index=1) - - test("Remove: initial count is 2", viewport.voxel_layer_count() == 2) - - # Remove one layer - result = viewport.remove_voxel_layer(vg1) - test("Remove: returns True for existing layer", result == True) - test("Remove: count decreases to 1", viewport.voxel_layer_count() == 1) - - # Remove same layer again should return False - result = viewport.remove_voxel_layer(vg1) - test("Remove: returns False for non-existing layer", result == False) - test("Remove: count still 1", viewport.voxel_layer_count() == 1) - -def test_remove_nonexistent(): - """Test removing a layer that was never added""" - import mcrfpy - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - vg = mcrfpy.VoxelGrid(size=(4, 4, 4)) - - result = viewport.remove_voxel_layer(vg) - test("Remove nonexistent: returns False", result == False) - -def test_add_invalid_type(): - """Test that adding non-VoxelGrid raises error""" - import mcrfpy - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - error_raised = False - try: - viewport.add_voxel_layer("not a voxel grid") - except TypeError: - error_raised = True - - test("Add invalid type: raises TypeError", error_raised) - -def test_z_index_parameter(): - """Test that z_index parameter is accepted""" - import mcrfpy - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - vg = mcrfpy.VoxelGrid(size=(4, 4, 4)) - - # Should not raise error - error_raised = False - try: - viewport.add_voxel_layer(vg, z_index=5) - except Exception as e: - error_raised = True - print(f" Error: {e}") - - test("Z-index parameter: accepted without error", not error_raised) - -def test_viewport_in_scene(): - """Test viewport with voxel layer added to a scene""" - import mcrfpy - - # Create and activate a test scene - scene = mcrfpy.Scene("voxel_test_scene") - - # Create viewport - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Create voxel grid with visible content - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg.fill_box((2, 0, 2), (5, 3, 5), stone) - vg.offset = (0, 0, 0) - - # Add voxel layer to viewport - viewport.add_voxel_layer(vg, z_index=0) - - # Position camera to see the voxels - viewport.camera_pos = (10, 10, 10) - viewport.camera_target = (4, 2, 4) - - # Add viewport to scene - scene.children.append(viewport) - - # Trigger mesh generation - vg.rebuild_mesh() - - test("Viewport in scene: voxel layer added", viewport.voxel_layer_count() == 1) - test("Viewport in scene: voxels have content", vg.count_non_air() > 0) - test("Viewport in scene: mesh generated", vg.vertex_count > 0) - -def main(): - """Run all rendering integration tests""" - print("=" * 60) - print("VoxelGrid Rendering Integration Tests (Milestone 10)") - print("=" * 60) - print() - - test_add_to_viewport() - print() - test_add_multiple_layers() - print() - test_remove_from_viewport() - print() - test_remove_nonexistent() - print() - test_add_invalid_type() - print() - test_z_index_parameter() - print() - test_viewport_in_scene() - print() - - print("=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - return 0 if failed == 0 else 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/unit/voxel_serialization_test.py b/tests/unit/voxel_serialization_test.py deleted file mode 100644 index 1737168..0000000 --- a/tests/unit/voxel_serialization_test.py +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for Milestone 14: VoxelGrid Serialization - -Tests save/load to file and to_bytes/from_bytes memory serialization. -""" - -import mcrfpy -import sys -import os -import tempfile - -# Test counters -tests_passed = 0 -tests_failed = 0 - -def test(name, condition): - """Simple test helper""" - global tests_passed, tests_failed - if condition: - tests_passed += 1 - print(f" PASS: {name}") - else: - tests_failed += 1 - print(f" FAIL: {name}") - -# ============================================================================= -# Test basic save/load -# ============================================================================= - -print("\n=== Testing basic save/load ===") - -# Create a test grid with materials and voxel data -vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) -stone = vg.add_material("stone", (128, 128, 128)) -wood = vg.add_material("wood", (139, 90, 43), transparent=False, path_cost=0.8) -glass = vg.add_material("glass", (200, 220, 255, 128), transparent=True, path_cost=1.5) - -# Fill with some patterns -vg.fill_box((0, 0, 0), (7, 0, 7), stone) # Floor -vg.fill_box((0, 1, 0), (0, 3, 7), wood) # Wall -vg.set(4, 1, 4, glass) # Single glass block - -original_non_air = vg.count_non_air() -original_stone = vg.count_material(stone) -original_wood = vg.count_material(wood) -original_glass = vg.count_material(glass) - -print(f" Original grid: {original_non_air} non-air voxels") -print(f" Stone={original_stone}, Wood={original_wood}, Glass={original_glass}") - -# Save to temp file -with tempfile.NamedTemporaryFile(suffix='.mcvg', delete=False) as f: - temp_path = f.name - -save_result = vg.save(temp_path) -test("save() returns True on success", save_result == True) -test("File was created", os.path.exists(temp_path)) - -file_size = os.path.getsize(temp_path) -print(f" File size: {file_size} bytes") -test("File has non-zero size", file_size > 0) - -# Create new grid and load -vg2 = mcrfpy.VoxelGrid((1, 1, 1)) # Start with tiny grid -load_result = vg2.load(temp_path) -test("load() returns True on success", load_result == True) - -# Verify loaded data matches -test("Loaded size matches original", vg2.size == (8, 8, 8)) -test("Loaded cell_size matches", vg2.cell_size == 1.0) -test("Loaded material_count matches", vg2.material_count == 3) -test("Loaded count_non_air matches", vg2.count_non_air() == original_non_air) -test("Loaded stone count matches", vg2.count_material(stone) == original_stone) -test("Loaded wood count matches", vg2.count_material(wood) == original_wood) -test("Loaded glass count matches", vg2.count_material(glass) == original_glass) - -# Clean up temp file -os.unlink(temp_path) -test("Temp file cleaned up", not os.path.exists(temp_path)) - -# ============================================================================= -# Test to_bytes/from_bytes -# ============================================================================= - -print("\n=== Testing to_bytes/from_bytes ===") - -vg3 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=2.0) -mat1 = vg3.add_material("test_mat", (255, 0, 0)) -vg3.fill_box((1, 1, 1), (2, 2, 2), mat1) - -original_bytes = vg3.to_bytes() -test("to_bytes() returns bytes", isinstance(original_bytes, bytes)) -test("Bytes have content", len(original_bytes) > 0) - -print(f" Serialized to {len(original_bytes)} bytes") - -# Load into new grid -vg4 = mcrfpy.VoxelGrid((1, 1, 1)) -load_result = vg4.from_bytes(original_bytes) -test("from_bytes() returns True", load_result == True) -test("Bytes loaded - size matches", vg4.size == (4, 4, 4)) -test("Bytes loaded - cell_size matches", vg4.cell_size == 2.0) -test("Bytes loaded - voxels match", vg4.count_non_air() == vg3.count_non_air()) - -# ============================================================================= -# Test material preservation -# ============================================================================= - -print("\n=== Testing material preservation ===") - -vg5 = mcrfpy.VoxelGrid((4, 4, 4)) -mat_a = vg5.add_material("alpha", (10, 20, 30, 200), sprite_index=5, transparent=True, path_cost=0.5) -mat_b = vg5.add_material("beta", (100, 110, 120, 255), sprite_index=-1, transparent=False, path_cost=2.0) -vg5.set(0, 0, 0, mat_a) -vg5.set(1, 1, 1, mat_b) - -data = vg5.to_bytes() -vg6 = mcrfpy.VoxelGrid((1, 1, 1)) -vg6.from_bytes(data) - -# Check first material -mat_a_loaded = vg6.get_material(1) -test("Material 1 name preserved", mat_a_loaded['name'] == "alpha") -test("Material 1 color R preserved", mat_a_loaded['color'].r == 10) -test("Material 1 color G preserved", mat_a_loaded['color'].g == 20) -test("Material 1 color B preserved", mat_a_loaded['color'].b == 30) -test("Material 1 color A preserved", mat_a_loaded['color'].a == 200) -test("Material 1 sprite_index preserved", mat_a_loaded['sprite_index'] == 5) -test("Material 1 transparent preserved", mat_a_loaded['transparent'] == True) -test("Material 1 path_cost preserved", abs(mat_a_loaded['path_cost'] - 0.5) < 0.001) - -# Check second material -mat_b_loaded = vg6.get_material(2) -test("Material 2 name preserved", mat_b_loaded['name'] == "beta") -test("Material 2 transparent preserved", mat_b_loaded['transparent'] == False) -test("Material 2 path_cost preserved", abs(mat_b_loaded['path_cost'] - 2.0) < 0.001) - -# ============================================================================= -# Test voxel data integrity -# ============================================================================= - -print("\n=== Testing voxel data integrity ===") - -vg7 = mcrfpy.VoxelGrid((16, 16, 16)) -mat = vg7.add_material("checker", (255, 255, 255)) - -# Create checkerboard pattern -for z in range(16): - for y in range(16): - for x in range(16): - if (x + y + z) % 2 == 0: - vg7.set(x, y, z, mat) - -original_count = vg7.count_non_air() -print(f" Original checkerboard: {original_count} voxels") - -# Save/load -data = vg7.to_bytes() -print(f" Serialized size: {len(data)} bytes") - -vg8 = mcrfpy.VoxelGrid((1, 1, 1)) -vg8.from_bytes(data) - -test("Checkerboard voxel count preserved", vg8.count_non_air() == original_count) - -# Verify individual voxels -all_match = True -for z in range(16): - for y in range(16): - for x in range(16): - expected = mat if (x + y + z) % 2 == 0 else 0 - actual = vg8.get(x, y, z) - if actual != expected: - all_match = False - break - if not all_match: - break - -test("All checkerboard voxels match", all_match) - -# ============================================================================= -# Test RLE compression effectiveness -# ============================================================================= - -print("\n=== Testing RLE compression ===") - -# Test with uniform data (should compress well) -vg9 = mcrfpy.VoxelGrid((32, 32, 32)) -mat_uniform = vg9.add_material("solid", (100, 100, 100)) -vg9.fill(mat_uniform) - -uniform_bytes = vg9.to_bytes() -raw_size = 32 * 32 * 32 # 32768 bytes uncompressed -compressed_size = len(uniform_bytes) -compression_ratio = raw_size / compressed_size if compressed_size > 0 else 0 - -print(f" Uniform 32x32x32: raw={raw_size}, compressed={compressed_size}") -print(f" Compression ratio: {compression_ratio:.1f}x") - -test("Uniform data compresses significantly (>10x)", compression_ratio > 10) - -# Test with alternating data (should compress poorly) -vg10 = mcrfpy.VoxelGrid((32, 32, 32)) -mat_alt = vg10.add_material("alt", (200, 200, 200)) - -for z in range(32): - for y in range(32): - for x in range(32): - if (x + y + z) % 2 == 0: - vg10.set(x, y, z, mat_alt) - -alt_bytes = vg10.to_bytes() -alt_ratio = raw_size / len(alt_bytes) if len(alt_bytes) > 0 else 0 - -print(f" Alternating pattern: compressed={len(alt_bytes)}") -print(f" Compression ratio: {alt_ratio:.1f}x") - -# Alternating data should still compress somewhat due to row patterns -test("Alternating data serializes successfully", len(alt_bytes) > 0) - -# ============================================================================= -# Test error handling -# ============================================================================= - -print("\n=== Testing error handling ===") - -vg_err = mcrfpy.VoxelGrid((2, 2, 2)) - -# Test load from non-existent file -load_fail = vg_err.load("/nonexistent/path/file.mcvg") -test("load() returns False for non-existent file", load_fail == False) - -# Test from_bytes with invalid data -invalid_data = b"not valid mcvg data" -from_fail = vg_err.from_bytes(invalid_data) -test("from_bytes() returns False for invalid data", from_fail == False) - -# Test from_bytes with truncated data -vg_good = mcrfpy.VoxelGrid((2, 2, 2)) -good_data = vg_good.to_bytes() -truncated = good_data[:10] # Take only first 10 bytes -from_truncated = vg_err.from_bytes(truncated) -test("from_bytes() returns False for truncated data", from_truncated == False) - -# ============================================================================= -# Test large grid -# ============================================================================= - -print("\n=== Testing large grid ===") - -vg_large = mcrfpy.VoxelGrid((64, 32, 64)) -mat_large = vg_large.add_material("large", (50, 50, 50)) - -# Fill floor and some walls -vg_large.fill_box((0, 0, 0), (63, 0, 63), mat_large) # Floor -vg_large.fill_box((0, 1, 0), (0, 31, 63), mat_large) # Wall - -large_bytes = vg_large.to_bytes() -print(f" 64x32x64 grid: {len(large_bytes)} bytes") - -vg_large2 = mcrfpy.VoxelGrid((1, 1, 1)) -vg_large2.from_bytes(large_bytes) - -test("Large grid size preserved", vg_large2.size == (64, 32, 64)) -test("Large grid voxels preserved", vg_large2.count_non_air() == vg_large.count_non_air()) - -# ============================================================================= -# Test round-trip with transform -# ============================================================================= - -print("\n=== Testing transform preservation (not serialized) ===") - -# Note: Transform (offset, rotation) is NOT serialized - it's runtime state -vg_trans = mcrfpy.VoxelGrid((4, 4, 4)) -vg_trans.offset = (10, 20, 30) -vg_trans.rotation = 45.0 -mat_trans = vg_trans.add_material("trans", (128, 128, 128)) -vg_trans.set(0, 0, 0, mat_trans) - -data_trans = vg_trans.to_bytes() -vg_trans2 = mcrfpy.VoxelGrid((1, 1, 1)) -vg_trans2.from_bytes(data_trans) - -# Voxel data should be preserved -test("Voxel data preserved after load", vg_trans2.get(0, 0, 0) == mat_trans) - -# Transform should be at default (not serialized) -test("Offset resets to default after load", vg_trans2.offset == (0, 0, 0)) -test("Rotation resets to default after load", vg_trans2.rotation == 0.0) - -# ============================================================================= -# Summary -# ============================================================================= - -print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===") - -if tests_failed > 0: - sys.exit(1) -else: - print("All tests passed!") - sys.exit(0) diff --git a/tests/unit/voxelgrid_test.py b/tests/unit/voxelgrid_test.py deleted file mode 100644 index 18cd37e..0000000 --- a/tests/unit/voxelgrid_test.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for VoxelGrid (Milestone 9) - -Tests the core VoxelGrid data structure: -- Creation with various sizes -- Per-voxel get/set operations -- Bounds checking behavior -- Material palette management -- Bulk operations (fill, clear) -- Transform properties (offset, rotation) -- Statistics (count_non_air, count_material) -""" -import sys - -# Track test results -passed = 0 -failed = 0 - -def test(name, condition, detail=""): - """Record test result""" - global passed, failed - if condition: - print(f"[PASS] {name}") - passed += 1 - else: - print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) - failed += 1 - -def test_creation(): - """Test VoxelGrid creation with various parameters""" - import mcrfpy - - # Basic creation - vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) - test("Creation: basic", vg is not None) - test("Creation: width", vg.width == 16) - test("Creation: height", vg.height == 8) - test("Creation: depth", vg.depth == 16) - test("Creation: default cell_size", vg.cell_size == 1.0) - - # With cell_size - vg2 = mcrfpy.VoxelGrid(size=(10, 5, 10), cell_size=2.0) - test("Creation: custom cell_size", vg2.cell_size == 2.0) - - # Size property - test("Creation: size tuple", vg.size == (16, 8, 16)) - - # Initial state - test("Creation: initially empty", vg.count_non_air() == 0) - test("Creation: no materials", vg.material_count == 0) - -def test_invalid_creation(): - """Test that invalid parameters raise errors""" - import mcrfpy - - errors_caught = 0 - - try: - vg = mcrfpy.VoxelGrid(size=(0, 8, 16)) - except ValueError: - errors_caught += 1 - - try: - vg = mcrfpy.VoxelGrid(size=(16, -1, 16)) - except ValueError: - errors_caught += 1 - - try: - vg = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=-1.0) - except ValueError: - errors_caught += 1 - - try: - vg = mcrfpy.VoxelGrid(size=(16, 8)) # Missing dimension - except (ValueError, TypeError): - errors_caught += 1 - - test("Invalid creation: catches errors", errors_caught == 4, f"caught {errors_caught}/4") - -def test_get_set(): - """Test per-voxel get/set operations""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Initially all air - test("Get/Set: initial value is air", vg.get(0, 0, 0) == 0) - - # Set and get - vg.set(5, 3, 7, stone) - test("Get/Set: set then get", vg.get(5, 3, 7) == stone) - - # Verify adjacent cells unaffected - test("Get/Set: adjacent unaffected", vg.get(5, 3, 6) == 0) - test("Get/Set: adjacent unaffected 2", vg.get(4, 3, 7) == 0) - - # Set back to air - vg.set(5, 3, 7, 0) - test("Get/Set: set to air", vg.get(5, 3, 7) == 0) - - # Multiple materials - wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43)) - vg.set(0, 0, 0, stone) - vg.set(1, 0, 0, wood) - vg.set(2, 0, 0, stone) - test("Get/Set: multiple materials", - vg.get(0, 0, 0) == stone and vg.get(1, 0, 0) == wood and vg.get(2, 0, 0) == stone) - -def test_bounds(): - """Test bounds checking behavior""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 4, 8)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Out of bounds get returns 0 (air) - test("Bounds: negative x", vg.get(-1, 0, 0) == 0) - test("Bounds: negative y", vg.get(0, -1, 0) == 0) - test("Bounds: negative z", vg.get(0, 0, -1) == 0) - test("Bounds: overflow x", vg.get(8, 0, 0) == 0) - test("Bounds: overflow y", vg.get(0, 4, 0) == 0) - test("Bounds: overflow z", vg.get(0, 0, 8) == 0) - test("Bounds: large overflow", vg.get(100, 100, 100) == 0) - - # Out of bounds set is silently ignored (no crash) - vg.set(-1, 0, 0, stone) # Should not crash - vg.set(100, 0, 0, stone) # Should not crash - test("Bounds: OOB set doesn't crash", True) - - # Corner cases - max valid indices - vg.set(7, 3, 7, stone) - test("Bounds: max valid index", vg.get(7, 3, 7) == stone) - -def test_materials(): - """Test material palette management""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - - # Add first material - stone_id = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - test("Materials: first ID is 1", stone_id == 1) - - # Add with all properties - glass_id = vg.add_material("glass", - color=mcrfpy.Color(200, 200, 255, 128), - sprite_index=5, - transparent=True, - path_cost=0.5) - test("Materials: second ID is 2", glass_id == 2) - - # Verify material count - test("Materials: count", vg.material_count == 2) - - # Get material and verify properties - stone = vg.get_material(stone_id) - test("Materials: name", stone["name"] == "stone") - test("Materials: color type", hasattr(stone["color"], 'r')) - test("Materials: default sprite_index", stone["sprite_index"] == -1) - test("Materials: default transparent", stone["transparent"] == False) - test("Materials: default path_cost", stone["path_cost"] == 1.0) - - glass = vg.get_material(glass_id) - test("Materials: custom sprite_index", glass["sprite_index"] == 5) - test("Materials: custom transparent", glass["transparent"] == True) - test("Materials: custom path_cost", glass["path_cost"] == 0.5) - - # Air material (ID 0) - air = vg.get_material(0) - test("Materials: air name", air["name"] == "air") - test("Materials: air transparent", air["transparent"] == True) - - # Invalid material ID returns air - invalid = vg.get_material(255) - test("Materials: invalid returns air", invalid["name"] == "air") - -def test_fill_clear(): - """Test bulk fill and clear operations""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(10, 5, 10)) - total = 10 * 5 * 10 # 500 - - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill with material - vg.fill(stone) - test("Fill: all cells filled", vg.count_non_air() == total) - test("Fill: specific cell", vg.get(5, 2, 5) == stone) - test("Fill: corner cell", vg.get(0, 0, 0) == stone) - test("Fill: opposite corner", vg.get(9, 4, 9) == stone) - - # Clear (fill with air) - vg.clear() - test("Clear: all cells empty", vg.count_non_air() == 0) - test("Clear: specific cell", vg.get(5, 2, 5) == 0) - -def test_transform(): - """Test transform properties (offset, rotation)""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - - # Default values - test("Transform: default offset", vg.offset == (0.0, 0.0, 0.0)) - test("Transform: default rotation", vg.rotation == 0.0) - - # Set offset - vg.offset = (10.5, -5.0, 20.0) - offset = vg.offset - test("Transform: set offset x", abs(offset[0] - 10.5) < 0.001) - test("Transform: set offset y", abs(offset[1] - (-5.0)) < 0.001) - test("Transform: set offset z", abs(offset[2] - 20.0) < 0.001) - - # Set rotation - vg.rotation = 45.0 - test("Transform: set rotation", abs(vg.rotation - 45.0) < 0.001) - - # Negative rotation - vg.rotation = -90.0 - test("Transform: negative rotation", abs(vg.rotation - (-90.0)) < 0.001) - - # Large rotation - vg.rotation = 720.0 - test("Transform: large rotation", abs(vg.rotation - 720.0) < 0.001) - -def test_statistics(): - """Test statistics methods""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) - - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43)) - - # Initially empty - test("Stats: initial non_air", vg.count_non_air() == 0) - test("Stats: initial stone count", vg.count_material(stone) == 0) - - # Add some voxels - for i in range(5): - vg.set(i, 0, 0, stone) - for i in range(3): - vg.set(i, 1, 0, wood) - - test("Stats: non_air after setting", vg.count_non_air() == 8) - test("Stats: stone count", vg.count_material(stone) == 5) - test("Stats: wood count", vg.count_material(wood) == 3) - test("Stats: air count", vg.count_material(0) == 10*10*10 - 8) - -def test_repr(): - """Test string representation""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - vg.set(0, 0, 0, stone) - - repr_str = repr(vg) - test("Repr: contains VoxelGrid", "VoxelGrid" in repr_str) - test("Repr: contains dimensions", "16x8x16" in repr_str) - test("Repr: contains materials", "materials=1" in repr_str) - test("Repr: contains non_air", "non_air=1" in repr_str) - -def test_large_grid(): - """Test with larger grid sizes""" - import mcrfpy - - # 64x64x64 = 262144 voxels - vg = mcrfpy.VoxelGrid(size=(64, 64, 64)) - test("Large: creation", vg is not None) - test("Large: size", vg.size == (64, 64, 64)) - - stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) - - # Fill entire grid - vg.fill(stone) - expected = 64 * 64 * 64 - test("Large: fill count", vg.count_non_air() == expected, f"got {vg.count_non_air()}, expected {expected}") - - # Clear - vg.clear() - test("Large: clear", vg.count_non_air() == 0) - -def test_material_limit(): - """Test material palette limit (255 max)""" - import mcrfpy - - vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) - - # Add many materials - for i in range(255): - mat_id = vg.add_material(f"mat_{i}", color=mcrfpy.Color(i, i, i)) - if mat_id != i + 1: - test("Material limit: IDs sequential", False, f"expected {i+1}, got {mat_id}") - return - - test("Material limit: 255 materials added", vg.material_count == 255) - - # 256th should fail - try: - vg.add_material("overflow", color=mcrfpy.Color(255, 255, 255)) - test("Material limit: overflow error", False, "should have raised exception") - except RuntimeError: - test("Material limit: overflow error", True) - -def main(): - """Run all tests""" - print("=" * 60) - print("VoxelGrid Unit Tests (Milestone 9)") - print("=" * 60) - print() - - test_creation() - print() - test_invalid_creation() - print() - test_get_set() - print() - test_bounds() - print() - test_materials() - print() - test_fill_clear() - print() - test_transform() - print() - test_statistics() - print() - test_repr() - print() - test_large_grid() - print() - test_material_limit() - print() - - print("=" * 60) - print(f"Results: {passed} passed, {failed} failed") - print("=" * 60) - - return 0 if failed == 0 else 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/unit/voxelpoint_test.py b/tests/unit/voxelpoint_test.py deleted file mode 100644 index 7b2ff7b..0000000 --- a/tests/unit/voxelpoint_test.py +++ /dev/null @@ -1,196 +0,0 @@ -# voxelpoint_test.py - Unit tests for VoxelPoint navigation grid -# Tests grid creation, cell access, and property modification - -import mcrfpy -import sys - -def test_grid_creation(): - """Test creating and sizing a navigation grid""" - print("Testing navigation grid creation...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Initial grid should be 0x0 - assert viewport.grid_size == (0, 0), f"Expected (0, 0), got {viewport.grid_size}" - - # Set grid size via property - viewport.grid_size = (10, 8) - assert viewport.grid_size == (10, 8), f"Expected (10, 8), got {viewport.grid_size}" - - # Set grid size via method - viewport.set_grid_size(20, 15) - assert viewport.grid_size == (20, 15), f"Expected (20, 15), got {viewport.grid_size}" - - print(" PASS: Grid creation and sizing") - - -def test_voxelpoint_access(): - """Test accessing VoxelPoint cells""" - print("Testing VoxelPoint access...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Access a cell - vp = viewport.at(5, 5) - assert vp is not None, "at() returned None" - - # Check grid_pos - assert vp.grid_pos == (5, 5), f"Expected grid_pos (5, 5), got {vp.grid_pos}" - - # Test bounds checking - try: - viewport.at(-1, 0) - assert False, "Expected IndexError for negative coordinate" - except IndexError: - pass - - try: - viewport.at(10, 5) # Out of bounds (0-9 valid) - assert False, "Expected IndexError for out of bounds" - except IndexError: - pass - - print(" PASS: VoxelPoint access") - - -def test_voxelpoint_properties(): - """Test VoxelPoint property read/write""" - print("Testing VoxelPoint properties...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - vp = viewport.at(3, 4) - - # Test default values - assert vp.walkable == True, f"Default walkable should be True, got {vp.walkable}" - assert vp.transparent == True, f"Default transparent should be True, got {vp.transparent}" - assert vp.height == 0.0, f"Default height should be 0.0, got {vp.height}" - assert vp.cost == 1.0, f"Default cost should be 1.0, got {vp.cost}" - - # Test setting bool properties - vp.walkable = False - assert vp.walkable == False, "walkable not set to False" - - vp.transparent = False - assert vp.transparent == False, "transparent not set to False" - - # Test setting float properties - vp.height = 5.5 - assert abs(vp.height - 5.5) < 0.01, f"height should be 5.5, got {vp.height}" - - vp.cost = 2.0 - assert abs(vp.cost - 2.0) < 0.01, f"cost should be 2.0, got {vp.cost}" - - # Test cost must be non-negative - try: - vp.cost = -1.0 - assert False, "Expected ValueError for negative cost" - except ValueError: - pass - - print(" PASS: VoxelPoint properties") - - -def test_voxelpoint_persistence(): - """Test that VoxelPoint changes persist in the grid""" - print("Testing VoxelPoint persistence...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - # Modify a cell - vp = viewport.at(2, 3) - vp.walkable = False - vp.height = 10.0 - vp.cost = 3.5 - - # Access the same cell again - vp2 = viewport.at(2, 3) - assert vp2.walkable == False, "walkable change did not persist" - assert abs(vp2.height - 10.0) < 0.01, "height change did not persist" - assert abs(vp2.cost - 3.5) < 0.01, "cost change did not persist" - - # Make sure other cells are unaffected - vp3 = viewport.at(2, 4) - assert vp3.walkable == True, "Adjacent cell was modified" - assert vp3.height == 0.0, "Adjacent cell height was modified" - - print(" PASS: VoxelPoint persistence") - - -def test_cell_size_property(): - """Test cell_size property""" - print("Testing cell_size property...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - - # Default cell size should be 1.0 - assert abs(viewport.cell_size - 1.0) < 0.01, f"Default cell_size should be 1.0, got {viewport.cell_size}" - - # Set cell size - viewport.cell_size = 2.5 - assert abs(viewport.cell_size - 2.5) < 0.01, f"cell_size should be 2.5, got {viewport.cell_size}" - - # cell_size must be positive - try: - viewport.cell_size = 0 - assert False, "Expected ValueError for zero cell_size" - except ValueError: - pass - - try: - viewport.cell_size = -1.0 - assert False, "Expected ValueError for negative cell_size" - except ValueError: - pass - - print(" PASS: cell_size property") - - -def test_repr(): - """Test VoxelPoint __repr__""" - print("Testing VoxelPoint repr...") - - viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) - viewport.grid_size = (10, 10) - - vp = viewport.at(3, 7) - r = repr(vp) - assert "VoxelPoint" in r, f"repr should contain 'VoxelPoint', got {r}" - assert "3, 7" in r, f"repr should contain '3, 7', got {r}" - - print(" PASS: VoxelPoint repr") - - -def run_all_tests(): - """Run all unit tests""" - print("=" * 60) - print("VoxelPoint Unit Tests") - print("=" * 60) - - try: - test_grid_creation() - test_voxelpoint_access() - test_voxelpoint_properties() - test_voxelpoint_persistence() - test_cell_size_property() - test_repr() - - print("=" * 60) - print("ALL TESTS PASSED") - print("=" * 60) - sys.exit(0) - except AssertionError as e: - print(f"FAIL: {e}") - sys.exit(1) - except Exception as e: - print(f"ERROR: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -# Run tests -run_all_tests() diff --git a/tests/unit/wang_resolve_test.py b/tests/unit/wang_resolve_test.py deleted file mode 100644 index 21107a3..0000000 --- a/tests/unit/wang_resolve_test.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Unit tests for WangSet terrain_enum, resolve, and apply""" -import mcrfpy -import sys - -PASS_COUNT = 0 -FAIL_COUNT = 0 - -def check(condition, msg): - global PASS_COUNT, FAIL_COUNT - if condition: - PASS_COUNT += 1 - print(f" PASS: {msg}") - else: - FAIL_COUNT += 1 - print(f" FAIL: {msg}") - -def test_terrain_enum(): - """Test IntEnum generation from WangSet colors""" - print("=== Terrain Enum ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - Terrain = ws.terrain_enum() - - check(Terrain is not None, "terrain_enum() returns something") - check(hasattr(Terrain, "NONE"), "has NONE member") - check(hasattr(Terrain, "GRASS"), "has GRASS member") - check(hasattr(Terrain, "DIRT"), "has DIRT member") - check(int(Terrain.NONE) == 0, f"NONE = {int(Terrain.NONE)}") - check(int(Terrain.GRASS) == 1, f"GRASS = {int(Terrain.GRASS)}") - check(int(Terrain.DIRT) == 2, f"DIRT = {int(Terrain.DIRT)}") - - # Check it's an IntEnum - import enum - check(issubclass(Terrain, enum.IntEnum), "is IntEnum subclass") - return Terrain - -def test_enum_with_discrete_map(Terrain): - """Test that terrain enum is compatible with DiscreteMap""" - print("\n=== Enum + DiscreteMap ===") - dm = mcrfpy.DiscreteMap((4, 4)) - dm.enum_type = Terrain - check(dm.enum_type == Terrain, "DiscreteMap accepts terrain enum") - - # Set values using enum - dm.set(0, 0, Terrain.GRASS) - dm.set(1, 0, Terrain.DIRT) - val = dm.get(0, 0) - check(int(val) == int(Terrain.GRASS), f"get(0,0) = {val}") - val = dm.get(1, 0) - check(int(val) == int(Terrain.DIRT), f"get(1,0) = {val}") - -def test_resolve_uniform(): - """Test resolve with uniform terrain""" - print("\n=== Resolve Uniform ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - - # All grass (terrain ID 1) - dm = mcrfpy.DiscreteMap((3, 3)) - dm.fill(1) # All grass - - tiles = ws.resolve(dm) - check(isinstance(tiles, list), f"resolve returns list: {type(tiles)}") - check(len(tiles) == 9, f"resolve length = {len(tiles)}") - - # All cells should map to the "all grass corners" tile (id=0) - # wangid [0,1,0,1,0,1,0,1] = tile 0 - # Note: border cells will see 0 (NONE) on their outer edges, so may not match - # Center cell (1,1) sees all grass neighbors -> should be tile 0 - center = tiles[4] # (1,1) in 3x3 - check(center == 0, f"center tile (uniform grass) = {center}") - -def test_resolve_mixed(): - """Test resolve with mixed terrain""" - print("\n=== Resolve Mixed ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - - # Create a 3x3 grid: grass everywhere except center = dirt - dm = mcrfpy.DiscreteMap((3, 3)) - dm.fill(1) # All grass - dm.set(1, 1, 2) # Center = dirt - - tiles = ws.resolve(dm) - check(len(tiles) == 9, f"resolve length = {len(tiles)}") - - # The center cell has grass neighbors and is dirt itself - # Corners depend on the max of surrounding cells - center = tiles[4] - # Center: all 4 corners should be max(dirt, grass neighbors) = 2 (dirt) - # wangid [0,2,0,2,0,2,0,2] = tile 1 (all-dirt) - check(center == 1, f"center (dirt surrounded by grass) = {center}") - -def test_resolve_returns_negative_for_unknown(): - """Test that unknown wangid combinations return -1""" - print("\n=== Unknown WangID ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - - # Use terrain ID 3 which doesn't exist in the wang set - dm = mcrfpy.DiscreteMap((2, 2)) - dm.fill(3) # Terrain 3 not in wang set - - tiles = ws.resolve(dm) - # All should be -1 since terrain 3 has no matching wangid - all_neg = all(t == -1 for t in tiles) - check(all_neg, f"all tiles = -1 for unknown terrain: {tiles}") - -def test_resolve_border_handling(): - """Test that border cells handle out-of-bounds correctly""" - print("\n=== Border Handling ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - - # 1x1 grid - all neighbors are out-of-bounds (0) - dm = mcrfpy.DiscreteMap((1, 1)) - dm.set(0, 0, 1) # Single grass cell - - tiles = ws.resolve(dm) - check(len(tiles) == 1, f"1x1 resolve length = {len(tiles)}") - # Corner terrain: max(0, 0, 0, grass) = 1 for each corner -> all grass - # wangid [0,1,0,1,0,1,0,1] = tile 0 - check(tiles[0] == 0, f"1x1 grass tile = {tiles[0]}") - -def test_wang_set_repr(): - """Test WangSet repr""" - print("\n=== WangSet Repr ===") - ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") - ws = ts.wang_set("terrain") - r = repr(ws) - check("WangSet" in r, f"repr contains 'WangSet': {r}") - check("terrain" in r, f"repr contains name: {r}") - check("corner" in r, f"repr contains type: {r}") - -def main(): - Terrain = test_terrain_enum() - test_enum_with_discrete_map(Terrain) - test_resolve_uniform() - test_resolve_mixed() - test_resolve_returns_negative_for_unknown() - test_resolve_border_handling() - test_wang_set_repr() - - print(f"\n{'='*40}") - print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") - if FAIL_COUNT > 0: - sys.exit(1) - else: - print("ALL TESTS PASSED") - sys.exit(0) - -if __name__ == "__main__": - main()