HeightMap: add query methods (closes #196)
Add methods to query HeightMap values and statistics: - get(pos): Get height value at integer coordinates - get_interpolated(pos): Get bilinearly interpolated height at float coords - get_slope(pos): Get slope angle (0 to pi/2) at position - get_normal(pos, water_level): Get surface normal vector - min_max(): Get (min, max) tuple of all values - count_in_range(range): Count cells with values in range All methods include proper bounds checking and error messages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
87444c2fd0
commit
8d6d564d6b
3 changed files with 468 additions and 0 deletions
|
|
@ -60,6 +60,56 @@ PyMethodDef PyHeightMap::methods[] = {
|
|||
MCRF_ARG("max", "Target maximum value (default 1.0)")
|
||||
MCRF_RETURNS("HeightMap: self, for method chaining")
|
||||
)},
|
||||
// Query methods (#196)
|
||||
{"get", (PyCFunction)PyHeightMap::get, METH_VARARGS,
|
||||
MCRF_METHOD(HeightMap, get,
|
||||
MCRF_SIG("(pos: tuple[int, int])", "float"),
|
||||
MCRF_DESC("Get the height value at integer coordinates."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Position as (x, y) tuple")
|
||||
MCRF_RETURNS("float: Height value at that position")
|
||||
MCRF_RAISES("IndexError", "Position is out of bounds")
|
||||
)},
|
||||
{"get_interpolated", (PyCFunction)PyHeightMap::get_interpolated, METH_VARARGS,
|
||||
MCRF_METHOD(HeightMap, get_interpolated,
|
||||
MCRF_SIG("(pos: tuple[float, float])", "float"),
|
||||
MCRF_DESC("Get interpolated height value at non-integer coordinates."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Position as (x, y) tuple with float coordinates")
|
||||
MCRF_RETURNS("float: Bilinearly interpolated height value")
|
||||
)},
|
||||
{"get_slope", (PyCFunction)PyHeightMap::get_slope, METH_VARARGS,
|
||||
MCRF_METHOD(HeightMap, get_slope,
|
||||
MCRF_SIG("(pos: tuple[int, int])", "float"),
|
||||
MCRF_DESC("Get the slope at integer coordinates, from 0 (flat) to pi/2 (vertical)."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Position as (x, y) tuple")
|
||||
MCRF_RETURNS("float: Slope angle in radians (0 to pi/2)")
|
||||
MCRF_RAISES("IndexError", "Position is out of bounds")
|
||||
)},
|
||||
{"get_normal", (PyCFunction)PyHeightMap::get_normal, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(HeightMap, get_normal,
|
||||
MCRF_SIG("(pos: tuple[float, float], water_level: float = 0.0)", "tuple[float, float, float]"),
|
||||
MCRF_DESC("Get the normal vector at given coordinates for lighting calculations."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Position as (x, y) tuple with float coordinates")
|
||||
MCRF_ARG("water_level", "Water level below which terrain is considered flat (default 0.0)")
|
||||
MCRF_RETURNS("tuple[float, float, float]: Normal vector (nx, ny, nz)")
|
||||
)},
|
||||
{"min_max", (PyCFunction)PyHeightMap::min_max, METH_NOARGS,
|
||||
MCRF_METHOD(HeightMap, min_max,
|
||||
MCRF_SIG("()", "tuple[float, float]"),
|
||||
MCRF_DESC("Get the minimum and maximum height values in the map."),
|
||||
MCRF_RETURNS("tuple[float, float]: (min_value, max_value)")
|
||||
)},
|
||||
{"count_in_range", (PyCFunction)PyHeightMap::count_in_range, METH_VARARGS,
|
||||
MCRF_METHOD(HeightMap, count_in_range,
|
||||
MCRF_SIG("(range: tuple[float, float])", "int"),
|
||||
MCRF_DESC("Count cells with values in the specified range (inclusive)."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("range", "Value range as (min, max) tuple")
|
||||
MCRF_RETURNS("int: Number of cells with values in range")
|
||||
)},
|
||||
{NULL}
|
||||
};
|
||||
|
||||
|
|
@ -300,3 +350,191 @@ PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObje
|
|||
Py_INCREF(self);
|
||||
return (PyObject*)self;
|
||||
}
|
||||
|
||||
// Query methods (#196)
|
||||
|
||||
// Method: get(pos) -> float
|
||||
PyObject* PyHeightMap::get(PyHeightMapObject* self, PyObject* args)
|
||||
{
|
||||
PyObject* pos_obj = nullptr;
|
||||
if (!PyArg_ParseTuple(args, "O", &pos_obj)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse position tuple
|
||||
if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int x = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 0));
|
||||
int y = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 1));
|
||||
|
||||
if (PyErr_Occurred()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 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 nullptr;
|
||||
}
|
||||
|
||||
float value = TCOD_heightmap_get_value(self->heightmap, x, y);
|
||||
return PyFloat_FromDouble(value);
|
||||
}
|
||||
|
||||
// Method: get_interpolated(pos) -> float
|
||||
PyObject* PyHeightMap::get_interpolated(PyHeightMapObject* self, PyObject* args)
|
||||
{
|
||||
PyObject* pos_obj = nullptr;
|
||||
if (!PyArg_ParseTuple(args, "O", &pos_obj)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse position tuple (floats)
|
||||
if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float x = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 0));
|
||||
float y = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 1));
|
||||
|
||||
if (PyErr_Occurred()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float value = TCOD_heightmap_get_interpolated_value(self->heightmap, x, y);
|
||||
return PyFloat_FromDouble(value);
|
||||
}
|
||||
|
||||
// Method: get_slope(pos) -> float
|
||||
PyObject* PyHeightMap::get_slope(PyHeightMapObject* self, PyObject* args)
|
||||
{
|
||||
PyObject* pos_obj = nullptr;
|
||||
if (!PyArg_ParseTuple(args, "O", &pos_obj)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse position tuple
|
||||
if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int x = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 0));
|
||||
int y = (int)PyLong_AsLong(PyTuple_GetItem(pos_obj, 1));
|
||||
|
||||
if (PyErr_Occurred()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 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 nullptr;
|
||||
}
|
||||
|
||||
float slope = TCOD_heightmap_get_slope(self->heightmap, x, y);
|
||||
return PyFloat_FromDouble(slope);
|
||||
}
|
||||
|
||||
// Method: get_normal(pos, water_level=0.0) -> tuple[float, float, float]
|
||||
PyObject* PyHeightMap::get_normal(PyHeightMapObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
static const char* keywords[] = {"pos", "water_level", nullptr};
|
||||
PyObject* pos_obj = nullptr;
|
||||
float water_level = 0.0f;
|
||||
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(keywords),
|
||||
&pos_obj, &water_level)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse position tuple (floats)
|
||||
if (!PyTuple_Check(pos_obj) || PyTuple_Size(pos_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "pos must be a tuple of (x, y)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float x = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 0));
|
||||
float y = (float)PyFloat_AsDouble(PyTuple_GetItem(pos_obj, 1));
|
||||
|
||||
if (PyErr_Occurred()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float n[3];
|
||||
TCOD_heightmap_get_normal(self->heightmap, x, y, n, water_level);
|
||||
|
||||
return Py_BuildValue("(fff)", n[0], n[1], n[2]);
|
||||
}
|
||||
|
||||
// Method: min_max() -> tuple[float, float]
|
||||
PyObject* PyHeightMap::min_max(PyHeightMapObject* self, PyObject* Py_UNUSED(args))
|
||||
{
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float min_val, max_val;
|
||||
TCOD_heightmap_get_minmax(self->heightmap, &min_val, &max_val);
|
||||
|
||||
return Py_BuildValue("(ff)", min_val, max_val);
|
||||
}
|
||||
|
||||
// Method: count_in_range(range) -> int
|
||||
PyObject* PyHeightMap::count_in_range(PyHeightMapObject* self, PyObject* args)
|
||||
{
|
||||
PyObject* range_obj = nullptr;
|
||||
if (!PyArg_ParseTuple(args, "O", &range_obj)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!self->heightmap) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parse range tuple
|
||||
if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float min_val = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0));
|
||||
float max_val = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1));
|
||||
|
||||
if (PyErr_Occurred()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int count = TCOD_heightmap_count_cells(self->heightmap, min_val, max_val);
|
||||
return PyLong_FromLong(count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ public:
|
|||
static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Query methods (#196)
|
||||
static PyObject* get(PyHeightMapObject* self, PyObject* args);
|
||||
static PyObject* get_interpolated(PyHeightMapObject* self, PyObject* args);
|
||||
static PyObject* get_slope(PyHeightMapObject* self, PyObject* args);
|
||||
static PyObject* get_normal(PyHeightMapObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* min_max(PyHeightMapObject* self, PyObject* Py_UNUSED(args));
|
||||
static PyObject* count_in_range(PyHeightMapObject* self, PyObject* args);
|
||||
|
||||
// Method and property definitions
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
|
|
|
|||
222
tests/unit/test_heightmap_query.py
Normal file
222
tests/unit/test_heightmap_query.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for mcrfpy.HeightMap query methods (#196)
|
||||
|
||||
Tests the HeightMap query methods: get, get_interpolated, get_slope, get_normal, min_max, count_in_range
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
import mcrfpy
|
||||
|
||||
|
||||
def test_get_basic():
|
||||
"""get() returns correct value at position"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
value = hmap.get((5, 5))
|
||||
assert abs(value - 0.5) < 0.001, f"Expected 0.5, got {value}"
|
||||
print("PASS: test_get_basic")
|
||||
|
||||
|
||||
def test_get_corners():
|
||||
"""get() works at all corners"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.25)
|
||||
|
||||
# All corners should have the fill value
|
||||
assert abs(hmap.get((0, 0)) - 0.25) < 0.001
|
||||
assert abs(hmap.get((9, 0)) - 0.25) < 0.001
|
||||
assert abs(hmap.get((0, 9)) - 0.25) < 0.001
|
||||
assert abs(hmap.get((9, 9)) - 0.25) < 0.001
|
||||
print("PASS: test_get_corners")
|
||||
|
||||
|
||||
def test_get_out_of_bounds():
|
||||
"""get() raises IndexError for out-of-bounds position"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
|
||||
# Test various out-of-bounds positions
|
||||
for pos in [(-1, 0), (0, -1), (10, 0), (0, 10), (10, 10)]:
|
||||
try:
|
||||
hmap.get(pos)
|
||||
print(f"FAIL: test_get_out_of_bounds - should have raised IndexError for {pos}")
|
||||
sys.exit(1)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
print("PASS: test_get_out_of_bounds")
|
||||
|
||||
|
||||
def test_get_invalid_type():
|
||||
"""get() raises TypeError for invalid position"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
|
||||
try:
|
||||
hmap.get([5, 5]) # list instead of tuple
|
||||
print("FAIL: test_get_invalid_type - should have raised TypeError")
|
||||
sys.exit(1)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
print("PASS: test_get_invalid_type")
|
||||
|
||||
|
||||
def test_get_interpolated_basic():
|
||||
"""get_interpolated() returns value at float position"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
value = hmap.get_interpolated((5.5, 5.5))
|
||||
# With uniform fill, interpolation should return same value
|
||||
assert abs(value - 0.5) < 0.001, f"Expected ~0.5, got {value}"
|
||||
print("PASS: test_get_interpolated_basic")
|
||||
|
||||
|
||||
def test_get_interpolated_at_integers():
|
||||
"""get_interpolated() matches get() at integer positions"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.75)
|
||||
|
||||
int_value = hmap.get((3, 4))
|
||||
interp_value = hmap.get_interpolated((3.0, 4.0))
|
||||
|
||||
assert abs(int_value - interp_value) < 0.001, f"Values differ: {int_value} vs {interp_value}"
|
||||
print("PASS: test_get_interpolated_at_integers")
|
||||
|
||||
|
||||
def test_get_slope_flat():
|
||||
"""get_slope() returns 0 for flat terrain"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
slope = hmap.get_slope((5, 5))
|
||||
# Flat terrain should have slope near 0
|
||||
assert abs(slope) < 0.01, f"Expected ~0 for flat terrain, got {slope}"
|
||||
print("PASS: test_get_slope_flat")
|
||||
|
||||
|
||||
def test_get_slope_out_of_bounds():
|
||||
"""get_slope() raises IndexError for out-of-bounds position"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
|
||||
try:
|
||||
hmap.get_slope((10, 5))
|
||||
print("FAIL: test_get_slope_out_of_bounds - should have raised IndexError")
|
||||
sys.exit(1)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
print("PASS: test_get_slope_out_of_bounds")
|
||||
|
||||
|
||||
def test_get_normal_flat():
|
||||
"""get_normal() returns up vector for flat terrain"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
nx, ny, nz = hmap.get_normal((5.0, 5.0))
|
||||
|
||||
# Flat terrain should have normal pointing up (0, 0, 1)
|
||||
assert abs(nx) < 0.01, f"Expected nx~0, got {nx}"
|
||||
assert abs(ny) < 0.01, f"Expected ny~0, got {ny}"
|
||||
assert abs(nz - 1.0) < 0.01, f"Expected nz~1, got {nz}"
|
||||
print("PASS: test_get_normal_flat")
|
||||
|
||||
|
||||
def test_get_normal_with_water_level():
|
||||
"""get_normal() accepts water_level parameter"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
nx, ny, nz = hmap.get_normal((5.0, 5.0), water_level=0.3)
|
||||
|
||||
# Should still return valid normal
|
||||
assert isinstance(nx, float)
|
||||
assert isinstance(ny, float)
|
||||
assert isinstance(nz, float)
|
||||
print("PASS: test_get_normal_with_water_level")
|
||||
|
||||
|
||||
def test_min_max_uniform():
|
||||
"""min_max() returns correct values for uniform heightmap"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
min_val, max_val = hmap.min_max()
|
||||
|
||||
assert abs(min_val - 0.5) < 0.001, f"Expected min=0.5, got {min_val}"
|
||||
assert abs(max_val - 0.5) < 0.001, f"Expected max=0.5, got {max_val}"
|
||||
print("PASS: test_min_max_uniform")
|
||||
|
||||
|
||||
def test_min_max_after_operations():
|
||||
"""min_max() updates after operations"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
hmap.fill(0.0).add_constant(0.5).scale(2.0)
|
||||
|
||||
min_val, max_val = hmap.min_max()
|
||||
expected = 1.0 # 0.0 + 0.5 * 2.0
|
||||
|
||||
assert abs(min_val - expected) < 0.001, f"Expected min={expected}, got {min_val}"
|
||||
assert abs(max_val - expected) < 0.001, f"Expected max={expected}, got {max_val}"
|
||||
print("PASS: test_min_max_after_operations")
|
||||
|
||||
|
||||
def test_count_in_range_all():
|
||||
"""count_in_range() returns all cells for uniform map in range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
count = hmap.count_in_range((0.0, 1.0))
|
||||
|
||||
assert count == 100, f"Expected 100 cells, got {count}"
|
||||
print("PASS: test_count_in_range_all")
|
||||
|
||||
|
||||
def test_count_in_range_none():
|
||||
"""count_in_range() returns 0 when no cells in range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
count = hmap.count_in_range((0.0, 0.4))
|
||||
|
||||
assert count == 0, f"Expected 0 cells, got {count}"
|
||||
print("PASS: test_count_in_range_none")
|
||||
|
||||
|
||||
def test_count_in_range_exact():
|
||||
"""count_in_range() with exact bounds"""
|
||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||
count = hmap.count_in_range((0.5, 0.5))
|
||||
|
||||
# Should count all cells since fill value is exactly 0.5
|
||||
assert count == 100, f"Expected 100 cells at exact value, got {count}"
|
||||
print("PASS: test_count_in_range_exact")
|
||||
|
||||
|
||||
def test_count_in_range_invalid():
|
||||
"""count_in_range() raises TypeError for invalid range"""
|
||||
hmap = mcrfpy.HeightMap((10, 10))
|
||||
|
||||
try:
|
||||
hmap.count_in_range([0.0, 1.0]) # list instead of tuple
|
||||
print("FAIL: test_count_in_range_invalid - should have raised TypeError")
|
||||
sys.exit(1)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
print("PASS: test_count_in_range_invalid")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests"""
|
||||
print("Running HeightMap query method tests...")
|
||||
print()
|
||||
|
||||
test_get_basic()
|
||||
test_get_corners()
|
||||
test_get_out_of_bounds()
|
||||
test_get_invalid_type()
|
||||
test_get_interpolated_basic()
|
||||
test_get_interpolated_at_integers()
|
||||
test_get_slope_flat()
|
||||
test_get_slope_out_of_bounds()
|
||||
test_get_normal_flat()
|
||||
test_get_normal_with_water_level()
|
||||
test_min_max_uniform()
|
||||
test_min_max_after_operations()
|
||||
test_count_in_range_all()
|
||||
test_count_in_range_none()
|
||||
test_count_in_range_exact()
|
||||
test_count_in_range_invalid()
|
||||
|
||||
print()
|
||||
print("All HeightMap query method tests PASSED!")
|
||||
|
||||
|
||||
# Run tests directly
|
||||
run_all_tests()
|
||||
sys.exit(0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue