Compare commits
3 commits
e12e80e511
...
322beeaf78
| Author | SHA1 | Date | |
|---|---|---|---|
| 322beeaf78 | |||
| b093e087e1 | |||
| 71cd2b9b41 |
45 changed files with 7791 additions and 6 deletions
6
.gitmodules
vendored
6
.gitmodules
vendored
|
|
@ -14,3 +14,9 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ 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)
|
||||
|
|
|
|||
1
modules/RapidXML
Submodule
1
modules/RapidXML
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 3a42082084509e9efb58dcef17b1ad5860dab6ac
|
||||
1
modules/json
Submodule
1
modules/json
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 21b53746c9d73d314d5de454e2e7cddd20cbbe5d
|
||||
|
|
@ -37,6 +37,9 @@
|
|||
#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
|
||||
|
|
@ -486,6 +489,11 @@ 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)
|
||||
|
|
@ -559,6 +567,14 @@ 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);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ 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
|
||||
|
|
|
|||
|
|
@ -217,6 +217,14 @@ 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
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ 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
|
||||
|
|
|
|||
332
src/tiled/PyTileMapFile.cpp
Normal file
332
src/tiled/PyTileMapFile.cpp
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
#include "PyTileMapFile.h"
|
||||
#include "PyTileSetFile.h"
|
||||
#include "TiledParse.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
#include "GridLayers.h"
|
||||
#include <cstring>
|
||||
|
||||
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<TileMapData>();
|
||||
}
|
||||
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<char**>(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("<TileMapFile (uninitialized)>");
|
||||
}
|
||||
return PyUnicode_FromFormat("<TileMapFile %dx%d, %d tilesets, %d tile layers, %d object layers>",
|
||||
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<TileSetData>(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<char**>(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<int>(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}
|
||||
};
|
||||
81
src/tiled/PyTileMapFile.h
Normal file
81
src/tiled/PyTileMapFile.h
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#pragma once
|
||||
#include "Python.h"
|
||||
#include "TiledTypes.h"
|
||||
#include <memory>
|
||||
|
||||
// Python object structure
|
||||
typedef struct PyTileMapFileObject {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<mcrf::tiled::TileMapData> 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
|
||||
234
src/tiled/PyTileSetFile.cpp
Normal file
234
src/tiled/PyTileSetFile.cpp
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
#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<TileSetData>();
|
||||
}
|
||||
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<char**>(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("<TileSetFile (uninitialized)>");
|
||||
}
|
||||
return PyUnicode_FromFormat("<TileSetFile '%s' (%d tiles, %dx%d)>",
|
||||
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<int>(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<int>(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}
|
||||
};
|
||||
79
src/tiled/PyTileSetFile.h
Normal file
79
src/tiled/PyTileSetFile.h
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#pragma once
|
||||
#include "Python.h"
|
||||
#include "TiledTypes.h"
|
||||
#include <memory>
|
||||
|
||||
// Python object structure
|
||||
typedef struct PyTileSetFileObject {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<mcrf::tiled::TileSetData> 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
|
||||
266
src/tiled/PyWangSet.cpp
Normal file
266
src/tiled/PyWangSet.cpp
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
#include "PyWangSet.h"
|
||||
#include "TiledParse.h"
|
||||
#include "WangResolve.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
#include "PyDiscreteMap.h"
|
||||
#include "GridLayers.h"
|
||||
#include <cstring>
|
||||
|
||||
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<TileSetData> 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<TileSetData>(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("<WangSet '%s' type='%s' colors=%d>",
|
||||
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<char>(toupper(static_cast<unsigned char>(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<int> 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<char**>(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<int> 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}
|
||||
};
|
||||
72
src/tiled/PyWangSet.h
Normal file
72
src/tiled/PyWangSet.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
#include "Python.h"
|
||||
#include "TiledTypes.h"
|
||||
#include <memory>
|
||||
|
||||
// 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<mcrf::tiled::TileSetData> 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<mcrf::tiled::TileSetData> 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
|
||||
772
src/tiled/TiledParse.cpp
Normal file
772
src/tiled/TiledParse.cpp
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
#include "TiledParse.h"
|
||||
#include "RapidXML/rapidxml.hpp"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
|
||||
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<std::string, PropertyValue> convertProperties(
|
||||
const std::vector<RawProperty>& raw_props) {
|
||||
std::unordered_map<std::string, PropertyValue> result;
|
||||
for (const auto& rp : raw_props) {
|
||||
result[rp.name] = convertProperty(rp);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WangSet packing
|
||||
// ============================================================
|
||||
|
||||
uint64_t WangSet::packWangId(const std::array<int, 8>& 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<uint64_t>(id[i] & 0xFF)) << (i * 8);
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// XML property parsing (shared by TSX and TMX)
|
||||
// ============================================================
|
||||
|
||||
static void parseXmlProperties(rapidxml::xml_node<>* parent, std::vector<RawProperty>& 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 <tileset> 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<int, 8> 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<RawProperty>& 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<bool>() ? "true" : "false";
|
||||
} else if (val.is_number_integer()) {
|
||||
rp.type = "int";
|
||||
rp.value = std::to_string(val.get<int>());
|
||||
} else if (val.is_number_float()) {
|
||||
rp.type = "float";
|
||||
rp.value = std::to_string(val.get<float>());
|
||||
} else if (val.is_string()) {
|
||||
rp.value = val.get<std::string>();
|
||||
}
|
||||
}
|
||||
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<int, 8> 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<int>();
|
||||
}
|
||||
}
|
||||
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<TileSetData> buildTileSet(const RawTileSet& raw, const std::string& source_path) {
|
||||
auto ts = std::make_shared<TileSetData>();
|
||||
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 <map> 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<uint32_t>(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<RawProperty> 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<uint32_t>());
|
||||
}
|
||||
}
|
||||
}
|
||||
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<TileMapData> buildTileMap(const RawTileMap& raw, const std::string& source_path) {
|
||||
auto tm = std::make_shared<TileMapData>();
|
||||
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<TileSetData> 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<TileMapData> 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<bool>());
|
||||
}
|
||||
if (j.is_number_integer()) {
|
||||
return PyLong_FromLongLong(j.get<long long>());
|
||||
}
|
||||
if (j.is_number_float()) {
|
||||
return PyFloat_FromDouble(j.get<double>());
|
||||
}
|
||||
if (j.is_string()) {
|
||||
const std::string& s = j.get_ref<const std::string&>();
|
||||
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<decltype(arg)>;
|
||||
if constexpr (std::is_same_v<T, bool>) {
|
||||
return PyBool_FromLong(arg);
|
||||
} else if constexpr (std::is_same_v<T, int>) {
|
||||
return PyLong_FromLong(arg);
|
||||
} else if constexpr (std::is_same_v<T, float>) {
|
||||
return PyFloat_FromDouble(arg);
|
||||
} else if constexpr (std::is_same_v<T, std::string>) {
|
||||
return PyUnicode_FromStringAndSize(arg.c_str(), arg.size());
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}, val);
|
||||
}
|
||||
|
||||
PyObject* propertiesToPython(const std::unordered_map<std::string, PropertyValue>& 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
|
||||
24
src/tiled/TiledParse.h
Normal file
24
src/tiled/TiledParse.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
#include "TiledTypes.h"
|
||||
#include <Python.h>
|
||||
|
||||
namespace mcrf {
|
||||
namespace tiled {
|
||||
|
||||
// Load a tileset from .tsx or .tsj (auto-detect by extension)
|
||||
std::shared_ptr<TileSetData> loadTileSet(const std::string& path);
|
||||
|
||||
// Load a tilemap from .tmx or .tmj (auto-detect by extension)
|
||||
std::shared_ptr<TileMapData> 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<std::string, PropertyValue>& props);
|
||||
|
||||
} // namespace tiled
|
||||
} // namespace mcrf
|
||||
186
src/tiled/TiledTypes.h
Normal file
186
src/tiled/TiledTypes.h
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <unordered_map>
|
||||
#include <variant>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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<RawProperty> properties;
|
||||
std::vector<std::pair<int, int>> 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<int, 8> wang_id;
|
||||
};
|
||||
|
||||
struct RawWangSet {
|
||||
std::string name;
|
||||
std::string type; // "corner", "edge", "mixed"
|
||||
std::vector<RawWangColor> colors;
|
||||
std::vector<RawWangTile> 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<RawProperty> properties;
|
||||
std::vector<RawTile> tiles;
|
||||
std::vector<RawWangSet> 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<RawProperty> properties;
|
||||
std::vector<uint32_t> 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<RawProperty> properties;
|
||||
std::vector<RawTileSetRef> tileset_refs;
|
||||
std::vector<RawLayer> layers;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Final (built) types — what Python bindings expose
|
||||
// ============================================================
|
||||
|
||||
using PropertyValue = std::variant<bool, int, float, std::string>;
|
||||
|
||||
struct KeyFrame {
|
||||
int tile_id;
|
||||
int duration_ms;
|
||||
};
|
||||
|
||||
struct TileInfo {
|
||||
int id;
|
||||
std::unordered_map<std::string, PropertyValue> properties;
|
||||
std::vector<KeyFrame> 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<WangColor> colors;
|
||||
// Maps packed wang_id → tile_id for O(1) lookup
|
||||
std::unordered_map<uint64_t, int> wang_lookup;
|
||||
|
||||
static uint64_t packWangId(const std::array<int, 8>& 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<std::string, PropertyValue> properties;
|
||||
std::unordered_map<int, TileInfo> tile_info;
|
||||
std::vector<WangSet> wang_sets;
|
||||
};
|
||||
|
||||
struct TileLayerData {
|
||||
std::string name;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
bool visible = true;
|
||||
float opacity = 1.0f;
|
||||
std::vector<uint32_t> global_gids;
|
||||
};
|
||||
|
||||
struct ObjectLayerData {
|
||||
std::string name;
|
||||
bool visible = true;
|
||||
float opacity = 1.0f;
|
||||
nlohmann::json objects;
|
||||
std::unordered_map<std::string, PropertyValue> 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<std::string, PropertyValue> properties;
|
||||
|
||||
struct TileSetRef {
|
||||
int firstgid;
|
||||
std::shared_ptr<TileSetData> tileset;
|
||||
};
|
||||
std::vector<TileSetRef> tilesets;
|
||||
std::vector<TileLayerData> tile_layers;
|
||||
std::vector<ObjectLayerData> object_layers;
|
||||
};
|
||||
|
||||
} // namespace tiled
|
||||
} // namespace mcrf
|
||||
142
src/tiled/WangResolve.cpp
Normal file
142
src/tiled/WangResolve.cpp
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
#include "WangResolve.h"
|
||||
#include <array>
|
||||
|
||||
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<int> resolveWangTerrain(
|
||||
const uint8_t* terrain_data, int width, int height,
|
||||
const WangSet& wang_set)
|
||||
{
|
||||
std::vector<int> 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<int, 8> 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<int, 8> 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<int, 8> 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
|
||||
17
src/tiled/WangResolve.h
Normal file
17
src/tiled/WangResolve.h
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#pragma once
|
||||
#include "TiledTypes.h"
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
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<int> resolveWangTerrain(
|
||||
const uint8_t* terrain_data, int width, int height,
|
||||
const WangSet& wang_set);
|
||||
|
||||
} // namespace tiled
|
||||
} // namespace mcrf
|
||||
|
|
@ -212,26 +212,26 @@ class CookbookLauncher:
|
|||
category = self.categories[self.selected_category]
|
||||
items = self.DEMOS[category]
|
||||
|
||||
if key == "Escape":
|
||||
if key == mcrfpy.Key.ESCAPE:
|
||||
sys.exit(0)
|
||||
elif key == "Left":
|
||||
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
|
||||
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 == "Right":
|
||||
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
|
||||
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 == "Up":
|
||||
elif key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
|
||||
self.selected_item = (self.selected_item - 1) % len(items)
|
||||
self._update_selection()
|
||||
elif key == "Down":
|
||||
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
|
||||
self.selected_item = (self.selected_item + 1) % len(items)
|
||||
self._update_selection()
|
||||
elif key == "Enter":
|
||||
elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE:
|
||||
self._run_selected_demo()
|
||||
|
||||
def activate(self):
|
||||
|
|
|
|||
167
tests/demo/screens/tiled_analysis.py
Normal file
167
tests/demo/screens/tiled_analysis.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# 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)
|
||||
504
tests/demo/screens/tiled_demo.py
Normal file
504
tests/demo/screens/tiled_demo.py
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
# 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")
|
||||
98
tests/unit/animated_model_test.py
Normal file
98
tests/unit/animated_model_test.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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)
|
||||
159
tests/unit/animation_test.py
Normal file
159
tests/unit/animation_test.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# 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)
|
||||
260
tests/unit/billboard_test.py
Normal file
260
tests/unit/billboard_test.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
# 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)
|
||||
293
tests/unit/entity3d_test.py
Normal file
293
tests/unit/entity3d_test.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
# 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)
|
||||
198
tests/unit/fov_3d_test.py
Normal file
198
tests/unit/fov_3d_test.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# 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()
|
||||
78
tests/unit/integration_api_test.py
Normal file
78
tests/unit/integration_api_test.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# 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)
|
||||
182
tests/unit/mesh_instance_test.py
Normal file
182
tests/unit/mesh_instance_test.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# 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)
|
||||
202
tests/unit/meshlayer_test.py
Normal file
202
tests/unit/meshlayer_test.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# 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()
|
||||
219
tests/unit/model3d_test.py
Normal file
219
tests/unit/model3d_test.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# 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)
|
||||
208
tests/unit/pathfinding_3d_test.py
Normal file
208
tests/unit/pathfinding_3d_test.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# 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()
|
||||
241
tests/unit/procgen_interactive_test.py
Normal file
241
tests/unit/procgen_interactive_test.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#!/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()
|
||||
80
tests/unit/skeleton_test.py
Normal file
80
tests/unit/skeleton_test.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# 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)
|
||||
189
tests/unit/tilemap_file_test.py
Normal file
189
tests/unit/tilemap_file_test.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""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()
|
||||
145
tests/unit/tileset_file_test.py
Normal file
145
tests/unit/tileset_file_test.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""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()
|
||||
335
tests/unit/voxel_bulk_ops_test.py
Normal file
335
tests/unit/voxel_bulk_ops_test.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
#!/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())
|
||||
240
tests/unit/voxel_greedy_meshing_test.py
Normal file
240
tests/unit/voxel_greedy_meshing_test.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/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)
|
||||
300
tests/unit/voxel_meshing_test.py
Normal file
300
tests/unit/voxel_meshing_test.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
#!/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())
|
||||
247
tests/unit/voxel_navigation_test.py
Normal file
247
tests/unit/voxel_navigation_test.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#!/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)
|
||||
189
tests/unit/voxel_rendering_test.py
Normal file
189
tests/unit/voxel_rendering_test.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/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())
|
||||
301
tests/unit/voxel_serialization_test.py
Normal file
301
tests/unit/voxel_serialization_test.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
#!/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)
|
||||
345
tests/unit/voxelgrid_test.py
Normal file
345
tests/unit/voxelgrid_test.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
#!/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())
|
||||
196
tests/unit/voxelpoint_test.py
Normal file
196
tests/unit/voxelpoint_test.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# 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()
|
||||
153
tests/unit/wang_resolve_test.py
Normal file
153
tests/unit/wang_resolve_test.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue