diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp index 42b4246..f25ff95 100644 --- a/src/PyHeightMap.cpp +++ b/src/PyHeightMap.cpp @@ -8,6 +8,210 @@ #include // For random seed handling #include // For time-based seeds #include // For BSP node collection +#include // For std::min + +// ============================================================================= +// Region Parameter System - standardized handling of pos, source_pos, size +// ============================================================================= + +// Region parameters for HeightMap operations +struct HMRegion { + // Validated region coordinates + int dest_x, dest_y; // Destination origin in self + int src_x, src_y; // Source origin (for binary ops, 0 for scalar) + int width, height; // Region dimensions + + // Full heightmap dimensions (for iteration) + int dest_w, dest_h; + int src_w, src_h; + + // Direct indexing helpers + inline int dest_idx(int x, int y) const { + return (dest_y + y) * dest_w + (dest_x + x); + } + inline int src_idx(int x, int y) const { + return (src_y + y) * src_w + (src_x + x); + } +}; + +// Parse optional position tuple, returning (0, 0) if None/not provided +static bool parseOptionalPos(PyObject* pos_obj, int* out_x, int* out_y, const char* param_name) { + *out_x = 0; + *out_y = 0; + + if (!pos_obj || pos_obj == Py_None) { + return true; // Default to (0, 0) + } + + // Try to parse as tuple/list of 2 ints + if (PyTuple_Check(pos_obj) && PyTuple_Size(pos_obj) == 2) { + PyObject* x_obj = PyTuple_GetItem(pos_obj, 0); + PyObject* y_obj = PyTuple_GetItem(pos_obj, 1); + if (PyLong_Check(x_obj) && PyLong_Check(y_obj)) { + *out_x = (int)PyLong_AsLong(x_obj); + *out_y = (int)PyLong_AsLong(y_obj); + return true; + } + } else if (PyList_Check(pos_obj) && PyList_Size(pos_obj) == 2) { + PyObject* x_obj = PyList_GetItem(pos_obj, 0); + PyObject* y_obj = PyList_GetItem(pos_obj, 1); + if (PyLong_Check(x_obj) && PyLong_Check(y_obj)) { + *out_x = (int)PyLong_AsLong(x_obj); + *out_y = (int)PyLong_AsLong(y_obj); + return true; + } + } + + // Try PyPositionHelper for Vector support + if (PyPosition_FromObjectInt(pos_obj, out_x, out_y)) { + return true; + } + + // Clear any error from PyPosition_FromObjectInt and set our own + PyErr_Clear(); + PyErr_Format(PyExc_TypeError, "%s must be a (x, y) tuple, list, or Vector", param_name); + return false; +} + +// Parse optional size tuple +static bool parseOptionalSize(PyObject* size_obj, int* out_w, int* out_h, const char* param_name) { + *out_w = -1; // -1 means "not specified" + *out_h = -1; + + if (!size_obj || size_obj == Py_None) { + return true; + } + + if (PyTuple_Check(size_obj) && PyTuple_Size(size_obj) == 2) { + PyObject* w_obj = PyTuple_GetItem(size_obj, 0); + PyObject* h_obj = PyTuple_GetItem(size_obj, 1); + if (PyLong_Check(w_obj) && PyLong_Check(h_obj)) { + *out_w = (int)PyLong_AsLong(w_obj); + *out_h = (int)PyLong_AsLong(h_obj); + if (*out_w <= 0 || *out_h <= 0) { + PyErr_Format(PyExc_ValueError, "%s dimensions must be positive", param_name); + return false; + } + return true; + } + } else if (PyList_Check(size_obj) && PyList_Size(size_obj) == 2) { + PyObject* w_obj = PyList_GetItem(size_obj, 0); + PyObject* h_obj = PyList_GetItem(size_obj, 1); + if (PyLong_Check(w_obj) && PyLong_Check(h_obj)) { + *out_w = (int)PyLong_AsLong(w_obj); + *out_h = (int)PyLong_AsLong(h_obj); + if (*out_w <= 0 || *out_h <= 0) { + PyErr_Format(PyExc_ValueError, "%s dimensions must be positive", param_name); + return false; + } + return true; + } + } + + PyErr_Format(PyExc_TypeError, "%s must be a (width, height) tuple or list", param_name); + return false; +} + +// Parse region parameters for binary operations (two heightmaps) +// Returns true on success, sets Python error and returns false on failure +static bool parseHMRegion( + TCOD_heightmap_t* dest, + TCOD_heightmap_t* src, // Can be nullptr for scalar operations + PyObject* pos, // (x, y) or None - destination position + PyObject* source_pos, // (x, y) or None - source position + PyObject* size, // (w, h) or None + HMRegion& out +) { + // Store full dimensions + out.dest_w = dest->w; + out.dest_h = dest->h; + out.src_w = src ? src->w : dest->w; + out.src_h = src ? src->h : dest->h; + + // Parse positions, default to (0, 0) + if (!parseOptionalPos(pos, &out.dest_x, &out.dest_y, "pos")) { + return false; + } + if (!parseOptionalPos(source_pos, &out.src_x, &out.src_y, "source_pos")) { + return false; + } + + // Validate positions are within bounds + if (out.dest_x < 0 || out.dest_y < 0) { + PyErr_SetString(PyExc_ValueError, "pos coordinates cannot be negative"); + return false; + } + if (out.dest_x >= out.dest_w || out.dest_y >= out.dest_h) { + PyErr_Format(PyExc_ValueError, + "pos (%d, %d) is out of bounds for destination of size (%d, %d)", + out.dest_x, out.dest_y, out.dest_w, out.dest_h); + return false; + } + if (src && (out.src_x < 0 || out.src_y < 0)) { + PyErr_SetString(PyExc_ValueError, "source_pos coordinates cannot be negative"); + return false; + } + if (src && (out.src_x >= out.src_w || out.src_y >= out.src_h)) { + PyErr_Format(PyExc_ValueError, + "source_pos (%d, %d) is out of bounds for source of size (%d, %d)", + out.src_x, out.src_y, out.src_w, out.src_h); + return false; + } + + // Calculate remaining space from each position + int dest_remaining_w = out.dest_w - out.dest_x; + int dest_remaining_h = out.dest_h - out.dest_y; + int src_remaining_w = src ? (out.src_w - out.src_x) : dest_remaining_w; + int src_remaining_h = src ? (out.src_h - out.src_y) : dest_remaining_h; + + // Parse or infer size + int requested_w = -1, requested_h = -1; + if (!parseOptionalSize(size, &requested_w, &requested_h, "size")) { + return false; + } + + if (requested_w > 0 && requested_h > 0) { + // Explicit size - must fit in both + if (requested_w > dest_remaining_w || requested_h > dest_remaining_h) { + PyErr_Format(PyExc_ValueError, + "size (%d, %d) exceeds available space in destination (%d, %d) from pos (%d, %d)", + requested_w, requested_h, dest_remaining_w, dest_remaining_h, + out.dest_x, out.dest_y); + return false; + } + if (src && (requested_w > src_remaining_w || requested_h > src_remaining_h)) { + PyErr_Format(PyExc_ValueError, + "size (%d, %d) exceeds available space in source (%d, %d) from source_pos (%d, %d)", + requested_w, requested_h, src_remaining_w, src_remaining_h, + out.src_x, out.src_y); + return false; + } + out.width = requested_w; + out.height = requested_h; + } else { + // Infer size: smaller of remaining space in each + out.width = std::min(dest_remaining_w, src_remaining_w); + out.height = std::min(dest_remaining_h, src_remaining_h); + } + + // Final validation: non-zero region + if (out.width <= 0 || out.height <= 0) { + PyErr_SetString(PyExc_ValueError, "computed region has zero size"); + return false; + } + + return true; +} + +// Parse region parameters for scalar operations (just destination region) +static bool parseHMRegionScalar( + TCOD_heightmap_t* dest, + PyObject* pos, + PyObject* size, + HMRegion& out +) { + return parseHMRegion(dest, nullptr, pos, nullptr, size, out); +} // Property definitions PyGetSetDef PyHeightMap::getsetters[] = { @@ -25,12 +229,14 @@ PyMappingMethods PyHeightMap::mapping_methods = { // Method definitions PyMethodDef PyHeightMap::methods[] = { - {"fill", (PyCFunction)PyHeightMap::fill, METH_VARARGS, + {"fill", (PyCFunction)PyHeightMap::fill, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, fill, - MCRF_SIG("(value: float)", "HeightMap"), - MCRF_DESC("Set all cells to the specified value."), + MCRF_SIG("(value: float, *, pos=None, size=None)", "HeightMap"), + MCRF_DESC("Set cells in region to the specified value."), MCRF_ARGS_START - MCRF_ARG("value", "The value to set for all cells") + MCRF_ARG("value", "The value to set") + MCRF_ARG("pos", "Region start (x, y) in destination (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) to fill (default: remaining space)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, {"clear", (PyCFunction)PyHeightMap::clear, METH_NOARGS, @@ -39,38 +245,46 @@ PyMethodDef PyHeightMap::methods[] = { MCRF_DESC("Set all cells to 0.0. Equivalent to fill(0.0)."), MCRF_RETURNS("HeightMap: self, for method chaining") )}, - {"add_constant", (PyCFunction)PyHeightMap::add_constant, METH_VARARGS, + {"add_constant", (PyCFunction)PyHeightMap::add_constant, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, add_constant, - MCRF_SIG("(value: float)", "HeightMap"), - MCRF_DESC("Add a constant value to every cell."), + MCRF_SIG("(value: float, *, pos=None, size=None)", "HeightMap"), + MCRF_DESC("Add a constant value to cells in region."), MCRF_ARGS_START MCRF_ARG("value", "The value to add to each cell") + MCRF_ARG("pos", "Region start (x, y) in destination (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: remaining space)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, - {"scale", (PyCFunction)PyHeightMap::scale, METH_VARARGS, + {"scale", (PyCFunction)PyHeightMap::scale, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, scale, - MCRF_SIG("(factor: float)", "HeightMap"), - MCRF_DESC("Multiply every cell by a factor."), + MCRF_SIG("(factor: float, *, pos=None, size=None)", "HeightMap"), + MCRF_DESC("Multiply cells in region by a factor."), MCRF_ARGS_START MCRF_ARG("factor", "The multiplier for each cell") + MCRF_ARG("pos", "Region start (x, y) in destination (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: remaining space)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, {"clamp", (PyCFunction)PyHeightMap::clamp, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, clamp, - MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), - MCRF_DESC("Clamp all values to the specified range."), + MCRF_SIG("(min: float = 0.0, max: float = 1.0, *, pos=None, size=None)", "HeightMap"), + MCRF_DESC("Clamp values in region to the specified range."), MCRF_ARGS_START MCRF_ARG("min", "Minimum value (default 0.0)") MCRF_ARG("max", "Maximum value (default 1.0)") + MCRF_ARG("pos", "Region start (x, y) in destination (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: remaining space)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, {"normalize", (PyCFunction)PyHeightMap::normalize, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, normalize, - MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), - MCRF_DESC("Linearly rescale values so the current minimum becomes min and current maximum becomes max."), + MCRF_SIG("(min: float = 0.0, max: float = 1.0, *, pos=None, size=None)", "HeightMap"), + MCRF_DESC("Linearly rescale values in region. Current min becomes new min, current max becomes new max."), MCRF_ARGS_START MCRF_ARG("min", "Target minimum value (default 0.0)") MCRF_ARG("max", "Target maximum value (default 1.0)") + MCRF_ARG("pos", "Region start (x, y) in destination (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: remaining space)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, // Query methods (#196) @@ -224,70 +438,84 @@ PyMethodDef PyHeightMap::methods[] = { MCRF_ARG("iterations", "Number of smoothing passes (default 1)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, - // Combination operations (#194) - {"add", (PyCFunction)PyHeightMap::add, METH_VARARGS, + // Combination operations (#194) - with region support + {"add", (PyCFunction)PyHeightMap::add, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, add, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Add another heightmap's values to this one cell-by-cell."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Add another heightmap's values to this one in the specified region."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions to add") + MCRF_ARG("other", "HeightMap to add values from") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"subtract", (PyCFunction)PyHeightMap::subtract, METH_VARARGS, + {"subtract", (PyCFunction)PyHeightMap::subtract, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, subtract, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Subtract another heightmap's values from this one cell-by-cell."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Subtract another heightmap's values from this one in the specified region."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions to subtract") + MCRF_ARG("other", "HeightMap to subtract values from") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"multiply", (PyCFunction)PyHeightMap::multiply, METH_VARARGS, + {"multiply", (PyCFunction)PyHeightMap::multiply, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, multiply, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Multiply this heightmap by another cell-by-cell (useful for masking)."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Multiply this heightmap by another in the specified region (useful for masking)."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions to multiply by") + MCRF_ARG("other", "HeightMap to multiply by") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"lerp", (PyCFunction)PyHeightMap::lerp, METH_VARARGS, + {"lerp", (PyCFunction)PyHeightMap::lerp, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, lerp, - MCRF_SIG("(other: HeightMap, t: float)", "HeightMap"), - MCRF_DESC("Linear interpolation between this and another heightmap."), + MCRF_SIG("(other: HeightMap, t: float, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Linear interpolation between this and another heightmap in the specified region."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions to interpolate towards") + MCRF_ARG("other", "HeightMap to interpolate towards") MCRF_ARG("t", "Interpolation factor (0.0 = this, 1.0 = other)") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"copy_from", (PyCFunction)PyHeightMap::copy_from, METH_VARARGS, + {"copy_from", (PyCFunction)PyHeightMap::copy_from, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, copy_from, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Copy all values from another heightmap."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Copy values from another heightmap into the specified region."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions to copy from") + MCRF_ARG("other", "HeightMap to copy from") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"max", (PyCFunction)PyHeightMap::hmap_max, METH_VARARGS, + {"max", (PyCFunction)PyHeightMap::hmap_max, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, max, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Set each cell to the maximum of this and another heightmap."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Set each cell in region to the maximum of this and another heightmap."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions") + MCRF_ARG("other", "HeightMap to compare with") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, - {"min", (PyCFunction)PyHeightMap::hmap_min, METH_VARARGS, + {"min", (PyCFunction)PyHeightMap::hmap_min, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, min, - MCRF_SIG("(other: HeightMap)", "HeightMap"), - MCRF_DESC("Set each cell to the minimum of this and another heightmap."), + MCRF_SIG("(other: HeightMap, *, pos=None, source_pos=None, size=None)", "HeightMap"), + MCRF_DESC("Set each cell in region to the minimum of this and another heightmap."), MCRF_ARGS_START - MCRF_ARG("other", "HeightMap with same dimensions") + MCRF_ARG("other", "HeightMap to compare with") + MCRF_ARG("pos", "Destination start (x, y) in self (default: (0, 0))") + MCRF_ARG("source_pos", "Source start (x, y) in other (default: (0, 0))") + MCRF_ARG("size", "Region (width, height) (default: max overlapping area)") MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_RAISES("ValueError", "Dimensions don't match") )}, // Direct source sampling (#209) {"add_noise", (PyCFunction)PyHeightMap::add_noise, METH_VARARGS | METH_KEYWORDS, @@ -320,11 +548,12 @@ PyMethodDef PyHeightMap::methods[] = { )}, {"add_bsp", (PyCFunction)PyHeightMap::add_bsp, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, add_bsp, - MCRF_SIG("(bsp: BSP, select: str = 'leaves', nodes: list = None, " + MCRF_SIG("(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, " "shrink: int = 0, value: float = 1.0)", "HeightMap"), MCRF_DESC("Add BSP node regions to heightmap. More efficient than creating intermediate HeightMap."), MCRF_ARGS_START MCRF_ARG("bsp", "BSP tree to sample from") + MCRF_ARG("pos", "Where BSP origin maps to in HeightMap (default: origin-relative like to_heightmap)") MCRF_ARG("select", "'leaves', 'all', or 'internal' (default: 'leaves')") MCRF_ARG("nodes", "Override: specific BSPNodes only (default: None)") MCRF_ARG("shrink", "Pixels to shrink from node bounds (default: 0)") @@ -333,11 +562,12 @@ PyMethodDef PyHeightMap::methods[] = { )}, {"multiply_bsp", (PyCFunction)PyHeightMap::multiply_bsp, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(HeightMap, multiply_bsp, - MCRF_SIG("(bsp: BSP, select: str = 'leaves', nodes: list = None, " + MCRF_SIG("(bsp: BSP, *, pos=None, select: str = 'leaves', nodes: list = None, " "shrink: int = 0, value: float = 1.0)", "HeightMap"), MCRF_DESC("Multiply by BSP regions. Effectively masks the heightmap to node interiors."), MCRF_ARGS_START MCRF_ARG("bsp", "BSP tree to sample from") + MCRF_ARG("pos", "Where BSP origin maps to in HeightMap (default: origin-relative like to_heightmap)") MCRF_ARG("select", "'leaves', 'all', or 'internal' (default: 'leaves')") MCRF_ARG("nodes", "Override: specific BSPNodes only (default: None)") MCRF_ARG("shrink", "Pixels to shrink from node bounds (default: 0)") @@ -448,11 +678,16 @@ PyObject* PyHeightMap::get_size(PyHeightMapObject* self, void* closure) return Py_BuildValue("(ii)", self->heightmap->w, self->heightmap->h); } -// Method: fill(value) -> HeightMap -PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args) +// Method: fill(value, *, pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"value", "pos", "size", nullptr}; float value; - if (!PyArg_ParseTuple(args, "f", &value)) { + PyObject* pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "f|OO", const_cast(kwlist), + &value, &pos, &size)) { return nullptr; } @@ -461,13 +696,19 @@ PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args) return nullptr; } - // Clear and then add the value (libtcod doesn't have a direct "set all" function) - TCOD_heightmap_clear(self->heightmap); - if (value != 0.0f) { - TCOD_heightmap_add(self->heightmap, value); + // Parse region parameters + HMRegion region; + if (!parseHMRegionScalar(self->heightmap, pos, size, region)) { + return nullptr; + } + + // Fill the region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] = value; + } } - // Return self for chaining Py_INCREF(self); return (PyObject*)self; } @@ -487,11 +728,16 @@ PyObject* PyHeightMap::clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)) return (PyObject*)self; } -// Method: add_constant(value) -> HeightMap -PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args) +// Method: add_constant(value, *, pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"value", "pos", "size", nullptr}; float value; - if (!PyArg_ParseTuple(args, "f", &value)) { + PyObject* pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "f|OO", const_cast(kwlist), + &value, &pos, &size)) { return nullptr; } @@ -500,18 +746,33 @@ PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args) return nullptr; } - TCOD_heightmap_add(self->heightmap, value); + // Parse region parameters + HMRegion region; + if (!parseHMRegionScalar(self->heightmap, pos, size, region)) { + return nullptr; + } + + // Add constant to region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] += value; + } + } - // Return self for chaining Py_INCREF(self); return (PyObject*)self; } -// Method: scale(factor) -> HeightMap -PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args) +// Method: scale(factor, *, pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"factor", "pos", "size", nullptr}; float factor; - if (!PyArg_ParseTuple(args, "f", &factor)) { + PyObject* pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "f|OO", const_cast(kwlist), + &factor, &pos, &size)) { return nullptr; } @@ -520,22 +781,34 @@ PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args) return nullptr; } - TCOD_heightmap_scale(self->heightmap, factor); + // Parse region parameters + HMRegion region; + if (!parseHMRegionScalar(self->heightmap, pos, size, region)) { + return nullptr; + } + + // Scale region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] *= factor; + } + } - // Return self for chaining Py_INCREF(self); return (PyObject*)self; } -// Method: clamp(min=0.0, max=1.0) -> HeightMap +// Method: clamp(min=0.0, max=1.0, *, pos=None, size=None) -> HeightMap PyObject* PyHeightMap::clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"min", "max", nullptr}; + static const char* kwlist[] = {"min", "max", "pos", "size", nullptr}; float min_val = 0.0f; float max_val = 1.0f; + PyObject* pos = nullptr; + PyObject* size = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), - &min_val, &max_val)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ffOO", const_cast(kwlist), + &min_val, &max_val, &pos, &size)) { return nullptr; } @@ -549,22 +822,36 @@ PyObject* PyHeightMap::clamp(PyHeightMapObject* self, PyObject* args, PyObject* return nullptr; } - TCOD_heightmap_clamp(self->heightmap, min_val, max_val); + // Parse region parameters + HMRegion region; + if (!parseHMRegionScalar(self->heightmap, pos, size, region)) { + return nullptr; + } + + // Clamp values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float& val = self->heightmap->values[region.dest_idx(x, y)]; + if (val < min_val) val = min_val; + else if (val > max_val) val = max_val; + } + } - // Return self for chaining Py_INCREF(self); return (PyObject*)self; } -// Method: normalize(min=0.0, max=1.0) -> HeightMap +// Method: normalize(min=0.0, max=1.0, *, pos=None, size=None) -> HeightMap PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"min", "max", nullptr}; - float min_val = 0.0f; - float max_val = 1.0f; + static const char* kwlist[] = {"min", "max", "pos", "size", nullptr}; + float target_min = 0.0f; + float target_max = 1.0f; + PyObject* pos = nullptr; + PyObject* size = nullptr; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), - &min_val, &max_val)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ffOO", const_cast(kwlist), + &target_min, &target_max, &pos, &size)) { return nullptr; } @@ -573,14 +860,48 @@ PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObje return nullptr; } - if (min_val > max_val) { + if (target_min > target_max) { PyErr_SetString(PyExc_ValueError, "min must be less than or equal to max"); return nullptr; } - TCOD_heightmap_normalize(self->heightmap, min_val, max_val); + // Parse region parameters + HMRegion region; + if (!parseHMRegionScalar(self->heightmap, pos, size, region)) { + return nullptr; + } + + // Find min/max in region + float current_min = self->heightmap->values[region.dest_idx(0, 0)]; + float current_max = current_min; + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float val = self->heightmap->values[region.dest_idx(x, y)]; + if (val < current_min) current_min = val; + if (val > current_max) current_max = val; + } + } + + // Normalize values in region + float range = current_max - current_min; + if (range > 0.0f) { + float scale = (target_max - target_min) / range; + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float& val = self->heightmap->values[region.dest_idx(x, y)]; + val = target_min + (val - current_min) * scale; + } + } + } else { + // All values are the same - set to midpoint + float mid = (target_min + target_max) / 2.0f; + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] = mid; + } + } + } - // Return self for chaining Py_INCREF(self); return (PyObject*)self; } @@ -1299,17 +1620,12 @@ PyObject* PyHeightMap::smooth(PyHeightMapObject* self, PyObject* args, PyObject* } // ============================================================================= -// Combination operations (#194) +// Combination operations (#194) - with region support // ============================================================================= -// Helper: Validate other HeightMap and check dimensions match -static PyHeightMapObject* validateOtherHeightMap(PyHeightMapObject* self, PyObject* args, const char* method_name) +// Helper: Validate other HeightMap (type check only, no dimension check) +static PyHeightMapObject* validateOtherHeightMapType(PyObject* other_obj, const char* method_name) { - PyObject* other_obj; - if (!PyArg_ParseTuple(args, "O", &other_obj)) { - return nullptr; - } - // Check that other is a HeightMap PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); if (!heightmap_type) { @@ -1330,123 +1646,47 @@ static PyHeightMapObject* validateOtherHeightMap(PyHeightMapObject* self, PyObje PyHeightMapObject* other = (PyHeightMapObject*)other_obj; - // Check both are initialized - if (!self->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } if (!other->heightmap) { PyErr_SetString(PyExc_RuntimeError, "Other HeightMap not initialized"); return nullptr; } - // Check dimensions match - if (self->heightmap->w != other->heightmap->w || - self->heightmap->h != other->heightmap->h) { - PyErr_Format(PyExc_ValueError, - "%s() requires HeightMaps with same dimensions: self is (%d, %d), other is (%d, %d)", - method_name, self->heightmap->w, self->heightmap->h, - other->heightmap->w, other->heightmap->h); - return nullptr; - } - return other; } -// Method: add(other) -> HeightMap -PyObject* PyHeightMap::add(PyHeightMapObject* self, PyObject* args) -{ - PyHeightMapObject* other = validateOtherHeightMap(self, args, "add"); - if (!other) return nullptr; - - // TCOD_heightmap_add_hm adds hm1 + hm2 into out - // We want self = self + other, so we can use self as out - TCOD_heightmap_add_hm(self->heightmap, other->heightmap, self->heightmap); - - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: subtract(other) -> HeightMap -PyObject* PyHeightMap::subtract(PyHeightMapObject* self, PyObject* args) -{ - PyHeightMapObject* other = validateOtherHeightMap(self, args, "subtract"); - if (!other) return nullptr; - - // No direct TCOD function - do cell-by-cell - for (int y = 0; y < self->heightmap->h; y++) { - for (int x = 0; x < self->heightmap->w; x++) { - float v1 = TCOD_heightmap_get_value(self->heightmap, x, y); - float v2 = TCOD_heightmap_get_value(other->heightmap, x, y); - TCOD_heightmap_set_value(self->heightmap, x, y, v1 - v2); - } - } - - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: multiply(other) -> HeightMap -PyObject* PyHeightMap::multiply(PyHeightMapObject* self, PyObject* args) -{ - PyHeightMapObject* other = validateOtherHeightMap(self, args, "multiply"); - if (!other) return nullptr; - - // TCOD_heightmap_multiply_hm multiplies hm1 * hm2 into out - TCOD_heightmap_multiply_hm(self->heightmap, other->heightmap, self->heightmap); - - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: lerp(other, t) -> HeightMap -PyObject* PyHeightMap::lerp(PyHeightMapObject* self, PyObject* args) +// Method: add(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::add(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; PyObject* other_obj; - float t; - if (!PyArg_ParseTuple(args, "Of", &other_obj, &t)) { + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { return nullptr; } - // Create args tuple with just the other object for validation - PyObject* other_args = PyTuple_Pack(1, other_obj); - PyHeightMapObject* other = validateOtherHeightMap(self, other_args, "lerp"); - Py_DECREF(other_args); + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "add"); if (!other) return nullptr; - // TCOD_heightmap_lerp_hm lerps hm1 and hm2 into out with coef - // When coef=0, out=hm1. When coef=1, out=hm2 - TCOD_heightmap_lerp_hm(self->heightmap, other->heightmap, self->heightmap, t); + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: copy_from(other) -> HeightMap -PyObject* PyHeightMap::copy_from(PyHeightMapObject* self, PyObject* args) -{ - PyHeightMapObject* other = validateOtherHeightMap(self, args, "copy_from"); - if (!other) return nullptr; - - // TCOD_heightmap_copy copies source to dest - TCOD_heightmap_copy(other->heightmap, self->heightmap); - - Py_INCREF(self); - return (PyObject*)self; -} - -// Method: max(other) -> HeightMap -PyObject* PyHeightMap::hmap_max(PyHeightMapObject* self, PyObject* args) -{ - PyHeightMapObject* other = validateOtherHeightMap(self, args, "max"); - if (!other) return nullptr; - - // No direct TCOD function - do cell-by-cell - for (int y = 0; y < self->heightmap->h; y++) { - for (int x = 0; x < self->heightmap->w; x++) { - float v1 = TCOD_heightmap_get_value(self->heightmap, x, y); - float v2 = TCOD_heightmap_get_value(other->heightmap, x, y); - TCOD_heightmap_set_value(self->heightmap, x, y, v1 > v2 ? v1 : v2); + // Add values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] += + other->heightmap->values[region.src_idx(x, y)]; } } @@ -1454,18 +1694,244 @@ PyObject* PyHeightMap::hmap_max(PyHeightMapObject* self, PyObject* args) return (PyObject*)self; } -// Method: min(other) -> HeightMap -PyObject* PyHeightMap::hmap_min(PyHeightMapObject* self, PyObject* args) +// Method: subtract(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::subtract(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { - PyHeightMapObject* other = validateOtherHeightMap(self, args, "min"); + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "subtract"); if (!other) return nullptr; - // No direct TCOD function - do cell-by-cell - for (int y = 0; y < self->heightmap->h; y++) { - for (int x = 0; x < self->heightmap->w; x++) { - float v1 = TCOD_heightmap_get_value(self->heightmap, x, y); - float v2 = TCOD_heightmap_get_value(other->heightmap, x, y); - TCOD_heightmap_set_value(self->heightmap, x, y, v1 < v2 ? v1 : v2); + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Subtract values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] -= + other->heightmap->values[region.src_idx(x, y)]; + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: multiply(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::multiply(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "multiply"); + if (!other) return nullptr; + + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Multiply values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] *= + other->heightmap->values[region.src_idx(x, y)]; + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: lerp(other, t, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::lerp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"other", "t", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + float t; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Of|OOO", const_cast(kwlist), + &other_obj, &t, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "lerp"); + if (!other) return nullptr; + + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Lerp values in region: self = self * (1-t) + other * t + float one_minus_t = 1.0f - t; + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float& dest = self->heightmap->values[region.dest_idx(x, y)]; + float src = other->heightmap->values[region.src_idx(x, y)]; + dest = dest * one_minus_t + src * t; + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: copy_from(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::copy_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "copy_from"); + if (!other) return nullptr; + + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Copy values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + self->heightmap->values[region.dest_idx(x, y)] = + other->heightmap->values[region.src_idx(x, y)]; + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: max(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::hmap_max(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "max"); + if (!other) return nullptr; + + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Max values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float& dest = self->heightmap->values[region.dest_idx(x, y)]; + float src = other->heightmap->values[region.src_idx(x, y)]; + if (src > dest) dest = src; + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: min(other, *, pos=None, source_pos=None, size=None) -> HeightMap +PyObject* PyHeightMap::hmap_min(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"other", "pos", "source_pos", "size", nullptr}; + PyObject* other_obj; + PyObject* pos = nullptr; + PyObject* source_pos = nullptr; + PyObject* size = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", const_cast(kwlist), + &other_obj, &pos, &source_pos, &size)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyHeightMapObject* other = validateOtherHeightMapType(other_obj, "min"); + if (!other) return nullptr; + + // Parse region parameters + HMRegion region; + if (!parseHMRegion(self->heightmap, other->heightmap, pos, source_pos, size, region)) { + return nullptr; + } + + // Min values in region + for (int y = 0; y < region.height; y++) { + for (int x = 0; x < region.width; x++) { + float& dest = self->heightmap->values[region.dest_idx(x, y)]; + float src = other->heightmap->values[region.src_idx(x, y)]; + if (src < dest) dest = src; } } @@ -1804,7 +2270,7 @@ static bool collectBSPNodes( return true; } -// Method: add_bsp(bsp, ...) -> HeightMap +// Method: add_bsp(bsp, *, pos=None, select='leaves', nodes=None, shrink=0, value=1.0) -> HeightMap PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { if (!self->heightmap) { @@ -1812,15 +2278,16 @@ PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject return nullptr; } - static const char* keywords[] = {"bsp", "select", "nodes", "shrink", "value", nullptr}; + static const char* keywords[] = {"bsp", "pos", "select", "nodes", "shrink", "value", nullptr}; PyObject* bsp_obj = nullptr; + PyObject* pos_obj = nullptr; const char* select_str = "leaves"; PyObject* nodes_obj = nullptr; int shrink = 0; float value = 1.0f; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|sOif", const_cast(keywords), - &bsp_obj, &select_str, &nodes_obj, &shrink, &value)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OsOif", const_cast(keywords), + &bsp_obj, &pos_obj, &select_str, &nodes_obj, &shrink, &value)) { return nullptr; } @@ -1845,18 +2312,36 @@ PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject return nullptr; } + // Calculate offset for BSP coordinates + // Default (pos=None): origin-relative like to_heightmap (-bsp.orig_x, -bsp.orig_y) + // pos=(x, y): offset so BSP origin maps to (x, y) in heightmap + int offset_x, offset_y; + if (!pos_obj || pos_obj == Py_None) { + // Origin-relative: translate BSP to (0, 0) + offset_x = -bsp->orig_x; + offset_y = -bsp->orig_y; + } else { + // Custom position + int pos_x, pos_y; + if (!parseOptionalPos(pos_obj, &pos_x, &pos_y, "pos")) { + return nullptr; + } + offset_x = pos_x - bsp->orig_x; + offset_y = pos_y - bsp->orig_y; + } + // Collect nodes std::vector nodes; if (!collectBSPNodes(bsp, select_str, nodes_obj, nodes, "add_bsp")) { return nullptr; } - // Add value to each node's region + // Add value to each node's region (with offset) for (TCOD_bsp_t* node : nodes) { - int x1 = node->x + shrink; - int y1 = node->y + shrink; - int x2 = node->x + node->w - shrink; - int y2 = node->y + node->h - shrink; + int x1 = node->x + offset_x + shrink; + int y1 = node->y + offset_y + shrink; + int x2 = node->x + offset_x + node->w - shrink; + int y2 = node->y + offset_y + node->h - shrink; // Clamp to heightmap bounds and skip if shrunk to nothing if (x1 >= x2 || y1 >= y2) continue; @@ -1864,6 +2349,8 @@ PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject if (y1 < 0) y1 = 0; if (x2 > self->heightmap->w) x2 = self->heightmap->w; if (y2 > self->heightmap->h) y2 = self->heightmap->h; + if (x1 >= self->heightmap->w || y1 >= self->heightmap->h) continue; + if (x2 <= 0 || y2 <= 0) continue; for (int y = y1; y < y2; y++) { for (int x = x1; x < x2; x++) { @@ -1877,7 +2364,7 @@ PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject return (PyObject*)self; } -// Method: multiply_bsp(bsp, ...) -> HeightMap +// Method: multiply_bsp(bsp, *, pos=None, select='leaves', nodes=None, shrink=0, value=1.0) -> HeightMap PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { if (!self->heightmap) { @@ -1885,15 +2372,16 @@ PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyO return nullptr; } - static const char* keywords[] = {"bsp", "select", "nodes", "shrink", "value", nullptr}; + static const char* keywords[] = {"bsp", "pos", "select", "nodes", "shrink", "value", nullptr}; PyObject* bsp_obj = nullptr; + PyObject* pos_obj = nullptr; const char* select_str = "leaves"; PyObject* nodes_obj = nullptr; int shrink = 0; float value = 1.0f; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|sOif", const_cast(keywords), - &bsp_obj, &select_str, &nodes_obj, &shrink, &value)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OsOif", const_cast(keywords), + &bsp_obj, &pos_obj, &select_str, &nodes_obj, &shrink, &value)) { return nullptr; } @@ -1918,24 +2406,35 @@ PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyO return nullptr; } + // Calculate offset for BSP coordinates + int offset_x, offset_y; + if (!pos_obj || pos_obj == Py_None) { + // Origin-relative: translate BSP to (0, 0) + offset_x = -bsp->orig_x; + offset_y = -bsp->orig_y; + } else { + int pos_x, pos_y; + if (!parseOptionalPos(pos_obj, &pos_x, &pos_y, "pos")) { + return nullptr; + } + offset_x = pos_x - bsp->orig_x; + offset_y = pos_y - bsp->orig_y; + } + // Collect nodes std::vector nodes; if (!collectBSPNodes(bsp, select_str, nodes_obj, nodes, "multiply_bsp")) { return nullptr; } - // Create a mask: 0 everywhere, then set to 1 inside node regions - // Then multiply heightmap by mask - // Actually, for efficiency, we set cells OUTSIDE regions to 0 - - // First, create a "touched" array to track which cells are in regions + // Create a mask to track which cells are in regions std::vector in_region(self->heightmap->w * self->heightmap->h, false); for (TCOD_bsp_t* node : nodes) { - int x1 = node->x + shrink; - int y1 = node->y + shrink; - int x2 = node->x + node->w - shrink; - int y2 = node->y + node->h - shrink; + int x1 = node->x + offset_x + shrink; + int y1 = node->y + offset_y + shrink; + int x2 = node->x + offset_x + node->w - shrink; + int y2 = node->y + offset_y + node->h - shrink; // Clamp and skip if invalid if (x1 >= x2 || y1 >= y2) continue; @@ -1943,6 +2442,8 @@ PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyO if (y1 < 0) y1 = 0; if (x2 > self->heightmap->w) x2 = self->heightmap->w; if (y2 > self->heightmap->h) y2 = self->heightmap->h; + if (x1 >= self->heightmap->w || y1 >= self->heightmap->h) continue; + if (x2 <= 0 || y2 <= 0) continue; for (int y = y1; y < y2; y++) { for (int x = x1; x < x2; x++) { @@ -1951,7 +2452,7 @@ PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyO } } - // Now apply: multiply by value inside regions, by 0 outside + // Apply: multiply by value inside regions, by 0 outside for (int y = 0; y < self->heightmap->h; y++) { for (int x = 0; x < self->heightmap->w; x++) { float current = TCOD_heightmap_get_value(self->heightmap, x, y); diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h index 1e00f10..6065c35 100644 --- a/src/PyHeightMap.h +++ b/src/PyHeightMap.h @@ -24,11 +24,11 @@ public: // Properties static PyObject* get_size(PyHeightMapObject* self, void* closure); - // Scalar operations (all return self for chaining) - static PyObject* fill(PyHeightMapObject* self, PyObject* args); + // Scalar operations (all return self for chaining, support region parameters) + static PyObject* fill(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); - static PyObject* add_constant(PyHeightMapObject* self, PyObject* args); - static PyObject* scale(PyHeightMapObject* self, PyObject* args); + static PyObject* add_constant(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* scale(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds); @@ -57,14 +57,14 @@ public: // Subscript support for hmap[x, y] syntax static PyObject* subscript(PyHeightMapObject* self, PyObject* key); - // Combination operations (#194) - mutate self, return self for chaining - static PyObject* add(PyHeightMapObject* self, PyObject* args); - static PyObject* subtract(PyHeightMapObject* self, PyObject* args); - static PyObject* multiply(PyHeightMapObject* self, PyObject* args); - static PyObject* lerp(PyHeightMapObject* self, PyObject* args); - static PyObject* copy_from(PyHeightMapObject* self, PyObject* args); - static PyObject* hmap_max(PyHeightMapObject* self, PyObject* args); // 'max' conflicts with macro - static PyObject* hmap_min(PyHeightMapObject* self, PyObject* args); // 'min' conflicts with macro + // Combination operations (#194) - mutate self, return self for chaining, support region parameters + static PyObject* add(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* subtract(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* multiply(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* lerp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* copy_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* hmap_max(PyHeightMapObject* self, PyObject* args, PyObject* kwds); // 'max' conflicts with macro + static PyObject* hmap_min(PyHeightMapObject* self, PyObject* args, PyObject* kwds); // 'min' conflicts with macro // Direct source sampling (#209) - sample from NoiseSource/BSP directly static PyObject* add_noise(PyHeightMapObject* self, PyObject* args, PyObject* kwds); diff --git a/tests/unit/heightmap_combination_test.py b/tests/unit/heightmap_combination_test.py new file mode 100644 index 0000000..ff63027 --- /dev/null +++ b/tests/unit/heightmap_combination_test.py @@ -0,0 +1,310 @@ +"""Unit tests for HeightMap combination operations (Issue #194) + +Tests: +- add(other) - cell-by-cell addition +- subtract(other) - cell-by-cell subtraction +- multiply(other) - cell-by-cell multiplication (masking) +- lerp(other, t) - linear interpolation +- copy_from(other) - copy values +- max(other) - cell-by-cell maximum +- min(other) - cell-by-cell minimum +- Dimension mismatch handling (operates on overlapping region) +- Method chaining +""" +import mcrfpy +import sys + +def test_add(): + """Test add() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=1.0) + h2 = mcrfpy.HeightMap((10, 10), fill=2.0) + + result = h1.add(h2) + + # Should return self for chaining + assert result is h1, "add() should return self" + + # Check values + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 3.0, f"Expected 3.0 at ({x},{y}), got {h1.get((x, y))}" + + print(" PASS: add()") + +def test_subtract(): + """Test subtract() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=5.0) + h2 = mcrfpy.HeightMap((10, 10), fill=2.0) + + result = h1.subtract(h2) + + assert result is h1, "subtract() should return self" + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 3.0, f"Expected 3.0, got {h1.get((x, y))}" + + print(" PASS: subtract()") + +def test_multiply(): + """Test multiply() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=3.0) + h2 = mcrfpy.HeightMap((10, 10), fill=2.0) + + result = h1.multiply(h2) + + assert result is h1, "multiply() should return self" + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 6.0, f"Expected 6.0, got {h1.get((x, y))}" + + print(" PASS: multiply()") + +def test_multiply_masking(): + """Test multiply() for masking (0/1 values)""" + h1 = mcrfpy.HeightMap((10, 10), fill=5.0) + + # Create mask: 1.0 in center, 0.0 outside + mask = mcrfpy.HeightMap((10, 10), fill=0.0) + for x in range(3, 7): + for y in range(3, 7): + # Need to use the underlying heightmap directly + pass # We'll fill differently + + # Actually fill the mask using a different approach + mask = mcrfpy.HeightMap((10, 10), fill=0.0) + # Fill with 0, then add 1 to center region + center = mcrfpy.HeightMap((10, 10), fill=0.0) + center.add_hill((5, 5), 0.1, 1.0) # Small hill at center + center.threshold_binary((0.5, 2.0), value=1.0) # Make binary + + # Just test basic masking with simple uniform values + h1 = mcrfpy.HeightMap((10, 10), fill=5.0) + mask = mcrfpy.HeightMap((10, 10), fill=0.5) + + h1.multiply(mask) + + # All values should be 5.0 * 0.5 = 2.5 + for x in range(10): + for y in range(10): + assert abs(h1.get((x, y)) - 2.5) < 0.001, f"Expected 2.5, got {h1.get((x, y))}" + + print(" PASS: multiply() for masking") + +def test_lerp(): + """Test lerp() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=0.0) + h2 = mcrfpy.HeightMap((10, 10), fill=10.0) + + # t=0.5 should give midpoint + result = h1.lerp(h2, 0.5) + + assert result is h1, "lerp() should return self" + + for x in range(10): + for y in range(10): + assert abs(h1.get((x, y)) - 5.0) < 0.001, f"Expected 5.0, got {h1.get((x, y))}" + + print(" PASS: lerp() at t=0.5") + +def test_lerp_extremes(): + """Test lerp() at t=0 and t=1""" + h1 = mcrfpy.HeightMap((10, 10), fill=0.0) + h2 = mcrfpy.HeightMap((10, 10), fill=10.0) + + # t=0 should keep h1 values + h1.lerp(h2, 0.0) + assert abs(h1.get((5, 5)) - 0.0) < 0.001, f"t=0: Expected 0.0, got {h1.get((5, 5))}" + + # Reset and test t=1 + h1.fill(0.0) + h1.lerp(h2, 1.0) + assert abs(h1.get((5, 5)) - 10.0) < 0.001, f"t=1: Expected 10.0, got {h1.get((5, 5))}" + + print(" PASS: lerp() at extremes") + +def test_copy_from(): + """Test copy_from() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=0.0) + h2 = mcrfpy.HeightMap((10, 10), fill=7.5) + + result = h1.copy_from(h2) + + assert result is h1, "copy_from() should return self" + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 7.5, f"Expected 7.5, got {h1.get((x, y))}" + + print(" PASS: copy_from()") + +def test_max(): + """Test max() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=3.0) + h2 = mcrfpy.HeightMap((10, 10), fill=5.0) + + result = h1.max(h2) + + assert result is h1, "max() should return self" + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 5.0, f"Expected 5.0, got {h1.get((x, y))}" + + print(" PASS: max()") + +def test_max_varying(): + """Test max() with varying values""" + h1 = mcrfpy.HeightMap((10, 10), fill=0.0) + h2 = mcrfpy.HeightMap((10, 10), fill=0.0) + + # h1 has values 0-4 in left half, h2 has values 5-9 in right half + h1.fill(3.0) # All 3 + h2.fill(7.0) # All 7 + + # Modify h1 to have some higher values + h1.add_constant(5.0) # Now h1 is 8.0 + + h1.max(h2) + + # Result should be 8.0 everywhere (h1 was 8, h2 was 7) + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 8.0, f"Expected 8.0, got {h1.get((x, y))}" + + print(" PASS: max() with varying values") + +def test_min(): + """Test min() operation""" + h1 = mcrfpy.HeightMap((10, 10), fill=8.0) + h2 = mcrfpy.HeightMap((10, 10), fill=5.0) + + result = h1.min(h2) + + assert result is h1, "min() should return self" + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 5.0, f"Expected 5.0, got {h1.get((x, y))}" + + print(" PASS: min()") + +def test_dimension_mismatch_allowed(): + """Test that dimension mismatch works (operates on overlapping region)""" + # Smaller dest, larger source - uses smaller size + h1 = mcrfpy.HeightMap((10, 10), fill=5.0) + h2 = mcrfpy.HeightMap((20, 20), fill=3.0) + + h1.add(h2) + + # All cells in h1 should be 5.0 + 3.0 = 8.0 + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 8.0, f"Expected 8.0 at ({x},{y}), got {h1.get((x, y))}" + + # Test the reverse: larger dest, smaller source + h3 = mcrfpy.HeightMap((20, 20), fill=10.0) + h4 = mcrfpy.HeightMap((5, 5), fill=2.0) + + h3.add(h4) + + # Only the 5x5 region should be affected + for x in range(20): + for y in range(20): + expected = 12.0 if (x < 5 and y < 5) else 10.0 + assert h3.get((x, y)) == expected, f"Expected {expected} at ({x},{y}), got {h3.get((x, y))}" + + print(" PASS: Dimension mismatch handling (overlapping region)") + +def test_type_error(): + """Test that non-HeightMap argument raises TypeError""" + h1 = mcrfpy.HeightMap((10, 10), fill=0.0) + + ops = [ + ('add', lambda: h1.add(5.0)), + ('subtract', lambda: h1.subtract("invalid")), + ('multiply', lambda: h1.multiply([1, 2, 3])), + ('copy_from', lambda: h1.copy_from(None)), + ('max', lambda: h1.max({})), + ('min', lambda: h1.min(42)), + ] + + for name, op in ops: + try: + op() + print(f" FAIL: {name}() should raise TypeError for non-HeightMap") + sys.exit(1) + except TypeError: + pass + + print(" PASS: Type error handling") + +def test_method_chaining(): + """Test method chaining with combination operations""" + h1 = mcrfpy.HeightMap((10, 10), fill=1.0) + h2 = mcrfpy.HeightMap((10, 10), fill=2.0) + h3 = mcrfpy.HeightMap((10, 10), fill=3.0) + + # Chain multiple operations + result = h1.add(h2).add(h3).scale(0.5) + + # 1.0 + 2.0 + 3.0 = 6.0, then * 0.5 = 3.0 + for x in range(10): + for y in range(10): + assert abs(h1.get((x, y)) - 3.0) < 0.001, f"Expected 3.0, got {h1.get((x, y))}" + + print(" PASS: Method chaining") + +def test_self_operation(): + """Test operations with self (h.add(h))""" + h1 = mcrfpy.HeightMap((10, 10), fill=5.0) + + # Adding to self should double values + h1.add(h1) + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 10.0, f"Expected 10.0, got {h1.get((x, y))}" + + # Multiplying self by self should square + h1.fill(3.0) + h1.multiply(h1) + + for x in range(10): + for y in range(10): + assert h1.get((x, y)) == 9.0, f"Expected 9.0, got {h1.get((x, y))}" + + print(" PASS: Self operations") + +def run_tests(): + """Run all HeightMap combination tests""" + print("Testing HeightMap combination operations (Issue #194)...") + + test_add() + test_subtract() + test_multiply() + test_multiply_masking() + test_lerp() + test_lerp_extremes() + test_copy_from() + test_max() + test_max_varying() + test_min() + test_dimension_mismatch_allowed() + test_type_error() + test_method_chaining() + test_self_operation() + + print("All HeightMap combination tests PASSED!") + return True + +if __name__ == "__main__": + try: + success = run_tests() + sys.exit(0 if success else 1) + except Exception as e: + print(f"FAIL: Unexpected exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/unit/heightmap_direct_sampling_test.py b/tests/unit/heightmap_direct_sampling_test.py new file mode 100644 index 0000000..ac62bce --- /dev/null +++ b/tests/unit/heightmap_direct_sampling_test.py @@ -0,0 +1,362 @@ +"""Unit tests for HeightMap direct source sampling (Issue #209) + +Tests: +- add_noise() - sample noise and add to heightmap +- multiply_noise() - sample noise and multiply with heightmap +- add_bsp() - add BSP regions to heightmap +- multiply_bsp() - multiply by BSP regions (masking) +- Equivalence with intermediate HeightMap approach +- Error handling +""" +import mcrfpy +import sys + +def test_add_noise_basic(): + """Test basic add_noise() operation""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + hmap = mcrfpy.HeightMap((50, 50), fill=0.0) + + result = hmap.add_noise(noise) + + # Should return self for chaining + assert result is hmap, "add_noise() should return self" + + # Check that values have been modified + min_val, max_val = hmap.min_max() + assert max_val > 0 or min_val < 0, "Noise should add non-zero values" + + print(" PASS: add_noise() basic") + +def test_add_noise_modes(): + """Test add_noise() with different modes""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + for mode in ["flat", "fbm", "turbulence"]: + hmap = mcrfpy.HeightMap((30, 30), fill=0.0) + hmap.add_noise(noise, mode=mode) + + min_val, max_val = hmap.min_max() + assert min_val >= -2.0 and max_val <= 2.0, f"Mode '{mode}': values out of expected range" + + print(" PASS: add_noise() modes") + +def test_add_noise_scale(): + """Test add_noise() with scale parameter""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Scale 0.5 + hmap1 = mcrfpy.HeightMap((30, 30), fill=0.0) + hmap1.add_noise(noise, scale=0.5) + min1, max1 = hmap1.min_max() + + # Scale 1.0 + hmap2 = mcrfpy.HeightMap((30, 30), fill=0.0) + hmap2.add_noise(noise, scale=1.0) + min2, max2 = hmap2.min_max() + + # Scale 0.5 should have smaller range + range1 = max1 - min1 + range2 = max2 - min2 + assert range1 < range2 or abs(range1 - range2 * 0.5) < 0.1, "Scale should affect value range" + + print(" PASS: add_noise() scale") + +def test_add_noise_equivalence(): + """Test that add_noise() produces same result as sample() + add()""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Method 1: Using sample() then add() + hmap1 = mcrfpy.HeightMap((40, 40), fill=5.0) + sampled = noise.sample(size=(40, 40), mode="fbm", octaves=4) + hmap1.add(sampled) + + # Method 2: Using add_noise() directly + hmap2 = mcrfpy.HeightMap((40, 40), fill=5.0) + hmap2.add_noise(noise, mode="fbm", octaves=4) + + # Compare values - should be identical + differences = 0 + for y in range(40): + for x in range(40): + v1 = hmap1.get((x, y)) + v2 = hmap2.get((x, y)) + if abs(v1 - v2) > 0.0001: + differences += 1 + + assert differences == 0, f"add_noise() should produce same result as sample()+add(), got {differences} differences" + + print(" PASS: add_noise() equivalence") + +def test_multiply_noise_basic(): + """Test basic multiply_noise() operation""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + hmap = mcrfpy.HeightMap((50, 50), fill=1.0) + + result = hmap.multiply_noise(noise) + + assert result is hmap, "multiply_noise() should return self" + + # Values should now be in a range around 0 (noise * 1.0) + min_val, max_val = hmap.min_max() + # FBM noise ranges from ~-1 to ~1, so multiplied values should too + assert min_val < 0.5, "multiply_noise() should produce values less than 0.5" + + print(" PASS: multiply_noise() basic") + +def test_multiply_noise_scale(): + """Test multiply_noise() with scale parameter""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Start with values of 10 + hmap = mcrfpy.HeightMap((30, 30), fill=10.0) + # Multiply by noise scaled to 0.5 + # Result should be 10 * (noise_value * 0.5) + hmap.multiply_noise(noise, scale=0.5) + + min_val, max_val = hmap.min_max() + # With scale 0.5, max possible is 10 * 0.5 = 5, min is 10 * -0.5 = -5 + assert max_val <= 6.0, f"Expected max <= 6.0, got {max_val}" + + print(" PASS: multiply_noise() scale") + +def test_add_bsp_basic(): + """Test basic add_bsp() operation""" + # Create BSP + bsp = mcrfpy.BSP(pos=(0, 0), size=(50, 50)) + bsp.split_recursive(depth=3, min_size=(8, 8)) + + # Create heightmap and add BSP + hmap = mcrfpy.HeightMap((50, 50), fill=0.0) + result = hmap.add_bsp(bsp) + + assert result is hmap, "add_bsp() should return self" + + # Check that some values are non-zero (inside rooms) + min_val, max_val = hmap.min_max() + assert max_val > 0, "add_bsp() should add non-zero values inside BSP regions" + + print(" PASS: add_bsp() basic") + +def test_add_bsp_select_modes(): + """Test add_bsp() with different select modes""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(60, 60)) + bsp.split_recursive(depth=3, min_size=(10, 10)) + + # Test leaves + hmap_leaves = mcrfpy.HeightMap((60, 60), fill=0.0) + hmap_leaves.add_bsp(bsp, select="leaves") + + # Test all + hmap_all = mcrfpy.HeightMap((60, 60), fill=0.0) + hmap_all.add_bsp(bsp, select="all") + + # Test internal + hmap_internal = mcrfpy.HeightMap((60, 60), fill=0.0) + hmap_internal.add_bsp(bsp, select="internal") + + # All modes should produce some non-zero values + for name, hmap in [("leaves", hmap_leaves), ("all", hmap_all), ("internal", hmap_internal)]: + min_val, max_val = hmap.min_max() + assert max_val > 0, f"select='{name}' should produce non-zero values" + + # "all" should cover more area than just leaves or internal + count_leaves = hmap_leaves.count_in_range((0.5, 2.0)) + count_all = hmap_all.count_in_range((0.5, 10.0)) # 'all' may overlap, so higher max + count_internal = hmap_internal.count_in_range((0.5, 2.0)) + + assert count_all >= count_leaves, "'all' should cover at least as much as 'leaves'" + + print(" PASS: add_bsp() select modes") + +def test_add_bsp_shrink(): + """Test add_bsp() with shrink parameter""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 80)) + bsp.split_recursive(depth=2, min_size=(20, 20)) + + # Without shrink + hmap1 = mcrfpy.HeightMap((80, 80), fill=0.0) + hmap1.add_bsp(bsp, shrink=0) + count1 = hmap1.count_in_range((0.5, 2.0)) + + # With shrink + hmap2 = mcrfpy.HeightMap((80, 80), fill=0.0) + hmap2.add_bsp(bsp, shrink=2) + count2 = hmap2.count_in_range((0.5, 2.0)) + + # Shrunk version should have fewer cells + assert count2 < count1, f"Shrink should reduce covered cells: {count2} vs {count1}" + + print(" PASS: add_bsp() shrink") + +def test_add_bsp_value(): + """Test add_bsp() with custom value""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(40, 40)) + bsp.split_recursive(depth=2, min_size=(10, 10)) + + hmap = mcrfpy.HeightMap((40, 40), fill=0.0) + hmap.add_bsp(bsp, value=5.0) + + min_val, max_val = hmap.min_max() + assert max_val == 5.0, f"Value parameter should set cell values to 5.0, got {max_val}" + + print(" PASS: add_bsp() value") + +def test_multiply_bsp_basic(): + """Test basic multiply_bsp() operation (masking)""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(50, 50)) + bsp.split_recursive(depth=3, min_size=(8, 8)) + + # Create heightmap with uniform value + hmap = mcrfpy.HeightMap((50, 50), fill=10.0) + result = hmap.multiply_bsp(bsp) + + assert result is hmap, "multiply_bsp() should return self" + + # Note: BSP leaves partition the ENTIRE space, so all cells are inside some leaf + # To get "walls" between rooms, you need to use shrink > 0 + # Without shrink, all cells should be preserved + min_val, max_val = hmap.min_max() + assert max_val == 10.0, f"Areas inside BSP should be 10.0, got max={max_val}" + + # Test with shrink to get actual masking (walls between rooms) + hmap2 = mcrfpy.HeightMap((50, 50), fill=10.0) + hmap2.multiply_bsp(bsp, shrink=2) # Leave 2-pixel walls + + min_val2, max_val2 = hmap2.min_max() + assert min_val2 == 0.0, f"Areas between shrunken rooms should be 0, got min={min_val2}" + assert max_val2 == 10.0, f"Areas inside shrunken rooms should be 10.0, got max={max_val2}" + + print(" PASS: multiply_bsp() basic (masking)") + +def test_multiply_bsp_with_noise(): + """Test multiply_bsp() to mask noisy terrain""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 80)) + bsp.split_recursive(depth=3, min_size=(10, 10)) + + # Generate noisy terrain + hmap = mcrfpy.HeightMap((80, 80), fill=0.0) + hmap.add_noise(noise, mode="fbm", octaves=6, scale=1.0) + hmap.normalize(0.0, 1.0) + + # Mask to BSP regions + hmap.multiply_bsp(bsp, select="leaves", shrink=1) + + # Check that some values are 0 (outside rooms) and some are positive (inside) + count_zero = hmap.count_in_range((-0.001, 0.001)) + count_positive = hmap.count_in_range((0.1, 1.5)) + + assert count_zero > 0, "Should have zero values outside BSP" + assert count_positive > 0, "Should have positive values inside BSP" + + print(" PASS: multiply_bsp() with noise (terrain masking)") + +def test_add_noise_requires_2d(): + """Test that add_noise() requires 2D NoiseSource""" + for dim in [1, 3, 4]: + noise = mcrfpy.NoiseSource(dimensions=dim, seed=42) + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + try: + hmap.add_noise(noise) + print(f" FAIL: add_noise() should raise ValueError for {dim}D noise") + sys.exit(1) + except ValueError: + pass + + print(" PASS: add_noise() requires 2D noise") + +def test_type_errors(): + """Test type error handling""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # add_noise with non-NoiseSource + try: + hmap.add_noise("not a noise source") + print(" FAIL: add_noise() should raise TypeError") + sys.exit(1) + except TypeError: + pass + + # add_bsp with non-BSP + try: + hmap.add_bsp(noise) # passing NoiseSource instead of BSP + print(" FAIL: add_bsp() should raise TypeError") + sys.exit(1) + except TypeError: + pass + + print(" PASS: Type error handling") + +def test_invalid_select_mode(): + """Test invalid select mode error""" + bsp = mcrfpy.BSP(pos=(0, 0), size=(30, 30)) + hmap = mcrfpy.HeightMap((30, 30), fill=0.0) + + try: + hmap.add_bsp(bsp, select="invalid") + print(" FAIL: add_bsp() should raise ValueError for invalid select") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Invalid select mode error") + +def test_method_chaining(): + """Test method chaining with direct sampling""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + bsp = mcrfpy.BSP(pos=(0, 0), size=(60, 60)) + bsp.split_recursive(depth=2, min_size=(15, 15)) + + hmap = mcrfpy.HeightMap((60, 60), fill=0.0) + + # Chain operations + result = (hmap + .add_noise(noise, mode="fbm", octaves=4) + .normalize(0.0, 1.0) + .multiply_bsp(bsp, select="leaves", shrink=1) + .scale(10.0)) + + assert result is hmap, "Chained operations should return self" + + # Verify the result makes sense + min_val, max_val = hmap.min_max() + assert min_val == 0.0, "Masked areas should be 0" + assert max_val > 0.0, "Unmasked areas should have positive values" + + print(" PASS: Method chaining") + +def run_tests(): + """Run all HeightMap direct sampling tests""" + print("Testing HeightMap direct sampling (Issue #209)...") + + test_add_noise_basic() + test_add_noise_modes() + test_add_noise_scale() + test_add_noise_equivalence() + test_multiply_noise_basic() + test_multiply_noise_scale() + test_add_bsp_basic() + test_add_bsp_select_modes() + test_add_bsp_shrink() + test_add_bsp_value() + test_multiply_bsp_basic() + test_multiply_bsp_with_noise() + test_add_noise_requires_2d() + test_type_errors() + test_invalid_select_mode() + test_method_chaining() + + print("All HeightMap direct sampling tests PASSED!") + return True + +if __name__ == "__main__": + try: + success = run_tests() + sys.exit(0 if success else 1) + except Exception as e: + print(f"FAIL: Unexpected exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/unit/heightmap_region_test.py b/tests/unit/heightmap_region_test.py new file mode 100644 index 0000000..ac53c0f --- /dev/null +++ b/tests/unit/heightmap_region_test.py @@ -0,0 +1,503 @@ +"""Unit tests for HeightMap region-based operations + +Tests: +- Scalar operations with pos/size parameters (fill, add_constant, scale, clamp, normalize) +- Combination operations with pos/source_pos/size parameters +- BSP operations with pos parameter for coordinate translation +- Region parameter validation and error handling +""" +import mcrfpy +import sys + +# ============================================================================ +# Scalar operations with region parameters +# ============================================================================ + +def test_fill_region(): + """Test fill() with region parameters""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Fill a 5x5 region starting at (5, 5) + result = hmap.fill(10.0, pos=(5, 5), size=(5, 5)) + + # Should return self + assert result is hmap, "fill() should return self" + + # Check values + for x in range(20): + for y in range(20): + expected = 10.0 if (5 <= x < 10 and 5 <= y < 10) else 0.0 + actual = hmap.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: fill() with region") + + +def test_fill_region_inferred_size(): + """Test fill() with position but no size (infers remaining)""" + hmap = mcrfpy.HeightMap((15, 15), fill=0.0) + + # Fill from (10, 10) with no size - should fill to end + hmap.fill(5.0, pos=(10, 10)) + + for x in range(15): + for y in range(15): + expected = 5.0 if (x >= 10 and y >= 10) else 0.0 + actual = hmap.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: fill() with inferred size") + + +def test_add_constant_region(): + """Test add_constant() with region parameters""" + hmap = mcrfpy.HeightMap((20, 20), fill=1.0) + + # Add 5.0 to a 10x10 region at origin + hmap.add_constant(5.0, pos=(0, 0), size=(10, 10)) + + for x in range(20): + for y in range(20): + expected = 6.0 if (x < 10 and y < 10) else 1.0 + actual = hmap.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: add_constant() with region") + + +def test_scale_region(): + """Test scale() with region parameters""" + hmap = mcrfpy.HeightMap((20, 20), fill=2.0) + + # Scale a 5x5 region by 3.0 + hmap.scale(3.0, pos=(5, 5), size=(5, 5)) + + for x in range(20): + for y in range(20): + expected = 6.0 if (5 <= x < 10 and 5 <= y < 10) else 2.0 + actual = hmap.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: scale() with region") + + +def test_clamp_region(): + """Test clamp() with region parameters""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Set up varying values + for x in range(20): + for y in range(20): + # Values from 0 to 39 + val = float(x + y) + hmap.fill(val, pos=(x, y), size=(1, 1)) + + # Clamp only the center region + hmap.clamp(5.0, 15.0, pos=(5, 5), size=(10, 10)) + + for x in range(20): + for y in range(20): + original = float(x + y) + if 5 <= x < 15 and 5 <= y < 15: + # Clamped region + expected = max(5.0, min(15.0, original)) + else: + # Unaffected + expected = original + actual = hmap.get((x, y)) + assert abs(actual - expected) < 0.001, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: clamp() with region") + + +def test_normalize_region(): + """Test normalize() with region parameters""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Set up the center region with known min/max + for x in range(5, 15): + for y in range(5, 15): + val = float((x - 5) + (y - 5)) # 0 to 18 + hmap.fill(val, pos=(x, y), size=(1, 1)) + + # Normalize the center region to 0-100 + hmap.normalize(0.0, 100.0, pos=(5, 5), size=(10, 10)) + + # Check the center region is normalized + center_min, center_max = float('inf'), float('-inf') + for x in range(5, 15): + for y in range(5, 15): + val = hmap.get((x, y)) + center_min = min(center_min, val) + center_max = max(center_max, val) + + assert abs(center_min - 0.0) < 0.001, f"Normalized min should be 0.0, got {center_min}" + assert abs(center_max - 100.0) < 0.001, f"Normalized max should be 100.0, got {center_max}" + + # Check outside region unchanged (should still be 0.0) + assert hmap.get((0, 0)) == 0.0, "Outside region should be unchanged" + + print(" PASS: normalize() with region") + + +# ============================================================================ +# Combination operations with region parameters +# ============================================================================ + +def test_add_region(): + """Test add() with region parameters""" + h1 = mcrfpy.HeightMap((20, 20), fill=1.0) + h2 = mcrfpy.HeightMap((20, 20), fill=5.0) + + # Add h2 to h1 in a specific region + h1.add(h2, pos=(5, 5), source_pos=(0, 0), size=(10, 10)) + + for x in range(20): + for y in range(20): + expected = 6.0 if (5 <= x < 15 and 5 <= y < 15) else 1.0 + actual = h1.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: add() with region") + + +def test_add_source_pos(): + """Test add() with different source_pos""" + h1 = mcrfpy.HeightMap((20, 20), fill=0.0) + h2 = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Set up h2 with non-zero values in a specific area + h2.fill(10.0, pos=(10, 10), size=(5, 5)) + + # Copy from h2's non-zero region to h1's origin + h1.add(h2, pos=(0, 0), source_pos=(10, 10), size=(5, 5)) + + for x in range(20): + for y in range(20): + expected = 10.0 if (x < 5 and y < 5) else 0.0 + actual = h1.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: add() with source_pos") + + +def test_copy_from_region(): + """Test copy_from() with region parameters""" + h1 = mcrfpy.HeightMap((30, 30), fill=0.0) + h2 = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Fill h2 with a pattern + for x in range(20): + for y in range(20): + h2.fill(float(x * 20 + y), pos=(x, y), size=(1, 1)) + + # Copy a 10x10 region from h2 to h1 at offset + h1.copy_from(h2, pos=(5, 5), source_pos=(3, 3), size=(10, 10)) + + # Verify copied region + for x in range(10): + for y in range(10): + src_val = float((3 + x) * 20 + (3 + y)) + dest_val = h1.get((5 + x, 5 + y)) + assert dest_val == src_val, f"At dest ({5+x},{5+y}): expected {src_val}, got {dest_val}" + + # Verify outside copied region is still 0 + assert h1.get((0, 0)) == 0.0, "Outside region should be 0" + assert h1.get((4, 4)) == 0.0, "Just outside region should be 0" + + print(" PASS: copy_from() with region") + + +def test_multiply_region(): + """Test multiply() with region parameters (masking)""" + h1 = mcrfpy.HeightMap((20, 20), fill=10.0) + h2 = mcrfpy.HeightMap((20, 20), fill=0.5) + + # Multiply a 5x5 region + h1.multiply(h2, pos=(5, 5), size=(5, 5)) + + for x in range(20): + for y in range(20): + expected = 5.0 if (5 <= x < 10 and 5 <= y < 10) else 10.0 + actual = h1.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: multiply() with region") + + +def test_lerp_region(): + """Test lerp() with region parameters""" + h1 = mcrfpy.HeightMap((20, 20), fill=0.0) + h2 = mcrfpy.HeightMap((20, 20), fill=100.0) + + # Lerp a region with t=0.3 + h1.lerp(h2, 0.3, pos=(5, 5), size=(10, 10)) + + for x in range(20): + for y in range(20): + expected = 30.0 if (5 <= x < 15 and 5 <= y < 15) else 0.0 + actual = h1.get((x, y)) + assert abs(actual - expected) < 0.001, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: lerp() with region") + + +def test_max_region(): + """Test max() with region parameters""" + h1 = mcrfpy.HeightMap((20, 20), fill=5.0) + h2 = mcrfpy.HeightMap((20, 20), fill=10.0) + + # Max in a specific region + h1.max(h2, pos=(5, 5), size=(5, 5)) + + for x in range(20): + for y in range(20): + expected = 10.0 if (5 <= x < 10 and 5 <= y < 10) else 5.0 + actual = h1.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: max() with region") + + +def test_min_region(): + """Test min() with region parameters""" + h1 = mcrfpy.HeightMap((20, 20), fill=10.0) + h2 = mcrfpy.HeightMap((20, 20), fill=3.0) + + # Min in a specific region + h1.min(h2, pos=(5, 5), size=(5, 5)) + + for x in range(20): + for y in range(20): + expected = 3.0 if (5 <= x < 10 and 5 <= y < 10) else 10.0 + actual = h1.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: min() with region") + + +# ============================================================================ +# BSP operations with pos parameter +# ============================================================================ + +def test_add_bsp_pos_default(): + """Test add_bsp() with default pos (origin-relative, like to_heightmap)""" + # Create BSP at non-origin position + bsp = mcrfpy.BSP(pos=(10, 10), size=(30, 30)) + bsp.split_recursive(depth=2, min_size=(10, 10)) + + # Create heightmap larger than BSP + hmap = mcrfpy.HeightMap((50, 50), fill=0.0) + + # Default pos=None should translate to origin-relative (BSP at 0,0) + hmap.add_bsp(bsp) + + # Verify: the BSP region should be mapped starting from (0, 0) + # Check that at least some of the 30x30 region has non-zero values + count = 0 + for x in range(30): + for y in range(30): + if hmap.get((x, y)) > 0: + count += 1 + + assert count > 0, "add_bsp() with default pos should map BSP to origin" + + # Check that outside BSP's relative bounds is zero + assert hmap.get((35, 35)) == 0.0, "Outside BSP bounds should be 0" + + print(" PASS: add_bsp() with default pos (origin-relative)") + + +def test_add_bsp_pos_custom(): + """Test add_bsp() with custom pos parameter""" + # Create BSP at (0, 0) + bsp = mcrfpy.BSP(pos=(0, 0), size=(20, 20)) + bsp.split_recursive(depth=2, min_size=(5, 5)) + + # Create heightmap + hmap = mcrfpy.HeightMap((50, 50), fill=0.0) + + # Map BSP to position (15, 15) in heightmap + hmap.add_bsp(bsp, pos=(15, 15)) + + # Verify: there should be values in the 15-35 region + count_in_region = 0 + for x in range(15, 35): + for y in range(15, 35): + if hmap.get((x, y)) > 0: + count_in_region += 1 + + assert count_in_region > 0, "add_bsp() with pos=(15,15) should place BSP there" + + # Verify: the 0-15 region should be empty + count_outside = 0 + for x in range(15): + for y in range(15): + if hmap.get((x, y)) > 0: + count_outside += 1 + + assert count_outside == 0, "Before pos offset should be empty" + + print(" PASS: add_bsp() with custom pos") + + +def test_multiply_bsp_pos(): + """Test multiply_bsp() with pos parameter for masking""" + # Create BSP + bsp = mcrfpy.BSP(pos=(0, 0), size=(30, 30)) + bsp.split_recursive(depth=2, min_size=(10, 10)) + + # Create heightmap with uniform value + hmap = mcrfpy.HeightMap((50, 50), fill=10.0) + + # Multiply/mask at position (10, 10) with shrink + hmap.multiply_bsp(bsp, pos=(10, 10), shrink=2) + + # Check that areas outside the BSP+pos region are zeroed + # At (5, 5) should be zeroed (before pos offset) + assert hmap.get((5, 5)) == 0.0, "Before BSP region should be zeroed" + + # At (45, 45) should be zeroed (after BSP region) + assert hmap.get((45, 45)) == 0.0, "After BSP region should be zeroed" + + # Inside the BSP region (accounting for pos and shrink), some should be preserved + preserved_count = 0 + for x in range(10, 40): + for y in range(10, 40): + if hmap.get((x, y)) > 0: + preserved_count += 1 + + assert preserved_count > 0, "Inside BSP region should have some preserved values" + + print(" PASS: multiply_bsp() with pos") + + +# ============================================================================ +# Error handling +# ============================================================================ + +def test_region_out_of_bounds(): + """Test that out-of-bounds positions raise ValueError""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Position beyond bounds + try: + hmap.fill(1.0, pos=(25, 25)) + print(" FAIL: Should raise ValueError for out-of-bounds pos") + sys.exit(1) + except ValueError: + pass + + # Negative position + try: + hmap.fill(1.0, pos=(-1, 0)) + print(" FAIL: Should raise ValueError for negative pos") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Out-of-bounds position error handling") + + +def test_region_size_exceeds_bounds(): + """Test that explicit size exceeding bounds raises ValueError""" + hmap = mcrfpy.HeightMap((20, 20), fill=0.0) + + # Size that exceeds remaining space + try: + hmap.fill(1.0, pos=(15, 15), size=(10, 10)) + print(" FAIL: Should raise ValueError when size exceeds bounds") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Size exceeds bounds error handling") + + +def test_source_region_out_of_bounds(): + """Test that out-of-bounds source_pos raises ValueError""" + h1 = mcrfpy.HeightMap((20, 20), fill=0.0) + h2 = mcrfpy.HeightMap((10, 10), fill=5.0) + + # source_pos beyond source bounds + try: + h1.add(h2, source_pos=(15, 15)) + print(" FAIL: Should raise ValueError for out-of-bounds source_pos") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Out-of-bounds source_pos error handling") + + +def test_method_chaining_with_regions(): + """Test that region operations support method chaining""" + hmap = mcrfpy.HeightMap((30, 30), fill=0.0) + + # Chain multiple regional operations + result = (hmap + .fill(10.0, pos=(5, 5), size=(10, 10)) + .scale(2.0, pos=(5, 5), size=(10, 10)) + .add_constant(-5.0, pos=(5, 5), size=(10, 10))) + + assert result is hmap, "Chained operations should return self" + + # Verify: region should be 10*2-5 = 15, outside should be 0 + for x in range(30): + for y in range(30): + expected = 15.0 if (5 <= x < 15 and 5 <= y < 15) else 0.0 + actual = hmap.get((x, y)) + assert actual == expected, f"At ({x},{y}): expected {expected}, got {actual}" + + print(" PASS: Method chaining with regions") + + +# ============================================================================ +# Run all tests +# ============================================================================ + +def run_tests(): + """Run all HeightMap region tests""" + print("Testing HeightMap region operations...") + + # Scalar operations + test_fill_region() + test_fill_region_inferred_size() + test_add_constant_region() + test_scale_region() + test_clamp_region() + test_normalize_region() + + # Combination operations + test_add_region() + test_add_source_pos() + test_copy_from_region() + test_multiply_region() + test_lerp_region() + test_max_region() + test_min_region() + + # BSP operations + test_add_bsp_pos_default() + test_add_bsp_pos_custom() + test_multiply_bsp_pos() + + # Error handling + test_region_out_of_bounds() + test_region_size_exceeds_bounds() + test_source_region_out_of_bounds() + test_method_chaining_with_regions() + + print("All HeightMap region tests PASSED!") + return True + + +if __name__ == "__main__": + try: + success = run_tests() + sys.exit(0 if success else 1) + except Exception as e: + print(f"FAIL: Unexpected exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/unit/noise_sample_test.py b/tests/unit/noise_sample_test.py new file mode 100644 index 0000000..79ed14b --- /dev/null +++ b/tests/unit/noise_sample_test.py @@ -0,0 +1,298 @@ +"""Unit tests for NoiseSource.sample() method (Issue #208) + +Tests: +- Basic sampling returning HeightMap +- world_origin parameter +- world_size parameter (zoom effect) +- Mode parameter (flat, fbm, turbulence) +- Octaves parameter +- Determinism +- Error handling +""" +import mcrfpy +import sys + +def test_basic_sample(): + """Test basic sample() returning HeightMap""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + hmap = noise.sample(size=(50, 50)) + + # Should return HeightMap + assert isinstance(hmap, mcrfpy.HeightMap), f"Expected HeightMap, got {type(hmap)}" + + # Check dimensions + assert hmap.size == (50, 50), f"Expected size (50, 50), got {hmap.size}" + + # Check values are in range + min_val, max_val = hmap.min_max() + assert min_val >= -1.0, f"Min value {min_val} out of range" + assert max_val <= 1.0, f"Max value {max_val} out of range" + + print(" PASS: Basic sample") + +def test_sample_world_origin(): + """Test sample() with world_origin parameter""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Sample at origin + hmap1 = noise.sample(size=(20, 20), world_origin=(0.0, 0.0)) + + # Sample at different location + hmap2 = noise.sample(size=(20, 20), world_origin=(100.0, 100.0)) + + # Values should be different (at least some) + v1 = hmap1.get((0, 0)) + v2 = hmap2.get((0, 0)) + + # Note: could be equal by chance but very unlikely + # Just verify both are valid + assert -1.0 <= v1 <= 1.0, f"Value1 {v1} out of range" + assert -1.0 <= v2 <= 1.0, f"Value2 {v2} out of range" + + print(" PASS: Sample with world_origin") + +def test_sample_world_size(): + """Test sample() with world_size parameter (zoom effect)""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Small world_size = zoomed in (smoother features) + hmap_zoom_in = noise.sample(size=(20, 20), world_size=(1.0, 1.0)) + + # Large world_size = zoomed out (more detail) + hmap_zoom_out = noise.sample(size=(20, 20), world_size=(100.0, 100.0)) + + # Both should be valid + min1, max1 = hmap_zoom_in.min_max() + min2, max2 = hmap_zoom_out.min_max() + + assert min1 >= -1.0 and max1 <= 1.0, "Zoomed-in values out of range" + assert min2 >= -1.0 and max2 <= 1.0, "Zoomed-out values out of range" + + print(" PASS: Sample with world_size") + +def test_sample_modes(): + """Test all sampling modes (flat, fbm, turbulence)""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + for mode in ["flat", "fbm", "turbulence"]: + hmap = noise.sample(size=(20, 20), mode=mode) + assert isinstance(hmap, mcrfpy.HeightMap), f"Mode '{mode}': Expected HeightMap" + + min_val, max_val = hmap.min_max() + assert min_val >= -1.0, f"Mode '{mode}': Min {min_val} out of range" + assert max_val <= 1.0, f"Mode '{mode}': Max {max_val} out of range" + + print(" PASS: All sample modes") + +def test_sample_octaves(): + """Test sample() with different octave values""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + for octaves in [1, 4, 8]: + hmap = noise.sample(size=(20, 20), mode="fbm", octaves=octaves) + assert isinstance(hmap, mcrfpy.HeightMap), f"Octaves {octaves}: Expected HeightMap" + + print(" PASS: Sample with different octaves") + +def test_sample_determinism(): + """Test that same parameters produce same HeightMap""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + hmap1 = noise.sample( + size=(30, 30), + world_origin=(10.0, 20.0), + world_size=(5.0, 5.0), + mode="fbm", + octaves=4 + ) + + hmap2 = noise.sample( + size=(30, 30), + world_origin=(10.0, 20.0), + world_size=(5.0, 5.0), + mode="fbm", + octaves=4 + ) + + # Compare several points + for x in [0, 10, 20, 29]: + for y in [0, 10, 20, 29]: + v1 = hmap1.get((x, y)) + v2 = hmap2.get((x, y)) + assert v1 == v2, f"Determinism failed at ({x}, {y}): {v1} != {v2}" + + print(" PASS: Sample determinism") + +def test_sample_different_seeds(): + """Test that different seeds produce different HeightMaps""" + noise1 = mcrfpy.NoiseSource(dimensions=2, seed=42) + noise2 = mcrfpy.NoiseSource(dimensions=2, seed=999) + + hmap1 = noise1.sample(size=(20, 20)) + hmap2 = noise2.sample(size=(20, 20)) + + # At least some values should differ + differences = 0 + for x in range(20): + for y in range(20): + if hmap1.get((x, y)) != hmap2.get((x, y)): + differences += 1 + + assert differences > 0, "Different seeds should produce different results" + print(" PASS: Different seeds produce different HeightMaps") + +def test_sample_heightmap_operations(): + """Test that returned HeightMap supports all HeightMap operations""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + hmap = noise.sample(size=(50, 50), mode="fbm") + + # Test various HeightMap operations + # Normalize to 0-1 range + hmap.normalize(0.0, 1.0) + min_val, max_val = hmap.min_max() + assert abs(min_val - 0.0) < 0.001, f"After normalize: min should be ~0, got {min_val}" + assert abs(max_val - 1.0) < 0.001, f"After normalize: max should be ~1, got {max_val}" + + # Scale + hmap.scale(2.0) + min_val, max_val = hmap.min_max() + assert abs(max_val - 2.0) < 0.001, f"After scale: max should be ~2, got {max_val}" + + # Clamp + hmap.clamp(0.5, 1.5) + min_val, max_val = hmap.min_max() + assert min_val >= 0.5, f"After clamp: min should be >= 0.5, got {min_val}" + assert max_val <= 1.5, f"After clamp: max should be <= 1.5, got {max_val}" + + print(" PASS: HeightMap operations on sampled noise") + +def test_sample_requires_2d(): + """Test that sample() requires 2D NoiseSource""" + for dim in [1, 3, 4]: + noise = mcrfpy.NoiseSource(dimensions=dim, seed=42) + try: + hmap = noise.sample(size=(20, 20)) + print(f" FAIL: sample() should raise ValueError for {dim}D noise") + sys.exit(1) + except ValueError: + pass + + print(" PASS: sample() requires 2D noise") + +def test_sample_invalid_size(): + """Test error handling for invalid size parameter""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Non-tuple + try: + noise.sample(size=50) + print(" FAIL: Should raise TypeError for non-tuple size") + sys.exit(1) + except TypeError: + pass + + # Wrong tuple length + try: + noise.sample(size=(50,)) + print(" FAIL: Should raise TypeError for wrong tuple length") + sys.exit(1) + except TypeError: + pass + + # Zero dimensions + try: + noise.sample(size=(0, 50)) + print(" FAIL: Should raise ValueError for zero width") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Invalid size error handling") + +def test_sample_invalid_mode(): + """Test error handling for invalid mode""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + try: + noise.sample(size=(20, 20), mode="invalid") + print(" FAIL: Should raise ValueError for invalid mode") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Invalid mode error handling") + +def test_sample_contiguous_regions(): + """Test that adjacent samples are contiguous (proper world coordinate mapping)""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + world_size = (10.0, 10.0) + sample_size = (20, 20) + + # Sample left region + left = noise.sample( + size=sample_size, + world_origin=(0.0, 0.0), + world_size=world_size, + mode="fbm" + ) + + # Sample right region (adjacent to left) + right = noise.sample( + size=sample_size, + world_origin=(10.0, 0.0), + world_size=world_size, + mode="fbm" + ) + + # The rightmost column of 'left' should match same world coords + # sampled at leftmost of 'right' - but due to discrete sampling, + # we verify the pattern rather than exact match + + # Verify both samples are valid + assert left.size == sample_size, f"Left sample wrong size" + assert right.size == sample_size, f"Right sample wrong size" + + print(" PASS: Contiguous region sampling") + +def test_sample_large(): + """Test sampling large HeightMap""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + hmap = noise.sample(size=(200, 200), mode="fbm", octaves=6) + + assert hmap.size == (200, 200), f"Expected size (200, 200), got {hmap.size}" + + min_val, max_val = hmap.min_max() + assert min_val >= -1.0 and max_val <= 1.0, "Values out of range" + + print(" PASS: Large sample") + +def run_tests(): + """Run all NoiseSource.sample() tests""" + print("Testing NoiseSource.sample() (Issue #208)...") + + test_basic_sample() + test_sample_world_origin() + test_sample_world_size() + test_sample_modes() + test_sample_octaves() + test_sample_determinism() + test_sample_different_seeds() + test_sample_heightmap_operations() + test_sample_requires_2d() + test_sample_invalid_size() + test_sample_invalid_mode() + test_sample_contiguous_regions() + test_sample_large() + + print("All NoiseSource.sample() tests PASSED!") + return True + +if __name__ == "__main__": + try: + success = run_tests() + sys.exit(0 if success else 1) + except Exception as e: + print(f"FAIL: Unexpected exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/unit/noise_source_test.py b/tests/unit/noise_source_test.py new file mode 100644 index 0000000..7e519c5 --- /dev/null +++ b/tests/unit/noise_source_test.py @@ -0,0 +1,272 @@ +"""Unit tests for NoiseSource class (Issue #207) + +Tests: +- Construction with default and custom parameters +- Read-only property access +- Point query methods (get, fbm, turbulence) +- Determinism (same seed = same results) +- Error handling for invalid inputs +""" +import mcrfpy +import sys + +def test_default_construction(): + """Test NoiseSource with default parameters""" + noise = mcrfpy.NoiseSource() + assert noise.dimensions == 2, f"Expected dimensions=2, got {noise.dimensions}" + assert noise.algorithm == "simplex", f"Expected algorithm='simplex', got {noise.algorithm}" + assert noise.hurst == 0.5, f"Expected hurst=0.5, got {noise.hurst}" + assert noise.lacunarity == 2.0, f"Expected lacunarity=2.0, got {noise.lacunarity}" + assert isinstance(noise.seed, int), f"Expected seed to be int, got {type(noise.seed)}" + print(" PASS: Default construction") + +def test_custom_construction(): + """Test NoiseSource with custom parameters""" + noise = mcrfpy.NoiseSource( + dimensions=3, + algorithm="perlin", + hurst=0.7, + lacunarity=2.5, + seed=12345 + ) + assert noise.dimensions == 3, f"Expected dimensions=3, got {noise.dimensions}" + assert noise.algorithm == "perlin", f"Expected algorithm='perlin', got {noise.algorithm}" + assert abs(noise.hurst - 0.7) < 0.001, f"Expected hurst~=0.7, got {noise.hurst}" + assert abs(noise.lacunarity - 2.5) < 0.001, f"Expected lacunarity~=2.5, got {noise.lacunarity}" + assert noise.seed == 12345, f"Expected seed=12345, got {noise.seed}" + print(" PASS: Custom construction") + +def test_algorithms(): + """Test all supported algorithms""" + for alg in ["simplex", "perlin", "wavelet"]: + noise = mcrfpy.NoiseSource(algorithm=alg, seed=42) + assert noise.algorithm == alg, f"Expected algorithm='{alg}', got {noise.algorithm}" + print(" PASS: All algorithms") + +def test_dimensions(): + """Test valid dimension values (1-4)""" + for dim in [1, 2, 3, 4]: + noise = mcrfpy.NoiseSource(dimensions=dim, seed=42) + assert noise.dimensions == dim, f"Expected dimensions={dim}, got {noise.dimensions}" + print(" PASS: All valid dimensions") + +def test_get_method(): + """Test flat noise get() method""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + value = noise.get((0.0, 0.0)) + assert isinstance(value, float), f"Expected float, got {type(value)}" + assert -1.0 <= value <= 1.0, f"Value {value} out of range [-1, 1]" + + # Test different coordinates + value2 = noise.get((10.5, 20.3)) + assert isinstance(value2, float), f"Expected float, got {type(value2)}" + assert -1.0 <= value2 <= 1.0, f"Value {value2} out of range [-1, 1]" + + # Different coordinates should produce different values (most of the time) + value3 = noise.get((100.0, 200.0)) + assert value != value3 or value == value3, "Values can be equal but typically differ" # This is always true + print(" PASS: get() method") + +def test_fbm_method(): + """Test fractal brownian motion method""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Default octaves + value = noise.fbm((10.0, 20.0)) + assert isinstance(value, float), f"Expected float, got {type(value)}" + assert -1.0 <= value <= 1.0, f"Value {value} out of range [-1, 1]" + + # Custom octaves + value2 = noise.fbm((10.0, 20.0), octaves=6) + assert isinstance(value2, float), f"Expected float, got {type(value2)}" + + # Different octaves should produce different values + value3 = noise.fbm((10.0, 20.0), octaves=2) + # Values with different octaves are typically different + print(" PASS: fbm() method") + +def test_turbulence_method(): + """Test turbulence method""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Default octaves + value = noise.turbulence((10.0, 20.0)) + assert isinstance(value, float), f"Expected float, got {type(value)}" + assert -1.0 <= value <= 1.0, f"Value {value} out of range [-1, 1]" + + # Custom octaves + value2 = noise.turbulence((10.0, 20.0), octaves=6) + assert isinstance(value2, float), f"Expected float, got {type(value2)}" + print(" PASS: turbulence() method") + +def test_determinism(): + """Test that same seed produces same results""" + noise1 = mcrfpy.NoiseSource(dimensions=2, seed=42) + noise2 = mcrfpy.NoiseSource(dimensions=2, seed=42) + + coords = [(0.0, 0.0), (10.5, 20.3), (100.0, 200.0), (-50.0, 75.0)] + + for pos in coords: + v1 = noise1.get(pos) + v2 = noise2.get(pos) + assert v1 == v2, f"Determinism failed at {pos}: {v1} != {v2}" + + fbm1 = noise1.fbm(pos, octaves=4) + fbm2 = noise2.fbm(pos, octaves=4) + assert fbm1 == fbm2, f"FBM determinism failed at {pos}: {fbm1} != {fbm2}" + + turb1 = noise1.turbulence(pos, octaves=4) + turb2 = noise2.turbulence(pos, octaves=4) + assert turb1 == turb2, f"Turbulence determinism failed at {pos}: {turb1} != {turb2}" + + print(" PASS: Determinism") + +def test_different_seeds(): + """Test that different seeds produce different results""" + noise1 = mcrfpy.NoiseSource(dimensions=2, seed=42) + noise2 = mcrfpy.NoiseSource(dimensions=2, seed=999) + + # Check several positions - at least some should differ + coords = [(0.0, 0.0), (10.5, 20.3), (100.0, 200.0)] + differences_found = 0 + + for pos in coords: + v1 = noise1.get(pos) + v2 = noise2.get(pos) + if v1 != v2: + differences_found += 1 + + assert differences_found > 0, "Different seeds should produce different results" + print(" PASS: Different seeds produce different results") + +def test_multidimensional(): + """Test 1D, 3D, and 4D noise""" + # 1D noise + noise1d = mcrfpy.NoiseSource(dimensions=1, seed=42) + v1d = noise1d.get((5.5,)) + assert isinstance(v1d, float), f"1D: Expected float, got {type(v1d)}" + assert -1.0 <= v1d <= 1.0, f"1D: Value {v1d} out of range" + + # 3D noise + noise3d = mcrfpy.NoiseSource(dimensions=3, seed=42) + v3d = noise3d.get((5.5, 10.0, 15.5)) + assert isinstance(v3d, float), f"3D: Expected float, got {type(v3d)}" + assert -1.0 <= v3d <= 1.0, f"3D: Value {v3d} out of range" + + # 4D noise + noise4d = mcrfpy.NoiseSource(dimensions=4, seed=42) + v4d = noise4d.get((5.5, 10.0, 15.5, 20.0)) + assert isinstance(v4d, float), f"4D: Expected float, got {type(v4d)}" + assert -1.0 <= v4d <= 1.0, f"4D: Value {v4d} out of range" + + print(" PASS: Multidimensional noise") + +def test_invalid_dimensions(): + """Test error handling for invalid dimensions""" + # Test dimension 0 + try: + noise = mcrfpy.NoiseSource(dimensions=0) + print(" FAIL: Should raise ValueError for dimensions=0") + sys.exit(1) + except ValueError: + pass + + # Test dimension 5 (exceeds max) + try: + noise = mcrfpy.NoiseSource(dimensions=5) + print(" FAIL: Should raise ValueError for dimensions=5") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Invalid dimensions error handling") + +def test_invalid_algorithm(): + """Test error handling for invalid algorithm""" + try: + noise = mcrfpy.NoiseSource(algorithm="invalid") + print(" FAIL: Should raise ValueError for invalid algorithm") + sys.exit(1) + except ValueError: + pass + print(" PASS: Invalid algorithm error handling") + +def test_dimension_mismatch(): + """Test error handling for position/dimension mismatch""" + noise = mcrfpy.NoiseSource(dimensions=2, seed=42) + + # Too few coordinates + try: + noise.get((5.0,)) + print(" FAIL: Should raise ValueError for wrong dimension count") + sys.exit(1) + except ValueError: + pass + + # Too many coordinates + try: + noise.get((5.0, 10.0, 15.0)) + print(" FAIL: Should raise ValueError for wrong dimension count") + sys.exit(1) + except ValueError: + pass + + print(" PASS: Dimension mismatch error handling") + +def test_repr(): + """Test string representation""" + noise = mcrfpy.NoiseSource(dimensions=2, algorithm="simplex", seed=42) + r = repr(noise) + assert "NoiseSource" in r, f"repr should contain 'NoiseSource': {r}" + assert "2D" in r, f"repr should contain '2D': {r}" + assert "simplex" in r, f"repr should contain 'simplex': {r}" + assert "42" in r, f"repr should contain seed '42': {r}" + print(" PASS: String representation") + +def test_properties_readonly(): + """Test that properties are read-only""" + noise = mcrfpy.NoiseSource(seed=42) + + readonly_props = ['dimensions', 'algorithm', 'hurst', 'lacunarity', 'seed'] + for prop in readonly_props: + try: + setattr(noise, prop, 0) + print(f" FAIL: Property '{prop}' should be read-only") + sys.exit(1) + except AttributeError: + pass + + print(" PASS: Properties are read-only") + +def run_tests(): + """Run all NoiseSource tests""" + print("Testing NoiseSource (Issue #207)...") + + test_default_construction() + test_custom_construction() + test_algorithms() + test_dimensions() + test_get_method() + test_fbm_method() + test_turbulence_method() + test_determinism() + test_different_seeds() + test_multidimensional() + test_invalid_dimensions() + test_invalid_algorithm() + test_dimension_mismatch() + test_repr() + test_properties_readonly() + + print("All NoiseSource tests PASSED!") + return True + +if __name__ == "__main__": + try: + success = run_tests() + sys.exit(0 if success else 1) + except Exception as e: + print(f"FAIL: Unexpected exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1)