diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp index c227cdb..98e3101 100644 --- a/src/PyHeightMap.cpp +++ b/src/PyHeightMap.cpp @@ -9,6 +9,7 @@ #include // For time-based seeds #include // For BSP node collection #include // For std::min +#include // For FLT_MAX // ============================================================================= // Region Parameter System - standardized handling of pos, source_pos, size @@ -224,7 +225,7 @@ PyGetSetDef PyHeightMap::getsetters[] = { PyMappingMethods PyHeightMap::mapping_methods = { .mp_length = nullptr, // __len__ not needed .mp_subscript = (binaryfunc)PyHeightMap::subscript, // __getitem__ - .mp_ass_subscript = nullptr // __setitem__ (read-only for now) + .mp_ass_subscript = (objobjargproc)PyHeightMap::subscript_assign // __setitem__ }; // Method definitions @@ -438,16 +439,58 @@ PyMethodDef PyHeightMap::methods[] = { MCRF_ARG("iterations", "Number of smoothing passes (default 1)") MCRF_RETURNS("HeightMap: self, for method chaining") )}, - {"kernel_transform", (PyCFunction)PyHeightMap::kernel_transform, METH_VARARGS | METH_KEYWORDS, - MCRF_METHOD(HeightMap, kernel_transform, - MCRF_SIG("(weights: dict[tuple[int, int], float], *, min: float = 0.0, max: float = 1e6)", "HeightMap"), - MCRF_DESC("Apply a convolution kernel to the heightmap. Keys are (dx, dy) offsets, values are weights."), + // Convolution methods (libtcod 2.2.2+) + {"sparse_kernel", (PyCFunction)PyHeightMap::sparse_kernel, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, sparse_kernel, + MCRF_SIG("(weights: dict[tuple[int, int], float], *, min_level: float = -inf, max_level: float = inf)", "HeightMap"), + MCRF_DESC("Apply sparse convolution kernel, returning a NEW HeightMap with results."), MCRF_ARGS_START MCRF_ARG("weights", "Dict mapping (dx, dy) offsets to weight values") - MCRF_ARG("min", "Only transform cells with value >= min (default: 0.0)") - MCRF_ARG("max", "Only transform cells with value <= max (default: 1e6)") - MCRF_RETURNS("HeightMap: self, for method chaining") - MCRF_NOTE("Use for edge detection, blur, sharpen, and other convolution effects") + MCRF_ARG("min_level", "Only transform cells with value >= min_level (default: -inf)") + MCRF_ARG("max_level", "Only transform cells with value <= max_level (default: inf)") + MCRF_RETURNS("HeightMap: new heightmap with convolution result") + )}, + {"sparse_kernel_from", (PyCFunction)PyHeightMap::sparse_kernel_from, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, sparse_kernel_from, + MCRF_SIG("(source: HeightMap, weights: dict[tuple[int, int], float], *, min_level: float = -inf, max_level: float = inf)", "None"), + MCRF_DESC("Apply sparse convolution from source heightmap into self (for reusing destination buffers)."), + MCRF_ARGS_START + MCRF_ARG("source", "Source HeightMap to convolve from") + MCRF_ARG("weights", "Dict mapping (dx, dy) offsets to weight values") + MCRF_ARG("min_level", "Only transform cells with value >= min_level (default: -inf)") + MCRF_ARG("max_level", "Only transform cells with value <= max_level (default: inf)") + MCRF_RETURNS("None") + )}, + {"kernel3", (PyCFunction)PyHeightMap::kernel3, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, kernel3, + MCRF_SIG("(weights: Sequence[float], *, normalize: bool = True)", "HeightMap"), + MCRF_DESC("Apply 3x3 convolution kernel, returning a NEW HeightMap with results."), + MCRF_ARGS_START + MCRF_ARG("weights", "9 floats as flat list [w0..w8] or nested [[r0],[r1],[r2]]") + MCRF_ARG("normalize", "Divide result by sum of weights (default: True)") + MCRF_RETURNS("HeightMap: new heightmap with convolution result") + MCRF_NOTE("Kernel layout: [0,1,2] = top row, [3,4,5] = middle, [6,7,8] = bottom") + )}, + {"kernel3_from", (PyCFunction)PyHeightMap::kernel3_from, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, kernel3_from, + MCRF_SIG("(source: HeightMap, weights: Sequence[float], *, normalize: bool = True)", "None"), + MCRF_DESC("Apply 3x3 convolution from source heightmap into self (for reusing destination buffers)."), + MCRF_ARGS_START + MCRF_ARG("source", "Source HeightMap to convolve from") + MCRF_ARG("weights", "9 floats as flat list [w0..w8] or nested [[r0],[r1],[r2]]") + MCRF_ARG("normalize", "Divide result by sum of weights (default: True)") + MCRF_RETURNS("None") + MCRF_NOTE("Kernel layout: [0,1,2] = top row, [3,4,5] = middle, [6,7,8] = bottom") + )}, + {"gradients", (PyCFunction)PyHeightMap::gradients, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, gradients, + MCRF_SIG("(dx=True, dy=True)", "HeightMap | tuple[HeightMap, HeightMap] | None"), + MCRF_DESC("Compute gradient (partial derivatives) of the heightmap."), + MCRF_ARGS_START + MCRF_ARG("dx", "HeightMap to write dx into, True to create new, False to skip") + MCRF_ARG("dy", "HeightMap to write dy into, True to create new, False to skip") + MCRF_RETURNS("Depends on args: (dx, dy) tuple, single HeightMap, or None") + MCRF_NOTE("Pass existing HeightMaps for dx/dy to reuse buffers in hot loops") )}, // Combination operations (#194) - with region support {"add", (PyCFunction)PyHeightMap::add, METH_VARARGS | METH_KEYWORDS, @@ -1112,6 +1155,48 @@ PyObject* PyHeightMap::subscript(PyHeightMapObject* self, PyObject* key) return PyFloat_FromDouble(value); } +// Subscript assign: hmap[x, y] = value (shorthand for set) +int PyHeightMap::subscript_assign(PyHeightMapObject* self, PyObject* key, PyObject* value) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return -1; + } + + // Handle deletion (not supported) + if (value == nullptr) { + PyErr_SetString(PyExc_TypeError, "cannot delete HeightMap elements"); + return -1; + } + + int x, y; + if (!PyPosition_FromObjectInt(key, &x, &y)) { + return -1; + } + + // Bounds check + if (x < 0 || x >= self->heightmap->w || y < 0 || y >= self->heightmap->h) { + PyErr_Format(PyExc_IndexError, + "Position (%d, %d) out of bounds for HeightMap of size (%d, %d)", + x, y, self->heightmap->w, self->heightmap->h); + return -1; + } + + // Parse value as float + float fval; + if (PyFloat_Check(value)) { + fval = static_cast(PyFloat_AsDouble(value)); + } else if (PyLong_Check(value)) { + fval = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "value must be numeric (int or float)"); + return -1; + } + + TCOD_heightmap_set_value(self->heightmap, x, y, fval); + return 0; // Success +} + // Threshold operations (#197) - return NEW HeightMaps // Helper: Parse range from tuple or list @@ -1148,6 +1233,9 @@ static bool ParseRange(PyObject* range_obj, float* min_val, float* max_val) return !PyErr_Occurred(); } +// Forward declaration for helper used by convolution methods +static PyHeightMapObject* validateOtherHeightMapType(PyObject* other_obj, const char* method_name); + // Helper: Create a new HeightMap object with same dimensions static PyHeightMapObject* CreateNewHeightMap(int width, int height) { @@ -1630,14 +1718,219 @@ PyObject* PyHeightMap::smooth(PyHeightMapObject* self, PyObject* args, PyObject* return (PyObject*)self; } -// kernel_transform - apply custom convolution kernel (#198) -PyObject* PyHeightMap::kernel_transform(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +// ============================================================================= +// Convolution methods (libtcod 2.2.2+) +// ============================================================================= + +// Helper: Parse weights dict into arrays for sparse kernel +// Returns kernel_size on success, -1 on error +static Py_ssize_t ParseWeightsDict(PyObject* weights_dict, + std::vector& dx, + std::vector& dy, + std::vector& weight) +{ + if (!PyDict_Check(weights_dict)) { + PyErr_SetString(PyExc_TypeError, "weights must be a dict"); + return -1; + } + + Py_ssize_t kernel_size = PyDict_Size(weights_dict); + if (kernel_size <= 0) { + PyErr_SetString(PyExc_ValueError, "weights dict cannot be empty"); + return -1; + } + + dx.resize(kernel_size); + dy.resize(kernel_size); + weight.resize(kernel_size); + + PyObject* key; + PyObject* value; + Py_ssize_t pos = 0; + Py_ssize_t idx = 0; + + while (PyDict_Next(weights_dict, &pos, &key, &value)) { + int key_dx = 0, key_dy = 0; + + if (PyTuple_Check(key) && PyTuple_Size(key) == 2) { + PyObject* x_obj = PyTuple_GetItem(key, 0); + PyObject* y_obj = PyTuple_GetItem(key, 1); + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "weights keys must be (int, int) tuples"); + return -1; + } + key_dx = PyLong_AsLong(x_obj); + key_dy = PyLong_AsLong(y_obj); + } else if (PyList_Check(key) && PyList_Size(key) == 2) { + PyObject* x_obj = PyList_GetItem(key, 0); + PyObject* y_obj = PyList_GetItem(key, 1); + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "weights keys must be [int, int] lists"); + return -1; + } + key_dx = PyLong_AsLong(x_obj); + key_dy = PyLong_AsLong(y_obj); + } else if (PyObject_HasAttrString(key, "x") && PyObject_HasAttrString(key, "y")) { + PyObject* x_attr = PyObject_GetAttrString(key, "x"); + PyObject* y_attr = PyObject_GetAttrString(key, "y"); + if (!x_attr || !y_attr) { + Py_XDECREF(x_attr); + Py_XDECREF(y_attr); + PyErr_SetString(PyExc_TypeError, "weights keys must be (dx, dy) tuples, lists, or Vectors"); + return -1; + } + key_dx = static_cast(PyFloat_Check(x_attr) ? PyFloat_AsDouble(x_attr) : PyLong_AsLong(x_attr)); + key_dy = static_cast(PyFloat_Check(y_attr) ? PyFloat_AsDouble(y_attr) : PyLong_AsLong(y_attr)); + Py_DECREF(x_attr); + Py_DECREF(y_attr); + } else { + PyErr_SetString(PyExc_TypeError, "weights keys must be (dx, dy) tuples, lists, or Vectors"); + return -1; + } + + float w = 0.0f; + if (PyFloat_Check(value)) { + w = static_cast(PyFloat_AsDouble(value)); + } else if (PyLong_Check(value)) { + w = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "weights values must be numeric (int or float)"); + return -1; + } + + dx[idx] = key_dx; + dy[idx] = key_dy; + weight[idx] = w; + idx++; + } + + return kernel_size; +} + +// Helper: Parse 3x3 kernel from flat or nested sequence +// Returns true on success, sets error and returns false on failure +static bool ParseKernel3(PyObject* weights_obj, float kernel[9]) +{ + // Check if it's a sequence + if (!PySequence_Check(weights_obj)) { + PyErr_SetString(PyExc_TypeError, "weights must be a sequence (list or tuple)"); + return false; + } + + Py_ssize_t len = PySequence_Size(weights_obj); + + if (len == 9) { + // Flat format: [w0, w1, w2, w3, w4, w5, w6, w7, w8] + for (int i = 0; i < 9; i++) { + PyObject* item = PySequence_GetItem(weights_obj, i); + if (!item) return false; + + if (PyFloat_Check(item)) { + kernel[i] = static_cast(PyFloat_AsDouble(item)); + } else if (PyLong_Check(item)) { + kernel[i] = static_cast(PyLong_AsLong(item)); + } else { + Py_DECREF(item); + PyErr_SetString(PyExc_TypeError, "kernel weights must be numeric"); + return false; + } + Py_DECREF(item); + } + return true; + } else if (len == 3) { + // Nested format: [[r0], [r1], [r2]] where each row has 3 elements + for (int row = 0; row < 3; row++) { + PyObject* row_obj = PySequence_GetItem(weights_obj, row); + if (!row_obj) return false; + + if (!PySequence_Check(row_obj) || PySequence_Size(row_obj) != 3) { + Py_DECREF(row_obj); + PyErr_SetString(PyExc_TypeError, "nested kernel must have 3 rows of 3 elements each"); + return false; + } + + for (int col = 0; col < 3; col++) { + PyObject* item = PySequence_GetItem(row_obj, col); + if (!item) { + Py_DECREF(row_obj); + return false; + } + + if (PyFloat_Check(item)) { + kernel[row * 3 + col] = static_cast(PyFloat_AsDouble(item)); + } else if (PyLong_Check(item)) { + kernel[row * 3 + col] = static_cast(PyLong_AsLong(item)); + } else { + Py_DECREF(item); + Py_DECREF(row_obj); + PyErr_SetString(PyExc_TypeError, "kernel weights must be numeric"); + return false; + } + Py_DECREF(item); + } + Py_DECREF(row_obj); + } + return true; + } else { + PyErr_SetString(PyExc_ValueError, "weights must be 9 elements (flat) or 3x3 nested"); + return false; + } +} + +// sparse_kernel_from - apply sparse convolution from source into self +PyObject* PyHeightMap::sparse_kernel_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + PyObject* source_obj = nullptr; + PyObject* weights_dict = nullptr; + float min_level = -FLT_MAX; + float max_level = FLT_MAX; + + static const char* kwlist[] = {"source", "weights", "min_level", "max_level", nullptr}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|ff", const_cast(kwlist), + &source_obj, &weights_dict, &min_level, &max_level)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Validate source + PyHeightMapObject* source = validateOtherHeightMapType(source_obj, "sparse_kernel_from"); + if (!source) return nullptr; + + // Check dimensions match + if (source->heightmap->w != self->heightmap->w || + source->heightmap->h != self->heightmap->h) { + PyErr_SetString(PyExc_ValueError, "source and destination HeightMaps must have same dimensions"); + return nullptr; + } + + // Parse weights + std::vector dx, dy; + std::vector weight; + Py_ssize_t kernel_size = ParseWeightsDict(weights_dict, dx, dy, weight); + if (kernel_size < 0) return nullptr; + + // Apply the kernel transform + TCOD_heightmap_kernel_transform_hm(source->heightmap, self->heightmap, + static_cast(kernel_size), + dx.data(), dy.data(), weight.data(), + min_level, max_level); + + Py_RETURN_NONE; +} + +// sparse_kernel - apply sparse convolution, return new HeightMap +PyObject* PyHeightMap::sparse_kernel(PyHeightMapObject* self, PyObject* args, PyObject* kwds) { PyObject* weights_dict = nullptr; - float min_level = 0.0f; - float max_level = 1000000.0f; + float min_level = -FLT_MAX; + float max_level = FLT_MAX; - static const char* kwlist[] = {"weights", "min", "max", nullptr}; + static const char* kwlist[] = {"weights", "min_level", "max_level", nullptr}; if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|ff", const_cast(kwlist), &weights_dict, &min_level, &max_level)) { @@ -1649,93 +1942,200 @@ PyObject* PyHeightMap::kernel_transform(PyHeightMapObject* self, PyObject* args, return nullptr; } - if (!PyDict_Check(weights_dict)) { - PyErr_SetString(PyExc_TypeError, "weights must be a dict"); + // Create new HeightMap for result + PyHeightMapObject* result = CreateNewHeightMap(self->heightmap->w, self->heightmap->h); + if (!result) return nullptr; + + // Parse weights + std::vector dx, dy; + std::vector weight; + Py_ssize_t kernel_size = ParseWeightsDict(weights_dict, dx, dy, weight); + if (kernel_size < 0) { + Py_DECREF(result); return nullptr; } - Py_ssize_t kernel_size = PyDict_Size(weights_dict); - if (kernel_size <= 0) { - PyErr_SetString(PyExc_ValueError, "weights dict cannot be empty"); - return nullptr; - } - - // Allocate arrays for the kernel - std::vector dx(kernel_size); - std::vector dy(kernel_size); - std::vector weight(kernel_size); - - // Iterate through the dict - PyObject* key; - PyObject* value; - Py_ssize_t pos = 0; - Py_ssize_t idx = 0; - - while (PyDict_Next(weights_dict, &pos, &key, &value)) { - // Parse the key as (dx, dy) - can be tuple, list, or Vector - int key_dx = 0, key_dy = 0; - - if (PyTuple_Check(key) && PyTuple_Size(key) == 2) { - PyObject* x_obj = PyTuple_GetItem(key, 0); - PyObject* y_obj = PyTuple_GetItem(key, 1); - if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { - PyErr_SetString(PyExc_TypeError, "weights keys must be (int, int) tuples"); - return nullptr; - } - key_dx = PyLong_AsLong(x_obj); - key_dy = PyLong_AsLong(y_obj); - } else if (PyList_Check(key) && PyList_Size(key) == 2) { - PyObject* x_obj = PyList_GetItem(key, 0); - PyObject* y_obj = PyList_GetItem(key, 1); - if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { - PyErr_SetString(PyExc_TypeError, "weights keys must be [int, int] lists"); - return nullptr; - } - key_dx = PyLong_AsLong(x_obj); - key_dy = PyLong_AsLong(y_obj); - } else if (PyObject_HasAttrString(key, "x") && PyObject_HasAttrString(key, "y")) { - // Vector-like object - PyObject* x_attr = PyObject_GetAttrString(key, "x"); - PyObject* y_attr = PyObject_GetAttrString(key, "y"); - if (!x_attr || !y_attr) { - Py_XDECREF(x_attr); - Py_XDECREF(y_attr); - PyErr_SetString(PyExc_TypeError, "weights keys must be (dx, dy) tuples, lists, or Vectors"); - return nullptr; - } - key_dx = static_cast(PyFloat_Check(x_attr) ? PyFloat_AsDouble(x_attr) : PyLong_AsLong(x_attr)); - key_dy = static_cast(PyFloat_Check(y_attr) ? PyFloat_AsDouble(y_attr) : PyLong_AsLong(y_attr)); - Py_DECREF(x_attr); - Py_DECREF(y_attr); - } else { - PyErr_SetString(PyExc_TypeError, "weights keys must be (dx, dy) tuples, lists, or Vectors"); - return nullptr; - } - - // Parse the value as float - float w = 0.0f; - if (PyFloat_Check(value)) { - w = static_cast(PyFloat_AsDouble(value)); - } else if (PyLong_Check(value)) { - w = static_cast(PyLong_AsLong(value)); - } else { - PyErr_SetString(PyExc_TypeError, "weights values must be numeric (int or float)"); - return nullptr; - } - - dx[idx] = key_dx; - dy[idx] = key_dy; - weight[idx] = w; - idx++; - } - // Apply the kernel transform - TCOD_heightmap_kernel_transform(self->heightmap, static_cast(kernel_size), - dx.data(), dy.data(), weight.data(), - min_level, max_level); + TCOD_heightmap_kernel_transform_hm(self->heightmap, result->heightmap, + static_cast(kernel_size), + dx.data(), dy.data(), weight.data(), + min_level, max_level); - Py_INCREF(self); - return (PyObject*)self; + return (PyObject*)result; +} + +// kernel3_from - apply 3x3 convolution from source into self +PyObject* PyHeightMap::kernel3_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + PyObject* source_obj = nullptr; + PyObject* weights_obj = nullptr; + int normalize = 1; // Python bool + + static const char* kwlist[] = {"source", "weights", "normalize", nullptr}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|p", const_cast(kwlist), + &source_obj, &weights_obj, &normalize)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Validate source + PyHeightMapObject* source = validateOtherHeightMapType(source_obj, "kernel3_from"); + if (!source) return nullptr; + + // Check dimensions match + if (source->heightmap->w != self->heightmap->w || + source->heightmap->h != self->heightmap->h) { + PyErr_SetString(PyExc_ValueError, "source and destination HeightMaps must have same dimensions"); + return nullptr; + } + + // Parse kernel + float kernel[9]; + if (!ParseKernel3(weights_obj, kernel)) return nullptr; + + // Apply convolution + TCOD_heightmap_convolve3x3(source->heightmap, self->heightmap, kernel, normalize != 0); + + Py_RETURN_NONE; +} + +// kernel3 - apply 3x3 convolution, return new HeightMap +PyObject* PyHeightMap::kernel3(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + PyObject* weights_obj = nullptr; + int normalize = 1; + + static const char* kwlist[] = {"weights", "normalize", nullptr}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|p", const_cast(kwlist), + &weights_obj, &normalize)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Create new HeightMap for result + PyHeightMapObject* result = CreateNewHeightMap(self->heightmap->w, self->heightmap->h); + if (!result) return nullptr; + + // Parse kernel + float kernel[9]; + if (!ParseKernel3(weights_obj, kernel)) { + Py_DECREF(result); + return nullptr; + } + + // Apply convolution + TCOD_heightmap_convolve3x3(self->heightmap, result->heightmap, kernel, normalize != 0); + + return (PyObject*)result; +} + +// gradients - compute partial derivatives +// Usage: +// source.gradients(dx_hm, dy_hm) - write to existing HeightMaps, return None +// dx, dy = source.gradients() - create new HeightMaps, return tuple +// dx = source.gradients(dy=False) - skip dy, return single HeightMap +PyObject* PyHeightMap::gradients(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + PyObject* dx_arg = Py_True; + PyObject* dy_arg = Py_True; + + static const char* kwlist[] = {"dx", "dy", nullptr}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OO", const_cast(kwlist), + &dx_arg, &dy_arg)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + int w = self->heightmap->w; + int h = self->heightmap->h; + + // Determine what to do with dx + PyHeightMapObject* dx_hm = nullptr; + bool dx_return_new = false; + bool dx_skip = false; + + if (dx_arg == Py_True) { + // Create new HeightMap for dx + dx_hm = CreateNewHeightMap(w, h); + if (!dx_hm) return nullptr; + dx_return_new = true; + } else if (dx_arg == Py_False || dx_arg == Py_None) { + // Skip dx + dx_skip = true; + } else { + // Should be a HeightMap to write into + dx_hm = validateOtherHeightMapType(dx_arg, "gradients"); + if (!dx_hm) return nullptr; + if (dx_hm->heightmap->w != w || dx_hm->heightmap->h != h) { + PyErr_SetString(PyExc_ValueError, "dx HeightMap must have same dimensions as source"); + return nullptr; + } + } + + // Determine what to do with dy + PyHeightMapObject* dy_hm = nullptr; + bool dy_return_new = false; + bool dy_skip = false; + + if (dy_arg == Py_True) { + // Create new HeightMap for dy + dy_hm = CreateNewHeightMap(w, h); + if (!dy_hm) { + if (dx_return_new) Py_DECREF(dx_hm); + return nullptr; + } + dy_return_new = true; + } else if (dy_arg == Py_False || dy_arg == Py_None) { + // Skip dy + dy_skip = true; + } else { + // Should be a HeightMap to write into + dy_hm = validateOtherHeightMapType(dy_arg, "gradients"); + if (!dy_hm) { + if (dx_return_new) Py_DECREF(dx_hm); + return nullptr; + } + if (dy_hm->heightmap->w != w || dy_hm->heightmap->h != h) { + if (dx_return_new) Py_DECREF(dx_hm); + PyErr_SetString(PyExc_ValueError, "dy HeightMap must have same dimensions as source"); + return nullptr; + } + } + + // Call the gradient function + TCOD_heightmap_gradient(self->heightmap, + dx_skip ? nullptr : dx_hm->heightmap, + dy_skip ? nullptr : dy_hm->heightmap); + + // Build return value + if (dx_return_new && dy_return_new) { + // Return tuple of (dx, dy) + return Py_BuildValue("(OO)", dx_hm, dy_hm); + } else if (dx_return_new) { + // Return just dx + return (PyObject*)dx_hm; + } else if (dy_return_new) { + // Return just dy + return (PyObject*)dy_hm; + } else { + // Nothing to return + Py_RETURN_NONE; + } } // ============================================================================= diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h index 22d832f..ea9d889 100644 --- a/src/PyHeightMap.h +++ b/src/PyHeightMap.h @@ -53,10 +53,17 @@ public: static PyObject* rain_erosion(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* dig_bezier(PyHeightMapObject* self, PyObject* args, PyObject* kwds); static PyObject* smooth(PyHeightMapObject* self, PyObject* args, PyObject* kwds); - static PyObject* kernel_transform(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + + // Correct convolution methods (using new libtcod functions) + static PyObject* sparse_kernel(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* sparse_kernel_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* kernel3(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* kernel3_from(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* gradients(PyHeightMapObject* self, PyObject* args, PyObject* kwds); // Subscript support for hmap[x, y] syntax static PyObject* subscript(PyHeightMapObject* self, PyObject* key); + static int subscript_assign(PyHeightMapObject* self, PyObject* key, PyObject* value); // Combination operations (#194) - mutate self, return self for chaining, support region parameters static PyObject* add(PyHeightMapObject* self, PyObject* args, PyObject* kwds); diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index f26ea21..bc0c00f 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -4,278 +4,34 @@ Core game engine interface for creating roguelike games with Python. """ from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload -from enum import IntEnum # Type aliases UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid', 'Line', 'Circle', 'Arc'] Transition = Union[str, None] -# Enums - -class Key(IntEnum): - """Keyboard key codes. - - These enum values compare equal to their legacy string equivalents - for backwards compatibility: - Key.ESCAPE == 'Escape' # True - Key.LEFT_SHIFT == 'LShift' # True - """ - # Letters - A = 0 - B = 1 - C = 2 - D = 3 - E = 4 - F = 5 - G = 6 - H = 7 - I = 8 - J = 9 - K = 10 - L = 11 - M = 12 - N = 13 - O = 14 - P = 15 - Q = 16 - R = 17 - S = 18 - T = 19 - U = 20 - V = 21 - W = 22 - X = 23 - Y = 24 - Z = 25 - # Number row - NUM_0 = 26 - NUM_1 = 27 - NUM_2 = 28 - NUM_3 = 29 - NUM_4 = 30 - NUM_5 = 31 - NUM_6 = 32 - NUM_7 = 33 - NUM_8 = 34 - NUM_9 = 35 - # Control keys - ESCAPE = 36 - LEFT_CONTROL = 37 - LEFT_SHIFT = 38 - LEFT_ALT = 39 - LEFT_SYSTEM = 40 - RIGHT_CONTROL = 41 - RIGHT_SHIFT = 42 - RIGHT_ALT = 43 - RIGHT_SYSTEM = 44 - MENU = 45 - # Punctuation - LEFT_BRACKET = 46 - RIGHT_BRACKET = 47 - SEMICOLON = 48 - COMMA = 49 - PERIOD = 50 - APOSTROPHE = 51 - SLASH = 52 - BACKSLASH = 53 - GRAVE = 54 - EQUAL = 55 - HYPHEN = 56 - # Whitespace/editing - SPACE = 57 - ENTER = 58 - BACKSPACE = 59 - TAB = 60 - # Navigation - PAGE_UP = 61 - PAGE_DOWN = 62 - END = 63 - HOME = 64 - INSERT = 65 - DELETE = 66 - # Numpad operators - ADD = 67 - SUBTRACT = 68 - MULTIPLY = 69 - DIVIDE = 70 - # Arrows - LEFT = 71 - RIGHT = 72 - UP = 73 - DOWN = 74 - # Numpad numbers - NUMPAD_0 = 75 - NUMPAD_1 = 76 - NUMPAD_2 = 77 - NUMPAD_3 = 78 - NUMPAD_4 = 79 - NUMPAD_5 = 80 - NUMPAD_6 = 81 - NUMPAD_7 = 82 - NUMPAD_8 = 83 - NUMPAD_9 = 84 - # Function keys - F1 = 85 - F2 = 86 - F3 = 87 - F4 = 88 - F5 = 89 - F6 = 90 - F7 = 91 - F8 = 92 - F9 = 93 - F10 = 94 - F11 = 95 - F12 = 96 - F13 = 97 - F14 = 98 - F15 = 99 - # Misc - PAUSE = 100 - UNKNOWN = -1 - -class MouseButton(IntEnum): - """Mouse button codes. - - These enum values compare equal to their legacy string equivalents - for backwards compatibility: - MouseButton.LEFT == 'left' # True - MouseButton.RIGHT == 'right' # True - """ - LEFT = 0 - RIGHT = 1 - MIDDLE = 2 - X1 = 3 - X2 = 4 - -class InputState(IntEnum): - """Input event states (pressed/released). - - These enum values compare equal to their legacy string equivalents - for backwards compatibility: - InputState.PRESSED == 'start' # True - InputState.RELEASED == 'end' # True - """ - PRESSED = 0 - RELEASED = 1 - -class Easing(IntEnum): - """Easing functions for animations.""" - LINEAR = 0 - EASE_IN = 1 - EASE_OUT = 2 - EASE_IN_OUT = 3 - EASE_IN_QUAD = 4 - EASE_OUT_QUAD = 5 - EASE_IN_OUT_QUAD = 6 - EASE_IN_CUBIC = 7 - EASE_OUT_CUBIC = 8 - EASE_IN_OUT_CUBIC = 9 - EASE_IN_QUART = 10 - EASE_OUT_QUART = 11 - EASE_IN_OUT_QUART = 12 - EASE_IN_SINE = 13 - EASE_OUT_SINE = 14 - EASE_IN_OUT_SINE = 15 - EASE_IN_EXPO = 16 - EASE_OUT_EXPO = 17 - EASE_IN_OUT_EXPO = 18 - EASE_IN_CIRC = 19 - EASE_OUT_CIRC = 20 - EASE_IN_OUT_CIRC = 21 - EASE_IN_ELASTIC = 22 - EASE_OUT_ELASTIC = 23 - EASE_IN_OUT_ELASTIC = 24 - EASE_IN_BACK = 25 - EASE_OUT_BACK = 26 - EASE_IN_OUT_BACK = 27 - EASE_IN_BOUNCE = 28 - EASE_OUT_BOUNCE = 29 - EASE_IN_OUT_BOUNCE = 30 - -class FOV(IntEnum): - """Field of view algorithms for visibility calculations.""" - BASIC = 0 - DIAMOND = 1 - SHADOW = 2 - PERMISSIVE_0 = 3 - PERMISSIVE_1 = 4 - PERMISSIVE_2 = 5 - PERMISSIVE_3 = 6 - PERMISSIVE_4 = 7 - PERMISSIVE_5 = 8 - PERMISSIVE_6 = 9 - PERMISSIVE_7 = 10 - PERMISSIVE_8 = 11 - RESTRICTIVE = 12 - SYMMETRIC_SHADOWCAST = 13 - -class Alignment(IntEnum): - """Alignment positions for automatic child positioning relative to parent bounds. - - When a drawable has an alignment set and is added to a parent, its position - is automatically calculated based on the parent's bounds. The position is - updated whenever the parent is resized. - - Example: - parent = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) - child = mcrfpy.Caption(text="Centered!", align=mcrfpy.Alignment.CENTER) - parent.children.append(child) # child is auto-positioned to center - parent.w = 800 # child position updates automatically - - Set align=None to disable automatic positioning and use manual coordinates. - """ - TOP_LEFT = 0 - TOP_CENTER = 1 - TOP_RIGHT = 2 - CENTER_LEFT = 3 - CENTER = 4 - CENTER_RIGHT = 5 - BOTTOM_LEFT = 6 - BOTTOM_CENTER = 7 - BOTTOM_RIGHT = 8 - # Classes class Color: - """RGBA color representation. - - Note: - When accessing colors from UI elements (e.g., frame.fill_color), - you receive a COPY of the color. Modifying it doesn't affect the - original. To change a component: - - # This does NOT work: - frame.fill_color.r = 255 # Modifies a temporary copy - - # Do this instead: - c = frame.fill_color - c.r = 255 - frame.fill_color = c - - # Or use Animation for sub-properties: - anim = mcrfpy.Animation('fill_color.r', 255, 0.5, 'linear') - anim.start(frame) - """ - + """SFML Color Object for RGBA colors.""" + r: int g: int b: int a: int - + @overload def __init__(self) -> None: ... @overload def __init__(self, r: int, g: int, b: int, a: int = 255) -> None: ... - + def from_hex(self, hex_string: str) -> 'Color': """Create color from hex string (e.g., '#FF0000' or 'FF0000').""" ... - + def to_hex(self) -> str: """Convert color to hex string format.""" ... - + def lerp(self, other: 'Color', t: float) -> 'Color': """Linear interpolation between two colors.""" ... @@ -311,160 +67,12 @@ class Texture: class Font: """SFML Font Object for text rendering.""" - + def __init__(self, filename: str) -> None: ... - + filename: str family: str -class Sound: - """Sound effect object for short audio clips. - - Sounds are loaded entirely into memory, making them suitable for - short sound effects that need to be played with minimal latency. - Multiple Sound instances can play simultaneously. - """ - - def __init__(self, filename: str) -> None: - """Load a sound effect from a file. - - Args: - filename: Path to the sound file (WAV, OGG, FLAC supported) - - Raises: - RuntimeError: If the file cannot be loaded - """ - ... - - volume: float - """Volume level from 0 (silent) to 100 (full volume).""" - - loop: bool - """Whether the sound loops when it reaches the end.""" - - playing: bool - """True if the sound is currently playing (read-only).""" - - duration: float - """Total duration of the sound in seconds (read-only).""" - - source: str - """Filename path used to load this sound (read-only).""" - - def play(self) -> None: - """Start or resume playing the sound.""" - ... - - def pause(self) -> None: - """Pause the sound. Use play() to resume.""" - ... - - def stop(self) -> None: - """Stop playing and reset to the beginning.""" - ... - -class Music: - """Streaming music object for longer audio tracks. - - Music is streamed from disk rather than loaded entirely into memory, - making it suitable for longer audio tracks like background music. - """ - - def __init__(self, filename: str) -> None: - """Load a music track from a file. - - Args: - filename: Path to the music file (WAV, OGG, FLAC supported) - - Raises: - RuntimeError: If the file cannot be loaded - """ - ... - - volume: float - """Volume level from 0 (silent) to 100 (full volume).""" - - loop: bool - """Whether the music loops when it reaches the end.""" - - playing: bool - """True if the music is currently playing (read-only).""" - - duration: float - """Total duration of the music in seconds (read-only).""" - - position: float - """Current playback position in seconds. Can be set to seek.""" - - source: str - """Filename path used to load this music (read-only).""" - - def play(self) -> None: - """Start or resume playing the music.""" - ... - - def pause(self) -> None: - """Pause the music. Use play() to resume.""" - ... - - def stop(self) -> None: - """Stop playing and reset to the beginning.""" - ... - -class Keyboard: - """Keyboard state singleton for checking modifier keys. - - Access via mcrfpy.keyboard (singleton instance). - Queries real-time keyboard state from SFML. - """ - - shift: bool - """True if either Shift key is currently pressed (read-only).""" - - ctrl: bool - """True if either Control key is currently pressed (read-only).""" - - alt: bool - """True if either Alt key is currently pressed (read-only).""" - - system: bool - """True if either System key (Win/Cmd) is currently pressed (read-only).""" - -class Mouse: - """Mouse state singleton for reading button/position state and controlling cursor. - - Access via mcrfpy.mouse (singleton instance). - Queries real-time mouse state from SFML. In headless mode, returns - simulated position from mcrfpy.automation calls. - """ - - # Position (read-only) - x: int - """Current mouse X position in window coordinates (read-only).""" - - y: int - """Current mouse Y position in window coordinates (read-only).""" - - pos: Vector - """Current mouse position as Vector (read-only).""" - - # Button state (read-only) - left: bool - """True if left mouse button is currently pressed (read-only).""" - - right: bool - """True if right mouse button is currently pressed (read-only).""" - - middle: bool - """True if middle mouse button is currently pressed (read-only).""" - - # Cursor control (read-write) - visible: bool - """Whether the mouse cursor is visible (default: True).""" - - grabbed: bool - """Whether the mouse cursor is confined to the window (default: False).""" - class Drawable: """Base class for all drawable UI elements.""" @@ -484,16 +92,6 @@ class Drawable: # Read-only hover state (#140) hovered: bool - # Alignment system - automatic positioning relative to parent - align: Optional[Alignment] - """Alignment relative to parent bounds. Set to None for manual positioning.""" - margin: float - """General margin from edge when aligned (applies to both axes unless overridden).""" - horiz_margin: float - """Horizontal margin override (0 = use general margin).""" - vert_margin: float - """Vertical margin override (0 = use general margin).""" - def get_bounds(self) -> Tuple[float, float, float, float]: """Get bounding box as (x, y, width, height).""" ... @@ -518,12 +116,7 @@ class Frame(Drawable): def __init__(self, x: float = 0, y: float = 0, w: float = 0, h: float = 0, fill_color: Optional[Color] = None, outline_color: Optional[Color] = None, outline: float = 0, on_click: Optional[Callable] = None, - children: Optional[List[UIElement]] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, pos: Optional[Tuple[float, float]] = None, - size: Optional[Tuple[float, float]] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + children: Optional[List[UIElement]] = None) -> None: ... w: float h: float @@ -546,12 +139,7 @@ class Caption(Drawable): def __init__(self, text: str = '', x: float = 0, y: float = 0, font: Optional[Font] = None, fill_color: Optional[Color] = None, outline_color: Optional[Color] = None, outline: float = 0, - on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, pos: Optional[Tuple[float, float]] = None, - size: Optional[Tuple[float, float]] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + on_click: Optional[Callable] = None) -> None: ... text: str font: Font @@ -573,12 +161,7 @@ class Sprite(Drawable): @overload def __init__(self, x: float = 0, y: float = 0, texture: Optional[Texture] = None, sprite_index: int = 0, scale: float = 1.0, - on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, pos: Optional[Tuple[float, float]] = None, - size: Optional[Tuple[float, float]] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + on_click: Optional[Callable] = None) -> None: ... texture: Texture sprite_index: int @@ -598,12 +181,7 @@ class Grid(Drawable): @overload def __init__(self, x: float = 0, y: float = 0, grid_size: Tuple[int, int] = (20, 20), texture: Optional[Texture] = None, tile_width: int = 16, tile_height: int = 16, - scale: float = 1.0, on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, pos: Optional[Tuple[float, float]] = None, - size: Optional[Tuple[float, float]] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + scale: float = 1.0, on_click: Optional[Callable] = None) -> None: ... grid_size: Tuple[int, int] tile_width: int @@ -631,11 +209,7 @@ class Line(Drawable): def __init__(self, start: Optional[Tuple[float, float]] = None, end: Optional[Tuple[float, float]] = None, thickness: float = 1.0, color: Optional[Color] = None, - on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + on_click: Optional[Callable] = None) -> None: ... start: Vector end: Vector @@ -654,11 +228,7 @@ class Circle(Drawable): @overload def __init__(self, radius: float = 0, center: Optional[Tuple[float, float]] = None, fill_color: Optional[Color] = None, outline_color: Optional[Color] = None, - outline: float = 0, on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + outline: float = 0, on_click: Optional[Callable] = None) -> None: ... radius: float center: Vector @@ -679,11 +249,7 @@ class Arc(Drawable): def __init__(self, center: Optional[Tuple[float, float]] = None, radius: float = 0, start_angle: float = 0, end_angle: float = 90, color: Optional[Color] = None, thickness: float = 1.0, - on_click: Optional[Callable] = None, - visible: bool = True, opacity: float = 1.0, z_index: int = 0, - name: Optional[str] = None, - align: Optional[Alignment] = None, margin: float = 0.0, - horiz_margin: float = 0.0, vert_margin: float = 0.0) -> None: ... + on_click: Optional[Callable] = None) -> None: ... center: Vector radius: float @@ -855,134 +421,61 @@ class Window: ... class Animation: - """Animation for interpolating UI properties over time. - - Create an animation targeting a specific property, then call start() on a - UI element to begin the animation. The AnimationManager handles updates - automatically. - - Example: - # Move a frame to x=500 over 2 seconds with easing - anim = mcrfpy.Animation('x', 500.0, 2.0, 'easeInOut') - anim.start(my_frame) - - # Animate color with completion callback - def on_done(anim, target): - print('Fade complete!') - fade = mcrfpy.Animation('fill_color.a', 0, 1.0, callback=on_done) - fade.start(my_sprite) - """ - - @property - def property(self) -> str: - """Target property name being animated (read-only).""" + """Animation object for animating UI properties.""" + + target: Any + property: str + duration: float + easing: str + loop: bool + on_complete: Optional[Callable] + + def __init__(self, target: Any, property: str, start_value: Any, end_value: Any, + duration: float, easing: str = 'linear', loop: bool = False, + on_complete: Optional[Callable] = None) -> None: ... + + def start(self) -> None: + """Start the animation.""" ... - - @property - def duration(self) -> float: - """Animation duration in seconds (read-only).""" - ... - - @property - def elapsed(self) -> float: - """Time elapsed since animation started in seconds (read-only).""" - ... - - @property - def is_complete(self) -> bool: - """Whether the animation has finished (read-only).""" - ... - - @property - def is_delta(self) -> bool: - """Whether animation uses delta/additive mode (read-only).""" - ... - - def __init__(self, - property: str, - target: Union[float, int, Tuple[float, float], Tuple[int, int, int], Tuple[int, int, int, int], List[int], str], - duration: float, - easing: str = 'linear', - delta: bool = False, - callback: Optional[Callable[['Animation', Any], None]] = None) -> None: - """Create an animation for a UI property. - - Args: - property: Property name to animate. Common properties: - - Position/Size: 'x', 'y', 'w', 'h', 'pos', 'size' - - Appearance: 'fill_color', 'outline_color', 'opacity' - - Sprite: 'sprite_index', 'scale' - - Grid: 'center', 'zoom' - - Sub-properties: 'fill_color.r', 'fill_color.g', etc. - target: Target value. Type depends on property: - - float: For x, y, w, h, scale, opacity, zoom - - int: For sprite_index - - (r, g, b) or (r, g, b, a): For colors - - (x, y): For pos, size, center - - [int, ...]: For sprite animation sequences - - str: For text animation - duration: Animation duration in seconds. - easing: Easing function. Options: 'linear', 'easeIn', 'easeOut', - 'easeInOut', 'easeInQuad', 'easeOutQuad', 'easeInOutQuad', - 'easeInCubic', 'easeOutCubic', 'easeInOutCubic', - 'easeInElastic', 'easeOutElastic', 'easeInOutElastic', - 'easeInBounce', 'easeOutBounce', 'easeInOutBounce', and more. - delta: If True, target value is added to start value. - callback: Function(animation, target) called on completion. - """ - ... - - def start(self, target: UIElement, conflict_mode: str = 'replace') -> None: - """Start the animation on a UI element. - - Args: - target: The UI element to animate (Frame, Caption, Sprite, Grid, or Entity) - conflict_mode: How to handle if property is already animating: - - 'replace': Stop existing animation, start new one (default) - - 'queue': Wait for existing animation to complete - - 'error': Raise RuntimeError if property is busy - """ - ... - + def update(self, dt: float) -> bool: - """Update animation by time delta. Returns True if still running. - - Note: Normally called automatically by AnimationManager. - """ + """Update animation, returns True if still running.""" ... - + def get_current_value(self) -> Any: """Get the current interpolated value.""" ... - def complete(self) -> None: - """Complete the animation immediately, jumping to final value.""" - ... - - def hasValidTarget(self) -> bool: - """Check if the animation target still exists.""" - ... - - def __repr__(self) -> str: - """Return string representation showing property, duration, and status.""" - ... - -# Module-level attributes - -__version__: str -"""McRogueFace version string (e.g., '1.0.0').""" - -keyboard: Keyboard -"""Keyboard state singleton for checking modifier keys.""" - -mouse: Mouse -"""Mouse state singleton for reading button/position state and controlling cursor.""" - -window: Window -"""Window singleton for controlling window properties.""" - # Module functions +def createSoundBuffer(filename: str) -> int: + """Load a sound effect from a file and return its buffer ID.""" + ... + +def loadMusic(filename: str) -> None: + """Load and immediately play background music from a file.""" + ... + +def setMusicVolume(volume: int) -> None: + """Set the global music volume (0-100).""" + ... + +def setSoundVolume(volume: int) -> None: + """Set the global sound effects volume (0-100).""" + ... + +def playSound(buffer_id: int) -> None: + """Play a sound effect using a previously loaded buffer.""" + ... + +def getMusicVolume() -> int: + """Get the current music volume level (0-100).""" + ... + +def getSoundVolume() -> int: + """Get the current sound effects volume level (0-100).""" + ... + def sceneUI(scene: Optional[str] = None) -> UICollection: """Get all UI elements for a scene.""" ...