McRogueFace/tests/unit/voxel_navigation_test.py

247 lines
10 KiB
Python
Raw Normal View History

2026-02-06 16:15:07 -05:00
#!/usr/bin/env python3
"""Unit tests for Milestone 12: VoxelGrid Navigation Projection
Tests VoxelGrid.project_column() and Viewport3D voxel-to-nav projection methods.
"""
import mcrfpy
import sys
# Test counters
tests_passed = 0
tests_failed = 0
def test(name, condition):
"""Simple test helper"""
global tests_passed, tests_failed
if condition:
tests_passed += 1
print(f" PASS: {name}")
else:
tests_failed += 1
print(f" FAIL: {name}")
def approx_eq(a, b, epsilon=0.001):
"""Approximate floating-point equality"""
return abs(a - b) < epsilon
# =============================================================================
# Test projectColumn() on VoxelGrid
# =============================================================================
print("\n=== Testing VoxelGrid.project_column() ===")
# Test 1: Empty grid - all air
vg = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
nav = vg.project_column(5, 5)
test("Empty grid - height is 0", approx_eq(nav['height'], 0.0))
test("Empty grid - not walkable (no floor)", nav['walkable'] == False)
test("Empty grid - transparent", nav['transparent'] == True)
test("Empty grid - default path cost", approx_eq(nav['path_cost'], 1.0))
# Test 2: Simple floor
vg2 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone = vg2.add_material("stone", (128, 128, 128))
vg2.fill_box((0, 0, 0), (9, 0, 9), stone) # Floor at y=0
nav2 = vg2.project_column(5, 5)
test("Floor at y=0 - height is 1.0 (top of floor voxel)", approx_eq(nav2['height'], 1.0))
test("Floor at y=0 - walkable", nav2['walkable'] == True)
test("Floor at y=0 - not transparent (has solid voxel)", nav2['transparent'] == False)
# Test 3: Solid column extending to top - no headroom at boundary
vg3 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone3 = vg3.add_material("stone", (128, 128, 128))
vg3.fill_box((0, 0, 0), (9, 0, 9), stone3) # Floor at y=0
vg3.fill_box((0, 2, 0), (9, 9, 9), stone3) # Solid block from y=2 to y=9
nav3 = vg3.project_column(5, 5, headroom=2)
# Scan finds y=9 as topmost floor (boundary has "air above" but no actual headroom)
# Height = 10.0 (top of y=9 voxel), no air above means airCount=0, so not walkable
test("Top boundary floor - height at top", approx_eq(nav3['height'], 10.0))
test("Top boundary floor - not walkable (no headroom)", nav3['walkable'] == False)
# Test 4: Single floor slab with plenty of headroom
vg4 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone4 = vg4.add_material("stone", (128, 128, 128))
vg4.fill_box((0, 2, 0), (9, 2, 9), stone4) # Floor slab at y=2 (air below, 7 voxels air above)
nav4 = vg4.project_column(5, 5, headroom=2)
test("Floor slab at y=2 - height is 3.0", approx_eq(nav4['height'], 3.0))
test("Floor slab - walkable (7 voxels headroom)", nav4['walkable'] == True)
# Test 5: Custom headroom thresholds
nav4_h1 = vg4.project_column(5, 5, headroom=1)
test("Headroom=1 - walkable", nav4_h1['walkable'] == True)
nav4_h7 = vg4.project_column(5, 5, headroom=7)
test("Headroom=7 - walkable (exactly 7 air voxels)", nav4_h7['walkable'] == True)
nav4_h8 = vg4.project_column(5, 5, headroom=8)
test("Headroom=8 - not walkable (only 7 air)", nav4_h8['walkable'] == False)
# Test 6: Multi-level floor (finds topmost walkable)
vg5 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone5 = vg5.add_material("stone", (128, 128, 128))
vg5.fill_box((0, 0, 0), (9, 0, 9), stone5) # Bottom floor at y=0
vg5.fill_box((0, 5, 0), (9, 5, 9), stone5) # Upper floor at y=5
nav5 = vg5.project_column(5, 5)
test("Multi-level - finds top floor", approx_eq(nav5['height'], 6.0))
test("Multi-level - walkable", nav5['walkable'] == True)
# Test 7: Transparent material
vg6 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
glass = vg6.add_material("glass", (200, 200, 255), transparent=True)
vg6.set(5, 5, 5, glass)
nav6 = vg6.project_column(5, 5)
test("Transparent voxel - column is transparent", nav6['transparent'] == True)
# Test 8: Non-transparent material
vg7 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
wall = vg7.add_material("wall", (100, 100, 100), transparent=False)
vg7.set(5, 5, 5, wall)
nav7 = vg7.project_column(5, 5)
test("Opaque voxel - column not transparent", nav7['transparent'] == False)
# Test 9: Path cost from material
vg8 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
mud = vg8.add_material("mud", (139, 90, 43), path_cost=2.0)
vg8.fill_box((0, 0, 0), (9, 0, 9), mud) # Floor of mud
nav8 = vg8.project_column(5, 5)
test("Mud floor - path cost is 2.0", approx_eq(nav8['path_cost'], 2.0))
# Test 10: Cell size affects height
vg9 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=2.0)
stone9 = vg9.add_material("stone", (128, 128, 128))
vg9.fill_box((0, 0, 0), (9, 0, 9), stone9) # Floor at y=0
nav9 = vg9.project_column(5, 5)
test("Cell size 2.0 - height is 2.0", approx_eq(nav9['height'], 2.0))
# Test 11: Out of bounds returns default
nav_oob = vg.project_column(-1, 5)
test("Out of bounds - not walkable", nav_oob['walkable'] == False)
test("Out of bounds - height 0", approx_eq(nav_oob['height'], 0.0))
# =============================================================================
# Test Viewport3D voxel-to-nav projection
# =============================================================================
print("\n=== Testing Viewport3D voxel-to-nav projection ===")
# Create viewport with navigation grid
vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp.set_grid_size(20, 20)
vp.cell_size = 1.0
# Test 12: Initial nav grid state
cell = vp.at(10, 10)
test("Initial nav cell - walkable", cell.walkable == True)
test("Initial nav cell - transparent", cell.transparent == True)
test("Initial nav cell - height 0", approx_eq(cell.height, 0.0))
test("Initial nav cell - cost 1", approx_eq(cell.cost, 1.0))
# Test 13: Project simple voxel grid
vg_nav = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_nav = vg_nav.add_material("stone", (128, 128, 128))
vg_nav.fill_box((0, 0, 0), (9, 0, 9), stone_nav) # Floor
vg_nav.offset = (5, 0, 5) # Position grid at (5, 0, 5) in world
vp.add_voxel_layer(vg_nav)
vp.project_voxel_to_nav(vg_nav, headroom=2)
# Check cell within grid footprint
cell_in = vp.at(10, 10) # World (10, 10) = voxel grid local (5, 5)
test("Projected cell - walkable (floor present)", cell_in.walkable == True)
test("Projected cell - height is 1.0", approx_eq(cell_in.height, 1.0))
test("Projected cell - not transparent", cell_in.transparent == False)
# Check cell outside grid footprint (unchanged)
cell_out = vp.at(0, 0) # Outside voxel grid area
test("Outside cell - still walkable (unchanged)", cell_out.walkable == True)
test("Outside cell - height still 0", approx_eq(cell_out.height, 0.0))
# Test 14: Clear voxel nav region
vp.clear_voxel_nav_region(vg_nav)
cell_cleared = vp.at(10, 10)
test("Cleared cell - walkable reset to true", cell_cleared.walkable == True)
test("Cleared cell - height reset to 0", approx_eq(cell_cleared.height, 0.0))
test("Cleared cell - transparent reset to true", cell_cleared.transparent == True)
# Test 15: Project with walls (blocking)
vg_wall = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_wall = vg_wall.add_material("stone", (128, 128, 128))
vg_wall.fill_box((0, 0, 0), (9, 4, 9), stone_wall) # Solid block (no air above floor)
vg_wall.offset = (0, 0, 0)
vp2 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp2.set_grid_size(20, 20)
vp2.add_voxel_layer(vg_wall)
vp2.project_voxel_to_nav(vg_wall)
cell_wall = vp2.at(5, 5)
test("Solid block - height at top", approx_eq(cell_wall.height, 5.0))
test("Solid block - not transparent", cell_wall.transparent == False)
# Test 16: project_all_voxels_to_nav with multiple layers
vp3 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp3.set_grid_size(20, 20)
# First layer - lower priority
vg_layer1 = mcrfpy.VoxelGrid((20, 5, 20), cell_size=1.0)
dirt = vg_layer1.add_material("dirt", (139, 90, 43))
vg_layer1.fill_box((0, 0, 0), (19, 0, 19), dirt) # Floor everywhere
# Second layer - higher priority, partial coverage
vg_layer2 = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0)
stone_l2 = vg_layer2.add_material("stone", (128, 128, 128))
vg_layer2.fill_box((0, 0, 0), (4, 2, 4), stone_l2) # Higher floor
vg_layer2.offset = (5, 0, 5)
vp3.add_voxel_layer(vg_layer1, z_index=0)
vp3.add_voxel_layer(vg_layer2, z_index=1)
vp3.project_all_voxels_to_nav()
cell_dirt = vp3.at(0, 0) # Only dirt layer
cell_stone = vp3.at(7, 7) # Stone layer overlaps (higher z_index)
test("Multi-layer - dirt area height is 1", approx_eq(cell_dirt.height, 1.0))
test("Multi-layer - stone area height is 3 (higher layer)", approx_eq(cell_stone.height, 3.0))
# Test 17: Viewport projection with different headroom values
vg_low = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_low = vg_low.add_material("stone", (128, 128, 128))
vg_low.fill_box((0, 0, 0), (9, 0, 9), stone_low) # Floor at y=0
# Grid has height=5, so floor at y=0 has 4 air voxels above (y=1,2,3,4)
vp4 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp4.set_grid_size(20, 20)
vp4.add_voxel_layer(vg_low)
vp4.project_voxel_to_nav(vg_low, headroom=1)
test("Headroom 1 - walkable (4 air voxels)", vp4.at(5, 5).walkable == True)
vp4.project_voxel_to_nav(vg_low, headroom=4)
test("Headroom 4 - walkable (exactly 4 air)", vp4.at(5, 5).walkable == True)
vp4.project_voxel_to_nav(vg_low, headroom=5)
test("Headroom 5 - not walkable (only 4 air)", vp4.at(5, 5).walkable == False)
# Test 18: Grid offset in world space
vg_offset = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0)
stone_off = vg_offset.add_material("stone", (128, 128, 128))
vg_offset.fill_box((0, 0, 0), (4, 0, 4), stone_off)
vg_offset.offset = (10, 5, 10) # Y offset = 5
vp5 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp5.set_grid_size(20, 20)
vp5.add_voxel_layer(vg_offset)
vp5.project_voxel_to_nav(vg_offset)
cell_off = vp5.at(12, 12)
test("Y-offset grid - height includes offset", approx_eq(cell_off.height, 6.0)) # floor 1 + offset 5
# =============================================================================
# Summary
# =============================================================================
print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===")
if tests_failed > 0:
sys.exit(1)
else:
print("All tests passed!")
sys.exit(0)