DiscreteMap class - mask for operations or uint8 tile data

This commit is contained in:
John McCardle 2026-02-03 20:36:42 -05:00
commit d8fec5fea0
7 changed files with 2817 additions and 0 deletions

470
src/MapOps.h Normal file
View file

@ -0,0 +1,470 @@
#pragma once
#include "Python.h"
#include <algorithm>
#include <cstdint>
// ============================================================================
// MapOps - Template abstractions for 2D grid map operations
// ============================================================================
//
// Provides common operations for HeightMap (float) and DiscreteMap (uint8_t).
// Uses policy-based design for type-specific behavior (clamping, conversion).
//
// Benefits:
// - Single implementation for fill, copy, region iteration
// - Type-appropriate clamping via saturation policies
// - Compile-time polymorphism (no virtual overhead)
// - Shared region parameter parsing from Python kwargs
// ============================================================================
// Forward declarations
class PyPositionHelper;
// ============================================================================
// Unified region struct for all map operations
// ============================================================================
struct MapRegion {
// Validated region coordinates
int dest_x, dest_y; // Destination origin in target map
int src_x, src_y; // Source origin (for binary ops, 0 for scalar ops)
int width, height; // Region dimensions
// Full map dimensions (for iteration)
int dest_w, dest_h;
int src_w, src_h;
// Direct indexing helpers
inline int dest_idx(int x, int y) const {
return (dest_y + y) * dest_w + (dest_x + x);
}
inline int src_idx(int x, int y) const {
return (src_y + y) * src_w + (src_x + x);
}
};
// ============================================================================
// Saturation Policies - type-specific clamping behavior
// ============================================================================
struct FloatPolicy {
using Type = float;
static float clamp(float v) { return v; } // No clamping for float
static float clamp(int v) { return static_cast<float>(v); }
static float from_int(int v) { return static_cast<float>(v); }
static float from_float(float v) { return v; }
static float zero() { return 0.0f; }
static float one() { return 1.0f; }
};
struct Uint8Policy {
using Type = uint8_t;
static uint8_t clamp(int v) {
return static_cast<uint8_t>(std::clamp(v, 0, 255));
}
static uint8_t clamp(float v) {
return static_cast<uint8_t>(std::clamp(static_cast<int>(v), 0, 255));
}
static uint8_t from_int(int v) { return clamp(v); }
static uint8_t from_float(float v) { return clamp(static_cast<int>(v)); }
static uint8_t zero() { return 0; }
static uint8_t one() { return 1; }
};
// ============================================================================
// Region Parameter Parsing - shared helpers
// ============================================================================
namespace MapOpsInternal {
// Parse optional position tuple, returning (0, 0) if None/not provided
inline bool parseOptionalPos(PyObject* pos_obj, int* out_x, int* out_y, const char* param_name) {
*out_x = 0;
*out_y = 0;
if (!pos_obj || pos_obj == Py_None) {
return true; // Default to (0, 0)
}
// Try to parse as tuple/list of 2 ints
if (PyTuple_Check(pos_obj) && PyTuple_Size(pos_obj) == 2) {
PyObject* x_obj = PyTuple_GetItem(pos_obj, 0);
PyObject* y_obj = PyTuple_GetItem(pos_obj, 1);
if (PyLong_Check(x_obj) && PyLong_Check(y_obj)) {
*out_x = (int)PyLong_AsLong(x_obj);
*out_y = (int)PyLong_AsLong(y_obj);
return true;
}
} else if (PyList_Check(pos_obj) && PyList_Size(pos_obj) == 2) {
PyObject* x_obj = PyList_GetItem(pos_obj, 0);
PyObject* y_obj = PyList_GetItem(pos_obj, 1);
if (PyLong_Check(x_obj) && PyLong_Check(y_obj)) {
*out_x = (int)PyLong_AsLong(x_obj);
*out_y = (int)PyLong_AsLong(y_obj);
return true;
}
}
PyErr_Format(PyExc_TypeError, "%s must be a (x, y) tuple or list", param_name);
return false;
}
// Parse optional size tuple
inline bool parseOptionalSize(PyObject* size_obj, int* out_w, int* out_h, const char* param_name) {
*out_w = -1; // -1 means "not specified"
*out_h = -1;
if (!size_obj || size_obj == Py_None) {
return true;
}
if (PyTuple_Check(size_obj) && PyTuple_Size(size_obj) == 2) {
PyObject* w_obj = PyTuple_GetItem(size_obj, 0);
PyObject* h_obj = PyTuple_GetItem(size_obj, 1);
if (PyLong_Check(w_obj) && PyLong_Check(h_obj)) {
*out_w = (int)PyLong_AsLong(w_obj);
*out_h = (int)PyLong_AsLong(h_obj);
if (*out_w <= 0 || *out_h <= 0) {
PyErr_Format(PyExc_ValueError, "%s dimensions must be positive", param_name);
return false;
}
return true;
}
} else if (PyList_Check(size_obj) && PyList_Size(size_obj) == 2) {
PyObject* w_obj = PyList_GetItem(size_obj, 0);
PyObject* h_obj = PyList_GetItem(size_obj, 1);
if (PyLong_Check(w_obj) && PyLong_Check(h_obj)) {
*out_w = (int)PyLong_AsLong(w_obj);
*out_h = (int)PyLong_AsLong(h_obj);
if (*out_w <= 0 || *out_h <= 0) {
PyErr_Format(PyExc_ValueError, "%s dimensions must be positive", param_name);
return false;
}
return true;
}
}
PyErr_Format(PyExc_TypeError, "%s must be a (width, height) tuple or list", param_name);
return false;
}
} // namespace MapOpsInternal
// ============================================================================
// parseMapRegion - Parse region parameters for binary operations
// ============================================================================
// For binary operations (two maps)
inline bool parseMapRegion(
int dest_w, int dest_h,
int src_w, int src_h,
PyObject* pos, // (x, y) or None - destination position
PyObject* source_pos, // (x, y) or None - source position
PyObject* size, // (w, h) or None
MapRegion& out
) {
using namespace MapOpsInternal;
// Store full dimensions
out.dest_w = dest_w;
out.dest_h = dest_h;
out.src_w = src_w;
out.src_h = src_h;
// Parse positions, default to (0, 0)
if (!parseOptionalPos(pos, &out.dest_x, &out.dest_y, "pos")) {
return false;
}
if (!parseOptionalPos(source_pos, &out.src_x, &out.src_y, "source_pos")) {
return false;
}
// Validate positions are within bounds
if (out.dest_x < 0 || out.dest_y < 0) {
PyErr_SetString(PyExc_ValueError, "pos coordinates cannot be negative");
return false;
}
if (out.dest_x >= out.dest_w || out.dest_y >= out.dest_h) {
PyErr_Format(PyExc_ValueError,
"pos (%d, %d) is out of bounds for destination of size (%d, %d)",
out.dest_x, out.dest_y, out.dest_w, out.dest_h);
return false;
}
if (out.src_x < 0 || out.src_y < 0) {
PyErr_SetString(PyExc_ValueError, "source_pos coordinates cannot be negative");
return false;
}
if (out.src_x >= out.src_w || out.src_y >= out.src_h) {
PyErr_Format(PyExc_ValueError,
"source_pos (%d, %d) is out of bounds for source of size (%d, %d)",
out.src_x, out.src_y, out.src_w, out.src_h);
return false;
}
// Calculate remaining space from each position
int dest_remaining_w = out.dest_w - out.dest_x;
int dest_remaining_h = out.dest_h - out.dest_y;
int src_remaining_w = out.src_w - out.src_x;
int src_remaining_h = out.src_h - out.src_y;
// Parse or infer size
int requested_w = -1, requested_h = -1;
if (!parseOptionalSize(size, &requested_w, &requested_h, "size")) {
return false;
}
if (requested_w > 0 && requested_h > 0) {
// Explicit size - must fit in both
if (requested_w > dest_remaining_w || requested_h > dest_remaining_h) {
PyErr_Format(PyExc_ValueError,
"size (%d, %d) exceeds available space in destination (%d, %d) from pos (%d, %d)",
requested_w, requested_h, dest_remaining_w, dest_remaining_h,
out.dest_x, out.dest_y);
return false;
}
if (requested_w > src_remaining_w || requested_h > src_remaining_h) {
PyErr_Format(PyExc_ValueError,
"size (%d, %d) exceeds available space in source (%d, %d) from source_pos (%d, %d)",
requested_w, requested_h, src_remaining_w, src_remaining_h,
out.src_x, out.src_y);
return false;
}
out.width = requested_w;
out.height = requested_h;
} else {
// Infer size: smaller of remaining space in each
out.width = std::min(dest_remaining_w, src_remaining_w);
out.height = std::min(dest_remaining_h, src_remaining_h);
}
// Final validation: non-zero region
if (out.width <= 0 || out.height <= 0) {
PyErr_SetString(PyExc_ValueError, "computed region has zero size");
return false;
}
return true;
}
// For scalar operations (single map, just destination region)
inline bool parseMapRegionScalar(
int dest_w, int dest_h,
PyObject* pos,
PyObject* size,
MapRegion& out
) {
return parseMapRegion(dest_w, dest_h, dest_w, dest_h, pos, nullptr, size, out);
}
// ============================================================================
// Core map operations as free functions (used by both HeightMap and DiscreteMap)
// ============================================================================
namespace MapOps {
// Fill region with value
template<typename Policy>
void fill(typename Policy::Type* data, int w, int h,
typename Policy::Type value, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
data[region.dest_idx(x, y)] = value;
}
}
}
// Clear (fill with zero)
template<typename Policy>
void clear(typename Policy::Type* data, int w, int h, const MapRegion& region) {
fill<Policy>(data, w, h, Policy::zero(), region);
}
// Copy from source (same type)
template<typename Policy>
void copy(typename Policy::Type* dst, const typename Policy::Type* src,
const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
dst[region.dest_idx(x, y)] = src[region.src_idx(x, y)];
}
}
}
// Add with saturation
template<typename Policy>
void add(typename Policy::Type* dst, const typename Policy::Type* src,
const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
// Use int accumulator to detect overflow
int result = static_cast<int>(dst[idx]) + static_cast<int>(src[region.src_idx(x, y)]);
dst[idx] = Policy::clamp(result);
}
}
}
// Add scalar
template<typename Policy>
void add_scalar(typename Policy::Type* data, int w, int h,
typename Policy::Type value, const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
int result = static_cast<int>(data[idx]) + static_cast<int>(value);
data[idx] = Policy::clamp(result);
}
}
}
// Subtract with saturation
template<typename Policy>
void subtract(typename Policy::Type* dst, const typename Policy::Type* src,
const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
int result = static_cast<int>(dst[idx]) - static_cast<int>(src[region.src_idx(x, y)]);
dst[idx] = Policy::clamp(result);
}
}
}
// Multiply by scalar
template<typename Policy>
void multiply_scalar(typename Policy::Type* data, int w, int h,
float factor, const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
float result = static_cast<float>(data[idx]) * factor;
data[idx] = Policy::clamp(result);
}
}
}
// Element-wise max
template<typename Policy>
void element_max(typename Policy::Type* dst, const typename Policy::Type* src,
const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
T src_val = src[region.src_idx(x, y)];
if (src_val > dst[idx]) dst[idx] = src_val;
}
}
}
// Element-wise min
template<typename Policy>
void element_min(typename Policy::Type* dst, const typename Policy::Type* src,
const MapRegion& region) {
using T = typename Policy::Type;
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
T src_val = src[region.src_idx(x, y)];
if (src_val < dst[idx]) dst[idx] = src_val;
}
}
}
} // namespace MapOps
// ============================================================================
// Cross-type operations (HeightMap <-> DiscreteMap conversion)
// ============================================================================
namespace MapConvert {
// Copy float to uint8_t (floors and clamps)
inline void float_to_uint8(uint8_t* dst, const float* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
float val = src[region.src_idx(x, y)];
dst[region.dest_idx(x, y)] = Uint8Policy::clamp(val);
}
}
}
// Copy uint8_t to float (simple promotion)
inline void uint8_to_float(float* dst, const uint8_t* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
dst[region.dest_idx(x, y)] = static_cast<float>(src[region.src_idx(x, y)]);
}
}
}
// Add float to uint8_t (with clamping)
inline void add_float_to_uint8(uint8_t* dst, const float* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
float result = static_cast<float>(dst[idx]) + src[region.src_idx(x, y)];
dst[idx] = Uint8Policy::clamp(result);
}
}
}
// Add uint8_t to float
inline void add_uint8_to_float(float* dst, const uint8_t* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
dst[idx] += static_cast<float>(src[region.src_idx(x, y)]);
}
}
}
} // namespace MapConvert
// ============================================================================
// Uint8-only bitwise operations
// ============================================================================
namespace MapBitwise {
inline void bitwise_and(uint8_t* dst, const uint8_t* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
dst[region.dest_idx(x, y)] &= src[region.src_idx(x, y)];
}
}
}
inline void bitwise_or(uint8_t* dst, const uint8_t* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
dst[region.dest_idx(x, y)] |= src[region.src_idx(x, y)];
}
}
}
inline void bitwise_xor(uint8_t* dst, const uint8_t* src, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
dst[region.dest_idx(x, y)] ^= src[region.src_idx(x, y)];
}
}
}
inline void invert(uint8_t* data, int w, int h, const MapRegion& region) {
for (int y = 0; y < region.height; y++) {
for (int x = 0; x < region.width; x++) {
int idx = region.dest_idx(x, y);
data[idx] = 255 - data[idx];
}
}
}
} // namespace MapBitwise

View file

@ -23,6 +23,7 @@
#include "PyMouse.h"
#include "UIGridPathfinding.h" // AStarPath and DijkstraMap types
#include "PyHeightMap.h" // Procedural generation heightmap (#193)
#include "PyDiscreteMap.h" // Procedural generation discrete map (#193)
#include "PyBSP.h" // Procedural generation BSP (#202-206)
#include "PyNoiseSource.h" // Procedural generation noise (#207-208)
#include "PyLock.h" // Thread synchronization (#219)
@ -464,6 +465,7 @@ PyObject* PyInit_mcrfpy()
/*procedural generation (#192)*/
&mcrfpydef::PyHeightMapType,
&mcrfpydef::PyDiscreteMapType,
&mcrfpydef::PyBSPType,
&mcrfpydef::PyNoiseSourceType,
@ -510,6 +512,10 @@ PyObject* PyInit_mcrfpy()
mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods;
mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters;
// Set up PyDiscreteMapType methods and getsetters (#193)
mcrfpydef::PyDiscreteMapType.tp_methods = PyDiscreteMap::methods;
mcrfpydef::PyDiscreteMapType.tp_getset = PyDiscreteMap::getsetters;
// Set up PyBSPType and BSPNode methods and getsetters (#202-206)
mcrfpydef::PyBSPType.tp_methods = PyBSP::methods;
mcrfpydef::PyBSPType.tp_getset = PyBSP::getsetters;

