Add y_plane parameter to screen_to_world(), closes #245

screen_to_world() previously only intersected the Y=0 plane.
Now accepts an optional y_plane parameter (default 0.0) for
intersecting arbitrary horizontal planes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-07 20:15:48 -05:00
commit f766e9efa2
2 changed files with 65 additions and 10 deletions

View file

@ -196,7 +196,7 @@ void Viewport3D::orbitCamera(float angle, float distance, float height) {
camera_.setTarget(vec3(0, 0, 0)); camera_.setTarget(vec3(0, 0, 0));
} }
vec3 Viewport3D::screenToWorld(float screenX, float screenY) { vec3 Viewport3D::screenToWorld(float screenX, float screenY, float yPlane) {
// Convert screen coordinates to normalized device coordinates (-1 to 1) // Convert screen coordinates to normalized device coordinates (-1 to 1)
// screenX/Y are relative to the viewport position // screenX/Y are relative to the viewport position
float ndcX = (2.0f * screenX / size_.x) - 1.0f; float ndcX = (2.0f * screenX / size_.x) - 1.0f;
@ -217,10 +217,10 @@ vec3 Viewport3D::screenToWorld(float screenX, float screenY) {
vec3 rayDir = vec3(rayWorld4.x, rayWorld4.y, rayWorld4.z).normalized(); vec3 rayDir = vec3(rayWorld4.x, rayWorld4.y, rayWorld4.z).normalized();
vec3 rayOrigin = camera_.getPosition(); vec3 rayOrigin = camera_.getPosition();
// Intersect with Y=0 plane (ground level) // Intersect with Y=yPlane horizontal plane
// This is a simplification - for hilly terrain, you'd want ray-marching // This is a simplification - for hilly terrain, you'd want ray-marching
if (std::abs(rayDir.y) > 0.0001f) { if (std::abs(rayDir.y) > 0.0001f) {
float t = -rayOrigin.y / rayDir.y; float t = (yPlane - rayOrigin.y) / rayDir.y;
if (t > 0) { if (t > 0) {
return rayOrigin + rayDir * t; return rayOrigin + rayDir * t;
} }
@ -2453,16 +2453,16 @@ static PyObject* Viewport3D_billboard_count(PyViewport3DObject* self, PyObject*
// ============================================================================= // =============================================================================
static PyObject* Viewport3D_screen_to_world(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { static PyObject* Viewport3D_screen_to_world(PyViewport3DObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"x", "y", NULL}; static const char* kwlist[] = {"x", "y", "y_plane", NULL};
float x = 0.0f, y = 0.0f; float x = 0.0f, y = 0.0f, y_plane = 0.0f;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff", const_cast<char**>(kwlist), &x, &y)) { if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff|f", const_cast<char**>(kwlist), &x, &y, &y_plane)) {
return NULL; return NULL;
} }
// Adjust for viewport position (user passes screen coords relative to viewport) // Adjust for viewport position (user passes screen coords relative to viewport)
vec3 worldPos = self->data->screenToWorld(x, y); vec3 worldPos = self->data->screenToWorld(x, y, y_plane);
// Return None if no intersection (ray parallel to ground or invalid) // Return None if no intersection (ray parallel to ground or invalid)
if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) { if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) {
@ -2833,13 +2833,14 @@ PyMethodDef Viewport3D_methods[] = {
// Camera & Input methods (Milestone 8) // Camera & Input methods (Milestone 8)
{"screen_to_world", (PyCFunction)mcrf::Viewport3D_screen_to_world, METH_VARARGS | METH_KEYWORDS, {"screen_to_world", (PyCFunction)mcrf::Viewport3D_screen_to_world, METH_VARARGS | METH_KEYWORDS,
"screen_to_world(x, y) -> tuple or None\n\n" "screen_to_world(x, y, y_plane=0.0) -> tuple or None\n\n"
"Convert screen coordinates to world position via ray casting.\n\n" "Convert screen coordinates to world position via ray casting.\n\n"
"Args:\n" "Args:\n"
" x: Screen X coordinate relative to viewport\n" " x: Screen X coordinate relative to viewport\n"
" y: Screen Y coordinate relative to viewport\n\n" " y: Screen Y coordinate relative to viewport\n"
" y_plane: Y value of horizontal plane to intersect (default: 0.0)\n\n"
"Returns:\n" "Returns:\n"
" (x, y, z) world position tuple, or None if no intersection with ground plane"}, " (x, y, z) world position tuple, or None if no intersection with the plane"},
{"follow", (PyCFunction)mcrf::Viewport3D_follow, METH_VARARGS | METH_KEYWORDS, {"follow", (PyCFunction)mcrf::Viewport3D_follow, METH_VARARGS | METH_KEYWORDS,
"follow(entity, distance=10, height=5, smoothing=1.0)\n\n" "follow(entity, distance=10, height=5, smoothing=1.0)\n\n"
"Position camera to follow an entity.\n\n" "Position camera to follow an entity.\n\n"

View file

@ -0,0 +1,54 @@
"""Test screen_to_world y_plane parameter (issue #245)"""
import mcrfpy
import sys
errors = []
vp = mcrfpy.Viewport3D(pos=(0,0), size=(320,240))
vp.set_grid_size(16, 16)
# Position camera above the Y=0 plane
vp.camera_pos = (0, 5, 5)
vp.camera_target = (0, 0, 0)
# Test 1: Default y_plane=0 works
r1 = vp.screen_to_world(160, 120)
if r1 is None:
errors.append("screen_to_world with default y_plane returned None")
# Test 2: Explicit y_plane=0 matches default
r2 = vp.screen_to_world(160, 120, y_plane=0.0)
if r2 is None:
errors.append("screen_to_world with y_plane=0 returned None")
elif r1 is not None:
for i in range(3):
if abs(r1[i] - r2[i]) > 0.001:
errors.append(f"Default and explicit y_plane=0 differ: {r1} vs {r2}")
break
# Test 3: Different y_plane gives different result
r3 = vp.screen_to_world(160, 120, y_plane=2.0)
if r3 is None:
errors.append("screen_to_world with y_plane=2 returned None")
elif r1 is not None:
if r1 == r3:
errors.append("y_plane=0 and y_plane=2 should give different results")
# Test 4: y component matches y_plane
if r3 is not None:
if abs(r3[1] - 2.0) > 0.001:
errors.append(f"y component should be 2.0 for y_plane=2.0, got {r3[1]}")
# Test 5: Positional argument also works
r4 = vp.screen_to_world(160, 120, 3.0)
if r4 is None:
errors.append("Positional y_plane argument returned None")
elif abs(r4[1] - 3.0) > 0.001:
errors.append(f"y component should be 3.0, got {r4[1]}")
if errors:
for err in errors:
print(f"FAIL: {err}", file=sys.stderr)
sys.exit(1)
else:
print("PASS: screen_to_world y_plane (issue #245)", file=sys.stderr)
sys.exit(0)