From d92d5f02740cddfc7c5db4976ea534e24bca9456 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 11 Jan 2026 21:49:28 -0500 Subject: [PATCH] HeightMap: add threshold operations that return new HeightMaps (closes #197) Add three methods that create NEW HeightMap objects: - threshold(range): preserve original values where in range, 0.0 elsewhere - threshold_binary(range, value=1.0): set uniform value where in range - inverse(): return (1.0 - value) for each cell These operations are immutable - they preserve the original HeightMap. Useful for masking operations with Grid.apply_threshold/apply_ranges. Co-Authored-By: Claude Opus 4.5 --- src/PyHeightMap.cpp | 204 ++++++++++++++++++++ src/PyHeightMap.h | 5 + tests/unit/test_heightmap_threshold.py | 254 +++++++++++++++++++++++++ 3 files changed, 463 insertions(+) create mode 100644 tests/unit/test_heightmap_threshold.py diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp index 43b7221..cf5b759 100644 --- a/src/PyHeightMap.cpp +++ b/src/PyHeightMap.cpp @@ -119,6 +119,32 @@ PyMethodDef PyHeightMap::methods[] = { MCRF_RETURNS("int: Number of cells with values in range") MCRF_RAISES("ValueError", "min > max") )}, + // Threshold operations (#197) - return NEW HeightMaps + {"threshold", (PyCFunction)PyHeightMap::threshold, METH_VARARGS, + MCRF_METHOD(HeightMap, threshold, + MCRF_SIG("(range: tuple[float, float])", "HeightMap"), + MCRF_DESC("Return NEW HeightMap with original values where in range, 0.0 elsewhere."), + MCRF_ARGS_START + MCRF_ARG("range", "Value range as (min, max) tuple or list, inclusive") + MCRF_RETURNS("HeightMap: New HeightMap (original is unchanged)") + MCRF_RAISES("ValueError", "min > max") + )}, + {"threshold_binary", (PyCFunction)PyHeightMap::threshold_binary, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, threshold_binary, + MCRF_SIG("(range: tuple[float, float], value: float = 1.0)", "HeightMap"), + MCRF_DESC("Return NEW HeightMap with uniform value where in range, 0.0 elsewhere."), + MCRF_ARGS_START + MCRF_ARG("range", "Value range as (min, max) tuple or list, inclusive") + MCRF_ARG("value", "Value to set for cells in range (default 1.0)") + MCRF_RETURNS("HeightMap: New HeightMap (original is unchanged)") + MCRF_RAISES("ValueError", "min > max") + )}, + {"inverse", (PyCFunction)PyHeightMap::inverse, METH_NOARGS, + MCRF_METHOD(HeightMap, inverse, + MCRF_SIG("()", "HeightMap"), + MCRF_DESC("Return NEW HeightMap with (1.0 - value) for each cell."), + MCRF_RETURNS("HeightMap: New inverted HeightMap (original is unchanged)") + )}, {NULL} }; @@ -554,3 +580,181 @@ PyObject* PyHeightMap::subscript(PyHeightMapObject* self, PyObject* key) float value = TCOD_heightmap_get_value(self->heightmap, x, y); return PyFloat_FromDouble(value); } + +// Threshold operations (#197) - return NEW HeightMaps + +// Helper: Parse range from tuple or list +static bool ParseRange(PyObject* range_obj, float* min_val, float* max_val) +{ + if (PyTuple_Check(range_obj) && PyTuple_Size(range_obj) == 2) { + PyObject* min_obj = PyTuple_GetItem(range_obj, 0); + PyObject* max_obj = PyTuple_GetItem(range_obj, 1); + if (PyFloat_Check(min_obj)) *min_val = (float)PyFloat_AsDouble(min_obj); + else if (PyLong_Check(min_obj)) *min_val = (float)PyLong_AsLong(min_obj); + else { PyErr_SetString(PyExc_TypeError, "range values must be numeric"); return false; } + if (PyFloat_Check(max_obj)) *max_val = (float)PyFloat_AsDouble(max_obj); + else if (PyLong_Check(max_obj)) *max_val = (float)PyLong_AsLong(max_obj); + else { PyErr_SetString(PyExc_TypeError, "range values must be numeric"); return false; } + } else if (PyList_Check(range_obj) && PyList_Size(range_obj) == 2) { + PyObject* min_obj = PyList_GetItem(range_obj, 0); + PyObject* max_obj = PyList_GetItem(range_obj, 1); + if (PyFloat_Check(min_obj)) *min_val = (float)PyFloat_AsDouble(min_obj); + else if (PyLong_Check(min_obj)) *min_val = (float)PyLong_AsLong(min_obj); + else { PyErr_SetString(PyExc_TypeError, "range values must be numeric"); return false; } + if (PyFloat_Check(max_obj)) *max_val = (float)PyFloat_AsDouble(max_obj); + else if (PyLong_Check(max_obj)) *max_val = (float)PyLong_AsLong(max_obj); + else { PyErr_SetString(PyExc_TypeError, "range values must be numeric"); return false; } + } else { + PyErr_SetString(PyExc_TypeError, "range must be a tuple or list of (min, max)"); + return false; + } + + if (*min_val > *max_val) { + PyErr_SetString(PyExc_ValueError, "range min must be less than or equal to max"); + return false; + } + + return !PyErr_Occurred(); +} + +// Helper: Create a new HeightMap object with same dimensions +static PyHeightMapObject* CreateNewHeightMap(int width, int height) +{ + // Get the HeightMap type from the module + 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; + } + + // Create size tuple + PyObject* size_tuple = Py_BuildValue("(ii)", width, height); + if (!size_tuple) { + Py_DECREF(heightmap_type); + return nullptr; + } + + // Create args tuple containing the size tuple + PyObject* args = PyTuple_Pack(1, size_tuple); + Py_DECREF(size_tuple); + if (!args) { + Py_DECREF(heightmap_type); + return nullptr; + } + + // Create the new object + PyHeightMapObject* new_hmap = (PyHeightMapObject*)PyObject_Call(heightmap_type, args, nullptr); + Py_DECREF(args); + Py_DECREF(heightmap_type); + + if (!new_hmap) { + return nullptr; // Python error already set + } + + return new_hmap; +} + +// Method: threshold(range) -> HeightMap +PyObject* PyHeightMap::threshold(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; + } + + float min_val, max_val; + if (!ParseRange(range_obj, &min_val, &max_val)) { + return nullptr; + } + + // Create new HeightMap with same dimensions + PyHeightMapObject* result = CreateNewHeightMap(self->heightmap->w, self->heightmap->h); + if (!result) { + return nullptr; + } + + // Copy values that are in range, leave others as 0.0 + for (int y = 0; y < self->heightmap->h; y++) { + for (int x = 0; x < self->heightmap->w; x++) { + float value = TCOD_heightmap_get_value(self->heightmap, x, y); + if (value >= min_val && value <= max_val) { + TCOD_heightmap_set_value(result->heightmap, x, y, value); + } + // else: already 0.0 from initialization + } + } + + return (PyObject*)result; +} + +// Method: threshold_binary(range, value=1.0) -> HeightMap +PyObject* PyHeightMap::threshold_binary(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"range", "value", nullptr}; + PyObject* range_obj = nullptr; + float set_value = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(keywords), + &range_obj, &set_value)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + float min_val, max_val; + if (!ParseRange(range_obj, &min_val, &max_val)) { + return nullptr; + } + + // Create new HeightMap with same dimensions + PyHeightMapObject* result = CreateNewHeightMap(self->heightmap->w, self->heightmap->h); + if (!result) { + return nullptr; + } + + // Set uniform value where in range, leave others as 0.0 + for (int y = 0; y < self->heightmap->h; y++) { + for (int x = 0; x < self->heightmap->w; x++) { + float value = TCOD_heightmap_get_value(self->heightmap, x, y); + if (value >= min_val && value <= max_val) { + TCOD_heightmap_set_value(result->heightmap, x, y, set_value); + } + // else: already 0.0 from initialization + } + } + + return (PyObject*)result; +} + +// Method: inverse() -> HeightMap +PyObject* PyHeightMap::inverse(PyHeightMapObject* self, PyObject* Py_UNUSED(args)) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Create new HeightMap with same dimensions + PyHeightMapObject* result = CreateNewHeightMap(self->heightmap->w, self->heightmap->h); + if (!result) { + return nullptr; + } + + // Set (1.0 - value) for each cell + for (int y = 0; y < self->heightmap->h; y++) { + for (int x = 0; x < self->heightmap->w; x++) { + float value = TCOD_heightmap_get_value(self->heightmap, x, y); + TCOD_heightmap_set_value(result->heightmap, x, y, 1.0f - value); + } + } + + return (PyObject*)result; +} diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h index ca6a2ee..1e5bba7 100644 --- a/src/PyHeightMap.h +++ b/src/PyHeightMap.h @@ -40,6 +40,11 @@ public: static PyObject* min_max(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); static PyObject* count_in_range(PyHeightMapObject* self, PyObject* args); + // Threshold operations (#197) - return NEW HeightMaps + static PyObject* threshold(PyHeightMapObject* self, PyObject* args); + static PyObject* threshold_binary(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* inverse(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); + // Subscript support for hmap[x, y] syntax static PyObject* subscript(PyHeightMapObject* self, PyObject* key); diff --git a/tests/unit/test_heightmap_threshold.py b/tests/unit/test_heightmap_threshold.py new file mode 100644 index 0000000..6ad2fc3 --- /dev/null +++ b/tests/unit/test_heightmap_threshold.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Unit tests for mcrfpy.HeightMap threshold operations (#197) + +Tests the HeightMap threshold methods: threshold, threshold_binary, inverse +These methods return NEW HeightMap objects, preserving the original. +""" + +import sys +import mcrfpy + + +def test_threshold_basic(): + """threshold() returns new HeightMap with values in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold((0.4, 0.6)) + + # Result should have values (all 0.5 are in range) + assert abs(result[5, 5] - 0.5) < 0.001, f"Expected 0.5, got {result[5, 5]}" + print("PASS: test_threshold_basic") + + +def test_threshold_preserves_original(): + """threshold() does not modify original""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + original_value = hmap[5, 5] + + _ = hmap.threshold((0.0, 0.3)) # Range excludes 0.5 + + # Original should be unchanged + assert abs(hmap[5, 5] - original_value) < 0.001, "Original was modified!" + print("PASS: test_threshold_preserves_original") + + +def test_threshold_returns_new(): + """threshold() returns a different object""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold((0.0, 1.0)) + + assert result is not hmap, "threshold should return a new HeightMap" + print("PASS: test_threshold_returns_new") + + +def test_threshold_out_of_range(): + """threshold() sets values outside range to 0.0""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold((0.6, 1.0)) # Excludes 0.5 + + # All values should be 0.0 since 0.5 is not in [0.6, 1.0] + assert abs(result[5, 5]) < 0.001, f"Expected 0.0, got {result[5, 5]}" + print("PASS: test_threshold_out_of_range") + + +def test_threshold_preserves_values(): + """threshold() preserves original values (not just 1.0)""" + hmap = mcrfpy.HeightMap((10, 10)) + + # Set different values manually using scalar ops + hmap.fill(0.0) + + # We can't set individual values, so let's test with uniform map + # and verify the value is preserved, not converted to 1.0 + hmap2 = mcrfpy.HeightMap((10, 10), fill=0.75) + result = hmap2.threshold((0.5, 1.0)) + + assert abs(result[5, 5] - 0.75) < 0.001, f"Expected 0.75, got {result[5, 5]}" + print("PASS: test_threshold_preserves_values") + + +def test_threshold_invalid_range(): + """threshold() raises ValueError for invalid range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + try: + hmap.threshold((1.0, 0.0)) # min > max + print("FAIL: test_threshold_invalid_range - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "min" in str(e).lower() + + print("PASS: test_threshold_invalid_range") + + +def test_threshold_accepts_list(): + """threshold() accepts list as range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold([0.4, 0.6]) # List instead of tuple + + assert abs(result[5, 5] - 0.5) < 0.001 + print("PASS: test_threshold_accepts_list") + + +def test_threshold_binary_basic(): + """threshold_binary() sets uniform value in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold_binary((0.4, 0.6)) + + # Default value is 1.0 + assert abs(result[5, 5] - 1.0) < 0.001, f"Expected 1.0, got {result[5, 5]}" + print("PASS: test_threshold_binary_basic") + + +def test_threshold_binary_custom_value(): + """threshold_binary() uses custom value""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold_binary((0.4, 0.6), value=0.8) + + assert abs(result[5, 5] - 0.8) < 0.001, f"Expected 0.8, got {result[5, 5]}" + print("PASS: test_threshold_binary_custom_value") + + +def test_threshold_binary_out_of_range(): + """threshold_binary() sets 0.0 for values outside range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.threshold_binary((0.6, 1.0)) + + # 0.5 is not in [0.6, 1.0], so result should be 0.0 + assert abs(result[5, 5]) < 0.001, f"Expected 0.0, got {result[5, 5]}" + print("PASS: test_threshold_binary_out_of_range") + + +def test_threshold_binary_preserves_original(): + """threshold_binary() does not modify original""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + _ = hmap.threshold_binary((0.0, 1.0), value=0.0) + + assert abs(hmap[5, 5] - 0.5) < 0.001, "Original was modified!" + print("PASS: test_threshold_binary_preserves_original") + + +def test_threshold_binary_invalid_range(): + """threshold_binary() raises ValueError for invalid range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + try: + hmap.threshold_binary((1.0, 0.0)) # min > max + print("FAIL: test_threshold_binary_invalid_range - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "min" in str(e).lower() + + print("PASS: test_threshold_binary_invalid_range") + + +def test_inverse_basic(): + """inverse() returns (1.0 - value) for each cell""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.3) + result = hmap.inverse() + + expected = 1.0 - 0.3 + assert abs(result[5, 5] - expected) < 0.001, f"Expected {expected}, got {result[5, 5]}" + print("PASS: test_inverse_basic") + + +def test_inverse_preserves_original(): + """inverse() does not modify original""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.3) + _ = hmap.inverse() + + assert abs(hmap[5, 5] - 0.3) < 0.001, "Original was modified!" + print("PASS: test_inverse_preserves_original") + + +def test_inverse_returns_new(): + """inverse() returns a different object""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.inverse() + + assert result is not hmap, "inverse should return a new HeightMap" + print("PASS: test_inverse_returns_new") + + +def test_inverse_zero(): + """inverse() of 0.0 is 1.0""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.0) + result = hmap.inverse() + + assert abs(result[5, 5] - 1.0) < 0.001, f"Expected 1.0, got {result[5, 5]}" + print("PASS: test_inverse_zero") + + +def test_inverse_one(): + """inverse() of 1.0 is 0.0""" + hmap = mcrfpy.HeightMap((10, 10), fill=1.0) + result = hmap.inverse() + + assert abs(result[5, 5]) < 0.001, f"Expected 0.0, got {result[5, 5]}" + print("PASS: test_inverse_one") + + +def test_inverse_half(): + """inverse() of 0.5 is 0.5""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.inverse() + + assert abs(result[5, 5] - 0.5) < 0.001, f"Expected 0.5, got {result[5, 5]}" + print("PASS: test_inverse_half") + + +def test_double_inverse(): + """double inverse() returns to original value""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.7) + result = hmap.inverse().inverse() + + assert abs(result[5, 5] - 0.7) < 0.001, f"Expected 0.7, got {result[5, 5]}" + print("PASS: test_double_inverse") + + +def test_size_preserved(): + """threshold operations preserve HeightMap size""" + hmap = mcrfpy.HeightMap((15, 20), fill=0.5) + + result1 = hmap.threshold((0.0, 1.0)) + result2 = hmap.threshold_binary((0.0, 1.0)) + result3 = hmap.inverse() + + assert result1.size == (15, 20), f"threshold size mismatch: {result1.size}" + assert result2.size == (15, 20), f"threshold_binary size mismatch: {result2.size}" + assert result3.size == (15, 20), f"inverse size mismatch: {result3.size}" + print("PASS: test_size_preserved") + + +def run_all_tests(): + """Run all tests""" + print("Running HeightMap threshold operation tests (#197)...") + print() + + test_threshold_basic() + test_threshold_preserves_original() + test_threshold_returns_new() + test_threshold_out_of_range() + test_threshold_preserves_values() + test_threshold_invalid_range() + test_threshold_accepts_list() + test_threshold_binary_basic() + test_threshold_binary_custom_value() + test_threshold_binary_out_of_range() + test_threshold_binary_preserves_original() + test_threshold_binary_invalid_range() + test_inverse_basic() + test_inverse_preserves_original() + test_inverse_returns_new() + test_inverse_zero() + test_inverse_one() + test_inverse_half() + test_double_inverse() + test_size_preserved() + + print() + print("All HeightMap threshold operation tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0)