diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index f6c6f85..b807fa3 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -5,8 +5,138 @@ #include "PyTexture.h" #include "PyFOV.h" #include "PyPositionHelper.h" +#include "PyHeightMap.h" #include +// ============================================================================= +// HeightMap helper functions for layer operations +// ============================================================================= + +// Helper to parse a range tuple (min, max) and validate +static bool ParseRange(PyObject* range_obj, float* out_min, float* out_max, const char* arg_name) { + if (!PyTuple_Check(range_obj) && !PyList_Check(range_obj)) { + PyErr_Format(PyExc_TypeError, "%s must be a (min, max) tuple or list", arg_name); + return false; + } + + PyObject* seq = PySequence_Fast(range_obj, "range must be sequence"); + if (!seq) return false; + + if (PySequence_Fast_GET_SIZE(seq) != 2) { + Py_DECREF(seq); + PyErr_Format(PyExc_ValueError, "%s must have exactly 2 elements (min, max)", arg_name); + return false; + } + + *out_min = (float)PyFloat_AsDouble(PySequence_Fast_GET_ITEM(seq, 0)); + *out_max = (float)PyFloat_AsDouble(PySequence_Fast_GET_ITEM(seq, 1)); + Py_DECREF(seq); + + if (PyErr_Occurred()) return false; + + if (*out_min > *out_max) { + // Build error message manually since PyErr_Format has limited float support + char buf[256]; + snprintf(buf, sizeof(buf), "%s: min (%.3f) must be <= max (%.3f)", + arg_name, *out_min, *out_max); + PyErr_SetString(PyExc_ValueError, buf); + return false; + } + + return true; +} + +// Helper to validate HeightMap matches layer dimensions +static bool ValidateHeightMapSize(PyHeightMapObject* hmap, int grid_x, int grid_y) { + int hmap_width = hmap->heightmap->w; + int hmap_height = hmap->heightmap->h; + + if (hmap_width != grid_x || hmap_height != grid_y) { + PyErr_Format(PyExc_ValueError, + "HeightMap size (%d, %d) does not match layer size (%d, %d)", + hmap_width, hmap_height, grid_x, grid_y); + return false; + } + return true; +} + +// Helper to check if an object is a HeightMap (runtime lookup to avoid static type issues) +static bool IsHeightMapObject(PyObject* obj, PyHeightMapObject** out_hmap) { + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return false; + + auto* heightmap_type = PyObject_GetAttrString(mcrfpy_module, "HeightMap"); + Py_DECREF(mcrfpy_module); + if (!heightmap_type) return false; + + bool result = PyObject_IsInstance(obj, heightmap_type); + Py_DECREF(heightmap_type); + + if (result && out_hmap) { + *out_hmap = (PyHeightMapObject*)obj; + } + return result; +} + +// Helper to parse a color from Python object +static bool ParseColorArg(PyObject* obj, sf::Color& out_color, const char* arg_name) { + if (!obj || obj == Py_None) { + PyErr_Format(PyExc_TypeError, "%s cannot be None", arg_name); + return false; + } + + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return false; + + auto* color_type = PyObject_GetAttrString(mcrfpy_module, "Color"); + Py_DECREF(mcrfpy_module); + if (!color_type) return false; + + if (PyObject_IsInstance(obj, color_type)) { + out_color = ((PyColorObject*)obj)->data; + Py_DECREF(color_type); + return true; + } + Py_DECREF(color_type); + + if (PyTuple_Check(obj) || PyList_Check(obj)) { + PyObject* seq = PySequence_Fast(obj, "color must be sequence"); + if (!seq) return false; + + Py_ssize_t len = PySequence_Fast_GET_SIZE(seq); + if (len < 3 || len > 4) { + Py_DECREF(seq); + PyErr_Format(PyExc_ValueError, "%s must be (r, g, b) or (r, g, b, a)", arg_name); + return false; + } + + int r = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 0)); + int g = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 1)); + int b = (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 2)); + int a = (len == 4) ? (int)PyLong_AsLong(PySequence_Fast_GET_ITEM(seq, 3)) : 255; + Py_DECREF(seq); + + if (PyErr_Occurred()) return false; + + out_color = sf::Color(r, g, b, a); + return true; + } + + PyErr_Format(PyExc_TypeError, "%s must be a Color or (r, g, b[, a]) tuple", arg_name); + return false; +} + +// Interpolate between two colors +static sf::Color LerpColor(const sf::Color& a, const sf::Color& b, float t) { + t = std::max(0.0f, std::min(1.0f, t)); // Clamp t to [0, 1] + return sf::Color( + static_cast(a.r + (b.r - a.r) * t), + static_cast(a.g + (b.g - a.g) * t), + static_cast(a.b + (b.b - a.b) * t), + static_cast(a.a + (b.a - a.a) * t) + ); +} + // ============================================================================= // GridLayer base class // ============================================================================= @@ -606,6 +736,53 @@ PyMethodDef PyGridLayerAPI::ColorLayer_methods[] = { {"clear_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_clear_perspective, METH_NOARGS, "clear_perspective()\n\n" "Remove the perspective binding from this layer."}, + {"apply_threshold", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_hmap_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source, range, color) -> ColorLayer\n\n" + "Set fixed color for cells where HeightMap value is within range.\n\n" + "Args:\n" + " source (HeightMap): Source heightmap (must match layer dimensions)\n" + " range (tuple): Value range as (min, max) inclusive\n" + " color: Color or (r, g, b[, a]) tuple to set for cells in range\n\n" + "Returns:\n" + " self for method chaining\n\n" + "Example:\n" + " layer.apply_threshold(terrain, (0.0, 0.3), (0, 0, 180)) # Blue for water"}, + {"apply_gradient", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_gradient, METH_VARARGS | METH_KEYWORDS, + "apply_gradient(source, range, color_low, color_high) -> ColorLayer\n\n" + "Interpolate between colors based on HeightMap value within range.\n\n" + "Args:\n" + " source (HeightMap): Source heightmap (must match layer dimensions)\n" + " range (tuple): Value range as (min, max) inclusive\n" + " color_low: Color at range minimum\n" + " color_high: Color at range maximum\n\n" + "Returns:\n" + " self for method chaining\n\n" + "Note:\n" + " Uses the original HeightMap value for interpolation, not binary.\n" + " This allows smooth color transitions within a value range.\n\n" + "Example:\n" + " layer.apply_gradient(terrain, (0.3, 0.7),\n" + " (50, 120, 50), # Dark green at 0.3\n" + " (100, 200, 100)) # Light green at 0.7"}, + {"apply_ranges", (PyCFunction)PyGridLayerAPI::ColorLayer_apply_ranges, METH_VARARGS, + "apply_ranges(source, ranges) -> ColorLayer\n\n" + "Apply multiple color assignments in a single pass.\n\n" + "Args:\n" + " source (HeightMap): Source heightmap (must match layer dimensions)\n" + " ranges (list): List of range specifications. Each entry is:\n" + " ((min, max), (r, g, b[, a])) - for fixed color\n" + " ((min, max), ((r1, g1, b1[, a1]), (r2, g2, b2[, a2]))) - for gradient\n\n" + "Returns:\n" + " self for method chaining\n\n" + "Note:\n" + " Later ranges override earlier ones if overlapping.\n" + " Cells not matching any range are left unchanged.\n\n" + "Example:\n" + " layer.apply_ranges(terrain, [\n" + " ((0.0, 0.3), (0, 0, 180)), # Fixed blue\n" + " ((0.3, 0.7), ((50, 120, 50), (100, 200, 100))), # Gradient\n" + " ((0.7, 1.0), ((100, 100, 100), (255, 255, 255))), # Gradient\n" + " ])"}, {NULL} }; @@ -1053,6 +1230,269 @@ PyObject* PyGridLayerAPI::ColorLayer_clear_perspective(PyColorLayerObject* self, Py_RETURN_NONE; } +PyObject* PyGridLayerAPI::ColorLayer_apply_hmap_threshold(PyColorLayerObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"source", "range", "color", NULL}; + PyObject* source_obj; + PyObject* range_obj; + PyObject* color_obj; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOO", const_cast(kwlist), + &source_obj, &range_obj, &color_obj)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Validate source is a HeightMap + PyHeightMapObject* hmap; + if (!IsHeightMapObject(source_obj, &hmap)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return NULL; + } + + if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) { + return NULL; + } + + // Parse range + float range_min, range_max; + if (!ParseRange(range_obj, &range_min, &range_max, "range")) { + return NULL; + } + + // Parse color + sf::Color color; + if (!ParseColorArg(color_obj, color, "color")) { + return NULL; + } + + // Apply threshold + int width = self->data->grid_x; + int height = self->data->grid_y; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + if (value >= range_min && value <= range_max) { + self->data->at(x, y) = color; + } + } + } + + self->data->markDirty(); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +PyObject* PyGridLayerAPI::ColorLayer_apply_gradient(PyColorLayerObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"source", "range", "color_low", "color_high", NULL}; + PyObject* source_obj; + PyObject* range_obj; + PyObject* color_low_obj; + PyObject* color_high_obj; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOOO", const_cast(kwlist), + &source_obj, &range_obj, &color_low_obj, &color_high_obj)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Validate source is a HeightMap + PyHeightMapObject* hmap; + if (!IsHeightMapObject(source_obj, &hmap)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return NULL; + } + + if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) { + return NULL; + } + + // Parse range + float range_min, range_max; + if (!ParseRange(range_obj, &range_min, &range_max, "range")) { + return NULL; + } + + // Parse colors + sf::Color color_low, color_high; + if (!ParseColorArg(color_low_obj, color_low, "color_low")) { + return NULL; + } + if (!ParseColorArg(color_high_obj, color_high, "color_high")) { + return NULL; + } + + // Apply gradient + int width = self->data->grid_x; + int height = self->data->grid_y; + float range_span = range_max - range_min; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + if (value >= range_min && value <= range_max) { + // Normalize value within range for interpolation + float t = (range_span > 0.0f) ? (value - range_min) / range_span : 0.0f; + self->data->at(x, y) = LerpColor(color_low, color_high, t); + } + } + } + + self->data->markDirty(); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +PyObject* PyGridLayerAPI::ColorLayer_apply_ranges(PyColorLayerObject* self, PyObject* args) { + PyObject* source_obj; + PyObject* ranges_obj; + + if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Validate source is a HeightMap + PyHeightMapObject* hmap; + if (!IsHeightMapObject(source_obj, &hmap)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return NULL; + } + + if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) { + return NULL; + } + + // Validate ranges is a list + if (!PyList_Check(ranges_obj)) { + PyErr_SetString(PyExc_TypeError, "ranges must be a list"); + return NULL; + } + + // Pre-parse all ranges for validation + // Each range can be: + // ((min, max), (r, g, b[, a])) - fixed color + // ((min, max), ((r1, g1, b1[, a1]), (r2, g2, b2[, a2]))) - gradient + struct ColorRange { + float min_val, max_val; + sf::Color color_low; + sf::Color color_high; + bool is_gradient; + }; + std::vector ranges; + + Py_ssize_t n_ranges = PyList_Size(ranges_obj); + for (Py_ssize_t i = 0; i < n_ranges; ++i) { + PyObject* item = PyList_GetItem(ranges_obj, i); + + if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] must be a ((min, max), color) tuple", i); + return NULL; + } + + PyObject* range_tuple = PyTuple_GetItem(item, 0); + PyObject* color_spec = PyTuple_GetItem(item, 1); + + float min_val, max_val; + char range_name[32]; + snprintf(range_name, sizeof(range_name), "ranges[%zd] range", i); + if (!ParseRange(range_tuple, &min_val, &max_val, range_name)) { + return NULL; + } + + ColorRange cr; + cr.min_val = min_val; + cr.max_val = max_val; + + // Determine if this is a gradient (tuple of 2 tuples) or fixed color + // Check if color_spec is a tuple of 2 elements where each element is also a sequence + bool is_gradient = false; + if (PyTuple_Check(color_spec) && PyTuple_Size(color_spec) == 2) { + PyObject* first = PyTuple_GetItem(color_spec, 0); + PyObject* second = PyTuple_GetItem(color_spec, 1); + // If both elements are tuples/lists (not ints), it's a gradient + if ((PyTuple_Check(first) || PyList_Check(first)) && + (PyTuple_Check(second) || PyList_Check(second))) { + is_gradient = true; + } + } + + cr.is_gradient = is_gradient; + + if (is_gradient) { + // Parse as gradient: ((r1,g1,b1), (r2,g2,b2)) + PyObject* color_low_obj = PyTuple_GetItem(color_spec, 0); + PyObject* color_high_obj = PyTuple_GetItem(color_spec, 1); + + char color_name[48]; + snprintf(color_name, sizeof(color_name), "ranges[%zd] color_low", i); + if (!ParseColorArg(color_low_obj, cr.color_low, color_name)) { + return NULL; + } + snprintf(color_name, sizeof(color_name), "ranges[%zd] color_high", i); + if (!ParseColorArg(color_high_obj, cr.color_high, color_name)) { + return NULL; + } + } else { + // Parse as fixed color + char color_name[48]; + snprintf(color_name, sizeof(color_name), "ranges[%zd] color", i); + if (!ParseColorArg(color_spec, cr.color_low, color_name)) { + return NULL; + } + cr.color_high = cr.color_low; // Not used, but set for consistency + } + + ranges.push_back(cr); + } + + // Apply all ranges in order (later ranges override) + int width = self->data->grid_x; + int height = self->data->grid_y; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + + // Check ranges in order, last match wins + for (const auto& cr : ranges) { + if (value >= cr.min_val && value <= cr.max_val) { + if (cr.is_gradient) { + float range_span = cr.max_val - cr.min_val; + float t = (range_span > 0.0f) ? (value - cr.min_val) / range_span : 0.0f; + self->data->at(x, y) = LerpColor(cr.color_low, cr.color_high, t); + } else { + self->data->at(x, y) = cr.color_low; + } + } + } + } + } + + self->data->markDirty(); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + PyObject* PyGridLayerAPI::ColorLayer_get_z_index(PyColorLayerObject* self, void* closure) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); @@ -1138,6 +1578,34 @@ PyMethodDef PyGridLayerAPI::TileLayer_methods[] = { " pos (tuple): Top-left corner as (x, y)\n" " size (tuple): Dimensions as (width, height)\n" " index (int): Tile index to fill with (-1 for no tile)"}, + {"apply_threshold", (PyCFunction)PyGridLayerAPI::TileLayer_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source, range, tile) -> TileLayer\n\n" + "Set tile index for cells where HeightMap value is within range.\n\n" + "Args:\n" + " source (HeightMap): Source heightmap (must match layer dimensions)\n" + " range (tuple): Value range as (min, max) inclusive\n" + " tile (int): Tile index to set for cells in range\n\n" + "Returns:\n" + " self for method chaining\n\n" + "Example:\n" + " layer.apply_threshold(terrain, (0.0, 0.3), WATER_TILE)"}, + {"apply_ranges", (PyCFunction)PyGridLayerAPI::TileLayer_apply_ranges, METH_VARARGS, + "apply_ranges(source, ranges) -> TileLayer\n\n" + "Apply multiple tile assignments in a single pass.\n\n" + "Args:\n" + " source (HeightMap): Source heightmap (must match layer dimensions)\n" + " ranges (list): List of ((min, max), tile_index) tuples\n\n" + "Returns:\n" + " self for method chaining\n\n" + "Note:\n" + " Later ranges override earlier ones if overlapping.\n" + " Cells not matching any range are left unchanged.\n\n" + "Example:\n" + " layer.apply_ranges(terrain, [\n" + " ((0.0, 0.2), DEEP_WATER),\n" + " ((0.2, 0.3), SHALLOW_WATER),\n" + " ((0.3, 0.7), GRASS),\n" + " ])"}, {NULL} }; @@ -1310,6 +1778,149 @@ PyObject* PyGridLayerAPI::TileLayer_fill_rect(PyTileLayerObject* self, PyObject* Py_RETURN_NONE; } +PyObject* PyGridLayerAPI::TileLayer_apply_threshold(PyTileLayerObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"source", "range", "tile", NULL}; + PyObject* source_obj; + PyObject* range_obj; + int tile_index; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OOi", const_cast(kwlist), + &source_obj, &range_obj, &tile_index)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Validate source is a HeightMap + PyHeightMapObject* hmap; + if (!IsHeightMapObject(source_obj, &hmap)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return NULL; + } + + if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) { + return NULL; + } + + // Parse range + float range_min, range_max; + if (!ParseRange(range_obj, &range_min, &range_max, "range")) { + return NULL; + } + + // Apply threshold + int width = self->data->grid_x; + int height = self->data->grid_y; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + if (value >= range_min && value <= range_max) { + self->data->at(x, y) = tile_index; + } + } + } + + self->data->markDirty(); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +PyObject* PyGridLayerAPI::TileLayer_apply_ranges(PyTileLayerObject* self, PyObject* args) { + PyObject* source_obj; + PyObject* ranges_obj; + + if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Validate source is a HeightMap + PyHeightMapObject* hmap; + if (!IsHeightMapObject(source_obj, &hmap)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return NULL; + } + + if (!ValidateHeightMapSize(hmap, self->data->grid_x, self->data->grid_y)) { + return NULL; + } + + // Validate ranges is a list + if (!PyList_Check(ranges_obj)) { + PyErr_SetString(PyExc_TypeError, "ranges must be a list"); + return NULL; + } + + // Pre-parse all ranges for validation + struct TileRange { + float min_val, max_val; + int tile_index; + }; + std::vector ranges; + + Py_ssize_t n_ranges = PyList_Size(ranges_obj); + for (Py_ssize_t i = 0; i < n_ranges; ++i) { + PyObject* item = PyList_GetItem(ranges_obj, i); + + if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] must be a ((min, max), tile) tuple", i); + return NULL; + } + + PyObject* range_tuple = PyTuple_GetItem(item, 0); + PyObject* tile_obj = PyTuple_GetItem(item, 1); + + float min_val, max_val; + char range_name[32]; + snprintf(range_name, sizeof(range_name), "ranges[%zd] range", i); + if (!ParseRange(range_tuple, &min_val, &max_val, range_name)) { + return NULL; + } + + int tile_index = (int)PyLong_AsLong(tile_obj); + if (PyErr_Occurred()) { + PyErr_Format(PyExc_TypeError, "ranges[%zd] tile must be an integer", i); + return NULL; + } + + ranges.push_back({min_val, max_val, tile_index}); + } + + // Apply all ranges in order (later ranges override) + int width = self->data->grid_x; + int height = self->data->grid_y; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + + // Check ranges in order, last match wins + for (const auto& range : ranges) { + if (value >= range.min_val && value <= range.max_val) { + self->data->at(x, y) = range.tile_index; + } + } + } + } + + self->data->markDirty(); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + PyObject* PyGridLayerAPI::TileLayer_get_z_index(PyTileLayerObject* self, void* closure) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); diff --git a/src/GridLayers.h b/src/GridLayers.h index aeccdf5..c2c6fbf 100644 --- a/src/GridLayers.h +++ b/src/GridLayers.h @@ -205,6 +205,9 @@ public: static PyObject* ColorLayer_apply_perspective(PyColorLayerObject* self, PyObject* args, PyObject* kwds); static PyObject* ColorLayer_update_perspective(PyColorLayerObject* self, PyObject* args); static PyObject* ColorLayer_clear_perspective(PyColorLayerObject* self, PyObject* args); + static PyObject* ColorLayer_apply_hmap_threshold(PyColorLayerObject* self, PyObject* args, PyObject* kwds); + static PyObject* ColorLayer_apply_gradient(PyColorLayerObject* self, PyObject* args, PyObject* kwds); + static PyObject* ColorLayer_apply_ranges(PyColorLayerObject* self, PyObject* args); static PyObject* ColorLayer_get_z_index(PyColorLayerObject* self, void* closure); static int ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* value, void* closure); static PyObject* ColorLayer_get_visible(PyColorLayerObject* self, void* closure); @@ -218,6 +221,8 @@ public: static PyObject* TileLayer_set(PyTileLayerObject* self, PyObject* args); static PyObject* TileLayer_fill(PyTileLayerObject* self, PyObject* args); static PyObject* TileLayer_fill_rect(PyTileLayerObject* self, PyObject* args, PyObject* kwds); + static PyObject* TileLayer_apply_threshold(PyTileLayerObject* self, PyObject* args, PyObject* kwds); + static PyObject* TileLayer_apply_ranges(PyTileLayerObject* self, PyObject* args); static PyObject* TileLayer_get_z_index(PyTileLayerObject* self, void* closure); static int TileLayer_set_z_index(PyTileLayerObject* self, PyObject* value, void* closure); static PyObject* TileLayer_get_visible(PyTileLayerObject* self, void* closure); diff --git a/tests/unit/test_colorlayer_heightmap.py b/tests/unit/test_colorlayer_heightmap.py new file mode 100644 index 0000000..1ff2ffe --- /dev/null +++ b/tests/unit/test_colorlayer_heightmap.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Unit tests for ColorLayer HeightMap methods (#201) + +Tests ColorLayer.apply_threshold(), apply_gradient(), and apply_ranges() methods. +""" + +import sys +import mcrfpy + + +def test_apply_threshold_basic(): + """apply_threshold sets colors in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((0, 0, 0, 0)) # Clear all + + # Apply threshold - all cells should get blue + result = layer.apply_threshold(hmap, (0.4, 0.6), (0, 0, 255)) + + # Verify result is the layer (chaining) + assert result is layer, "apply_threshold should return self" + + # Verify color was set + c = layer.at(0, 0) + assert c.r == 0 and c.g == 0 and c.b == 255, f"Expected (0, 0, 255), got ({c.r}, {c.g}, {c.b})" + print("PASS: test_apply_threshold_basic") + + +def test_apply_threshold_with_alpha(): + """apply_threshold handles RGBA colors""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128)) + + c = layer.at(5, 5) + assert c.r == 100 and c.g == 150 and c.b == 200 and c.a == 128, \ + f"Expected (100, 150, 200, 128), got ({c.r}, {c.g}, {c.b}, {c.a})" + print("PASS: test_apply_threshold_with_alpha") + + +def test_apply_threshold_preserves_outside(): + """apply_threshold doesn't modify cells outside range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((255, 0, 0)) # Fill with red + + # Apply threshold for range that doesn't include 0.5 + layer.apply_threshold(hmap, (0.6, 1.0), (0, 0, 255)) + + # Should still be red + c = layer.at(0, 0) + assert c.r == 255 and c.g == 0 and c.b == 0, \ + f"Expected red, got ({c.r}, {c.g}, {c.b})" + print("PASS: test_apply_threshold_preserves_outside") + + +def test_apply_threshold_with_color_object(): + """apply_threshold accepts Color objects""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + color = mcrfpy.Color(50, 100, 150) + layer.apply_threshold(hmap, (0.0, 1.0), color) + + c = layer.at(0, 0) + assert c.r == 50 and c.g == 100 and c.b == 150 + print("PASS: test_apply_threshold_with_color_object") + + +def test_apply_threshold_size_mismatch(): + """apply_threshold rejects mismatched HeightMap size""" + hmap = mcrfpy.HeightMap((5, 5)) # Different size + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + try: + layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0)) + 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_gradient_basic(): + """apply_gradient interpolates colors""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + # Apply gradient from black to white + result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255)) + + assert result is layer, "apply_gradient should return self" + + # At 0.5 in range [0,1], should be gray (~127-128) + c = layer.at(0, 0) + assert 120 < c.r < 135, f"Expected ~127, got r={c.r}" + assert 120 < c.g < 135, f"Expected ~127, got g={c.g}" + assert 120 < c.b < 135, f"Expected ~127, got b={c.b}" + print("PASS: test_apply_gradient_basic") + + +def test_apply_gradient_full_range(): + """apply_gradient at range endpoints""" + # Test at minimum of range + hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0)) + + c = layer.at(0, 0) + # At t=0.0, should be color_low + assert c.r == 100 and c.g == 0 and c.b == 0, \ + f"Expected (100, 0, 0), got ({c.r}, {c.g}, {c.b})" + + # Test at maximum of range + hmap_high = mcrfpy.HeightMap((10, 10), fill=1.0) + layer.apply_gradient(hmap_high, (0.0, 1.0), (100, 0, 0), (200, 255, 0)) + + c = layer.at(0, 0) + # At t=1.0, should be color_high + assert c.r == 200 and c.g == 255 and c.b == 0, \ + f"Expected (200, 255, 0), got ({c.r}, {c.g}, {c.b})" + + print("PASS: test_apply_gradient_full_range") + + +def test_apply_gradient_preserves_outside(): + """apply_gradient doesn't modify cells outside range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((255, 0, 0)) # Fill with red + + # Apply gradient for range that doesn't include 0.5 + layer.apply_gradient(hmap, (0.6, 1.0), (0, 0, 0), (255, 255, 255)) + + # Should still be red + c = layer.at(0, 0) + assert c.r == 255 and c.g == 0 and c.b == 0 + print("PASS: test_apply_gradient_preserves_outside") + + +def test_apply_ranges_fixed_colors(): + """apply_ranges with fixed colors""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((0, 0, 0)) + + result = layer.apply_ranges(hmap, [ + ((0.0, 0.3), (255, 0, 0)), # Red, won't match + ((0.3, 0.7), (0, 255, 0)), # Green, will match + ((0.7, 1.0), (0, 0, 255)), # Blue, won't match + ]) + + assert result is layer, "apply_ranges should return self" + + c = layer.at(0, 0) + assert c.r == 0 and c.g == 255 and c.b == 0, \ + f"Expected green, got ({c.r}, {c.g}, {c.b})" + print("PASS: test_apply_ranges_fixed_colors") + + +def test_apply_ranges_gradient(): + """apply_ranges with gradient specification""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + # Gradient from (0,0,0) to (255,255,255) over range [0,1] + # At value 0.5, should be ~(127,127,127) + layer.apply_ranges(hmap, [ + ((0.0, 1.0), ((0, 0, 0), (255, 255, 255))), # Gradient + ]) + + c = layer.at(0, 0) + assert 120 < c.r < 135, f"Expected ~127, got r={c.r}" + print("PASS: test_apply_ranges_gradient") + + +def test_apply_ranges_mixed(): + """apply_ranges with mixed fixed and gradient entries""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((0, 0, 0)) + + # Test mixed: gradient that includes 0.5 + layer.apply_ranges(hmap, [ + ((0.0, 0.3), (255, 0, 0)), # Fixed red + ((0.3, 0.7), ((50, 50, 50), (200, 200, 200))), # Gradient gray + ]) + + # 0.5 is at midpoint of [0.3, 0.7] range, so t = 0.5 + # Expected: 50 + (200-50)*0.5 = 125 + c = layer.at(0, 0) + assert 120 < c.r < 130, f"Expected ~125, got r={c.r}" + print("PASS: test_apply_ranges_mixed") + + +def test_apply_ranges_later_wins(): + """apply_ranges: later ranges override earlier ones""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + layer.apply_ranges(hmap, [ + ((0.0, 1.0), (255, 0, 0)), # Red, matches everything + ((0.4, 0.6), (0, 255, 0)), # Green, also matches 0.5 + ]) + + # Green should win (later entry) + c = layer.at(0, 0) + assert c.r == 0 and c.g == 255 and c.b == 0 + print("PASS: test_apply_ranges_later_wins") + + +def test_apply_ranges_no_match_unchanged(): + """apply_ranges leaves unmatched cells unchanged""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + layer.fill((128, 128, 128)) # Gray marker + + layer.apply_ranges(hmap, [ + ((0.0, 0.2), (255, 0, 0)), + ((0.8, 1.0), (0, 0, 255)), + ]) + + # Should still be gray + c = layer.at(0, 0) + assert c.r == 128 and c.g == 128 and c.b == 128 + print("PASS: test_apply_ranges_no_match_unchanged") + + +def test_apply_threshold_invalid_range(): + """apply_threshold rejects min > max""" + hmap = mcrfpy.HeightMap((10, 10)) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + try: + layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0)) + print("FAIL: test_apply_threshold_invalid_range - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "min" in str(e).lower() + + print("PASS: test_apply_threshold_invalid_range") + + +def test_apply_gradient_narrow_range(): + """apply_gradient handles narrow value ranges correctly""" + # Use a value exactly at the range + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('color', z_index=0) + + # Apply gradient over exact value (min == max) + layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255)) + + # When range_span is 0, t should be 0, so color_low + c = layer.at(0, 0) + assert c.r == 0 and c.g == 0 and c.b == 0, \ + f"Expected black at zero-width range, got ({c.r}, {c.g}, {c.b})" + print("PASS: test_apply_gradient_narrow_range") + + +def run_all_tests(): + """Run all tests""" + print("Running ColorLayer HeightMap method tests (#201)...") + print() + + test_apply_threshold_basic() + test_apply_threshold_with_alpha() + test_apply_threshold_preserves_outside() + test_apply_threshold_with_color_object() + test_apply_threshold_size_mismatch() + test_apply_gradient_basic() + test_apply_gradient_full_range() + test_apply_gradient_preserves_outside() + test_apply_ranges_fixed_colors() + test_apply_ranges_gradient() + test_apply_ranges_mixed() + test_apply_ranges_later_wins() + test_apply_ranges_no_match_unchanged() + test_apply_threshold_invalid_range() + test_apply_gradient_narrow_range() + + print() + print("All ColorLayer HeightMap method tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0) diff --git a/tests/unit/test_tilelayer_heightmap.py b/tests/unit/test_tilelayer_heightmap.py new file mode 100644 index 0000000..0e202ba --- /dev/null +++ b/tests/unit/test_tilelayer_heightmap.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Unit tests for TileLayer HeightMap methods (#200) + +Tests TileLayer.apply_threshold() and TileLayer.apply_ranges() methods. +""" + +import sys +import mcrfpy + + +def test_apply_threshold_basic(): + """apply_threshold sets tiles in range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + # Create a grid and get a tile layer + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) # Clear all tiles + + # Apply threshold - all cells should get tile 5 + result = layer.apply_threshold(hmap, (0.4, 0.6), 5) + + # Verify result is the layer (chaining) + assert result is layer, "apply_threshold should return self" + + # Verify tiles were set + assert layer.at(0, 0) == 5, f"Expected tile 5, got {layer.at(0, 0)}" + assert layer.at(5, 5) == 5, f"Expected tile 5, got {layer.at(5, 5)}" + print("PASS: test_apply_threshold_basic") + + +def test_apply_threshold_partial(): + """apply_threshold only affects cells in range""" + hmap = mcrfpy.HeightMap((10, 10)) + # Fill with different values in different areas + hmap.fill(0.0) # Start with 0 + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) + + # Apply threshold for range that doesn't match (0.5-1.0 when values are 0.0) + layer.apply_threshold(hmap, (0.5, 1.0), 10) + + # Should still be -1 since 0.0 is not in [0.5, 1.0] + assert layer.at(0, 0) == -1, f"Expected -1, got {layer.at(0, 0)}" + print("PASS: test_apply_threshold_partial") + + +def test_apply_threshold_preserves_outside(): + """apply_threshold doesn't modify cells outside range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(99) # Fill with marker value + + # Apply threshold for range that doesn't include 0.5 + layer.apply_threshold(hmap, (0.6, 1.0), 10) + + # Should still be 99 + assert layer.at(0, 0) == 99, f"Expected 99, got {layer.at(0, 0)}" + print("PASS: test_apply_threshold_preserves_outside") + + +def test_apply_threshold_invalid_range(): + """apply_threshold rejects min > max""" + hmap = mcrfpy.HeightMap((10, 10)) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + + try: + layer.apply_threshold(hmap, (1.0, 0.0), 5) # min > max + print("FAIL: test_apply_threshold_invalid_range - should have raised ValueError") + sys.exit(1) + except ValueError as e: + assert "min" in str(e).lower() + + print("PASS: test_apply_threshold_invalid_range") + + +def test_apply_threshold_size_mismatch(): + """apply_threshold rejects mismatched HeightMap size""" + hmap = mcrfpy.HeightMap((5, 5)) # Different size + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + + try: + layer.apply_threshold(hmap, (0.0, 1.0), 5) + 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_ranges_basic(): + """apply_ranges sets multiple tile ranges""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) + + # Apply ranges - 0.5 falls in the second range + result = layer.apply_ranges(hmap, [ + ((0.0, 0.3), 1), # Won't match + ((0.3, 0.7), 2), # Will match (0.5 is in here) + ((0.7, 1.0), 3), # Won't match + ]) + + assert result is layer, "apply_ranges should return self" + assert layer.at(0, 0) == 2, f"Expected tile 2, got {layer.at(0, 0)}" + print("PASS: test_apply_ranges_basic") + + +def test_apply_ranges_later_wins(): + """apply_ranges: later ranges override earlier ones""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) + + # Apply overlapping ranges - later should win + layer.apply_ranges(hmap, [ + ((0.0, 1.0), 10), # Matches everything + ((0.4, 0.6), 20), # Also matches 0.5, comes later + ]) + + # Later range (20) should win + assert layer.at(0, 0) == 20, f"Expected tile 20, got {layer.at(0, 0)}" + print("PASS: test_apply_ranges_later_wins") + + +def test_apply_ranges_no_match_unchanged(): + """apply_ranges leaves unmatched cells unchanged""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(99) + + # Apply ranges that don't match 0.5 + layer.apply_ranges(hmap, [ + ((0.0, 0.2), 1), + ((0.8, 1.0), 2), + ]) + + # Should still be 99 + assert layer.at(0, 0) == 99, f"Expected 99, got {layer.at(0, 0)}" + print("PASS: test_apply_ranges_no_match_unchanged") + + +def test_apply_ranges_invalid_format(): + """apply_ranges rejects invalid range format""" + hmap = mcrfpy.HeightMap((10, 10)) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + + # Missing tile index + try: + layer.apply_ranges(hmap, [((0.0, 1.0),)]) # Tuple with only one element + print("FAIL: test_apply_ranges_invalid_format - should have raised TypeError") + sys.exit(1) + except TypeError: + pass + + print("PASS: test_apply_ranges_invalid_format") + + +def test_apply_threshold_boundary(): + """apply_threshold includes boundary values""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) + + # Range includes 0.5 exactly + layer.apply_threshold(hmap, (0.5, 0.5), 7) + + assert layer.at(0, 0) == 7, f"Expected 7, got {layer.at(0, 0)}" + print("PASS: test_apply_threshold_boundary") + + +def test_apply_threshold_accepts_list(): + """apply_threshold accepts list as range""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + + grid = mcrfpy.Grid(grid_size=(10, 10)) + layer = grid.add_layer('tile', z_index=0) + layer.fill(-1) + + # Use list instead of tuple + layer.apply_threshold(hmap, [0.4, 0.6], 5) + + assert layer.at(0, 0) == 5 + print("PASS: test_apply_threshold_accepts_list") + + +def run_all_tests(): + """Run all tests""" + print("Running TileLayer HeightMap method tests (#200)...") + print() + + test_apply_threshold_basic() + test_apply_threshold_partial() + test_apply_threshold_preserves_outside() + test_apply_threshold_invalid_range() + test_apply_threshold_size_mismatch() + test_apply_ranges_basic() + test_apply_ranges_later_wins() + test_apply_ranges_no_match_unchanged() + test_apply_ranges_invalid_format() + test_apply_threshold_boundary() + test_apply_threshold_accepts_list() + + print() + print("All TileLayer HeightMap method tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0)