diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp index e263d14..ba2c1cf 100644 --- a/src/PyHeightMap.cpp +++ b/src/PyHeightMap.cpp @@ -60,6 +60,56 @@ PyMethodDef PyHeightMap::methods[] = { MCRF_ARG("max", "Target maximum value (default 1.0)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, + // Query methods (#196) + {"get", (PyCFunction)PyHeightMap::get, METH_VARARGS, + MCRF_METHOD(HeightMap, get, + MCRF_SIG("(pos: tuple[int, int])", "float"), + MCRF_DESC("Get the height value at integer coordinates."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position as (x, y) tuple") + MCRF_RETURNS("float: Height value at that position") + MCRF_RAISES("IndexError", "Position is out of bounds") + )}, + {"get_interpolated", (PyCFunction)PyHeightMap::get_interpolated, METH_VARARGS, + MCRF_METHOD(HeightMap, get_interpolated, + MCRF_SIG("(pos: tuple[float, float])", "float"), + MCRF_DESC("Get interpolated height value at non-integer coordinates."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position as (x, y) tuple with float coordinates") + MCRF_RETURNS("float: Bilinearly interpolated height value") + )}, + {"get_slope", (PyCFunction)PyHeightMap::get_slope, METH_VARARGS, + MCRF_METHOD(HeightMap, get_slope, + MCRF_SIG("(pos: tuple[int, int])", "float"), + MCRF_DESC("Get the slope at integer coordinates, from 0 (flat) to pi/2 (vertical)."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position as (x, y) tuple") + MCRF_RETURNS("float: Slope angle in radians (0 to pi/2)") + MCRF_RAISES("IndexError", "Position is out of bounds") + )}, + {"get_normal", (PyCFunction)PyHeightMap::get_normal, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, get_normal, + MCRF_SIG("(pos: tuple[float, float], water_level: float = 0.0)", "tuple[float, float, float]"), + MCRF_DESC("Get the normal vector at given coordinates for lighting calculations."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position as (x, y) tuple with float coordinates") + MCRF_ARG("water_level", "Water level below which terrain is considered flat (default 0.0)") + MCRF_RETURNS("tuple[float, float, float]: Normal vector (nx, ny, nz)") + )}, + {"min_max", (PyCFunction)PyHeightMap::min_max, METH_NOARGS, + MCRF_METHOD(HeightMap, min_max, + MCRF_SIG("()", "tuple[float, float]"), + MCRF_DESC("Get the minimum and maximum height values in the map."), + MCRF_RETURNS("tuple[float, float]: (min_value, max_value)") + )}, + {"count_in_range", (PyCFunction)PyHeightMap::count_in_range, METH_VARARGS, + MCRF_METHOD(HeightMap, count_in_range, + MCRF_SIG("(range: tuple[float, float])", "int"), + MCRF_DESC("Count cells with values in the specified range (inclusive)."), + MCRF_ARGS_START + MCRF_ARG("range", "Value range as (min, max) tuple") + MCRF_RETURNS("int: Number of cells with values in range") + )}, {NULL} }; @@ -300,3 +350,191 @@ PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObje Py_INCREF(self); return (PyObject*)self; } + +// Query methods (#196) + +// Method: get(pos) -> float +PyObject* PyHeightMap::get(PyHeightMapObject* self, PyObject* args) +{ + PyObject* pos_obj = nullptr; + if (!PyArg_ParseTuple(args, "O", &pos_obj)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse position tuple + if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)"); + return nullptr; + } + + int x = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 0)); + int y = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + // Bounds check + if (x < 0 || x >= self->heightmap->w || y < 0 || y >= self->heightmap->h) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for HeightMap of size (%d, %d)", + x, y, self->heightmap->w, self->heightmap->h); + return nullptr; + } + + float value = TCOD_heightmap_get_value(self->heightmap, x, y); + return PyFloat_FromDouble(value); +} + +// Method: get_interpolated(pos) -> float +PyObject* PyHeightMap::get_interpolated(PyHeightMapObject* self, PyObject* args) +{ + PyObject* pos_obj = nullptr; + if (!PyArg_ParseTuple(args, "O", &pos_obj)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse position tuple (floats) + if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)"); + return nullptr; + } + + float x = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 0)); + float y = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + float value = TCOD_heightmap_get_interpolated_value(self->heightmap, x, y); + return PyFloat_FromDouble(value); +} + +// Method: get_slope(pos) -> float +PyObject* PyHeightMap::get_slope(PyHeightMapObject* self, PyObject* args) +{ + PyObject* pos_obj = nullptr; + if (!PyArg_ParseTuple(args, "O", &pos_obj)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse position tuple + if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)"); + return nullptr; + } + + int x = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 0)); + int y = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + // Bounds check + if (x < 0 || x >= self->heightmap->w || y < 0 || y >= self->heightmap->h) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for HeightMap of size (%d, %d)", + x, y, self->heightmap->w, self->heightmap->h); + return nullptr; + } + + float slope = TCOD_heightmap_get_slope(self->heightmap, x, y); + return PyFloat_FromDouble(slope); +} + +// Method: get_normal(pos, water_level=0.0) -> tuple[float, float, float] +PyObject* PyHeightMap::get_normal(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"pos", "water_level", nullptr}; + PyObject* pos_obj = nullptr; + float water_level = 0.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(keywords), + &pos_obj, &water_level)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse position tuple (floats) + if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)"); + return nullptr; + } + + float x = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 0)); + float y = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + float n[3]; + TCOD_heightmap_get_normal(self->heightmap, x, y, n, water_level); + + return Py_BuildValue("(fff)", n[0], n[1], n[2]); +} + +// Method: min_max() -> tuple[float, float] +PyObject* PyHeightMap::min_max(PyHeightMapObject* self, PyObject* Py_UNUSED(args)) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + float min_val, max_val; + TCOD_heightmap_get_minmax(self->heightmap, &min_val, &max_val); + + return Py_BuildValue("(ff)", min_val, max_val); +} + +// Method: count_in_range(range) -> int +PyObject* PyHeightMap::count_in_range(PyHeightMapObject* self, PyObject* args) +{ + PyObject* range_obj = nullptr; + if (!PyArg_ParseTuple(args, "O", &range_obj)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse range tuple + if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)"); + return nullptr; + } + + float min_val = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0)); + float max_val = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + int count = TCOD_heightmap_count_cells(self->heightmap, min_val, max_val); + return PyLong_FromLong(count); +} diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h index df02af6..cd33809 100644 --- a/src/PyHeightMap.h +++ b/src/PyHeightMap.h @@ -32,6 +32,14 @@ public: static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + // Query methods (#196) + static PyObject* get(PyHeightMapObject* self, PyObject* args); + static PyObject* get_interpolated(PyHeightMapObject* self, PyObject* args); + static PyObject* get_slope(PyHeightMapObject* self, PyObject* args); + static PyObject* get_normal(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* min_max(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); + static PyObject* count_in_range(PyHeightMapObject* self, PyObject* args); + // Method and property definitions static PyMethodDef methods[]; static PyGetSetDef getsetters[]; diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index cff7649..2f4ae50 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -9,6 +9,7 @@ #include "PyFOV.h" #include "PyPositionHelper.h" // For standardized position argument parsing #include "PyVector.h" // #179, #181 - For Vector return types +#include "PyHeightMap.h" // #199 - HeightMap application methods #include #include // #142 - for std::floor, std::isnan #include // #150 - for strcmp @@ -1690,6 +1691,229 @@ PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) { Py_RETURN_NONE; } +// #199 - HeightMap application methods + +PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr}; + PyObject* source_obj = nullptr; + PyObject* range_obj = nullptr; + PyObject* walkable_obj = Py_None; + PyObject* transparent_obj = Py_None; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|OO", const_cast(keywords), + &source_obj, &range_obj, &walkable_obj, &transparent_obj)) { + return nullptr; + } + + // Validate source is a HeightMap + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module"); + return nullptr; + } + bool is_heightmap = PyObject_IsInstance(source_obj, heightmap_type); + Py_DECREF(heightmap_type); + if (!is_heightmap) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return nullptr; + } + PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; + + if (!hmap->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Parse range tuple + if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)"); + return nullptr; + } + + float range_min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0)); + float range_max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + // Check size match + if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "HeightMap size (%d, %d) does not match Grid size (%d, %d)", + hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); + return nullptr; + } + + // Parse optional walkable/transparent booleans + bool set_walkable = (walkable_obj != Py_None); + bool set_transparent = (transparent_obj != Py_None); + bool walkable_value = false; + bool transparent_value = false; + + if (set_walkable) { + walkable_value = PyObject_IsTrue(walkable_obj); + } + if (set_transparent) { + transparent_value = PyObject_IsTrue(transparent_obj); + } + + // Apply threshold + for (int y = 0; y < self->data->grid_h; y++) { + for (int x = 0; x < self->data->grid_w; x++) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + if (value >= range_min && value <= range_max) { + UIGridPoint& point = self->data->at(x, y); + if (set_walkable) { + point.walkable = walkable_value; + } + if (set_transparent) { + point.transparent = transparent_value; + } + } + } + } + + // Sync TCOD map if it exists + if (self->data->getTCODMap()) { + self->data->syncTCODMap(); + } + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) { + PyObject* source_obj = nullptr; + PyObject* ranges_obj = nullptr; + + if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) { + return nullptr; + } + + // Validate source is a HeightMap + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module"); + return nullptr; + } + bool is_heightmap = PyObject_IsInstance(source_obj, heightmap_type); + Py_DECREF(heightmap_type); + if (!is_heightmap) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return nullptr; + } + PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; + + if (!hmap->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Validate ranges is a list + if (!PyList_Check(ranges_obj)) { + PyErr_SetString(PyExc_TypeError, "ranges must be a list"); + return nullptr; + } + + // Check size match + if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "HeightMap size (%d, %d) does not match Grid size (%d, %d)", + hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); + return nullptr; + } + + // Parse all ranges first to catch errors early + struct RangeEntry { + float min, max; + bool set_walkable, set_transparent; + bool walkable_value, transparent_value; + }; + std::vector entries; + + Py_ssize_t num_ranges = PyList_Size(ranges_obj); + for (Py_ssize_t i = 0; i < num_ranges; i++) { + PyObject* entry = PyList_GetItem(ranges_obj, i); + + if (!PyTuple_Check(entry) || PyTuple_Size(entry) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] must be a tuple of (range, properties_dict)", i); + return nullptr; + } + + PyObject* range_tuple = PyTuple_GetItem(entry, 0); + PyObject* props_dict = PyTuple_GetItem(entry, 1); + + if (!PyTuple_Check(range_tuple) || PyTuple_Size(range_tuple) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] range must be a tuple of (min, max)", i); + return nullptr; + } + + if (!PyDict_Check(props_dict)) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] properties must be a dict", i); + return nullptr; + } + + RangeEntry re; + re.min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 0)); + re.max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + // Parse walkable from dict + PyObject* walkable_val = PyDict_GetItemString(props_dict, "walkable"); + re.set_walkable = (walkable_val != nullptr); + if (re.set_walkable) { + re.walkable_value = PyObject_IsTrue(walkable_val); + } + + // Parse transparent from dict + PyObject* transparent_val = PyDict_GetItemString(props_dict, "transparent"); + re.set_transparent = (transparent_val != nullptr); + if (re.set_transparent) { + re.transparent_value = PyObject_IsTrue(transparent_val); + } + + entries.push_back(re); + } + + // Apply all ranges in a single pass + for (int y = 0; y < self->data->grid_h; y++) { + for (int x = 0; x < self->data->grid_w; x++) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + UIGridPoint& point = self->data->at(x, y); + + // Check each range (first match wins) + for (const auto& re : entries) { + if (value >= re.min && value <= re.max) { + if (re.set_walkable) { + point.walkable = re.walkable_value; + } + if (re.set_transparent) { + point.transparent = re.transparent_value; + } + break; // First matching range wins + } + } + } + } + + // Sync TCOD map if it exists + if (self->data->getTCODMap()) { + self->data->syncTCODMap(); + } + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + PyMethodDef UIGrid::methods[] = { {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, @@ -1759,6 +1983,35 @@ PyMethodDef UIGrid::methods[] = { " grid.center_camera() # Center on middle of grid\n" " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, + // #199 - HeightMap application methods + {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" + "Apply walkable/transparent properties where heightmap values are in range.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " range: Tuple of (min, max) - cells with values in this range are affected.\n" + " walkable: If not None, set walkable to this value for cells in range.\n" + " transparent: If not None, set transparent to this value for cells in range.\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Raises:\n" + " ValueError: If HeightMap size doesn't match grid size."}, + {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, + "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" + "Apply multiple thresholds in a single pass.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " ranges: List of (range_tuple, properties_dict) tuples.\n" + " range_tuple: (min, max) value range\n" + " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Example:\n" + " grid.apply_ranges(terrain, [\n" + " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" + " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" + " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" + " ])"}, {NULL, NULL, 0, NULL} }; @@ -1851,6 +2104,35 @@ PyMethodDef UIGrid_all_methods[] = { " grid.center_camera() # Center on middle of grid\n" " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, + // #199 - HeightMap application methods + {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" + "Apply walkable/transparent properties where heightmap values are in range.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " range: Tuple of (min, max) - cells with values in this range are affected.\n" + " walkable: If not None, set walkable to this value for cells in range.\n" + " transparent: If not None, set transparent to this value for cells in range.\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Raises:\n" + " ValueError: If HeightMap size doesn't match grid size."}, + {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, + "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" + "Apply multiple thresholds in a single pass.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " ranges: List of (range_tuple, properties_dict) tuples.\n" + " range_tuple: (min, max) value range\n" + " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Example:\n" + " grid.apply_ranges(terrain, [\n" + " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" + " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" + " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" + " ])"}, {NULL} // Sentinel }; diff --git a/src/UIGrid.h b/src/UIGrid.h index 3d1a56a..aa1336b 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -179,6 +179,10 @@ public: static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169 + // #199 - HeightMap application methods + static PyObject* py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_apply_ranges(PyUIGridObject* self, PyObject* args); + // #169 - Camera positioning void center_camera(); // Center on grid's middle tile void center_camera(float tile_x, float tile_y); // Center on specific tile diff --git a/tests/unit/test_grid_apply.py b/tests/unit/test_grid_apply.py new file mode 100644 index 0000000..65a365a --- /dev/null +++ b/tests/unit/test_grid_apply.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Unit tests for Grid.apply_threshold and Grid.apply_ranges (#199) + +Tests the Grid methods for applying HeightMap data to walkable/transparent properties. +""" + +import sys +import mcrfpy + + +def test_apply_threshold_walkable(): + """apply_threshold sets walkable property correctly""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # All cells start with default walkable + grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True) + + # Check a few cells + assert grid.at((5, 5)).walkable == True + assert grid.at((0, 0)).walkable == True + assert grid.at((9, 9)).walkable == True + print("PASS: test_apply_threshold_walkable") + + +def test_apply_threshold_transparent(): + """apply_threshold sets transparent property correctly""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid.apply_threshold(hmap, range=(0.0, 1.0), transparent=False) + + assert grid.at((5, 5)).transparent == False + print("PASS: test_apply_threshold_transparent") + + +def test_apply_threshold_both(): + """apply_threshold sets both walkable and transparent""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True, transparent=True) + + point = grid.at((5, 5)) + assert point.walkable == True + assert point.transparent == True + print("PASS: test_apply_threshold_both") + + +def test_apply_threshold_out_of_range(): + """apply_threshold doesn't affect cells outside range""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Set initial state + grid.at((5, 5)).walkable = False + grid.at((5, 5)).transparent = False + + # Apply threshold with range that excludes 0.5 + grid.apply_threshold(hmap, range=(0.0, 0.4), walkable=True, transparent=True) + + # Cell should remain unchanged + assert grid.at((5, 5)).walkable == False + assert grid.at((5, 5)).transparent == False + print("PASS: test_apply_threshold_out_of_range") + + +def test_apply_threshold_returns_self(): + """apply_threshold returns self for chaining""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True) + assert result is grid, "apply_threshold should return self" + print("PASS: test_apply_threshold_returns_self") + + +def test_apply_threshold_size_mismatch(): + """apply_threshold raises ValueError for size mismatch""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((20, 20), fill=0.5) + + try: + grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True) + print("FAIL: test_apply_threshold_size_mismatch - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "size" in str(e).lower() + + print("PASS: test_apply_threshold_size_mismatch") + + +def test_apply_threshold_invalid_source(): + """apply_threshold raises TypeError for non-HeightMap source""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + + try: + grid.apply_threshold("not a heightmap", range=(0.0, 1.0), walkable=True) + print("FAIL: test_apply_threshold_invalid_source - should have raised TypeError") + sys.exit(1) + except TypeError: + pass + + print("PASS: test_apply_threshold_invalid_source") + + +def test_apply_threshold_none_values(): + """apply_threshold with None values leaves properties unchanged""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Set initial state + grid.at((5, 5)).walkable = True + grid.at((5, 5)).transparent = False + + # Apply with only walkable=False, transparent should stay unchanged + grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=False) + + assert grid.at((5, 5)).walkable == False + assert grid.at((5, 5)).transparent == False # Unchanged + print("PASS: test_apply_threshold_none_values") + + +def test_apply_ranges_basic(): + """apply_ranges applies multiple ranges correctly""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Apply a range that covers 0.5 + grid.apply_ranges(hmap, [ + ((0.4, 0.6), {"walkable": True, "transparent": True}), + ]) + + assert grid.at((5, 5)).walkable == True + assert grid.at((5, 5)).transparent == True + print("PASS: test_apply_ranges_basic") + + +def test_apply_ranges_first_match_wins(): + """apply_ranges uses first matching range""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Both ranges cover 0.5, first should win + grid.apply_ranges(hmap, [ + ((0.0, 0.6), {"walkable": True}), + ((0.4, 1.0), {"walkable": False}), + ]) + + assert grid.at((5, 5)).walkable == True # First match wins + print("PASS: test_apply_ranges_first_match_wins") + + +def test_apply_ranges_returns_self(): + """apply_ranges returns self for chaining""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + result = grid.apply_ranges(hmap, [ + ((0.0, 1.0), {"walkable": True}), + ]) + assert result is grid, "apply_ranges should return self" + print("PASS: test_apply_ranges_returns_self") + + +def test_apply_ranges_size_mismatch(): + """apply_ranges raises ValueError for size mismatch""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((5, 5), fill=0.5) + + try: + grid.apply_ranges(hmap, [ + ((0.0, 1.0), {"walkable": True}), + ]) + print("FAIL: test_apply_ranges_size_mismatch - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "size" in str(e).lower() + + print("PASS: test_apply_ranges_size_mismatch") + + +def test_apply_ranges_empty_list(): + """apply_ranges with empty list doesn't change anything""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid.at((5, 5)).walkable = True + grid.at((5, 5)).transparent = False + + grid.apply_ranges(hmap, []) + + # Should remain unchanged + assert grid.at((5, 5)).walkable == True + assert grid.at((5, 5)).transparent == False + print("PASS: test_apply_ranges_empty_list") + + +def test_apply_ranges_no_match(): + """apply_ranges leaves cells unchanged when no range matches""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid.at((5, 5)).walkable = True + grid.at((5, 5)).transparent = True + + # Ranges that don't include 0.5 + grid.apply_ranges(hmap, [ + ((0.0, 0.4), {"walkable": False}), + ((0.6, 1.0), {"transparent": False}), + ]) + + # Should remain unchanged + assert grid.at((5, 5)).walkable == True + assert grid.at((5, 5)).transparent == True + print("PASS: test_apply_ranges_no_match") + + +def test_apply_ranges_invalid_format(): + """apply_ranges raises TypeError for invalid format""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Invalid: not a list + try: + grid.apply_ranges(hmap, "not a list") + print("FAIL: should have raised TypeError for non-list") + sys.exit(1) + except TypeError: + pass + + # Invalid: entry not a tuple + try: + grid.apply_ranges(hmap, ["not a tuple"]) + print("FAIL: should have raised TypeError for non-tuple entry") + sys.exit(1) + except TypeError: + pass + + # Invalid: range not a tuple + try: + grid.apply_ranges(hmap, [ + ([0.0, 1.0], {"walkable": True}), # list instead of tuple for range + ]) + print("FAIL: should have raised TypeError for non-tuple range") + sys.exit(1) + except TypeError: + pass + + # Invalid: props not a dict + try: + grid.apply_ranges(hmap, [ + ((0.0, 1.0), "not a dict"), + ]) + print("FAIL: should have raised TypeError for non-dict props") + sys.exit(1) + except TypeError: + pass + + print("PASS: test_apply_ranges_invalid_format") + + +def test_chaining(): + """Methods can be chained together""" + grid = mcrfpy.Grid(grid_size=(10, 10)) + hmap = mcrfpy.HeightMap((10, 10)) + + # Chain multiple operations + hmap.fill(0.5) + + result = (grid + .apply_threshold(hmap, range=(0.0, 0.4), walkable=False) + .apply_threshold(hmap, range=(0.6, 1.0), transparent=False) + .apply_ranges(hmap, [ + ((0.4, 0.6), {"walkable": True, "transparent": True}), + ])) + + assert result is grid + print("PASS: test_chaining") + + +def run_all_tests(): + """Run all tests""" + print("Running Grid apply method tests...") + print() + + test_apply_threshold_walkable() + test_apply_threshold_transparent() + test_apply_threshold_both() + test_apply_threshold_out_of_range() + test_apply_threshold_returns_self() + test_apply_threshold_size_mismatch() + test_apply_threshold_invalid_source() + test_apply_threshold_none_values() + test_apply_ranges_basic() + test_apply_ranges_first_match_wins() + test_apply_ranges_returns_self() + test_apply_ranges_size_mismatch() + test_apply_ranges_empty_list() + test_apply_ranges_no_match() + test_apply_ranges_invalid_format() + test_chaining() + + print() + print("All Grid apply method tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0) diff --git a/tests/unit/test_heightmap_query.py b/tests/unit/test_heightmap_query.py new file mode 100644 index 0000000..2522753 --- /dev/null +++ b/tests/unit/test_heightmap_query.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Unit tests for mcrfpy.HeightMap query methods (#196) + +Tests the HeightMap query methods: get, get_interpolated, get_slope, get_normal, min_max, count_in_range +""" + +import sys +import math +import mcrfpy + + +def test_get_basic(): + """get() returns correct value at position""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + value = hmap.get((5, 5)) + assert abs(value - 0.5) < 0.001, f"Expected 0.5, got {value}" + print("PASS: test_get_basic") + + +def test_get_corners(): + """get() works at all corners""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.25) + + # All corners should have the fill value + assert abs(hmap.get((0, 0)) - 0.25) < 0.001 + assert abs(hmap.get((9, 0)) - 0.25) < 0.001 + assert abs(hmap.get((0, 9)) - 0.25) < 0.001 + assert abs(hmap.get((9, 9)) - 0.25) < 0.001 + print("PASS: test_get_corners") + + +def test_get_out_of_bounds(): + """get() raises IndexError for out-of-bounds position""" + hmap = mcrfpy.HeightMap((10, 10)) + + # Test various out-of-bounds positions + for pos in [(-1, 0), (0, -1), (10, 0), (0, 10), (10, 10)]: + try: + hmap.get(pos) + print(f"FAIL: test_get_out_of_bounds - should have raised IndexError for {pos}") + sys.exit(1) + except IndexError: + pass + + print("PASS: test_get_out_of_bounds") + + +def test_get_invalid_type(): + """get() raises TypeError for invalid position""" + hmap = mcrfpy.HeightMap((10, 10)) + + try: + hmap.get([5, 5]) # list instead of tuple + print("FAIL: test_get_invalid_type - should have raised TypeError") + sys.exit(1) + except TypeError: + pass + + print("PASS: test_get_invalid_type") + + +def test_get_interpolated_basic(): + """get_interpolated() returns value at float position""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + value = hmap.get_interpolated((5.5, 5.5)) + # With uniform fill, interpolation should return same value + assert abs(value - 0.5) < 0.001, f"Expected ~0.5, got {value}" + print("PASS: test_get_interpolated_basic") + + +def test_get_interpolated_at_integers(): + """get_interpolated() matches get() at integer positions""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.75) + + int_value = hmap.get((3, 4)) + interp_value = hmap.get_interpolated((3.0, 4.0)) + + assert abs(int_value - interp_value) < 0.001, f"Values differ: {int_value} vs {interp_value}" + print("PASS: test_get_interpolated_at_integers") + + +def test_get_slope_flat(): + """get_slope() returns 0 for flat terrain""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + slope = hmap.get_slope((5, 5)) + # Flat terrain should have slope near 0 + assert abs(slope) < 0.01, f"Expected ~0 for flat terrain, got {slope}" + print("PASS: test_get_slope_flat") + + +def test_get_slope_out_of_bounds(): + """get_slope() raises IndexError for out-of-bounds position""" + hmap = mcrfpy.HeightMap((10, 10)) + + try: + hmap.get_slope((10, 5)) + print("FAIL: test_get_slope_out_of_bounds - should have raised IndexError") + sys.exit(1) + except IndexError: + pass + + print("PASS: test_get_slope_out_of_bounds") + + +def test_get_normal_flat(): + """get_normal() returns up vector for flat terrain""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + nx, ny, nz = hmap.get_normal((5.0, 5.0)) + + # Flat terrain should have normal pointing up (0, 0, 1) + assert abs(nx) < 0.01, f"Expected nx~0, got {nx}" + assert abs(ny) < 0.01, f"Expected ny~0, got {ny}" + assert abs(nz - 1.0) < 0.01, f"Expected nz~1, got {nz}" + print("PASS: test_get_normal_flat") + + +def test_get_normal_with_water_level(): + """get_normal() accepts water_level parameter""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + nx, ny, nz = hmap.get_normal((5.0, 5.0), water_level=0.3) + + # Should still return valid normal + assert isinstance(nx, float) + assert isinstance(ny, float) + assert isinstance(nz, float) + print("PASS: test_get_normal_with_water_level") + + +def test_min_max_uniform(): + """min_max() returns correct values for uniform heightmap""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + min_val, max_val = hmap.min_max() + + assert abs(min_val - 0.5) < 0.001, f"Expected min=0.5, got {min_val}" + assert abs(max_val - 0.5) < 0.001, f"Expected max=0.5, got {max_val}" + print("PASS: test_min_max_uniform") + + +def test_min_max_after_operations(): + """min_max() updates after operations""" + hmap = mcrfpy.HeightMap((10, 10)) + hmap.fill(0.0).add_constant(0.5).scale(2.0) + + min_val, max_val = hmap.min_max() + expected = 1.0 # 0.0 + 0.5 * 2.0 + + assert abs(min_val - expected) < 0.001, f"Expected min={expected}, got {min_val}" + assert abs(max_val - expected) < 0.001, f"Expected max={expected}, got {max_val}" + print("PASS: test_min_max_after_operations") + + +def test_count_in_range_all(): + """count_in_range() returns all cells for uniform map in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + count = hmap.count_in_range((0.0, 1.0)) + + assert count == 100, f"Expected 100 cells, got {count}" + print("PASS: test_count_in_range_all") + + +def test_count_in_range_none(): + """count_in_range() returns 0 when no cells in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + count = hmap.count_in_range((0.0, 0.4)) + + assert count == 0, f"Expected 0 cells, got {count}" + print("PASS: test_count_in_range_none") + + +def test_count_in_range_exact(): + """count_in_range() with exact bounds""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + count = hmap.count_in_range((0.5, 0.5)) + + # Should count all cells since fill value is exactly 0.5 + assert count == 100, f"Expected 100 cells at exact value, got {count}" + print("PASS: test_count_in_range_exact") + + +def test_count_in_range_invalid(): + """count_in_range() raises TypeError for invalid range""" + hmap = mcrfpy.HeightMap((10, 10)) + + try: + hmap.count_in_range([0.0, 1.0]) # list instead of tuple + print("FAIL: test_count_in_range_invalid - should have raised TypeError") + sys.exit(1) + except TypeError: + pass + + print("PASS: test_count_in_range_invalid") + + +def run_all_tests(): + """Run all tests""" + print("Running HeightMap query method tests...") + print() + + test_get_basic() + test_get_corners() + test_get_out_of_bounds() + test_get_invalid_type() + test_get_interpolated_basic() + test_get_interpolated_at_integers() + test_get_slope_flat() + test_get_slope_out_of_bounds() + test_get_normal_flat() + test_get_normal_with_water_level() + test_min_max_uniform() + test_min_max_after_operations() + test_count_in_range_all() + test_count_in_range_none() + test_count_in_range_exact() + test_count_in_range_invalid() + + print() + print("All HeightMap query method tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0)