diff --git a/src/3d/PyVoxelGrid.cpp b/src/3d/PyVoxelGrid.cpp index 3325a73..f3d4a65 100644 --- a/src/3d/PyVoxelGrid.cpp +++ b/src/3d/PyVoxelGrid.cpp @@ -234,6 +234,31 @@ int PyVoxelGrid::set_rotation(PyVoxelGridObject* self, PyObject* value, void* cl return 0; } +// Greedy meshing (Milestone 13) +PyObject* PyVoxelGrid::get_greedy_meshing(PyVoxelGridObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + return PyBool_FromLong(self->data->isGreedyMeshingEnabled()); +} + +int PyVoxelGrid::set_greedy_meshing(PyVoxelGridObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return -1; + } + + if (!PyBool_Check(value)) { + PyErr_SetString(PyExc_TypeError, "greedy_meshing must be a boolean"); + return -1; + } + + bool enabled = (value == Py_True); + self->data->setGreedyMeshing(enabled); + return 0; +} + // ============================================================================= // Voxel access methods // ============================================================================= @@ -522,6 +547,557 @@ PyObject* PyVoxelGrid::count_material(PyVoxelGridObject* self, PyObject* args) { return PyLong_FromSize_t(self->data->countMaterial(static_cast(material))); } +// ============================================================================= +// Bulk operations - Milestone 11 +// ============================================================================= + +PyObject* PyVoxelGrid::fill_box_hollow(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + static const char* kwlist[] = {"min_coord", "max_coord", "material", "thickness", nullptr}; + + PyObject* min_obj = nullptr; + PyObject* max_obj = nullptr; + int material; + int thickness = 1; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOi|i", const_cast(kwlist), + &min_obj, &max_obj, &material, &thickness)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + if (thickness < 1) { + PyErr_SetString(PyExc_ValueError, "thickness must be >= 1"); + return nullptr; + } + + // Parse coordinates + 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 (!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(min_obj) != 3 || PySequence_Size(max_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "coordinates 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->fillBoxHollow(x0, y0, z0, x1, y1, z1, static_cast(material), thickness); + Py_RETURN_NONE; +} + +PyObject* PyVoxelGrid::fill_sphere(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + PyObject* center_obj = nullptr; + int radius; + int material; + + if (!PyArg_ParseTuple(args, "Oii", ¢er_obj, &radius, &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + if (radius < 0) { + PyErr_SetString(PyExc_ValueError, "radius must be >= 0"); + return nullptr; + } + + if (!PyTuple_Check(center_obj) && !PyList_Check(center_obj)) { + PyErr_SetString(PyExc_TypeError, "center must be a tuple or list of 3 integers"); + return nullptr; + } + if (PySequence_Size(center_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "center must have exactly 3 elements"); + return nullptr; + } + + int cx, cy, cz; + PyObject* items[3]; + items[0] = PySequence_GetItem(center_obj, 0); + items[1] = PySequence_GetItem(center_obj, 1); + items[2] = PySequence_GetItem(center_obj, 2); + + bool valid = true; + if (PyLong_Check(items[0])) cx = (int)PyLong_AsLong(items[0]); else valid = false; + if (PyLong_Check(items[1])) cy = (int)PyLong_AsLong(items[1]); else valid = false; + if (PyLong_Check(items[2])) cz = (int)PyLong_AsLong(items[2]); else valid = false; + + for (int i = 0; i < 3; i++) Py_DECREF(items[i]); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "center elements must be integers"); + return nullptr; + } + + self->data->fillSphere(cx, cy, cz, radius, static_cast(material)); + Py_RETURN_NONE; +} + +PyObject* PyVoxelGrid::fill_cylinder(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + PyObject* base_obj = nullptr; + int radius; + int height; + int material; + + if (!PyArg_ParseTuple(args, "Oiii", &base_obj, &radius, &height, &material)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + if (radius < 0) { + PyErr_SetString(PyExc_ValueError, "radius must be >= 0"); + return nullptr; + } + + if (height < 1) { + PyErr_SetString(PyExc_ValueError, "height must be >= 1"); + return nullptr; + } + + if (!PyTuple_Check(base_obj) && !PyList_Check(base_obj)) { + PyErr_SetString(PyExc_TypeError, "base_pos must be a tuple or list of 3 integers"); + return nullptr; + } + if (PySequence_Size(base_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "base_pos must have exactly 3 elements"); + return nullptr; + } + + int cx, cy, cz; + PyObject* items[3]; + items[0] = PySequence_GetItem(base_obj, 0); + items[1] = PySequence_GetItem(base_obj, 1); + items[2] = PySequence_GetItem(base_obj, 2); + + bool valid = true; + if (PyLong_Check(items[0])) cx = (int)PyLong_AsLong(items[0]); else valid = false; + if (PyLong_Check(items[1])) cy = (int)PyLong_AsLong(items[1]); else valid = false; + if (PyLong_Check(items[2])) cz = (int)PyLong_AsLong(items[2]); else valid = false; + + for (int i = 0; i < 3; i++) Py_DECREF(items[i]); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "base_pos elements must be integers"); + return nullptr; + } + + self->data->fillCylinder(cx, cy, cz, radius, height, static_cast(material)); + Py_RETURN_NONE; +} + +PyObject* PyVoxelGrid::fill_noise(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + static const char* kwlist[] = {"min_coord", "max_coord", "material", "threshold", "scale", "seed", nullptr}; + + PyObject* min_obj = nullptr; + PyObject* max_obj = nullptr; + int material; + float threshold = 0.5f; + float scale = 0.1f; + unsigned int seed = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOi|ffI", const_cast(kwlist), + &min_obj, &max_obj, &material, &threshold, &scale, &seed)) { + return nullptr; + } + + if (material < 0 || material > 255) { + PyErr_SetString(PyExc_ValueError, "material must be 0-255"); + return nullptr; + } + + // Parse coordinates + 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 (!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(min_obj) != 3 || PySequence_Size(max_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "coordinates 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->fillNoise(x0, y0, z0, x1, y1, z1, static_cast(material), threshold, scale, seed); + Py_RETURN_NONE; +} + +// ============================================================================= +// Copy/paste operations - Milestone 11 +// ============================================================================= + +PyObject* PyVoxelGrid::copy_region(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + PyObject* min_obj = nullptr; + PyObject* max_obj = nullptr; + + if (!PyArg_ParseTuple(args, "OO", &min_obj, &max_obj)) { + return nullptr; + } + + // Parse coordinates + 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 (!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(min_obj) != 3 || PySequence_Size(max_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "coordinates 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; + } + + // Copy the region + mcrf::VoxelRegion region = self->data->copyRegion(x0, y0, z0, x1, y1, z1); + + // Create Python object + PyVoxelRegionObject* result = (PyVoxelRegionObject*)mcrfpydef::PyVoxelRegionType.tp_alloc( + &mcrfpydef::PyVoxelRegionType, 0); + if (!result) { + return nullptr; + } + + result->data = std::make_shared(std::move(region)); + return (PyObject*)result; +} + +PyObject* PyVoxelGrid::paste_region(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + static const char* kwlist[] = {"region", "position", "skip_air", nullptr}; + + PyObject* region_obj = nullptr; + PyObject* pos_obj = nullptr; + int skip_air = 1; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|p", const_cast(kwlist), + ®ion_obj, &pos_obj, &skip_air)) { + return nullptr; + } + + // Check region type + if (!PyObject_TypeCheck(region_obj, &mcrfpydef::PyVoxelRegionType)) { + PyErr_SetString(PyExc_TypeError, "region must be a VoxelRegion object"); + return nullptr; + } + + PyVoxelRegionObject* py_region = (PyVoxelRegionObject*)region_obj; + if (!py_region->data || !py_region->data->isValid()) { + PyErr_SetString(PyExc_ValueError, "VoxelRegion is empty or invalid"); + return nullptr; + } + + // Parse position + if (!PyTuple_Check(pos_obj) && !PyList_Check(pos_obj)) { + PyErr_SetString(PyExc_TypeError, "position must be a tuple or list of 3 integers"); + return nullptr; + } + if (PySequence_Size(pos_obj) != 3) { + PyErr_SetString(PyExc_ValueError, "position must have exactly 3 elements"); + return nullptr; + } + + int x, y, z; + PyObject* items[3]; + items[0] = PySequence_GetItem(pos_obj, 0); + items[1] = PySequence_GetItem(pos_obj, 1); + items[2] = PySequence_GetItem(pos_obj, 2); + + bool valid = true; + if (PyLong_Check(items[0])) x = (int)PyLong_AsLong(items[0]); else valid = false; + if (PyLong_Check(items[1])) y = (int)PyLong_AsLong(items[1]); else valid = false; + if (PyLong_Check(items[2])) z = (int)PyLong_AsLong(items[2]); else valid = false; + + for (int i = 0; i < 3; i++) Py_DECREF(items[i]); + + if (!valid) { + PyErr_SetString(PyExc_TypeError, "position elements must be integers"); + return nullptr; + } + + self->data->pasteRegion(*py_region->data, x, y, z, skip_air != 0); + Py_RETURN_NONE; +} + +// ============================================================================= +// Serialization (Milestone 14) +// ============================================================================= + +PyObject* PyVoxelGrid::save(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + const char* path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + + if (self->data->save(path)) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + +PyObject* PyVoxelGrid::load(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + const char* path = nullptr; + if (!PyArg_ParseTuple(args, "s", &path)) { + return nullptr; + } + + if (self->data->load(path)) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + +PyObject* PyVoxelGrid::to_bytes(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + std::vector buffer; + if (!self->data->saveToBuffer(buffer)) { + PyErr_SetString(PyExc_RuntimeError, "Failed to serialize VoxelGrid"); + return nullptr; + } + + return PyBytes_FromStringAndSize(reinterpret_cast(buffer.data()), buffer.size()); +} + +PyObject* PyVoxelGrid::from_bytes(PyVoxelGridObject* self, PyObject* args) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + Py_buffer buffer; + if (!PyArg_ParseTuple(args, "y*", &buffer)) { + return nullptr; + } + + bool success = self->data->loadFromBuffer( + static_cast(buffer.buf), buffer.len); + + PyBuffer_Release(&buffer); + + if (success) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + +// ============================================================================= +// Navigation Projection (Milestone 12) +// ============================================================================= + +static PyObject* project_column(PyVoxelGridObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"x", "z", "headroom", nullptr}; + int x = 0, z = 0, headroom = 2; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "ii|i", const_cast(kwlist), + &x, &z, &headroom)) { + return nullptr; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized"); + return nullptr; + } + + if (headroom < 0) { + PyErr_SetString(PyExc_ValueError, "headroom must be non-negative"); + return nullptr; + } + + mcrf::VoxelGrid::NavInfo nav = self->data->projectColumn(x, z, headroom); + + // Return as dictionary with nav info + return Py_BuildValue("{s:f,s:O,s:O,s:f}", + "height", nav.height, + "walkable", nav.walkable ? Py_True : Py_False, + "transparent", nav.transparent ? Py_True : Py_False, + "path_cost", nav.pathCost); +} + +// ============================================================================= +// PyVoxelRegion implementation +// ============================================================================= + +void PyVoxelRegion::dealloc(PyVoxelRegionObject* self) { + self->data.reset(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyVoxelRegion::repr(PyObject* obj) { + PyVoxelRegionObject* self = (PyVoxelRegionObject*)obj; + if (!self->data || !self->data->isValid()) { + return PyUnicode_FromString(""); + } + + std::ostringstream oss; + oss << "data->width << "x" + << self->data->height << "x" << self->data->depth + << " voxels=" << self->data->totalVoxels() << ">"; + return PyUnicode_FromString(oss.str().c_str()); +} + +PyObject* PyVoxelRegion::get_size(PyVoxelRegionObject* self, void* closure) { + if (!self->data || !self->data->isValid()) { + return Py_BuildValue("(iii)", 0, 0, 0); + } + return Py_BuildValue("(iii)", self->data->width, self->data->height, self->data->depth); +} + +PyObject* PyVoxelRegion::get_width(PyVoxelRegionObject* self, void* closure) { + if (!self->data) return PyLong_FromLong(0); + return PyLong_FromLong(self->data->width); +} + +PyObject* PyVoxelRegion::get_height(PyVoxelRegionObject* self, void* closure) { + if (!self->data) return PyLong_FromLong(0); + return PyLong_FromLong(self->data->height); +} + +PyObject* PyVoxelRegion::get_depth(PyVoxelRegionObject* self, void* closure) { + if (!self->data) return PyLong_FromLong(0); + return PyLong_FromLong(self->data->depth); +} + +PyGetSetDef PyVoxelRegion::getsetters[] = { + {"size", (getter)get_size, nullptr, + "Dimensions (width, height, depth) of the region. Read-only.", nullptr}, + {"width", (getter)get_width, nullptr, "Region width. Read-only.", nullptr}, + {"height", (getter)get_height, nullptr, "Region height. Read-only.", nullptr}, + {"depth", (getter)get_depth, nullptr, "Region depth. Read-only.", nullptr}, + {nullptr} // Sentinel +}; + // ============================================================================= // Method definitions // ============================================================================= @@ -556,6 +1132,54 @@ PyMethodDef PyVoxelGrid::methods[] = { " max_coord: (x1, y1, z1) - maximum corner (inclusive)\n" " material: material ID (0-255)\n\n" "Coordinates are clamped to grid bounds."}, + {"fill_box_hollow", (PyCFunction)fill_box_hollow, METH_VARARGS | METH_KEYWORDS, + "fill_box_hollow(min_coord, max_coord, material, thickness=1) -> None\n\n" + "Create a hollow rectangular room (walls only, hollow inside).\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 for walls (0-255)\n" + " thickness: wall thickness in voxels (default 1)"}, + {"fill_sphere", (PyCFunction)fill_sphere, METH_VARARGS, + "fill_sphere(center, radius, material) -> None\n\n" + "Fill a spherical region.\n\n" + "Args:\n" + " center: (cx, cy, cz) - sphere center coordinates\n" + " radius: sphere radius in voxels\n" + " material: material ID (0-255, use 0 to carve)"}, + {"fill_cylinder", (PyCFunction)fill_cylinder, METH_VARARGS, + "fill_cylinder(base_pos, radius, height, material) -> None\n\n" + "Fill a vertical cylinder (Y-axis aligned).\n\n" + "Args:\n" + " base_pos: (cx, cy, cz) - base center position\n" + " radius: cylinder radius in voxels\n" + " height: cylinder height in voxels\n" + " material: material ID (0-255)"}, + {"fill_noise", (PyCFunction)fill_noise, METH_VARARGS | METH_KEYWORDS, + "fill_noise(min_coord, max_coord, material, threshold=0.5, scale=0.1, seed=0) -> None\n\n" + "Fill region with 3D noise-based pattern (caves, clouds).\n\n" + "Args:\n" + " min_coord: (x0, y0, z0) - minimum corner\n" + " max_coord: (x1, y1, z1) - maximum corner\n" + " material: material ID for solid areas\n" + " threshold: noise threshold (0-1, higher = more solid)\n" + " scale: noise scale (smaller = larger features)\n" + " seed: random seed (0 for default)"}, + {"copy_region", (PyCFunction)copy_region, METH_VARARGS, + "copy_region(min_coord, max_coord) -> VoxelRegion\n\n" + "Copy a rectangular region to a VoxelRegion prefab.\n\n" + "Args:\n" + " min_coord: (x0, y0, z0) - minimum corner (inclusive)\n" + " max_coord: (x1, y1, z1) - maximum corner (inclusive)\n\n" + "Returns:\n" + " VoxelRegion object that can be pasted elsewhere."}, + {"paste_region", (PyCFunction)paste_region, METH_VARARGS | METH_KEYWORDS, + "paste_region(region, position, skip_air=True) -> None\n\n" + "Paste a VoxelRegion prefab at the specified position.\n\n" + "Args:\n" + " region: VoxelRegion from copy_region()\n" + " position: (x, y, z) - paste destination\n" + " skip_air: if True, air voxels don't overwrite (default True)"}, {"clear", (PyCFunction)clear, METH_NOARGS, "clear() -> None\n\n" "Clear the grid (fill with air, material 0)."}, @@ -568,6 +1192,53 @@ PyMethodDef PyVoxelGrid::methods[] = { {"count_material", (PyCFunction)count_material, METH_VARARGS, "count_material(material) -> int\n\n" "Count the number of voxels with the specified material ID."}, + {"project_column", (PyCFunction)project_column, METH_VARARGS | METH_KEYWORDS, + "project_column(x, z, headroom=2) -> dict\n\n" + "Project a single column to navigation info.\n\n" + "Scans the column from top to bottom, finding the topmost floor\n" + "(solid voxel with air above) and checking for adequate headroom.\n\n" + "Args:\n" + " x: X coordinate in voxel grid\n" + " z: Z coordinate in voxel grid\n" + " headroom: Required air voxels above floor (default 2)\n\n" + "Returns:\n" + " dict with keys:\n" + " height (float): World Y of floor surface\n" + " walkable (bool): True if floor found with adequate headroom\n" + " transparent (bool): True if no opaque voxels in column\n" + " path_cost (float): Floor material's path cost"}, + {"save", (PyCFunction)PyVoxelGrid::save, METH_VARARGS, + "save(path) -> bool\n\n" + "Save the voxel grid to a binary file.\n\n" + "Args:\n" + " path: File path to save to (.mcvg extension recommended)\n\n" + "Returns:\n" + " True on success, False on failure.\n\n" + "The file format includes grid dimensions, cell size, material palette,\n" + "and RLE-compressed voxel data."}, + {"load", (PyCFunction)PyVoxelGrid::load, METH_VARARGS, + "load(path) -> bool\n\n" + "Load voxel data from a binary file.\n\n" + "Args:\n" + " path: File path to load from\n\n" + "Returns:\n" + " True on success, False on failure.\n\n" + "Note: This replaces the current grid data entirely, including\n" + "dimensions and material palette."}, + {"to_bytes", (PyCFunction)PyVoxelGrid::to_bytes, METH_NOARGS, + "to_bytes() -> bytes\n\n" + "Serialize the voxel grid to a bytes object.\n\n" + "Returns:\n" + " bytes object containing the serialized grid data.\n\n" + "Useful for network transmission or custom storage."}, + {"from_bytes", (PyCFunction)PyVoxelGrid::from_bytes, METH_VARARGS, + "from_bytes(data) -> bool\n\n" + "Load voxel data from a bytes object.\n\n" + "Args:\n" + " data: bytes object containing serialized grid data\n\n" + "Returns:\n" + " True on success, False on failure.\n\n" + "Note: This replaces the current grid data entirely."}, {nullptr} // Sentinel }; @@ -594,5 +1265,7 @@ PyGetSetDef PyVoxelGrid::getsetters[] = { "World-space position (x, y, z) of the grid origin.", nullptr}, {"rotation", (getter)get_rotation, (setter)set_rotation, "Y-axis rotation in degrees.", nullptr}, + {"greedy_meshing", (getter)get_greedy_meshing, (setter)set_greedy_meshing, + "Enable greedy meshing optimization (reduces vertex count for uniform regions).", nullptr}, {nullptr} // Sentinel }; diff --git a/src/3d/PyVoxelGrid.h b/src/3d/PyVoxelGrid.h index 92eb13b..dd99cf6 100644 --- a/src/3d/PyVoxelGrid.h +++ b/src/3d/PyVoxelGrid.h @@ -1,5 +1,5 @@ // PyVoxelGrid.h - Python bindings for VoxelGrid -// Part of McRogueFace 3D Extension - Milestone 9 +// Part of McRogueFace 3D Extension - Milestones 9, 11 #pragma once #include "../Common.h" @@ -7,11 +7,8 @@ #include "VoxelGrid.h" #include -// Forward declaration -class PyVoxelGrid; - // ============================================================================= -// Python object structure +// Python object structures // ============================================================================= typedef struct PyVoxelGridObject { @@ -20,8 +17,13 @@ typedef struct PyVoxelGridObject { PyObject* weakreflist; } PyVoxelGridObject; +typedef struct PyVoxelRegionObject { + PyObject_HEAD + std::shared_ptr data; +} PyVoxelRegionObject; + // ============================================================================= -// Python binding class +// Python binding classes // ============================================================================= class PyVoxelGrid { @@ -46,6 +48,10 @@ public: static PyObject* get_rotation(PyVoxelGridObject* self, void* closure); static int set_rotation(PyVoxelGridObject* self, PyObject* value, void* closure); + // Properties - mesh generation (Milestone 13) + static PyObject* get_greedy_meshing(PyVoxelGridObject* self, void* closure); + static int set_greedy_meshing(PyVoxelGridObject* self, PyObject* value, void* closure); + // Voxel access methods static PyObject* get(PyVoxelGridObject* self, PyObject* args); static PyObject* set(PyVoxelGridObject* self, PyObject* args); @@ -59,10 +65,26 @@ public: static PyObject* fill_box(PyVoxelGridObject* self, PyObject* args); static PyObject* clear(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + // Bulk operations - Milestone 11 + static PyObject* fill_box_hollow(PyVoxelGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* fill_sphere(PyVoxelGridObject* self, PyObject* args); + static PyObject* fill_cylinder(PyVoxelGridObject* self, PyObject* args); + static PyObject* fill_noise(PyVoxelGridObject* self, PyObject* args, PyObject* kwds); + + // Copy/paste operations - Milestone 11 + static PyObject* copy_region(PyVoxelGridObject* self, PyObject* args); + static PyObject* paste_region(PyVoxelGridObject* self, PyObject* args, PyObject* kwds); + // Mesh caching (Milestone 10) static PyObject* get_vertex_count(PyVoxelGridObject* self, void* closure); static PyObject* rebuild_mesh(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + // Serialization (Milestone 14) + static PyObject* save(PyVoxelGridObject* self, PyObject* args); + static PyObject* load(PyVoxelGridObject* self, PyObject* args); + static PyObject* to_bytes(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); + static PyObject* from_bytes(PyVoxelGridObject* self, PyObject* args); + // Statistics static PyObject* count_non_air(PyVoxelGridObject* self, PyObject* Py_UNUSED(args)); static PyObject* count_material(PyVoxelGridObject* self, PyObject* args); @@ -72,8 +94,20 @@ public: static PyGetSetDef getsetters[]; }; +class PyVoxelRegion { +public: + static void dealloc(PyVoxelRegionObject* self); + static PyObject* repr(PyObject* obj); + static PyObject* get_size(PyVoxelRegionObject* self, void* closure); + static PyObject* get_width(PyVoxelRegionObject* self, void* closure); + static PyObject* get_height(PyVoxelRegionObject* self, void* closure); + static PyObject* get_depth(PyVoxelRegionObject* self, void* closure); + + static PyGetSetDef getsetters[]; +}; + // ============================================================================= -// Python type definition +// Python type definitions (in mcrfpydef namespace) // ============================================================================= namespace mcrfpydef { @@ -122,4 +156,24 @@ inline PyTypeObject PyVoxelGridType = { .tp_new = PyVoxelGrid::pynew, }; +inline PyTypeObject PyVoxelRegionType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.VoxelRegion", + .tp_basicsize = sizeof(PyVoxelRegionObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyVoxelRegion::dealloc, + .tp_repr = PyVoxelRegion::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "VoxelRegion - Portable voxel data for copy/paste operations.\n\n" + "Created by VoxelGrid.copy_region(), used with paste_region().\n" + "Cannot be instantiated directly.\n\n" + "Properties:\n" + " size (tuple, read-only): Dimensions as (width, height, depth)\n" + " width, height, depth (int, read-only): Individual dimensions" + ), + .tp_getset = nullptr, // Set before PyType_Ready + .tp_new = nullptr, // Cannot instantiate directly +}; + } // namespace mcrfpydef diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 5d1f392..f284ac4 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -872,6 +872,104 @@ bool Viewport3D::removeVoxelLayer(std::shared_ptr grid) { return false; } +// ============================================================================= +// Voxel-to-Nav Projection (Milestone 12) +// ============================================================================= + +void Viewport3D::clearVoxelNavRegion(std::shared_ptr grid) { + if (!grid || navGrid_.empty()) return; + + // Get voxel grid offset in world space + vec3 offset = grid->getOffset(); + float cellSize = grid->cellSize(); + + // Calculate nav grid cell offset from voxel grid offset + int navOffsetX = static_cast(std::floor(offset.x / cellSize_)); + int navOffsetZ = static_cast(std::floor(offset.z / cellSize_)); + + // Clear nav cells corresponding to voxel grid footprint + for (int vz = 0; vz < grid->depth(); vz++) { + for (int vx = 0; vx < grid->width(); vx++) { + int navX = navOffsetX + vx; + int navZ = navOffsetZ + vz; + + if (isValidCell(navX, navZ)) { + VoxelPoint& cell = at(navX, navZ); + cell.walkable = true; + cell.transparent = true; + cell.height = 0.0f; + cell.cost = 1.0f; + } + } + } + + // Sync to TCOD + syncToTCOD(); +} + +void Viewport3D::projectVoxelToNav(std::shared_ptr grid, int headroom) { + if (!grid || navGrid_.empty()) return; + + // Get voxel grid offset in world space + vec3 offset = grid->getOffset(); + float voxelCellSize = grid->cellSize(); + + // Calculate nav grid cell offset from voxel grid offset + // Assuming nav cell size matches voxel cell size for 1:1 mapping + int navOffsetX = static_cast(std::floor(offset.x / cellSize_)); + int navOffsetZ = static_cast(std::floor(offset.z / cellSize_)); + + // Project each column of the voxel grid to the navigation grid + for (int vz = 0; vz < grid->depth(); vz++) { + for (int vx = 0; vx < grid->width(); vx++) { + int navX = navOffsetX + vx; + int navZ = navOffsetZ + vz; + + if (!isValidCell(navX, navZ)) continue; + + // Get projection info from voxel column + VoxelGrid::NavInfo navInfo = grid->projectColumn(vx, vz, headroom); + + // Update nav cell + VoxelPoint& cell = at(navX, navZ); + cell.height = navInfo.height + offset.y; // Add world Y offset + cell.walkable = navInfo.walkable; + cell.transparent = navInfo.transparent; + cell.cost = navInfo.pathCost; + + // Sync this cell to TCOD + syncTCODCell(navX, navZ); + } + } +} + +void Viewport3D::projectAllVoxelsToNav(int headroom) { + if (navGrid_.empty()) return; + + // First, reset all nav cells to default state + for (auto& cell : navGrid_) { + cell.walkable = true; + cell.transparent = true; + cell.height = 0.0f; + cell.cost = 1.0f; + } + + // Project each voxel layer in order (later layers overwrite earlier) + // Sort by z_index so higher z_index layers take precedence + std::vector, int>> sortedLayers = voxelLayers_; + std::sort(sortedLayers.begin(), sortedLayers.end(), + [](const auto& a, const auto& b) { return a.second < b.second; }); + + for (const auto& pair : sortedLayers) { + if (pair.first) { + projectVoxelToNav(pair.first, headroom); + } + } + + // Final sync to TCOD (redundant but ensures consistency) + syncToTCOD(); +} + void Viewport3D::renderVoxelLayers(const mat4& view, const mat4& proj) { #ifdef MCRF_HAS_GL if (voxelLayers_.empty() || !shader_ || !shader_->isValid()) { @@ -2475,6 +2573,99 @@ static PyObject* Viewport3D_voxel_layer_count(PyViewport3DObject* self, PyObject return PyLong_FromSize_t(self->data->getVoxelLayerCount()); } +// ============================================================================= +// Voxel-to-Nav Projection Methods (Milestone 12) +// ============================================================================= + +static PyObject* Viewport3D_project_voxel_to_nav(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"voxel_grid", "headroom", NULL}; + PyObject* voxel_grid_obj = nullptr; + int headroom = 2; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast(kwlist), + &voxel_grid_obj, &headroom)) { + 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; + } + + if (headroom < 0) { + PyErr_SetString(PyExc_ValueError, "headroom must be non-negative"); + return NULL; + } + + self->data->projectVoxelToNav(vg->data, headroom); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_project_all_voxels_to_nav(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"headroom", NULL}; + int headroom = 2; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|i", const_cast(kwlist), &headroom)) { + return NULL; + } + + if (headroom < 0) { + PyErr_SetString(PyExc_ValueError, "headroom must be non-negative"); + return NULL; + } + + self->data->projectAllVoxelsToNav(headroom); + Py_RETURN_NONE; +} + +static PyObject* Viewport3D_clear_voxel_nav_region(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; + } + + self->data->clearVoxelNavRegion(vg->data); + Py_RETURN_NONE; +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -2674,5 +2865,29 @@ PyMethodDef Viewport3D_methods[] = { "Get the number of voxel layers.\n\n" "Returns:\n" " Number of voxel layers in the viewport"}, + + // Voxel-to-Nav projection methods (Milestone 12) + {"project_voxel_to_nav", (PyCFunction)mcrf::Viewport3D_project_voxel_to_nav, METH_VARARGS | METH_KEYWORDS, + "project_voxel_to_nav(voxel_grid, headroom=2)\n\n" + "Project a VoxelGrid to the navigation grid.\n\n" + "Scans each column of the voxel grid and updates corresponding\n" + "navigation cells with walkability, transparency, height, and cost.\n\n" + "Args:\n" + " voxel_grid: VoxelGrid to project\n" + " headroom: Required air voxels above floor for walkability (default: 2)"}, + {"project_all_voxels_to_nav", (PyCFunction)mcrf::Viewport3D_project_all_voxels_to_nav, METH_VARARGS | METH_KEYWORDS, + "project_all_voxels_to_nav(headroom=2)\n\n" + "Project all voxel layers to the navigation grid.\n\n" + "Resets navigation grid and projects each voxel layer in z_index order.\n" + "Later layers (higher z_index) overwrite earlier ones.\n\n" + "Args:\n" + " headroom: Required air voxels above floor for walkability (default: 2)"}, + {"clear_voxel_nav_region", (PyCFunction)mcrf::Viewport3D_clear_voxel_nav_region, METH_VARARGS, + "clear_voxel_nav_region(voxel_grid)\n\n" + "Clear navigation cells in a voxel grid's footprint.\n\n" + "Resets walkability, transparency, height, and cost to defaults\n" + "for all nav cells corresponding to the voxel grid's XZ extent.\n\n" + "Args:\n" + " voxel_grid: VoxelGrid whose nav region to clear"}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index 59b8b59..c38901f 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -250,6 +250,23 @@ public: /// Render all voxel layers void renderVoxelLayers(const mat4& view, const mat4& proj); + // ========================================================================= + // Voxel-to-Nav Projection (Milestone 12) + // ========================================================================= + + /// Project a single voxel grid to the navigation grid + /// @param grid The voxel grid to project + /// @param headroom Required air voxels above floor for walkability + void projectVoxelToNav(std::shared_ptr grid, int headroom = 2); + + /// Project all voxel layers to the navigation grid + /// @param headroom Required air voxels above floor for walkability + void projectAllVoxelsToNav(int headroom = 2); + + /// Clear nav cells in a voxel grid's footprint (before re-projection) + /// @param grid The voxel grid whose footprint to clear + void clearVoxelNavRegion(std::shared_ptr grid); + // Background color void setBackgroundColor(const sf::Color& color) { bgColor_ = color; } sf::Color getBackgroundColor() const { return bgColor_; } diff --git a/src/3d/VoxelGrid.cpp b/src/3d/VoxelGrid.cpp index 2c3ed34..f69e71e 100644 --- a/src/3d/VoxelGrid.cpp +++ b/src/3d/VoxelGrid.cpp @@ -1,9 +1,13 @@ // VoxelGrid.cpp - Dense 3D voxel array implementation -// Part of McRogueFace 3D Extension - Milestones 9-10 +// Part of McRogueFace 3D Extension - Milestones 9-11 #include "VoxelGrid.h" #include "VoxelMesher.h" #include "MeshLayer.h" // For MeshVertex +#include +#include +#include // For memcpy, memcmp +#include // For file I/O namespace mcrf { @@ -151,6 +155,297 @@ void VoxelGrid::fillBox(int x0, int y0, int z0, int x1, int y1, int z1, uint8_t meshDirty_ = true; } +// ============================================================================= +// Bulk Operations - Milestone 11 +// ============================================================================= + +void VoxelGrid::fillBoxHollow(int x0, int y0, int z0, int x1, int y1, int z1, + uint8_t material, int thickness) { + // 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); + + // Fill entire box with material + fillBox(x0, y0, z0, x1, y1, z1, material); + + // Carve out interior (inset by thickness on all sides) + int ix0 = x0 + thickness; + int iy0 = y0 + thickness; + int iz0 = z0 + thickness; + int ix1 = x1 - thickness; + int iy1 = y1 - thickness; + int iz1 = z1 - thickness; + + // Only carve if there's interior space + if (ix0 <= ix1 && iy0 <= iy1 && iz0 <= iz1) { + fillBox(ix0, iy0, iz0, ix1, iy1, iz1, 0); // Air + } + // meshDirty_ already set by fillBox calls +} + +void VoxelGrid::fillSphere(int cx, int cy, int cz, int radius, uint8_t material) { + int r2 = radius * radius; + + for (int z = cz - radius; z <= cz + radius; z++) { + for (int y = cy - radius; y <= cy + radius; y++) { + for (int x = cx - radius; x <= cx + radius; x++) { + int dx = x - cx; + int dy = y - cy; + int dz = z - cz; + if (dx * dx + dy * dy + dz * dz <= r2) { + if (isValid(x, y, z)) { + data_[index(x, y, z)] = material; + } + } + } + } + } + meshDirty_ = true; +} + +void VoxelGrid::fillCylinder(int cx, int cy, int cz, int radius, int height, uint8_t material) { + int r2 = radius * radius; + + for (int y = cy; y < cy + height; y++) { + for (int z = cz - radius; z <= cz + radius; z++) { + for (int x = cx - radius; x <= cx + radius; x++) { + int dx = x - cx; + int dz = z - cz; + if (dx * dx + dz * dz <= r2) { + if (isValid(x, y, z)) { + data_[index(x, y, z)] = material; + } + } + } + } + } + meshDirty_ = true; +} + +// Simple 3D noise implementation (hash-based, similar to value noise) +namespace { + // Simple hash function for noise + inline unsigned int hash3D(int x, int y, int z, unsigned int seed) { + unsigned int h = seed; + h ^= static_cast(x) * 374761393u; + h ^= static_cast(y) * 668265263u; + h ^= static_cast(z) * 2147483647u; + h = (h ^ (h >> 13)) * 1274126177u; + return h; + } + + // Convert hash to 0-1 float + inline float hashToFloat(unsigned int h) { + return static_cast(h & 0xFFFFFF) / static_cast(0xFFFFFF); + } + + // Linear interpolation + inline float lerp(float a, float b, float t) { + return a + t * (b - a); + } + + // Smoothstep for smoother interpolation + inline float smoothstep(float t) { + return t * t * (3.0f - 2.0f * t); + } + + // 3D value noise + float noise3D(float x, float y, float z, unsigned int seed) { + int xi = static_cast(std::floor(x)); + int yi = static_cast(std::floor(y)); + int zi = static_cast(std::floor(z)); + + float xf = x - xi; + float yf = y - yi; + float zf = z - zi; + + // Smoothstep the fractions + float u = smoothstep(xf); + float v = smoothstep(yf); + float w = smoothstep(zf); + + // Hash corners of the unit cube + float c000 = hashToFloat(hash3D(xi, yi, zi, seed)); + float c100 = hashToFloat(hash3D(xi + 1, yi, zi, seed)); + float c010 = hashToFloat(hash3D(xi, yi + 1, zi, seed)); + float c110 = hashToFloat(hash3D(xi + 1, yi + 1, zi, seed)); + float c001 = hashToFloat(hash3D(xi, yi, zi + 1, seed)); + float c101 = hashToFloat(hash3D(xi + 1, yi, zi + 1, seed)); + float c011 = hashToFloat(hash3D(xi, yi + 1, zi + 1, seed)); + float c111 = hashToFloat(hash3D(xi + 1, yi + 1, zi + 1, seed)); + + // Trilinear interpolation + float x00 = lerp(c000, c100, u); + float x10 = lerp(c010, c110, u); + float x01 = lerp(c001, c101, u); + float x11 = lerp(c011, c111, u); + + float y0 = lerp(x00, x10, v); + float y1 = lerp(x01, x11, v); + + return lerp(y0, y1, w); + } +} + +void VoxelGrid::fillNoise(int x0, int y0, int z0, int x1, int y1, int z1, + uint8_t material, float threshold, float scale, unsigned int seed) { + // Ensure proper ordering + 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++) { + float n = noise3D(x * scale, y * scale, z * scale, seed); + if (n > threshold) { + data_[index(x, y, z)] = material; + } + } + } + } + meshDirty_ = true; +} + +// ============================================================================= +// Copy/Paste Operations - Milestone 11 +// ============================================================================= + +VoxelRegion VoxelGrid::copyRegion(int x0, int y0, int z0, int x1, int y1, int z1) const { + // Ensure proper ordering + 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)); + + int rw = x1 - x0 + 1; + int rh = y1 - y0 + 1; + int rd = z1 - z0 + 1; + + VoxelRegion region(rw, rh, rd); + + for (int rz = 0; rz < rd; rz++) { + for (int ry = 0; ry < rh; ry++) { + for (int rx = 0; rx < rw; rx++) { + int sx = x0 + rx; + int sy = y0 + ry; + int sz = z0 + rz; + size_t ri = static_cast(rz) * (rw * rh) + + static_cast(ry) * rw + rx; + region.data[ri] = get(sx, sy, sz); + } + } + } + + return region; +} + +void VoxelGrid::pasteRegion(const VoxelRegion& region, int x, int y, int z, bool skipAir) { + if (!region.isValid()) return; + + for (int rz = 0; rz < region.depth; rz++) { + for (int ry = 0; ry < region.height; ry++) { + for (int rx = 0; rx < region.width; rx++) { + size_t ri = static_cast(rz) * (region.width * region.height) + + static_cast(ry) * region.width + rx; + uint8_t mat = region.data[ri]; + + if (skipAir && mat == 0) continue; + + int dx = x + rx; + int dy = y + ry; + int dz = z + rz; + + if (isValid(dx, dy, dz)) { + data_[index(dx, dy, dz)] = mat; + } + } + } + } + meshDirty_ = true; +} + +// ============================================================================= +// Navigation Projection - Milestone 12 +// ============================================================================= + +VoxelGrid::NavInfo VoxelGrid::projectColumn(int x, int z, int headroom) const { + NavInfo info; + info.height = 0.0f; + info.walkable = false; + info.transparent = true; + info.pathCost = 1.0f; + + // Out of bounds check + if (x < 0 || x >= width_ || z < 0 || z >= depth_) { + return info; + } + + // Scan from top to bottom, find first solid with air above (floor) + int floorY = -1; + for (int y = height_ - 1; y >= 0; y--) { + uint8_t mat = get(x, y, z); + if (mat != 0) { + // Found solid - check if it's a floor (air above) or ceiling + bool hasAirAbove = (y == height_ - 1) || (get(x, y + 1, z) == 0); + if (hasAirAbove) { + floorY = y; + break; + } + } + } + + if (floorY >= 0) { + // Found a floor + info.height = (floorY + 1) * cellSize_; // Top of floor voxel + info.walkable = true; + + // Check headroom (need enough air voxels above floor) + int airCount = 0; + for (int y = floorY + 1; y < height_; y++) { + if (get(x, y, z) == 0) { + airCount++; + } else { + break; + } + } + if (airCount < headroom) { + info.walkable = false; // Can't fit entity + } + + // Get path cost from floor material + uint8_t floorMat = get(x, floorY, z); + info.pathCost = getMaterial(floorMat).pathCost; + } + + // Check transparency: any non-transparent solid in column blocks FOV + for (int y = 0; y < height_; y++) { + uint8_t mat = get(x, y, z); + if (mat != 0 && !getMaterial(mat).transparent) { + info.transparent = false; + break; + } + } + + return info; +} + // ============================================================================= // Mesh Caching (Milestone 10) // ============================================================================= @@ -164,8 +459,337 @@ const std::vector& VoxelGrid::getVertices() const { void VoxelGrid::rebuildMesh() const { cachedVertices_.clear(); - VoxelMesher::generateMesh(*this, cachedVertices_); + if (greedyMeshing_) { + VoxelMesher::generateGreedyMesh(*this, cachedVertices_); + } else { + VoxelMesher::generateMesh(*this, cachedVertices_); + } meshDirty_ = false; } +// ============================================================================= +// Serialization - Milestone 14 +// ============================================================================= + +// File format: +// Magic "MCVG" (4 bytes) +// Version (1 byte) - currently 1 +// Width, Height, Depth (3 x int32 = 12 bytes) +// Cell Size (float32 = 4 bytes) +// Material count (uint8 = 1 byte) +// For each material: +// Name length (uint16) + name bytes +// Color RGBA (4 bytes) +// Sprite index (int32) +// Transparent (uint8) +// Path cost (float32) +// Voxel data length (uint32) +// Voxel data: RLE encoded (run_length: uint8, material: uint8) pairs +// If run_length == 255, read extended_length: uint16 for longer runs + +namespace { + const char MAGIC[4] = {'M', 'C', 'V', 'G'}; + const uint8_t FORMAT_VERSION = 1; + + // Write helpers + void writeU8(std::vector& buf, uint8_t v) { + buf.push_back(v); + } + + void writeU16(std::vector& buf, uint16_t v) { + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + } + + void writeI32(std::vector& buf, int32_t v) { + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + buf.push_back(static_cast((v >> 16) & 0xFF)); + buf.push_back(static_cast((v >> 24) & 0xFF)); + } + + void writeU32(std::vector& buf, uint32_t v) { + buf.push_back(static_cast(v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + buf.push_back(static_cast((v >> 16) & 0xFF)); + buf.push_back(static_cast((v >> 24) & 0xFF)); + } + + void writeF32(std::vector& buf, float v) { + static_assert(sizeof(float) == 4, "Expected 4-byte float"); + const uint8_t* bytes = reinterpret_cast(&v); + buf.insert(buf.end(), bytes, bytes + 4); + } + + void writeString(std::vector& buf, const std::string& s) { + uint16_t len = static_cast(std::min(s.size(), size_t(65535))); + writeU16(buf, len); + buf.insert(buf.end(), s.begin(), s.begin() + len); + } + + // Read helpers + class Reader { + const uint8_t* data_; + size_t size_; + size_t pos_; + public: + Reader(const uint8_t* data, size_t size) : data_(data), size_(size), pos_(0) {} + + bool hasBytes(size_t n) const { return pos_ + n <= size_; } + size_t position() const { return pos_; } + + bool readU8(uint8_t& v) { + if (!hasBytes(1)) return false; + v = data_[pos_++]; + return true; + } + + bool readU16(uint16_t& v) { + if (!hasBytes(2)) return false; + v = static_cast(data_[pos_]) | + (static_cast(data_[pos_ + 1]) << 8); + pos_ += 2; + return true; + } + + bool readI32(int32_t& v) { + if (!hasBytes(4)) return false; + v = static_cast(data_[pos_]) | + (static_cast(data_[pos_ + 1]) << 8) | + (static_cast(data_[pos_ + 2]) << 16) | + (static_cast(data_[pos_ + 3]) << 24); + pos_ += 4; + return true; + } + + bool readU32(uint32_t& v) { + if (!hasBytes(4)) return false; + v = static_cast(data_[pos_]) | + (static_cast(data_[pos_ + 1]) << 8) | + (static_cast(data_[pos_ + 2]) << 16) | + (static_cast(data_[pos_ + 3]) << 24); + pos_ += 4; + return true; + } + + bool readF32(float& v) { + if (!hasBytes(4)) return false; + static_assert(sizeof(float) == 4, "Expected 4-byte float"); + std::memcpy(&v, data_ + pos_, 4); + pos_ += 4; + return true; + } + + bool readString(std::string& s) { + uint16_t len; + if (!readU16(len)) return false; + if (!hasBytes(len)) return false; + s.assign(reinterpret_cast(data_ + pos_), len); + pos_ += len; + return true; + } + + bool readBytes(uint8_t* out, size_t n) { + if (!hasBytes(n)) return false; + std::memcpy(out, data_ + pos_, n); + pos_ += n; + return true; + } + }; + + // RLE encode voxel data + void rleEncode(const std::vector& data, std::vector& out) { + if (data.empty()) return; + + size_t i = 0; + while (i < data.size()) { + uint8_t mat = data[i]; + size_t runStart = i; + + // Count consecutive same materials + while (i < data.size() && data[i] == mat && (i - runStart) < 65535 + 255) { + i++; + } + + size_t runLen = i - runStart; + + if (runLen < 255) { + writeU8(out, static_cast(runLen)); + } else { + // Extended run: 255 marker + uint16 length + writeU8(out, 255); + writeU16(out, static_cast(runLen - 255)); + } + writeU8(out, mat); + } + } + + // RLE decode voxel data + bool rleDecode(Reader& reader, std::vector& data, size_t expectedSize) { + data.clear(); + data.reserve(expectedSize); + + while (data.size() < expectedSize) { + uint8_t runLen8; + if (!reader.readU8(runLen8)) return false; + + size_t runLen = runLen8; + if (runLen8 == 255) { + uint16_t extLen; + if (!reader.readU16(extLen)) return false; + runLen = 255 + extLen; + } + + uint8_t mat; + if (!reader.readU8(mat)) return false; + + for (size_t j = 0; j < runLen && data.size() < expectedSize; j++) { + data.push_back(mat); + } + } + + return data.size() == expectedSize; + } +} + +bool VoxelGrid::saveToBuffer(std::vector& buffer) const { + buffer.clear(); + buffer.reserve(1024 + data_.size()); // Rough estimate + + // Magic + buffer.insert(buffer.end(), MAGIC, MAGIC + 4); + + // Version + writeU8(buffer, FORMAT_VERSION); + + // Dimensions + writeI32(buffer, width_); + writeI32(buffer, height_); + writeI32(buffer, depth_); + + // Cell size + writeF32(buffer, cellSize_); + + // Materials + writeU8(buffer, static_cast(materials_.size())); + for (const auto& mat : materials_) { + writeString(buffer, mat.name); + writeU8(buffer, mat.color.r); + writeU8(buffer, mat.color.g); + writeU8(buffer, mat.color.b); + writeU8(buffer, mat.color.a); + writeI32(buffer, mat.spriteIndex); + writeU8(buffer, mat.transparent ? 1 : 0); + writeF32(buffer, mat.pathCost); + } + + // RLE encode voxel data + std::vector rleData; + rleEncode(data_, rleData); + + // Write RLE data length and data + writeU32(buffer, static_cast(rleData.size())); + buffer.insert(buffer.end(), rleData.begin(), rleData.end()); + + return true; +} + +bool VoxelGrid::loadFromBuffer(const uint8_t* data, size_t size) { + Reader reader(data, size); + + // Check magic + uint8_t magic[4]; + if (!reader.readBytes(magic, 4)) return false; + if (std::memcmp(magic, MAGIC, 4) != 0) return false; + + // Check version + uint8_t version; + if (!reader.readU8(version)) return false; + if (version != FORMAT_VERSION) return false; + + // Read dimensions + int32_t w, h, d; + if (!reader.readI32(w) || !reader.readI32(h) || !reader.readI32(d)) return false; + if (w <= 0 || h <= 0 || d <= 0) return false; + + // Read cell size + float cs; + if (!reader.readF32(cs)) return false; + if (cs <= 0.0f) return false; + + // Read materials + uint8_t matCount; + if (!reader.readU8(matCount)) return false; + + std::vector newMaterials; + newMaterials.reserve(matCount); + for (uint8_t i = 0; i < matCount; i++) { + VoxelMaterial mat; + if (!reader.readString(mat.name)) return false; + + uint8_t r, g, b, a; + if (!reader.readU8(r) || !reader.readU8(g) || !reader.readU8(b) || !reader.readU8(a)) + return false; + mat.color = sf::Color(r, g, b, a); + + int32_t sprite; + if (!reader.readI32(sprite)) return false; + mat.spriteIndex = sprite; + + uint8_t transp; + if (!reader.readU8(transp)) return false; + mat.transparent = (transp != 0); + + if (!reader.readF32(mat.pathCost)) return false; + + newMaterials.push_back(mat); + } + + // Read RLE data length + uint32_t rleLen; + if (!reader.readU32(rleLen)) return false; + + // Decode voxel data + size_t expectedVoxels = static_cast(w) * h * d; + std::vector newData; + if (!rleDecode(reader, newData, expectedVoxels)) return false; + + // Success - update the grid + width_ = w; + height_ = h; + depth_ = d; + cellSize_ = cs; + materials_ = std::move(newMaterials); + data_ = std::move(newData); + meshDirty_ = true; + + return true; +} + +bool VoxelGrid::save(const std::string& path) const { + std::vector buffer; + if (!saveToBuffer(buffer)) return false; + + std::ofstream file(path, std::ios::binary); + if (!file) return false; + + file.write(reinterpret_cast(buffer.data()), buffer.size()); + return file.good(); +} + +bool VoxelGrid::load(const std::string& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) return false; + + std::streamsize size = file.tellg(); + if (size <= 0) return false; + + file.seekg(0, std::ios::beg); + + std::vector buffer(static_cast(size)); + if (!file.read(reinterpret_cast(buffer.data()), size)) return false; + + return loadFromBuffer(buffer.data(), buffer.size()); +} + } // namespace mcrf diff --git a/src/3d/VoxelGrid.h b/src/3d/VoxelGrid.h index 3f6447a..740e8c7 100644 --- a/src/3d/VoxelGrid.h +++ b/src/3d/VoxelGrid.h @@ -1,5 +1,5 @@ // VoxelGrid.h - Dense 3D voxel array with material palette -// Part of McRogueFace 3D Extension - Milestones 9-10 +// Part of McRogueFace 3D Extension - Milestones 9-11 #pragma once #include "../Common.h" @@ -8,6 +8,7 @@ #include #include #include +#include namespace mcrf { @@ -28,6 +29,22 @@ struct VoxelMaterial { : name(n), color(c), spriteIndex(sprite), transparent(transp), pathCost(cost) {} }; +// ============================================================================= +// VoxelRegion - Portable voxel data for copy/paste operations (Milestone 11) +// ============================================================================= + +struct VoxelRegion { + int width, height, depth; + std::vector data; + + VoxelRegion() : width(0), height(0), depth(0) {} + VoxelRegion(int w, int h, int d) : width(w), height(h), depth(d), + data(static_cast(w) * h * d, 0) {} + + bool isValid() const { return width > 0 && height > 0 && depth > 0; } + size_t totalVoxels() const { return static_cast(width) * height * depth; } +}; + // ============================================================================= // VoxelGrid - Dense 3D array of material IDs // ============================================================================= @@ -43,9 +60,10 @@ private: vec3 offset_; float rotation_ = 0.0f; // Y-axis only, degrees - // Mesh caching (Milestone 10) + // Mesh caching (Milestones 10, 13) mutable bool meshDirty_ = true; mutable std::vector cachedVertices_; + bool greedyMeshing_ = false; // Use greedy meshing algorithm // Index calculation (row-major: X varies fastest, then Y, then Z) inline size_t index(int x, int y, int z) const { @@ -79,11 +97,39 @@ public: const VoxelMaterial& getMaterial(uint8_t id) const; size_t materialCount() const { return materials_.size(); } - // Bulk operations + // Bulk operations - Basic 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); + // Bulk operations - Milestone 11 + void fillBoxHollow(int x0, int y0, int z0, int x1, int y1, int z1, + uint8_t material, int thickness = 1); + void fillSphere(int cx, int cy, int cz, int radius, uint8_t material); + void fillCylinder(int cx, int cy, int cz, int radius, int height, uint8_t material); + void fillNoise(int x0, int y0, int z0, int x1, int y1, int z1, + uint8_t material, float threshold = 0.5f, + float scale = 0.1f, unsigned int seed = 0); + + // Copy/paste operations - Milestone 11 + VoxelRegion copyRegion(int x0, int y0, int z0, int x1, int y1, int z1) const; + void pasteRegion(const VoxelRegion& region, int x, int y, int z, bool skipAir = true); + + // Navigation projection - Milestone 12 + struct NavInfo { + float height = 0.0f; + bool walkable = false; + bool transparent = true; + float pathCost = 1.0f; + }; + + /// Project a single column to get navigation info + /// @param x X coordinate in voxel grid + /// @param z Z coordinate in voxel grid + /// @param headroom Required air voxels above floor (default 2) + /// @return Navigation info for this column + NavInfo projectColumn(int x, int z, int headroom = 2) const; + // Transform void setOffset(const vec3& offset) { offset_ = offset; } void setOffset(float x, float y, float z) { offset_ = vec3(x, y, z); } @@ -96,7 +142,7 @@ public: size_t countNonAir() const; size_t countMaterial(uint8_t material) const; - // Mesh caching (Milestone 10) + // Mesh caching (Milestones 10, 13) /// Mark mesh as needing rebuild (called automatically by set/fill operations) void markDirty() { meshDirty_ = true; } @@ -112,10 +158,37 @@ public: /// Get vertex count after mesh generation size_t vertexCount() const { return cachedVertices_.size(); } + /// Enable/disable greedy meshing (Milestone 13) + /// Greedy meshing merges coplanar faces to reduce vertex count + void setGreedyMeshing(bool enabled) { greedyMeshing_ = enabled; markDirty(); } + bool isGreedyMeshingEnabled() const { return greedyMeshing_; } + // Memory info (for debugging) size_t memoryUsageBytes() const { return data_.size() + materials_.size() * sizeof(VoxelMaterial); } + + // Serialization (Milestone 14) + /// Save voxel grid to binary file + /// @param path File path to save to + /// @return true on success + bool save(const std::string& path) const; + + /// Load voxel grid from binary file + /// @param path File path to load from + /// @return true on success + bool load(const std::string& path); + + /// Save to memory buffer + /// @param buffer Output buffer (resized as needed) + /// @return true on success + bool saveToBuffer(std::vector& buffer) const; + + /// Load from memory buffer + /// @param data Buffer to load from + /// @param size Buffer size + /// @return true on success + bool loadFromBuffer(const uint8_t* data, size_t size); }; } // namespace mcrf diff --git a/src/3d/VoxelMesher.cpp b/src/3d/VoxelMesher.cpp index ee7df0d..bb85278 100644 --- a/src/3d/VoxelMesher.cpp +++ b/src/3d/VoxelMesher.cpp @@ -70,6 +70,187 @@ bool VoxelMesher::shouldGenerateFace(const VoxelGrid& grid, return grid.getMaterial(neighbor).transparent; } +void VoxelMesher::emitQuad(std::vector& vertices, + const vec3& corner, + const vec3& uAxis, + const vec3& vAxis, + const vec3& normal, + const VoxelMaterial& material) { + // 4 corners of the quad + vec3 corners[4] = { + corner, // 0: origin + corner + uAxis, // 1: +U + corner + uAxis + vAxis, // 2: +U+V + corner + vAxis // 3: +V + }; + + // Calculate UV based on quad size (for potential texture tiling) + float uLen = uAxis.length(); + float vLen = vAxis.length(); + vec2 uvs[4] = { + vec2(0, 0), // 0 + vec2(uLen, 0), // 1 + vec2(uLen, vLen), // 2 + vec2(0, vLen) // 3 + }; + + // Color from material + 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 + // Triangle 1: 0-2-1 + 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 + 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)); +} + +void VoxelMesher::generateGreedyMesh(const VoxelGrid& grid, std::vector& outVertices) { + const float cs = grid.cellSize(); + const int width = grid.width(); + const int height = grid.height(); + const int depth = grid.depth(); + + // Process each face direction + // Axis 0 = X, 1 = Y, 2 = Z + // Direction: +1 = positive, -1 = negative + + for (int axis = 0; axis < 3; axis++) { + for (int dir = -1; dir <= 1; dir += 2) { + // Determine slice dimensions based on axis + int sliceW, sliceH, sliceCount; + if (axis == 0) { // X-axis: slices in YZ plane + sliceW = depth; + sliceH = height; + sliceCount = width; + } else if (axis == 1) { // Y-axis: slices in XZ plane + sliceW = width; + sliceH = depth; + sliceCount = height; + } else { // Z-axis: slices in XY plane + sliceW = width; + sliceH = height; + sliceCount = depth; + } + + // Create mask for this slice + std::vector mask(sliceW * sliceH); + + // Process each slice + for (int sliceIdx = 0; sliceIdx < sliceCount; sliceIdx++) { + // Fill mask with material IDs where faces should be generated + std::fill(mask.begin(), mask.end(), 0); + + for (int v = 0; v < sliceH; v++) { + for (int u = 0; u < sliceW; u++) { + // Map (u, v, sliceIdx) to (x, y, z) based on axis + int x, y, z, nx, ny, nz; + if (axis == 0) { + x = sliceIdx; y = v; z = u; + nx = x + dir; ny = y; nz = z; + } else if (axis == 1) { + x = u; y = sliceIdx; z = v; + nx = x; ny = y + dir; nz = z; + } else { + x = u; y = v; z = sliceIdx; + nx = x; ny = y; nz = z + dir; + } + + uint8_t mat = grid.get(x, y, z); + if (mat == 0) continue; + + // Check if face should be generated + if (shouldGenerateFace(grid, x, y, z, nx, ny, nz)) { + mask[v * sliceW + u] = mat; + } + } + } + + // Greedy rectangle merging + for (int v = 0; v < sliceH; v++) { + for (int u = 0; u < sliceW; ) { + uint8_t mat = mask[v * sliceW + u]; + if (mat == 0) { + u++; + continue; + } + + // Find width of rectangle (extend along U) + int rectW = 1; + while (u + rectW < sliceW && mask[v * sliceW + u + rectW] == mat) { + rectW++; + } + + // Find height of rectangle (extend along V) + int rectH = 1; + bool canExtend = true; + while (canExtend && v + rectH < sliceH) { + // Check if entire row matches + for (int i = 0; i < rectW; i++) { + if (mask[(v + rectH) * sliceW + u + i] != mat) { + canExtend = false; + break; + } + } + if (canExtend) rectH++; + } + + // Clear mask for merged area + for (int dv = 0; dv < rectH; dv++) { + for (int du = 0; du < rectW; du++) { + mask[(v + dv) * sliceW + u + du] = 0; + } + } + + // Emit quad for this merged rectangle + const VoxelMaterial& material = grid.getMaterial(mat); + + // Calculate corner and axes based on face direction + vec3 corner, uAxis, vAxis, normal; + + if (axis == 0) { // X-facing + float faceX = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs; + corner = vec3(faceX, v * cs, u * cs); + uAxis = vec3(0, 0, rectW * cs); + vAxis = vec3(0, rectH * cs, 0); + normal = vec3(static_cast(dir), 0, 0); + // Flip winding for back faces + if (dir < 0) std::swap(uAxis, vAxis); + } else if (axis == 1) { // Y-facing + float faceY = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs; + corner = vec3(u * cs, faceY, v * cs); + uAxis = vec3(rectW * cs, 0, 0); + vAxis = vec3(0, 0, rectH * cs); + normal = vec3(0, static_cast(dir), 0); + if (dir < 0) std::swap(uAxis, vAxis); + } else { // Z-facing + float faceZ = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs; + corner = vec3(u * cs, v * cs, faceZ); + uAxis = vec3(rectW * cs, 0, 0); + vAxis = vec3(0, rectH * cs, 0); + normal = vec3(0, 0, static_cast(dir)); + if (dir < 0) std::swap(uAxis, vAxis); + } + + emitQuad(outVertices, corner, uAxis, vAxis, normal, material); + + u += rectW; + } + } + } + } + } +} + void VoxelMesher::emitFace(std::vector& vertices, const vec3& center, const vec3& normal, diff --git a/src/3d/VoxelMesher.h b/src/3d/VoxelMesher.h index 9dc87c4..a0b3c4d 100644 --- a/src/3d/VoxelMesher.h +++ b/src/3d/VoxelMesher.h @@ -1,5 +1,5 @@ // VoxelMesher.h - Face-culled mesh generation for VoxelGrid -// Part of McRogueFace 3D Extension - Milestone 10 +// Part of McRogueFace 3D Extension - Milestones 10, 13 #pragma once #include "VoxelGrid.h" @@ -14,7 +14,7 @@ namespace mcrf { class VoxelMesher { public: - /// Generate face-culled mesh from voxel data + /// Generate face-culled mesh from voxel data (simple per-voxel faces) /// 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) @@ -23,6 +23,16 @@ public: std::vector& outVertices ); + /// Generate mesh using greedy meshing algorithm (Milestone 13) + /// Merges coplanar faces of the same material into larger rectangles, + /// significantly reducing vertex count for uniform regions. + /// @param grid The VoxelGrid to generate mesh from + /// @param outVertices Output vector of vertices (appended to, not cleared) + static void generateGreedyMesh( + const VoxelGrid& grid, + std::vector& outVertices + ); + private: /// Check if face should be generated (neighbor is air or transparent) /// @param grid The VoxelGrid @@ -48,6 +58,23 @@ private: float size, const VoxelMaterial& material ); + + /// Generate a rectangular face (2 triangles = 6 vertices) + /// Used by greedy meshing to emit merged quads + /// @param vertices Output vector to append vertices to + /// @param corner Base corner of the rectangle + /// @param uAxis Direction and length along U axis + /// @param vAxis Direction and length along V axis + /// @param normal Face normal direction + /// @param material Material for coloring + static void emitQuad( + std::vector& vertices, + const vec3& corner, + const vec3& uAxis, + const vec3& vAxis, + const vec3& normal, + const VoxelMaterial& material + ); }; } // namespace mcrf diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 6a67dec..ec4ffe1 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -444,6 +444,7 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType, &mcrfpydef::PyEntityCollection3DIterType, &mcrfpydef::PyModel3DType, &mcrfpydef::PyBillboardType, &mcrfpydef::PyVoxelGridType, + &mcrfpydef::PyVoxelRegionType, /*grid layers (#147)*/ &PyColorLayerType, &PyTileLayerType, @@ -544,6 +545,9 @@ PyObject* PyInit_mcrfpy() mcrfpydef::PyVoxelGridType.tp_methods = PyVoxelGrid::methods; mcrfpydef::PyVoxelGridType.tp_getset = PyVoxelGrid::getsetters; + // Set up PyVoxelRegionType getsetters (Milestone 11) + mcrfpydef::PyVoxelRegionType.tp_getset = PyVoxelRegion::getsetters; + // Set up PyShaderType methods and getsetters (#106) mcrfpydef::PyShaderType.tp_methods = PyShader::methods; mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters; diff --git a/src/PyColor.cpp b/src/PyColor.cpp index 9e352af..7bc8b10 100644 --- a/src/PyColor.cpp +++ b/src/PyColor.cpp @@ -68,8 +68,68 @@ PyObject* PyColor::pyObject() sf::Color PyColor::fromPy(PyObject* obj) { - PyColorObject* self = (PyColorObject*)obj; - return self->data; + // Handle None or NULL + if (!obj || obj == Py_None) { + return sf::Color::White; + } + + // Check if it's already a Color object + PyTypeObject* color_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color"); + if (color_type) { + bool is_color = PyObject_TypeCheck(obj, color_type); + Py_DECREF(color_type); + if (is_color) { + PyColorObject* self = (PyColorObject*)obj; + return self->data; + } + } + + // Handle tuple or list input + if (PyTuple_Check(obj) || PyList_Check(obj)) { + Py_ssize_t size = PySequence_Size(obj); + if (size < 3 || size > 4) { + PyErr_SetString(PyExc_TypeError, "Color tuple/list must have 3 or 4 elements (r, g, b[, a])"); + return sf::Color::White; + } + + int r = 255, g = 255, b = 255, a = 255; + + PyObject* item0 = PySequence_GetItem(obj, 0); + PyObject* item1 = PySequence_GetItem(obj, 1); + PyObject* item2 = PySequence_GetItem(obj, 2); + + if (PyLong_Check(item0)) r = (int)PyLong_AsLong(item0); + if (PyLong_Check(item1)) g = (int)PyLong_AsLong(item1); + if (PyLong_Check(item2)) b = (int)PyLong_AsLong(item2); + + Py_DECREF(item0); + Py_DECREF(item1); + Py_DECREF(item2); + + if (size == 4) { + PyObject* item3 = PySequence_GetItem(obj, 3); + if (PyLong_Check(item3)) a = (int)PyLong_AsLong(item3); + Py_DECREF(item3); + } + + // Clamp values + r = std::max(0, std::min(255, r)); + g = std::max(0, std::min(255, g)); + b = std::max(0, std::min(255, b)); + a = std::max(0, std::min(255, a)); + + return sf::Color(r, g, b, a); + } + + // Handle integer (grayscale) + if (PyLong_Check(obj)) { + int v = std::max(0, std::min(255, (int)PyLong_AsLong(obj))); + return sf::Color(v, v, v, 255); + } + + // Unknown type - set error and return white + PyErr_SetString(PyExc_TypeError, "Color must be a Color object, tuple, list, or integer"); + return sf::Color::White; } sf::Color PyColor::fromPy(PyColorObject* self) diff --git a/tests/demo/screens/voxel_core_demo.py b/tests/demo/screens/voxel_core_demo.py new file mode 100644 index 0000000..47c2627 --- /dev/null +++ b/tests/demo/screens/voxel_core_demo.py @@ -0,0 +1,263 @@ +"""VoxelGrid Core Demo (Milestone 9) + +Demonstrates the VoxelGrid data structure without rendering. +This is a "console demo" that creates VoxelGrids, defines materials, +places voxel patterns, and displays statistics. + +Note: Visual rendering comes in Milestone 10 (VoxelMeshing). +""" +import mcrfpy +from mcrfpy import Color + +def format_bytes(bytes_val): + """Format bytes as human-readable string""" + if bytes_val < 1024: + return f"{bytes_val} B" + elif bytes_val < 1024 * 1024: + return f"{bytes_val / 1024:.1f} KB" + else: + return f"{bytes_val / (1024 * 1024):.1f} MB" + +def print_header(title): + """Print a formatted header""" + print("\n" + "=" * 60) + print(f" {title}") + print("=" * 60) + +def print_grid_stats(vg, name="VoxelGrid"): + """Print statistics for a VoxelGrid""" + print(f"\n {name}:") + print(f" Dimensions: {vg.width} x {vg.height} x {vg.depth}") + print(f" Total voxels: {vg.width * vg.height * vg.depth:,}") + print(f" Cell size: {vg.cell_size} units") + print(f" Materials: {vg.material_count}") + print(f" Non-air voxels: {vg.count_non_air():,}") + print(f" Memory estimate: {format_bytes(vg.width * vg.height * vg.depth)}") + print(f" Offset: {vg.offset}") + print(f" Rotation: {vg.rotation} deg") + +def demo_basic_creation(): + """Demonstrate basic VoxelGrid creation""" + print_header("1. Basic VoxelGrid Creation") + + # Create various sizes + small = mcrfpy.VoxelGrid(size=(8, 4, 8)) + medium = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=1.0) + large = mcrfpy.VoxelGrid(size=(32, 16, 32), cell_size=0.5) + + print_grid_stats(small, "Small (8x4x8)") + print_grid_stats(medium, "Medium (16x8x16)") + print_grid_stats(large, "Large (32x16x32, 0.5 cell size)") + +def demo_material_palette(): + """Demonstrate material palette system""" + print_header("2. Material Palette System") + + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + + # Define a palette of building materials + materials = {} + materials['stone'] = vg.add_material("stone", color=Color(128, 128, 128)) + materials['brick'] = vg.add_material("brick", color=Color(165, 42, 42)) + materials['wood'] = vg.add_material("wood", color=Color(139, 90, 43)) + materials['glass'] = vg.add_material("glass", + color=Color(200, 220, 255, 128), + transparent=True, + path_cost=1.0) + materials['metal'] = vg.add_material("metal", + color=Color(180, 180, 190), + path_cost=0.8) + materials['grass'] = vg.add_material("grass", color=Color(60, 150, 60)) + + print(f"\n Defined {vg.material_count} materials:") + print(f" ID 0: air (implicit, always transparent)") + + for name, mat_id in materials.items(): + mat = vg.get_material(mat_id) + c = mat['color'] + props = [] + if mat['transparent']: + props.append("transparent") + if mat['path_cost'] != 1.0: + props.append(f"cost={mat['path_cost']}") + props_str = f" ({', '.join(props)})" if props else "" + print(f" ID {mat_id}: {name} RGB({c.r},{c.g},{c.b},{c.a}){props_str}") + + return vg, materials + +def demo_voxel_placement(): + """Demonstrate voxel placement patterns""" + print_header("3. Voxel Placement Patterns") + + vg, materials = demo_material_palette() + stone = materials['stone'] + brick = materials['brick'] + wood = materials['wood'] + + # Pattern 1: Solid cube + print("\n Pattern: Solid 4x4x4 cube at origin") + for z in range(4): + for y in range(4): + for x in range(4): + vg.set(x, y, z, stone) + print(f" Placed {vg.count_material(stone)} stone voxels") + + # Pattern 2: Checkerboard floor + print("\n Pattern: Checkerboard floor at y=0, x=6-14, z=0-8") + for z in range(8): + for x in range(6, 14): + mat = stone if (x + z) % 2 == 0 else brick + vg.set(x, 0, z, mat) + print(f" Stone: {vg.count_material(stone)}, Brick: {vg.count_material(brick)}") + + # Pattern 3: Hollow cube (walls only) + print("\n Pattern: Hollow cube frame 4x4x4 at x=10, z=10") + for x in range(4): + for y in range(4): + for z in range(4): + # Only place on edges + on_edge_x = (x == 0 or x == 3) + on_edge_y = (y == 0 or y == 3) + on_edge_z = (z == 0 or z == 3) + if sum([on_edge_x, on_edge_y, on_edge_z]) >= 2: + vg.set(10 + x, y, 10 + z, wood) + print(f" Wood voxels: {vg.count_material(wood)}") + + print_grid_stats(vg, "After patterns") + + # Material breakdown + print("\n Material breakdown:") + print(f" Air: {vg.count_material(0):,} ({100 * vg.count_material(0) / (16*8*16):.1f}%)") + print(f" Stone: {vg.count_material(stone):,}") + print(f" Brick: {vg.count_material(brick):,}") + print(f" Wood: {vg.count_material(wood):,}") + +def demo_bulk_operations(): + """Demonstrate bulk fill and clear operations""" + print_header("4. Bulk Operations") + + vg = mcrfpy.VoxelGrid(size=(32, 8, 32)) + total = 32 * 8 * 32 + + stone = vg.add_material("stone", color=Color(128, 128, 128)) + + print(f"\n Grid: 32x8x32 = {total:,} voxels") + + # Fill + vg.fill(stone) + print(f" After fill(stone): {vg.count_non_air():,} non-air") + + # Clear + vg.clear() + print(f" After clear(): {vg.count_non_air():,} non-air") + +def demo_transforms(): + """Demonstrate transform properties""" + print_header("5. Transform Properties") + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + + print(f"\n Default state:") + print(f" Offset: {vg.offset}") + print(f" Rotation: {vg.rotation} deg") + + # Position for a building + vg.offset = (100.0, 0.0, 50.0) + vg.rotation = 45.0 + + print(f"\n After positioning:") + print(f" Offset: {vg.offset}") + print(f" Rotation: {vg.rotation} deg") + + # Multiple buildings with different transforms + print("\n Example: Village layout with 3 buildings") + buildings = [] + positions = [(0, 0, 0), (20, 0, 0), (10, 0, 15)] + rotations = [0, 90, 45] + + for i, (pos, rot) in enumerate(zip(positions, rotations)): + b = mcrfpy.VoxelGrid(size=(8, 6, 8)) + b.offset = pos + b.rotation = rot + buildings.append(b) + print(f" Building {i+1}: offset={pos}, rotation={rot} deg") + +def demo_edge_cases(): + """Test edge cases and limits""" + print_header("6. Edge Cases and Limits") + + # Maximum practical size + print("\n Testing large grid (64x64x64)...") + large = mcrfpy.VoxelGrid(size=(64, 64, 64)) + mat = large.add_material("test", color=Color(128, 128, 128)) + large.fill(mat) + print(f" Created and filled: {large.count_non_air():,} voxels") + large.clear() + print(f" Cleared: {large.count_non_air()} voxels") + + # Bounds checking + print("\n Bounds checking (should not crash):") + small = mcrfpy.VoxelGrid(size=(4, 4, 4)) + test_mat = small.add_material("test", color=Color(255, 0, 0)) + small.set(-1, 0, 0, test_mat) + small.set(100, 0, 0, test_mat) + print(f" Out-of-bounds get(-1,0,0): {small.get(-1, 0, 0)} (expected 0)") + print(f" Out-of-bounds get(100,0,0): {small.get(100, 0, 0)} (expected 0)") + + # Material palette capacity + print("\n Material palette capacity test:") + full_vg = mcrfpy.VoxelGrid(size=(4, 4, 4)) + for i in range(255): + full_vg.add_material(f"mat_{i}", color=Color(i, i, i)) + print(f" Added 255 materials: count = {full_vg.material_count}") + + try: + full_vg.add_material("overflow", color=Color(255, 255, 255)) + print(" ERROR: Should have raised exception!") + except RuntimeError as e: + print(f" 256th material correctly rejected: {e}") + +def demo_memory_usage(): + """Show memory usage for various grid sizes""" + print_header("7. Memory Usage Estimates") + + sizes = [ + (8, 8, 8), + (16, 8, 16), + (32, 16, 32), + (64, 32, 64), + (80, 16, 45), # Example dungeon size + ] + + print("\n Size Voxels Memory") + print(" " + "-" * 40) + + for w, h, d in sizes: + voxels = w * h * d + memory = voxels # 1 byte per voxel + print(f" {w:3}x{h:3}x{d:3} {voxels:>10,} {format_bytes(memory):>10}") + +def main(): + """Run all demos""" + print("\n" + "=" * 60) + print(" VOXELGRID CORE DEMO (Milestone 9)") + print(" Dense 3D Voxel Array with Material Palette") + print("=" * 60) + + demo_basic_creation() + demo_material_palette() + demo_voxel_placement() + demo_bulk_operations() + demo_transforms() + demo_edge_cases() + demo_memory_usage() + + print_header("Demo Complete!") + print("\n Next milestone (10): Voxel Mesh Generation") + print(" The VoxelGrid data will be converted to renderable 3D meshes.") + print() + +if __name__ == "__main__": + import sys + main() + sys.exit(0) diff --git a/tests/demo/screens/voxel_dungeon_demo.py b/tests/demo/screens/voxel_dungeon_demo.py new file mode 100644 index 0000000..33a5799 --- /dev/null +++ b/tests/demo/screens/voxel_dungeon_demo.py @@ -0,0 +1,273 @@ +# voxel_dungeon_demo.py - Procedural dungeon demonstrating bulk voxel operations +# Milestone 11: Bulk Operations and Building Primitives + +import mcrfpy +import sys +import math +import random + +# Create demo scene +scene = mcrfpy.Scene("voxel_dungeon_demo") + +# Dark background +bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(20, 20, 30)) +scene.children.append(bg) + +# Title +title = mcrfpy.Caption(text="Voxel Dungeon Demo - Bulk Operations (Milestone 11)", 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=(620, 520), + render_resolution=(400, 320), + fov=60.0, + camera_pos=(40.0, 30.0, 40.0), + camera_target=(16.0, 4.0, 16.0), + bg_color=mcrfpy.Color(30, 30, 40) # Dark atmosphere +) +scene.children.append(viewport) + +# Global voxel grid reference +voxels = None +seed = 42 + +def generate_dungeon(dungeon_seed=42): + """Generate a procedural dungeon showcasing all bulk operations""" + global voxels, seed + seed = dungeon_seed + random.seed(seed) + + # Create voxel grid for dungeon + print(f"Generating dungeon (seed={seed})...") + voxels = mcrfpy.VoxelGrid(size=(32, 12, 32), cell_size=1.0) + + # Define materials + STONE_WALL = voxels.add_material("stone_wall", color=mcrfpy.Color(80, 80, 90)) + STONE_FLOOR = voxels.add_material("stone_floor", color=mcrfpy.Color(100, 95, 90)) + MOSS = voxels.add_material("moss", color=mcrfpy.Color(40, 80, 40)) + WATER = voxels.add_material("water", color=mcrfpy.Color(40, 80, 160, 180), transparent=True) + PILLAR = voxels.add_material("pillar", color=mcrfpy.Color(120, 110, 100)) + GOLD = voxels.add_material("gold", color=mcrfpy.Color(255, 215, 0)) + + print(f"Defined {voxels.material_count} materials") + + # 1. Main room using fill_box_hollow + print("Building main room with fill_box_hollow...") + voxels.fill_box_hollow((2, 0, 2), (29, 10, 29), STONE_WALL, thickness=1) + + # 2. Floor with slight variation using fill_box + voxels.fill_box((3, 0, 3), (28, 0, 28), STONE_FLOOR) + + # 3. Spherical alcoves carved into walls using fill_sphere + print("Carving alcoves with fill_sphere...") + alcove_positions = [ + (2, 5, 16), # West wall + (29, 5, 16), # East wall + (16, 5, 2), # North wall + (16, 5, 29), # South wall + ] + for pos in alcove_positions: + voxels.fill_sphere(pos, 3, 0) # Carve out (air) + + # 4. Small decorative spheres (gold orbs in alcoves) + print("Adding gold orbs in alcoves...") + for i, pos in enumerate(alcove_positions): + # Offset inward so orb is visible + ox, oy, oz = pos + if ox < 10: + ox += 2 + elif ox > 20: + ox -= 2 + if oz < 10: + oz += 2 + elif oz > 20: + oz -= 2 + voxels.fill_sphere((ox, oy - 1, oz), 1, GOLD) + + # 5. Support pillars using fill_cylinder + print("Building pillars with fill_cylinder...") + pillar_positions = [ + (8, 1, 8), (8, 1, 24), + (24, 1, 8), (24, 1, 24), + (16, 1, 8), (16, 1, 24), + (8, 1, 16), (24, 1, 16), + ] + for px, py, pz in pillar_positions: + voxels.fill_cylinder((px, py, pz), 1, 9, PILLAR) + + # 6. Moss patches using fill_noise + print("Adding moss patches with fill_noise...") + voxels.fill_noise((3, 1, 3), (28, 1, 28), MOSS, threshold=0.65, scale=0.15, seed=seed) + + # 7. Central water pool + print("Creating water pool...") + voxels.fill_box((12, 0, 12), (20, 0, 20), 0) # Carve depression + voxels.fill_box((12, 0, 12), (20, 0, 20), WATER) + + # 8. Copy a pillar as prefab and paste variations + print("Creating prefab from pillar and pasting copies...") + pillar_prefab = voxels.copy_region((8, 1, 8), (9, 9, 9)) + print(f" Pillar prefab: {pillar_prefab.size}") + + # Paste smaller pillars at corners (offset from main room) + corner_positions = [(4, 1, 4), (4, 1, 27), (27, 1, 4), (27, 1, 27)] + for cx, cy, cz in corner_positions: + voxels.paste_region(pillar_prefab, (cx, cy, cz), skip_air=True) + + # Build mesh + voxels.rebuild_mesh() + + print(f"\nDungeon generated:") + print(f" Non-air voxels: {voxels.count_non_air()}") + print(f" Vertices: {voxels.vertex_count}") + print(f" Faces: {voxels.vertex_count // 6}") + + # Add to viewport + # First remove old layer if exists + if viewport.voxel_layer_count() > 0: + pass # Can't easily remove, so we regenerate the whole viewport + viewport.add_voxel_layer(voxels, z_index=0) + + return voxels + +# Generate initial dungeon +generate_dungeon(42) + +# Create info panel +info_frame = mcrfpy.Frame(pos=(690, 60), size=(300, 280), fill_color=mcrfpy.Color(40, 40, 60, 220)) +scene.children.append(info_frame) + +info_title = mcrfpy.Caption(text="Dungeon Stats", pos=(700, 70)) +info_title.fill_color = mcrfpy.Color(255, 255, 100) +scene.children.append(info_title) + +def update_stats(): + global stats_caption + stats_text = f"""Grid: {voxels.width}x{voxels.height}x{voxels.depth} +Total cells: {voxels.width * voxels.height * voxels.depth} +Non-air: {voxels.count_non_air()} +Materials: {voxels.material_count} + +Mesh Stats: + Vertices: {voxels.vertex_count} + Faces: {voxels.vertex_count // 6} + +Seed: {seed} + +Operations Used: + - fill_box_hollow (walls) + - fill_sphere (alcoves) + - fill_cylinder (pillars) + - fill_noise (moss) + - copy/paste (prefabs)""" + stats_caption.text = stats_text + +stats_caption = mcrfpy.Caption(text="", pos=(700, 100)) +stats_caption.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(stats_caption) +update_stats() + +# Controls panel +controls_frame = mcrfpy.Frame(pos=(690, 360), size=(300, 180), fill_color=mcrfpy.Color(40, 40, 60, 220)) +scene.children.append(controls_frame) + +controls_title = mcrfpy.Caption(text="Controls", pos=(700, 370)) +controls_title.fill_color = mcrfpy.Color(255, 255, 100) +scene.children.append(controls_title) + +controls_text = """R - Regenerate dungeon (new seed) +1-4 - Camera presets ++/- - Zoom in/out +SPACE - Reset camera +ESC - Exit demo""" + +controls = mcrfpy.Caption(text=controls_text, pos=(700, 400)) +controls.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(controls) + +# Camera animation state +rotation_enabled = False +camera_distance = 50.0 +camera_angle = 45.0 # degrees +camera_height = 30.0 + +camera_presets = [ + (40.0, 30.0, 40.0, 16.0, 4.0, 16.0), # Default diagonal + (16.0, 30.0, 50.0, 16.0, 4.0, 16.0), # Front view + (50.0, 30.0, 16.0, 16.0, 4.0, 16.0), # Side view + (16.0, 50.0, 16.0, 16.0, 4.0, 16.0), # Top-down +] + +def rotate_camera(timer_name, runtime): + """Timer callback for camera rotation""" + global camera_angle, rotation_enabled + if rotation_enabled: + camera_angle += 0.5 + if camera_angle >= 360.0: + camera_angle = 0.0 + rad = camera_angle * math.pi / 180.0 + x = 16.0 + camera_distance * math.cos(rad) + z = 16.0 + camera_distance * math.sin(rad) + viewport.camera_pos = (x, camera_height, z) + +# Set up rotation timer +timer = mcrfpy.Timer("rotate_cam", rotate_camera, 33) + +def handle_key(key, action): + """Keyboard handler""" + global rotation_enabled, seed, camera_distance, camera_height + if action != mcrfpy.InputState.PRESSED: + return + + if key == mcrfpy.Key.R: + seed = random.randint(1, 99999) + generate_dungeon(seed) + update_stats() + print(f"Regenerated dungeon with seed {seed}") + elif key == mcrfpy.Key.NUM_1: + viewport.camera_pos = camera_presets[0][:3] + viewport.camera_target = camera_presets[0][3:] + rotation_enabled = False + elif key == mcrfpy.Key.NUM_2: + viewport.camera_pos = camera_presets[1][:3] + viewport.camera_target = camera_presets[1][3:] + rotation_enabled = False + elif key == mcrfpy.Key.NUM_3: + viewport.camera_pos = camera_presets[2][:3] + viewport.camera_target = camera_presets[2][3:] + rotation_enabled = False + elif key == mcrfpy.Key.NUM_4: + viewport.camera_pos = camera_presets[3][:3] + viewport.camera_target = camera_presets[3][3:] + rotation_enabled = False + elif key == mcrfpy.Key.SPACE: + rotation_enabled = not rotation_enabled + print(f"Camera rotation: {'ON' if rotation_enabled else 'OFF'}") + elif key == mcrfpy.Key.EQUALS or key == mcrfpy.Key.ADD: + camera_distance = max(20.0, camera_distance - 5.0) + camera_height = max(15.0, camera_height - 2.0) + elif key == mcrfpy.Key.DASH or key == mcrfpy.Key.SUBTRACT: + camera_distance = min(80.0, camera_distance + 5.0) + camera_height = min(50.0, camera_height + 2.0) + elif key == mcrfpy.Key.ESCAPE: + print("Exiting demo...") + sys.exit(0) + +scene.on_key = handle_key + +# Activate the scene +mcrfpy.current_scene = scene +print("\nVoxel Dungeon Demo ready!") +print("Press SPACE to toggle camera rotation, R to regenerate") + +# Main entry point for --exec mode +if __name__ == "__main__": + print("\n=== Voxel Dungeon 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") diff --git a/tests/demo/screens/voxel_navigation_demo.py b/tests/demo/screens/voxel_navigation_demo.py new file mode 100644 index 0000000..7cb36e9 --- /dev/null +++ b/tests/demo/screens/voxel_navigation_demo.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Visual Demo: Milestone 12 - VoxelGrid Navigation Projection + +Demonstrates projection of 3D voxel terrain to 2D navigation grid for pathfinding. +Shows: +1. Voxel dungeon with multiple levels +2. Navigation grid projection (walkable/unwalkable areas) +3. A* pathfinding through the projected terrain +4. FOV computation from voxel transparency +""" + +import mcrfpy +import sys +import math + +def create_demo_scene(): + """Create the navigation projection demo scene""" + + scene = mcrfpy.Scene("voxel_nav_demo") + + # ========================================================================= + # Create a small dungeon-style voxel grid + # ========================================================================= + + vg = mcrfpy.VoxelGrid((16, 8, 16), cell_size=1.0) + + # Add materials + floor_mat = vg.add_material("floor", (100, 80, 60)) # Brown floor + wall_mat = vg.add_material("wall", (80, 80, 90), transparent=False) # Gray walls + pillar_mat = vg.add_material("pillar", (60, 60, 70), transparent=False) # Dark pillars + glass_mat = vg.add_material("glass", (150, 200, 255), transparent=True) # Transparent glass + water_mat = vg.add_material("water", (50, 100, 200), transparent=True, path_cost=3.0) # Slow water + + # Create floor + vg.fill_box((0, 0, 0), (15, 0, 15), floor_mat) + + # Create outer walls + vg.fill_box((0, 1, 0), (15, 4, 0), wall_mat) # North wall + vg.fill_box((0, 1, 15), (15, 4, 15), wall_mat) # South wall + vg.fill_box((0, 1, 0), (0, 4, 15), wall_mat) # West wall + vg.fill_box((15, 1, 0), (15, 4, 15), wall_mat) # East wall + + # Interior walls creating rooms + vg.fill_box((5, 1, 0), (5, 4, 10), wall_mat) # Vertical wall + vg.fill_box((10, 1, 5), (15, 4, 5), wall_mat) # Horizontal wall + + # Doorways (carve holes) + vg.fill_box((5, 1, 3), (5, 2, 4), 0) # Door in vertical wall + vg.fill_box((12, 1, 5), (13, 2, 5), 0) # Door in horizontal wall + + # Central pillars + vg.fill_box((8, 1, 8), (8, 4, 8), pillar_mat) + vg.fill_box((8, 1, 12), (8, 4, 12), pillar_mat) + + # Water pool in one corner (slow movement) + vg.fill_box((1, 0, 11), (3, 0, 14), water_mat) + + # Glass window + vg.fill_box((10, 2, 5), (11, 3, 5), glass_mat) + + # Raised platform in one area (height variation) + vg.fill_box((12, 1, 8), (14, 1, 13), floor_mat) # Platform at y=1 + + # ========================================================================= + # Create Viewport3D with navigation grid + # ========================================================================= + + viewport = mcrfpy.Viewport3D(pos=(10, 10), size=(600, 400)) + viewport.set_grid_size(16, 16) + viewport.cell_size = 1.0 + + # Configure camera for top-down view + viewport.camera_pos = (8, 15, 20) + viewport.camera_target = (8, 0, 8) + + # Add voxel layer + viewport.add_voxel_layer(vg, z_index=0) + + # Project voxels to navigation grid with headroom=2 (entity needs 2 voxels height) + viewport.project_voxel_to_nav(vg, headroom=2) + + # ========================================================================= + # Info panel + # ========================================================================= + + info_frame = mcrfpy.Frame(pos=(620, 10), size=(250, 400)) + info_frame.fill_color = mcrfpy.Color(30, 30, 40, 220) + info_frame.outline_color = mcrfpy.Color(100, 100, 120) + info_frame.outline = 2.0 + + title = mcrfpy.Caption(text="Nav Projection Demo", pos=(10, 10)) + title.fill_color = mcrfpy.Color(255, 255, 100) + + desc = mcrfpy.Caption(text="Voxels projected to\n2D nav grid", pos=(10, 35)) + desc.fill_color = mcrfpy.Color(200, 200, 200) + + info1 = mcrfpy.Caption(text="Grid: 16x16 cells", pos=(10, 75)) + info1.fill_color = mcrfpy.Color(150, 200, 255) + + info2 = mcrfpy.Caption(text="Headroom: 2 voxels", pos=(10, 95)) + info2.fill_color = mcrfpy.Color(150, 200, 255) + + # Count walkable cells + walkable_count = 0 + for x in range(16): + for z in range(16): + cell = viewport.at(x, z) + if cell.walkable: + walkable_count += 1 + + info3 = mcrfpy.Caption(text=f"Walkable: {walkable_count}/256", pos=(10, 115)) + info3.fill_color = mcrfpy.Color(100, 255, 100) + + # Find path example + path = viewport.find_path((1, 1), (13, 13)) + info4 = mcrfpy.Caption(text=f"Path length: {len(path)}", pos=(10, 135)) + info4.fill_color = mcrfpy.Color(255, 200, 100) + + # FOV example + fov = viewport.compute_fov((8, 8), 10) + info5 = mcrfpy.Caption(text=f"FOV cells: {len(fov)}", pos=(10, 155)) + info5.fill_color = mcrfpy.Color(200, 150, 255) + + # Legend + legend_title = mcrfpy.Caption(text="Materials:", pos=(10, 185)) + legend_title.fill_color = mcrfpy.Color(255, 255, 255) + + leg1 = mcrfpy.Caption(text=" Floor (walkable)", pos=(10, 205)) + leg1.fill_color = mcrfpy.Color(100, 80, 60) + + leg2 = mcrfpy.Caption(text=" Wall (blocking)", pos=(10, 225)) + leg2.fill_color = mcrfpy.Color(80, 80, 90) + + leg3 = mcrfpy.Caption(text=" Water (slow)", pos=(10, 245)) + leg3.fill_color = mcrfpy.Color(50, 100, 200) + + leg4 = mcrfpy.Caption(text=" Glass (see-through)", pos=(10, 265)) + leg4.fill_color = mcrfpy.Color(150, 200, 255) + + controls = mcrfpy.Caption(text="[Space] Recompute FOV\n[P] Show path\n[Q] Quit", pos=(10, 300)) + controls.fill_color = mcrfpy.Color(150, 150, 150) + + info_frame.children.extend([ + title, desc, info1, info2, info3, info4, info5, + legend_title, leg1, leg2, leg3, leg4, controls + ]) + + # ========================================================================= + # Status bar + # ========================================================================= + + status_frame = mcrfpy.Frame(pos=(10, 420), size=(860, 50)) + status_frame.fill_color = mcrfpy.Color(20, 20, 30, 220) + status_frame.outline_color = mcrfpy.Color(80, 80, 100) + status_frame.outline = 1.0 + + status_text = mcrfpy.Caption( + text="Milestone 12: VoxelGrid Navigation Projection - Project 3D voxels to 2D pathfinding grid", + pos=(10, 15) + ) + status_text.fill_color = mcrfpy.Color(180, 180, 200) + status_frame.children.append(status_text) + + # ========================================================================= + # Add elements to scene + # ========================================================================= + + scene.children.extend([viewport, info_frame, status_frame]) + + # Store references for interaction (using module-level globals) + global demo_viewport, demo_voxelgrid, demo_path, demo_fov_origin + demo_viewport = viewport + demo_voxelgrid = vg + demo_path = path + demo_fov_origin = (8, 8) + + # ========================================================================= + # Keyboard handler + # ========================================================================= + + def on_key(key, state): + global demo_fov_origin + if state != mcrfpy.InputState.PRESSED: + return + + if key == mcrfpy.Key.Q or key == mcrfpy.Key.ESCAPE: + # Exit + sys.exit(0) + elif key == mcrfpy.Key.SPACE: + # Recompute FOV from different origin + ox, oz = demo_fov_origin + ox = (ox + 3) % 14 + 1 + oz = (oz + 5) % 14 + 1 + demo_fov_origin = (ox, oz) + fov = demo_viewport.compute_fov((ox, oz), 8) + info5.text = f"FOV from ({ox},{oz}): {len(fov)}" + elif key == mcrfpy.Key.P: + # Show path info + print(f"Path from (1,1) to (13,13): {len(demo_path)} steps") + for i, (px, pz) in enumerate(demo_path[:10]): + cell = demo_viewport.at(px, pz) + print(f" Step {i}: ({px},{pz}) h={cell.height:.1f} cost={cell.cost:.1f}") + if len(demo_path) > 10: + print(f" ... and {len(demo_path) - 10} more steps") + + scene.on_key = on_key + + return scene + +def main(): + """Main entry point""" + print("=== Milestone 12: VoxelGrid Navigation Projection Demo ===") + print() + print("This demo shows how 3D voxel terrain is projected to a 2D") + print("navigation grid for pathfinding and FOV calculations.") + print() + print("The projection scans each column from top to bottom, finding") + print("the topmost walkable floor with adequate headroom.") + print() + + scene = create_demo_scene() + mcrfpy.current_scene = scene + + # Print nav grid summary + grid_w, grid_d = demo_viewport.grid_size + print("Navigation grid summary:") + print(f" Grid size: {grid_w}x{grid_d}") + + # Count by walkability and transparency + walkable = 0 + blocking = 0 + transparent = 0 + for x in range(grid_w): + for z in range(grid_d): + cell = demo_viewport.at(x, z) + if cell.walkable: + walkable += 1 + else: + blocking += 1 + if cell.transparent: + transparent += 1 + + print(f" Walkable cells: {walkable}") + print(f" Blocking cells: {blocking}") + print(f" Transparent cells: {transparent}") + print() + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/demo/screens/voxel_serialization_demo.py b/tests/demo/screens/voxel_serialization_demo.py new file mode 100644 index 0000000..9d11b91 --- /dev/null +++ b/tests/demo/screens/voxel_serialization_demo.py @@ -0,0 +1,314 @@ +"""Voxel Serialization Demo - Milestone 14 + +Demonstrates save/load functionality for VoxelGrid, including: +- Saving to file with .mcvg format +- Loading from file +- Serialization to bytes (for network/custom storage) +- RLE compression effectiveness +""" + +import mcrfpy +import os +import tempfile + +def create_demo_scene(): + """Create a scene demonstrating voxel serialization.""" + scene = mcrfpy.Scene("voxel_serialization_demo") + ui = scene.children + + # Dark background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(20, 20, 30)) + ui.append(bg) + + # Title + title = mcrfpy.Caption(text="Milestone 14: VoxelGrid Serialization", + pos=(30, 20)) + title.font_size = 28 + title.fill_color = (255, 220, 100) + ui.append(title) + + # Create demo VoxelGrid with interesting structure + grid = mcrfpy.VoxelGrid((16, 16, 16), cell_size=1.0) + + # Add materials + stone = grid.add_material("stone", (100, 100, 110)) + wood = grid.add_material("wood", (139, 90, 43)) + glass = grid.add_material("glass", (180, 200, 220, 100), transparent=True) + gold = grid.add_material("gold", (255, 215, 0)) + + # Build a small structure + grid.fill_box((0, 0, 0), (15, 0, 15), stone) # Floor + grid.fill_box((0, 1, 0), (0, 4, 15), stone) # Wall 1 + grid.fill_box((15, 1, 0), (15, 4, 15), stone) # Wall 2 + grid.fill_box((0, 1, 0), (15, 4, 0), stone) # Wall 3 + grid.fill_box((0, 1, 15), (15, 4, 15), stone) # Wall 4 + + # Windows (clear some wall, add glass) + grid.fill_box((6, 2, 0), (10, 3, 0), 0) # Clear for window + grid.fill_box((6, 2, 0), (10, 3, 0), glass) # Add glass + + # Pillars + grid.fill_box((4, 1, 4), (4, 3, 4), wood) + grid.fill_box((12, 1, 4), (12, 3, 4), wood) + grid.fill_box((4, 1, 12), (4, 3, 12), wood) + grid.fill_box((12, 1, 12), (12, 3, 12), wood) + + # Gold decorations + grid.set(8, 1, 8, gold) + grid.set(7, 1, 8, gold) + grid.set(9, 1, 8, gold) + grid.set(8, 1, 7, gold) + grid.set(8, 1, 9, gold) + + # Get original stats + original_voxels = grid.count_non_air() + original_materials = grid.material_count + + # === Test save/load to file === + with tempfile.NamedTemporaryFile(suffix='.mcvg', delete=False) as f: + temp_path = f.name + + save_success = grid.save(temp_path) + file_size = os.path.getsize(temp_path) if save_success else 0 + + # Load into new grid + loaded_grid = mcrfpy.VoxelGrid((1, 1, 1)) + load_success = loaded_grid.load(temp_path) + os.unlink(temp_path) # Clean up + + loaded_voxels = loaded_grid.count_non_air() if load_success else 0 + loaded_materials = loaded_grid.material_count if load_success else 0 + + # === Test to_bytes/from_bytes === + data_bytes = grid.to_bytes() + bytes_size = len(data_bytes) + + bytes_grid = mcrfpy.VoxelGrid((1, 1, 1)) + bytes_success = bytes_grid.from_bytes(data_bytes) + bytes_voxels = bytes_grid.count_non_air() if bytes_success else 0 + + # === Calculate compression === + raw_size = 16 * 16 * 16 # Uncompressed voxel data + compression_ratio = raw_size / bytes_size if bytes_size > 0 else 0 + + # Display information + y_pos = 80 + + # Original Grid Info + info1 = mcrfpy.Caption(text="Original VoxelGrid:", + pos=(30, y_pos)) + info1.font_size = 20 + info1.fill_color = (100, 200, 255) + ui.append(info1) + y_pos += 30 + + for line in [ + f" Dimensions: 16x16x16 = 4096 voxels", + f" Non-air voxels: {original_voxels}", + f" Materials defined: {original_materials}", + f" Structure: Walled room with pillars, windows, gold decor" + ]: + cap = mcrfpy.Caption(text=line, pos=(30, y_pos)) + cap.font_size = 16 + cap.fill_color = (200, 200, 210) + ui.append(cap) + y_pos += 22 + + y_pos += 20 + + # File Save/Load Results + info2 = mcrfpy.Caption(text="File Serialization (.mcvg):", + pos=(30, y_pos)) + info2.font_size = 20 + info2.fill_color = (100, 255, 150) + ui.append(info2) + y_pos += 30 + + save_status = "SUCCESS" if save_success else "FAILED" + load_status = "SUCCESS" if load_success else "FAILED" + match_status = "MATCH" if loaded_voxels == original_voxels else "MISMATCH" + + for line in [ + f" Save to file: {save_status}", + f" File size: {file_size} bytes", + f" Load from file: {load_status}", + f" Loaded voxels: {loaded_voxels} ({match_status})", + f" Loaded materials: {loaded_materials}" + ]: + color = (150, 255, 150) if "SUCCESS" in line or "MATCH" in line else (200, 200, 210) + if "FAILED" in line or "MISMATCH" in line: + color = (255, 100, 100) + cap = mcrfpy.Caption(text=line, pos=(30, y_pos)) + cap.font_size = 16 + cap.fill_color = color + ui.append(cap) + y_pos += 22 + + y_pos += 20 + + # Bytes Serialization Results + info3 = mcrfpy.Caption(text="Memory Serialization (to_bytes/from_bytes):", + pos=(30, y_pos)) + info3.font_size = 20 + info3.fill_color = (255, 200, 100) + ui.append(info3) + y_pos += 30 + + bytes_status = "SUCCESS" if bytes_success else "FAILED" + bytes_match = "MATCH" if bytes_voxels == original_voxels else "MISMATCH" + + for line in [ + f" Serialized size: {bytes_size} bytes", + f" Raw voxel data: {raw_size} bytes", + f" Compression ratio: {compression_ratio:.1f}x", + f" from_bytes(): {bytes_status}", + f" Restored voxels: {bytes_voxels} ({bytes_match})" + ]: + color = (200, 200, 210) + if "SUCCESS" in line or "MATCH" in line: + color = (150, 255, 150) + cap = mcrfpy.Caption(text=line, pos=(30, y_pos)) + cap.font_size = 16 + cap.fill_color = color + ui.append(cap) + y_pos += 22 + + y_pos += 20 + + # RLE Compression Demo + info4 = mcrfpy.Caption(text="RLE Compression Effectiveness:", + pos=(30, y_pos)) + info4.font_size = 20 + info4.fill_color = (200, 150, 255) + ui.append(info4) + y_pos += 30 + + # Create uniform grid for compression test + uniform_grid = mcrfpy.VoxelGrid((32, 32, 32)) + uniform_mat = uniform_grid.add_material("solid", (128, 128, 128)) + uniform_grid.fill(uniform_mat) + uniform_bytes = uniform_grid.to_bytes() + uniform_raw = 32 * 32 * 32 + uniform_ratio = uniform_raw / len(uniform_bytes) + + for line in [ + f" Uniform 32x32x32 filled grid:", + f" Raw: {uniform_raw} bytes", + f" Compressed: {len(uniform_bytes)} bytes", + f" Compression: {uniform_ratio:.0f}x", + f" ", + f" RLE excels at runs of identical values." + ]: + cap = mcrfpy.Caption(text=line, pos=(30, y_pos)) + cap.font_size = 16 + cap.fill_color = (200, 180, 220) + ui.append(cap) + y_pos += 22 + + y_pos += 30 + + # File Format Info + info5 = mcrfpy.Caption(text="File Format (.mcvg):", + pos=(30, y_pos)) + info5.font_size = 20 + info5.fill_color = (255, 150, 200) + ui.append(info5) + y_pos += 30 + + for line in [ + " Header: Magic 'MCVG' + version + dimensions + cell_size", + " Materials: name, color (RGBA), sprite_index, transparent, path_cost", + " Voxel data: RLE-encoded material IDs", + " ", + " Note: Transform (offset, rotation) is runtime state, not serialized" + ]: + cap = mcrfpy.Caption(text=line, pos=(30, y_pos)) + cap.font_size = 14 + cap.fill_color = (200, 180, 200) + ui.append(cap) + y_pos += 20 + + # API Reference on right side + y_ref = 80 + x_ref = 550 + + api_title = mcrfpy.Caption(text="Python API:", pos=(x_ref, y_ref)) + api_title.font_size = 20 + api_title.fill_color = (150, 200, 255) + ui.append(api_title) + y_ref += 35 + + for line in [ + "# Save to file", + "success = grid.save('world.mcvg')", + "", + "# Load from file", + "grid = VoxelGrid((1,1,1))", + "success = grid.load('world.mcvg')", + "", + "# Save to bytes", + "data = grid.to_bytes()", + "", + "# Load from bytes", + "success = grid.from_bytes(data)", + "", + "# Network example:", + "# send_to_server(grid.to_bytes())", + "# data = recv_from_server()", + "# grid.from_bytes(data)" + ]: + cap = mcrfpy.Caption(text=line, pos=(x_ref, y_ref)) + cap.font_size = 14 + if line.startswith("#"): + cap.fill_color = (100, 150, 100) + elif "=" in line or "(" in line: + cap.fill_color = (255, 220, 150) + else: + cap.fill_color = (180, 180, 180) + ui.append(cap) + y_ref += 18 + + return scene + + +# Run demonstration +if __name__ == "__main__": + import sys + # Create and activate the scene + scene = create_demo_scene() + mcrfpy.current_scene = scene + + # When run directly, print summary and exit for headless testing + print("\n=== Voxel Serialization Demo (Milestone 14) ===\n") + + # Run a quick verification + grid = mcrfpy.VoxelGrid((8, 8, 8)) + mat = grid.add_material("test", (100, 100, 100)) + grid.fill_box((0, 0, 0), (7, 0, 7), mat) + + print(f"Created 8x8x8 grid with {grid.count_non_air()} non-air voxels") + + # Test to_bytes + data = grid.to_bytes() + print(f"Serialized to {len(data)} bytes") + + # Test from_bytes + grid2 = mcrfpy.VoxelGrid((1, 1, 1)) + success = grid2.from_bytes(data) + print(f"from_bytes(): {'SUCCESS' if success else 'FAILED'}") + print(f"Restored size: {grid2.size}") + print(f"Restored voxels: {grid2.count_non_air()}") + + # Compression test + big_grid = mcrfpy.VoxelGrid((32, 32, 32)) + big_mat = big_grid.add_material("solid", (128, 128, 128)) + big_grid.fill(big_mat) + big_data = big_grid.to_bytes() + raw_size = 32 * 32 * 32 + print(f"\nCompression test (32x32x32 uniform):") + print(f" Raw: {raw_size} bytes") + print(f" Compressed: {len(big_data)} bytes") + print(f" Ratio: {raw_size / len(big_data):.0f}x") + + print("\n=== Demo complete ===") + sys.exit(0)