diff --git a/src/3d/PyVoxelGrid.cpp b/src/3d/PyVoxelGrid.cpp new file mode 100644 index 0000000..3325a73 --- /dev/null +++ b/src/3d/PyVoxelGrid.cpp @@ -0,0 +1,598 @@ +// PyVoxelGrid.cpp - Python bindings for VoxelGrid implementation +// Part of McRogueFace 3D Extension - Milestone 9 + +#include "PyVoxelGrid.h" +#include "../McRFPy_API.h" +#include "../PyColor.h" +#include + +// ============================================================================= +// Python type interface +// ============================================================================= + +PyObject* PyVoxelGrid::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + PyVoxelGridObject* self = (PyVoxelGridObject*)type->tp_alloc(type, 0); + if (self) { + self->data = nullptr; // Will be initialized in init + self->weakreflist = nullptr; + } + return (PyObject*)self; +} + +int PyVoxelGrid::init(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"size", "cell_size", nullptr}; + + PyObject* size_obj = nullptr; + float cell_size = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(kwlist), + &size_obj, &cell_size)) { + return -1; + } + + // Parse size tuple + if (!PyTuple_Check(size_obj) && !PyList_Check(size_obj)) { + PyErr_SetString(PyExc_TypeError, "size must be a tuple or list of 3 integers"); + return -1; + } + + if (PySequence_Size(size_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "size must have exactly 3 elements (width, height, depth)"); + return -1; + } + + int width = 0, height = 0, depth = 0; + + PyObject* w_obj = PySequence_GetItem(size_obj, 0); + PyObject* h_obj = PySequence_GetItem(size_obj, 1); + PyObject* d_obj = PySequence_GetItem(size_obj, 2); + + bool valid = true; + if (PyLong_Check(w_obj)) width = (int)PyLong_AsLong(w_obj); else valid = false; + if (PyLong_Check(h_obj)) height = (int)PyLong_AsLong(h_obj); else valid = false; + if (PyLong_Check(d_obj)) depth = (int)PyLong_AsLong(d_obj); else valid = false; + + Py_DECREF(w_obj); + Py_DECREF(h_obj); + Py_DECREF(d_obj); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "size elements must be integers"); + return -1; + } + + if (width <= 0 || height <= 0 || depth <= 0) { + PyErr_SetString(PyExc_ValueError, "size dimensions must be positive"); + return -1; + } + + if (cell_size <= 0.0f) { + PyErr_SetString(PyExc_ValueError, "cell_size must be positive"); + return -1; + } + + // Create the VoxelGrid + try { + self->data = std::make_shared(width, height, depth, cell_size); + } catch (const std::exception& e) { + PyErr_SetString(PyExc_RuntimeError, e.what()); + return -1; + } + + return 0; +} + +void PyVoxelGrid::dealloc(PyVoxelGridObject* self) { + PyObject_GC_UnTrack(self); + if (self->weakreflist != nullptr) { + PyObject_ClearWeakRefs((PyObject*)self); + } + self->data.reset(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyVoxelGrid::repr(PyObject* obj) { + PyVoxelGridObject* self = (PyVoxelGridObject*)obj; + if (!self->data) { + return PyUnicode_FromString(""); + } + + std::ostringstream oss; + oss << "data->width() << "x" + << self->data->height() << "x" << self->data->depth() + << " cells=" << self->data->totalVoxels() + << " materials=" << self->data->materialCount() + << " non_air=" << self->data->countNonAir() << ">"; + return PyUnicode_FromString(oss.str().c_str()); +} + +// ============================================================================= +// Properties - dimensions (read-only) +// ============================================================================= + +PyObject* PyVoxelGrid::get_size(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return Py_BuildValue("(iii)", self->data->width(), self->data->height(), self->data->depth()); +} + +PyObject* PyVoxelGrid::get_width(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyLong_FromLong(self->data->width()); +} + +PyObject* PyVoxelGrid::get_height(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyLong_FromLong(self->data->height()); +} + +PyObject* PyVoxelGrid::get_depth(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyLong_FromLong(self->data->depth()); +} + +PyObject* PyVoxelGrid::get_cell_size(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyFloat_FromDouble(self->data->cellSize()); +} + +PyObject* PyVoxelGrid::get_material_count(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyLong_FromSize_t(self->data->materialCount()); +} + +// ============================================================================= +// Properties - transform (read-write) +// ============================================================================= + +PyObject* PyVoxelGrid::get_offset(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + mcrf::vec3 offset = self->data->getOffset(); + return Py_BuildValue("(fff)", offset.x, offset.y, offset.z); +} + +int PyVoxelGrid::set_offset(PyVoxelGridObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return -1; + } + + if (!PyTuple_Check(value) && !PyList_Check(value)) { + PyErr_SetString(PyExc_TypeError, "offset must be a tuple or list of 3 floats"); + return -1; + } + + if (PySequence_Size(value) != 3) { + PyErr_SetString(PyExc_ValueError, "offset must have exactly 3 elements (x, y, z)"); + return -1; + } + + float x = 0, y = 0, z = 0; + PyObject* x_obj = PySequence_GetItem(value, 0); + PyObject* y_obj = PySequence_GetItem(value, 1); + PyObject* z_obj = PySequence_GetItem(value, 2); + + bool valid = true; + if (PyNumber_Check(x_obj)) x = (float)PyFloat_AsDouble(PyNumber_Float(x_obj)); else valid = false; + if (PyNumber_Check(y_obj)) y = (float)PyFloat_AsDouble(PyNumber_Float(y_obj)); else valid = false; + if (PyNumber_Check(z_obj)) z = (float)PyFloat_AsDouble(PyNumber_Float(z_obj)); else valid = false; + + Py_DECREF(x_obj); + Py_DECREF(y_obj); + Py_DECREF(z_obj); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "offset elements must be numbers"); + return -1; + } + + self->data->setOffset(x, y, z); + return 0; +} + +PyObject* PyVoxelGrid::get_rotation(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyFloat_FromDouble(self->data->getRotation()); +} + +int PyVoxelGrid::set_rotation(PyVoxelGridObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return -1; + } + + if (!PyNumber_Check(value)) { + PyErr_SetString(PyExc_TypeError, "rotation must be a number"); + return -1; + } + + float rotation = (float)PyFloat_AsDouble(PyNumber_Float(value)); + self->data->setRotation(rotation); + return 0; +} + +// ============================================================================= +// Voxel access methods +// ============================================================================= + +PyObject* PyVoxelGrid::get(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + int x, y, z; + if (!PyArg_ParseTuple(args, "iii", &x, &y, &z)) { + return nullptr; + } + + // Bounds check with warning (returns 0 for out-of-bounds, like C++ API) + if (!self->data->isValid(x, y, z)) { + // Return 0 (air) for out-of-bounds, matching C++ behavior + return PyLong_FromLong(0); + } + + return PyLong_FromLong(self->data->get(x, y, z)); +} + +PyObject* PyVoxelGrid::set(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + int x, y, z, material; + if (!PyArg_ParseTuple(args, "iiii", &x, &y, &z, &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + // Bounds check - silently ignore out-of-bounds, like C++ API + self->data->set(x, y, z, static_cast(material)); + Py_RETURN_NONE; +} + +// ============================================================================= +// Material methods +// ============================================================================= + +PyObject* PyVoxelGrid::add_material(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + static const char* kwlist[] = {"name", "color", "sprite_index", "transparent", "path_cost", nullptr}; + + const char* name = nullptr; + PyObject* color_obj = nullptr; + int sprite_index = -1; + int transparent = 0; // Python bool maps to int + float path_cost = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|Oipf", const_cast(kwlist), + &name, &color_obj, &sprite_index, &transparent, &path_cost)) { + return nullptr; + } + + // Default color is white + sf::Color color = sf::Color::White; + + // Parse color if provided + if (color_obj && color_obj != Py_None) { + color = PyColor::fromPy(color_obj); + if (PyErr_Occurred()) { + return nullptr; + } + } + + try { + uint8_t id = self->data->addMaterial(name, color, sprite_index, transparent != 0, path_cost); + return PyLong_FromLong(id); + } catch (const std::exception& e) { + PyErr_SetString(PyExc_RuntimeError, e.what()); + return nullptr; + } +} + +PyObject* PyVoxelGrid::get_material(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + int id; + if (!PyArg_ParseTuple(args, "i", &id)) { + return nullptr; + } + + if (id < 0 || id > 255) { + PyErr_SetString(PyExc_ValueError, "material id must be 0-255"); + return nullptr; + } + + const mcrf::VoxelMaterial& mat = self->data->getMaterial(static_cast(id)); + + // Create color object + PyObject* color_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color"); + if (!color_type) { + return nullptr; + } + + PyObject* color_obj = PyObject_Call(color_type, + Py_BuildValue("(iiii)", mat.color.r, mat.color.g, mat.color.b, mat.color.a), + nullptr); + Py_DECREF(color_type); + + if (!color_obj) { + return nullptr; + } + + // Build result dict + PyObject* result = Py_BuildValue("{s:s, s:N, s:i, s:O, s:f}", + "name", mat.name.c_str(), + "color", color_obj, + "sprite_index", mat.spriteIndex, + "transparent", mat.transparent ? Py_True : Py_False, + "path_cost", mat.pathCost); + + return result; +} + +// ============================================================================= +// Bulk operations +// ============================================================================= + +PyObject* PyVoxelGrid::fill(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + int material; + if (!PyArg_ParseTuple(args, "i", &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + self->data->fill(static_cast(material)); + Py_RETURN_NONE; +} + +PyObject* PyVoxelGrid::clear(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + self->data->clear(); + Py_RETURN_NONE; +} + +PyObject* PyVoxelGrid::fill_box(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + PyObject* min_obj = nullptr; + PyObject* max_obj = nullptr; + int material; + + if (!PyArg_ParseTuple(args, "OOi", &min_obj, &max_obj, &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + // Parse min tuple (x0, y0, z0) + if (!PyTuple_Check(min_obj) && !PyList_Check(min_obj)) { + PyErr_SetString(PyExc_TypeError, "min_coord must be a tuple or list of 3 integers"); + return nullptr; + } + if (PySequence_Size(min_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "min_coord must have exactly 3 elements"); + return nullptr; + } + + // Parse max tuple (x1, y1, z1) + if (!PyTuple_Check(max_obj) && !PyList_Check(max_obj)) { + PyErr_SetString(PyExc_TypeError, "max_coord must be a tuple or list of 3 integers"); + return nullptr; + } + if (PySequence_Size(max_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "max_coord must have exactly 3 elements"); + return nullptr; + } + + int x0, y0, z0, x1, y1, z1; + PyObject* items[6]; + + items[0] = PySequence_GetItem(min_obj, 0); + items[1] = PySequence_GetItem(min_obj, 1); + items[2] = PySequence_GetItem(min_obj, 2); + items[3] = PySequence_GetItem(max_obj, 0); + items[4] = PySequence_GetItem(max_obj, 1); + items[5] = PySequence_GetItem(max_obj, 2); + + bool valid = true; + if (PyLong_Check(items[0])) x0 = (int)PyLong_AsLong(items[0]); else valid = false; + if (PyLong_Check(items[1])) y0 = (int)PyLong_AsLong(items[1]); else valid = false; + if (PyLong_Check(items[2])) z0 = (int)PyLong_AsLong(items[2]); else valid = false; + if (PyLong_Check(items[3])) x1 = (int)PyLong_AsLong(items[3]); else valid = false; + if (PyLong_Check(items[4])) y1 = (int)PyLong_AsLong(items[4]); else valid = false; + if (PyLong_Check(items[5])) z1 = (int)PyLong_AsLong(items[5]); else valid = false; + + for (int i = 0; i < 6; i++) Py_DECREF(items[i]); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "coordinate elements must be integers"); + return nullptr; + } + + self->data->fillBox(x0, y0, z0, x1, y1, z1, static_cast(material)); + Py_RETURN_NONE; +} + +// ============================================================================= +// Mesh caching methods (Milestone 10) +// ============================================================================= + +PyObject* PyVoxelGrid::get_vertex_count(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyLong_FromSize_t(self->data->vertexCount()); +} + +PyObject* PyVoxelGrid::rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + self->data->rebuildMesh(); + Py_RETURN_NONE; +} + +// ============================================================================= +// Statistics +// ============================================================================= + +PyObject* PyVoxelGrid::count_non_air(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + return PyLong_FromSize_t(self->data->countNonAir()); +} + +PyObject* PyVoxelGrid::count_material(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + int material; + if (!PyArg_ParseTuple(args, "i", &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + return PyLong_FromSize_t(self->data->countMaterial(static_cast(material))); +} + +// ============================================================================= +// Method definitions +// ============================================================================= + +PyMethodDef PyVoxelGrid::methods[] = { + {"get", (PyCFunction)get, METH_VARARGS, + "get(x, y, z) -> int\n\n" + "Get the material ID at integer coordinates.\n\n" + "Returns 0 (air) for out-of-bounds coordinates."}, + {"set", (PyCFunction)set, METH_VARARGS, + "set(x, y, z, material) -> None\n\n" + "Set the material ID at integer coordinates.\n\n" + "Out-of-bounds coordinates are silently ignored."}, + {"add_material", (PyCFunction)add_material, METH_VARARGS | METH_KEYWORDS, + "add_material(name, color=Color(255,255,255), sprite_index=-1, transparent=False, path_cost=1.0) -> int\n\n" + "Add a new material to the palette. Returns the material ID (1-indexed).\n\n" + "Material 0 is always air (implicit, never stored in palette).\n" + "Maximum 255 materials can be added."}, + {"get_material", (PyCFunction)get_material, METH_VARARGS, + "get_material(id) -> dict\n\n" + "Get material properties by ID.\n\n" + "Returns dict with keys: name, color, sprite_index, transparent, path_cost.\n" + "ID 0 returns the implicit air material."}, + {"fill", (PyCFunction)fill, METH_VARARGS, + "fill(material) -> None\n\n" + "Fill the entire grid with the specified material ID."}, + {"fill_box", (PyCFunction)fill_box, METH_VARARGS, + "fill_box(min_coord, max_coord, material) -> None\n\n" + "Fill a rectangular region with the specified material.\n\n" + "Args:\n" + " min_coord: (x0, y0, z0) - minimum corner (inclusive)\n" + " max_coord: (x1, y1, z1) - maximum corner (inclusive)\n" + " material: material ID (0-255)\n\n" + "Coordinates are clamped to grid bounds."}, + {"clear", (PyCFunction)clear, METH_NOARGS, + "clear() -> None\n\n" + "Clear the grid (fill with air, material 0)."}, + {"rebuild_mesh", (PyCFunction)rebuild_mesh, METH_NOARGS, + "rebuild_mesh() -> None\n\n" + "Force immediate mesh rebuild for rendering."}, + {"count_non_air", (PyCFunction)count_non_air, METH_NOARGS, + "count_non_air() -> int\n\n" + "Count the number of non-air voxels in the grid."}, + {"count_material", (PyCFunction)count_material, METH_VARARGS, + "count_material(material) -> int\n\n" + "Count the number of voxels with the specified material ID."}, + {nullptr} // Sentinel +}; + +// ============================================================================= +// Property definitions +// ============================================================================= + +PyGetSetDef PyVoxelGrid::getsetters[] = { + {"size", (getter)get_size, nullptr, + "Dimensions (width, height, depth) of the grid. Read-only.", nullptr}, + {"width", (getter)get_width, nullptr, + "Grid width (X dimension). Read-only.", nullptr}, + {"height", (getter)get_height, nullptr, + "Grid height (Y dimension). Read-only.", nullptr}, + {"depth", (getter)get_depth, nullptr, + "Grid depth (Z dimension). Read-only.", nullptr}, + {"cell_size", (getter)get_cell_size, nullptr, + "World units per voxel. Read-only.", nullptr}, + {"material_count", (getter)get_material_count, nullptr, + "Number of materials in the palette. Read-only.", nullptr}, + {"vertex_count", (getter)get_vertex_count, nullptr, + "Number of vertices after mesh generation. Read-only.", nullptr}, + {"offset", (getter)get_offset, (setter)set_offset, + "World-space position (x, y, z) of the grid origin.", nullptr}, + {"rotation", (getter)get_rotation, (setter)set_rotation, + "Y-axis rotation in degrees.", nullptr}, + {nullptr} // Sentinel +}; diff --git a/src/3d/PyVoxelGrid.h b/src/3d/PyVoxelGrid.h new file mode 100644 index 0000000..92eb13b --- /dev/null +++ b/src/3d/PyVoxelGrid.h @@ -0,0 +1,125 @@ +// PyVoxelGrid.h - Python bindings for VoxelGrid +// Part of McRogueFace 3D Extension - Milestone 9 +#pragma once + +#include "../Common.h" +#include "Python.h" +#include "VoxelGrid.h" +#include + +// Forward declaration +class PyVoxelGrid; + +// ============================================================================= +// Python object structure +// ============================================================================= + +typedef struct PyVoxelGridObject { + PyObject_HEAD + std::shared_ptr data; + PyObject* weakreflist; +} PyVoxelGridObject; + +// ============================================================================= +// Python binding class +// ============================================================================= + +class PyVoxelGrid { +public: + // Python type interface + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyVoxelGridObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyVoxelGridObject* self); + static PyObject* repr(PyObject* obj); + + // Properties - dimensions (read-only) + static PyObject* get_size(PyVoxelGridObject* self, void* closure); + static PyObject* get_width(PyVoxelGridObject* self, void* closure); + static PyObject* get_height(PyVoxelGridObject* self, void* closure); + static PyObject* get_depth(PyVoxelGridObject* self, void* closure); + static PyObject* get_cell_size(PyVoxelGridObject* self, void* closure); + static PyObject* get_material_count(PyVoxelGridObject* self, void* closure); + + // Properties - transform (read-write) + static PyObject* get_offset(PyVoxelGridObject* self, void* closure); + static int set_offset(PyVoxelGridObject* self, PyObject* value, void* closure); + static PyObject* get_rotation(PyVoxelGridObject* self, void* closure); + static int set_rotation(PyVoxelGridObject* self, PyObject* value, void* closure); + + // Voxel access methods + static PyObject* get(PyVoxelGridObject* self, PyObject* args); + static PyObject* set(PyVoxelGridObject* self, PyObject* args); + + // Material methods + static PyObject* add_material(PyVoxelGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* get_material(PyVoxelGridObject* self, PyObject* args); + + // Bulk operations + static PyObject* fill(PyVoxelGridObject* self, PyObject* args); + static PyObject* fill_box(PyVoxelGridObject* self, PyObject* args); + static PyObject* clear(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + + // Mesh caching (Milestone 10) + static PyObject* get_vertex_count(PyVoxelGridObject* self, void* closure); + static PyObject* rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + + // Statistics + static PyObject* count_non_air(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + static PyObject* count_material(PyVoxelGridObject* self, PyObject* args); + + // Type registration + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// ============================================================================= +// Python type definition +// ============================================================================= + +namespace mcrfpydef { + +inline PyTypeObject PyVoxelGridType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.VoxelGrid", + .tp_basicsize = sizeof(PyVoxelGridObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyVoxelGrid::dealloc, + .tp_repr = PyVoxelGrid::repr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + .tp_doc = PyDoc_STR( + "VoxelGrid(size: tuple[int, int, int], cell_size: float = 1.0)\n\n" + "A dense 3D grid of voxel material IDs with a material palette.\n\n" + "VoxelGrids provide volumetric storage for 3D structures like buildings,\n" + "caves, and dungeon walls. Each cell stores a uint8 material ID (0-255),\n" + "where 0 is always air.\n\n" + "Args:\n" + " size: (width, height, depth) dimensions. Immutable after creation.\n" + " cell_size: World units per voxel. Default 1.0.\n\n" + "Properties:\n" + " size (tuple, read-only): Grid dimensions as (width, height, depth)\n" + " width, height, depth (int, read-only): Individual dimensions\n" + " cell_size (float, read-only): World units per voxel\n" + " offset (tuple): World-space position (x, y, z)\n" + " rotation (float): Y-axis rotation in degrees\n" + " material_count (int, read-only): Number of defined materials\n\n" + "Example:\n" + " voxels = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=1.0)\n" + " stone = voxels.add_material('stone', color=mcrfpy.Color(128, 128, 128))\n" + " voxels.set(5, 0, 5, stone)\n" + " assert voxels.get(5, 0, 5) == stone\n" + " print(f'Non-air voxels: {voxels.count_non_air()}')" + ), + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + return 0; + }, + .tp_clear = [](PyObject* self) -> int { + return 0; + }, + .tp_weaklistoffset = offsetof(PyVoxelGridObject, weakreflist), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready + .tp_init = (initproc)PyVoxelGrid::init, + .tp_new = PyVoxelGrid::pynew, +}; + +} // namespace mcrfpydef diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 1997e08..5d1f392 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -7,6 +7,8 @@ #include "EntityCollection3D.h" #include "Billboard.h" #include "Model3D.h" +#include "VoxelGrid.h" +#include "PyVoxelGrid.h" #include "../platform/GLContext.h" #include "PyVector.h" #include "PyColor.h" @@ -62,6 +64,15 @@ Viewport3D::Viewport3D(float x, float y, float width, float height) Viewport3D::~Viewport3D() { cleanupTestGeometry(); cleanupFBO(); + + // Clean up voxel VBO (Milestone 10) +#ifdef MCRF_HAS_GL + if (voxelVBO_ != 0) { + glDeleteBuffers(1, &voxelVBO_); + voxelVBO_ = 0; + } +#endif + if (tcodMap_) { delete tcodMap_; tcodMap_ = nullptr; @@ -836,6 +847,130 @@ void Viewport3D::renderMeshLayers() { #endif } +// ============================================================================= +// Voxel Layer Management (Milestone 10) +// ============================================================================= + +void Viewport3D::addVoxelLayer(std::shared_ptr grid, int zIndex) { + if (!grid) return; + voxelLayers_.push_back({grid, zIndex}); + + // Disable test cube when real content is added + renderTestCube_ = false; +} + +bool Viewport3D::removeVoxelLayer(std::shared_ptr grid) { + if (!grid) return false; + + auto it = std::find_if(voxelLayers_.begin(), voxelLayers_.end(), + [&grid](const auto& pair) { return pair.first == grid; }); + + if (it != voxelLayers_.end()) { + voxelLayers_.erase(it); + return true; + } + return false; +} + +void Viewport3D::renderVoxelLayers(const mat4& view, const mat4& proj) { +#ifdef MCRF_HAS_GL + if (voxelLayers_.empty() || !shader_ || !shader_->isValid()) { + return; + } + + // Sort layers by z_index (lower = rendered first) + std::vector> sortedLayers; + sortedLayers.reserve(voxelLayers_.size()); + for (auto& pair : voxelLayers_) { + if (pair.first) { + sortedLayers.push_back({pair.first.get(), pair.second}); + } + } + std::sort(sortedLayers.begin(), sortedLayers.end(), + [](const auto& a, const auto& b) { return a.second < b.second; }); + + shader_->bind(); + + // Set up view and projection matrices + shader_->setUniform("u_view", view); + shader_->setUniform("u_projection", proj); + + // PS1 effect uniforms + shader_->setUniform("u_resolution", vec2(static_cast(internalWidth_), + static_cast(internalHeight_))); + shader_->setUniform("u_enable_snap", vertexSnapEnabled_); + shader_->setUniform("u_enable_dither", ditheringEnabled_); + + // Lighting + vec3 lightDir = vec3(0.5f, -0.7f, 0.5f).normalized(); + shader_->setUniform("u_light_dir", lightDir); + shader_->setUniform("u_ambient", vec3(0.3f, 0.3f, 0.3f)); + + // Fog + shader_->setUniform("u_fog_start", fogNear_); + shader_->setUniform("u_fog_end", fogFar_); + shader_->setUniform("u_fog_color", fogColor_); + + // No texture for voxels (use vertex colors) + shader_->setUniform("u_has_texture", false); + + // Create VBO if needed + if (voxelVBO_ == 0) { + glGenBuffers(1, &voxelVBO_); + } + + // Render each voxel grid + for (auto& pair : sortedLayers) { + VoxelGrid* grid = pair.first; + + // Get vertices (triggers rebuild if dirty) + const std::vector& vertices = grid->getVertices(); + if (vertices.empty()) continue; + + // Set model matrix for this grid + shader_->setUniform("u_model", grid->getModelMatrix()); + + // Upload vertices to VBO + glBindBuffer(GL_ARRAY_BUFFER, voxelVBO_); + glBufferData(GL_ARRAY_BUFFER, + vertices.size() * sizeof(MeshVertex), + vertices.data(), + GL_DYNAMIC_DRAW); + + // Set up vertex attributes (same as MeshLayer) + size_t stride = sizeof(MeshVertex); + + // Position + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, position)); + + // TexCoord + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, texcoord)); + + // Normal + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, normal)); + + // Color + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)offsetof(MeshVertex, color)); + + // Draw + glDrawArrays(GL_TRIANGLES, 0, static_cast(vertices.size())); + + // Cleanup + glDisableVertexAttribArray(0); + glDisableVertexAttribArray(1); + glDisableVertexAttribArray(2); + glDisableVertexAttribArray(3); + glBindBuffer(GL_ARRAY_BUFFER, 0); + } + + shader_->unbind(); +#endif +} + void Viewport3D::render3DContent() { // GL not available in current backend - skip 3D rendering if (!gl::isGLReady() || fbo_ == 0) { @@ -879,9 +1014,12 @@ void Viewport3D::render3DContent() { // Render mesh layers first (terrain, etc.) - sorted by z_index renderMeshLayers(); - // Render entities + // Render voxel layers (Milestone 10) mat4 view = camera_.getViewMatrix(); mat4 projection = camera_.getProjectionMatrix(); + renderVoxelLayers(view, projection); + + // Render entities renderEntities(view, projection); // Render billboards (after opaque geometry for proper transparency) @@ -2262,6 +2400,81 @@ static PyObject* Viewport3D_follow(PyViewport3DObject* self, PyObject* args, PyO Py_RETURN_NONE; } +// ============================================================================= +// Voxel Layer Methods (Milestone 10) +// ============================================================================= + +static PyObject* Viewport3D_add_voxel_layer(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"voxel_grid", "z_index", NULL}; + PyObject* voxel_grid_obj = nullptr; + int z_index = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast(kwlist), + &voxel_grid_obj, &z_index)) { + return NULL; + } + + // Check if it's a VoxelGrid object + PyTypeObject* voxelGridType = (PyTypeObject*)PyObject_GetAttrString( + McRFPy_API::mcrf_module, "VoxelGrid"); + if (!voxelGridType) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid type not found"); + return NULL; + } + + if (!PyObject_IsInstance(voxel_grid_obj, (PyObject*)voxelGridType)) { + Py_DECREF(voxelGridType); + PyErr_SetString(PyExc_TypeError, "voxel_grid must be a VoxelGrid object"); + return NULL; + } + Py_DECREF(voxelGridType); + + PyVoxelGridObject* vg = (PyVoxelGridObject*)voxel_grid_obj; + if (!vg->data) { + PyErr_SetString(PyExc_ValueError, "VoxelGrid not initialized"); + return NULL; + } + + self->data->addVoxelLayer(vg->data, z_index); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_remove_voxel_layer(PyViewport3DObject* self, PyObject* args) { + PyObject* voxel_grid_obj = nullptr; + + if (!PyArg_ParseTuple(args, "O", &voxel_grid_obj)) { + return NULL; + } + + // Check if it's a VoxelGrid object + PyTypeObject* voxelGridType = (PyTypeObject*)PyObject_GetAttrString( + McRFPy_API::mcrf_module, "VoxelGrid"); + if (!voxelGridType) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid type not found"); + return NULL; + } + + if (!PyObject_IsInstance(voxel_grid_obj, (PyObject*)voxelGridType)) { + Py_DECREF(voxelGridType); + PyErr_SetString(PyExc_TypeError, "voxel_grid must be a VoxelGrid object"); + return NULL; + } + Py_DECREF(voxelGridType); + + PyVoxelGridObject* vg = (PyVoxelGridObject*)voxel_grid_obj; + if (!vg->data) { + PyErr_SetString(PyExc_ValueError, "VoxelGrid not initialized"); + return NULL; + } + + bool removed = self->data->removeVoxelLayer(vg->data); + return PyBool_FromLong(removed); +} + +static PyObject* Viewport3D_voxel_layer_count(PyViewport3DObject* self, PyObject* Py_UNUSED(args)) { + return PyLong_FromSize_t(self->data->getVoxelLayerCount()); +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -2441,5 +2654,25 @@ PyMethodDef Viewport3D_methods[] = { " distance: Distance behind entity\n" " height: Camera height above entity\n" " smoothing: Interpolation factor (0-1). 1 = instant, lower = smoother"}, + + // Voxel layer methods (Milestone 10) + {"add_voxel_layer", (PyCFunction)mcrf::Viewport3D_add_voxel_layer, METH_VARARGS | METH_KEYWORDS, + "add_voxel_layer(voxel_grid, z_index=0)\n\n" + "Add a VoxelGrid as a renderable layer.\n\n" + "Args:\n" + " voxel_grid: VoxelGrid object to render\n" + " z_index: Render order (lower = rendered first)"}, + {"remove_voxel_layer", (PyCFunction)mcrf::Viewport3D_remove_voxel_layer, METH_VARARGS, + "remove_voxel_layer(voxel_grid) -> bool\n\n" + "Remove a VoxelGrid layer from the viewport.\n\n" + "Args:\n" + " voxel_grid: VoxelGrid object to remove\n\n" + "Returns:\n" + " True if the layer was found and removed"}, + {"voxel_layer_count", (PyCFunction)mcrf::Viewport3D_voxel_layer_count, METH_NOARGS, + "voxel_layer_count() -> int\n\n" + "Get the number of voxel layers.\n\n" + "Returns:\n" + " Number of voxel layers in the viewport"}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index 780c1ee..59b8b59 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -31,6 +31,7 @@ class Viewport3D; class Shader3D; class MeshLayer; class Billboard; +class VoxelGrid; } // namespace mcrf @@ -226,6 +227,29 @@ public: /// Render all billboards void renderBillboards(const mat4& view, const mat4& proj); + // ========================================================================= + // VoxelGrid Layer Management (Milestone 10) + // ========================================================================= + + /// Add a voxel layer to the viewport + /// @param grid The VoxelGrid to add + /// @param zIndex Render order (lower = rendered first, behind higher values) + void addVoxelLayer(std::shared_ptr grid, int zIndex = 0); + + /// Remove a voxel layer from the viewport + /// @param grid The VoxelGrid to remove + /// @return true if the layer was found and removed + bool removeVoxelLayer(std::shared_ptr grid); + + /// Get all voxel layers (read-only) + const std::vector, int>>& getVoxelLayers() const { return voxelLayers_; } + + /// Get number of voxel layers + size_t getVoxelLayerCount() const { return voxelLayers_.size(); } + + /// Render all voxel layers + void renderVoxelLayers(const mat4& view, const mat4& proj); + // Background color void setBackgroundColor(const sf::Color& color) { bgColor_ = color; } sf::Color getBackgroundColor() const { return bgColor_; } @@ -319,6 +343,11 @@ private: // Billboard storage std::shared_ptr>> billboards_; + // Voxel layer storage (Milestone 10) + // Pairs of (VoxelGrid, z_index) for render ordering + std::vector, int>> voxelLayers_; + unsigned int voxelVBO_ = 0; // Shared VBO for voxel rendering + // Shader for PS1-style rendering std::unique_ptr shader_; std::unique_ptr skinnedShader_; // For skeletal animation diff --git a/src/3d/VoxelGrid.cpp b/src/3d/VoxelGrid.cpp new file mode 100644 index 0000000..2c3ed34 --- /dev/null +++ b/src/3d/VoxelGrid.cpp @@ -0,0 +1,171 @@ +// VoxelGrid.cpp - Dense 3D voxel array implementation +// Part of McRogueFace 3D Extension - Milestones 9-10 + +#include "VoxelGrid.h" +#include "VoxelMesher.h" +#include "MeshLayer.h" // For MeshVertex + +namespace mcrf { + +// Static air material for out-of-bounds or ID=0 queries +static VoxelMaterial airMaterial{"air", sf::Color::Transparent, -1, true, 0.0f}; + +// ============================================================================= +// Constructor +// ============================================================================= + +VoxelGrid::VoxelGrid(int w, int h, int d, float cellSize) + : width_(w), height_(h), depth_(d), cellSize_(cellSize), + offset_(0, 0, 0), rotation_(0.0f) +{ + if (w <= 0 || h <= 0 || d <= 0) { + throw std::invalid_argument("VoxelGrid dimensions must be positive"); + } + if (cellSize <= 0.0f) { + throw std::invalid_argument("VoxelGrid cell size must be positive"); + } + + // Allocate dense array, initialized to air (0) + size_t totalSize = static_cast(w) * h * d; + data_.resize(totalSize, 0); +} + +// ============================================================================= +// Per-voxel access +// ============================================================================= + +bool VoxelGrid::isValid(int x, int y, int z) const { + return x >= 0 && x < width_ && + y >= 0 && y < height_ && + z >= 0 && z < depth_; +} + +uint8_t VoxelGrid::get(int x, int y, int z) const { + if (!isValid(x, y, z)) { + return 0; // Out of bounds returns air + } + return data_[index(x, y, z)]; +} + +void VoxelGrid::set(int x, int y, int z, uint8_t material) { + if (!isValid(x, y, z)) { + return; // Out of bounds is no-op + } + data_[index(x, y, z)] = material; + meshDirty_ = true; +} + +// ============================================================================= +// Material palette +// ============================================================================= + +uint8_t VoxelGrid::addMaterial(const VoxelMaterial& mat) { + if (materials_.size() >= 255) { + throw std::runtime_error("Material palette full (max 255 materials)"); + } + materials_.push_back(mat); + return static_cast(materials_.size()); // 1-indexed +} + +uint8_t VoxelGrid::addMaterial(const std::string& name, sf::Color color, + int spriteIndex, bool transparent, float pathCost) { + return addMaterial(VoxelMaterial(name, color, spriteIndex, transparent, pathCost)); +} + +const VoxelMaterial& VoxelGrid::getMaterial(uint8_t id) const { + if (id == 0 || id > materials_.size()) { + return airMaterial; + } + return materials_[id - 1]; // 1-indexed, so ID 1 = materials_[0] +} + +// ============================================================================= +// Bulk operations +// ============================================================================= + +void VoxelGrid::fill(uint8_t material) { + std::fill(data_.begin(), data_.end(), material); + meshDirty_ = true; +} + +// ============================================================================= +// Transform +// ============================================================================= + +mat4 VoxelGrid::getModelMatrix() const { + // Apply translation first, then rotation around Y axis + mat4 translation = mat4::translate(offset_); + mat4 rotation = mat4::rotateY(rotation_ * DEG_TO_RAD); + return translation * rotation; +} + +// ============================================================================= +// Statistics +// ============================================================================= + +size_t VoxelGrid::countNonAir() const { + size_t count = 0; + for (uint8_t v : data_) { + if (v != 0) { + count++; + } + } + return count; +} + +size_t VoxelGrid::countMaterial(uint8_t material) const { + size_t count = 0; + for (uint8_t v : data_) { + if (v == material) { + count++; + } + } + return count; +} + +// ============================================================================= +// fillBox (Milestone 10) +// ============================================================================= + +void VoxelGrid::fillBox(int x0, int y0, int z0, int x1, int y1, int z1, uint8_t material) { + // Ensure proper ordering (min to max) + if (x0 > x1) std::swap(x0, x1); + if (y0 > y1) std::swap(y0, y1); + if (z0 > z1) std::swap(z0, z1); + + // Clamp to valid range + x0 = std::max(0, std::min(x0, width_ - 1)); + x1 = std::max(0, std::min(x1, width_ - 1)); + y0 = std::max(0, std::min(y0, height_ - 1)); + y1 = std::max(0, std::min(y1, height_ - 1)); + z0 = std::max(0, std::min(z0, depth_ - 1)); + z1 = std::max(0, std::min(z1, depth_ - 1)); + + for (int z = z0; z <= z1; z++) { + for (int y = y0; y <= y1; y++) { + for (int x = x0; x <= x1; x++) { + data_[index(x, y, z)] = material; + } + } + } + meshDirty_ = true; +} + +// ============================================================================= +// Mesh Caching (Milestone 10) +// ============================================================================= + +const std::vector& VoxelGrid::getVertices() const { + if (meshDirty_) { + rebuildMesh(); + } + return cachedVertices_; +} + +void VoxelGrid::rebuildMesh() const { + cachedVertices_.clear(); + VoxelMesher::generateMesh(*this, cachedVertices_); + meshDirty_ = false; +} + +} // namespace mcrf diff --git a/src/3d/VoxelGrid.h b/src/3d/VoxelGrid.h new file mode 100644 index 0000000..3f6447a --- /dev/null +++ b/src/3d/VoxelGrid.h @@ -0,0 +1,121 @@ +// VoxelGrid.h - Dense 3D voxel array with material palette +// Part of McRogueFace 3D Extension - Milestones 9-10 +#pragma once + +#include "../Common.h" +#include "Math3D.h" +#include "MeshLayer.h" // For MeshVertex (needed for std::vector) +#include +#include +#include + +namespace mcrf { + +// ============================================================================= +// VoxelMaterial - Properties for a voxel material type +// ============================================================================= + +struct VoxelMaterial { + std::string name; + sf::Color color; // Fallback solid color + int spriteIndex = -1; // Texture atlas index (-1 = use color) + bool transparent = false; // For FOV projection and face culling + float pathCost = 1.0f; // Navigation cost multiplier (0 = impassable) + + VoxelMaterial() : name("unnamed"), color(sf::Color::White) {} + VoxelMaterial(const std::string& n, sf::Color c, int sprite = -1, + bool transp = false, float cost = 1.0f) + : name(n), color(c), spriteIndex(sprite), transparent(transp), pathCost(cost) {} +}; + +// ============================================================================= +// VoxelGrid - Dense 3D array of material IDs +// ============================================================================= + +class VoxelGrid { +private: + int width_, height_, depth_; + float cellSize_; + std::vector data_; // Material ID per cell (0 = air) + std::vector materials_; + + // Transform + vec3 offset_; + float rotation_ = 0.0f; // Y-axis only, degrees + + // Mesh caching (Milestone 10) + mutable bool meshDirty_ = true; + mutable std::vector cachedVertices_; + + // Index calculation (row-major: X varies fastest, then Y, then Z) + inline size_t index(int x, int y, int z) const { + return static_cast(z) * (width_ * height_) + + static_cast(y) * width_ + + static_cast(x); + } + +public: + // Constructor + VoxelGrid(int w, int h, int d, float cellSize = 1.0f); + + // Dimensions (read-only) + int width() const { return width_; } + int height() const { return height_; } + int depth() const { return depth_; } + float cellSize() const { return cellSize_; } + size_t totalVoxels() const { return static_cast(width_) * height_ * depth_; } + + // Per-voxel access + uint8_t get(int x, int y, int z) const; + void set(int x, int y, int z, uint8_t material); + bool isValid(int x, int y, int z) const; + + // Material palette + // Returns 1-indexed material ID (0 = air, always implicit) + uint8_t addMaterial(const VoxelMaterial& mat); + uint8_t addMaterial(const std::string& name, sf::Color color, + int spriteIndex = -1, bool transparent = false, + float pathCost = 1.0f); + const VoxelMaterial& getMaterial(uint8_t id) const; + size_t materialCount() const { return materials_.size(); } + + // Bulk operations + void fill(uint8_t material); + void clear() { fill(0); } + void fillBox(int x0, int y0, int z0, int x1, int y1, int z1, uint8_t material); + + // Transform + void setOffset(const vec3& offset) { offset_ = offset; } + void setOffset(float x, float y, float z) { offset_ = vec3(x, y, z); } + vec3 getOffset() const { return offset_; } + void setRotation(float degrees) { rotation_ = degrees; } + float getRotation() const { return rotation_; } + mat4 getModelMatrix() const; + + // Statistics + size_t countNonAir() const; + size_t countMaterial(uint8_t material) const; + + // Mesh caching (Milestone 10) + /// Mark mesh as needing rebuild (called automatically by set/fill operations) + void markDirty() { meshDirty_ = true; } + + /// Check if mesh needs rebuild + bool isMeshDirty() const { return meshDirty_; } + + /// Get vertices for rendering (rebuilds mesh if dirty) + const std::vector& getVertices() const; + + /// Force immediate mesh rebuild + void rebuildMesh() const; + + /// Get vertex count after mesh generation + size_t vertexCount() const { return cachedVertices_.size(); } + + // Memory info (for debugging) + size_t memoryUsageBytes() const { + return data_.size() + materials_.size() * sizeof(VoxelMaterial); + } +}; + +} // namespace mcrf diff --git a/src/3d/VoxelMesher.cpp b/src/3d/VoxelMesher.cpp new file mode 100644 index 0000000..ee7df0d --- /dev/null +++ b/src/3d/VoxelMesher.cpp @@ -0,0 +1,136 @@ +// VoxelMesher.cpp - Face-culled mesh generation for VoxelGrid +// Part of McRogueFace 3D Extension - Milestone 10 + +#include "VoxelMesher.h" +#include + +namespace mcrf { + +void VoxelMesher::generateMesh(const VoxelGrid& grid, std::vector& outVertices) { + const float cs = grid.cellSize(); + + for (int z = 0; z < grid.depth(); z++) { + for (int y = 0; y < grid.height(); y++) { + for (int x = 0; x < grid.width(); x++) { + uint8_t mat = grid.get(x, y, z); + if (mat == 0) continue; // Skip air + + const VoxelMaterial& material = grid.getMaterial(mat); + + // Voxel center in local space + vec3 center((x + 0.5f) * cs, (y + 0.5f) * cs, (z + 0.5f) * cs); + + // Check each face direction + // +X face + if (shouldGenerateFace(grid, x, y, z, x + 1, y, z)) { + emitFace(outVertices, center, vec3(1, 0, 0), cs, material); + } + // -X face + if (shouldGenerateFace(grid, x, y, z, x - 1, y, z)) { + emitFace(outVertices, center, vec3(-1, 0, 0), cs, material); + } + // +Y face (top) + if (shouldGenerateFace(grid, x, y, z, x, y + 1, z)) { + emitFace(outVertices, center, vec3(0, 1, 0), cs, material); + } + // -Y face (bottom) + if (shouldGenerateFace(grid, x, y, z, x, y - 1, z)) { + emitFace(outVertices, center, vec3(0, -1, 0), cs, material); + } + // +Z face + if (shouldGenerateFace(grid, x, y, z, x, y, z + 1)) { + emitFace(outVertices, center, vec3(0, 0, 1), cs, material); + } + // -Z face + if (shouldGenerateFace(grid, x, y, z, x, y, z - 1)) { + emitFace(outVertices, center, vec3(0, 0, -1), cs, material); + } + } + } + } +} + +bool VoxelMesher::shouldGenerateFace(const VoxelGrid& grid, + int x, int y, int z, + int nx, int ny, int nz) { + // Out of bounds = air, so generate face + if (!grid.isValid(nx, ny, nz)) { + return true; + } + + uint8_t neighbor = grid.get(nx, ny, nz); + + // Air neighbor = generate face + if (neighbor == 0) { + return true; + } + + // Check if neighbor is transparent + // Transparent materials allow faces to be visible behind them + return grid.getMaterial(neighbor).transparent; +} + +void VoxelMesher::emitFace(std::vector& vertices, + const vec3& center, + const vec3& normal, + float size, + const VoxelMaterial& material) { + // Calculate face corners based on normal direction + vec3 up, right; + + if (std::abs(normal.y) > 0.5f) { + // Horizontal face (floor/ceiling) + // For +Y (top), we want the face to look correct from above + // For -Y (bottom), we want it to look correct from below + up = vec3(0, 0, normal.y); // Z direction based on face direction + right = vec3(1, 0, 0); // Always X axis for horizontal faces + } else if (std::abs(normal.x) > 0.5f) { + // X-facing wall + up = vec3(0, 1, 0); // Y axis is up + right = vec3(0, 0, normal.x); // Z direction based on face direction + } else { + // Z-facing wall + up = vec3(0, 1, 0); // Y axis is up + right = vec3(-normal.z, 0, 0); // X direction based on face direction + } + + float halfSize = size * 0.5f; + vec3 faceCenter = center + normal * halfSize; + + // 4 corners of the face + vec3 corners[4] = { + faceCenter - right * halfSize - up * halfSize, // Bottom-left + faceCenter + right * halfSize - up * halfSize, // Bottom-right + faceCenter + right * halfSize + up * halfSize, // Top-right + faceCenter - right * halfSize + up * halfSize // Top-left + }; + + // UV coordinates (solid color or single sprite tile) + vec2 uvs[4] = { + vec2(0, 0), // Bottom-left + vec2(1, 0), // Bottom-right + vec2(1, 1), // Top-right + vec2(0, 1) // Top-left + }; + + // Color from material (convert 0-255 to 0-1) + vec4 color( + material.color.r / 255.0f, + material.color.g / 255.0f, + material.color.b / 255.0f, + material.color.a / 255.0f + ); + + // Emit 2 triangles (6 vertices) - CCW winding for OpenGL front faces + // Triangle 1: 0-2-1 (bottom-left, top-right, bottom-right) - CCW + vertices.push_back(MeshVertex(corners[0], uvs[0], normal, color)); + vertices.push_back(MeshVertex(corners[2], uvs[2], normal, color)); + vertices.push_back(MeshVertex(corners[1], uvs[1], normal, color)); + + // Triangle 2: 0-3-2 (bottom-left, top-left, top-right) - CCW + vertices.push_back(MeshVertex(corners[0], uvs[0], normal, color)); + vertices.push_back(MeshVertex(corners[3], uvs[3], normal, color)); + vertices.push_back(MeshVertex(corners[2], uvs[2], normal, color)); +} + +} // namespace mcrf diff --git a/src/3d/VoxelMesher.h b/src/3d/VoxelMesher.h new file mode 100644 index 0000000..9dc87c4 --- /dev/null +++ b/src/3d/VoxelMesher.h @@ -0,0 +1,53 @@ +// VoxelMesher.h - Face-culled mesh generation for VoxelGrid +// Part of McRogueFace 3D Extension - Milestone 10 +#pragma once + +#include "VoxelGrid.h" +#include "MeshLayer.h" // For MeshVertex +#include + +namespace mcrf { + +// ============================================================================= +// VoxelMesher - Static class for generating triangle meshes from VoxelGrid +// ============================================================================= + +class VoxelMesher { +public: + /// Generate face-culled mesh from voxel data + /// Output vertices in local space (model matrix applies world transform) + /// @param grid The VoxelGrid to generate mesh from + /// @param outVertices Output vector of vertices (appended to, not cleared) + static void generateMesh( + const VoxelGrid& grid, + std::vector& outVertices + ); + +private: + /// Check if face should be generated (neighbor is air or transparent) + /// @param grid The VoxelGrid + /// @param x, y, z Current voxel position + /// @param nx, ny, nz Neighbor voxel position + /// @return true if face should be generated + static bool shouldGenerateFace( + const VoxelGrid& grid, + int x, int y, int z, + int nx, int ny, int nz + ); + + /// Generate a single face (2 triangles = 6 vertices) + /// @param vertices Output vector to append vertices to + /// @param center Center of the voxel + /// @param normal Face normal direction + /// @param size Voxel cell size + /// @param material Material for coloring + static void emitFace( + std::vector& vertices, + const vec3& center, + const vec3& normal, + float size, + const VoxelMaterial& material + ); +}; + +} // namespace mcrf diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index c6b0ad2..6a67dec 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -36,6 +36,7 @@ #include "3d/EntityCollection3D.h" // Entity3D collection #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 "McRogueFaceVersion.h" #include "GameEngine.h" // ImGui is only available for SFML builds @@ -442,7 +443,7 @@ PyObject* PyInit_mcrfpy() /*3D entities*/ &mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType, &mcrfpydef::PyEntityCollection3DIterType, &mcrfpydef::PyModel3DType, - &mcrfpydef::PyBillboardType, + &mcrfpydef::PyBillboardType, &mcrfpydef::PyVoxelGridType, /*grid layers (#147)*/ &PyColorLayerType, &PyTileLayerType, @@ -539,6 +540,10 @@ PyObject* PyInit_mcrfpy() mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods; mcrfpydef::PyNoiseSourceType.tp_getset = PyNoiseSource::getsetters; + // Set up PyVoxelGridType methods and getsetters (Milestone 9) + mcrfpydef::PyVoxelGridType.tp_methods = PyVoxelGrid::methods; + mcrfpydef::PyVoxelGridType.tp_getset = PyVoxelGrid::getsetters; + // Set up PyShaderType methods and getsetters (#106) mcrfpydef::PyShaderType.tp_methods = PyShader::methods; mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters; diff --git a/tests/demo/screens/voxel_meshing_demo.py b/tests/demo/screens/voxel_meshing_demo.py new file mode 100644 index 0000000..862002b --- /dev/null +++ b/tests/demo/screens/voxel_meshing_demo.py @@ -0,0 +1,218 @@ +# voxel_meshing_demo.py - Visual demo of VoxelGrid mesh rendering +# Shows voxel building rendered in Viewport3D with PS1 effects + +import mcrfpy +import sys +import math + +# Create demo scene +scene = mcrfpy.Scene("voxel_meshing_demo") + +# Dark background frame +bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(15, 15, 25)) +scene.children.append(bg) + +# Title +title = mcrfpy.Caption(text="VoxelGrid Meshing Demo - Face-Culled 3D Voxels", pos=(20, 10)) +title.fill_color = mcrfpy.Color(255, 255, 255) +scene.children.append(title) + +# Create the 3D viewport +viewport = mcrfpy.Viewport3D( + pos=(50, 60), + size=(600, 500), + render_resolution=(320, 240), # PS1 resolution + fov=60.0, + camera_pos=(20.0, 15.0, 20.0), + camera_target=(4.0, 2.0, 4.0), + bg_color=mcrfpy.Color(50, 70, 100) # Sky color +) +scene.children.append(viewport) + +# Create voxel grid for building +print("Creating voxel building...") +voxels = mcrfpy.VoxelGrid(size=(12, 8, 12), cell_size=1.0) + +# Define materials +STONE = voxels.add_material("stone", color=mcrfpy.Color(128, 128, 128)) +BRICK = voxels.add_material("brick", color=mcrfpy.Color(165, 82, 42)) +WOOD = voxels.add_material("wood", color=mcrfpy.Color(139, 90, 43)) +GLASS = voxels.add_material("glass", color=mcrfpy.Color(180, 220, 255, 180), transparent=True) +GRASS = voxels.add_material("grass", color=mcrfpy.Color(60, 150, 60)) + +print(f"Defined {voxels.material_count} materials") + +# Build a simple house structure + +# Ground/foundation +voxels.fill_box((0, 0, 0), (11, 0, 11), GRASS) + +# Floor +voxels.fill_box((1, 1, 1), (10, 1, 10), STONE) + +# Walls +# Front wall (Z=1) +voxels.fill_box((1, 2, 1), (10, 5, 1), BRICK) +# Back wall (Z=10) +voxels.fill_box((1, 2, 10), (10, 5, 10), BRICK) +# Left wall (X=1) +voxels.fill_box((1, 2, 1), (1, 5, 10), BRICK) +# Right wall (X=10) +voxels.fill_box((10, 2, 1), (10, 5, 10), BRICK) + +# Door opening (front wall) +voxels.fill_box((4, 2, 1), (6, 4, 1), 0) # Clear door opening + +# Windows +# Front windows (beside door) +voxels.fill_box((2, 3, 1), (3, 4, 1), GLASS) +voxels.fill_box((8, 3, 1), (9, 4, 1), GLASS) +# Side windows +voxels.fill_box((1, 3, 4), (1, 4, 5), GLASS) +voxels.fill_box((1, 3, 7), (1, 4, 8), GLASS) +voxels.fill_box((10, 3, 4), (10, 4, 5), GLASS) +voxels.fill_box((10, 3, 7), (10, 4, 8), GLASS) + +# Ceiling +voxels.fill_box((1, 6, 1), (10, 6, 10), WOOD) + +# Simple roof (peaked) +voxels.fill_box((0, 7, 0), (11, 7, 11), WOOD) +voxels.fill_box((1, 8, 1), (10, 8, 10), WOOD) +voxels.fill_box((2, 9, 2), (9, 9, 9), WOOD) +voxels.fill_box((3, 10, 3), (8, 10, 8), WOOD) +voxels.fill_box((4, 11, 4), (7, 11, 7), WOOD) + +# Build the mesh +voxels.rebuild_mesh() + +print(f"Built voxel house:") +print(f" Non-air voxels: {voxels.count_non_air()}") +print(f" Vertices: {voxels.vertex_count}") +print(f" Faces: {voxels.vertex_count // 6}") + +# Position the building +voxels.offset = (0.0, 0.0, 0.0) +voxels.rotation = 0.0 + +# Add to viewport +viewport.add_voxel_layer(voxels, z_index=0) +print(f"Added voxel layer to viewport (count: {viewport.voxel_layer_count()})") + +# Create info panel +info_frame = mcrfpy.Frame(pos=(680, 60), size=(300, 250), fill_color=mcrfpy.Color(40, 40, 60, 200)) +scene.children.append(info_frame) + +info_title = mcrfpy.Caption(text="Building Stats", pos=(690, 70)) +info_title.fill_color = mcrfpy.Color(255, 255, 100) +scene.children.append(info_title) + +stats_text = f"""Grid: {voxels.width}x{voxels.height}x{voxels.depth} +Total voxels: {voxels.width * voxels.height * voxels.depth} +Non-air: {voxels.count_non_air()} +Materials: {voxels.material_count} +Vertices: {voxels.vertex_count} +Faces: {voxels.vertex_count // 6} + +Without culling would be: + {voxels.count_non_air() * 36} vertices + ({100 - (voxels.vertex_count / (voxels.count_non_air() * 36) * 100):.0f}% reduction)""" + +stats = mcrfpy.Caption(text=stats_text, pos=(690, 100)) +stats.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(stats) + +# Controls info +controls_frame = mcrfpy.Frame(pos=(680, 330), size=(300, 180), fill_color=mcrfpy.Color(40, 40, 60, 200)) +scene.children.append(controls_frame) + +controls_title = mcrfpy.Caption(text="Controls", pos=(690, 340)) +controls_title.fill_color = mcrfpy.Color(255, 255, 100) +scene.children.append(controls_title) + +controls_text = """R - Toggle rotation +1-5 - Change camera angle +SPACE - Reset camera +ESC - Exit demo""" + +controls = mcrfpy.Caption(text=controls_text, pos=(690, 370)) +controls.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(controls) + +# Animation state +rotation_enabled = False +current_angle = 0.0 +camera_angles = [ + (20.0, 15.0, 20.0), # Default - diagonal view + (0.0, 15.0, 25.0), # Front view + (25.0, 15.0, 0.0), # Side view + (5.5, 25.0, 5.5), # Top-down view + (5.5, 3.0, 20.0), # Low angle +] +current_camera = 0 + +def rotate_building(timer, runtime): + """Timer callback for building rotation""" + global current_angle, rotation_enabled + if rotation_enabled: + current_angle += 1.0 + if current_angle >= 360.0: + current_angle = 0.0 + voxels.rotation = current_angle + +# Set up rotation timer +timer = mcrfpy.Timer("rotate", rotate_building, 33) # ~30 FPS + +def handle_key(key, action): + """Keyboard handler""" + global rotation_enabled, current_camera + if action != mcrfpy.InputState.PRESSED: + return + + if key == mcrfpy.Key.R: + rotation_enabled = not rotation_enabled + print(f"Rotation: {'ON' if rotation_enabled else 'OFF'}") + elif key == mcrfpy.Key.NUM_1: + current_camera = 0 + viewport.camera_pos = camera_angles[0] + print("Camera: Default diagonal") + elif key == mcrfpy.Key.NUM_2: + current_camera = 1 + viewport.camera_pos = camera_angles[1] + print("Camera: Front view") + elif key == mcrfpy.Key.NUM_3: + current_camera = 2 + viewport.camera_pos = camera_angles[2] + print("Camera: Side view") + elif key == mcrfpy.Key.NUM_4: + current_camera = 3 + viewport.camera_pos = camera_angles[3] + print("Camera: Top-down view") + elif key == mcrfpy.Key.NUM_5: + current_camera = 4 + viewport.camera_pos = camera_angles[4] + print("Camera: Low angle") + elif key == mcrfpy.Key.SPACE: + current_camera = 0 + voxels.rotation = 0.0 + viewport.camera_pos = camera_angles[0] + print("Camera: Reset") + elif key == mcrfpy.Key.ESCAPE: + print("Exiting demo...") + sys.exit(0) + +scene.on_key = handle_key + +# Activate the scene +mcrfpy.current_scene = scene +print("Voxel Meshing Demo ready! Press R to toggle rotation.") + +# Main entry point for --exec mode +if __name__ == "__main__": + # Demo is set up, print summary + print("\n=== Voxel Meshing Demo Summary ===") + print(f"Grid size: {voxels.width}x{voxels.height}x{voxels.depth}") + print(f"Non-air voxels: {voxels.count_non_air()}") + print(f"Generated vertices: {voxels.vertex_count}") + print(f"Rendered faces: {voxels.vertex_count // 6}") + print("===================================\n")