Grid layers: add HeightMap-based procedural generation methods

TileLayer (closes #200):
- apply_threshold(source, range, tile): Set tile index where heightmap value is in range
- apply_ranges(source, ranges): Apply multiple tile assignments in one pass

ColorLayer (closes #201):
- apply_threshold(source, range, color): Set fixed color where value is in range
- apply_gradient(source, range, color_low, color_high): Interpolate colors based on value
- apply_ranges(source, ranges): Apply multiple color assignments (fixed or gradient)

All methods return self for chaining. HeightMap size must match layer dimensions.
Later ranges override earlier ones if overlapping. Cells not matching any range are unchanged.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-11 22:35:44 -05:00
commit a4b1ab7d68
4 changed files with 1165 additions and 0 deletions

View file

@ -5,8 +5,138 @@
#include "PyTexture.h" #include "PyTexture.h"
#include "PyFOV.h" #include "PyFOV.h"
#include "PyPositionHelper.h" #include "PyPositionHelper.h"
#include "PyHeightMap.h"
#include <sstream> #include <sstream>
// =============================================================================
// 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<sf::Uint8>(a.r + (b.r - a.r) * t),
static_cast<sf::Uint8>(a.g + (b.g - a.g) * t),
static_cast<sf::Uint8>(a.b + (b.b - a.b) * t),
static_cast<sf::Uint8>(a.a + (b.a - a.a) * t)
);
}
// ============================================================================= // =============================================================================
// GridLayer base class // GridLayer base class
// ============================================================================= // =============================================================================
@ -606,6 +736,53 @@ PyMethodDef PyGridLayerAPI::ColorLayer_methods[] = {
{"clear_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_clear_perspective, METH_NOARGS, {"clear_perspective", (PyCFunction)PyGridLayerAPI::ColorLayer_clear_perspective, METH_NOARGS,
"clear_perspective()\n\n" "clear_perspective()\n\n"
"Remove the perspective binding from this layer."}, "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} {NULL}
}; };
@ -1053,6 +1230,269 @@ PyObject* PyGridLayerAPI::ColorLayer_clear_perspective(PyColorLayerObject* self,
Py_RETURN_NONE; 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<char**>(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<char**>(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<ColorRange> 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) { PyObject* PyGridLayerAPI::ColorLayer_get_z_index(PyColorLayerObject* self, void* closure) {
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no 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" " pos (tuple): Top-left corner as (x, y)\n"
" size (tuple): Dimensions as (width, height)\n" " size (tuple): Dimensions as (width, height)\n"
" index (int): Tile index to fill with (-1 for no tile)"}, " 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} {NULL}
}; };
@ -1310,6 +1778,149 @@ PyObject* PyGridLayerAPI::TileLayer_fill_rect(PyTileLayerObject* self, PyObject*
Py_RETURN_NONE; 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<char**>(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<TileRange> 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) { PyObject* PyGridLayerAPI::TileLayer_get_z_index(PyTileLayerObject* self, void* closure) {
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); PyErr_SetString(PyExc_RuntimeError, "Layer has no data");

View file

@ -205,6 +205,9 @@ public:
static PyObject* ColorLayer_apply_perspective(PyColorLayerObject* self, PyObject* args, PyObject* kwds); static PyObject* ColorLayer_apply_perspective(PyColorLayerObject* self, PyObject* args, PyObject* kwds);
static PyObject* ColorLayer_update_perspective(PyColorLayerObject* self, PyObject* args); static PyObject* ColorLayer_update_perspective(PyColorLayerObject* self, PyObject* args);
static PyObject* ColorLayer_clear_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 PyObject* ColorLayer_get_z_index(PyColorLayerObject* self, void* closure);
static int ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* value, void* closure); static int ColorLayer_set_z_index(PyColorLayerObject* self, PyObject* value, void* closure);
static PyObject* ColorLayer_get_visible(PyColorLayerObject* self, 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_set(PyTileLayerObject* self, PyObject* args);
static PyObject* TileLayer_fill(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_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 PyObject* TileLayer_get_z_index(PyTileLayerObject* self, void* closure);
static int TileLayer_set_z_index(PyTileLayerObject* self, PyObject* value, void* closure); static int TileLayer_set_z_index(PyTileLayerObject* self, PyObject* value, void* closure);
static PyObject* TileLayer_get_visible(PyTileLayerObject* self, void* closure); static PyObject* TileLayer_get_visible(PyTileLayerObject* self, void* closure);

View file

@ -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)

View file

@ -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)