1548
src/PyDiscreteMap.cpp Normal file

File diff suppressed because it is too large Load diff

114
src/PyDiscreteMap.h Normal file
View file

@ -0,0 +1,114 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include <cstdint>
// Forward declaration
class PyDiscreteMap;
// Python object structure
typedef struct {
PyObject_HEAD
uint8_t* values; // Row-major array (width * height)
int w, h; // Dimensions (max 8192x8192)
PyObject* enum_type; // Optional Python IntEnum for value interpretation
} PyDiscreteMapObject;
class PyDiscreteMap
{
public:
// Python type interface
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int init(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyDiscreteMapObject* self);
static PyObject* repr(PyObject* obj);
// Properties
static PyObject* get_size(PyDiscreteMapObject* self, void* closure);
static PyObject* get_enum_type(PyDiscreteMapObject* self, void* closure);
static int set_enum_type(PyDiscreteMapObject* self, PyObject* value, void* closure);
// Scalar operations (all return self for chaining, support region parameters)
static PyObject* fill(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* clear(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
// Cell access
static PyObject* get(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* set(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
// Subscript support for dmap[x, y] syntax
static PyObject* subscript(PyDiscreteMapObject* self, PyObject* key);
static int subscript_assign(PyDiscreteMapObject* self, PyObject* key, PyObject* value);
// Combination operations with region support
static PyObject* add(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* subtract(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* multiply(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* copy_from(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* dmap_max(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* dmap_min(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
// Bitwise operations (DiscreteMap only)
static PyObject* bitwise_and(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* bitwise_or(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* bitwise_xor(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* invert(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
// Query methods
static PyObject* count(PyDiscreteMapObject* self, PyObject* args);
static PyObject* count_range(PyDiscreteMapObject* self, PyObject* args);
static PyObject* min_max(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
static PyObject* histogram(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
// Boolean/mask operations
static PyObject* to_bool(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
static PyObject* mask(PyDiscreteMapObject* self, PyObject* Py_UNUSED(args));
// HeightMap integration
static PyObject* from_heightmap(PyTypeObject* type, PyObject* args, PyObject* kwds);
static PyObject* to_heightmap(PyDiscreteMapObject* self, PyObject* args, PyObject* kwds);
// Mapping methods for subscript support
static PyMappingMethods mapping_methods;
// Method and property definitions
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
};
namespace mcrfpydef {
static PyTypeObject PyDiscreteMapType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.DiscreteMap",
.tp_basicsize = sizeof(PyDiscreteMapObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)PyDiscreteMap::dealloc,
.tp_repr = PyDiscreteMap::repr,
.tp_as_mapping = &PyDiscreteMap::mapping_methods, // dmap[x, y] subscript
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"DiscreteMap(size: tuple[int, int], fill: int = 0, enum: type[IntEnum] = None)\n\n"
"A 2D grid of uint8 values (0-255) for discrete/categorical data.\n\n"
"DiscreteMap provides memory-efficient storage for terrain types, region IDs,\n"
"walkability masks, and other categorical data. Uses 4x less memory than HeightMap\n"
"for the same dimensions.\n\n"
"Args:\n"
" size: (width, height) dimensions. Immutable after creation.\n"
" fill: Initial value for all cells (0-255). Default 0.\n"
" enum: Optional IntEnum class for value interpretation.\n\n"
"Example:\n"
" from enum import IntEnum\n"
" class Terrain(IntEnum):\n"
" WATER = 0\n"
" GRASS = 1\n"
" MOUNTAIN = 2\n\n"
" dmap = mcrfpy.DiscreteMap((100, 100), fill=0, enum=Terrain)\n"
" dmap.fill(Terrain.GRASS, pos=(10, 10), size=(20, 20))\n"
" print(dmap[15, 15]) # Terrain.GRASS\n"
),
.tp_methods = nullptr, // Set in McRFPy_API.cpp before PyType_Ready
.tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready
.tp_init = (initproc)PyDiscreteMap::init,
.tp_new = PyDiscreteMap::pynew,
};
}

View file

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Unit tests for DiscreteMap arithmetic and bitwise operations."""
import mcrfpy
import sys
def test_add_scalar():
"""Test adding a scalar value."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=50)
dmap.add(25)
assert dmap[5, 5] == 75, f"Expected 75, got {dmap[5, 5]}"
# Test saturation at 255
dmap.fill(250)
dmap.add(20) # 250 + 20 = 270 -> saturates to 255
assert dmap[0, 0] == 255, f"Expected 255 (saturated), got {dmap[0, 0]}"
print(" [PASS] Add scalar")
def test_add_map():
"""Test adding another DiscreteMap."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=50)
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=30)
dmap1.add(dmap2)
assert dmap1[5, 5] == 80, f"Expected 80, got {dmap1[5, 5]}"
# Test saturation
dmap1.fill(200)
dmap2.fill(100)
dmap1.add(dmap2)
assert dmap1[0, 0] == 255, f"Expected 255 (saturated), got {dmap1[0, 0]}"
print(" [PASS] Add map")
def test_subtract_scalar():
"""Test subtracting a scalar value."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=100)
dmap.subtract(25)
assert dmap[5, 5] == 75, f"Expected 75, got {dmap[5, 5]}"
# Test saturation at 0
dmap.fill(10)
dmap.subtract(20) # 10 - 20 = -10 -> saturates to 0
assert dmap[0, 0] == 0, f"Expected 0 (saturated), got {dmap[0, 0]}"
print(" [PASS] Subtract scalar")
def test_subtract_map():
"""Test subtracting another DiscreteMap."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=100)
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=30)
dmap1.subtract(dmap2)
assert dmap1[5, 5] == 70, f"Expected 70, got {dmap1[5, 5]}"
# Test saturation
dmap1.fill(50)
dmap2.fill(100)
dmap1.subtract(dmap2)
assert dmap1[0, 0] == 0, f"Expected 0 (saturated), got {dmap1[0, 0]}"
print(" [PASS] Subtract map")
def test_multiply():
"""Test scalar multiplication."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=50)
dmap.multiply(2.0)
assert dmap[5, 5] == 100, f"Expected 100, got {dmap[5, 5]}"
# Test saturation
dmap.fill(100)
dmap.multiply(3.0) # 100 * 3 = 300 -> saturates to 255
assert dmap[0, 0] == 255, f"Expected 255 (saturated), got {dmap[0, 0]}"
# Test fractional
dmap.fill(100)
dmap.multiply(0.5)
assert dmap[0, 0] == 50, f"Expected 50, got {dmap[0, 0]}"
print(" [PASS] Multiply")
def test_copy_from():
"""Test copy_from operation."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=0)
dmap2 = mcrfpy.DiscreteMap((5, 5), fill=99)
dmap1.copy_from(dmap2, pos=(2, 2))
assert dmap1[2, 2] == 99, f"Expected 99 at (2,2), got {dmap1[2, 2]}"
assert dmap1[6, 6] == 99, f"Expected 99 at (6,6), got {dmap1[6, 6]}"
assert dmap1[0, 0] == 0, f"Expected 0 at (0,0), got {dmap1[0, 0]}"
assert dmap1[7, 7] == 0, f"Expected 0 at (7,7), got {dmap1[7, 7]}"
print(" [PASS] Copy from")
def test_max():
"""Test element-wise max."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=50)
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=70)
# Set some values in dmap1 higher
dmap1[3, 3] = 100
dmap1.max(dmap2)
assert dmap1[0, 0] == 70, f"Expected 70 at (0,0), got {dmap1[0, 0]}"
assert dmap1[3, 3] == 100, f"Expected 100 at (3,3), got {dmap1[3, 3]}"
print(" [PASS] Max")
def test_min():
"""Test element-wise min."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=50)
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=30)
# Set some values in dmap1 lower
dmap1[3, 3] = 10
dmap1.min(dmap2)
assert dmap1[0, 0] == 30, f"Expected 30 at (0,0), got {dmap1[0, 0]}"
assert dmap1[3, 3] == 10, f"Expected 10 at (3,3), got {dmap1[3, 3]}"
print(" [PASS] Min")
def test_bitwise_and():
"""Test bitwise AND."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=0xFF) # 11111111
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=0x0F) # 00001111
dmap1.bitwise_and(dmap2)
assert dmap1[0, 0] == 0x0F, f"Expected 0x0F, got {hex(dmap1[0, 0])}"
# Test specific pattern
dmap1.fill(0b10101010)
dmap2.fill(0b11110000)
dmap1.bitwise_and(dmap2)
assert dmap1[0, 0] == 0b10100000, f"Expected 0b10100000, got {bin(dmap1[0, 0])}"
print(" [PASS] Bitwise AND")
def test_bitwise_or():
"""Test bitwise OR."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=0x0F) # 00001111
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=0xF0) # 11110000
dmap1.bitwise_or(dmap2)
assert dmap1[0, 0] == 0xFF, f"Expected 0xFF, got {hex(dmap1[0, 0])}"
print(" [PASS] Bitwise OR")
def test_bitwise_xor():
"""Test bitwise XOR."""
dmap1 = mcrfpy.DiscreteMap((10, 10), fill=0xFF)
dmap2 = mcrfpy.DiscreteMap((10, 10), fill=0xFF)
dmap1.bitwise_xor(dmap2)
assert dmap1[0, 0] == 0x00, f"Expected 0x00, got {hex(dmap1[0, 0])}"
dmap1.fill(0b10101010)
dmap2.fill(0b11110000)
dmap1.bitwise_xor(dmap2)
assert dmap1[0, 0] == 0b01011010, f"Expected 0b01011010, got {bin(dmap1[0, 0])}"
print(" [PASS] Bitwise XOR")
def test_invert():
"""Test invert (returns new map)."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=100)
result = dmap.invert()
# Original unchanged
assert dmap[0, 0] == 100, f"Original should be unchanged, got {dmap[0, 0]}"
# Result is inverted
assert result[0, 0] == 155, f"Expected 155 (255-100), got {result[0, 0]}"
# Test edge cases
dmap.fill(0)
result = dmap.invert()
assert result[0, 0] == 255, f"Expected 255, got {result[0, 0]}"
dmap.fill(255)
result = dmap.invert()
assert result[0, 0] == 0, f"Expected 0, got {result[0, 0]}"
print(" [PASS] Invert")
def test_region_operations():
"""Test operations with region parameters."""
dmap1 = mcrfpy.DiscreteMap((20, 20), fill=10)
dmap2 = mcrfpy.DiscreteMap((20, 20), fill=5)
# Add only in a region
dmap1.add(dmap2, pos=(5, 5), source_pos=(0, 0), size=(5, 5))
assert dmap1[5, 5] == 15, f"Expected 15 in region, got {dmap1[5, 5]}"
assert dmap1[9, 9] == 15, f"Expected 15 in region, got {dmap1[9, 9]}"
assert dmap1[0, 0] == 10, f"Expected 10 outside region, got {dmap1[0, 0]}"
assert dmap1[10, 10] == 10, f"Expected 10 outside region, got {dmap1[10, 10]}"
print(" [PASS] Region operations")
def main():
print("Running DiscreteMap arithmetic tests...")
test_add_scalar()
test_add_map()
test_subtract_scalar()
test_subtract_map()
test_multiply()
test_copy_from()
test_max()
test_min()
test_bitwise_and()
test_bitwise_or()
test_bitwise_xor()
test_invert()
test_region_operations()
print("All DiscreteMap arithmetic tests PASSED!")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Unit tests for DiscreteMap basic operations."""
import mcrfpy
import sys
def test_construction():
"""Test basic construction."""
# Default construction
dmap = mcrfpy.DiscreteMap((100, 100))
assert dmap.size == (100, 100), f"Expected (100, 100), got {dmap.size}"
# With fill value
dmap2 = mcrfpy.DiscreteMap((50, 50), fill=42)
assert dmap2[0, 0] == 42, f"Expected 42, got {dmap2[0, 0]}"
assert dmap2[25, 25] == 42, f"Expected 42, got {dmap2[25, 25]}"
print(" [PASS] Construction")
def test_size_property():
"""Test size property."""
dmap = mcrfpy.DiscreteMap((123, 456))
w, h = dmap.size
assert w == 123, f"Expected width 123, got {w}"
assert h == 456, f"Expected height 456, got {h}"
print(" [PASS] Size property")
def test_get_set():
"""Test get/set methods."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Test set/get
dmap.set(5, 5, 100)
assert dmap.get(5, 5) == 100, f"Expected 100, got {dmap.get(5, 5)}"
# Test subscript
dmap[3, 7] = 200
assert dmap[3, 7] == 200, f"Expected 200, got {dmap[3, 7]}"
# Test tuple subscript
dmap[(1, 2)] = 150
assert dmap[(1, 2)] == 150, f"Expected 150, got {dmap[(1, 2)]}"
print(" [PASS] Get/set methods")
def test_bounds_checking():
"""Test that out-of-bounds access raises IndexError."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Test out of bounds get
try:
_ = dmap[10, 10]
print(" [FAIL] Should have raised IndexError for (10, 10)")
return False
except IndexError:
pass
try:
_ = dmap[-1, 0]
print(" [FAIL] Should have raised IndexError for (-1, 0)")
return False
except IndexError:
pass
# Test out of bounds set
try:
dmap[100, 100] = 5
print(" [FAIL] Should have raised IndexError for set")
return False
except IndexError:
pass
print(" [PASS] Bounds checking")
return True
def test_value_range():
"""Test that values are clamped to 0-255."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Test valid range
dmap[0, 0] = 0
dmap[0, 1] = 255
assert dmap[0, 0] == 0
assert dmap[0, 1] == 255
# Test invalid values
try:
dmap[0, 0] = -1
print(" [FAIL] Should have raised ValueError for -1")
return False
except ValueError:
pass
try:
dmap[0, 0] = 256
print(" [FAIL] Should have raised ValueError for 256")
return False
except ValueError:
pass
print(" [PASS] Value range")
return True
def test_fill():
"""Test fill operation."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Fill entire map
dmap.fill(77)
for y in range(10):
for x in range(10):
assert dmap[x, y] == 77, f"Expected 77 at ({x}, {y}), got {dmap[x, y]}"
# Fill region
dmap.fill(88, pos=(2, 2), size=(3, 3))
assert dmap[2, 2] == 88, "Region fill failed at start"
assert dmap[4, 4] == 88, "Region fill failed at end"
assert dmap[1, 1] == 77, "Region fill affected outside area"
assert dmap[5, 5] == 77, "Region fill affected outside area"
print(" [PASS] Fill operation")
def test_clear():
"""Test clear operation."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=100)
dmap.clear()
for y in range(10):
for x in range(10):
assert dmap[x, y] == 0, f"Expected 0 at ({x}, {y}), got {dmap[x, y]}"
print(" [PASS] Clear operation")
def test_repr():
"""Test repr output."""
dmap = mcrfpy.DiscreteMap((100, 50))
r = repr(dmap)
assert "DiscreteMap" in r, f"Expected 'DiscreteMap' in repr, got {r}"
assert "100" in r, f"Expected '100' in repr, got {r}"
assert "50" in r, f"Expected '50' in repr, got {r}"
print(" [PASS] Repr")
def test_chaining():
"""Test method chaining."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Methods should return self
result = dmap.fill(50).clear().fill(100)
assert result is dmap, "Method chaining should return self"
assert dmap[5, 5] == 100, "Chained operations should work"
print(" [PASS] Method chaining")
def main():
print("Running DiscreteMap basic tests...")
test_construction()
test_size_property()
test_get_set()
if not test_bounds_checking():
sys.exit(1)
if not test_value_range():
sys.exit(1)
test_fill()
test_clear()
test_repr()
test_chaining()
print("All DiscreteMap basic tests PASSED!")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Unit tests for DiscreteMap <-> HeightMap integration."""
import mcrfpy
import sys
from enum import IntEnum
class Terrain(IntEnum):
WATER = 0
SAND = 1
GRASS = 2
FOREST = 3
MOUNTAIN = 4
def test_from_heightmap_basic():
"""Test basic HeightMap to DiscreteMap conversion."""
# Create a simple heightmap
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
# Create a simple mapping
mapping = [
((0.0, 0.3), 0),
((0.3, 0.6), 1),
((0.6, 1.0), 2),
]
dmap = mcrfpy.DiscreteMap.from_heightmap(hmap, mapping)
# 0.5 should map to category 1
assert dmap[5, 5] == 1, f"Expected 1, got {dmap[5, 5]}"
print(" [PASS] from_heightmap basic")
def test_from_heightmap_full_range():
"""Test conversion with values spanning the full range."""
hmap = mcrfpy.HeightMap((100, 1))
# Create gradient
for x in range(100):
hmap[x, 0] = x / 100.0 # 0.0 to 0.99
mapping = [
((0.0, 0.25), Terrain.WATER),
((0.25, 0.5), Terrain.SAND),
((0.5, 0.75), Terrain.GRASS),
((0.75, 1.0), Terrain.FOREST),
]
dmap = mcrfpy.DiscreteMap.from_heightmap(hmap, mapping)
# Check values at key positions
assert dmap[10, 0] == Terrain.WATER, f"Expected WATER at 10, got {dmap[10, 0]}"
assert dmap[30, 0] == Terrain.SAND, f"Expected SAND at 30, got {dmap[30, 0]}"
assert dmap[60, 0] == Terrain.GRASS, f"Expected GRASS at 60, got {dmap[60, 0]}"
assert dmap[80, 0] == Terrain.FOREST, f"Expected FOREST at 80, got {dmap[80, 0]}"
print(" [PASS] from_heightmap full range")
def test_from_heightmap_with_enum():
"""Test from_heightmap with enum parameter."""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
mapping = [
((0.0, 0.3), Terrain.WATER),
((0.3, 0.7), Terrain.GRASS),
((0.7, 1.0), Terrain.MOUNTAIN),
]
dmap = mcrfpy.DiscreteMap.from_heightmap(hmap, mapping, enum=Terrain)
# Value should be returned as enum member
val = dmap[5, 5]
assert val == Terrain.GRASS, f"Expected Terrain.GRASS, got {val}"
assert isinstance(val, Terrain), f"Expected Terrain type, got {type(val)}"
print(" [PASS] from_heightmap with enum")
def test_to_heightmap_basic():
"""Test basic DiscreteMap to HeightMap conversion."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=100)
hmap = dmap.to_heightmap()
# Direct conversion: uint8 -> float
assert abs(hmap[5, 5] - 100.0) < 0.001, f"Expected 100.0, got {hmap[5, 5]}"
print(" [PASS] to_heightmap basic")
def test_to_heightmap_with_mapping():
"""Test to_heightmap with value mapping."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Create pattern
dmap.fill(0, pos=(0, 0), size=(5, 10)) # Left half = 0
dmap.fill(1, pos=(5, 0), size=(5, 10)) # Right half = 1
# Map discrete values to heights
mapping = {
0: 0.2,
1: 0.8,
}
hmap = dmap.to_heightmap(mapping)
assert abs(hmap[2, 5] - 0.2) < 0.001, f"Expected 0.2, got {hmap[2, 5]}"
assert abs(hmap[7, 5] - 0.8) < 0.001, f"Expected 0.8, got {hmap[7, 5]}"
print(" [PASS] to_heightmap with mapping")
def test_roundtrip():
"""Test HeightMap -> DiscreteMap -> HeightMap roundtrip."""
# Create original heightmap
original = mcrfpy.HeightMap((50, 50))
for y in range(50):
for x in range(50):
original[x, y] = (x + y) / 100.0 # Gradient 0.0 to 0.98
# Convert to discrete with specific ranges
mapping = [
((0.0, 0.33), 0),
((0.33, 0.66), 1),
((0.66, 1.0), 2),
]
dmap = mcrfpy.DiscreteMap.from_heightmap(original, mapping)
# Convert back with value mapping
reverse_mapping = {
0: 0.15, # Midpoint of first range
1: 0.5, # Midpoint of second range
2: 0.85, # Midpoint of third range
}
restored = dmap.to_heightmap(reverse_mapping)
# Verify approximate restoration
assert abs(restored[0, 0] - 0.15) < 0.01, f"Expected ~0.15 at (0,0), got {restored[0, 0]}"
assert abs(restored[25, 25] - 0.5) < 0.01, f"Expected ~0.5 at (25,25), got {restored[25, 25]}"
print(" [PASS] Roundtrip conversion")
def test_query_methods():
"""Test count, count_range, min_max, histogram."""
dmap = mcrfpy.DiscreteMap((10, 10))
# Create pattern with different values
dmap.fill(0, pos=(0, 0), size=(5, 5)) # 25 cells with 0
dmap.fill(1, pos=(5, 0), size=(5, 5)) # 25 cells with 1
dmap.fill(2, pos=(0, 5), size=(5, 5)) # 25 cells with 2
dmap.fill(3, pos=(5, 5), size=(5, 5)) # 25 cells with 3
# Test count
assert dmap.count(0) == 25, f"Expected 25 zeros, got {dmap.count(0)}"
assert dmap.count(1) == 25, f"Expected 25 ones, got {dmap.count(1)}"
assert dmap.count(4) == 0, f"Expected 0 fours, got {dmap.count(4)}"
# Test count_range
assert dmap.count_range(0, 1) == 50, f"Expected 50 in range 0-1, got {dmap.count_range(0, 1)}"
assert dmap.count_range(0, 3) == 100, f"Expected 100 in range 0-3, got {dmap.count_range(0, 3)}"
# Test min_max
min_val, max_val = dmap.min_max()
assert min_val == 0, f"Expected min 0, got {min_val}"
assert max_val == 3, f"Expected max 3, got {max_val}"
# Test histogram
hist = dmap.histogram()
assert hist[0] == 25, f"Expected 25 for value 0, got {hist.get(0)}"
assert hist[1] == 25, f"Expected 25 for value 1, got {hist.get(1)}"
assert hist[2] == 25, f"Expected 25 for value 2, got {hist.get(2)}"
assert hist[3] == 25, f"Expected 25 for value 3, got {hist.get(3)}"
assert 4 not in hist, "Value 4 should not be in histogram"
print(" [PASS] Query methods")
def test_bool_int():
"""Test bool() with integer condition."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=0)
dmap.fill(1, pos=(2, 2), size=(3, 3))
mask = dmap.bool(1)
# Should be 1 where original is 1, 0 elsewhere
assert mask[0, 0] == 0, f"Expected 0 outside region, got {mask[0, 0]}"
assert mask[3, 3] == 1, f"Expected 1 inside region, got {mask[3, 3]}"
assert mask.count(1) == 9, f"Expected 9 ones, got {mask.count(1)}"
print(" [PASS] bool() with int")
def test_bool_set():
"""Test bool() with set condition."""
dmap = mcrfpy.DiscreteMap((10, 10))
dmap.fill(0, pos=(0, 0), size=(5, 5))
dmap.fill(1, pos=(5, 0), size=(5, 5))
dmap.fill(2, pos=(0, 5), size=(5, 5))
dmap.fill(3, pos=(5, 5), size=(5, 5))
# Match 0 or 2
mask = dmap.bool({0, 2})
assert mask[2, 2] == 1, "Expected 1 where value is 0"
assert mask[7, 2] == 0, "Expected 0 where value is 1"
assert mask[2, 7] == 1, "Expected 1 where value is 2"
assert mask[7, 7] == 0, "Expected 0 where value is 3"
assert mask.count(1) == 50, f"Expected 50 ones, got {mask.count(1)}"
print(" [PASS] bool() with set")
def test_bool_callable():
"""Test bool() with callable condition."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=0)
for y in range(10):
for x in range(10):
dmap[x, y] = x + y # Values 0-18
# Match where value > 10
mask = dmap.bool(lambda v: v > 10)
assert mask[5, 5] == 0, "Expected 0 where value is 10"
assert mask[6, 6] == 1, "Expected 1 where value is 12"
assert mask[9, 9] == 1, "Expected 1 where value is 18"
print(" [PASS] bool() with callable")
def test_mask_memoryview():
"""Test mask() returns working memoryview."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=42)
mv = dmap.mask()
assert len(mv) == 100, f"Expected 100 bytes, got {len(mv)}"
assert mv[0] == 42, f"Expected 42, got {mv[0]}"
# Test writing through memoryview
mv[50] = 99
assert dmap[0, 5] == 99, f"Expected 99, got {dmap[0, 5]}"
print(" [PASS] mask() memoryview")
def test_enum_type_property():
"""Test enum_type property getter/setter."""
dmap = mcrfpy.DiscreteMap((10, 10), fill=1)
# Initially no enum
assert dmap.enum_type is None, "Expected None initially"
# Set enum type
dmap.enum_type = Terrain
assert dmap.enum_type is Terrain, "Expected Terrain enum"
# Value should now return enum member
val = dmap[5, 5]
assert val == Terrain.SAND, f"Expected Terrain.SAND, got {val}"
# Clear enum type
dmap.enum_type = None
val = dmap[5, 5]
assert isinstance(val, int), f"Expected int after clearing enum, got {type(val)}"
print(" [PASS] enum_type property")
def main():
print("Running DiscreteMap HeightMap integration tests...")
test_from_heightmap_basic()
test_from_heightmap_full_range()
test_from_heightmap_with_enum()
test_to_heightmap_basic()
test_to_heightmap_with_mapping()
test_roundtrip()
test_query_methods()
test_bool_int()
test_bool_set()
test_bool_callable()
test_mask_memoryview()
test_enum_type_property()
print("All DiscreteMap HeightMap integration tests PASSED!")
sys.exit(0)
if __name__ == "__main__":
main()