From e5d0eb48474b6ed695d80af5230aa3e752a8070e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 12 Jan 2026 19:01:20 -0500 Subject: [PATCH] Noise, combination, and sampling: first pass at #207, #208, #194, #209 --- src/McRFPy_API.cpp | 6 + src/PyHeightMap.cpp | 792 ++++++++++++++++++++++++++++++++++++++++++ src/PyHeightMap.h | 15 + src/PyNoiseSource.cpp | 522 ++++++++++++++++++++++++++++ src/PyNoiseSource.h | 87 +++++ 5 files changed, 1422 insertions(+) create mode 100644 src/PyNoiseSource.cpp create mode 100644 src/PyNoiseSource.h diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 2761ef2..ef29285 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -23,6 +23,7 @@ #include "UIGridPathfinding.h" // AStarPath and DijkstraMap types #include "PyHeightMap.h" // Procedural generation heightmap (#193) #include "PyBSP.h" // Procedural generation BSP (#202-206) +#include "PyNoiseSource.h" // Procedural generation noise (#207-208) #include "McRogueFaceVersion.h" #include "GameEngine.h" #include "ImGuiConsole.h" @@ -420,6 +421,7 @@ PyObject* PyInit_mcrfpy() /*procedural generation (#192)*/ &mcrfpydef::PyHeightMapType, &mcrfpydef::PyBSPType, + &mcrfpydef::PyNoiseSourceType, nullptr}; @@ -460,6 +462,10 @@ PyObject* PyInit_mcrfpy() mcrfpydef::PyBSPNodeType.tp_methods = PyBSPNode::methods; mcrfpydef::PyBSPNodeType.tp_getset = PyBSPNode::getsetters; + // Set up PyNoiseSourceType methods and getsetters (#207-208) + mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods; + mcrfpydef::PyNoiseSourceType.tp_getset = PyNoiseSource::getsetters; + // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp index 7b56e9c..42b4246 100644 --- a/src/PyHeightMap.cpp +++ b/src/PyHeightMap.cpp @@ -2,9 +2,12 @@ #include "McRFPy_API.h" #include "McRFPy_Doc.h" #include "PyPositionHelper.h" // Standardized position argument parsing +#include "PyNoiseSource.h" // For direct noise sampling (#209) +#include "PyBSP.h" // For direct BSP sampling (#209) #include #include // For random seed handling #include // For time-based seeds +#include // For BSP node collection // Property definitions PyGetSetDef PyHeightMap::getsetters[] = { @@ -221,6 +224,126 @@ 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, + MCRF_METHOD(HeightMap, add, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Add another heightmap's values to this one cell-by-cell."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions to add") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"subtract", (PyCFunction)PyHeightMap::subtract, METH_VARARGS, + MCRF_METHOD(HeightMap, subtract, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Subtract another heightmap's values from this one cell-by-cell."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions to subtract") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"multiply", (PyCFunction)PyHeightMap::multiply, METH_VARARGS, + MCRF_METHOD(HeightMap, multiply, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Multiply this heightmap by another cell-by-cell (useful for masking)."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions to multiply by") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"lerp", (PyCFunction)PyHeightMap::lerp, METH_VARARGS, + MCRF_METHOD(HeightMap, lerp, + MCRF_SIG("(other: HeightMap, t: float)", "HeightMap"), + MCRF_DESC("Linear interpolation between this and another heightmap."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions to interpolate towards") + MCRF_ARG("t", "Interpolation factor (0.0 = this, 1.0 = other)") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"copy_from", (PyCFunction)PyHeightMap::copy_from, METH_VARARGS, + MCRF_METHOD(HeightMap, copy_from, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Copy all values from another heightmap."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions to copy from") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"max", (PyCFunction)PyHeightMap::hmap_max, METH_VARARGS, + MCRF_METHOD(HeightMap, max, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Set each cell to the maximum of this and another heightmap."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions") + MCRF_RETURNS("HeightMap: self, for method chaining") + MCRF_RAISES("ValueError", "Dimensions don't match") + )}, + {"min", (PyCFunction)PyHeightMap::hmap_min, METH_VARARGS, + MCRF_METHOD(HeightMap, min, + MCRF_SIG("(other: HeightMap)", "HeightMap"), + MCRF_DESC("Set each cell to the minimum of this and another heightmap."), + MCRF_ARGS_START + MCRF_ARG("other", "HeightMap with same dimensions") + 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, + MCRF_METHOD(HeightMap, add_noise, + MCRF_SIG("(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, " + "mode: str = 'fbm', octaves: int = 4, scale: float = 1.0)", "HeightMap"), + MCRF_DESC("Sample noise and add to current values. More efficient than creating intermediate HeightMap."), + MCRF_ARGS_START + MCRF_ARG("source", "2D NoiseSource to sample from") + MCRF_ARG("world_origin", "World coordinates of top-left (default: (0, 0))") + MCRF_ARG("world_size", "World area to sample (default: HeightMap size)") + MCRF_ARG("mode", "'flat', 'fbm', or 'turbulence' (default: 'fbm')") + MCRF_ARG("octaves", "Octaves for fbm/turbulence (default: 4)") + MCRF_ARG("scale", "Multiplier for sampled values (default: 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"multiply_noise", (PyCFunction)PyHeightMap::multiply_noise, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, multiply_noise, + MCRF_SIG("(source: NoiseSource, world_origin: tuple = (0.0, 0.0), world_size: tuple = None, " + "mode: str = 'fbm', octaves: int = 4, scale: float = 1.0)", "HeightMap"), + MCRF_DESC("Sample noise and multiply with current values. Useful for applying noise-based masks."), + MCRF_ARGS_START + MCRF_ARG("source", "2D NoiseSource to sample from") + MCRF_ARG("world_origin", "World coordinates of top-left (default: (0, 0))") + MCRF_ARG("world_size", "World area to sample (default: HeightMap size)") + MCRF_ARG("mode", "'flat', 'fbm', or 'turbulence' (default: 'fbm')") + MCRF_ARG("octaves", "Octaves for fbm/turbulence (default: 4)") + MCRF_ARG("scale", "Multiplier for sampled values (default: 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"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, " + "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("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)") + MCRF_ARG("value", "Value to add inside regions (default: 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"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, " + "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("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)") + MCRF_ARG("value", "Value to multiply inside regions (default: 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, {NULL} }; @@ -1174,3 +1297,672 @@ PyObject* PyHeightMap::smooth(PyHeightMapObject* self, PyObject* args, PyObject* Py_INCREF(self); return (PyObject*)self; } + +// ============================================================================= +// Combination operations (#194) +// ============================================================================= + +// Helper: Validate other HeightMap and check dimensions match +static PyHeightMapObject* validateOtherHeightMap(PyHeightMapObject* self, PyObject* args, 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) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module"); + return nullptr; + } + + int is_hmap = PyObject_IsInstance(other_obj, heightmap_type); + Py_DECREF(heightmap_type); + + if (is_hmap < 0) { + return nullptr; // Error in isinstance check + } + if (!is_hmap) { + PyErr_Format(PyExc_TypeError, "%s() requires a HeightMap argument", method_name); + return nullptr; + } + + 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) +{ + PyObject* other_obj; + float t; + if (!PyArg_ParseTuple(args, "Of", &other_obj, &t)) { + 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 (!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); + + 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); + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: min(other) -> HeightMap +PyObject* PyHeightMap::hmap_min(PyHeightMapObject* self, PyObject* args) +{ + PyHeightMapObject* other = validateOtherHeightMap(self, args, "min"); + 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); + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// ============================================================================= +// Direct source sampling (#209) +// ============================================================================= + +// Enum for noise sampling mode +enum class NoiseSampleMode { FLAT, FBM, TURBULENCE }; + +// Helper: Parse noise sampling parameters +static bool parseNoiseSampleParams( + PyObject* args, PyObject* kwds, + PyNoiseSourceObject** out_source, + float* out_origin_x, float* out_origin_y, + float* out_world_w, float* out_world_h, + NoiseSampleMode* out_mode, + int* out_octaves, + float* out_scale, + int hmap_w, int hmap_h, + const char* method_name) +{ + static const char* keywords[] = {"source", "world_origin", "world_size", "mode", "octaves", "scale", nullptr}; + PyObject* source_obj = nullptr; + PyObject* origin_obj = nullptr; + PyObject* world_size_obj = nullptr; + const char* mode_str = "fbm"; + int octaves = 4; + float scale = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOsif", const_cast(keywords), + &source_obj, &origin_obj, &world_size_obj, &mode_str, &octaves, &scale)) { + return false; + } + + // Validate source is a NoiseSource + PyObject* noise_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "NoiseSource"); + if (!noise_type) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource type not found in module"); + return false; + } + int is_noise = PyObject_IsInstance(source_obj, noise_type); + Py_DECREF(noise_type); + + if (is_noise < 0) return false; + if (!is_noise) { + PyErr_Format(PyExc_TypeError, "%s() requires a NoiseSource argument", method_name); + return false; + } + + PyNoiseSourceObject* source = (PyNoiseSourceObject*)source_obj; + + // Check NoiseSource is 2D + if (source->dimensions != 2) { + PyErr_Format(PyExc_ValueError, + "%s() requires a 2D NoiseSource, but source has %d dimensions", + method_name, source->dimensions); + return false; + } + + // Check NoiseSource is initialized + if (!source->noise) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource not initialized"); + return false; + } + + // Parse world_origin (default: (0, 0)) + float origin_x = 0.0f, origin_y = 0.0f; + if (origin_obj && origin_obj != Py_None) { + if (!PyTuple_Check(origin_obj) || PyTuple_Size(origin_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "world_origin must be a tuple of (x, y)"); + return false; + } + PyObject* ox = PyTuple_GetItem(origin_obj, 0); + PyObject* oy = PyTuple_GetItem(origin_obj, 1); + if (PyFloat_Check(ox)) origin_x = (float)PyFloat_AsDouble(ox); + else if (PyLong_Check(ox)) origin_x = (float)PyLong_AsLong(ox); + else { PyErr_SetString(PyExc_TypeError, "world_origin values must be numeric"); return false; } + if (PyFloat_Check(oy)) origin_y = (float)PyFloat_AsDouble(oy); + else if (PyLong_Check(oy)) origin_y = (float)PyLong_AsLong(oy); + else { PyErr_SetString(PyExc_TypeError, "world_origin values must be numeric"); return false; } + } + + // Parse world_size (default: HeightMap size) + float world_w = (float)hmap_w, world_h = (float)hmap_h; + if (world_size_obj && world_size_obj != Py_None) { + if (!PyTuple_Check(world_size_obj) || PyTuple_Size(world_size_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "world_size must be a tuple of (width, height)"); + return false; + } + PyObject* ww = PyTuple_GetItem(world_size_obj, 0); + PyObject* wh = PyTuple_GetItem(world_size_obj, 1); + if (PyFloat_Check(ww)) world_w = (float)PyFloat_AsDouble(ww); + else if (PyLong_Check(ww)) world_w = (float)PyLong_AsLong(ww); + else { PyErr_SetString(PyExc_TypeError, "world_size values must be numeric"); return false; } + if (PyFloat_Check(wh)) world_h = (float)PyFloat_AsDouble(wh); + else if (PyLong_Check(wh)) world_h = (float)PyLong_AsLong(wh); + else { PyErr_SetString(PyExc_TypeError, "world_size values must be numeric"); return false; } + } + + // Parse mode + NoiseSampleMode mode; + if (strcmp(mode_str, "flat") == 0) { + mode = NoiseSampleMode::FLAT; + } else if (strcmp(mode_str, "fbm") == 0) { + mode = NoiseSampleMode::FBM; + } else if (strcmp(mode_str, "turbulence") == 0) { + mode = NoiseSampleMode::TURBULENCE; + } else { + PyErr_Format(PyExc_ValueError, + "mode must be 'flat', 'fbm', or 'turbulence', got '%s'", + mode_str); + return false; + } + + // Validate octaves + if (octaves < 1 || octaves > TCOD_NOISE_MAX_OCTAVES) { + PyErr_Format(PyExc_ValueError, + "octaves must be between 1 and %d, got %d", + TCOD_NOISE_MAX_OCTAVES, octaves); + return false; + } + + // Set outputs + *out_source = source; + *out_origin_x = origin_x; + *out_origin_y = origin_y; + *out_world_w = world_w; + *out_world_h = world_h; + *out_mode = mode; + *out_octaves = octaves; + *out_scale = scale; + + return true; +} + +// Method: add_noise(source, ...) -> HeightMap +PyObject* PyHeightMap::add_noise(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyNoiseSourceObject* source; + float origin_x, origin_y, world_w, world_h, scale; + NoiseSampleMode mode; + int octaves; + + if (!parseNoiseSampleParams(args, kwds, &source, + &origin_x, &origin_y, &world_w, &world_h, + &mode, &octaves, &scale, + self->heightmap->w, self->heightmap->h, "add_noise")) { + return nullptr; + } + + // Sample noise and add to heightmap + float coords[2]; + for (int y = 0; y < self->heightmap->h; y++) { + for (int x = 0; x < self->heightmap->w; x++) { + coords[0] = origin_x + ((float)x / (float)self->heightmap->w) * world_w; + coords[1] = origin_y + ((float)y / (float)self->heightmap->h) * world_h; + + float noise_value; + switch (mode) { + case NoiseSampleMode::FLAT: + noise_value = TCOD_noise_get(source->noise, coords); + break; + case NoiseSampleMode::FBM: + noise_value = TCOD_noise_get_fbm(source->noise, coords, (float)octaves); + break; + case NoiseSampleMode::TURBULENCE: + noise_value = TCOD_noise_get_turbulence(source->noise, coords, (float)octaves); + break; + } + + float current = TCOD_heightmap_get_value(self->heightmap, x, y); + TCOD_heightmap_set_value(self->heightmap, x, y, current + noise_value * scale); + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: multiply_noise(source, ...) -> HeightMap +PyObject* PyHeightMap::multiply_noise(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + PyNoiseSourceObject* source; + float origin_x, origin_y, world_w, world_h, scale; + NoiseSampleMode mode; + int octaves; + + if (!parseNoiseSampleParams(args, kwds, &source, + &origin_x, &origin_y, &world_w, &world_h, + &mode, &octaves, &scale, + self->heightmap->w, self->heightmap->h, "multiply_noise")) { + return nullptr; + } + + // Sample noise and multiply with heightmap + float coords[2]; + for (int y = 0; y < self->heightmap->h; y++) { + for (int x = 0; x < self->heightmap->w; x++) { + coords[0] = origin_x + ((float)x / (float)self->heightmap->w) * world_w; + coords[1] = origin_y + ((float)y / (float)self->heightmap->h) * world_h; + + float noise_value; + switch (mode) { + case NoiseSampleMode::FLAT: + noise_value = TCOD_noise_get(source->noise, coords); + break; + case NoiseSampleMode::FBM: + noise_value = TCOD_noise_get_fbm(source->noise, coords, (float)octaves); + break; + case NoiseSampleMode::TURBULENCE: + noise_value = TCOD_noise_get_turbulence(source->noise, coords, (float)octaves); + break; + } + + float current = TCOD_heightmap_get_value(self->heightmap, x, y); + TCOD_heightmap_set_value(self->heightmap, x, y, current * (noise_value * scale)); + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Helper: Collect BSP nodes based on select mode +static bool collectBSPNodes( + PyBSPObject* bsp, + const char* select_str, + PyObject* nodes_list, + std::vector& out_nodes, + const char* method_name) +{ + // If nodes list provided, use it directly + if (nodes_list && nodes_list != Py_None) { + if (!PyList_Check(nodes_list)) { + PyErr_Format(PyExc_TypeError, "%s() nodes must be a list of BSPNode", method_name); + return false; + } + + PyObject* bspnode_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "BSPNode"); + if (!bspnode_type) { + PyErr_SetString(PyExc_RuntimeError, "BSPNode type not found in module"); + return false; + } + + Py_ssize_t count = PyList_Size(nodes_list); + for (Py_ssize_t i = 0; i < count; i++) { + PyObject* item = PyList_GetItem(nodes_list, i); + int is_node = PyObject_IsInstance(item, bspnode_type); + if (is_node < 0) { + Py_DECREF(bspnode_type); + return false; + } + if (!is_node) { + Py_DECREF(bspnode_type); + PyErr_Format(PyExc_TypeError, "%s() nodes[%zd] is not a BSPNode", method_name, i); + return false; + } + + PyBSPNodeObject* node = (PyBSPNodeObject*)item; + if (!PyBSPNode::checkValid(node)) { + Py_DECREF(bspnode_type); + return false; // Error already set + } + out_nodes.push_back(node->node); + } + Py_DECREF(bspnode_type); + return true; + } + + // Determine selection mode + enum class SelectMode { LEAVES, ALL, INTERNAL }; + SelectMode select; + if (strcmp(select_str, "leaves") == 0) { + select = SelectMode::LEAVES; + } else if (strcmp(select_str, "all") == 0) { + select = SelectMode::ALL; + } else if (strcmp(select_str, "internal") == 0) { + select = SelectMode::INTERNAL; + } else { + PyErr_Format(PyExc_ValueError, + "%s() select must be 'leaves', 'all', or 'internal', got '%s'", + method_name, select_str); + return false; + } + + // Collect nodes from BSP tree + // Use post-order traversal to collect all nodes + std::vector stack; + stack.push_back(bsp->root); + + while (!stack.empty()) { + TCOD_bsp_t* node = stack.back(); + stack.pop_back(); + + bool is_leaf = TCOD_bsp_is_leaf(node); + bool include = false; + + switch (select) { + case SelectMode::LEAVES: + include = is_leaf; + break; + case SelectMode::ALL: + include = true; + break; + case SelectMode::INTERNAL: + include = !is_leaf; + break; + } + + if (include) { + out_nodes.push_back(node); + } + + // Add children (if any) using libtcod functions + TCOD_bsp_t* left = TCOD_bsp_left(node); + TCOD_bsp_t* right = TCOD_bsp_right(node); + if (left) stack.push_back(left); + if (right) stack.push_back(right); + } + + return true; +} + +// Method: add_bsp(bsp, ...) -> HeightMap +PyObject* PyHeightMap::add_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + static const char* keywords[] = {"bsp", "select", "nodes", "shrink", "value", nullptr}; + PyObject* bsp_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)) { + return nullptr; + } + + // Validate bsp is a BSP + PyObject* bsp_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "BSP"); + if (!bsp_type) { + PyErr_SetString(PyExc_RuntimeError, "BSP type not found in module"); + return nullptr; + } + int is_bsp = PyObject_IsInstance(bsp_obj, bsp_type); + Py_DECREF(bsp_type); + + if (is_bsp < 0) return nullptr; + if (!is_bsp) { + PyErr_SetString(PyExc_TypeError, "add_bsp() requires a BSP argument"); + return nullptr; + } + + PyBSPObject* bsp = (PyBSPObject*)bsp_obj; + if (!bsp->root) { + PyErr_SetString(PyExc_RuntimeError, "BSP not initialized"); + return nullptr; + } + + // Collect nodes + std::vector nodes; + if (!collectBSPNodes(bsp, select_str, nodes_obj, nodes, "add_bsp")) { + return nullptr; + } + + // Add value to each node's region + 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; + + // Clamp to heightmap bounds and skip if shrunk to nothing + if (x1 >= x2 || y1 >= y2) continue; + if (x1 < 0) x1 = 0; + if (y1 < 0) y1 = 0; + if (x2 > self->heightmap->w) x2 = self->heightmap->w; + if (y2 > self->heightmap->h) y2 = self->heightmap->h; + + for (int y = y1; y < y2; y++) { + for (int x = x1; x < x2; x++) { + float current = TCOD_heightmap_get_value(self->heightmap, x, y); + TCOD_heightmap_set_value(self->heightmap, x, y, current + value); + } + } + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: multiply_bsp(bsp, ...) -> HeightMap +PyObject* PyHeightMap::multiply_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + static const char* keywords[] = {"bsp", "select", "nodes", "shrink", "value", nullptr}; + PyObject* bsp_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)) { + return nullptr; + } + + // Validate bsp is a BSP + PyObject* bsp_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "BSP"); + if (!bsp_type) { + PyErr_SetString(PyExc_RuntimeError, "BSP type not found in module"); + return nullptr; + } + int is_bsp = PyObject_IsInstance(bsp_obj, bsp_type); + Py_DECREF(bsp_type); + + if (is_bsp < 0) return nullptr; + if (!is_bsp) { + PyErr_SetString(PyExc_TypeError, "multiply_bsp() requires a BSP argument"); + return nullptr; + } + + PyBSPObject* bsp = (PyBSPObject*)bsp_obj; + if (!bsp->root) { + PyErr_SetString(PyExc_RuntimeError, "BSP not initialized"); + return nullptr; + } + + // 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 + 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; + + // Clamp and skip if invalid + if (x1 >= x2 || y1 >= y2) continue; + if (x1 < 0) x1 = 0; + if (y1 < 0) y1 = 0; + if (x2 > self->heightmap->w) x2 = self->heightmap->w; + if (y2 > self->heightmap->h) y2 = self->heightmap->h; + + for (int y = y1; y < y2; y++) { + for (int x = x1; x < x2; x++) { + in_region[y * self->heightmap->w + x] = true; + } + } + } + + // Now 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); + if (in_region[y * self->heightmap->w + x]) { + TCOD_heightmap_set_value(self->heightmap, x, y, current * value); + } else { + TCOD_heightmap_set_value(self->heightmap, x, y, 0.0f); + } + } + } + + Py_INCREF(self); + return (PyObject*)self; +} diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h index 841a6ea..1e00f10 100644 --- a/src/PyHeightMap.h +++ b/src/PyHeightMap.h @@ -57,6 +57,21 @@ 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 + + // Direct source sampling (#209) - sample from NoiseSource/BSP directly + static PyObject* add_noise(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* multiply_noise(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* add_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* multiply_bsp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + // Mapping methods for subscript support static PyMappingMethods mapping_methods; diff --git a/src/PyNoiseSource.cpp b/src/PyNoiseSource.cpp new file mode 100644 index 0000000..83792cf --- /dev/null +++ b/src/PyNoiseSource.cpp @@ -0,0 +1,522 @@ +#include "PyNoiseSource.h" +#include "PyHeightMap.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include +#include +#include +#include + +// Property definitions +PyGetSetDef PyNoiseSource::getsetters[] = { + {"dimensions", (getter)PyNoiseSource::get_dimensions, NULL, + MCRF_PROPERTY(dimensions, "Number of input dimensions (1-4). Read-only."), NULL}, + {"algorithm", (getter)PyNoiseSource::get_algorithm, NULL, + MCRF_PROPERTY(algorithm, "Noise algorithm name ('simplex', 'perlin', or 'wavelet'). Read-only."), NULL}, + {"hurst", (getter)PyNoiseSource::get_hurst, NULL, + MCRF_PROPERTY(hurst, "Hurst exponent for fbm/turbulence. Read-only."), NULL}, + {"lacunarity", (getter)PyNoiseSource::get_lacunarity, NULL, + MCRF_PROPERTY(lacunarity, "Frequency multiplier between octaves. Read-only."), NULL}, + {"seed", (getter)PyNoiseSource::get_seed, NULL, + MCRF_PROPERTY(seed, "Random seed used (even if originally None). Read-only."), NULL}, + {NULL} +}; + +// Method definitions +PyMethodDef PyNoiseSource::methods[] = { + {"get", (PyCFunction)PyNoiseSource::get, METH_VARARGS, + MCRF_METHOD(NoiseSource, get, + MCRF_SIG("(pos: tuple[float, ...])", "float"), + MCRF_DESC("Get flat noise value at coordinates."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position tuple with length matching dimensions") + MCRF_RETURNS("float: Noise value in range [-1.0, 1.0]") + MCRF_RAISES("ValueError", "Position tuple length doesn't match dimensions") + )}, + {"fbm", (PyCFunction)PyNoiseSource::fbm, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(NoiseSource, fbm, + MCRF_SIG("(pos: tuple[float, ...], octaves: int = 4)", "float"), + MCRF_DESC("Get fractal brownian motion value at coordinates."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position tuple with length matching dimensions") + MCRF_ARG("octaves", "Number of noise octaves to combine (default: 4)") + MCRF_RETURNS("float: FBM noise value in range [-1.0, 1.0]") + MCRF_RAISES("ValueError", "Position tuple length doesn't match dimensions") + )}, + {"turbulence", (PyCFunction)PyNoiseSource::turbulence, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(NoiseSource, turbulence, + MCRF_SIG("(pos: tuple[float, ...], octaves: int = 4)", "float"), + MCRF_DESC("Get turbulence (absolute fbm) value at coordinates."), + MCRF_ARGS_START + MCRF_ARG("pos", "Position tuple with length matching dimensions") + MCRF_ARG("octaves", "Number of noise octaves to combine (default: 4)") + MCRF_RETURNS("float: Turbulence noise value in range [-1.0, 1.0]") + MCRF_RAISES("ValueError", "Position tuple length doesn't match dimensions") + )}, + {"sample", (PyCFunction)PyNoiseSource::sample, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(NoiseSource, sample, + MCRF_SIG("(size: tuple[int, int], world_origin: tuple[float, float] = (0.0, 0.0), " + "world_size: tuple[float, float] = None, mode: str = 'fbm', octaves: int = 4)", "HeightMap"), + MCRF_DESC("Sample noise into a HeightMap for batch processing."), + MCRF_ARGS_START + MCRF_ARG("size", "Output dimensions in cells as (width, height)") + MCRF_ARG("world_origin", "World coordinates of top-left corner (default: (0, 0))") + MCRF_ARG("world_size", "World area to sample (default: same as size)") + MCRF_ARG("mode", "Sampling mode: 'flat', 'fbm', or 'turbulence' (default: 'fbm')") + MCRF_ARG("octaves", "Octaves for fbm/turbulence modes (default: 4)") + MCRF_RETURNS("HeightMap: New HeightMap filled with sampled noise values") + MCRF_NOTE("Requires dimensions=2. Values are in range [-1.0, 1.0].") + )}, + {NULL} +}; + +// Helper: Convert algorithm enum to string +static const char* algorithm_to_string(TCOD_noise_type_t alg) { + switch (alg) { + case TCOD_NOISE_PERLIN: return "perlin"; + case TCOD_NOISE_SIMPLEX: return "simplex"; + case TCOD_NOISE_WAVELET: return "wavelet"; + default: return "unknown"; + } +} + +// Helper: Convert string to algorithm enum +static bool string_to_algorithm(const char* str, TCOD_noise_type_t* out) { + if (strcmp(str, "simplex") == 0) { + *out = TCOD_NOISE_SIMPLEX; + return true; + } else if (strcmp(str, "perlin") == 0) { + *out = TCOD_NOISE_PERLIN; + return true; + } else if (strcmp(str, "wavelet") == 0) { + *out = TCOD_NOISE_WAVELET; + return true; + } + return false; +} + +// Helper: Parse position tuple and validate dimensions +static bool parse_position(PyObject* pos_obj, int expected_dims, float* coords) { + if (!PyTuple_Check(pos_obj) && !PyList_Check(pos_obj)) { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple or list"); + return false; + } + + Py_ssize_t size = PyTuple_Check(pos_obj) ? PyTuple_Size(pos_obj) : PyList_Size(pos_obj); + if (size != expected_dims) { + PyErr_Format(PyExc_ValueError, + "Position has %zd coordinates, but NoiseSource has %d dimensions", + size, expected_dims); + return false; + } + + for (Py_ssize_t i = 0; i < size; i++) { + PyObject* item = PyTuple_Check(pos_obj) ? PyTuple_GetItem(pos_obj, i) : PyList_GetItem(pos_obj, i); + if (PyFloat_Check(item)) { + coords[i] = (float)PyFloat_AsDouble(item); + } else if (PyLong_Check(item)) { + coords[i] = (float)PyLong_AsLong(item); + } else { + PyErr_Format(PyExc_TypeError, "Coordinate %zd must be a number", i); + return false; + } + } + + return true; +} + +// Constructor +PyObject* PyNoiseSource::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) +{ + PyNoiseSourceObject* self = (PyNoiseSourceObject*)type->tp_alloc(type, 0); + if (self) { + self->noise = nullptr; + self->dimensions = 2; + self->algorithm = TCOD_NOISE_SIMPLEX; + self->hurst = TCOD_NOISE_DEFAULT_HURST; + self->lacunarity = TCOD_NOISE_DEFAULT_LACUNARITY; + self->seed = 0; + } + return (PyObject*)self; +} + +int PyNoiseSource::init(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"dimensions", "algorithm", "hurst", "lacunarity", "seed", nullptr}; + int dimensions = 2; + const char* algorithm_str = "simplex"; + float hurst = TCOD_NOISE_DEFAULT_HURST; + float lacunarity = TCOD_NOISE_DEFAULT_LACUNARITY; + PyObject* seed_obj = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|isffO", const_cast(keywords), + &dimensions, &algorithm_str, &hurst, &lacunarity, &seed_obj)) { + return -1; + } + + // Validate dimensions + if (dimensions < 1 || dimensions > TCOD_NOISE_MAX_DIMENSIONS) { + PyErr_Format(PyExc_ValueError, + "dimensions must be between 1 and %d, got %d", + TCOD_NOISE_MAX_DIMENSIONS, dimensions); + return -1; + } + + // Parse algorithm + TCOD_noise_type_t algorithm; + if (!string_to_algorithm(algorithm_str, &algorithm)) { + PyErr_Format(PyExc_ValueError, + "algorithm must be 'simplex', 'perlin', or 'wavelet', got '%s'", + algorithm_str); + return -1; + } + + // Handle seed - generate random if None + uint32_t seed; + if (seed_obj == nullptr || seed_obj == Py_None) { + // Generate random seed using C++ random facilities + std::random_device rd; + seed = rd(); + } else if (PyLong_Check(seed_obj)) { + seed = (uint32_t)PyLong_AsUnsignedLong(seed_obj); + if (PyErr_Occurred()) { + return -1; + } + } else { + PyErr_SetString(PyExc_TypeError, "seed must be an integer or None"); + return -1; + } + + // Clean up any existing noise object + if (self->noise) { + TCOD_noise_delete(self->noise); + } + + // Create TCOD random generator with the seed + TCOD_Random* rng = TCOD_random_new_from_seed(TCOD_RNG_MT, seed); + if (!rng) { + PyErr_SetString(PyExc_MemoryError, "Failed to create random generator"); + return -1; + } + + // Create noise object + self->noise = TCOD_noise_new(dimensions, hurst, lacunarity, rng); + if (!self->noise) { + TCOD_random_delete(rng); + PyErr_SetString(PyExc_MemoryError, "Failed to create noise object"); + return -1; + } + + // Set the algorithm + TCOD_noise_set_type(self->noise, algorithm); + + // Store configuration + self->dimensions = dimensions; + self->algorithm = algorithm; + self->hurst = hurst; + self->lacunarity = lacunarity; + self->seed = seed; + + return 0; +} + +void PyNoiseSource::dealloc(PyNoiseSourceObject* self) +{ + if (self->noise) { + // The TCOD_Noise owns its random generator, so deleting noise also deletes rng + TCOD_noise_delete(self->noise); + self->noise = nullptr; + } + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyNoiseSource::repr(PyObject* obj) +{ + PyNoiseSourceObject* self = (PyNoiseSourceObject*)obj; + std::ostringstream ss; + + if (self->noise) { + ss << "dimensions << "D " + << algorithm_to_string(self->algorithm) + << " seed=" << self->seed << ">"; + } else { + ss << ""; + } + + return PyUnicode_FromString(ss.str().c_str()); +} + +// Properties + +PyObject* PyNoiseSource::get_dimensions(PyNoiseSourceObject* self, void* closure) +{ + return PyLong_FromLong(self->dimensions); +} + +PyObject* PyNoiseSource::get_algorithm(PyNoiseSourceObject* self, void* closure) +{ + return PyUnicode_FromString(algorithm_to_string(self->algorithm)); +} + +PyObject* PyNoiseSource::get_hurst(PyNoiseSourceObject* self, void* closure) +{ + return PyFloat_FromDouble(self->hurst); +} + +PyObject* PyNoiseSource::get_lacunarity(PyNoiseSourceObject* self, void* closure) +{ + return PyFloat_FromDouble(self->lacunarity); +} + +PyObject* PyNoiseSource::get_seed(PyNoiseSourceObject* self, void* closure) +{ + return PyLong_FromUnsignedLong(self->seed); +} + +// Point query methods + +PyObject* PyNoiseSource::get(PyNoiseSourceObject* self, PyObject* args) +{ + if (!self->noise) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource not initialized"); + return nullptr; + } + + PyObject* pos_obj; + if (!PyArg_ParseTuple(args, "O", &pos_obj)) { + return nullptr; + } + + float coords[TCOD_NOISE_MAX_DIMENSIONS]; + if (!parse_position(pos_obj, self->dimensions, coords)) { + return nullptr; + } + + float value = TCOD_noise_get(self->noise, coords); + return PyFloat_FromDouble(value); +} + +PyObject* PyNoiseSource::fbm(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->noise) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource not initialized"); + return nullptr; + } + + static const char* keywords[] = {"pos", "octaves", nullptr}; + PyObject* pos_obj; + int octaves = 4; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast(keywords), + &pos_obj, &octaves)) { + return nullptr; + } + + if (octaves < 1 || octaves > TCOD_NOISE_MAX_OCTAVES) { + PyErr_Format(PyExc_ValueError, + "octaves must be between 1 and %d, got %d", + TCOD_NOISE_MAX_OCTAVES, octaves); + return nullptr; + } + + float coords[TCOD_NOISE_MAX_DIMENSIONS]; + if (!parse_position(pos_obj, self->dimensions, coords)) { + return nullptr; + } + + float value = TCOD_noise_get_fbm(self->noise, coords, (float)octaves); + return PyFloat_FromDouble(value); +} + +PyObject* PyNoiseSource::turbulence(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->noise) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource not initialized"); + return nullptr; + } + + static const char* keywords[] = {"pos", "octaves", nullptr}; + PyObject* pos_obj; + int octaves = 4; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", const_cast(keywords), + &pos_obj, &octaves)) { + return nullptr; + } + + if (octaves < 1 || octaves > TCOD_NOISE_MAX_OCTAVES) { + PyErr_Format(PyExc_ValueError, + "octaves must be between 1 and %d, got %d", + TCOD_NOISE_MAX_OCTAVES, octaves); + return nullptr; + } + + float coords[TCOD_NOISE_MAX_DIMENSIONS]; + if (!parse_position(pos_obj, self->dimensions, coords)) { + return nullptr; + } + + float value = TCOD_noise_get_turbulence(self->noise, coords, (float)octaves); + return PyFloat_FromDouble(value); +} + +// Batch sampling method - returns HeightMap + +PyObject* PyNoiseSource::sample(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds) +{ + if (!self->noise) { + PyErr_SetString(PyExc_RuntimeError, "NoiseSource not initialized"); + return nullptr; + } + + // sample() only works for 2D noise + if (self->dimensions != 2) { + PyErr_Format(PyExc_ValueError, + "sample() requires 2D NoiseSource, but this NoiseSource has %d dimensions", + self->dimensions); + return nullptr; + } + + static const char* keywords[] = {"size", "world_origin", "world_size", "mode", "octaves", nullptr}; + PyObject* size_obj = nullptr; + PyObject* origin_obj = nullptr; + PyObject* world_size_obj = nullptr; + const char* mode_str = "fbm"; + int octaves = 4; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOsi", const_cast(keywords), + &size_obj, &origin_obj, &world_size_obj, &mode_str, &octaves)) { + return nullptr; + } + + // Parse size + int width, height; + if (!PyTuple_Check(size_obj) || PyTuple_Size(size_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "size must be a tuple of (width, height)"); + return nullptr; + } + width = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 0)); + height = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 1)); + if (PyErr_Occurred()) { + return nullptr; + } + if (width <= 0 || height <= 0) { + PyErr_SetString(PyExc_ValueError, "size dimensions must be positive"); + return nullptr; + } + + // Parse world_origin (default: (0, 0)) + float origin_x = 0.0f, origin_y = 0.0f; + if (origin_obj && origin_obj != Py_None) { + if (!PyTuple_Check(origin_obj) || PyTuple_Size(origin_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "world_origin must be a tuple of (x, y)"); + return nullptr; + } + PyObject* ox = PyTuple_GetItem(origin_obj, 0); + PyObject* oy = PyTuple_GetItem(origin_obj, 1); + if (PyFloat_Check(ox)) origin_x = (float)PyFloat_AsDouble(ox); + else if (PyLong_Check(ox)) origin_x = (float)PyLong_AsLong(ox); + else { PyErr_SetString(PyExc_TypeError, "world_origin values must be numeric"); return nullptr; } + if (PyFloat_Check(oy)) origin_y = (float)PyFloat_AsDouble(oy); + else if (PyLong_Check(oy)) origin_y = (float)PyLong_AsLong(oy); + else { PyErr_SetString(PyExc_TypeError, "world_origin values must be numeric"); return nullptr; } + } + + // Parse world_size (default: same as size) + float world_w = (float)width, world_h = (float)height; + if (world_size_obj && world_size_obj != Py_None) { + if (!PyTuple_Check(world_size_obj) || PyTuple_Size(world_size_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "world_size must be a tuple of (width, height)"); + return nullptr; + } + PyObject* ww = PyTuple_GetItem(world_size_obj, 0); + PyObject* wh = PyTuple_GetItem(world_size_obj, 1); + if (PyFloat_Check(ww)) world_w = (float)PyFloat_AsDouble(ww); + else if (PyLong_Check(ww)) world_w = (float)PyLong_AsLong(ww); + else { PyErr_SetString(PyExc_TypeError, "world_size values must be numeric"); return nullptr; } + if (PyFloat_Check(wh)) world_h = (float)PyFloat_AsDouble(wh); + else if (PyLong_Check(wh)) world_h = (float)PyLong_AsLong(wh); + else { PyErr_SetString(PyExc_TypeError, "world_size values must be numeric"); return nullptr; } + } + + // Parse mode + enum class SampleMode { FLAT, FBM, TURBULENCE }; + SampleMode mode; + if (strcmp(mode_str, "flat") == 0) { + mode = SampleMode::FLAT; + } else if (strcmp(mode_str, "fbm") == 0) { + mode = SampleMode::FBM; + } else if (strcmp(mode_str, "turbulence") == 0) { + mode = SampleMode::TURBULENCE; + } else { + PyErr_Format(PyExc_ValueError, + "mode must be 'flat', 'fbm', or 'turbulence', got '%s'", + mode_str); + return nullptr; + } + + // Validate octaves + if (octaves < 1 || octaves > TCOD_NOISE_MAX_OCTAVES) { + PyErr_Format(PyExc_ValueError, + "octaves must be between 1 and %d, got %d", + TCOD_NOISE_MAX_OCTAVES, octaves); + return nullptr; + } + + // Create HeightMap + PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap"); + if (!heightmap_type) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module"); + return nullptr; + } + + PyObject* size_tuple = Py_BuildValue("(ii)", width, height); + if (!size_tuple) { + Py_DECREF(heightmap_type); + return nullptr; + } + + PyObject* hmap_args = PyTuple_Pack(1, size_tuple); + Py_DECREF(size_tuple); + if (!hmap_args) { + Py_DECREF(heightmap_type); + return nullptr; + } + + PyHeightMapObject* hmap = (PyHeightMapObject*)PyObject_Call(heightmap_type, hmap_args, nullptr); + Py_DECREF(hmap_args); + Py_DECREF(heightmap_type); + + if (!hmap) { + return nullptr; + } + + // Sample noise into the heightmap + // Formula: For output cell (x, y), sample world coordinate: + // wx = world_origin[0] + (x / size[0]) * world_size[0] + // wy = world_origin[1] + (y / size[1]) * world_size[1] + float coords[2]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + coords[0] = origin_x + ((float)x / (float)width) * world_w; + coords[1] = origin_y + ((float)y / (float)height) * world_h; + + float value; + switch (mode) { + case SampleMode::FLAT: + value = TCOD_noise_get(self->noise, coords); + break; + case SampleMode::FBM: + value = TCOD_noise_get_fbm(self->noise, coords, (float)octaves); + break; + case SampleMode::TURBULENCE: + value = TCOD_noise_get_turbulence(self->noise, coords, (float)octaves); + break; + } + + TCOD_heightmap_set_value(hmap->heightmap, x, y, value); + } + } + + return (PyObject*)hmap; +} diff --git a/src/PyNoiseSource.h b/src/PyNoiseSource.h new file mode 100644 index 0000000..47f810b --- /dev/null +++ b/src/PyNoiseSource.h @@ -0,0 +1,87 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include +#include + +// Forward declaration +class PyNoiseSource; + +// Python object structure for NoiseSource +typedef struct { + PyObject_HEAD + TCOD_Noise* noise; // libtcod noise object (owned) + int dimensions; // 1-4 + TCOD_noise_type_t algorithm; // PERLIN, SIMPLEX, or WAVELET + float hurst; // Hurst exponent for fbm/turbulence + float lacunarity; // Frequency multiplier between octaves + uint32_t seed; // Random seed (stored even if auto-generated) +} PyNoiseSourceObject; + +class PyNoiseSource +{ +public: + // Python type interface + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyNoiseSourceObject* self); + static PyObject* repr(PyObject* obj); + + // Properties (all read-only) + static PyObject* get_dimensions(PyNoiseSourceObject* self, void* closure); + static PyObject* get_algorithm(PyNoiseSourceObject* self, void* closure); + static PyObject* get_hurst(PyNoiseSourceObject* self, void* closure); + static PyObject* get_lacunarity(PyNoiseSourceObject* self, void* closure); + static PyObject* get_seed(PyNoiseSourceObject* self, void* closure); + + // Point query methods (#207) + static PyObject* get(PyNoiseSourceObject* self, PyObject* args); + static PyObject* fbm(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds); + static PyObject* turbulence(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds); + + // Batch sampling method (#208) - returns HeightMap + static PyObject* sample(PyNoiseSourceObject* self, PyObject* args, PyObject* kwds); + + // Method and property definitions + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + inline PyTypeObject PyNoiseSourceType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.NoiseSource", + .tp_basicsize = sizeof(PyNoiseSourceObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyNoiseSource::dealloc, + .tp_repr = PyNoiseSource::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "NoiseSource(dimensions: int = 2, algorithm: str = 'simplex', hurst: float = 0.5, lacunarity: float = 2.0, seed: int = None)\n\n" + "A configured noise generator for procedural generation.\n\n" + "NoiseSource wraps libtcod's noise generator, providing coherent noise values " + "that can be used for terrain generation, textures, and other procedural content. " + "The same coordinates always produce the same value (deterministic).\n\n" + "Args:\n" + " dimensions: Number of input dimensions (1-4). Default: 2.\n" + " algorithm: Noise algorithm - 'simplex', 'perlin', or 'wavelet'. Default: 'simplex'.\n" + " hurst: Fractal Hurst exponent for fbm/turbulence (0.0-1.0). Default: 0.5.\n" + " lacunarity: Frequency multiplier between octaves. Default: 2.0.\n" + " seed: Random seed for reproducibility. None for random seed.\n\n" + "Properties:\n" + " dimensions (int): Read-only. Number of input dimensions.\n" + " algorithm (str): Read-only. Noise algorithm name.\n" + " hurst (float): Read-only. Hurst exponent.\n" + " lacunarity (float): Read-only. Lacunarity value.\n" + " seed (int): Read-only. Seed used (even if originally None).\n\n" + "Example:\n" + " noise = mcrfpy.NoiseSource(dimensions=2, algorithm='simplex', seed=42)\n" + " value = noise.get((10.5, 20.3)) # Returns -1.0 to 1.0\n" + " fbm_val = noise.fbm((10.5, 20.3), octaves=6)\n" + ), + .tp_methods = nullptr, // Set in McRFPy_API.cpp + .tp_getset = nullptr, // Set in McRFPy_API.cpp + .tp_init = (initproc)PyNoiseSource::init, + .tp_new = PyNoiseSource::pynew, + }; +}