From 71cd2b9b414ba5eb92ebc5338c489753cbc2959b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 6 Feb 2026 16:15:07 -0500 Subject: [PATCH 1/3] 3D / voxel unit tests --- tests/unit/animated_model_test.py | 98 +++++++ tests/unit/animation_test.py | 159 +++++++++++ tests/unit/billboard_test.py | 260 ++++++++++++++++++ tests/unit/entity3d_test.py | 293 ++++++++++++++++++++ tests/unit/fov_3d_test.py | 198 ++++++++++++++ tests/unit/integration_api_test.py | 78 ++++++ tests/unit/mesh_instance_test.py | 182 +++++++++++++ tests/unit/meshlayer_test.py | 202 ++++++++++++++ tests/unit/model3d_test.py | 219 +++++++++++++++ tests/unit/pathfinding_3d_test.py | 208 ++++++++++++++ tests/unit/procgen_interactive_test.py | 241 +++++++++++++++++ tests/unit/skeleton_test.py | 80 ++++++ tests/unit/tilemap_file_test.py | 189 +++++++++++++ tests/unit/tileset_file_test.py | 145 ++++++++++ tests/unit/voxel_bulk_ops_test.py | 335 +++++++++++++++++++++++ tests/unit/voxel_greedy_meshing_test.py | 240 +++++++++++++++++ tests/unit/voxel_meshing_test.py | 300 +++++++++++++++++++++ tests/unit/voxel_navigation_test.py | 247 +++++++++++++++++ tests/unit/voxel_rendering_test.py | 189 +++++++++++++ tests/unit/voxel_serialization_test.py | 301 +++++++++++++++++++++ tests/unit/voxelgrid_test.py | 345 ++++++++++++++++++++++++ tests/unit/voxelpoint_test.py | 196 ++++++++++++++ 22 files changed, 4705 insertions(+) create mode 100644 tests/unit/animated_model_test.py create mode 100644 tests/unit/animation_test.py create mode 100644 tests/unit/billboard_test.py create mode 100644 tests/unit/entity3d_test.py create mode 100644 tests/unit/fov_3d_test.py create mode 100644 tests/unit/integration_api_test.py create mode 100644 tests/unit/mesh_instance_test.py create mode 100644 tests/unit/meshlayer_test.py create mode 100644 tests/unit/model3d_test.py create mode 100644 tests/unit/pathfinding_3d_test.py create mode 100644 tests/unit/procgen_interactive_test.py create mode 100644 tests/unit/skeleton_test.py create mode 100644 tests/unit/tilemap_file_test.py create mode 100644 tests/unit/tileset_file_test.py create mode 100644 tests/unit/voxel_bulk_ops_test.py create mode 100644 tests/unit/voxel_greedy_meshing_test.py create mode 100644 tests/unit/voxel_meshing_test.py create mode 100644 tests/unit/voxel_navigation_test.py create mode 100644 tests/unit/voxel_rendering_test.py create mode 100644 tests/unit/voxel_serialization_test.py create mode 100644 tests/unit/voxelgrid_test.py create mode 100644 tests/unit/voxelpoint_test.py diff --git a/tests/unit/animated_model_test.py b/tests/unit/animated_model_test.py new file mode 100644 index 0000000..9bf6347 --- /dev/null +++ b/tests/unit/animated_model_test.py @@ -0,0 +1,98 @@ +# animated_model_test.py - Test loading actual animated glTF models +# Tests skeleton and animation data loading from real files + +import mcrfpy +import sys +import os + +def test_rigged_simple(): + """Test loading RiggedSimple - a cylinder with 2 bones""" + print("Loading RiggedSimple.glb...") + model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb") + + print(f" has_skeleton: {model.has_skeleton}") + print(f" bone_count: {model.bone_count}") + print(f" animation_clips: {model.animation_clips}") + print(f" vertex_count: {model.vertex_count}") + print(f" triangle_count: {model.triangle_count}") + print(f" mesh_count: {model.mesh_count}") + + assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}" + assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}" + assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}" + + print("[PASS] test_rigged_simple") + +def test_cesium_man(): + """Test loading CesiumMan - animated humanoid figure""" + print("Loading CesiumMan.glb...") + model = mcrfpy.Model3D("../assets/models/CesiumMan.glb") + + print(f" has_skeleton: {model.has_skeleton}") + print(f" bone_count: {model.bone_count}") + print(f" animation_clips: {model.animation_clips}") + print(f" vertex_count: {model.vertex_count}") + print(f" triangle_count: {model.triangle_count}") + print(f" mesh_count: {model.mesh_count}") + + assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}" + assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}" + assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}" + + print("[PASS] test_cesium_man") + +def test_entity_with_animated_model(): + """Test Entity3D with an animated model attached""" + print("Testing Entity3D with animated model...") + + model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb") + entity = mcrfpy.Entity3D() + entity.model = model + + # Check animation clips are available + clips = model.animation_clips + print(f" Available clips: {clips}") + + if clips: + # Set animation clip + entity.anim_clip = clips[0] + assert entity.anim_clip == clips[0], f"Expected clip '{clips[0]}', got '{entity.anim_clip}'" + + # Test animation time progression + entity.anim_time = 0.5 + assert abs(entity.anim_time - 0.5) < 0.001, f"Expected anim_time~=0.5, got {entity.anim_time}" + + # Test speed + entity.anim_speed = 2.0 + assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}" + + print("[PASS] test_entity_with_animated_model") + +def run_all_tests(): + """Run all animated model tests""" + tests = [ + test_rigged_simple, + test_cesium_man, + test_entity_with_animated_model, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/animation_test.py b/tests/unit/animation_test.py new file mode 100644 index 0000000..52e833b --- /dev/null +++ b/tests/unit/animation_test.py @@ -0,0 +1,159 @@ +# animation_test.py - Unit tests for Entity3D skeletal animation + +import mcrfpy +import sys + +def test_entity3d_animation_defaults(): + """Test Entity3D animation property defaults""" + entity = mcrfpy.Entity3D() + + # Default animation state + assert entity.anim_clip == "", f"Expected empty anim_clip, got '{entity.anim_clip}'" + assert entity.anim_time == 0.0, f"Expected anim_time=0.0, got {entity.anim_time}" + assert entity.anim_speed == 1.0, f"Expected anim_speed=1.0, got {entity.anim_speed}" + assert entity.anim_loop == True, f"Expected anim_loop=True, got {entity.anim_loop}" + assert entity.anim_paused == False, f"Expected anim_paused=False, got {entity.anim_paused}" + assert entity.anim_frame == 0, f"Expected anim_frame=0, got {entity.anim_frame}" + + # Auto-animate defaults + assert entity.auto_animate == True, f"Expected auto_animate=True, got {entity.auto_animate}" + assert entity.walk_clip == "walk", f"Expected walk_clip='walk', got '{entity.walk_clip}'" + assert entity.idle_clip == "idle", f"Expected idle_clip='idle', got '{entity.idle_clip}'" + + print("[PASS] test_entity3d_animation_defaults") + +def test_entity3d_animation_properties(): + """Test setting Entity3D animation properties""" + entity = mcrfpy.Entity3D() + + # Set animation clip + entity.anim_clip = "test_anim" + assert entity.anim_clip == "test_anim", f"Expected 'test_anim', got '{entity.anim_clip}'" + + # Set animation time + entity.anim_time = 1.5 + assert abs(entity.anim_time - 1.5) < 0.001, f"Expected anim_time~=1.5, got {entity.anim_time}" + + # Set animation speed + entity.anim_speed = 2.0 + assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}" + + # Set loop + entity.anim_loop = False + assert entity.anim_loop == False, f"Expected anim_loop=False, got {entity.anim_loop}" + + # Set paused + entity.anim_paused = True + assert entity.anim_paused == True, f"Expected anim_paused=True, got {entity.anim_paused}" + + print("[PASS] test_entity3d_animation_properties") + +def test_entity3d_auto_animate(): + """Test Entity3D auto-animate settings""" + entity = mcrfpy.Entity3D() + + # Disable auto-animate + entity.auto_animate = False + assert entity.auto_animate == False + + # Set custom clip names + entity.walk_clip = "run" + entity.idle_clip = "stand" + assert entity.walk_clip == "run" + assert entity.idle_clip == "stand" + + print("[PASS] test_entity3d_auto_animate") + +def test_entity3d_animation_callback(): + """Test Entity3D animation complete callback""" + entity = mcrfpy.Entity3D() + callback_called = [False] + callback_args = [None, None] + + def on_complete(ent, clip_name): + callback_called[0] = True + callback_args[0] = ent + callback_args[1] = clip_name + + # Set callback + entity.on_anim_complete = on_complete + assert entity.on_anim_complete is not None + + # Clear callback + entity.on_anim_complete = None + # Should not raise error even though callback is None + + print("[PASS] test_entity3d_animation_callback") + +def test_entity3d_animation_callback_invalid(): + """Test that non-callable is rejected for animation callback""" + entity = mcrfpy.Entity3D() + + try: + entity.on_anim_complete = "not a function" + assert False, "Should have raised TypeError" + except TypeError: + pass + + print("[PASS] test_entity3d_animation_callback_invalid") + +def test_entity3d_with_model(): + """Test Entity3D animation with a non-skeletal model""" + entity = mcrfpy.Entity3D() + cube = mcrfpy.Model3D.cube() + + entity.model = cube + + # Setting animation clip on non-skeletal model should not crash + entity.anim_clip = "walk" # Should just do nothing gracefully + assert entity.anim_clip == "walk" # The property is set even if model has no animation + + # Frame should be 0 since there's no skeleton + assert entity.anim_frame == 0 + + print("[PASS] test_entity3d_with_model") + +def test_entity3d_animation_negative_speed(): + """Test that animation speed can be negative (reverse playback)""" + entity = mcrfpy.Entity3D() + + entity.anim_speed = -1.0 + assert abs(entity.anim_speed - (-1.0)) < 0.001 + + entity.anim_speed = 0.0 + assert entity.anim_speed == 0.0 + + print("[PASS] test_entity3d_animation_negative_speed") + +def run_all_tests(): + """Run all animation tests""" + tests = [ + test_entity3d_animation_defaults, + test_entity3d_animation_properties, + test_entity3d_auto_animate, + test_entity3d_animation_callback, + test_entity3d_animation_callback_invalid, + test_entity3d_with_model, + test_entity3d_animation_negative_speed, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/billboard_test.py b/tests/unit/billboard_test.py new file mode 100644 index 0000000..d7d499e --- /dev/null +++ b/tests/unit/billboard_test.py @@ -0,0 +1,260 @@ +# billboard_test.py - Unit test for Billboard 3D camera-facing sprites + +import mcrfpy +import sys + +def test_billboard_creation(): + """Test basic Billboard creation and default properties""" + bb = mcrfpy.Billboard() + + # Default sprite index + assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}" + + # Default position + assert bb.pos == (0.0, 0.0, 0.0), f"Expected pos=(0,0,0), got {bb.pos}" + + # Default scale + assert bb.scale == 1.0, f"Expected scale=1.0, got {bb.scale}" + + # Default facing mode + assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}" + + # Default theta/phi (for fixed mode) + assert bb.theta == 0.0, f"Expected theta=0.0, got {bb.theta}" + assert bb.phi == 0.0, f"Expected phi=0.0, got {bb.phi}" + + # Default opacity and visibility + assert bb.opacity == 1.0, f"Expected opacity=1.0, got {bb.opacity}" + assert bb.visible == True, f"Expected visible=True, got {bb.visible}" + + print("[PASS] test_billboard_creation") + +def test_billboard_with_kwargs(): + """Test Billboard creation with keyword arguments""" + bb = mcrfpy.Billboard( + sprite_index=5, + pos=(10.0, 5.0, -3.0), + scale=2.5, + facing="camera", + opacity=0.8 + ) + + assert bb.sprite_index == 5, f"Expected sprite_index=5, got {bb.sprite_index}" + assert bb.pos == (10.0, 5.0, -3.0), f"Expected pos=(10,5,-3), got {bb.pos}" + assert bb.scale == 2.5, f"Expected scale=2.5, got {bb.scale}" + assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}" + assert abs(bb.opacity - 0.8) < 0.001, f"Expected opacity~=0.8, got {bb.opacity}" + + print("[PASS] test_billboard_with_kwargs") + +def test_billboard_facing_modes(): + """Test all Billboard facing modes""" + bb = mcrfpy.Billboard() + + # Test camera mode (full rotation to face camera) + bb.facing = "camera" + assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}" + + # Test camera_y mode (only Y-axis rotation, stays upright) + bb.facing = "camera_y" + assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}" + + # Test fixed mode (uses theta/phi angles) + bb.facing = "fixed" + assert bb.facing == "fixed", f"Expected facing='fixed', got {bb.facing}" + + print("[PASS] test_billboard_facing_modes") + +def test_billboard_fixed_rotation(): + """Test fixed mode rotation angles (theta/phi)""" + bb = mcrfpy.Billboard(facing="fixed") + + # Set theta (horizontal rotation) + bb.theta = 1.5708 # ~90 degrees + assert abs(bb.theta - 1.5708) < 0.001, f"Expected theta~=1.5708, got {bb.theta}" + + # Set phi (vertical tilt) + bb.phi = 0.7854 # ~45 degrees + assert abs(bb.phi - 0.7854) < 0.001, f"Expected phi~=0.7854, got {bb.phi}" + + print("[PASS] test_billboard_fixed_rotation") + +def test_billboard_property_modification(): + """Test modifying Billboard properties after creation""" + bb = mcrfpy.Billboard() + + # Modify position + bb.pos = (5.0, 10.0, 15.0) + assert bb.pos == (5.0, 10.0, 15.0), f"Expected pos=(5,10,15), got {bb.pos}" + + # Modify sprite index + bb.sprite_index = 42 + assert bb.sprite_index == 42, f"Expected sprite_index=42, got {bb.sprite_index}" + + # Modify scale + bb.scale = 0.5 + assert bb.scale == 0.5, f"Expected scale=0.5, got {bb.scale}" + + # Modify opacity + bb.opacity = 0.25 + assert abs(bb.opacity - 0.25) < 0.001, f"Expected opacity~=0.25, got {bb.opacity}" + + # Modify visibility + bb.visible = False + assert bb.visible == False, f"Expected visible=False, got {bb.visible}" + + print("[PASS] test_billboard_property_modification") + +def test_billboard_opacity_clamping(): + """Test that opacity is clamped to 0-1 range""" + bb = mcrfpy.Billboard() + + # Test upper clamp + bb.opacity = 2.0 + assert bb.opacity == 1.0, f"Expected opacity=1.0 after clamping, got {bb.opacity}" + + # Test lower clamp + bb.opacity = -0.5 + assert bb.opacity == 0.0, f"Expected opacity=0.0 after clamping, got {bb.opacity}" + + print("[PASS] test_billboard_opacity_clamping") + +def test_billboard_repr(): + """Test Billboard string representation""" + bb = mcrfpy.Billboard(pos=(1.0, 2.0, 3.0), sprite_index=7, facing="camera") + repr_str = repr(bb) + + # Check that repr contains expected information + assert "Billboard" in repr_str, f"Expected 'Billboard' in repr, got {repr_str}" + + print("[PASS] test_billboard_repr") + +def test_billboard_with_texture(): + """Test Billboard with texture assignment""" + # Use default_texture which is always available + tex = mcrfpy.default_texture + bb = mcrfpy.Billboard(texture=tex, sprite_index=0) + + # Verify texture is assigned + assert bb.texture is not None, "Expected texture to be assigned" + assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}" + + # Change sprite index + bb.sprite_index = 10 + assert bb.sprite_index == 10, f"Expected sprite_index=10, got {bb.sprite_index}" + + # Test assigning texture via property + bb2 = mcrfpy.Billboard() + assert bb2.texture is None, "Expected no texture initially" + bb2.texture = tex + assert bb2.texture is not None, "Expected texture after assignment" + + # Test setting texture to None + bb2.texture = None + assert bb2.texture is None, "Expected None after clearing texture" + + print("[PASS] test_billboard_with_texture") + +def test_viewport3d_billboard_methods(): + """Test Viewport3D billboard management methods""" + vp = mcrfpy.Viewport3D() + + # Initial count should be 0 + assert vp.billboard_count() == 0, f"Expected 0, got {vp.billboard_count()}" + + # Add billboards + bb1 = mcrfpy.Billboard(pos=(1, 0, 1), scale=1.0) + vp.add_billboard(bb1) + assert vp.billboard_count() == 1, f"Expected 1, got {vp.billboard_count()}" + + bb2 = mcrfpy.Billboard(pos=(2, 0, 2), scale=0.5) + vp.add_billboard(bb2) + assert vp.billboard_count() == 2, f"Expected 2, got {vp.billboard_count()}" + + # Get billboard by index + retrieved = vp.get_billboard(0) + assert retrieved.pos == (1.0, 0.0, 1.0), f"Expected (1,0,1), got {retrieved.pos}" + + # Modify retrieved billboard + retrieved.pos = (5, 1, 5) + assert retrieved.pos == (5.0, 1.0, 5.0), f"Expected (5,1,5), got {retrieved.pos}" + + # Clear all billboards + vp.clear_billboards() + assert vp.billboard_count() == 0, f"Expected 0 after clear, got {vp.billboard_count()}" + + print("[PASS] test_viewport3d_billboard_methods") + +def test_viewport3d_billboard_index_bounds(): + """Test get_billboard index bounds checking""" + vp = mcrfpy.Viewport3D() + + # Empty viewport - any index should fail + try: + vp.get_billboard(0) + assert False, "Should have raised IndexError" + except IndexError: + pass + + # Add one billboard + bb = mcrfpy.Billboard() + vp.add_billboard(bb) + + # Index 0 should work + vp.get_billboard(0) + + # Index 1 should fail + try: + vp.get_billboard(1) + assert False, "Should have raised IndexError" + except IndexError: + pass + + # Negative index should fail + try: + vp.get_billboard(-1) + assert False, "Should have raised IndexError" + except IndexError: + pass + + print("[PASS] test_viewport3d_billboard_index_bounds") + +def run_all_tests(): + """Run all Billboard tests""" + tests = [ + test_billboard_creation, + test_billboard_with_kwargs, + test_billboard_facing_modes, + test_billboard_fixed_rotation, + test_billboard_property_modification, + test_billboard_opacity_clamping, + test_billboard_repr, + test_billboard_with_texture, + test_viewport3d_billboard_methods, + test_viewport3d_billboard_index_bounds, + ] + + passed = 0 + failed = 0 + skipped = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + if "[SKIP]" in str(e): + skipped += 1 + else: + print(f"[ERROR] {test.__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed, {skipped} skipped ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/entity3d_test.py b/tests/unit/entity3d_test.py new file mode 100644 index 0000000..21da752 --- /dev/null +++ b/tests/unit/entity3d_test.py @@ -0,0 +1,293 @@ +# entity3d_test.py - Unit test for Entity3D 3D game entities + +import mcrfpy +import sys + +def test_entity3d_creation(): + """Test basic Entity3D creation and default properties""" + e = mcrfpy.Entity3D() + + # Default grid position (0, 0) + assert e.pos == (0, 0), f"Expected pos=(0, 0), got {e.pos}" + assert e.grid_pos == (0, 0), f"Expected grid_pos=(0, 0), got {e.grid_pos}" + + # Default world position (at origin) + wp = e.world_pos + assert len(wp) == 3, f"Expected 3-tuple for world_pos, got {wp}" + assert wp[0] == 0.0, f"Expected world_pos.x=0, got {wp[0]}" + assert wp[2] == 0.0, f"Expected world_pos.z=0, got {wp[2]}" + + # Default rotation + assert e.rotation == 0.0, f"Expected rotation=0, got {e.rotation}" + + # Default scale + assert e.scale == 1.0, f"Expected scale=1.0, got {e.scale}" + + # Default visibility + assert e.visible == True, f"Expected visible=True, got {e.visible}" + + # Default color (orange: 200, 100, 50) + c = e.color + assert c.r == 200, f"Expected color.r=200, got {c.r}" + assert c.g == 100, f"Expected color.g=100, got {c.g}" + assert c.b == 50, f"Expected color.b=50, got {c.b}" + + print("[PASS] test_entity3d_creation") + +def test_entity3d_with_pos(): + """Test Entity3D creation with position argument""" + e = mcrfpy.Entity3D(pos=(5, 10)) + + assert e.pos == (5, 10), f"Expected pos=(5, 10), got {e.pos}" + assert e.grid_pos == (5, 10), f"Expected grid_pos=(5, 10), got {e.grid_pos}" + + print("[PASS] test_entity3d_with_pos") + +def test_entity3d_with_kwargs(): + """Test Entity3D creation with keyword arguments""" + e = mcrfpy.Entity3D( + pos=(3, 7), + rotation=90.0, + scale=2.0, + visible=False, + color=mcrfpy.Color(255, 0, 0) + ) + + assert e.pos == (3, 7), f"Expected pos=(3, 7), got {e.pos}" + assert e.rotation == 90.0, f"Expected rotation=90, got {e.rotation}" + assert e.scale == 2.0, f"Expected scale=2.0, got {e.scale}" + assert e.visible == False, f"Expected visible=False, got {e.visible}" + assert e.color.r == 255, f"Expected color.r=255, got {e.color.r}" + assert e.color.g == 0, f"Expected color.g=0, got {e.color.g}" + + print("[PASS] test_entity3d_with_kwargs") + +def test_entity3d_property_modification(): + """Test modifying Entity3D properties after creation""" + e = mcrfpy.Entity3D() + + # Modify rotation + e.rotation = 180.0 + assert e.rotation == 180.0, f"Expected rotation=180, got {e.rotation}" + + # Modify scale + e.scale = 0.5 + assert e.scale == 0.5, f"Expected scale=0.5, got {e.scale}" + + # Modify visibility + e.visible = False + assert e.visible == False, f"Expected visible=False, got {e.visible}" + e.visible = True + assert e.visible == True, f"Expected visible=True, got {e.visible}" + + # Modify color + e.color = mcrfpy.Color(0, 255, 128) + assert e.color.r == 0, f"Expected color.r=0, got {e.color.r}" + assert e.color.g == 255, f"Expected color.g=255, got {e.color.g}" + assert e.color.b == 128, f"Expected color.b=128, got {e.color.b}" + + print("[PASS] test_entity3d_property_modification") + +def test_entity3d_teleport(): + """Test Entity3D teleport method""" + e = mcrfpy.Entity3D(pos=(0, 0)) + + # Teleport to new position + e.teleport(15, 20) + + assert e.pos == (15, 20), f"Expected pos=(15, 20), got {e.pos}" + assert e.grid_pos == (15, 20), f"Expected grid_pos=(15, 20), got {e.grid_pos}" + + # World position should also update + wp = e.world_pos + # World position is grid * cell_size, but we don't know cell size here + # Just verify it changed from origin + assert wp[0] != 0.0 or wp[2] != 0.0, f"Expected world_pos to change, got {wp}" + + print("[PASS] test_entity3d_teleport") + +def test_entity3d_pos_setter(): + """Test setting position via pos property""" + e = mcrfpy.Entity3D(pos=(0, 0)) + + # Set position (this should trigger animated movement when in a viewport) + e.pos = (8, 12) + + # The grid position should update + assert e.pos == (8, 12), f"Expected pos=(8, 12), got {e.pos}" + + print("[PASS] test_entity3d_pos_setter") + +def test_entity3d_repr(): + """Test Entity3D string representation""" + e = mcrfpy.Entity3D(pos=(5, 10)) + e.rotation = 45.0 + repr_str = repr(e) + + assert "Entity3D" in repr_str, f"Expected 'Entity3D' in repr, got {repr_str}" + assert "5" in repr_str, f"Expected grid_x in repr, got {repr_str}" + assert "10" in repr_str, f"Expected grid_z in repr, got {repr_str}" + + print("[PASS] test_entity3d_repr") + +def test_entity3d_viewport_integration(): + """Test adding Entity3D to a Viewport3D""" + # Create viewport with navigation grid + vp = mcrfpy.Viewport3D() + vp.set_grid_size(32, 32) + + # Create entity + e = mcrfpy.Entity3D(pos=(5, 5)) + + # Verify entity has no viewport initially + assert e.viewport is None, f"Expected viewport=None before adding, got {e.viewport}" + + # Add to viewport + vp.entities.append(e) + + # Verify entity count + assert len(vp.entities) == 1, f"Expected 1 entity, got {len(vp.entities)}" + + # Verify entity was linked to viewport + # Note: viewport property may not be set until render cycle + # For now, just verify the entity is in the collection + retrieved = vp.entities[0] + assert retrieved.pos == (5, 5), f"Expected retrieved entity at (5, 5), got {retrieved.pos}" + + print("[PASS] test_entity3d_viewport_integration") + +def test_entitycollection3d_operations(): + """Test EntityCollection3D sequence operations""" + vp = mcrfpy.Viewport3D() + vp.set_grid_size(20, 20) + + # Initially empty + assert len(vp.entities) == 0, f"Expected 0 entities initially, got {len(vp.entities)}" + + # Add multiple entities + e1 = mcrfpy.Entity3D(pos=(1, 1)) + e2 = mcrfpy.Entity3D(pos=(5, 5)) + e3 = mcrfpy.Entity3D(pos=(10, 10)) + + vp.entities.append(e1) + vp.entities.append(e2) + vp.entities.append(e3) + + assert len(vp.entities) == 3, f"Expected 3 entities, got {len(vp.entities)}" + + # Access by index + assert vp.entities[0].pos == (1, 1), f"Expected entities[0] at (1,1)" + assert vp.entities[1].pos == (5, 5), f"Expected entities[1] at (5,5)" + assert vp.entities[2].pos == (10, 10), f"Expected entities[2] at (10,10)" + + # Negative indexing + assert vp.entities[-1].pos == (10, 10), f"Expected entities[-1] at (10,10)" + + # Contains check + assert e2 in vp.entities, "Expected e2 in entities" + + # Iteration + positions = [e.pos for e in vp.entities] + assert (1, 1) in positions, "Expected (1,1) in iterated positions" + assert (5, 5) in positions, "Expected (5,5) in iterated positions" + + # Remove + vp.entities.remove(e2) + assert len(vp.entities) == 2, f"Expected 2 entities after remove, got {len(vp.entities)}" + assert e2 not in vp.entities, "Expected e2 not in entities after remove" + + # Clear + vp.entities.clear() + assert len(vp.entities) == 0, f"Expected 0 entities after clear, got {len(vp.entities)}" + + print("[PASS] test_entitycollection3d_operations") + +def test_entity3d_scene_integration(): + """Test Entity3D works when viewport is in a scene""" + scene = mcrfpy.Scene("entity3d_test") + + vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480)) + vp.set_grid_size(32, 32) + + # Add viewport to scene + scene.children.append(vp) + + # Add entity to viewport + e = mcrfpy.Entity3D(pos=(16, 16), rotation=45.0, color=mcrfpy.Color(0, 255, 0)) + vp.entities.append(e) + + # Verify everything is connected + assert len(scene.children) == 1, "Expected 1 child in scene" + + viewport_from_scene = scene.children[0] + assert type(viewport_from_scene).__name__ == "Viewport3D" + assert len(viewport_from_scene.entities) == 1, "Expected 1 entity in viewport" + + entity_from_vp = viewport_from_scene.entities[0] + assert entity_from_vp.pos == (16, 16), f"Expected entity at (16, 16), got {entity_from_vp.pos}" + assert entity_from_vp.rotation == 45.0, f"Expected rotation=45, got {entity_from_vp.rotation}" + + print("[PASS] test_entity3d_scene_integration") + +def test_entity3d_multiple_entities(): + """Test multiple entities in a viewport""" + vp = mcrfpy.Viewport3D() + vp.set_grid_size(50, 50) + + # Create a grid of entities + entities = [] + for x in range(0, 50, 10): + for z in range(0, 50, 10): + e = mcrfpy.Entity3D(pos=(x, z)) + e.color = mcrfpy.Color(x * 5, z * 5, 128) + entities.append(e) + vp.entities.append(e) + + expected_count = 5 * 5 # 0, 10, 20, 30, 40 for both x and z + assert len(vp.entities) == expected_count, f"Expected {expected_count} entities, got {len(vp.entities)}" + + # Verify we can access all entities + found_positions = set() + for e in vp.entities: + found_positions.add(e.pos) + + assert len(found_positions) == expected_count, f"Expected {expected_count} unique positions" + + print("[PASS] test_entity3d_multiple_entities") + +def run_all_tests(): + """Run all Entity3D tests""" + tests = [ + test_entity3d_creation, + test_entity3d_with_pos, + test_entity3d_with_kwargs, + test_entity3d_property_modification, + test_entity3d_teleport, + test_entity3d_pos_setter, + test_entity3d_repr, + test_entity3d_viewport_integration, + test_entitycollection3d_operations, + test_entity3d_scene_integration, + test_entity3d_multiple_entities, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/fov_3d_test.py b/tests/unit/fov_3d_test.py new file mode 100644 index 0000000..186c7e4 --- /dev/null +++ b/tests/unit/fov_3d_test.py @@ -0,0 +1,198 @@ +# fov_3d_test.py - Unit tests for 3D field of view +# Tests FOV computation on VoxelPoint navigation grid + +import mcrfpy +import sys + +def test_basic_fov(): + """Test basic FOV computation""" + print("Testing basic FOV...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (20, 20) + + # Compute FOV from center + visible = viewport.compute_fov((10, 10), radius=5) + + # Should have visible cells + assert len(visible) > 0, "Expected visible cells" + + # Origin should be visible + assert (10, 10) in visible, "Origin should be visible" + + # Cells within radius should be visible + assert (10, 11) in visible, "(10, 11) should be visible" + assert (11, 10) in visible, "(11, 10) should be visible" + + # Cells outside radius should not be visible + assert (10, 20) not in visible, "(10, 20) should not be visible" + assert (0, 0) not in visible, "(0, 0) should not be visible" + + print(f" PASS: Basic FOV ({len(visible)} cells visible)") + + +def test_fov_with_walls(): + """Test FOV blocked by opaque cells""" + print("Testing FOV with walls...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (20, 20) + + # Create a wall blocking line of sight + # Wall at x=12 + for z in range(5, 16): + viewport.at(12, z).transparent = False + + # Compute FOV from (10, 10) + visible = viewport.compute_fov((10, 10), radius=10) + + # Origin should be visible + assert (10, 10) in visible, "Origin should be visible" + + # Cells before wall should be visible + assert (11, 10) in visible, "Cell before wall should be visible" + + # Wall cells themselves might be visible (at the edge) + # But cells behind wall should NOT be visible + # Note: Exact behavior depends on FOV algorithm + + # Cells well behind the wall should not be visible + # (18, 10) is 6 cells behind the wall + assert (18, 10) not in visible, "Cell behind wall should not be visible" + + print(f" PASS: FOV with walls ({len(visible)} cells visible)") + + +def test_fov_radius(): + """Test FOV respects radius""" + print("Testing FOV radius...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (30, 30) + + # Compute small FOV + visible_small = viewport.compute_fov((15, 15), radius=3) + + # Compute larger FOV + visible_large = viewport.compute_fov((15, 15), radius=8) + + # Larger radius should reveal more cells + assert len(visible_large) > len(visible_small), \ + f"Larger radius should reveal more cells ({len(visible_large)} vs {len(visible_small)})" + + # Small FOV cells should be subset of large FOV + for cell in visible_small: + assert cell in visible_large, f"{cell} in small FOV but not in large FOV" + + print(f" PASS: FOV radius (small={len(visible_small)}, large={len(visible_large)})") + + +def test_is_in_fov(): + """Test is_in_fov() method""" + print("Testing is_in_fov...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (20, 20) + + # Compute FOV + viewport.compute_fov((10, 10), radius=5) + + # Check is_in_fov matches compute_fov results + assert viewport.is_in_fov(10, 10) == True, "Origin should be in FOV" + assert viewport.is_in_fov(10, 11) == True, "Adjacent cell should be in FOV" + assert viewport.is_in_fov(0, 0) == False, "Distant cell should not be in FOV" + + print(" PASS: is_in_fov method") + + +def test_fov_corner(): + """Test FOV from corner position""" + print("Testing FOV from corner...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (20, 20) + + # Compute FOV from corner + visible = viewport.compute_fov((0, 0), radius=5) + + # Origin should be visible + assert (0, 0) in visible, "Origin should be visible" + + # Cells in direction of grid should be visible + assert (1, 0) in visible, "(1, 0) should be visible" + assert (0, 1) in visible, "(0, 1) should be visible" + + # Should handle edge of grid gracefully + # Shouldn't crash or have negative coordinates + + print(f" PASS: FOV from corner ({len(visible)} cells visible)") + + +def test_fov_empty_grid(): + """Test FOV on uninitialized grid""" + print("Testing FOV on empty grid...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Grid size is 0x0 by default + # Compute FOV should return empty list or handle gracefully + visible = viewport.compute_fov((0, 0), radius=5) + + assert len(visible) == 0, "FOV on empty grid should return empty list" + + print(" PASS: FOV on empty grid") + + +def test_multiple_fov_calls(): + """Test that multiple FOV calls work correctly""" + print("Testing multiple FOV calls...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (20, 20) + + # First FOV from (5, 5) + visible1 = viewport.compute_fov((5, 5), radius=4) + assert (5, 5) in visible1, "First origin should be visible" + + # Second FOV from (15, 15) + visible2 = viewport.compute_fov((15, 15), radius=4) + assert (15, 15) in visible2, "Second origin should be visible" + + # is_in_fov should reflect the LAST computed FOV + assert viewport.is_in_fov(15, 15) == True, "Last origin should be in FOV" + # Note: (5, 5) might not be in FOV anymore depending on radius + + print(" PASS: Multiple FOV calls") + + +def run_all_tests(): + """Run all unit tests""" + print("=" * 60) + print("3D FOV Unit Tests") + print("=" * 60) + + try: + test_basic_fov() + test_fov_with_walls() + test_fov_radius() + test_is_in_fov() + test_fov_corner() + test_fov_empty_grid() + test_multiple_fov_calls() + + print("=" * 60) + print("ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + except AssertionError as e: + print(f"FAIL: {e}") + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +# Run tests +run_all_tests() diff --git a/tests/unit/integration_api_test.py b/tests/unit/integration_api_test.py new file mode 100644 index 0000000..e1f6f74 --- /dev/null +++ b/tests/unit/integration_api_test.py @@ -0,0 +1,78 @@ +# integration_api_test.py - Test Milestone 8 API additions +# Tests: Entity3D.follow_path, .is_moving, .clear_path +# Viewport3D.screen_to_world, .follow + +import mcrfpy +import sys + +print("Testing Milestone 8 API additions...") + +# Create test scene +scene = mcrfpy.Scene("test") + +# Create viewport +viewport = mcrfpy.Viewport3D( + pos=(0, 0), + size=(800, 600), + render_resolution=(320, 240), + fov=60.0, + camera_pos=(10.0, 10.0, 10.0), + camera_target=(5.0, 0.0, 5.0) +) +scene.children.append(viewport) + +# Set up navigation grid +viewport.set_grid_size(20, 20) + +# Create entity +entity = mcrfpy.Entity3D(pos=(5, 5)) +viewport.entities.append(entity) + +# Test 1: is_moving property (should be False initially) +print(f"Test 1: is_moving = {entity.is_moving}") +assert entity.is_moving == False, "Entity should not be moving initially" +print(" PASS: is_moving is False initially") + +# Test 2: follow_path method +path = [(6, 5), (7, 5), (8, 5)] +entity.follow_path(path) +print(f"Test 2: follow_path({path})") +# After follow_path, entity should be moving (or at least have queued moves) +print(f" is_moving after follow_path = {entity.is_moving}") +assert entity.is_moving == True, "Entity should be moving after follow_path" +print(" PASS: follow_path queued movement") + +# Test 3: clear_path method +entity.clear_path() +print("Test 3: clear_path()") +print(f" is_moving after clear_path = {entity.is_moving}") +# Note: is_moving may still be True if animation is in progress +print(" PASS: clear_path executed without error") + +# Test 4: screen_to_world +world_pos = viewport.screen_to_world(400, 300) +print(f"Test 4: screen_to_world(400, 300) = {world_pos}") +if world_pos is None: + print(" WARNING: screen_to_world returned None (ray missed ground)") +else: + assert len(world_pos) == 3, "Should return (x, y, z) tuple" + print(f" PASS: Got world position {world_pos}") + +# Test 5: follow method +viewport.follow(entity, distance=8.0, height=5.0) +print("Test 5: follow(entity, distance=8, height=5)") +cam_pos = viewport.camera_pos +print(f" Camera position after follow: {cam_pos}") +print(" PASS: follow executed without error") + +# Test 6: path_to (existing method) +path = entity.path_to(10, 10) +print(f"Test 6: path_to(10, 10) = {path[:3]}..." if len(path) > 3 else f"Test 6: path_to(10, 10) = {path}") +print(" PASS: path_to works") + +print() +print("=" * 50) +print("All Milestone 8 API tests PASSED!") +print("=" * 50) + +sys.exit(0) diff --git a/tests/unit/mesh_instance_test.py b/tests/unit/mesh_instance_test.py new file mode 100644 index 0000000..05ac45a --- /dev/null +++ b/tests/unit/mesh_instance_test.py @@ -0,0 +1,182 @@ +# mesh_instance_test.py - Unit test for MeshLayer mesh instances and Viewport3D mesh APIs + +import mcrfpy +import sys + +def test_viewport3d_add_mesh(): + """Test adding meshes to Viewport3D layers""" + vp = mcrfpy.Viewport3D() + + # Add a mesh layer first + vp.add_layer("ground", z_index=0) + + # Create a model to place (simple cube primitive) + model = mcrfpy.Model3D() + + # Add mesh instance at position + result = vp.add_mesh("ground", model, pos=(5.0, 0.0, 5.0)) + + # Should return the index of the added mesh + assert result is not None, "Expected add_mesh to return something" + assert isinstance(result, int), f"Expected int index, got {type(result)}" + assert result == 0, f"Expected first mesh index 0, got {result}" + + print("[PASS] test_viewport3d_add_mesh") + +def test_viewport3d_add_mesh_with_transform(): + """Test adding meshes with rotation and scale""" + vp = mcrfpy.Viewport3D() + vp.add_layer("buildings", z_index=0) + + model = mcrfpy.Model3D() + + # Add with rotation (in degrees as per API) + idx1 = vp.add_mesh("buildings", model, pos=(10.0, 0.0, 10.0), rotation=90) + assert idx1 == 0, f"Expected first mesh index 0, got {idx1}" + + # Add with scale + idx2 = vp.add_mesh("buildings", model, pos=(15.0, 0.0, 15.0), scale=2.0) + assert idx2 == 1, f"Expected second mesh index 1, got {idx2}" + + # Add with both rotation and scale + idx3 = vp.add_mesh("buildings", model, pos=(5.0, 0.0, 5.0), rotation=45, scale=0.5) + assert idx3 == 2, f"Expected third mesh index 2, got {idx3}" + + print("[PASS] test_viewport3d_add_mesh_with_transform") + +def test_viewport3d_clear_meshes(): + """Test clearing meshes from a layer""" + vp = mcrfpy.Viewport3D() + vp.add_layer("objects", z_index=0) + + model = mcrfpy.Model3D() + + # Add several meshes + vp.add_mesh("objects", model, pos=(1.0, 0.0, 1.0)) + vp.add_mesh("objects", model, pos=(2.0, 0.0, 2.0)) + vp.add_mesh("objects", model, pos=(3.0, 0.0, 3.0)) + + # Clear meshes from layer + vp.clear_meshes("objects") + + # Add a new mesh - should get index 0 since list was cleared + idx = vp.add_mesh("objects", model, pos=(0.0, 0.0, 0.0)) + assert idx == 0, f"Expected index 0 after clear, got {idx}" + + print("[PASS] test_viewport3d_clear_meshes") + +def test_viewport3d_place_blocking(): + """Test placing blocking information on the navigation grid""" + vp = mcrfpy.Viewport3D() + + # Initialize navigation grid first + vp.set_grid_size(width=16, depth=16) + + # Place blocking cell (unwalkable, non-transparent) + vp.place_blocking(grid_pos=(5, 5), footprint=(1, 1)) + + # Place larger blocking footprint + vp.place_blocking(grid_pos=(10, 10), footprint=(2, 2)) + + # Place blocking with custom walkability + vp.place_blocking(grid_pos=(0, 0), footprint=(3, 3), walkable=False, transparent=True) + + # Verify the cells were marked (check via VoxelPoint) + cell = vp.at(5, 5) + assert cell.walkable == False, f"Expected cell (5,5) unwalkable, got walkable={cell.walkable}" + + cell_transparent = vp.at(0, 0) + assert cell_transparent.transparent == True, f"Expected cell (0,0) transparent" + + print("[PASS] test_viewport3d_place_blocking") + +def test_viewport3d_mesh_layer_operations(): + """Test various mesh layer operations""" + vp = mcrfpy.Viewport3D() + + # Create multiple layers + vp.add_layer("floor", z_index=0) + vp.add_layer("walls", z_index=1) + vp.add_layer("props", z_index=2) + + model = mcrfpy.Model3D() + + # Add meshes to different layers + vp.add_mesh("floor", model, pos=(0.0, 0.0, 0.0)) + vp.add_mesh("walls", model, pos=(1.0, 1.0, 0.0), rotation=0, scale=1.5) + vp.add_mesh("props", model, pos=(2.0, 0.0, 2.0), scale=0.25) + + # Clear only one layer + vp.clear_meshes("walls") + + # Other layers should be unaffected + # (Can verify by adding to them and checking indices) + idx_floor = vp.add_mesh("floor", model, pos=(5.0, 0.0, 5.0)) + assert idx_floor == 1, f"Expected floor mesh index 1, got {idx_floor}" + + idx_walls = vp.add_mesh("walls", model, pos=(5.0, 0.0, 5.0)) + assert idx_walls == 0, f"Expected walls mesh index 0 after clear, got {idx_walls}" + + print("[PASS] test_viewport3d_mesh_layer_operations") + +def test_auto_layer_creation(): + """Test that add_mesh auto-creates layers if they don't exist""" + vp = mcrfpy.Viewport3D() + model = mcrfpy.Model3D() + + # Add mesh to a layer that doesn't exist yet - should auto-create it + idx = vp.add_mesh("auto_created", model, pos=(0.0, 0.0, 0.0)) + assert idx == 0, f"Expected index 0 for auto-created layer, got {idx}" + + # Verify the layer was created + layer = vp.get_layer("auto_created") + assert layer is not None, "Expected auto_created layer to exist" + + print("[PASS] test_auto_layer_creation") + +def test_invalid_layer_clear(): + """Test error handling for clearing non-existent layers""" + vp = mcrfpy.Viewport3D() + + # Try to clear meshes from non-existent layer + try: + vp.clear_meshes("nonexistent") + # If it doesn't raise, it might just silently succeed (which is fine too) + print("[PASS] test_invalid_layer_clear (no exception)") + return + except (ValueError, KeyError, RuntimeError): + print("[PASS] test_invalid_layer_clear (exception raised)") + return + +def run_all_tests(): + """Run all mesh instance tests""" + tests = [ + test_viewport3d_add_mesh, + test_viewport3d_add_mesh_with_transform, + test_viewport3d_clear_meshes, + test_viewport3d_place_blocking, + test_viewport3d_mesh_layer_operations, + test_auto_layer_creation, + test_invalid_layer_clear, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/meshlayer_test.py b/tests/unit/meshlayer_test.py new file mode 100644 index 0000000..b734c88 --- /dev/null +++ b/tests/unit/meshlayer_test.py @@ -0,0 +1,202 @@ +# meshlayer_test.py - Unit tests for MeshLayer terrain system +# Tests HeightMap to 3D mesh conversion via Viewport3D + +import mcrfpy +import sys + +def test_viewport3d_layer_creation(): + """Test that layers can be created and managed""" + print("Testing Viewport3D layer creation...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Initial layer count should be 0 + assert viewport.layer_count() == 0, f"Expected 0 layers, got {viewport.layer_count()}" + + # Add a layer + layer_info = viewport.add_layer("test_layer", z_index=5) + assert layer_info is not None, "add_layer returned None" + assert layer_info["name"] == "test_layer", f"Layer name mismatch: {layer_info['name']}" + assert layer_info["z_index"] == 5, f"Z-index mismatch: {layer_info['z_index']}" + + # Layer count should be 1 + assert viewport.layer_count() == 1, f"Expected 1 layer, got {viewport.layer_count()}" + + # Get the layer + retrieved = viewport.get_layer("test_layer") + assert retrieved is not None, "get_layer returned None" + assert retrieved["name"] == "test_layer" + + # Get non-existent layer + missing = viewport.get_layer("nonexistent") + assert missing is None, "Expected None for missing layer" + + # Remove the layer + removed = viewport.remove_layer("test_layer") + assert removed == True, "remove_layer should return True" + assert viewport.layer_count() == 0, "Layer count should be 0 after removal" + + # Remove non-existent layer + removed_again = viewport.remove_layer("test_layer") + assert removed_again == False, "remove_layer should return False for missing layer" + + print(" PASS: Layer creation and management") + +def test_terrain_from_heightmap(): + """Test building terrain mesh from HeightMap""" + print("Testing terrain mesh from HeightMap...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Create a small heightmap + hm = mcrfpy.HeightMap((10, 10)) + hm.fill(0.5) # Flat terrain at 0.5 height + + # Build terrain + vertex_count = viewport.build_terrain( + layer_name="terrain", + heightmap=hm, + y_scale=2.0, + cell_size=1.0 + ) + + # Expected vertices: (10-1) x (10-1) quads x 2 triangles x 3 vertices = 9 * 9 * 6 = 486 + expected_verts = 9 * 9 * 6 + assert vertex_count == expected_verts, f"Expected {expected_verts} vertices, got {vertex_count}" + + # Verify layer exists + layer = viewport.get_layer("terrain") + assert layer is not None, "Terrain layer not found" + assert layer["vertex_count"] == expected_verts + + print(f" PASS: Built terrain with {vertex_count} vertices") + +def test_heightmap_terrain_generation(): + """Test that HeightMap generation methods work with terrain""" + print("Testing HeightMap generation methods...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Test midpoint displacement + hm = mcrfpy.HeightMap((17, 17)) # Power of 2 + 1 for midpoint displacement + hm.mid_point_displacement(0.5, seed=123) + hm.normalize(0.0, 1.0) + + min_h, max_h = hm.min_max() + assert min_h >= 0.0, f"Min height should be >= 0, got {min_h}" + assert max_h <= 1.0, f"Max height should be <= 1, got {max_h}" + + vertex_count = viewport.build_terrain("terrain", hm, y_scale=5.0, cell_size=1.0) + assert vertex_count > 0, "Should have vertices" + + print(f" PASS: Midpoint displacement terrain with {vertex_count} vertices") + +def test_orbit_camera(): + """Test camera orbit helper""" + print("Testing camera orbit...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Test orbit at different angles + import math + + viewport.orbit_camera(angle=0, distance=10, height=5) + pos = viewport.camera_pos + assert abs(pos[0] - 10.0) < 0.01, f"X should be 10 at angle=0, got {pos[0]}" + assert abs(pos[1] - 5.0) < 0.01, f"Y (height) should be 5, got {pos[1]}" + assert abs(pos[2]) < 0.01, f"Z should be 0 at angle=0, got {pos[2]}" + + viewport.orbit_camera(angle=math.pi/2, distance=10, height=5) + pos = viewport.camera_pos + assert abs(pos[0]) < 0.01, f"X should be 0 at angle=pi/2, got {pos[0]}" + assert abs(pos[2] - 10.0) < 0.01, f"Z should be 10 at angle=pi/2, got {pos[2]}" + + print(" PASS: Camera orbit positioning") + +def test_large_terrain(): + """Test larger terrain (performance check)""" + print("Testing larger terrain mesh...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # 80x45 is mentioned in the milestone doc + hm = mcrfpy.HeightMap((80, 45)) + hm.mid_point_displacement(0.5, seed=999) + hm.normalize(0.0, 1.0) + + vertex_count = viewport.build_terrain("large_terrain", hm, y_scale=4.0, cell_size=1.0) + + # Expected: 79 * 44 * 6 = 20,856 vertices + expected = 79 * 44 * 6 + assert vertex_count == expected, f"Expected {expected} vertices, got {vertex_count}" + + print(f" PASS: Large terrain ({80}x{45} heightmap) with {vertex_count} vertices") + +def test_terrain_color_map(): + """Test applying RGB color maps to terrain""" + print("Testing terrain color map...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Create small terrain + hm = mcrfpy.HeightMap((10, 10)) + hm.fill(0.5) + viewport.build_terrain("colored_terrain", hm, y_scale=2.0, cell_size=1.0) + + # Create RGB color maps + r_map = mcrfpy.HeightMap((10, 10)) + g_map = mcrfpy.HeightMap((10, 10)) + b_map = mcrfpy.HeightMap((10, 10)) + + # Fill with test colors (red terrain) + r_map.fill(1.0) + g_map.fill(0.0) + b_map.fill(0.0) + + # Apply colors - should not raise + viewport.apply_terrain_colors("colored_terrain", r_map, g_map, b_map) + + # Test with mismatched dimensions (should fail silently or raise) + wrong_size = mcrfpy.HeightMap((5, 5)) + wrong_size.fill(0.5) + # This should not crash, just do nothing due to dimension mismatch + viewport.apply_terrain_colors("colored_terrain", wrong_size, wrong_size, wrong_size) + + # Test with non-existent layer + try: + viewport.apply_terrain_colors("nonexistent", r_map, g_map, b_map) + assert False, "Should have raised ValueError for non-existent layer" + except ValueError: + pass # Expected + + print(" PASS: Terrain color map application") + +def run_all_tests(): + """Run all unit tests""" + print("=" * 60) + print("MeshLayer Unit Tests") + print("=" * 60) + + try: + test_viewport3d_layer_creation() + test_terrain_from_heightmap() + test_heightmap_terrain_generation() + test_orbit_camera() + test_large_terrain() + test_terrain_color_map() + + print("=" * 60) + print("ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + except AssertionError as e: + print(f"FAIL: {e}") + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +# Run tests +run_all_tests() diff --git a/tests/unit/model3d_test.py b/tests/unit/model3d_test.py new file mode 100644 index 0000000..537dfa7 --- /dev/null +++ b/tests/unit/model3d_test.py @@ -0,0 +1,219 @@ +# model3d_test.py - Unit test for Model3D 3D model resource + +import mcrfpy +import sys + +def test_model3d_cube(): + """Test Model3D.cube() creates valid model""" + cube = mcrfpy.Model3D.cube(2.0) + + assert cube.name == "cube", f"Expected name='cube', got '{cube.name}'" + assert cube.vertex_count == 24, f"Expected 24 vertices, got {cube.vertex_count}" + assert cube.triangle_count == 12, f"Expected 12 triangles, got {cube.triangle_count}" + assert cube.has_skeleton == False, f"Expected has_skeleton=False, got {cube.has_skeleton}" + assert cube.mesh_count == 1, f"Expected 1 mesh, got {cube.mesh_count}" + + # Check bounds for size=2.0 cube + bounds = cube.bounds + assert bounds is not None, "Bounds should not be None" + min_b, max_b = bounds + assert min_b == (-1.0, -1.0, -1.0), f"Expected min=(-1,-1,-1), got {min_b}" + assert max_b == (1.0, 1.0, 1.0), f"Expected max=(1,1,1), got {max_b}" + + print("[PASS] test_model3d_cube") + +def test_model3d_cube_default_size(): + """Test Model3D.cube() with default size""" + cube = mcrfpy.Model3D.cube() + + # Default size is 1.0, so bounds should be -0.5 to 0.5 + bounds = cube.bounds + min_b, max_b = bounds + assert abs(min_b[0] - (-0.5)) < 0.001, f"Expected min.x=-0.5, got {min_b[0]}" + assert abs(max_b[0] - 0.5) < 0.001, f"Expected max.x=0.5, got {max_b[0]}" + + print("[PASS] test_model3d_cube_default_size") + +def test_model3d_plane(): + """Test Model3D.plane() creates valid model""" + plane = mcrfpy.Model3D.plane(4.0, 2.0, 2) + + assert plane.name == "plane", f"Expected name='plane', got '{plane.name}'" + # 2 segments = 3x3 grid = 9 vertices + assert plane.vertex_count == 9, f"Expected 9 vertices, got {plane.vertex_count}" + # 2x2 quads = 8 triangles + assert plane.triangle_count == 8, f"Expected 8 triangles, got {plane.triangle_count}" + assert plane.has_skeleton == False, f"Expected has_skeleton=False" + + # Bounds should be width/2, 0, depth/2 + bounds = plane.bounds + min_b, max_b = bounds + assert abs(min_b[0] - (-2.0)) < 0.001, f"Expected min.x=-2, got {min_b[0]}" + assert abs(max_b[0] - 2.0) < 0.001, f"Expected max.x=2, got {max_b[0]}" + assert abs(min_b[2] - (-1.0)) < 0.001, f"Expected min.z=-1, got {min_b[2]}" + assert abs(max_b[2] - 1.0) < 0.001, f"Expected max.z=1, got {max_b[2]}" + + print("[PASS] test_model3d_plane") + +def test_model3d_plane_default(): + """Test Model3D.plane() with default parameters""" + plane = mcrfpy.Model3D.plane() + + # Default is 1x1 with 1 segment = 4 vertices, 2 triangles + assert plane.vertex_count == 4, f"Expected 4 vertices, got {plane.vertex_count}" + assert plane.triangle_count == 2, f"Expected 2 triangles, got {plane.triangle_count}" + + print("[PASS] test_model3d_plane_default") + +def test_model3d_sphere(): + """Test Model3D.sphere() creates valid model""" + sphere = mcrfpy.Model3D.sphere(1.0, 8, 6) + + assert sphere.name == "sphere", f"Expected name='sphere', got '{sphere.name}'" + # vertices = (segments+1) * (rings+1) = 9 * 7 = 63 + assert sphere.vertex_count == 63, f"Expected 63 vertices, got {sphere.vertex_count}" + # triangles = 2 * segments * rings = 2 * 8 * 6 = 96 + assert sphere.triangle_count == 96, f"Expected 96 triangles, got {sphere.triangle_count}" + + # Bounds should be radius in all directions + bounds = sphere.bounds + min_b, max_b = bounds + assert abs(min_b[0] - (-1.0)) < 0.001, f"Expected min.x=-1, got {min_b[0]}" + assert abs(max_b[0] - 1.0) < 0.001, f"Expected max.x=1, got {max_b[0]}" + + print("[PASS] test_model3d_sphere") + +def test_model3d_sphere_default(): + """Test Model3D.sphere() with default parameters""" + sphere = mcrfpy.Model3D.sphere() + + # Default radius=0.5, segments=16, rings=12 + # vertices = 17 * 13 = 221 + assert sphere.vertex_count == 221, f"Expected 221 vertices, got {sphere.vertex_count}" + # triangles = 2 * 16 * 12 = 384 + assert sphere.triangle_count == 384, f"Expected 384 triangles, got {sphere.triangle_count}" + + print("[PASS] test_model3d_sphere_default") + +def test_model3d_empty(): + """Test creating empty Model3D""" + empty = mcrfpy.Model3D() + + assert empty.name == "unnamed", f"Expected name='unnamed', got '{empty.name}'" + assert empty.vertex_count == 0, f"Expected 0 vertices, got {empty.vertex_count}" + assert empty.triangle_count == 0, f"Expected 0 triangles, got {empty.triangle_count}" + assert empty.mesh_count == 0, f"Expected 0 meshes, got {empty.mesh_count}" + + print("[PASS] test_model3d_empty") + +def test_model3d_repr(): + """Test Model3D string representation""" + cube = mcrfpy.Model3D.cube() + repr_str = repr(cube) + + assert "Model3D" in repr_str, f"Expected 'Model3D' in repr, got {repr_str}" + assert "cube" in repr_str, f"Expected 'cube' in repr, got {repr_str}" + assert "24" in repr_str, f"Expected vertex count in repr, got {repr_str}" + + print("[PASS] test_model3d_repr") + +def test_entity3d_model_property(): + """Test Entity3D.model property""" + e = mcrfpy.Entity3D(pos=(0, 0)) + + # Initially no model + assert e.model is None, f"Expected model=None, got {e.model}" + + # Assign model + cube = mcrfpy.Model3D.cube() + e.model = cube + assert e.model is not None, "Expected model to be set" + assert e.model.name == "cube", f"Expected model.name='cube', got {e.model.name}" + + # Swap model + sphere = mcrfpy.Model3D.sphere() + e.model = sphere + assert e.model.name == "sphere", f"Expected model.name='sphere', got {e.model.name}" + + # Clear model + e.model = None + assert e.model is None, f"Expected model=None after clearing" + + print("[PASS] test_entity3d_model_property") + +def test_entity3d_model_type_error(): + """Test Entity3D.model raises TypeError for invalid input""" + e = mcrfpy.Entity3D() + + try: + e.model = "not a model" + print("[FAIL] test_entity3d_model_type_error: Expected TypeError") + return + except TypeError: + pass + + try: + e.model = 123 + print("[FAIL] test_entity3d_model_type_error: Expected TypeError") + return + except TypeError: + pass + + print("[PASS] test_entity3d_model_type_error") + +def test_entity3d_with_model_in_viewport(): + """Test Entity3D with model in a Viewport3D""" + vp = mcrfpy.Viewport3D() + vp.set_grid_size(16, 16) + + # Create entity with model + cube = mcrfpy.Model3D.cube(0.5) + e = mcrfpy.Entity3D(pos=(8, 8)) + e.model = cube + + # Add to viewport + vp.entities.append(e) + + # Verify model is preserved + retrieved = vp.entities[0] + assert retrieved.model is not None, "Expected model to be preserved" + assert retrieved.model.name == "cube", f"Expected model.name='cube', got {retrieved.model.name}" + + print("[PASS] test_entity3d_with_model_in_viewport") + +def run_all_tests(): + """Run all Model3D tests""" + tests = [ + test_model3d_cube, + test_model3d_cube_default_size, + test_model3d_plane, + test_model3d_plane_default, + test_model3d_sphere, + test_model3d_sphere_default, + test_model3d_empty, + test_model3d_repr, + test_entity3d_model_property, + test_entity3d_model_type_error, + test_entity3d_with_model_in_viewport, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/pathfinding_3d_test.py b/tests/unit/pathfinding_3d_test.py new file mode 100644 index 0000000..8985b30 --- /dev/null +++ b/tests/unit/pathfinding_3d_test.py @@ -0,0 +1,208 @@ +# pathfinding_3d_test.py - Unit tests for 3D pathfinding +# Tests A* pathfinding on VoxelPoint navigation grid + +import mcrfpy +import sys + +def test_simple_path(): + """Test pathfinding on an open grid""" + print("Testing simple pathfinding...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Find path from corner to corner + path = viewport.find_path((0, 0), (9, 9)) + + # Should find a path + assert len(path) > 0, "Expected a path, got empty list" + + # Path should end at destination (start is not included) + assert path[-1] == (9, 9), f"Path should end at (9, 9), got {path[-1]}" + + # Path length should be reasonable (diagonal allows shorter paths) + # Manhattan distance is 18, but with diagonals it can be ~9-14 steps + assert len(path) >= 9 and len(path) <= 18, f"Path length {len(path)} is unexpected" + + print(f" PASS: Simple pathfinding ({len(path)} steps)") + + +def test_path_with_obstacles(): + """Test pathfinding around obstacles""" + print("Testing pathfinding with obstacles...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Create a wall blocking direct path + # Wall from (4, 0) to (4, 8) + for z in range(9): + viewport.at(4, z).walkable = False + + # Find path from left side to right side + path = viewport.find_path((2, 5), (7, 5)) + + # Should find a path (going around the wall via z=9) + assert len(path) > 0, "Expected a path around the wall" + + # Verify path doesn't go through wall + for x, z in path: + if x == 4 and z < 9: + assert False, f"Path goes through wall at ({x}, {z})" + + print(f" PASS: Pathfinding with obstacles ({len(path)} steps)") + + +def test_no_path(): + """Test pathfinding when no path exists""" + print("Testing no path scenario...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Create a complete wall blocking all paths + # Wall from (5, 0) to (5, 9) - blocks entire grid + for z in range(10): + viewport.at(5, z).walkable = False + + # Try to find path from left to right + path = viewport.find_path((2, 5), (7, 5)) + + # Should return empty list (no path) + assert len(path) == 0, f"Expected empty path, got {len(path)} steps" + + print(" PASS: No path returns empty list") + + +def test_start_equals_end(): + """Test pathfinding when start equals end""" + print("Testing start equals end...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Find path to same location + path = viewport.find_path((5, 5), (5, 5)) + + # Should return empty path (already there) + assert len(path) == 0, f"Expected empty path for start==end, got {len(path)} steps" + + print(" PASS: Start equals end") + + +def test_adjacent_path(): + """Test pathfinding to adjacent cell""" + print("Testing adjacent cell pathfinding...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Find path to adjacent cell + path = viewport.find_path((5, 5), (5, 6)) + + # Should be a single step + assert len(path) == 1, f"Expected 1 step, got {len(path)}" + assert path[0] == (5, 6), f"Expected (5, 6), got {path[0]}" + + print(" PASS: Adjacent cell pathfinding") + + +def test_heightmap_threshold(): + """Test apply_threshold sets walkability""" + print("Testing HeightMap threshold...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Create a heightmap + hm = mcrfpy.HeightMap((10, 10)) + + # Set heights: left half low (0.2), right half high (0.8) + for z in range(10): + for x in range(5): + hm[x, z] = 0.2 + for x in range(5, 10): + hm[x, z] = 0.8 + + # Initialize grid + viewport.grid_size = (10, 10) + + # Apply threshold: mark high areas (>0.6) as unwalkable + viewport.apply_threshold(hm, 0.6, 1.0, walkable=False) + + # Check left side is walkable + assert viewport.at(2, 5).walkable == True, "Left side should be walkable" + + # Check right side is unwalkable + assert viewport.at(7, 5).walkable == False, "Right side should be unwalkable" + + # Pathfinding should fail to cross + path = viewport.find_path((2, 5), (7, 5)) + assert len(path) == 0, "Path should not exist through unwalkable terrain" + + print(" PASS: HeightMap threshold") + + +def test_slope_cost(): + """Test slope cost calculation""" + print("Testing slope cost...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Create terrain with a steep slope + # Set heights manually + for z in range(10): + for x in range(10): + viewport.at(x, z).height = 0.0 + + # Create a cliff at x=5 + for z in range(10): + for x in range(5, 10): + viewport.at(x, z).height = 2.0 # 2.0 units high + + # Apply slope cost: max slope 0.5, mark steeper as unwalkable + viewport.set_slope_cost(max_slope=0.5, cost_multiplier=2.0) + + # Check that cells at the cliff edge are marked unwalkable + # Cell at (4, 5) borders (5, 5) which is 2.0 higher + assert viewport.at(4, 5).walkable == False, "Cliff edge should be unwalkable" + assert viewport.at(5, 5).walkable == False, "Cliff top edge should be unwalkable" + + # Cells away from cliff should still be walkable + assert viewport.at(0, 5).walkable == True, "Flat area should be walkable" + assert viewport.at(9, 5).walkable == True, "Flat high area should be walkable" + + print(" PASS: Slope cost") + + +def run_all_tests(): + """Run all unit tests""" + print("=" * 60) + print("3D Pathfinding Unit Tests") + print("=" * 60) + + try: + test_simple_path() + test_path_with_obstacles() + test_no_path() + test_start_equals_end() + test_adjacent_path() + test_heightmap_threshold() + test_slope_cost() + + print("=" * 60) + print("ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + except AssertionError as e: + print(f"FAIL: {e}") + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +# Run tests +run_all_tests() diff --git a/tests/unit/procgen_interactive_test.py b/tests/unit/procgen_interactive_test.py new file mode 100644 index 0000000..69fb8a4 --- /dev/null +++ b/tests/unit/procgen_interactive_test.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Unit tests for the Interactive Procedural Generation Demo System. + +Tests: +- Demo creation and initialization +- Step execution (forward/backward) +- Parameter changes and regeneration +- Layer visibility toggling +- State snapshot capture/restore +""" + +import sys +import os + +# Add tests directory to path +tests_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if tests_dir not in sys.path: + sys.path.insert(0, tests_dir) + +import mcrfpy +from procgen_interactive.demos.cave_demo import CaveDemo +from procgen_interactive.demos.dungeon_demo import DungeonDemo +from procgen_interactive.demos.terrain_demo import TerrainDemo +from procgen_interactive.demos.town_demo import TownDemo + + +def test_cave_demo(): + """Test Cave demo creation and stepping.""" + print("Testing CaveDemo...") + + demo = CaveDemo() + demo.activate() + + # Run all steps + for i in range(len(demo.steps)): + demo.advance_step() + assert demo.current_step == i + 1, f"Step count mismatch: {demo.current_step} != {i + 1}" + + # Test backward navigation + demo.reverse_step() + assert demo.current_step == len(demo.steps) - 1, "Reverse step failed" + + print(" CaveDemo OK") + return True + + +def test_dungeon_demo(): + """Test Dungeon demo creation and stepping.""" + print("Testing DungeonDemo...") + + demo = DungeonDemo() + demo.activate() + + # Run all steps + for i in range(len(demo.steps)): + demo.advance_step() + + assert demo.current_step == len(demo.steps), "Step count mismatch" + print(" DungeonDemo OK") + return True + + +def test_terrain_demo(): + """Test Terrain demo creation and stepping.""" + print("Testing TerrainDemo...") + + demo = TerrainDemo() + demo.activate() + + # Run all steps + for i in range(len(demo.steps)): + demo.advance_step() + + assert demo.current_step == len(demo.steps), "Step count mismatch" + print(" TerrainDemo OK") + return True + + +def test_town_demo(): + """Test Town demo creation and stepping.""" + print("Testing TownDemo...") + + demo = TownDemo() + demo.activate() + + # Run all steps + for i in range(len(demo.steps)): + demo.advance_step() + + assert demo.current_step == len(demo.steps), "Step count mismatch" + print(" TownDemo OK") + return True + + +def test_parameter_change(demo=None): + """Test that parameter changes trigger regeneration.""" + print("Testing parameter changes...") + + # Reuse existing demo if provided (to avoid scene name conflict) + if demo is None: + demo = CaveDemo() + demo.activate() + + # Change a parameter + seed_param = demo.parameters["seed"] + original_seed = seed_param.value + + # Test parameter value change + seed_param.value = original_seed + 1 + assert seed_param.value == original_seed + 1, "Parameter value not updated" + + # Test parameter bounds + seed_param.value = -10 # Should clamp to min (0) + assert seed_param.value >= 0, "Parameter min bound not enforced" + + # Test increment/decrement + seed_param.value = 100 + old_val = seed_param.value + seed_param.increment() + assert seed_param.value > old_val, "Increment failed" + + seed_param.decrement() + assert seed_param.value == old_val, "Decrement failed" + + print(" Parameter changes OK") + return True + + +def test_layer_visibility(demo=None): + """Test layer visibility toggling.""" + print("Testing layer visibility...") + + # Reuse existing demo if provided (to avoid scene name conflict) + if demo is None: + demo = CaveDemo() + demo.activate() + + # Get a layer + final_layer = demo.get_layer("final") + assert final_layer is not None, "Layer not found" + + # Test visibility toggle + original_visible = final_layer.visible + final_layer.visible = not original_visible + assert final_layer.visible == (not original_visible), "Visibility not toggled" + + # Toggle back + final_layer.visible = original_visible + assert final_layer.visible == original_visible, "Visibility not restored" + + print(" Layer visibility OK") + return True + + +def main(): + """Run all tests.""" + print("=" * 50) + print("Interactive Procgen Demo System Tests") + print("=" * 50) + print() + + passed = 0 + failed = 0 + + # Demo creation tests + demo_tests = [ + ("test_cave_demo", test_cave_demo), + ("test_dungeon_demo", test_dungeon_demo), + ("test_terrain_demo", test_terrain_demo), + ("test_town_demo", test_town_demo), + ] + + # Create a fresh cave demo for parameter/layer tests + cave_demo = None + + for name, test in demo_tests: + try: + if test(): + passed += 1 + # Save cave demo for later tests + if name == "test_cave_demo": + cave_demo = CaveDemo.__last_instance__ if hasattr(CaveDemo, '__last_instance__') else None + else: + failed += 1 + print(f" FAILED: {name}") + except Exception as e: + failed += 1 + print(f" ERROR in {name}: {e}") + import traceback + traceback.print_exc() + + # Parameter and layer tests use the last cave demo created + # (or create a new one if cave test didn't run) + try: + # These tests are about the parameter/layer system, not demo creation + # We test with the first cave demo's parameters and layers + from procgen_interactive.core.parameter import Parameter + + print("Testing parameter system...") + p = Parameter(name="test", display="Test", type="int", default=50, min_val=0, max_val=100) + p.value = 75 + assert p.value == 75, "Parameter set failed" + p.increment() + assert p.value == 76, "Increment failed" + p.value = -10 + assert p.value == 0, "Min bound not enforced" + p.value = 200 + assert p.value == 100, "Max bound not enforced" + print(" Parameter system OK") + passed += 1 + + print("Testing float parameter...") + p = Parameter(name="test", display="Test", type="float", default=0.5, min_val=0.0, max_val=1.0, step=0.1) + p.value = 0.7 + assert abs(p.value - 0.7) < 0.001, "Float parameter set failed" + p.increment() + assert abs(p.value - 0.8) < 0.001, "Float increment failed" + print(" Float parameter OK") + passed += 1 + + except Exception as e: + failed += 1 + print(f" ERROR in parameter tests: {e}") + import traceback + traceback.print_exc() + + print() + print("=" * 50) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 50) + + if failed == 0: + print("PASS") + sys.exit(0) + else: + print("FAIL") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/skeleton_test.py b/tests/unit/skeleton_test.py new file mode 100644 index 0000000..6b96a8e --- /dev/null +++ b/tests/unit/skeleton_test.py @@ -0,0 +1,80 @@ +# skeleton_test.py - Unit tests for skeletal animation in Model3D + +import mcrfpy +import sys + +def test_model_skeleton_default(): + """Test that procedural models don't have skeletons""" + cube = mcrfpy.Model3D.cube(1.0) + + assert cube.has_skeleton == False, f"Expected cube.has_skeleton=False, got {cube.has_skeleton}" + assert cube.bone_count == 0, f"Expected cube.bone_count=0, got {cube.bone_count}" + assert cube.animation_clips == [], f"Expected empty animation_clips, got {cube.animation_clips}" + + print("[PASS] test_model_skeleton_default") + +def test_model_animation_clips_empty(): + """Test that models without skeleton have no animation clips""" + sphere = mcrfpy.Model3D.sphere(0.5) + + clips = sphere.animation_clips + assert isinstance(clips, list), f"Expected list, got {type(clips)}" + assert len(clips) == 0, f"Expected 0 clips, got {len(clips)}" + + print("[PASS] test_model_animation_clips_empty") + +def test_model_properties(): + """Test Model3D skeleton-related property access""" + plane = mcrfpy.Model3D.plane(2.0, 2.0) + + # These should all work without error + _ = plane.has_skeleton + _ = plane.bone_count + _ = plane.animation_clips + _ = plane.name + _ = plane.vertex_count + _ = plane.triangle_count + _ = plane.mesh_count + _ = plane.bounds + + print("[PASS] test_model_properties") + +def test_model_repr_no_skeleton(): + """Test Model3D repr for non-skeletal model""" + cube = mcrfpy.Model3D.cube() + r = repr(cube) + + assert "Model3D" in r, f"Expected 'Model3D' in repr, got {r}" + assert "skeletal" not in r, f"Non-skeletal model should not say 'skeletal' in repr" + + print("[PASS] test_model_repr_no_skeleton") + +def run_all_tests(): + """Run all skeleton tests""" + tests = [ + test_model_skeleton_default, + test_model_animation_clips_empty, + test_model_properties, + test_model_repr_no_skeleton, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"[FAIL] {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"[ERROR] {test.__name__}: {e}") + failed += 1 + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + return failed == 0 + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/tests/unit/tilemap_file_test.py b/tests/unit/tilemap_file_test.py new file mode 100644 index 0000000..2aad7d7 --- /dev/null +++ b/tests/unit/tilemap_file_test.py @@ -0,0 +1,189 @@ +"""Unit tests for mcrfpy.TileMapFile - Tiled tilemap loading""" +import mcrfpy +import sys + +PASS_COUNT = 0 +FAIL_COUNT = 0 + +def check(condition, msg): + global PASS_COUNT, FAIL_COUNT + if condition: + PASS_COUNT += 1 + print(f" PASS: {msg}") + else: + FAIL_COUNT += 1 + print(f" FAIL: {msg}") + +def test_tmx_loading(): + """Test loading a .tmx map""" + print("=== TMX Loading ===") + tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmx") + check(tm.width == 4, f"width = {tm.width}") + check(tm.height == 4, f"height = {tm.height}") + check(tm.tile_width == 16, f"tile_width = {tm.tile_width}") + check(tm.tile_height == 16, f"tile_height = {tm.tile_height}") + check(tm.orientation == "orthogonal", f"orientation = '{tm.orientation}'") + return tm + +def test_tmj_loading(): + """Test loading a .tmj map""" + print("\n=== TMJ Loading ===") + tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmj") + check(tm.width == 4, f"width = {tm.width}") + check(tm.height == 4, f"height = {tm.height}") + check(tm.tile_width == 16, f"tile_width = {tm.tile_width}") + check(tm.tile_height == 16, f"tile_height = {tm.tile_height}") + return tm + +def test_map_properties(tm): + """Test map properties""" + print("\n=== Map Properties ===") + props = tm.properties + check(isinstance(props, dict), f"properties is dict: {type(props)}") + check(props.get("map_name") == "test", f"map_name = '{props.get('map_name')}'") + +def test_tileset_references(tm): + """Test tileset references""" + print("\n=== Tileset References ===") + check(tm.tileset_count == 1, f"tileset_count = {tm.tileset_count}") + + firstgid, ts = tm.tileset(0) + check(firstgid == 1, f"firstgid = {firstgid}") + check(isinstance(ts, mcrfpy.TileSetFile), f"tileset is TileSetFile: {type(ts)}") + check(ts.name == "test_tileset", f"tileset name = '{ts.name}'") + check(ts.tile_count == 16, f"tileset tile_count = {ts.tile_count}") + +def test_tile_layer_names(tm): + """Test tile layer name listing""" + print("\n=== Layer Names ===") + names = tm.tile_layer_names + check(len(names) == 2, f"tile_layer count = {len(names)}") + check("Ground" in names, f"'Ground' in names: {names}") + check("Overlay" in names, f"'Overlay' in names: {names}") + + obj_names = tm.object_layer_names + check(len(obj_names) == 1, f"object_layer count = {len(obj_names)}") + check("Objects" in obj_names, f"'Objects' in obj_names: {obj_names}") + +def test_tile_layer_data(tm): + """Test raw tile layer data access""" + print("\n=== Tile Layer Data ===") + ground = tm.tile_layer_data("Ground") + check(len(ground) == 16, f"Ground layer length = {len(ground)}") + # First row: 1,2,1,1 (GIDs) + check(ground[0] == 1, f"ground[0] = {ground[0]}") + check(ground[1] == 2, f"ground[1] = {ground[1]}") + check(ground[2] == 1, f"ground[2] = {ground[2]}") + check(ground[3] == 1, f"ground[3] = {ground[3]}") + + overlay = tm.tile_layer_data("Overlay") + check(len(overlay) == 16, f"Overlay layer length = {len(overlay)}") + # First row all zeros (empty) + check(overlay[0] == 0, f"overlay[0] = {overlay[0]} (empty)") + # Second row: 0,9,10,0 + check(overlay[5] == 9, f"overlay[5] = {overlay[5]}") + + try: + tm.tile_layer_data("nonexistent") + check(False, "tile_layer_data('nonexistent') should raise KeyError") + except KeyError: + check(True, "tile_layer_data('nonexistent') raises KeyError") + +def test_resolve_gid(tm): + """Test GID resolution""" + print("\n=== GID Resolution ===") + # GID 0 = empty + ts_idx, local_id = tm.resolve_gid(0) + check(ts_idx == -1, f"GID 0: ts_idx = {ts_idx}") + check(local_id == -1, f"GID 0: local_id = {local_id}") + + # GID 1 = first tileset (firstgid=1), local_id=0 + ts_idx, local_id = tm.resolve_gid(1) + check(ts_idx == 0, f"GID 1: ts_idx = {ts_idx}") + check(local_id == 0, f"GID 1: local_id = {local_id}") + + # GID 2 = first tileset, local_id=1 + ts_idx, local_id = tm.resolve_gid(2) + check(ts_idx == 0, f"GID 2: ts_idx = {ts_idx}") + check(local_id == 1, f"GID 2: local_id = {local_id}") + + # GID 9 = first tileset, local_id=8 + ts_idx, local_id = tm.resolve_gid(9) + check(ts_idx == 0, f"GID 9: ts_idx = {ts_idx}") + check(local_id == 8, f"GID 9: local_id = {local_id}") + +def test_object_layer(tm): + """Test object layer access""" + print("\n=== Object Layer ===") + objects = tm.object_layer("Objects") + check(isinstance(objects, list), f"objects is list: {type(objects)}") + check(len(objects) == 2, f"object count = {len(objects)}") + + # Find spawn point + spawn = None + trigger = None + for obj in objects: + if obj.get("name") == "spawn": + spawn = obj + elif obj.get("name") == "trigger_zone": + trigger = obj + + check(spawn is not None, "spawn object found") + if spawn: + check(spawn.get("x") == 32, f"spawn x = {spawn.get('x')}") + check(spawn.get("y") == 32, f"spawn y = {spawn.get('y')}") + check(spawn.get("point") == True, f"spawn is point") + props = spawn.get("properties", {}) + check(props.get("player_start") == True, f"player_start = {props.get('player_start')}") + + check(trigger is not None, "trigger_zone object found") + if trigger: + check(trigger.get("width") == 64, f"trigger width = {trigger.get('width')}") + check(trigger.get("height") == 64, f"trigger height = {trigger.get('height')}") + props = trigger.get("properties", {}) + check(props.get("zone_id") == 42, f"zone_id = {props.get('zone_id')}") + + try: + tm.object_layer("nonexistent") + check(False, "object_layer('nonexistent') should raise KeyError") + except KeyError: + check(True, "object_layer('nonexistent') raises KeyError") + +def test_error_handling(): + """Test error cases""" + print("\n=== Error Handling ===") + try: + mcrfpy.TileMapFile("nonexistent.tmx") + check(False, "Missing file should raise IOError") + except IOError: + check(True, "Missing file raises IOError") + +def test_repr(tm): + """Test repr""" + print("\n=== Repr ===") + r = repr(tm) + check("TileMapFile" in r, f"repr contains 'TileMapFile': {r}") + check("4x4" in r, f"repr contains dimensions: {r}") + +def main(): + tm_tmx = test_tmx_loading() + tm_tmj = test_tmj_loading() + test_map_properties(tm_tmx) + test_tileset_references(tm_tmx) + test_tile_layer_names(tm_tmx) + test_tile_layer_data(tm_tmx) + test_resolve_gid(tm_tmx) + test_object_layer(tm_tmx) + test_error_handling() + test_repr(tm_tmx) + + print(f"\n{'='*40}") + print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") + if FAIL_COUNT > 0: + sys.exit(1) + else: + print("ALL TESTS PASSED") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/tests/unit/tileset_file_test.py b/tests/unit/tileset_file_test.py new file mode 100644 index 0000000..83d3ac7 --- /dev/null +++ b/tests/unit/tileset_file_test.py @@ -0,0 +1,145 @@ +"""Unit tests for mcrfpy.TileSetFile - Tiled tileset loading""" +import mcrfpy +import sys +import os + +PASS_COUNT = 0 +FAIL_COUNT = 0 + +def check(condition, msg): + global PASS_COUNT, FAIL_COUNT + if condition: + PASS_COUNT += 1 + print(f" PASS: {msg}") + else: + FAIL_COUNT += 1 + print(f" FAIL: {msg}") + +def test_tsx_loading(): + """Test loading a .tsx tileset""" + print("=== TSX Loading ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + check(ts.name == "test_tileset", f"name = '{ts.name}'") + check(ts.tile_width == 16, f"tile_width = {ts.tile_width}") + check(ts.tile_height == 16, f"tile_height = {ts.tile_height}") + check(ts.tile_count == 16, f"tile_count = {ts.tile_count}") + check(ts.columns == 4, f"columns = {ts.columns}") + check(ts.margin == 0, f"margin = {ts.margin}") + check(ts.spacing == 0, f"spacing = {ts.spacing}") + check("test_tileset.png" in ts.image_source, f"image_source contains PNG: {ts.image_source}") + return ts + +def test_tsj_loading(): + """Test loading a .tsj tileset""" + print("\n=== TSJ Loading ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsj") + check(ts.name == "test_tileset", f"name = '{ts.name}'") + check(ts.tile_width == 16, f"tile_width = {ts.tile_width}") + check(ts.tile_height == 16, f"tile_height = {ts.tile_height}") + check(ts.tile_count == 16, f"tile_count = {ts.tile_count}") + check(ts.columns == 4, f"columns = {ts.columns}") + return ts + +def test_properties(ts): + """Test tileset properties""" + print("\n=== Properties ===") + props = ts.properties + check(isinstance(props, dict), f"properties is dict: {type(props)}") + check(props.get("author") == "test", f"author = '{props.get('author')}'") + check(props.get("version") == 1, f"version = {props.get('version')}") + +def test_tile_info(ts): + """Test per-tile metadata""" + print("\n=== Tile Info ===") + info = ts.tile_info(0) + check(info is not None, "tile_info(0) exists") + check("properties" in info, "has 'properties' key") + check("animation" in info, "has 'animation' key") + check(info["properties"].get("terrain") == "grass", f"terrain = '{info['properties'].get('terrain')}'") + check(info["properties"].get("walkable") == True, f"walkable = {info['properties'].get('walkable')}") + check(len(info["animation"]) == 2, f"animation frames = {len(info['animation'])}") + check(info["animation"][0] == (0, 500), f"frame 0 = {info['animation'][0]}") + check(info["animation"][1] == (4, 500), f"frame 1 = {info['animation'][1]}") + + info1 = ts.tile_info(1) + check(info1 is not None, "tile_info(1) exists") + check(info1["properties"].get("terrain") == "dirt", f"terrain = '{info1['properties'].get('terrain')}'") + check(len(info1["animation"]) == 0, "tile 1 has no animation") + + info_none = ts.tile_info(5) + check(info_none is None, "tile_info(5) returns None (no metadata)") + +def test_wang_sets(ts): + """Test Wang set access""" + print("\n=== Wang Sets ===") + wang_sets = ts.wang_sets + check(len(wang_sets) == 1, f"wang_sets count = {len(wang_sets)}") + + ws = wang_sets[0] + check(ws.name == "terrain", f"wang set name = '{ws.name}'") + check(ws.type == "corner", f"wang set type = '{ws.type}'") + check(ws.color_count == 2, f"color_count = {ws.color_count}") + + colors = ws.colors + check(len(colors) == 2, f"colors length = {len(colors)}") + check(colors[0]["name"] == "Grass", f"color 0 name = '{colors[0]['name']}'") + check(colors[0]["index"] == 1, f"color 0 index = {colors[0]['index']}") + check(colors[1]["name"] == "Dirt", f"color 1 name = '{colors[1]['name']}'") + check(colors[1]["index"] == 2, f"color 1 index = {colors[1]['index']}") + +def test_wang_set_lookup(ts): + """Test wang_set() method""" + print("\n=== Wang Set Lookup ===") + ws = ts.wang_set("terrain") + check(ws.name == "terrain", "wang_set('terrain') found") + + try: + ts.wang_set("nonexistent") + check(False, "wang_set('nonexistent') should raise KeyError") + except KeyError: + check(True, "wang_set('nonexistent') raises KeyError") + +def test_to_texture(ts): + """Test texture creation""" + print("\n=== to_texture ===") + tex = ts.to_texture() + check(tex is not None, "to_texture() returns a Texture") + check(isinstance(tex, mcrfpy.Texture), f"is Texture: {type(tex)}") + +def test_error_handling(): + """Test error cases""" + print("\n=== Error Handling ===") + try: + mcrfpy.TileSetFile("nonexistent.tsx") + check(False, "Missing file should raise IOError") + except IOError: + check(True, "Missing file raises IOError") + +def test_repr(ts): + """Test repr""" + print("\n=== Repr ===") + r = repr(ts) + check("TileSetFile" in r, f"repr contains 'TileSetFile': {r}") + check("test_tileset" in r, f"repr contains name: {r}") + +def main(): + ts_tsx = test_tsx_loading() + ts_tsj = test_tsj_loading() + test_properties(ts_tsx) + test_tile_info(ts_tsx) + test_wang_sets(ts_tsx) + test_wang_set_lookup(ts_tsx) + test_to_texture(ts_tsx) + test_error_handling() + test_repr(ts_tsx) + + print(f"\n{'='*40}") + print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") + if FAIL_COUNT > 0: + sys.exit(1) + else: + print("ALL TESTS PASSED") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/tests/unit/voxel_bulk_ops_test.py b/tests/unit/voxel_bulk_ops_test.py new file mode 100644 index 0000000..614dcf6 --- /dev/null +++ b/tests/unit/voxel_bulk_ops_test.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Unit tests for VoxelGrid bulk operations (Milestone 11) + +Tests: +- fill_box_hollow: Verify shell only, interior empty +- fill_sphere: Volume roughly matches (4/3)πr³ +- fill_cylinder: Volume roughly matches πr²h +- fill_noise: Higher threshold = fewer voxels +- copy_region/paste_region: Round-trip verification +- skip_air option for paste +""" +import sys +import math + +# Track test results +passed = 0 +failed = 0 + +def test(name, condition, detail=""): + """Record test result""" + global passed, failed + if condition: + print(f"[PASS] {name}") + passed += 1 + else: + print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) + failed += 1 + +def test_fill_box_hollow_basic(): + """fill_box_hollow creates correct shell""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Create hollow 6x6x6 box with thickness 1 + vg.fill_box_hollow((2, 2, 2), (7, 7, 7), stone, thickness=1) + + # Total box = 6x6x6 = 216 + # Interior = 4x4x4 = 64 + # Shell = 216 - 64 = 152 + expected = 152 + actual = vg.count_non_air() + test("Hollow box: shell has correct voxel count", actual == expected, + f"got {actual}, expected {expected}") + + # Verify interior is empty (center should be air) + test("Hollow box: interior is air", vg.get(4, 4, 4) == 0) + test("Hollow box: interior is air (another point)", vg.get(5, 5, 5) == 0) + + # Verify shell exists + test("Hollow box: corner is filled", vg.get(2, 2, 2) == stone) + test("Hollow box: edge is filled", vg.get(4, 2, 2) == stone) + +def test_fill_box_hollow_thick(): + """fill_box_hollow with thickness > 1""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(12, 12, 12)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Create hollow 10x10x10 box with thickness 2 + vg.fill_box_hollow((1, 1, 1), (10, 10, 10), stone, thickness=2) + + # Total box = 10x10x10 = 1000 + # Interior = 6x6x6 = 216 + # Shell = 1000 - 216 = 784 + expected = 784 + actual = vg.count_non_air() + test("Thick hollow box: correct voxel count", actual == expected, + f"got {actual}, expected {expected}") + + # Verify interior is empty + test("Thick hollow box: center is air", vg.get(5, 5, 5) == 0) + +def test_fill_sphere_volume(): + """fill_sphere produces roughly spherical shape""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(30, 30, 30)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill sphere with radius 8 + radius = 8 + vg.fill_sphere((15, 15, 15), radius, stone) + + # Expected volume ≈ (4/3)πr³ + expected_vol = (4.0 / 3.0) * math.pi * (radius ** 3) + actual = vg.count_non_air() + + # Voxel sphere should be within 20% of theoretical volume + ratio = actual / expected_vol + test("Sphere volume: within 20% of (4/3)πr³", + 0.8 <= ratio <= 1.2, + f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}") + +def test_fill_sphere_carve(): + """fill_sphere with material 0 carves out voxels""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(20, 20, 20)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill entire grid with stone + vg.fill(stone) + initial = vg.count_non_air() + test("Sphere carve: initial fill", initial == 8000) # 20x20x20 + + # Carve out a sphere (material 0) + vg.fill_sphere((10, 10, 10), 5, 0) # Air + + final = vg.count_non_air() + test("Sphere carve: voxels removed", final < initial) + +def test_fill_cylinder_volume(): + """fill_cylinder produces roughly cylindrical shape""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(30, 30, 30)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill cylinder with radius 5, height 10 + radius = 5 + height = 10 + vg.fill_cylinder((15, 5, 15), radius, height, stone) + + # Expected volume ≈ πr²h + expected_vol = math.pi * (radius ** 2) * height + actual = vg.count_non_air() + + # Voxel cylinder should be within 20% of theoretical volume + ratio = actual / expected_vol + test("Cylinder volume: within 20% of πr²h", + 0.8 <= ratio <= 1.2, + f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}") + +def test_fill_cylinder_bounds(): + """fill_cylinder respects grid bounds""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Cylinder partially outside grid + vg.fill_cylinder((2, 0, 2), 3, 15, stone) # height extends beyond grid + + # Should not crash, and have some voxels + count = vg.count_non_air() + test("Cylinder bounds: handles out-of-bounds gracefully", count > 0) + test("Cylinder bounds: limited by grid height", count < 3.14 * 9 * 15) + +def test_fill_noise_threshold(): + """fill_noise: higher threshold = fewer voxels""" + import mcrfpy + + vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16)) + vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16)) + stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Same seed, different thresholds + vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.3, scale=0.15, seed=12345) + vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.7, scale=0.15, seed=12345) + + count1 = vg1.count_non_air() + count2 = vg2.count_non_air() + + # Higher threshold should produce fewer voxels + test("Noise threshold: high threshold produces fewer voxels", + count2 < count1, + f"threshold=0.3 gave {count1}, threshold=0.7 gave {count2}") + +def test_fill_noise_seed(): + """fill_noise: same seed produces same result""" + import mcrfpy + + vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16)) + vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16)) + stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Same parameters + vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42) + vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42) + + # Should produce identical results + count1 = vg1.count_non_air() + count2 = vg2.count_non_air() + + test("Noise seed: same seed produces same count", count1 == count2, + f"got {count1} vs {count2}") + + # Check a few sample points + same_values = True + for x, y, z in [(0, 0, 0), (8, 8, 8), (15, 15, 15), (3, 7, 11)]: + if vg1.get(x, y, z) != vg2.get(x, y, z): + same_values = False + break + + test("Noise seed: same seed produces identical voxels", same_values) + +def test_copy_paste_basic(): + """copy_region and paste_region round-trip""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + brick = vg.add_material("brick", color=mcrfpy.Color(165, 42, 42)) + + # Create a small structure + vg.fill_box((2, 0, 2), (5, 3, 5), stone) + vg.set(3, 1, 3, brick) # Add a different material + + # Copy the region + prefab = vg.copy_region((2, 0, 2), (5, 3, 5)) + + # Verify VoxelRegion properties + test("Copy region: correct width", prefab.width == 4) + test("Copy region: correct height", prefab.height == 4) + test("Copy region: correct depth", prefab.depth == 4) + test("Copy region: size tuple", prefab.size == (4, 4, 4)) + + # Paste elsewhere + vg.paste_region(prefab, (10, 0, 10)) + + # Verify paste + test("Paste region: stone at corner", vg.get(10, 0, 10) == stone) + test("Paste region: brick inside", vg.get(11, 1, 11) == brick) + +def test_copy_paste_skip_air(): + """paste_region with skip_air=True doesn't overwrite""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0)) + + # Create prefab with air gaps + vg.fill_box((0, 0, 0), (3, 3, 3), stone) + vg.set(1, 1, 1, 0) # Air hole + vg.set(2, 2, 2, 0) # Another air hole + + # Copy it + prefab = vg.copy_region((0, 0, 0), (3, 3, 3)) + + # Place gold in destination + vg.set(11, 1, 11, gold) # Where air hole will paste + vg.set(12, 2, 12, gold) # Where another air hole will paste + + # Paste with skip_air=True (default) + vg.paste_region(prefab, (10, 0, 10), skip_air=True) + + # Gold should still be there (air didn't overwrite) + test("Skip air: preserves existing material", vg.get(11, 1, 11) == gold) + test("Skip air: preserves at other location", vg.get(12, 2, 12) == gold) + +def test_copy_paste_overwrite(): + """paste_region with skip_air=False overwrites""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(20, 10, 20)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0)) + + # Create prefab with air gap + vg.fill_box((0, 0, 0), (3, 3, 3), stone) + vg.set(1, 1, 1, 0) # Air hole + + # Copy it + prefab = vg.copy_region((0, 0, 0), (3, 3, 3)) + + # Clear and place gold in destination + vg.clear() + vg.set(11, 1, 11, gold) + + # Paste with skip_air=False + vg.paste_region(prefab, (10, 0, 10), skip_air=False) + + # Gold should be overwritten with air + test("Overwrite air: replaces existing material", vg.get(11, 1, 11) == 0) + +def test_voxel_region_repr(): + """VoxelRegion has proper repr""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg.fill_box((0, 0, 0), (4, 4, 4), stone) + + prefab = vg.copy_region((0, 0, 0), (4, 4, 4)) + rep = repr(prefab) + + test("VoxelRegion repr: contains dimensions", "5x5x5" in rep) + test("VoxelRegion repr: is VoxelRegion", "VoxelRegion" in rep) + +def main(): + """Run all bulk operation tests""" + print("=" * 60) + print("VoxelGrid Bulk Operations Tests (Milestone 11)") + print("=" * 60) + print() + + test_fill_box_hollow_basic() + print() + test_fill_box_hollow_thick() + print() + test_fill_sphere_volume() + print() + test_fill_sphere_carve() + print() + test_fill_cylinder_volume() + print() + test_fill_cylinder_bounds() + print() + test_fill_noise_threshold() + print() + test_fill_noise_seed() + print() + test_copy_paste_basic() + print() + test_copy_paste_skip_air() + print() + test_copy_paste_overwrite() + print() + test_voxel_region_repr() + print() + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/voxel_greedy_meshing_test.py b/tests/unit/voxel_greedy_meshing_test.py new file mode 100644 index 0000000..c2746fa --- /dev/null +++ b/tests/unit/voxel_greedy_meshing_test.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Unit tests for Milestone 13: Greedy Meshing + +Tests that greedy meshing produces correct mesh geometry with reduced vertex count. +""" + +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}") + +# ============================================================================= +# Test greedy meshing property +# ============================================================================= + +print("\n=== Testing greedy_meshing property ===") + +vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) +test("Default greedy_meshing is False", vg.greedy_meshing == False) + +vg.greedy_meshing = True +test("Can enable greedy_meshing", vg.greedy_meshing == True) + +vg.greedy_meshing = False +test("Can disable greedy_meshing", vg.greedy_meshing == False) + +# ============================================================================= +# Test vertex count reduction +# ============================================================================= + +print("\n=== Testing vertex count reduction ===") + +# Create a solid 4x4x4 cube - this should benefit greatly from greedy meshing +# Non-greedy: 6 faces per voxel for exposed faces = many quads +# Greedy: 6 large quads (one per side) + +vg2 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) +stone = vg2.add_material("stone", (128, 128, 128)) +vg2.fill((stone)) # Fill entire grid + +# Get vertex count with standard meshing +vg2.greedy_meshing = False +vg2.rebuild_mesh() +standard_vertices = vg2.vertex_count +print(f" Standard meshing: {standard_vertices} vertices") + +# Get vertex count with greedy meshing +vg2.greedy_meshing = True +vg2.rebuild_mesh() +greedy_vertices = vg2.vertex_count +print(f" Greedy meshing: {greedy_vertices} vertices") + +# For a solid 4x4x4 cube, standard meshing creates: +# Each face of the cube is 4x4 = 16 voxel faces +# 6 cube faces * 16 faces/side * 6 vertices/face = 576 vertices +# Greedy meshing creates: +# 6 cube faces * 1 merged quad * 6 vertices/quad = 36 vertices + +test("Greedy meshing reduces vertex count", greedy_vertices < standard_vertices) +test("Solid cube greedy: 36 vertices (6 faces * 6 verts)", greedy_vertices == 36) + +# ============================================================================= +# Test larger solid block +# ============================================================================= + +print("\n=== Testing larger solid block ===") + +vg3 = mcrfpy.VoxelGrid((16, 16, 16), cell_size=1.0) +stone3 = vg3.add_material("stone", (128, 128, 128)) +vg3.fill(stone3) + +vg3.greedy_meshing = False +vg3.rebuild_mesh() +standard_verts_large = vg3.vertex_count +print(f" Standard: {standard_verts_large} vertices") + +vg3.greedy_meshing = True +vg3.rebuild_mesh() +greedy_verts_large = vg3.vertex_count +print(f" Greedy: {greedy_verts_large} vertices") + +# 16x16 faces = 256 quads per side -> 1 quad per side with greedy +# Reduction factor should be significant +reduction_factor = standard_verts_large / greedy_verts_large if greedy_verts_large > 0 else 0 +print(f" Reduction factor: {reduction_factor:.1f}x") + +test("Large block greedy: still 36 vertices", greedy_verts_large == 36) +test("Significant vertex reduction (>10x)", reduction_factor > 10) + +# ============================================================================= +# Test checkerboard pattern (worst case for greedy) +# ============================================================================= + +print("\n=== Testing checkerboard pattern (greedy stress test) ===") + +vg4 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) +stone4 = vg4.add_material("stone", (128, 128, 128)) + +# Create checkerboard pattern - no adjacent same-material voxels +for z in range(4): + for y in range(4): + for x in range(4): + if (x + y + z) % 2 == 0: + vg4.set(x, y, z, stone4) + +vg4.greedy_meshing = False +vg4.rebuild_mesh() +standard_checker = vg4.vertex_count +print(f" Standard: {standard_checker} vertices") + +vg4.greedy_meshing = True +vg4.rebuild_mesh() +greedy_checker = vg4.vertex_count +print(f" Greedy: {greedy_checker} vertices") + +# In checkerboard, greedy meshing can't merge much, so counts should be similar +test("Checkerboard: greedy meshing works (produces vertices)", greedy_checker > 0) +# Greedy might still reduce a bit due to row merging +test("Checkerboard: greedy <= standard", greedy_checker <= standard_checker) + +# ============================================================================= +# Test different materials (no cross-material merging) +# ============================================================================= + +print("\n=== Testing multi-material (no cross-material merging) ===") + +vg5 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) +red = vg5.add_material("red", (255, 0, 0)) +blue = vg5.add_material("blue", (0, 0, 255)) + +# Half red, half blue +vg5.fill_box((0, 0, 0), (1, 3, 3), red) +vg5.fill_box((2, 0, 0), (3, 3, 3), blue) + +vg5.greedy_meshing = True +vg5.rebuild_mesh() +multi_material_verts = vg5.vertex_count +print(f" Multi-material greedy: {multi_material_verts} vertices") + +# Should have 6 quads per material half = 12 quads = 72 vertices +# But there's a shared face between them that gets culled +# Actually: each 2x4x4 block has 5 exposed faces (not the shared internal face) +# So 5 + 5 = 10 quads = 60 vertices, but may be more due to the contact face +test("Multi-material produces vertices", multi_material_verts > 0) + +# ============================================================================= +# Test hollow box (interior faces) +# ============================================================================= + +print("\n=== Testing hollow box ===") + +vg6 = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) +stone6 = vg6.add_material("stone", (128, 128, 128)) +vg6.fill_box_hollow((0, 0, 0), (7, 7, 7), stone6, thickness=1) + +vg6.greedy_meshing = False +vg6.rebuild_mesh() +standard_hollow = vg6.vertex_count +print(f" Standard: {standard_hollow} vertices") + +vg6.greedy_meshing = True +vg6.rebuild_mesh() +greedy_hollow = vg6.vertex_count +print(f" Greedy: {greedy_hollow} vertices") + +# Hollow box has 6 outer faces and 6 inner faces +# Greedy should merge each face into one quad +# Expected: 12 quads * 6 verts = 72 vertices +test("Hollow box: greedy reduces vertices", greedy_hollow < standard_hollow) + +# ============================================================================= +# Test floor slab (single layer) +# ============================================================================= + +print("\n=== Testing floor slab (single layer) ===") + +vg7 = mcrfpy.VoxelGrid((10, 1, 10), cell_size=1.0) +floor_mat = vg7.add_material("floor", (100, 80, 60)) +vg7.fill(floor_mat) + +vg7.greedy_meshing = False +vg7.rebuild_mesh() +standard_floor = vg7.vertex_count +print(f" Standard: {standard_floor} vertices") + +vg7.greedy_meshing = True +vg7.rebuild_mesh() +greedy_floor = vg7.vertex_count +print(f" Greedy: {greedy_floor} vertices") + +# Floor slab: 10x10 top face + 10x10 bottom face + 4 edge faces (10x1 each) +# Greedy: 6 quads = 36 vertices +test("Floor slab: greedy = 36 vertices", greedy_floor == 36) + +# ============================================================================= +# Test that mesh is marked dirty when property changes +# ============================================================================= + +print("\n=== Testing dirty flag behavior ===") + +vg8 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0) +stone8 = vg8.add_material("stone", (128, 128, 128)) +vg8.fill(stone8) + +# Build mesh first +vg8.greedy_meshing = False +vg8.rebuild_mesh() +first_count = vg8.vertex_count + +# Change greedy_meshing - mesh should be marked dirty +vg8.greedy_meshing = True +# Rebuild +vg8.rebuild_mesh() +second_count = vg8.vertex_count + +test("Changing greedy_meshing affects vertex count", first_count != second_count) + +# ============================================================================= +# 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) diff --git a/tests/unit/voxel_meshing_test.py b/tests/unit/voxel_meshing_test.py new file mode 100644 index 0000000..1d7130d --- /dev/null +++ b/tests/unit/voxel_meshing_test.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Unit tests for VoxelGrid mesh generation (Milestone 10) + +Tests: +- Single voxel produces 36 vertices (6 faces x 6 verts) +- Two adjacent voxels share a face (60 verts instead of 72) +- Hollow cube only has outer faces +- fill_box works correctly +- Mesh dirty flag triggers rebuild +- Vertex positions are in correct local space +""" +import sys + +# Track test results +passed = 0 +failed = 0 + +def test(name, condition, detail=""): + """Record test result""" + global passed, failed + if condition: + print(f"[PASS] {name}") + passed += 1 + else: + print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) + failed += 1 + +def test_single_voxel(): + """Single voxel should produce 6 faces = 36 vertices""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Initially no vertices (empty grid) + test("Single voxel: initial vertex_count is 0", vg.vertex_count == 0) + + # Add one voxel + vg.set(4, 4, 4, stone) + vg.rebuild_mesh() + + # One voxel = 6 faces, each face = 2 triangles = 6 vertices + expected = 6 * 6 + test("Single voxel: produces 36 vertices", vg.vertex_count == expected, + f"got {vg.vertex_count}, expected {expected}") + +def test_two_adjacent(): + """Two adjacent voxels should share a face, producing 60 vertices instead of 72""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Add two adjacent voxels (share one face) + vg.set(4, 4, 4, stone) + vg.set(5, 4, 4, stone) # Adjacent in X + vg.rebuild_mesh() + + # Two separate voxels would be 72 vertices + # Shared face is culled: 2 * 36 - 2 * 6 = 72 - 12 = 60 + expected = 60 + test("Two adjacent: shared face culled", vg.vertex_count == expected, + f"got {vg.vertex_count}, expected {expected}") + +def test_hollow_cube(): + """Hollow 3x3x3 cube should have much fewer vertices than solid""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Create hollow 3x3x3 cube (only shell voxels) + # Solid 3x3x3 = 27 voxels, Hollow = 26 voxels (remove center) + for x in range(3): + for y in range(3): + for z in range(3): + # Skip center voxel + if x == 1 and y == 1 and z == 1: + continue + vg.set(x, y, z, stone) + + test("Hollow cube: 26 voxels placed", vg.count_non_air() == 26) + + vg.rebuild_mesh() + + # The hollow center creates inner faces facing the air void + # Outer surface = 6 sides * 9 faces = 54 faces + # Inner surface = 6 faces touching the center void + # Total = 60 faces = 360 vertices + expected = 360 + test("Hollow cube: outer + inner void faces", vg.vertex_count == expected, + f"got {vg.vertex_count}, expected {expected}") + +def test_fill_box(): + """fill_box should fill a rectangular region""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill a 4x3x5 box + vg.fill_box((2, 1, 3), (5, 3, 7), stone) + + # Count: (5-2+1) * (3-1+1) * (7-3+1) = 4 * 3 * 5 = 60 + expected = 60 + test("fill_box: correct voxel count", vg.count_non_air() == expected, + f"got {vg.count_non_air()}, expected {expected}") + + # Verify specific cells + test("fill_box: corner (2,1,3) is filled", vg.get(2, 1, 3) == stone) + test("fill_box: corner (5,3,7) is filled", vg.get(5, 3, 7) == stone) + test("fill_box: outside (1,1,3) is empty", vg.get(1, 1, 3) == 0) + test("fill_box: outside (6,1,3) is empty", vg.get(6, 1, 3) == 0) + +def test_fill_box_reversed(): + """fill_box should handle reversed coordinates""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill with reversed coordinates (max before min) + vg.fill_box((5, 3, 7), (2, 1, 3), stone) + + # Should still fill 4x3x5 = 60 voxels + expected = 60 + test("fill_box reversed: correct voxel count", vg.count_non_air() == expected, + f"got {vg.count_non_air()}, expected {expected}") + +def test_fill_box_clamping(): + """fill_box should clamp to grid bounds""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill beyond grid bounds + vg.fill_box((-5, -5, -5), (100, 100, 100), stone) + + # Should fill entire 8x8x8 grid = 512 voxels + expected = 512 + test("fill_box clamping: fills entire grid", vg.count_non_air() == expected, + f"got {vg.count_non_air()}, expected {expected}") + +def test_mesh_dirty(): + """Modifying voxels should mark mesh dirty; rebuild_mesh updates vertex count""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Initial state + vg.set(4, 4, 4, stone) + vg.rebuild_mesh() + initial_count = vg.vertex_count + + test("Mesh dirty: initial vertex count correct", initial_count == 36) + + # Modify voxel - marks dirty but doesn't auto-rebuild + vg.set(4, 4, 5, stone) + + # vertex_count doesn't auto-trigger rebuild (returns stale value) + stale_count = vg.vertex_count + test("Mesh dirty: vertex_count before rebuild is stale", stale_count == 36) + + # Explicit rebuild updates the mesh + vg.rebuild_mesh() + new_count = vg.vertex_count + + # Two adjacent voxels = 60 vertices + test("Mesh dirty: rebuilt after explicit rebuild_mesh()", new_count == 60, + f"got {new_count}, expected 60") + +def test_vertex_positions(): + """Vertices should be in correct local space positions""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8), cell_size=2.0) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Place voxel at (0,0,0) + vg.set(0, 0, 0, stone) + vg.rebuild_mesh() + + # With cell_size=2.0, the voxel center is at (1, 1, 1) + # Vertices should be at corners: (0,0,0) to (2,2,2) + # The vertex_count should still be 36 + test("Vertex positions: correct vertex count", vg.vertex_count == 36) + +def test_empty_grid(): + """Empty grid should produce no vertices""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + vg.rebuild_mesh() + + test("Empty grid: zero vertices", vg.vertex_count == 0) + +def test_all_air(): + """Grid filled with air produces no vertices""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill with stone, then fill with air + vg.fill(stone) + vg.fill(0) # Air + vg.rebuild_mesh() + + test("All air: zero vertices", vg.vertex_count == 0) + +def test_large_solid_cube(): + """Large solid cube should have face culling efficiency""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill entire grid + vg.fill(stone) + vg.rebuild_mesh() + + # Without culling: 512 voxels * 6 faces * 6 verts = 18432 + # With culling: only outer shell faces + # 6 faces of cube, each 8x8 = 64 faces per side = 384 faces + # 384 * 6 verts = 2304 vertices + expected = 2304 + test("Large solid cube: face culling efficiency", + vg.vertex_count == expected, + f"got {vg.vertex_count}, expected {expected}") + + # Verify massive reduction + no_cull = 512 * 6 * 6 + reduction = (no_cull - vg.vertex_count) / no_cull * 100 + test("Large solid cube: >85% vertex reduction", + reduction > 85, + f"got {reduction:.1f}% reduction") + +def test_transparent_material(): + """Faces between solid and transparent materials should be generated""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + glass = vg.add_material("glass", color=mcrfpy.Color(200, 200, 255, 128), + transparent=True) + + # Place stone with glass neighbor + vg.set(4, 4, 4, stone) + vg.set(5, 4, 4, glass) + vg.rebuild_mesh() + + # Stone has 6 faces (all exposed - glass is transparent) + # Glass has 5 faces (face towards stone not generated - stone is solid) + # Total = 36 + 30 = 66 vertices + expected = 66 + test("Transparent material: correct face culling", vg.vertex_count == expected, + f"got {vg.vertex_count}, expected {expected}") + +def main(): + """Run all mesh generation tests""" + print("=" * 60) + print("VoxelGrid Mesh Generation Tests (Milestone 10)") + print("=" * 60) + print() + + test_single_voxel() + print() + test_two_adjacent() + print() + test_hollow_cube() + print() + test_fill_box() + print() + test_fill_box_reversed() + print() + test_fill_box_clamping() + print() + test_mesh_dirty() + print() + test_vertex_positions() + print() + test_empty_grid() + print() + test_all_air() + print() + test_large_solid_cube() + print() + test_transparent_material() + print() + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/voxel_navigation_test.py b/tests/unit/voxel_navigation_test.py new file mode 100644 index 0000000..73e497c --- /dev/null +++ b/tests/unit/voxel_navigation_test.py @@ -0,0 +1,247 @@ +#!/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) diff --git a/tests/unit/voxel_rendering_test.py b/tests/unit/voxel_rendering_test.py new file mode 100644 index 0000000..3f9ab5a --- /dev/null +++ b/tests/unit/voxel_rendering_test.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Unit tests for VoxelGrid rendering integration (Milestone 10) + +Tests: +- Adding voxel layer to viewport +- Removing voxel layer from viewport +- Voxel layer count tracking +- Screenshot verification (visual rendering) +""" +import sys + +# Track test results +passed = 0 +failed = 0 + +def test(name, condition, detail=""): + """Record test result""" + global passed, failed + if condition: + print(f"[PASS] {name}") + passed += 1 + else: + print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) + failed += 1 + +def test_add_to_viewport(): + """Test adding a voxel layer to viewport""" + import mcrfpy + + # Create viewport + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Create voxel grid + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg.set(4, 4, 4, stone) + + # Initial layer count + test("Add to viewport: initial count is 0", viewport.voxel_layer_count() == 0) + + # Add voxel layer + viewport.add_voxel_layer(vg, z_index=1) + + test("Add to viewport: count increases to 1", viewport.voxel_layer_count() == 1) + +def test_add_multiple_layers(): + """Test adding multiple voxel layers""" + import mcrfpy + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4)) + vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4)) + vg3 = mcrfpy.VoxelGrid(size=(4, 4, 4)) + + viewport.add_voxel_layer(vg1, z_index=0) + viewport.add_voxel_layer(vg2, z_index=1) + viewport.add_voxel_layer(vg3, z_index=2) + + test("Multiple layers: count is 3", viewport.voxel_layer_count() == 3) + +def test_remove_from_viewport(): + """Test removing a voxel layer from viewport""" + import mcrfpy + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4)) + vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4)) + + viewport.add_voxel_layer(vg1, z_index=0) + viewport.add_voxel_layer(vg2, z_index=1) + + test("Remove: initial count is 2", viewport.voxel_layer_count() == 2) + + # Remove one layer + result = viewport.remove_voxel_layer(vg1) + test("Remove: returns True for existing layer", result == True) + test("Remove: count decreases to 1", viewport.voxel_layer_count() == 1) + + # Remove same layer again should return False + result = viewport.remove_voxel_layer(vg1) + test("Remove: returns False for non-existing layer", result == False) + test("Remove: count still 1", viewport.voxel_layer_count() == 1) + +def test_remove_nonexistent(): + """Test removing a layer that was never added""" + import mcrfpy + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + vg = mcrfpy.VoxelGrid(size=(4, 4, 4)) + + result = viewport.remove_voxel_layer(vg) + test("Remove nonexistent: returns False", result == False) + +def test_add_invalid_type(): + """Test that adding non-VoxelGrid raises error""" + import mcrfpy + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + error_raised = False + try: + viewport.add_voxel_layer("not a voxel grid") + except TypeError: + error_raised = True + + test("Add invalid type: raises TypeError", error_raised) + +def test_z_index_parameter(): + """Test that z_index parameter is accepted""" + import mcrfpy + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + vg = mcrfpy.VoxelGrid(size=(4, 4, 4)) + + # Should not raise error + error_raised = False + try: + viewport.add_voxel_layer(vg, z_index=5) + except Exception as e: + error_raised = True + print(f" Error: {e}") + + test("Z-index parameter: accepted without error", not error_raised) + +def test_viewport_in_scene(): + """Test viewport with voxel layer added to a scene""" + import mcrfpy + + # Create and activate a test scene + scene = mcrfpy.Scene("voxel_test_scene") + + # Create viewport + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Create voxel grid with visible content + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg.fill_box((2, 0, 2), (5, 3, 5), stone) + vg.offset = (0, 0, 0) + + # Add voxel layer to viewport + viewport.add_voxel_layer(vg, z_index=0) + + # Position camera to see the voxels + viewport.camera_pos = (10, 10, 10) + viewport.camera_target = (4, 2, 4) + + # Add viewport to scene + scene.children.append(viewport) + + # Trigger mesh generation + vg.rebuild_mesh() + + test("Viewport in scene: voxel layer added", viewport.voxel_layer_count() == 1) + test("Viewport in scene: voxels have content", vg.count_non_air() > 0) + test("Viewport in scene: mesh generated", vg.vertex_count > 0) + +def main(): + """Run all rendering integration tests""" + print("=" * 60) + print("VoxelGrid Rendering Integration Tests (Milestone 10)") + print("=" * 60) + print() + + test_add_to_viewport() + print() + test_add_multiple_layers() + print() + test_remove_from_viewport() + print() + test_remove_nonexistent() + print() + test_add_invalid_type() + print() + test_z_index_parameter() + print() + test_viewport_in_scene() + print() + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/voxel_serialization_test.py b/tests/unit/voxel_serialization_test.py new file mode 100644 index 0000000..1737168 --- /dev/null +++ b/tests/unit/voxel_serialization_test.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Unit tests for Milestone 14: VoxelGrid Serialization + +Tests save/load to file and to_bytes/from_bytes memory serialization. +""" + +import mcrfpy +import sys +import os +import tempfile + +# 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}") + +# ============================================================================= +# Test basic save/load +# ============================================================================= + +print("\n=== Testing basic save/load ===") + +# Create a test grid with materials and voxel data +vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0) +stone = vg.add_material("stone", (128, 128, 128)) +wood = vg.add_material("wood", (139, 90, 43), transparent=False, path_cost=0.8) +glass = vg.add_material("glass", (200, 220, 255, 128), transparent=True, path_cost=1.5) + +# Fill with some patterns +vg.fill_box((0, 0, 0), (7, 0, 7), stone) # Floor +vg.fill_box((0, 1, 0), (0, 3, 7), wood) # Wall +vg.set(4, 1, 4, glass) # Single glass block + +original_non_air = vg.count_non_air() +original_stone = vg.count_material(stone) +original_wood = vg.count_material(wood) +original_glass = vg.count_material(glass) + +print(f" Original grid: {original_non_air} non-air voxels") +print(f" Stone={original_stone}, Wood={original_wood}, Glass={original_glass}") + +# Save to temp file +with tempfile.NamedTemporaryFile(suffix='.mcvg', delete=False) as f: + temp_path = f.name + +save_result = vg.save(temp_path) +test("save() returns True on success", save_result == True) +test("File was created", os.path.exists(temp_path)) + +file_size = os.path.getsize(temp_path) +print(f" File size: {file_size} bytes") +test("File has non-zero size", file_size > 0) + +# Create new grid and load +vg2 = mcrfpy.VoxelGrid((1, 1, 1)) # Start with tiny grid +load_result = vg2.load(temp_path) +test("load() returns True on success", load_result == True) + +# Verify loaded data matches +test("Loaded size matches original", vg2.size == (8, 8, 8)) +test("Loaded cell_size matches", vg2.cell_size == 1.0) +test("Loaded material_count matches", vg2.material_count == 3) +test("Loaded count_non_air matches", vg2.count_non_air() == original_non_air) +test("Loaded stone count matches", vg2.count_material(stone) == original_stone) +test("Loaded wood count matches", vg2.count_material(wood) == original_wood) +test("Loaded glass count matches", vg2.count_material(glass) == original_glass) + +# Clean up temp file +os.unlink(temp_path) +test("Temp file cleaned up", not os.path.exists(temp_path)) + +# ============================================================================= +# Test to_bytes/from_bytes +# ============================================================================= + +print("\n=== Testing to_bytes/from_bytes ===") + +vg3 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=2.0) +mat1 = vg3.add_material("test_mat", (255, 0, 0)) +vg3.fill_box((1, 1, 1), (2, 2, 2), mat1) + +original_bytes = vg3.to_bytes() +test("to_bytes() returns bytes", isinstance(original_bytes, bytes)) +test("Bytes have content", len(original_bytes) > 0) + +print(f" Serialized to {len(original_bytes)} bytes") + +# Load into new grid +vg4 = mcrfpy.VoxelGrid((1, 1, 1)) +load_result = vg4.from_bytes(original_bytes) +test("from_bytes() returns True", load_result == True) +test("Bytes loaded - size matches", vg4.size == (4, 4, 4)) +test("Bytes loaded - cell_size matches", vg4.cell_size == 2.0) +test("Bytes loaded - voxels match", vg4.count_non_air() == vg3.count_non_air()) + +# ============================================================================= +# Test material preservation +# ============================================================================= + +print("\n=== Testing material preservation ===") + +vg5 = mcrfpy.VoxelGrid((4, 4, 4)) +mat_a = vg5.add_material("alpha", (10, 20, 30, 200), sprite_index=5, transparent=True, path_cost=0.5) +mat_b = vg5.add_material("beta", (100, 110, 120, 255), sprite_index=-1, transparent=False, path_cost=2.0) +vg5.set(0, 0, 0, mat_a) +vg5.set(1, 1, 1, mat_b) + +data = vg5.to_bytes() +vg6 = mcrfpy.VoxelGrid((1, 1, 1)) +vg6.from_bytes(data) + +# Check first material +mat_a_loaded = vg6.get_material(1) +test("Material 1 name preserved", mat_a_loaded['name'] == "alpha") +test("Material 1 color R preserved", mat_a_loaded['color'].r == 10) +test("Material 1 color G preserved", mat_a_loaded['color'].g == 20) +test("Material 1 color B preserved", mat_a_loaded['color'].b == 30) +test("Material 1 color A preserved", mat_a_loaded['color'].a == 200) +test("Material 1 sprite_index preserved", mat_a_loaded['sprite_index'] == 5) +test("Material 1 transparent preserved", mat_a_loaded['transparent'] == True) +test("Material 1 path_cost preserved", abs(mat_a_loaded['path_cost'] - 0.5) < 0.001) + +# Check second material +mat_b_loaded = vg6.get_material(2) +test("Material 2 name preserved", mat_b_loaded['name'] == "beta") +test("Material 2 transparent preserved", mat_b_loaded['transparent'] == False) +test("Material 2 path_cost preserved", abs(mat_b_loaded['path_cost'] - 2.0) < 0.001) + +# ============================================================================= +# Test voxel data integrity +# ============================================================================= + +print("\n=== Testing voxel data integrity ===") + +vg7 = mcrfpy.VoxelGrid((16, 16, 16)) +mat = vg7.add_material("checker", (255, 255, 255)) + +# Create checkerboard pattern +for z in range(16): + for y in range(16): + for x in range(16): + if (x + y + z) % 2 == 0: + vg7.set(x, y, z, mat) + +original_count = vg7.count_non_air() +print(f" Original checkerboard: {original_count} voxels") + +# Save/load +data = vg7.to_bytes() +print(f" Serialized size: {len(data)} bytes") + +vg8 = mcrfpy.VoxelGrid((1, 1, 1)) +vg8.from_bytes(data) + +test("Checkerboard voxel count preserved", vg8.count_non_air() == original_count) + +# Verify individual voxels +all_match = True +for z in range(16): + for y in range(16): + for x in range(16): + expected = mat if (x + y + z) % 2 == 0 else 0 + actual = vg8.get(x, y, z) + if actual != expected: + all_match = False + break + if not all_match: + break + +test("All checkerboard voxels match", all_match) + +# ============================================================================= +# Test RLE compression effectiveness +# ============================================================================= + +print("\n=== Testing RLE compression ===") + +# Test with uniform data (should compress well) +vg9 = mcrfpy.VoxelGrid((32, 32, 32)) +mat_uniform = vg9.add_material("solid", (100, 100, 100)) +vg9.fill(mat_uniform) + +uniform_bytes = vg9.to_bytes() +raw_size = 32 * 32 * 32 # 32768 bytes uncompressed +compressed_size = len(uniform_bytes) +compression_ratio = raw_size / compressed_size if compressed_size > 0 else 0 + +print(f" Uniform 32x32x32: raw={raw_size}, compressed={compressed_size}") +print(f" Compression ratio: {compression_ratio:.1f}x") + +test("Uniform data compresses significantly (>10x)", compression_ratio > 10) + +# Test with alternating data (should compress poorly) +vg10 = mcrfpy.VoxelGrid((32, 32, 32)) +mat_alt = vg10.add_material("alt", (200, 200, 200)) + +for z in range(32): + for y in range(32): + for x in range(32): + if (x + y + z) % 2 == 0: + vg10.set(x, y, z, mat_alt) + +alt_bytes = vg10.to_bytes() +alt_ratio = raw_size / len(alt_bytes) if len(alt_bytes) > 0 else 0 + +print(f" Alternating pattern: compressed={len(alt_bytes)}") +print(f" Compression ratio: {alt_ratio:.1f}x") + +# Alternating data should still compress somewhat due to row patterns +test("Alternating data serializes successfully", len(alt_bytes) > 0) + +# ============================================================================= +# Test error handling +# ============================================================================= + +print("\n=== Testing error handling ===") + +vg_err = mcrfpy.VoxelGrid((2, 2, 2)) + +# Test load from non-existent file +load_fail = vg_err.load("/nonexistent/path/file.mcvg") +test("load() returns False for non-existent file", load_fail == False) + +# Test from_bytes with invalid data +invalid_data = b"not valid mcvg data" +from_fail = vg_err.from_bytes(invalid_data) +test("from_bytes() returns False for invalid data", from_fail == False) + +# Test from_bytes with truncated data +vg_good = mcrfpy.VoxelGrid((2, 2, 2)) +good_data = vg_good.to_bytes() +truncated = good_data[:10] # Take only first 10 bytes +from_truncated = vg_err.from_bytes(truncated) +test("from_bytes() returns False for truncated data", from_truncated == False) + +# ============================================================================= +# Test large grid +# ============================================================================= + +print("\n=== Testing large grid ===") + +vg_large = mcrfpy.VoxelGrid((64, 32, 64)) +mat_large = vg_large.add_material("large", (50, 50, 50)) + +# Fill floor and some walls +vg_large.fill_box((0, 0, 0), (63, 0, 63), mat_large) # Floor +vg_large.fill_box((0, 1, 0), (0, 31, 63), mat_large) # Wall + +large_bytes = vg_large.to_bytes() +print(f" 64x32x64 grid: {len(large_bytes)} bytes") + +vg_large2 = mcrfpy.VoxelGrid((1, 1, 1)) +vg_large2.from_bytes(large_bytes) + +test("Large grid size preserved", vg_large2.size == (64, 32, 64)) +test("Large grid voxels preserved", vg_large2.count_non_air() == vg_large.count_non_air()) + +# ============================================================================= +# Test round-trip with transform +# ============================================================================= + +print("\n=== Testing transform preservation (not serialized) ===") + +# Note: Transform (offset, rotation) is NOT serialized - it's runtime state +vg_trans = mcrfpy.VoxelGrid((4, 4, 4)) +vg_trans.offset = (10, 20, 30) +vg_trans.rotation = 45.0 +mat_trans = vg_trans.add_material("trans", (128, 128, 128)) +vg_trans.set(0, 0, 0, mat_trans) + +data_trans = vg_trans.to_bytes() +vg_trans2 = mcrfpy.VoxelGrid((1, 1, 1)) +vg_trans2.from_bytes(data_trans) + +# Voxel data should be preserved +test("Voxel data preserved after load", vg_trans2.get(0, 0, 0) == mat_trans) + +# Transform should be at default (not serialized) +test("Offset resets to default after load", vg_trans2.offset == (0, 0, 0)) +test("Rotation resets to default after load", vg_trans2.rotation == 0.0) + +# ============================================================================= +# 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) diff --git a/tests/unit/voxelgrid_test.py b/tests/unit/voxelgrid_test.py new file mode 100644 index 0000000..18cd37e --- /dev/null +++ b/tests/unit/voxelgrid_test.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""Unit tests for VoxelGrid (Milestone 9) + +Tests the core VoxelGrid data structure: +- Creation with various sizes +- Per-voxel get/set operations +- Bounds checking behavior +- Material palette management +- Bulk operations (fill, clear) +- Transform properties (offset, rotation) +- Statistics (count_non_air, count_material) +""" +import sys + +# Track test results +passed = 0 +failed = 0 + +def test(name, condition, detail=""): + """Record test result""" + global passed, failed + if condition: + print(f"[PASS] {name}") + passed += 1 + else: + print(f"[FAIL] {name}" + (f" - {detail}" if detail else "")) + failed += 1 + +def test_creation(): + """Test VoxelGrid creation with various parameters""" + import mcrfpy + + # Basic creation + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + test("Creation: basic", vg is not None) + test("Creation: width", vg.width == 16) + test("Creation: height", vg.height == 8) + test("Creation: depth", vg.depth == 16) + test("Creation: default cell_size", vg.cell_size == 1.0) + + # With cell_size + vg2 = mcrfpy.VoxelGrid(size=(10, 5, 10), cell_size=2.0) + test("Creation: custom cell_size", vg2.cell_size == 2.0) + + # Size property + test("Creation: size tuple", vg.size == (16, 8, 16)) + + # Initial state + test("Creation: initially empty", vg.count_non_air() == 0) + test("Creation: no materials", vg.material_count == 0) + +def test_invalid_creation(): + """Test that invalid parameters raise errors""" + import mcrfpy + + errors_caught = 0 + + try: + vg = mcrfpy.VoxelGrid(size=(0, 8, 16)) + except ValueError: + errors_caught += 1 + + try: + vg = mcrfpy.VoxelGrid(size=(16, -1, 16)) + except ValueError: + errors_caught += 1 + + try: + vg = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=-1.0) + except ValueError: + errors_caught += 1 + + try: + vg = mcrfpy.VoxelGrid(size=(16, 8)) # Missing dimension + except (ValueError, TypeError): + errors_caught += 1 + + test("Invalid creation: catches errors", errors_caught == 4, f"caught {errors_caught}/4") + +def test_get_set(): + """Test per-voxel get/set operations""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Initially all air + test("Get/Set: initial value is air", vg.get(0, 0, 0) == 0) + + # Set and get + vg.set(5, 3, 7, stone) + test("Get/Set: set then get", vg.get(5, 3, 7) == stone) + + # Verify adjacent cells unaffected + test("Get/Set: adjacent unaffected", vg.get(5, 3, 6) == 0) + test("Get/Set: adjacent unaffected 2", vg.get(4, 3, 7) == 0) + + # Set back to air + vg.set(5, 3, 7, 0) + test("Get/Set: set to air", vg.get(5, 3, 7) == 0) + + # Multiple materials + wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43)) + vg.set(0, 0, 0, stone) + vg.set(1, 0, 0, wood) + vg.set(2, 0, 0, stone) + test("Get/Set: multiple materials", + vg.get(0, 0, 0) == stone and vg.get(1, 0, 0) == wood and vg.get(2, 0, 0) == stone) + +def test_bounds(): + """Test bounds checking behavior""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 4, 8)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Out of bounds get returns 0 (air) + test("Bounds: negative x", vg.get(-1, 0, 0) == 0) + test("Bounds: negative y", vg.get(0, -1, 0) == 0) + test("Bounds: negative z", vg.get(0, 0, -1) == 0) + test("Bounds: overflow x", vg.get(8, 0, 0) == 0) + test("Bounds: overflow y", vg.get(0, 4, 0) == 0) + test("Bounds: overflow z", vg.get(0, 0, 8) == 0) + test("Bounds: large overflow", vg.get(100, 100, 100) == 0) + + # Out of bounds set is silently ignored (no crash) + vg.set(-1, 0, 0, stone) # Should not crash + vg.set(100, 0, 0, stone) # Should not crash + test("Bounds: OOB set doesn't crash", True) + + # Corner cases - max valid indices + vg.set(7, 3, 7, stone) + test("Bounds: max valid index", vg.get(7, 3, 7) == stone) + +def test_materials(): + """Test material palette management""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + + # Add first material + stone_id = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + test("Materials: first ID is 1", stone_id == 1) + + # Add with all properties + glass_id = vg.add_material("glass", + color=mcrfpy.Color(200, 200, 255, 128), + sprite_index=5, + transparent=True, + path_cost=0.5) + test("Materials: second ID is 2", glass_id == 2) + + # Verify material count + test("Materials: count", vg.material_count == 2) + + # Get material and verify properties + stone = vg.get_material(stone_id) + test("Materials: name", stone["name"] == "stone") + test("Materials: color type", hasattr(stone["color"], 'r')) + test("Materials: default sprite_index", stone["sprite_index"] == -1) + test("Materials: default transparent", stone["transparent"] == False) + test("Materials: default path_cost", stone["path_cost"] == 1.0) + + glass = vg.get_material(glass_id) + test("Materials: custom sprite_index", glass["sprite_index"] == 5) + test("Materials: custom transparent", glass["transparent"] == True) + test("Materials: custom path_cost", glass["path_cost"] == 0.5) + + # Air material (ID 0) + air = vg.get_material(0) + test("Materials: air name", air["name"] == "air") + test("Materials: air transparent", air["transparent"] == True) + + # Invalid material ID returns air + invalid = vg.get_material(255) + test("Materials: invalid returns air", invalid["name"] == "air") + +def test_fill_clear(): + """Test bulk fill and clear operations""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(10, 5, 10)) + total = 10 * 5 * 10 # 500 + + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill with material + vg.fill(stone) + test("Fill: all cells filled", vg.count_non_air() == total) + test("Fill: specific cell", vg.get(5, 2, 5) == stone) + test("Fill: corner cell", vg.get(0, 0, 0) == stone) + test("Fill: opposite corner", vg.get(9, 4, 9) == stone) + + # Clear (fill with air) + vg.clear() + test("Clear: all cells empty", vg.count_non_air() == 0) + test("Clear: specific cell", vg.get(5, 2, 5) == 0) + +def test_transform(): + """Test transform properties (offset, rotation)""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + + # Default values + test("Transform: default offset", vg.offset == (0.0, 0.0, 0.0)) + test("Transform: default rotation", vg.rotation == 0.0) + + # Set offset + vg.offset = (10.5, -5.0, 20.0) + offset = vg.offset + test("Transform: set offset x", abs(offset[0] - 10.5) < 0.001) + test("Transform: set offset y", abs(offset[1] - (-5.0)) < 0.001) + test("Transform: set offset z", abs(offset[2] - 20.0) < 0.001) + + # Set rotation + vg.rotation = 45.0 + test("Transform: set rotation", abs(vg.rotation - 45.0) < 0.001) + + # Negative rotation + vg.rotation = -90.0 + test("Transform: negative rotation", abs(vg.rotation - (-90.0)) < 0.001) + + # Large rotation + vg.rotation = 720.0 + test("Transform: large rotation", abs(vg.rotation - 720.0) < 0.001) + +def test_statistics(): + """Test statistics methods""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(10, 10, 10)) + + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43)) + + # Initially empty + test("Stats: initial non_air", vg.count_non_air() == 0) + test("Stats: initial stone count", vg.count_material(stone) == 0) + + # Add some voxels + for i in range(5): + vg.set(i, 0, 0, stone) + for i in range(3): + vg.set(i, 1, 0, wood) + + test("Stats: non_air after setting", vg.count_non_air() == 8) + test("Stats: stone count", vg.count_material(stone) == 5) + test("Stats: wood count", vg.count_material(wood) == 3) + test("Stats: air count", vg.count_material(0) == 10*10*10 - 8) + +def test_repr(): + """Test string representation""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(16, 8, 16)) + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + vg.set(0, 0, 0, stone) + + repr_str = repr(vg) + test("Repr: contains VoxelGrid", "VoxelGrid" in repr_str) + test("Repr: contains dimensions", "16x8x16" in repr_str) + test("Repr: contains materials", "materials=1" in repr_str) + test("Repr: contains non_air", "non_air=1" in repr_str) + +def test_large_grid(): + """Test with larger grid sizes""" + import mcrfpy + + # 64x64x64 = 262144 voxels + vg = mcrfpy.VoxelGrid(size=(64, 64, 64)) + test("Large: creation", vg is not None) + test("Large: size", vg.size == (64, 64, 64)) + + stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128)) + + # Fill entire grid + vg.fill(stone) + expected = 64 * 64 * 64 + test("Large: fill count", vg.count_non_air() == expected, f"got {vg.count_non_air()}, expected {expected}") + + # Clear + vg.clear() + test("Large: clear", vg.count_non_air() == 0) + +def test_material_limit(): + """Test material palette limit (255 max)""" + import mcrfpy + + vg = mcrfpy.VoxelGrid(size=(8, 8, 8)) + + # Add many materials + for i in range(255): + mat_id = vg.add_material(f"mat_{i}", color=mcrfpy.Color(i, i, i)) + if mat_id != i + 1: + test("Material limit: IDs sequential", False, f"expected {i+1}, got {mat_id}") + return + + test("Material limit: 255 materials added", vg.material_count == 255) + + # 256th should fail + try: + vg.add_material("overflow", color=mcrfpy.Color(255, 255, 255)) + test("Material limit: overflow error", False, "should have raised exception") + except RuntimeError: + test("Material limit: overflow error", True) + +def main(): + """Run all tests""" + print("=" * 60) + print("VoxelGrid Unit Tests (Milestone 9)") + print("=" * 60) + print() + + test_creation() + print() + test_invalid_creation() + print() + test_get_set() + print() + test_bounds() + print() + test_materials() + print() + test_fill_clear() + print() + test_transform() + print() + test_statistics() + print() + test_repr() + print() + test_large_grid() + print() + test_material_limit() + print() + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return 0 if failed == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/voxelpoint_test.py b/tests/unit/voxelpoint_test.py new file mode 100644 index 0000000..7b2ff7b --- /dev/null +++ b/tests/unit/voxelpoint_test.py @@ -0,0 +1,196 @@ +# voxelpoint_test.py - Unit tests for VoxelPoint navigation grid +# Tests grid creation, cell access, and property modification + +import mcrfpy +import sys + +def test_grid_creation(): + """Test creating and sizing a navigation grid""" + print("Testing navigation grid creation...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Initial grid should be 0x0 + assert viewport.grid_size == (0, 0), f"Expected (0, 0), got {viewport.grid_size}" + + # Set grid size via property + viewport.grid_size = (10, 8) + assert viewport.grid_size == (10, 8), f"Expected (10, 8), got {viewport.grid_size}" + + # Set grid size via method + viewport.set_grid_size(20, 15) + assert viewport.grid_size == (20, 15), f"Expected (20, 15), got {viewport.grid_size}" + + print(" PASS: Grid creation and sizing") + + +def test_voxelpoint_access(): + """Test accessing VoxelPoint cells""" + print("Testing VoxelPoint access...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Access a cell + vp = viewport.at(5, 5) + assert vp is not None, "at() returned None" + + # Check grid_pos + assert vp.grid_pos == (5, 5), f"Expected grid_pos (5, 5), got {vp.grid_pos}" + + # Test bounds checking + try: + viewport.at(-1, 0) + assert False, "Expected IndexError for negative coordinate" + except IndexError: + pass + + try: + viewport.at(10, 5) # Out of bounds (0-9 valid) + assert False, "Expected IndexError for out of bounds" + except IndexError: + pass + + print(" PASS: VoxelPoint access") + + +def test_voxelpoint_properties(): + """Test VoxelPoint property read/write""" + print("Testing VoxelPoint properties...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + vp = viewport.at(3, 4) + + # Test default values + assert vp.walkable == True, f"Default walkable should be True, got {vp.walkable}" + assert vp.transparent == True, f"Default transparent should be True, got {vp.transparent}" + assert vp.height == 0.0, f"Default height should be 0.0, got {vp.height}" + assert vp.cost == 1.0, f"Default cost should be 1.0, got {vp.cost}" + + # Test setting bool properties + vp.walkable = False + assert vp.walkable == False, "walkable not set to False" + + vp.transparent = False + assert vp.transparent == False, "transparent not set to False" + + # Test setting float properties + vp.height = 5.5 + assert abs(vp.height - 5.5) < 0.01, f"height should be 5.5, got {vp.height}" + + vp.cost = 2.0 + assert abs(vp.cost - 2.0) < 0.01, f"cost should be 2.0, got {vp.cost}" + + # Test cost must be non-negative + try: + vp.cost = -1.0 + assert False, "Expected ValueError for negative cost" + except ValueError: + pass + + print(" PASS: VoxelPoint properties") + + +def test_voxelpoint_persistence(): + """Test that VoxelPoint changes persist in the grid""" + print("Testing VoxelPoint persistence...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + # Modify a cell + vp = viewport.at(2, 3) + vp.walkable = False + vp.height = 10.0 + vp.cost = 3.5 + + # Access the same cell again + vp2 = viewport.at(2, 3) + assert vp2.walkable == False, "walkable change did not persist" + assert abs(vp2.height - 10.0) < 0.01, "height change did not persist" + assert abs(vp2.cost - 3.5) < 0.01, "cost change did not persist" + + # Make sure other cells are unaffected + vp3 = viewport.at(2, 4) + assert vp3.walkable == True, "Adjacent cell was modified" + assert vp3.height == 0.0, "Adjacent cell height was modified" + + print(" PASS: VoxelPoint persistence") + + +def test_cell_size_property(): + """Test cell_size property""" + print("Testing cell_size property...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + + # Default cell size should be 1.0 + assert abs(viewport.cell_size - 1.0) < 0.01, f"Default cell_size should be 1.0, got {viewport.cell_size}" + + # Set cell size + viewport.cell_size = 2.5 + assert abs(viewport.cell_size - 2.5) < 0.01, f"cell_size should be 2.5, got {viewport.cell_size}" + + # cell_size must be positive + try: + viewport.cell_size = 0 + assert False, "Expected ValueError for zero cell_size" + except ValueError: + pass + + try: + viewport.cell_size = -1.0 + assert False, "Expected ValueError for negative cell_size" + except ValueError: + pass + + print(" PASS: cell_size property") + + +def test_repr(): + """Test VoxelPoint __repr__""" + print("Testing VoxelPoint repr...") + + viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240)) + viewport.grid_size = (10, 10) + + vp = viewport.at(3, 7) + r = repr(vp) + assert "VoxelPoint" in r, f"repr should contain 'VoxelPoint', got {r}" + assert "3, 7" in r, f"repr should contain '3, 7', got {r}" + + print(" PASS: VoxelPoint repr") + + +def run_all_tests(): + """Run all unit tests""" + print("=" * 60) + print("VoxelPoint Unit Tests") + print("=" * 60) + + try: + test_grid_creation() + test_voxelpoint_access() + test_voxelpoint_properties() + test_voxelpoint_persistence() + test_cell_size_property() + test_repr() + + print("=" * 60) + print("ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + except AssertionError as e: + print(f"FAIL: {e}") + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +# Run tests +run_all_tests() From b093e087e15ae7f7dcc4f23f934f7d9047796505 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 6 Feb 2026 21:43:03 -0500 Subject: [PATCH 2/3] Tiled XML/JSON import support --- .gitmodules | 6 + CMakeLists.txt | 3 + modules/RapidXML | 1 + modules/json | 1 + src/tiled/PyTileMapFile.cpp | 332 ++++++++++++ src/tiled/PyTileMapFile.h | 81 +++ src/tiled/PyTileSetFile.cpp | 234 ++++++++ src/tiled/PyTileSetFile.h | 79 +++ src/tiled/PyWangSet.cpp | 266 +++++++++ src/tiled/PyWangSet.h | 72 +++ src/tiled/TiledParse.cpp | 772 +++++++++++++++++++++++++++ src/tiled/TiledParse.h | 24 + src/tiled/TiledTypes.h | 186 +++++++ src/tiled/WangResolve.cpp | 142 +++++ src/tiled/WangResolve.h | 17 + tests/demo/screens/tiled_analysis.py | 167 ++++++ tests/demo/screens/tiled_demo.py | 504 +++++++++++++++++ tests/unit/wang_resolve_test.py | 153 ++++++ 18 files changed, 3040 insertions(+) create mode 160000 modules/RapidXML create mode 160000 modules/json create mode 100644 src/tiled/PyTileMapFile.cpp create mode 100644 src/tiled/PyTileMapFile.h create mode 100644 src/tiled/PyTileSetFile.cpp create mode 100644 src/tiled/PyTileSetFile.h create mode 100644 src/tiled/PyWangSet.cpp create mode 100644 src/tiled/PyWangSet.h create mode 100644 src/tiled/TiledParse.cpp create mode 100644 src/tiled/TiledParse.h create mode 100644 src/tiled/TiledTypes.h create mode 100644 src/tiled/WangResolve.cpp create mode 100644 src/tiled/WangResolve.h create mode 100644 tests/demo/screens/tiled_analysis.py create mode 100644 tests/demo/screens/tiled_demo.py create mode 100644 tests/unit/wang_resolve_test.py diff --git a/.gitmodules b/.gitmodules index 239bdda..1dccd8f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,9 @@ path = modules/libtcod-headless url = git@github.com:jmccardle/libtcod-headless.git branch = 2.2.1-headless +[submodule "modules/RapidXML"] + path = modules/RapidXML + url = https://github.com/Fe-Bell/RapidXML +[submodule "modules/json"] + path = modules/json + url = git@github.com:nlohmann/json.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 704b9c1..ed399d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,9 @@ include_directories(SYSTEM ${CMAKE_SOURCE_DIR}/deps/libtcod) include_directories(${CMAKE_SOURCE_DIR}/src) include_directories(${CMAKE_SOURCE_DIR}/src/3d) include_directories(${CMAKE_SOURCE_DIR}/src/platform) +include_directories(${CMAKE_SOURCE_DIR}/src/tiled) +include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML) +include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include) # Python includes: use different paths for Windows vs Linux vs Emscripten if(EMSCRIPTEN) diff --git a/modules/RapidXML b/modules/RapidXML new file mode 160000 index 0000000..3a42082 --- /dev/null +++ b/modules/RapidXML @@ -0,0 +1 @@ +Subproject commit 3a42082084509e9efb58dcef17b1ad5860dab6ac diff --git a/modules/json b/modules/json new file mode 160000 index 0000000..21b5374 --- /dev/null +++ b/modules/json @@ -0,0 +1 @@ +Subproject commit 21b53746c9d73d314d5de454e2e7cddd20cbbe5d diff --git a/src/tiled/PyTileMapFile.cpp b/src/tiled/PyTileMapFile.cpp new file mode 100644 index 0000000..f9c6aa1 --- /dev/null +++ b/src/tiled/PyTileMapFile.cpp @@ -0,0 +1,332 @@ +#include "PyTileMapFile.h" +#include "PyTileSetFile.h" +#include "TiledParse.h" +#include "McRFPy_Doc.h" +#include "GridLayers.h" +#include + +using namespace mcrf::tiled; + +// ============================================================ +// Type lifecycle +// ============================================================ + +PyObject* PyTileMapFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + auto* self = (PyTileMapFileObject*)type->tp_alloc(type, 0); + if (self) { + new (&self->data) std::shared_ptr(); + } + return (PyObject*)self; +} + +int PyTileMapFile::init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"path", nullptr}; + const char* path = nullptr; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) + return -1; + + try { + self->data = loadTileMap(path); + } catch (const std::exception& e) { + PyErr_Format(PyExc_IOError, "Failed to load tilemap: %s", e.what()); + return -1; + } + + return 0; +} + +void PyTileMapFile::dealloc(PyTileMapFileObject* self) { + self->data.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyTileMapFile::repr(PyObject* obj) { + auto* self = (PyTileMapFileObject*)obj; + if (!self->data) { + return PyUnicode_FromString(""); + } + return PyUnicode_FromFormat("", + self->data->width, self->data->height, + (int)self->data->tilesets.size(), + (int)self->data->tile_layers.size(), + (int)self->data->object_layers.size()); +} + +// ============================================================ +// Properties +// ============================================================ + +PyObject* PyTileMapFile::get_width(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->width); +} + +PyObject* PyTileMapFile::get_height(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->height); +} + +PyObject* PyTileMapFile::get_tile_width(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_width); +} + +PyObject* PyTileMapFile::get_tile_height(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_height); +} + +PyObject* PyTileMapFile::get_orientation(PyTileMapFileObject* self, void*) { + return PyUnicode_FromString(self->data->orientation.c_str()); +} + +PyObject* PyTileMapFile::get_properties(PyTileMapFileObject* self, void*) { + return propertiesToPython(self->data->properties); +} + +PyObject* PyTileMapFile::get_tileset_count(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tilesets.size()); +} + +PyObject* PyTileMapFile::get_tile_layer_names(PyTileMapFileObject* self, void*) { + PyObject* list = PyList_New(self->data->tile_layers.size()); + if (!list) return NULL; + for (size_t i = 0; i < self->data->tile_layers.size(); i++) { + PyObject* name = PyUnicode_FromString(self->data->tile_layers[i].name.c_str()); + if (!name) { Py_DECREF(list); return NULL; } + PyList_SET_ITEM(list, i, name); + } + return list; +} + +PyObject* PyTileMapFile::get_object_layer_names(PyTileMapFileObject* self, void*) { + PyObject* list = PyList_New(self->data->object_layers.size()); + if (!list) return NULL; + for (size_t i = 0; i < self->data->object_layers.size(); i++) { + PyObject* name = PyUnicode_FromString(self->data->object_layers[i].name.c_str()); + if (!name) { Py_DECREF(list); return NULL; } + PyList_SET_ITEM(list, i, name); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +PyObject* PyTileMapFile::tileset(PyTileMapFileObject* self, PyObject* args) { + int index; + if (!PyArg_ParseTuple(args, "i", &index)) + return NULL; + + if (index < 0 || index >= (int)self->data->tilesets.size()) { + PyErr_Format(PyExc_IndexError, "Tileset index %d out of range (0..%d)", + index, (int)self->data->tilesets.size() - 1); + return NULL; + } + + const auto& ref = self->data->tilesets[index]; + + // Create a TileSetFile wrapping the existing parsed data + auto* ts_type = &mcrfpydef::PyTileSetFileType; + auto* ts = (PyTileSetFileObject*)ts_type->tp_alloc(ts_type, 0); + if (!ts) return NULL; + new (&ts->data) std::shared_ptr(ref.tileset); + + // Return (firstgid, TileSetFile) + PyObject* result = Py_BuildValue("(iN)", ref.firstgid, (PyObject*)ts); + return result; +} + +PyObject* PyTileMapFile::tile_layer_data(PyTileMapFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (const auto& tl : self->data->tile_layers) { + if (tl.name == name) { + PyObject* list = PyList_New(tl.global_gids.size()); + if (!list) return NULL; + for (size_t i = 0; i < tl.global_gids.size(); i++) { + PyList_SET_ITEM(list, i, PyLong_FromUnsignedLong(tl.global_gids[i])); + } + return list; + } + } + + PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", name); + return NULL; +} + +PyObject* PyTileMapFile::resolve_gid(PyTileMapFileObject* self, PyObject* args) { + unsigned int gid; + if (!PyArg_ParseTuple(args, "I", &gid)) + return NULL; + + if (gid == 0) { + // GID 0 = empty tile + return Py_BuildValue("(ii)", -1, -1); + } + + // Strip flip flags (top 3 bits of a 32-bit GID) + uint32_t clean_gid = gid & 0x1FFFFFFF; + + // Find which tileset this GID belongs to (tilesets sorted by firstgid) + int ts_index = -1; + for (int i = (int)self->data->tilesets.size() - 1; i >= 0; i--) { + if (clean_gid >= (uint32_t)self->data->tilesets[i].firstgid) { + ts_index = i; + break; + } + } + + if (ts_index < 0) { + return Py_BuildValue("(ii)", -1, -1); + } + + int local_id = clean_gid - self->data->tilesets[ts_index].firstgid; + return Py_BuildValue("(ii)", ts_index, local_id); +} + +PyObject* PyTileMapFile::object_layer(PyTileMapFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (const auto& ol : self->data->object_layers) { + if (ol.name == name) { + return jsonToPython(ol.objects); + } + } + + PyErr_Format(PyExc_KeyError, "No object layer named '%s'", name); + return NULL; +} + +PyObject* PyTileMapFile::apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"tile_layer", "layer_name", "tileset_index", nullptr}; + PyObject* tlayer_obj; + const char* layer_name; + int tileset_index = 0; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Os|i", const_cast(keywords), + &tlayer_obj, &layer_name, &tileset_index)) + return NULL; + + // Validate TileLayer + // Check type by name since PyTileLayerType is static-per-TU + const char* type_name = Py_TYPE(tlayer_obj)->tp_name; + if (!type_name || strcmp(type_name, "mcrfpy.TileLayer") != 0) { + PyErr_SetString(PyExc_TypeError, "First argument must be a TileLayer"); + return NULL; + } + + // Find the tile layer data + const TileLayerData* tld = nullptr; + for (const auto& tl : self->data->tile_layers) { + if (tl.name == layer_name) { + tld = &tl; + break; + } + } + if (!tld) { + PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", layer_name); + return NULL; + } + + if (tileset_index < 0 || tileset_index >= (int)self->data->tilesets.size()) { + PyErr_Format(PyExc_IndexError, "Tileset index %d out of range", tileset_index); + return NULL; + } + + int firstgid = self->data->tilesets[tileset_index].firstgid; + auto* tlayer = (PyTileLayerObject*)tlayer_obj; + + int w = tld->width; + int h = tld->height; + for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { + for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { + uint32_t gid = tld->global_gids[y * w + x]; + if (gid == 0) { + tlayer->data->at(x, y) = -1; // empty + continue; + } + uint32_t clean_gid = gid & 0x1FFFFFFF; + int local_id = static_cast(clean_gid) - firstgid; + if (local_id >= 0) { + tlayer->data->at(x, y) = local_id; + } + } + } + tlayer->data->markDirty(); + + Py_RETURN_NONE; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyTileMapFile::methods[] = { + {"tileset", (PyCFunction)PyTileMapFile::tileset, METH_VARARGS, + MCRF_METHOD(TileMapFile, tileset, + MCRF_SIG("(index: int)", "tuple[int, TileSetFile]"), + MCRF_DESC("Get a referenced tileset by index."), + MCRF_ARGS_START + MCRF_ARG("index", "Tileset index (0-based)") + MCRF_RETURNS("Tuple of (firstgid, TileSetFile).") + )}, + {"tile_layer_data", (PyCFunction)PyTileMapFile::tile_layer_data, METH_VARARGS, + MCRF_METHOD(TileMapFile, tile_layer_data, + MCRF_SIG("(name: str)", "list[int]"), + MCRF_DESC("Get raw global GID data for a tile layer."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the tile layer") + MCRF_RETURNS("Flat list of global GIDs (0 = empty tile).") + MCRF_RAISES("KeyError", "If no tile layer with that name exists") + )}, + {"resolve_gid", (PyCFunction)PyTileMapFile::resolve_gid, METH_VARARGS, + MCRF_METHOD(TileMapFile, resolve_gid, + MCRF_SIG("(gid: int)", "tuple[int, int]"), + MCRF_DESC("Resolve a global tile ID to tileset index and local tile ID."), + MCRF_ARGS_START + MCRF_ARG("gid", "Global tile ID from tile_layer_data()") + MCRF_RETURNS("Tuple of (tileset_index, local_tile_id). (-1, -1) for empty/invalid.") + )}, + {"object_layer", (PyCFunction)PyTileMapFile::object_layer, METH_VARARGS, + MCRF_METHOD(TileMapFile, object_layer, + MCRF_SIG("(name: str)", "list[dict]"), + MCRF_DESC("Get objects from an object layer as Python dicts."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the object layer") + MCRF_RETURNS("List of dicts with object properties (id, name, x, y, width, height, etc.).") + MCRF_RAISES("KeyError", "If no object layer with that name exists") + )}, + {"apply_to_tile_layer", (PyCFunction)PyTileMapFile::apply_to_tile_layer, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(TileMapFile, apply_to_tile_layer, + MCRF_SIG("(tile_layer: TileLayer, layer_name: str, tileset_index: int = 0)", "None"), + MCRF_DESC("Resolve GIDs and write sprite indices into a TileLayer."), + MCRF_ARGS_START + MCRF_ARG("tile_layer", "Target TileLayer to write into") + MCRF_ARG("layer_name", "Name of the tile layer in this map") + MCRF_ARG("tileset_index", "Which tileset to resolve GIDs against (default 0)") + )}, + {NULL} +}; + +PyGetSetDef PyTileMapFile::getsetters[] = { + {"width", (getter)PyTileMapFile::get_width, NULL, + MCRF_PROPERTY(width, "Map width in tiles (int, read-only)."), NULL}, + {"height", (getter)PyTileMapFile::get_height, NULL, + MCRF_PROPERTY(height, "Map height in tiles (int, read-only)."), NULL}, + {"tile_width", (getter)PyTileMapFile::get_tile_width, NULL, + MCRF_PROPERTY(tile_width, "Tile width in pixels (int, read-only)."), NULL}, + {"tile_height", (getter)PyTileMapFile::get_tile_height, NULL, + MCRF_PROPERTY(tile_height, "Tile height in pixels (int, read-only)."), NULL}, + {"orientation", (getter)PyTileMapFile::get_orientation, NULL, + MCRF_PROPERTY(orientation, "Map orientation, e.g. 'orthogonal' (str, read-only)."), NULL}, + {"properties", (getter)PyTileMapFile::get_properties, NULL, + MCRF_PROPERTY(properties, "Custom map properties as a dict (read-only)."), NULL}, + {"tileset_count", (getter)PyTileMapFile::get_tileset_count, NULL, + MCRF_PROPERTY(tileset_count, "Number of referenced tilesets (int, read-only)."), NULL}, + {"tile_layer_names", (getter)PyTileMapFile::get_tile_layer_names, NULL, + MCRF_PROPERTY(tile_layer_names, "List of tile layer names (read-only)."), NULL}, + {"object_layer_names", (getter)PyTileMapFile::get_object_layer_names, NULL, + MCRF_PROPERTY(object_layer_names, "List of object layer names (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyTileMapFile.h b/src/tiled/PyTileMapFile.h new file mode 100644 index 0000000..36a879c --- /dev/null +++ b/src/tiled/PyTileMapFile.h @@ -0,0 +1,81 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +typedef struct PyTileMapFileObject { + PyObject_HEAD + std::shared_ptr data; +} PyTileMapFileObject; + +// Python binding class +class PyTileMapFile { +public: + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyTileMapFileObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_width(PyTileMapFileObject* self, void* closure); + static PyObject* get_height(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_width(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_height(PyTileMapFileObject* self, void* closure); + static PyObject* get_orientation(PyTileMapFileObject* self, void* closure); + static PyObject* get_properties(PyTileMapFileObject* self, void* closure); + static PyObject* get_tileset_count(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_layer_names(PyTileMapFileObject* self, void* closure); + static PyObject* get_object_layer_names(PyTileMapFileObject* self, void* closure); + + // Methods + static PyObject* tileset(PyTileMapFileObject* self, PyObject* args); + static PyObject* tile_layer_data(PyTileMapFileObject* self, PyObject* args); + static PyObject* resolve_gid(PyTileMapFileObject* self, PyObject* args); + static PyObject* object_layer(PyTileMapFileObject* self, PyObject* args); + static PyObject* apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyTileMapFileType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.TileMapFile", + .tp_basicsize = sizeof(PyTileMapFileObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyTileMapFile::dealloc, + .tp_repr = PyTileMapFile::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "TileMapFile(path: str)\n\n" + "Load a Tiled map file (.tmx or .tmj).\n\n" + "Parses the map and its referenced tilesets, providing access to tile layers,\n" + "object layers, and GID resolution.\n\n" + "Args:\n" + " path: Path to the .tmx or .tmj map file.\n\n" + "Properties:\n" + " width (int, read-only): Map width in tiles.\n" + " height (int, read-only): Map height in tiles.\n" + " tile_width (int, read-only): Tile width in pixels.\n" + " tile_height (int, read-only): Tile height in pixels.\n" + " orientation (str, read-only): Map orientation (e.g. 'orthogonal').\n" + " properties (dict, read-only): Custom map properties.\n" + " tileset_count (int, read-only): Number of referenced tilesets.\n" + " tile_layer_names (list, read-only): Names of tile layers.\n" + " object_layer_names (list, read-only): Names of object layers.\n\n" + "Example:\n" + " tm = mcrfpy.TileMapFile('map.tmx')\n" + " data = tm.tile_layer_data('Ground')\n" + " tm.apply_to_tile_layer(my_tile_layer, 'Ground')\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready + .tp_init = (initproc)PyTileMapFile::init, + .tp_new = PyTileMapFile::pynew, +}; + +} // namespace mcrfpydef diff --git a/src/tiled/PyTileSetFile.cpp b/src/tiled/PyTileSetFile.cpp new file mode 100644 index 0000000..9748aa6 --- /dev/null +++ b/src/tiled/PyTileSetFile.cpp @@ -0,0 +1,234 @@ +#include "PyTileSetFile.h" +#include "TiledParse.h" +#include "PyWangSet.h" +#include "McRFPy_Doc.h" + +using namespace mcrf::tiled; + +// ============================================================ +// Type lifecycle +// ============================================================ + +PyObject* PyTileSetFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + auto* self = (PyTileSetFileObject*)type->tp_alloc(type, 0); + if (self) { + new (&self->data) std::shared_ptr(); + } + return (PyObject*)self; +} + +int PyTileSetFile::init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"path", nullptr}; + const char* path = nullptr; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) + return -1; + + try { + self->data = loadTileSet(path); + } catch (const std::exception& e) { + PyErr_Format(PyExc_IOError, "Failed to load tileset: %s", e.what()); + return -1; + } + + return 0; +} + +void PyTileSetFile::dealloc(PyTileSetFileObject* self) { + self->data.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyTileSetFile::repr(PyObject* obj) { + auto* self = (PyTileSetFileObject*)obj; + if (!self->data) { + return PyUnicode_FromString(""); + } + return PyUnicode_FromFormat("", + self->data->name.c_str(), self->data->tile_count, + self->data->tile_width, self->data->tile_height); +} + +// ============================================================ +// Properties (all read-only) +// ============================================================ + +PyObject* PyTileSetFile::get_name(PyTileSetFileObject* self, void*) { + return PyUnicode_FromString(self->data->name.c_str()); +} + +PyObject* PyTileSetFile::get_tile_width(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_width); +} + +PyObject* PyTileSetFile::get_tile_height(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_height); +} + +PyObject* PyTileSetFile::get_tile_count(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_count); +} + +PyObject* PyTileSetFile::get_columns(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->columns); +} + +PyObject* PyTileSetFile::get_margin(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->margin); +} + +PyObject* PyTileSetFile::get_spacing(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->spacing); +} + +PyObject* PyTileSetFile::get_image_source(PyTileSetFileObject* self, void*) { + return PyUnicode_FromString(self->data->image_source.c_str()); +} + +PyObject* PyTileSetFile::get_properties(PyTileSetFileObject* self, void*) { + return propertiesToPython(self->data->properties); +} + +PyObject* PyTileSetFile::get_wang_sets(PyTileSetFileObject* self, void*) { + PyObject* list = PyList_New(self->data->wang_sets.size()); + if (!list) return NULL; + + for (size_t i = 0; i < self->data->wang_sets.size(); i++) { + PyObject* ws = PyWangSet::create(self->data, static_cast(i)); + if (!ws) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, ws); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +PyObject* PyTileSetFile::to_texture(PyTileSetFileObject* self, PyObject* args) { + // Create a PyTexture using the image source and tile dimensions + // Get the Texture type from the mcrfpy module (safe cross-compilation-unit access) + PyObject* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; + + PyObject* tex_type = PyObject_GetAttrString(mcrfpy_module, "Texture"); + Py_DECREF(mcrfpy_module); + if (!tex_type) return NULL; + + PyObject* tex_args = Py_BuildValue("(sii)", + self->data->image_source.c_str(), + self->data->tile_width, + self->data->tile_height); + if (!tex_args) { Py_DECREF(tex_type); return NULL; } + + PyObject* tex = PyObject_Call(tex_type, tex_args, NULL); + Py_DECREF(tex_type); + Py_DECREF(tex_args); + return tex; +} + +PyObject* PyTileSetFile::tile_info(PyTileSetFileObject* self, PyObject* args) { + int tile_id; + if (!PyArg_ParseTuple(args, "i", &tile_id)) + return NULL; + + auto it = self->data->tile_info.find(tile_id); + if (it == self->data->tile_info.end()) { + Py_RETURN_NONE; + } + + const TileInfo& ti = it->second; + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + + // Properties + PyObject* props = propertiesToPython(ti.properties); + if (!props) { Py_DECREF(dict); return NULL; } + PyDict_SetItemString(dict, "properties", props); + Py_DECREF(props); + + // Animation + PyObject* anim_list = PyList_New(ti.animation.size()); + if (!anim_list) { Py_DECREF(dict); return NULL; } + for (size_t i = 0; i < ti.animation.size(); i++) { + PyObject* frame = Py_BuildValue("(ii)", ti.animation[i].tile_id, ti.animation[i].duration_ms); + if (!frame) { Py_DECREF(anim_list); Py_DECREF(dict); return NULL; } + PyList_SET_ITEM(anim_list, i, frame); + } + PyDict_SetItemString(dict, "animation", anim_list); + Py_DECREF(anim_list); + + return dict; +} + +PyObject* PyTileSetFile::wang_set(PyTileSetFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (size_t i = 0; i < self->data->wang_sets.size(); i++) { + if (self->data->wang_sets[i].name == name) { + return PyWangSet::create(self->data, static_cast(i)); + } + } + + PyErr_Format(PyExc_KeyError, "No WangSet named '%s'", name); + return NULL; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyTileSetFile::methods[] = { + {"to_texture", (PyCFunction)PyTileSetFile::to_texture, METH_NOARGS, + MCRF_METHOD(TileSetFile, to_texture, + MCRF_SIG("()", "Texture"), + MCRF_DESC("Create a Texture from the tileset image."), + MCRF_RETURNS("A Texture object for use with TileLayer.") + )}, + {"tile_info", (PyCFunction)PyTileSetFile::tile_info, METH_VARARGS, + MCRF_METHOD(TileSetFile, tile_info, + MCRF_SIG("(tile_id: int)", "dict | None"), + MCRF_DESC("Get metadata for a specific tile."), + MCRF_ARGS_START + MCRF_ARG("tile_id", "Local tile ID (0-based)") + MCRF_RETURNS("Dict with 'properties' and 'animation' keys, or None if no metadata.") + )}, + {"wang_set", (PyCFunction)PyTileSetFile::wang_set, METH_VARARGS, + MCRF_METHOD(TileSetFile, wang_set, + MCRF_SIG("(name: str)", "WangSet"), + MCRF_DESC("Look up a WangSet by name."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the Wang set") + MCRF_RETURNS("The WangSet object.") + MCRF_RAISES("KeyError", "If no WangSet with that name exists") + )}, + {NULL} +}; + +PyGetSetDef PyTileSetFile::getsetters[] = { + {"name", (getter)PyTileSetFile::get_name, NULL, + MCRF_PROPERTY(name, "Tileset name (str, read-only)."), NULL}, + {"tile_width", (getter)PyTileSetFile::get_tile_width, NULL, + MCRF_PROPERTY(tile_width, "Width of each tile in pixels (int, read-only)."), NULL}, + {"tile_height", (getter)PyTileSetFile::get_tile_height, NULL, + MCRF_PROPERTY(tile_height, "Height of each tile in pixels (int, read-only)."), NULL}, + {"tile_count", (getter)PyTileSetFile::get_tile_count, NULL, + MCRF_PROPERTY(tile_count, "Total number of tiles (int, read-only)."), NULL}, + {"columns", (getter)PyTileSetFile::get_columns, NULL, + MCRF_PROPERTY(columns, "Number of columns in tileset image (int, read-only)."), NULL}, + {"margin", (getter)PyTileSetFile::get_margin, NULL, + MCRF_PROPERTY(margin, "Margin around tiles in pixels (int, read-only)."), NULL}, + {"spacing", (getter)PyTileSetFile::get_spacing, NULL, + MCRF_PROPERTY(spacing, "Spacing between tiles in pixels (int, read-only)."), NULL}, + {"image_source", (getter)PyTileSetFile::get_image_source, NULL, + MCRF_PROPERTY(image_source, "Resolved path to the tileset image file (str, read-only)."), NULL}, + {"properties", (getter)PyTileSetFile::get_properties, NULL, + MCRF_PROPERTY(properties, "Custom tileset properties as a dict (read-only)."), NULL}, + {"wang_sets", (getter)PyTileSetFile::get_wang_sets, NULL, + MCRF_PROPERTY(wang_sets, "List of WangSet objects from this tileset (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyTileSetFile.h b/src/tiled/PyTileSetFile.h new file mode 100644 index 0000000..ed94985 --- /dev/null +++ b/src/tiled/PyTileSetFile.h @@ -0,0 +1,79 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +typedef struct PyTileSetFileObject { + PyObject_HEAD + std::shared_ptr data; +} PyTileSetFileObject; + +// Python binding class +class PyTileSetFile { +public: + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyTileSetFileObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_name(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_width(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_height(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_count(PyTileSetFileObject* self, void* closure); + static PyObject* get_columns(PyTileSetFileObject* self, void* closure); + static PyObject* get_margin(PyTileSetFileObject* self, void* closure); + static PyObject* get_spacing(PyTileSetFileObject* self, void* closure); + static PyObject* get_image_source(PyTileSetFileObject* self, void* closure); + static PyObject* get_properties(PyTileSetFileObject* self, void* closure); + static PyObject* get_wang_sets(PyTileSetFileObject* self, void* closure); + + // Methods + static PyObject* to_texture(PyTileSetFileObject* self, PyObject* args); + static PyObject* tile_info(PyTileSetFileObject* self, PyObject* args); + static PyObject* wang_set(PyTileSetFileObject* self, PyObject* args); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyTileSetFileType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.TileSetFile", + .tp_basicsize = sizeof(PyTileSetFileObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyTileSetFile::dealloc, + .tp_repr = PyTileSetFile::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "TileSetFile(path: str)\n\n" + "Load a Tiled tileset file (.tsx or .tsj).\n\n" + "Parses the tileset and provides access to tile metadata, properties,\n" + "Wang sets, and texture creation.\n\n" + "Args:\n" + " path: Path to the .tsx or .tsj tileset file.\n\n" + "Properties:\n" + " name (str, read-only): Tileset name.\n" + " tile_width (int, read-only): Width of each tile in pixels.\n" + " tile_height (int, read-only): Height of each tile in pixels.\n" + " tile_count (int, read-only): Total number of tiles.\n" + " columns (int, read-only): Number of columns in the tileset image.\n" + " image_source (str, read-only): Resolved path to the tileset image.\n" + " properties (dict, read-only): Custom properties from the tileset.\n" + " wang_sets (list, read-only): List of WangSet objects.\n\n" + "Example:\n" + " ts = mcrfpy.TileSetFile('tileset.tsx')\n" + " texture = ts.to_texture()\n" + " print(f'{ts.name}: {ts.tile_count} tiles')\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready + .tp_init = (initproc)PyTileSetFile::init, + .tp_new = PyTileSetFile::pynew, +}; + +} // namespace mcrfpydef diff --git a/src/tiled/PyWangSet.cpp b/src/tiled/PyWangSet.cpp new file mode 100644 index 0000000..592688c --- /dev/null +++ b/src/tiled/PyWangSet.cpp @@ -0,0 +1,266 @@ +#include "PyWangSet.h" +#include "TiledParse.h" +#include "WangResolve.h" +#include "McRFPy_Doc.h" +#include "PyDiscreteMap.h" +#include "GridLayers.h" +#include + +using namespace mcrf::tiled; + +// ============================================================ +// Helper +// ============================================================ + +const WangSet& PyWangSet::getWangSet(PyWangSetObject* self) { + return self->parent->wang_sets[self->wang_set_index]; +} + +// ============================================================ +// Factory +// ============================================================ + +PyObject* PyWangSet::create(std::shared_ptr parent, int index) { + auto* type = &mcrfpydef::PyWangSetType; + auto* self = (PyWangSetObject*)type->tp_alloc(type, 0); + if (!self) return NULL; + new (&self->parent) std::shared_ptr(parent); + self->wang_set_index = index; + return (PyObject*)self; +} + +// ============================================================ +// Type lifecycle +// ============================================================ + +void PyWangSet::dealloc(PyWangSetObject* self) { + self->parent.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyWangSet::repr(PyObject* obj) { + auto* self = (PyWangSetObject*)obj; + const auto& ws = getWangSet(self); + const char* type_str = "unknown"; + switch (ws.type) { + case WangSetType::Corner: type_str = "corner"; break; + case WangSetType::Edge: type_str = "edge"; break; + case WangSetType::Mixed: type_str = "mixed"; break; + } + return PyUnicode_FromFormat("", + ws.name.c_str(), type_str, (int)ws.colors.size()); +} + +// ============================================================ +// Properties +// ============================================================ + +PyObject* PyWangSet::get_name(PyWangSetObject* self, void*) { + return PyUnicode_FromString(getWangSet(self).name.c_str()); +} + +PyObject* PyWangSet::get_type(PyWangSetObject* self, void*) { + switch (getWangSet(self).type) { + case WangSetType::Corner: return PyUnicode_FromString("corner"); + case WangSetType::Edge: return PyUnicode_FromString("edge"); + case WangSetType::Mixed: return PyUnicode_FromString("mixed"); + } + return PyUnicode_FromString("unknown"); +} + +PyObject* PyWangSet::get_color_count(PyWangSetObject* self, void*) { + return PyLong_FromLong(getWangSet(self).colors.size()); +} + +PyObject* PyWangSet::get_colors(PyWangSetObject* self, void*) { + const auto& ws = getWangSet(self); + PyObject* list = PyList_New(ws.colors.size()); + if (!list) return NULL; + + for (size_t i = 0; i < ws.colors.size(); i++) { + const auto& wc = ws.colors[i]; + PyObject* dict = Py_BuildValue("{s:s, s:i, s:i, s:f}", + "name", wc.name.c_str(), + "index", wc.index, + "tile_id", wc.tile_id, + "probability", (double)wc.probability); + if (!dict) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, dict); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +// Convert a name like "Grass Terrain" to "GRASS_TERRAIN" +static std::string toUpperSnakeCase(const std::string& s) { + std::string result; + result.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + char c = s[i]; + if (c == ' ' || c == '-') { + result += '_'; + } else { + result += static_cast(toupper(static_cast(c))); + } + } + return result; +} + +PyObject* PyWangSet::terrain_enum(PyWangSetObject* self, PyObject*) { + const auto& ws = getWangSet(self); + + // Import IntEnum from enum module + PyObject* enum_module = PyImport_ImportModule("enum"); + if (!enum_module) return NULL; + + PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum"); + Py_DECREF(enum_module); + if (!int_enum) return NULL; + + // Build members dict: NONE=0, then each color + PyObject* members = PyDict_New(); + if (!members) { Py_DECREF(int_enum); return NULL; } + + // NONE = 0 (unset terrain) + PyObject* zero = PyLong_FromLong(0); + PyDict_SetItemString(members, "NONE", zero); + Py_DECREF(zero); + + for (const auto& wc : ws.colors) { + std::string key = toUpperSnakeCase(wc.name); + PyObject* val = PyLong_FromLong(wc.index); + PyDict_SetItemString(members, key.c_str(), val); + Py_DECREF(val); + } + + // Create enum class: IntEnum(ws.name, members) + PyObject* name = PyUnicode_FromString(ws.name.c_str()); + PyObject* args = PyTuple_Pack(2, name, members); + Py_DECREF(name); + Py_DECREF(members); + + PyObject* enum_class = PyObject_Call(int_enum, args, NULL); + Py_DECREF(args); + Py_DECREF(int_enum); + + return enum_class; +} + +PyObject* PyWangSet::resolve(PyWangSetObject* self, PyObject* args) { + PyObject* dmap_obj; + if (!PyArg_ParseTuple(args, "O", &dmap_obj)) + return NULL; + + // Check type by name since static types differ per translation unit + const char* dmap_type_name = Py_TYPE(dmap_obj)->tp_name; + if (!dmap_type_name || strcmp(dmap_type_name, "mcrfpy.DiscreteMap") != 0) { + PyErr_SetString(PyExc_TypeError, "Expected a DiscreteMap object"); + return NULL; + } + + auto* dmap = (PyDiscreteMapObject*)dmap_obj; + const auto& ws = getWangSet(self); + + std::vector result = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); + + // Convert to Python list + PyObject* list = PyList_New(result.size()); + if (!list) return NULL; + for (size_t i = 0; i < result.size(); i++) { + PyList_SET_ITEM(list, i, PyLong_FromLong(result[i])); + } + return list; +} + +PyObject* PyWangSet::apply(PyWangSetObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"discrete_map", "tile_layer", nullptr}; + PyObject* dmap_obj; + PyObject* tlayer_obj; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO", const_cast(keywords), + &dmap_obj, &tlayer_obj)) + return NULL; + + // Validate DiscreteMap (check by name since static types differ per TU) + const char* dmap_tn = Py_TYPE(dmap_obj)->tp_name; + if (!dmap_tn || strcmp(dmap_tn, "mcrfpy.DiscreteMap") != 0) { + PyErr_SetString(PyExc_TypeError, "First argument must be a DiscreteMap"); + return NULL; + } + + // Validate TileLayer + const char* tl_tn = Py_TYPE(tlayer_obj)->tp_name; + if (!tl_tn || strcmp(tl_tn, "mcrfpy.TileLayer") != 0) { + PyErr_SetString(PyExc_TypeError, "Second argument must be a TileLayer"); + return NULL; + } + + auto* dmap = (PyDiscreteMapObject*)dmap_obj; + auto* tlayer = (PyTileLayerObject*)tlayer_obj; + const auto& ws = getWangSet(self); + + // Resolve terrain to tile indices + std::vector tile_ids = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); + + // Write into TileLayer + int w = dmap->w; + int h = dmap->h; + for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { + for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { + int tid = tile_ids[y * w + x]; + if (tid >= 0) { + tlayer->data->at(x, y) = tid; + } + } + } + tlayer->data->markDirty(); + + Py_RETURN_NONE; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyWangSet::methods[] = { + {"terrain_enum", (PyCFunction)PyWangSet::terrain_enum, METH_NOARGS, + MCRF_METHOD(WangSet, terrain_enum, + MCRF_SIG("()", "IntEnum"), + MCRF_DESC("Generate a Python IntEnum from this WangSet's terrain colors."), + MCRF_RETURNS("IntEnum class with NONE=0 and one member per color (UPPER_SNAKE_CASE).") + )}, + {"resolve", (PyCFunction)PyWangSet::resolve, METH_VARARGS, + MCRF_METHOD(WangSet, resolve, + MCRF_SIG("(discrete_map: DiscreteMap)", "list[int]"), + MCRF_DESC("Resolve terrain data to tile indices using Wang tile rules."), + MCRF_ARGS_START + MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs matching this WangSet's colors") + MCRF_RETURNS("List of tile IDs (one per cell). -1 means no matching Wang tile.") + )}, + {"apply", (PyCFunction)PyWangSet::apply, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(WangSet, apply, + MCRF_SIG("(discrete_map: DiscreteMap, tile_layer: TileLayer)", "None"), + MCRF_DESC("Resolve terrain and write tile indices directly into a TileLayer."), + MCRF_ARGS_START + MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs") + MCRF_ARG("tile_layer", "Target TileLayer to write resolved tiles into") + )}, + {NULL} +}; + +PyGetSetDef PyWangSet::getsetters[] = { + {"name", (getter)PyWangSet::get_name, NULL, + MCRF_PROPERTY(name, "Wang set name (str, read-only)."), NULL}, + {"type", (getter)PyWangSet::get_type, NULL, + MCRF_PROPERTY(type, "Wang set type: 'corner', 'edge', or 'mixed' (str, read-only)."), NULL}, + {"color_count", (getter)PyWangSet::get_color_count, NULL, + MCRF_PROPERTY(color_count, "Number of terrain colors (int, read-only)."), NULL}, + {"colors", (getter)PyWangSet::get_colors, NULL, + MCRF_PROPERTY(colors, "List of color dicts with name, index, tile_id, probability (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyWangSet.h b/src/tiled/PyWangSet.h new file mode 100644 index 0000000..8641f34 --- /dev/null +++ b/src/tiled/PyWangSet.h @@ -0,0 +1,72 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +// Holds a shared_ptr to the parent TileSetData (keeps it alive) + index into wang_sets +typedef struct PyWangSetObject { + PyObject_HEAD + std::shared_ptr parent; + int wang_set_index; +} PyWangSetObject; + +// Python binding class +class PyWangSet { +public: + // Factory: create a PyWangSet from parent tileset + index + static PyObject* create(std::shared_ptr parent, int index); + + static void dealloc(PyWangSetObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_name(PyWangSetObject* self, void* closure); + static PyObject* get_type(PyWangSetObject* self, void* closure); + static PyObject* get_color_count(PyWangSetObject* self, void* closure); + static PyObject* get_colors(PyWangSetObject* self, void* closure); + + // Methods + static PyObject* terrain_enum(PyWangSetObject* self, PyObject* args); + static PyObject* resolve(PyWangSetObject* self, PyObject* args); + static PyObject* apply(PyWangSetObject* self, PyObject* args, PyObject* kwds); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; + +private: + // Helper: get the WangSet reference + static const mcrf::tiled::WangSet& getWangSet(PyWangSetObject* self); +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyWangSetType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.WangSet", + .tp_basicsize = sizeof(PyWangSetObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyWangSet::dealloc, + .tp_repr = PyWangSet::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "WangSet - Wang terrain auto-tile set from a Tiled tileset.\n\n" + "WangSets are obtained from TileSetFile.wang_sets or TileSetFile.wang_set().\n" + "They map abstract terrain types to concrete sprite indices using Tiled's\n" + "Wang tile algorithm.\n\n" + "Properties:\n" + " name (str, read-only): Wang set name.\n" + " type (str, read-only): 'corner', 'edge', or 'mixed'.\n" + " color_count (int, read-only): Number of terrain colors.\n" + " colors (list, read-only): List of color dicts.\n\n" + "Example:\n" + " ws = tileset.wang_set('overworld')\n" + " Terrain = ws.terrain_enum()\n" + " tiles = ws.resolve(discrete_map)\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready +}; + +} // namespace mcrfpydef diff --git a/src/tiled/TiledParse.cpp b/src/tiled/TiledParse.cpp new file mode 100644 index 0000000..4a9673d --- /dev/null +++ b/src/tiled/TiledParse.cpp @@ -0,0 +1,772 @@ +#include "TiledParse.h" +#include "RapidXML/rapidxml.hpp" +#include +#include +#include +#include +#include + +namespace mcrf { +namespace tiled { + +// ============================================================ +// Utility helpers +// ============================================================ + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) { + throw std::runtime_error("Cannot open file: " + path); + } + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +static std::string parentDir(const std::string& path) { + std::filesystem::path p(path); + return p.parent_path().string(); +} + +static std::string resolvePath(const std::string& base_dir, const std::string& relative) { + std::filesystem::path p = std::filesystem::path(base_dir) / relative; + return p.lexically_normal().string(); +} + +static bool endsWith(const std::string& str, const std::string& suffix) { + if (suffix.size() > str.size()) return false; + return str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// Get attribute value or empty string +static std::string xmlAttr(rapidxml::xml_node<>* node, const char* name) { + auto* attr = node->first_attribute(name); + return attr ? std::string(attr->value(), attr->value_size()) : ""; +} + +static int xmlAttrInt(rapidxml::xml_node<>* node, const char* name, int def = 0) { + auto* attr = node->first_attribute(name); + if (!attr) return def; + return std::stoi(std::string(attr->value(), attr->value_size())); +} + +static float xmlAttrFloat(rapidxml::xml_node<>* node, const char* name, float def = 0.0f) { + auto* attr = node->first_attribute(name); + if (!attr) return def; + return std::stof(std::string(attr->value(), attr->value_size())); +} + +// ============================================================ +// Property conversion (Raw → Final) +// ============================================================ + +static PropertyValue convertProperty(const RawProperty& raw) { + if (raw.type == "bool") { + return PropertyValue(raw.value == "true"); + } else if (raw.type == "int") { + return PropertyValue(std::stoi(raw.value)); + } else if (raw.type == "float") { + return PropertyValue(std::stof(raw.value)); + } else { + // Default: string (includes empty type) + return PropertyValue(raw.value); + } +} + +static std::unordered_map convertProperties( + const std::vector& raw_props) { + std::unordered_map result; + for (const auto& rp : raw_props) { + result[rp.name] = convertProperty(rp); + } + return result; +} + +// ============================================================ +// WangSet packing +// ============================================================ + +uint64_t WangSet::packWangId(const std::array& id) { + // Pack 8 values (each 0-255) into 64-bit integer + // Each value gets 8 bits + uint64_t packed = 0; + for (int i = 0; i < 8; i++) { + packed |= (static_cast(id[i] & 0xFF)) << (i * 8); + } + return packed; +} + +// ============================================================ +// XML property parsing (shared by TSX and TMX) +// ============================================================ + +static void parseXmlProperties(rapidxml::xml_node<>* parent, std::vector& out) { + auto* props_node = parent->first_node("properties"); + if (!props_node) return; + for (auto* prop = props_node->first_node("property"); prop; prop = prop->next_sibling("property")) { + RawProperty rp; + rp.name = xmlAttr(prop, "name"); + rp.type = xmlAttr(prop, "type"); + rp.value = xmlAttr(prop, "value"); + // Some properties have value as node text instead of attribute + if (rp.value.empty() && prop->value_size() > 0) { + rp.value = std::string(prop->value(), prop->value_size()); + } + out.push_back(std::move(rp)); + } +} + +// ============================================================ +// TSX parser (XML tileset) +// ============================================================ + +static RawTileSet parseTSX(const std::string& path) { + std::string text = readFile(path); + rapidxml::xml_document<> doc; + doc.parse<0>(text.data()); + + auto* tileset_node = doc.first_node("tileset"); + if (!tileset_node) { + throw std::runtime_error("No element in: " + path); + } + + RawTileSet raw; + raw.name = xmlAttr(tileset_node, "name"); + raw.tile_width = xmlAttrInt(tileset_node, "tilewidth"); + raw.tile_height = xmlAttrInt(tileset_node, "tileheight"); + raw.tile_count = xmlAttrInt(tileset_node, "tilecount"); + raw.columns = xmlAttrInt(tileset_node, "columns"); + raw.margin = xmlAttrInt(tileset_node, "margin"); + raw.spacing = xmlAttrInt(tileset_node, "spacing"); + + // Image element + auto* image_node = tileset_node->first_node("image"); + if (image_node) { + raw.image_source = xmlAttr(image_node, "source"); + raw.image_width = xmlAttrInt(image_node, "width"); + raw.image_height = xmlAttrInt(image_node, "height"); + } + + // Properties + parseXmlProperties(tileset_node, raw.properties); + + // Tile elements (for per-tile properties and animations) + for (auto* tile = tileset_node->first_node("tile"); tile; tile = tile->next_sibling("tile")) { + RawTile rt; + rt.id = xmlAttrInt(tile, "id"); + parseXmlProperties(tile, rt.properties); + + // Animation frames + auto* anim = tile->first_node("animation"); + if (anim) { + for (auto* frame = anim->first_node("frame"); frame; frame = frame->next_sibling("frame")) { + int tid = xmlAttrInt(frame, "tileid"); + int dur = xmlAttrInt(frame, "duration"); + rt.animation_frames.emplace_back(tid, dur); + } + } + raw.tiles.push_back(std::move(rt)); + } + + // Wang sets + auto* wangsets_node = tileset_node->first_node("wangsets"); + if (wangsets_node) { + for (auto* ws = wangsets_node->first_node("wangset"); ws; ws = ws->next_sibling("wangset")) { + RawWangSet rws; + rws.name = xmlAttr(ws, "name"); + rws.type = xmlAttr(ws, "type"); + + // Wang colors (1-indexed by position in list) + int color_idx = 1; + for (auto* wc = ws->first_node("wangcolor"); wc; wc = wc->next_sibling("wangcolor")) { + RawWangColor rwc; + rwc.name = xmlAttr(wc, "name"); + rwc.color_index = color_idx++; + rwc.tile_id = xmlAttrInt(wc, "tile"); + rwc.probability = xmlAttrFloat(wc, "probability", 1.0f); + rws.colors.push_back(std::move(rwc)); + } + + // Wang tiles + for (auto* wt = ws->first_node("wangtile"); wt; wt = wt->next_sibling("wangtile")) { + RawWangTile rwt; + rwt.tile_id = xmlAttrInt(wt, "tileid"); + // Parse wangid: comma-separated 8 integers + std::string wid_str = xmlAttr(wt, "wangid"); + std::array wid = {}; + std::istringstream iss(wid_str); + std::string token; + int idx = 0; + while (std::getline(iss, token, ',') && idx < 8) { + wid[idx++] = std::stoi(token); + } + rwt.wang_id = wid; + rws.tiles.push_back(std::move(rwt)); + } + + raw.wang_sets.push_back(std::move(rws)); + } + } + + return raw; +} + +// ============================================================ +// TSJ parser (JSON tileset) +// ============================================================ + +static void parseJsonProperties(const nlohmann::json& j, std::vector& out) { + if (!j.contains("properties") || !j["properties"].is_array()) return; + for (const auto& prop : j["properties"]) { + RawProperty rp; + rp.name = prop.value("name", ""); + rp.type = prop.value("type", ""); + // Value can be different JSON types + if (prop.contains("value")) { + const auto& val = prop["value"]; + if (val.is_boolean()) { + rp.type = "bool"; + rp.value = val.get() ? "true" : "false"; + } else if (val.is_number_integer()) { + rp.type = "int"; + rp.value = std::to_string(val.get()); + } else if (val.is_number_float()) { + rp.type = "float"; + rp.value = std::to_string(val.get()); + } else if (val.is_string()) { + rp.value = val.get(); + } + } + out.push_back(std::move(rp)); + } +} + +static RawTileSet parseTSJ(const std::string& path) { + std::string text = readFile(path); + nlohmann::json j = nlohmann::json::parse(text); + + RawTileSet raw; + raw.name = j.value("name", ""); + raw.tile_width = j.value("tilewidth", 0); + raw.tile_height = j.value("tileheight", 0); + raw.tile_count = j.value("tilecount", 0); + raw.columns = j.value("columns", 0); + raw.margin = j.value("margin", 0); + raw.spacing = j.value("spacing", 0); + raw.image_source = j.value("image", ""); + raw.image_width = j.value("imagewidth", 0); + raw.image_height = j.value("imageheight", 0); + + parseJsonProperties(j, raw.properties); + + // Tiles + if (j.contains("tiles") && j["tiles"].is_array()) { + for (const auto& tile : j["tiles"]) { + RawTile rt; + rt.id = tile.value("id", 0); + parseJsonProperties(tile, rt.properties); + if (tile.contains("animation") && tile["animation"].is_array()) { + for (const auto& frame : tile["animation"]) { + int tid = frame.value("tileid", 0); + int dur = frame.value("duration", 0); + rt.animation_frames.emplace_back(tid, dur); + } + } + raw.tiles.push_back(std::move(rt)); + } + } + + // Wang sets + if (j.contains("wangsets") && j["wangsets"].is_array()) { + for (const auto& ws : j["wangsets"]) { + RawWangSet rws; + rws.name = ws.value("name", ""); + rws.type = ws.value("type", ""); + + if (ws.contains("colors") && ws["colors"].is_array()) { + int ci = 1; // Tiled wang colors are 1-indexed + for (const auto& wc : ws["colors"]) { + RawWangColor rwc; + rwc.name = wc.value("name", ""); + rwc.color_index = ci++; + rwc.tile_id = wc.value("tile", -1); + rwc.probability = wc.value("probability", 1.0f); + rws.colors.push_back(std::move(rwc)); + } + } + + if (ws.contains("wangtiles") && ws["wangtiles"].is_array()) { + for (const auto& wt : ws["wangtiles"]) { + RawWangTile rwt; + rwt.tile_id = wt.value("tileid", 0); + std::array wid = {}; + if (wt.contains("wangid") && wt["wangid"].is_array()) { + for (int i = 0; i < 8 && i < (int)wt["wangid"].size(); i++) { + wid[i] = wt["wangid"][i].get(); + } + } + rwt.wang_id = wid; + rws.tiles.push_back(std::move(rwt)); + } + } + + raw.wang_sets.push_back(std::move(rws)); + } + } + + return raw; +} + +// ============================================================ +// Builder: RawTileSet → TileSetData +// ============================================================ + +static std::shared_ptr buildTileSet(const RawTileSet& raw, const std::string& source_path) { + auto ts = std::make_shared(); + ts->source_path = source_path; + ts->name = raw.name; + ts->tile_width = raw.tile_width; + ts->tile_height = raw.tile_height; + ts->tile_count = raw.tile_count; + ts->columns = raw.columns; + ts->margin = raw.margin; + ts->spacing = raw.spacing; + ts->image_width = raw.image_width; + ts->image_height = raw.image_height; + + // Resolve image path relative to tileset file + std::string base_dir = parentDir(source_path); + ts->image_source = resolvePath(base_dir, raw.image_source); + + // Convert properties + ts->properties = convertProperties(raw.properties); + + // Convert tile info + for (const auto& rt : raw.tiles) { + TileInfo ti; + ti.id = rt.id; + ti.properties = convertProperties(rt.properties); + for (const auto& [tid, dur] : rt.animation_frames) { + ti.animation.push_back({tid, dur}); + } + ts->tile_info[ti.id] = std::move(ti); + } + + // Convert wang sets + for (const auto& rws : raw.wang_sets) { + WangSet ws; + ws.name = rws.name; + if (rws.type == "corner") ws.type = WangSetType::Corner; + else if (rws.type == "edge") ws.type = WangSetType::Edge; + else ws.type = WangSetType::Mixed; + + for (const auto& rwc : rws.colors) { + WangColor wc; + wc.name = rwc.name; + wc.index = rwc.color_index; + wc.tile_id = rwc.tile_id; + wc.probability = rwc.probability; + ws.colors.push_back(std::move(wc)); + } + + // Build lookup table + for (const auto& rwt : rws.tiles) { + uint64_t key = WangSet::packWangId(rwt.wang_id); + ws.wang_lookup[key] = rwt.tile_id; + } + + ts->wang_sets.push_back(std::move(ws)); + } + + return ts; +} + +// ============================================================ +// TMX parser (XML tilemap) +// ============================================================ + +static RawTileMap parseTMX(const std::string& path) { + std::string text = readFile(path); + rapidxml::xml_document<> doc; + doc.parse<0>(text.data()); + + auto* map_node = doc.first_node("map"); + if (!map_node) { + throw std::runtime_error("No element in: " + path); + } + + RawTileMap raw; + raw.width = xmlAttrInt(map_node, "width"); + raw.height = xmlAttrInt(map_node, "height"); + raw.tile_width = xmlAttrInt(map_node, "tilewidth"); + raw.tile_height = xmlAttrInt(map_node, "tileheight"); + raw.orientation = xmlAttr(map_node, "orientation"); + + parseXmlProperties(map_node, raw.properties); + + // Tileset references + for (auto* ts = map_node->first_node("tileset"); ts; ts = ts->next_sibling("tileset")) { + RawTileSetRef ref; + ref.firstgid = xmlAttrInt(ts, "firstgid"); + ref.source = xmlAttr(ts, "source"); + raw.tileset_refs.push_back(std::move(ref)); + } + + // Layers + for (auto* child = map_node->first_node(); child; child = child->next_sibling()) { + std::string node_name(child->name(), child->name_size()); + + if (node_name == "layer") { + RawLayer layer; + layer.name = xmlAttr(child, "name"); + layer.type = "tilelayer"; + layer.width = xmlAttrInt(child, "width"); + layer.height = xmlAttrInt(child, "height"); + std::string vis = xmlAttr(child, "visible"); + layer.visible = vis.empty() || vis != "0"; + layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); + parseXmlProperties(child, layer.properties); + + // Parse CSV tile data + auto* data_node = child->first_node("data"); + if (data_node) { + std::string encoding = xmlAttr(data_node, "encoding"); + if (!encoding.empty() && encoding != "csv") { + throw std::runtime_error("Unsupported tile data encoding: " + encoding + + " (only CSV supported). File: " + path); + } + std::string csv(data_node->value(), data_node->value_size()); + std::istringstream iss(csv); + std::string token; + while (std::getline(iss, token, ',')) { + // Trim whitespace + auto start = token.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) continue; + auto end = token.find_last_not_of(" \t\r\n"); + token = token.substr(start, end - start + 1); + if (!token.empty()) { + layer.tile_data.push_back(static_cast(std::stoul(token))); + } + } + } + + raw.layers.push_back(std::move(layer)); + } + else if (node_name == "objectgroup") { + RawLayer layer; + layer.name = xmlAttr(child, "name"); + layer.type = "objectgroup"; + std::string vis = xmlAttr(child, "visible"); + layer.visible = vis.empty() || vis != "0"; + layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); + parseXmlProperties(child, layer.properties); + + // Convert XML objects to JSON for uniform Python interface + nlohmann::json objects_arr = nlohmann::json::array(); + for (auto* obj = child->first_node("object"); obj; obj = obj->next_sibling("object")) { + nlohmann::json obj_json; + std::string id_str = xmlAttr(obj, "id"); + if (!id_str.empty()) obj_json["id"] = std::stoi(id_str); + std::string name = xmlAttr(obj, "name"); + if (!name.empty()) obj_json["name"] = name; + std::string type = xmlAttr(obj, "type"); + if (!type.empty()) obj_json["type"] = type; + std::string x_str = xmlAttr(obj, "x"); + if (!x_str.empty()) obj_json["x"] = std::stof(x_str); + std::string y_str = xmlAttr(obj, "y"); + if (!y_str.empty()) obj_json["y"] = std::stof(y_str); + std::string w_str = xmlAttr(obj, "width"); + if (!w_str.empty()) obj_json["width"] = std::stof(w_str); + std::string h_str = xmlAttr(obj, "height"); + if (!h_str.empty()) obj_json["height"] = std::stof(h_str); + std::string rot_str = xmlAttr(obj, "rotation"); + if (!rot_str.empty()) obj_json["rotation"] = std::stof(rot_str); + std::string visible_str = xmlAttr(obj, "visible"); + if (!visible_str.empty()) obj_json["visible"] = (visible_str != "0"); + + // Object properties + std::vector obj_props; + parseXmlProperties(obj, obj_props); + if (!obj_props.empty()) { + nlohmann::json props_json; + for (const auto& rp : obj_props) { + if (rp.type == "bool") props_json[rp.name] = (rp.value == "true"); + else if (rp.type == "int") props_json[rp.name] = std::stoi(rp.value); + else if (rp.type == "float") props_json[rp.name] = std::stof(rp.value); + else props_json[rp.name] = rp.value; + } + obj_json["properties"] = props_json; + } + + // Check for point/ellipse/polygon sub-elements + if (obj->first_node("point")) { + obj_json["point"] = true; + } + if (obj->first_node("ellipse")) { + obj_json["ellipse"] = true; + } + auto* polygon_node = obj->first_node("polygon"); + if (polygon_node) { + std::string points_str = xmlAttr(polygon_node, "points"); + nlohmann::json points_arr = nlohmann::json::array(); + std::istringstream pss(points_str); + std::string pt; + while (pss >> pt) { + auto comma = pt.find(','); + if (comma != std::string::npos) { + nlohmann::json point; + point["x"] = std::stof(pt.substr(0, comma)); + point["y"] = std::stof(pt.substr(comma + 1)); + points_arr.push_back(point); + } + } + obj_json["polygon"] = points_arr; + } + + objects_arr.push_back(std::move(obj_json)); + } + layer.objects_json = objects_arr; + + raw.layers.push_back(std::move(layer)); + } + } + + return raw; +} + +// ============================================================ +// TMJ parser (JSON tilemap) +// ============================================================ + +static RawTileMap parseTMJ(const std::string& path) { + std::string text = readFile(path); + nlohmann::json j = nlohmann::json::parse(text); + + RawTileMap raw; + raw.width = j.value("width", 0); + raw.height = j.value("height", 0); + raw.tile_width = j.value("tilewidth", 0); + raw.tile_height = j.value("tileheight", 0); + raw.orientation = j.value("orientation", "orthogonal"); + + parseJsonProperties(j, raw.properties); + + // Tileset references + if (j.contains("tilesets") && j["tilesets"].is_array()) { + for (const auto& ts : j["tilesets"]) { + RawTileSetRef ref; + ref.firstgid = ts.value("firstgid", 0); + ref.source = ts.value("source", ""); + raw.tileset_refs.push_back(std::move(ref)); + } + } + + // Layers + if (j.contains("layers") && j["layers"].is_array()) { + for (const auto& layer_json : j["layers"]) { + RawLayer layer; + layer.name = layer_json.value("name", ""); + layer.type = layer_json.value("type", ""); + layer.width = layer_json.value("width", 0); + layer.height = layer_json.value("height", 0); + layer.visible = layer_json.value("visible", true); + layer.opacity = layer_json.value("opacity", 1.0f); + + parseJsonProperties(layer_json, layer.properties); + + if (layer.type == "tilelayer") { + if (layer_json.contains("data") && layer_json["data"].is_array()) { + for (const auto& val : layer_json["data"]) { + layer.tile_data.push_back(val.get()); + } + } + } + else if (layer.type == "objectgroup") { + if (layer_json.contains("objects")) { + layer.objects_json = layer_json["objects"]; + } + } + + raw.layers.push_back(std::move(layer)); + } + } + + return raw; +} + +// ============================================================ +// Builder: RawTileMap → TileMapData +// ============================================================ + +static std::shared_ptr buildTileMap(const RawTileMap& raw, const std::string& source_path) { + auto tm = std::make_shared(); + tm->source_path = source_path; + tm->width = raw.width; + tm->height = raw.height; + tm->tile_width = raw.tile_width; + tm->tile_height = raw.tile_height; + tm->orientation = raw.orientation; + tm->properties = convertProperties(raw.properties); + + // Load referenced tilesets + std::string base_dir = parentDir(source_path); + for (const auto& ref : raw.tileset_refs) { + TileMapData::TileSetRef ts_ref; + ts_ref.firstgid = ref.firstgid; + std::string ts_path = resolvePath(base_dir, ref.source); + ts_ref.tileset = loadTileSet(ts_path); + tm->tilesets.push_back(std::move(ts_ref)); + } + + // Separate tile layers from object layers + for (const auto& rl : raw.layers) { + if (rl.type == "tilelayer") { + TileLayerData tld; + tld.name = rl.name; + tld.width = rl.width; + tld.height = rl.height; + tld.visible = rl.visible; + tld.opacity = rl.opacity; + tld.global_gids = rl.tile_data; + tm->tile_layers.push_back(std::move(tld)); + } + else if (rl.type == "objectgroup") { + ObjectLayerData old; + old.name = rl.name; + old.visible = rl.visible; + old.opacity = rl.opacity; + old.objects = rl.objects_json; + old.properties = convertProperties(rl.properties); + tm->object_layers.push_back(std::move(old)); + } + } + + return tm; +} + +// ============================================================ +// Public API: auto-detect and load +// ============================================================ + +std::shared_ptr loadTileSet(const std::string& path) { + std::string abs_path = std::filesystem::absolute(path).string(); + RawTileSet raw; + if (endsWith(abs_path, ".tsx")) { + raw = parseTSX(abs_path); + } else if (endsWith(abs_path, ".tsj") || endsWith(abs_path, ".json")) { + raw = parseTSJ(abs_path); + } else { + throw std::runtime_error("Unknown tileset format (expected .tsx or .tsj): " + path); + } + return buildTileSet(raw, abs_path); +} + +std::shared_ptr loadTileMap(const std::string& path) { + std::string abs_path = std::filesystem::absolute(path).string(); + RawTileMap raw; + if (endsWith(abs_path, ".tmx")) { + raw = parseTMX(abs_path); + } else if (endsWith(abs_path, ".tmj") || endsWith(abs_path, ".json")) { + raw = parseTMJ(abs_path); + } else { + throw std::runtime_error("Unknown tilemap format (expected .tmx or .tmj): " + path); + } + return buildTileMap(raw, abs_path); +} + +// ============================================================ +// JSON → Python conversion (for object layers) +// ============================================================ + +PyObject* jsonToPython(const nlohmann::json& j) { + if (j.is_null()) { + Py_RETURN_NONE; + } + if (j.is_boolean()) { + return PyBool_FromLong(j.get()); + } + if (j.is_number_integer()) { + return PyLong_FromLongLong(j.get()); + } + if (j.is_number_float()) { + return PyFloat_FromDouble(j.get()); + } + if (j.is_string()) { + const std::string& s = j.get_ref(); + return PyUnicode_FromStringAndSize(s.c_str(), s.size()); + } + if (j.is_array()) { + PyObject* list = PyList_New(j.size()); + if (!list) return NULL; + for (size_t i = 0; i < j.size(); i++) { + PyObject* item = jsonToPython(j[i]); + if (!item) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, item); // steals ref + } + return list; + } + if (j.is_object()) { + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + for (auto it = j.begin(); it != j.end(); ++it) { + PyObject* val = jsonToPython(it.value()); + if (!val) { + Py_DECREF(dict); + return NULL; + } + if (PyDict_SetItemString(dict, it.key().c_str(), val) < 0) { + Py_DECREF(val); + Py_DECREF(dict); + return NULL; + } + Py_DECREF(val); + } + return dict; + } + Py_RETURN_NONE; +} + +// ============================================================ +// PropertyValue → Python conversion +// ============================================================ + +PyObject* propertyValueToPython(const PropertyValue& val) { + return std::visit([](auto&& arg) -> PyObject* { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return PyBool_FromLong(arg); + } else if constexpr (std::is_same_v) { + return PyLong_FromLong(arg); + } else if constexpr (std::is_same_v) { + return PyFloat_FromDouble(arg); + } else if constexpr (std::is_same_v) { + return PyUnicode_FromStringAndSize(arg.c_str(), arg.size()); + } + Py_RETURN_NONE; + }, val); +} + +PyObject* propertiesToPython(const std::unordered_map& props) { + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + for (const auto& [key, val] : props) { + PyObject* py_val = propertyValueToPython(val); + if (!py_val) { + Py_DECREF(dict); + return NULL; + } + if (PyDict_SetItemString(dict, key.c_str(), py_val) < 0) { + Py_DECREF(py_val); + Py_DECREF(dict); + return NULL; + } + Py_DECREF(py_val); + } + return dict; +} + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/TiledParse.h b/src/tiled/TiledParse.h new file mode 100644 index 0000000..02233f4 --- /dev/null +++ b/src/tiled/TiledParse.h @@ -0,0 +1,24 @@ +#pragma once +#include "TiledTypes.h" +#include + +namespace mcrf { +namespace tiled { + +// Load a tileset from .tsx or .tsj (auto-detect by extension) +std::shared_ptr loadTileSet(const std::string& path); + +// Load a tilemap from .tmx or .tmj (auto-detect by extension) +std::shared_ptr loadTileMap(const std::string& path); + +// Convert nlohmann::json to Python object (for object layers) +PyObject* jsonToPython(const nlohmann::json& j); + +// Convert PropertyValue to Python object +PyObject* propertyValueToPython(const PropertyValue& val); + +// Convert a properties map to Python dict +PyObject* propertiesToPython(const std::unordered_map& props); + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/TiledTypes.h b/src/tiled/TiledTypes.h new file mode 100644 index 0000000..0c30fbf --- /dev/null +++ b/src/tiled/TiledTypes.h @@ -0,0 +1,186 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mcrf { +namespace tiled { + +// ============================================================ +// Raw intermediate structs — populated by thin XML/JSON parsers +// ============================================================ + +struct RawProperty { + std::string name; + std::string type; // "bool", "int", "float", "string" (or empty = string) + std::string value; +}; + +struct RawTile { + int id; + std::vector properties; + std::vector> animation_frames; // (tile_id, duration_ms) +}; + +struct RawWangColor { + std::string name; + int color_index; + int tile_id; + float probability; +}; + +struct RawWangTile { + int tile_id; + std::array wang_id; +}; + +struct RawWangSet { + std::string name; + std::string type; // "corner", "edge", "mixed" + std::vector colors; + std::vector tiles; +}; + +struct RawTileSet { + std::string name; + std::string image_source; + int tile_width = 0; + int tile_height = 0; + int tile_count = 0; + int columns = 0; + int margin = 0; + int spacing = 0; + int image_width = 0; + int image_height = 0; + std::vector properties; + std::vector tiles; + std::vector wang_sets; +}; + +struct RawTileSetRef { + int firstgid; + std::string source; +}; + +struct RawLayer { + std::string name; + std::string type; // "tilelayer", "objectgroup" + int width = 0; + int height = 0; + bool visible = true; + float opacity = 1.0f; + std::vector properties; + std::vector tile_data; + nlohmann::json objects_json; +}; + +struct RawTileMap { + int width = 0; + int height = 0; + int tile_width = 0; + int tile_height = 0; + std::string orientation; // "orthogonal", etc. + std::vector properties; + std::vector tileset_refs; + std::vector layers; +}; + +// ============================================================ +// Final (built) types — what Python bindings expose +// ============================================================ + +using PropertyValue = std::variant; + +struct KeyFrame { + int tile_id; + int duration_ms; +}; + +struct TileInfo { + int id; + std::unordered_map properties; + std::vector animation; +}; + +enum class WangSetType { + Corner, + Edge, + Mixed +}; + +struct WangColor { + std::string name; + int index; + int tile_id; + float probability; +}; + +struct WangSet { + std::string name; + WangSetType type; + std::vector colors; + // Maps packed wang_id → tile_id for O(1) lookup + std::unordered_map wang_lookup; + + static uint64_t packWangId(const std::array& id); +}; + +struct TileSetData { + std::string name; + std::string source_path; // Filesystem path of the .tsx/.tsj file + std::string image_source; // Resolved path to image file + int tile_width = 0; + int tile_height = 0; + int tile_count = 0; + int columns = 0; + int margin = 0; + int spacing = 0; + int image_width = 0; + int image_height = 0; + std::unordered_map properties; + std::unordered_map tile_info; + std::vector wang_sets; +}; + +struct TileLayerData { + std::string name; + int width = 0; + int height = 0; + bool visible = true; + float opacity = 1.0f; + std::vector global_gids; +}; + +struct ObjectLayerData { + std::string name; + bool visible = true; + float opacity = 1.0f; + nlohmann::json objects; + std::unordered_map properties; +}; + +struct TileMapData { + std::string source_path; // Filesystem path of the .tmx/.tmj file + int width = 0; + int height = 0; + int tile_width = 0; + int tile_height = 0; + std::string orientation; + std::unordered_map properties; + + struct TileSetRef { + int firstgid; + std::shared_ptr tileset; + }; + std::vector tilesets; + std::vector tile_layers; + std::vector object_layers; +}; + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/WangResolve.cpp b/src/tiled/WangResolve.cpp new file mode 100644 index 0000000..5970502 --- /dev/null +++ b/src/tiled/WangResolve.cpp @@ -0,0 +1,142 @@ +#include "WangResolve.h" +#include + +namespace mcrf { +namespace tiled { + +// Helper: get terrain at (x, y), return 0 for out-of-bounds +static inline int getTerrain(const uint8_t* data, int w, int h, int x, int y) { + if (x < 0 || x >= w || y < 0 || y >= h) return 0; + return data[y * w + x]; +} + +// For corner wang sets: each corner is at the junction of 4 cells. +// The corner terrain is the max index among those cells (standard Tiled convention: +// higher-index terrain "wins" at shared corners). +static inline int cornerTerrain(int a, int b, int c, int d) { + int m = a; + if (b > m) m = b; + if (c > m) m = c; + if (d > m) m = d; + return m; +} + +std::vector resolveWangTerrain( + const uint8_t* terrain_data, int width, int height, + const WangSet& wang_set) +{ + std::vector result(width * height, -1); + + if (wang_set.type == WangSetType::Corner) { + // Corner set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] + // For corner sets, only even indices matter: [_, TR, _, BR, _, BL, _, TL] + // i.e. indices 1, 3, 5, 7 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + // Top-left corner: junction of (x-1,y-1), (x,y-1), (x-1,y), (x,y) + int tl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y-1), + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y)); + + // Top-right corner: junction of (x,y-1), (x+1,y-1), (x,y), (x+1,y) + int tr = cornerTerrain( + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x+1, y-1), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y)); + + // Bottom-right corner: junction of (x,y), (x+1,y), (x,y+1), (x+1,y+1) + int br = cornerTerrain( + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y), + getTerrain(terrain_data, width, height, x, y+1), + getTerrain(terrain_data, width, height, x+1, y+1)); + + // Bottom-left corner: junction of (x-1,y), (x,y), (x-1,y+1), (x,y+1) + int bl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x-1, y+1), + getTerrain(terrain_data, width, height, x, y+1)); + + // Pack: [0, TR, 0, BR, 0, BL, 0, TL] + std::array wid = {0, tr, 0, br, 0, bl, 0, tl}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + else if (wang_set.type == WangSetType::Edge) { + // Edge set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] + // For edge sets, only even indices matter: [top, _, right, _, bottom, _, left, _] + // i.e. indices 0, 2, 4, 6 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int top = getTerrain(terrain_data, width, height, x, y-1); + int right = getTerrain(terrain_data, width, height, x+1, y); + int bottom = getTerrain(terrain_data, width, height, x, y+1); + int left = getTerrain(terrain_data, width, height, x-1, y); + + // Pack: [top, 0, right, 0, bottom, 0, left, 0] + std::array wid = {top, 0, right, 0, bottom, 0, left, 0}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + else { + // Mixed: use all 8 values (both edges and corners) + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int top = getTerrain(terrain_data, width, height, x, y-1); + int right = getTerrain(terrain_data, width, height, x+1, y); + int bottom = getTerrain(terrain_data, width, height, x, y+1); + int left = getTerrain(terrain_data, width, height, x-1, y); + + int tl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y-1), + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y)); + int tr = cornerTerrain( + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x+1, y-1), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y)); + int br = cornerTerrain( + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y), + getTerrain(terrain_data, width, height, x, y+1), + getTerrain(terrain_data, width, height, x+1, y+1)); + int bl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x-1, y+1), + getTerrain(terrain_data, width, height, x, y+1)); + + std::array wid = {top, tr, right, br, bottom, bl, left, tl}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + + return result; +} + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/WangResolve.h b/src/tiled/WangResolve.h new file mode 100644 index 0000000..b2aad82 --- /dev/null +++ b/src/tiled/WangResolve.h @@ -0,0 +1,17 @@ +#pragma once +#include "TiledTypes.h" +#include +#include + +namespace mcrf { +namespace tiled { + +// Resolve terrain data (from DiscreteMap) to tile indices using a WangSet. +// Returns a vector of tile IDs (one per cell). -1 means no matching tile found. +// terrain_data: row-major uint8 array, width*height elements +std::vector resolveWangTerrain( + const uint8_t* terrain_data, int width, int height, + const WangSet& wang_set); + +} // namespace tiled +} // namespace mcrf diff --git a/tests/demo/screens/tiled_analysis.py b/tests/demo/screens/tiled_analysis.py new file mode 100644 index 0000000..3bc9e09 --- /dev/null +++ b/tests/demo/screens/tiled_analysis.py @@ -0,0 +1,167 @@ +# tiled_analysis.py - Wang set adjacency analysis utility +# Prints adjacency graph, terrain chains, and valid/invalid pair counts +# for exploring tileset Wang transition rules. +# +# Usage: +# cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_analysis.py + +import mcrfpy +import sys +from collections import defaultdict + +# -- Configuration -------------------------------------------------------- +PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" +TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" +WANG_SET_NAME = "overworld" + + +def analyze_wang_set(tileset, wang_set_name): + """Analyze a Wang set and print adjacency information.""" + ws = tileset.wang_set(wang_set_name) + T = ws.terrain_enum() + + print("=" * 60) + print(f"Wang Set Analysis: {ws.name}") + print(f" Type: {ws.type}") + print(f" Colors: {ws.color_count}") + print("=" * 60) + + # List all terrains + terrains = [t for t in T if t != T.NONE] + print(f"\nTerrains ({len(terrains)}):") + for t in terrains: + print(f" {t.value:3d}: {t.name}") + + # Test all pairs for valid Wang transitions using 2x2 grids + adjacency = defaultdict(set) # terrain -> set of valid neighbors + valid_pairs = [] + invalid_pairs = [] + + for a in terrains: + for b in terrains: + if b.value <= a.value: + continue + # Create a 2x2 map: columns of A and B + dm = mcrfpy.DiscreteMap((2, 2)) + dm.set(0, 0, a) + dm.set(1, 0, b) + dm.set(0, 1, a) + dm.set(1, 1, b) + results = ws.resolve(dm) + has_invalid = any(r == -1 for r in results) + if not has_invalid: + valid_pairs.append((a, b)) + adjacency[a.name].add(b.name) + adjacency[b.name].add(a.name) + else: + invalid_pairs.append((a, b)) + + # Print adjacency graph + print(f"\nAdjacency Graph ({len(valid_pairs)} valid pairs):") + print("-" * 40) + for t in terrains: + neighbors = sorted(adjacency.get(t.name, set())) + if neighbors: + print(f" {t.name}") + for n in neighbors: + print(f" <-> {n}") + else: + print(f" {t.name} (ISOLATED - no valid neighbors)") + + # Find terrain chains (connected components) + print(f"\nTerrain Chains (connected components):") + print("-" * 40) + visited = set() + chains = [] + + def bfs(start): + chain = [] + queue = [start] + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + chain.append(node) + for neighbor in sorted(adjacency.get(node, set())): + if neighbor not in visited: + queue.append(neighbor) + return chain + + for t in terrains: + if t.name not in visited: + chain = bfs(t.name) + if chain: + chains.append(chain) + + for i, chain in enumerate(chains): + print(f"\n Chain {i+1}: {len(chain)} terrains") + for name in chain: + neighbors = sorted(adjacency.get(name, set())) + connections = ", ".join(neighbors) if neighbors else "(none)" + print(f" {name} -> [{connections}]") + + # Find linear paths within chains + print(f"\nLinear Paths (degree-1 endpoints to degree-1 endpoints):") + print("-" * 40) + for chain in chains: + # Find nodes with degree 1 (endpoints) or degree > 2 (hubs) + endpoints = [n for n in chain if len(adjacency.get(n, set())) == 1] + hubs = [n for n in chain if len(adjacency.get(n, set())) > 2] + + if endpoints: + print(f" Endpoints: {', '.join(endpoints)}") + if hubs: + print(f" Hubs: {', '.join(hubs)} (branch points)") + + # Trace from each endpoint + for ep in endpoints: + path = [ep] + current = ep + prev = None + while True: + neighbors = adjacency.get(current, set()) - {prev} if prev else adjacency.get(current, set()) + if len(neighbors) == 0: + break + if len(neighbors) > 1: + path.append(f"({current} branches)") + break + nxt = list(neighbors)[0] + path.append(nxt) + prev = current + current = nxt + if len(adjacency.get(current, set())) != 2: + break # reached endpoint or hub + print(f" Path: {' -> '.join(path)}") + + # Summary statistics + total_possible = len(terrains) * (len(terrains) - 1) // 2 + print(f"\nSummary:") + print(f" Total terrain types: {len(terrains)}") + print(f" Valid transitions: {len(valid_pairs)} / {total_possible} " + f"({100*len(valid_pairs)/total_possible:.1f}%)") + print(f" Invalid transitions: {len(invalid_pairs)}") + print(f" Connected components: {len(chains)}") + + # Print invalid pairs for reference + if invalid_pairs: + print(f"\nInvalid Pairs ({len(invalid_pairs)}):") + for a, b in invalid_pairs: + print(f" {a.name} X {b.name}") + + return valid_pairs, invalid_pairs, chains + + +def main(): + print("Loading tileset...") + tileset = mcrfpy.TileSetFile(TSX_PATH) + print(f" {tileset.name}: {tileset.tile_count} tiles " + f"({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") + + analyze_wang_set(tileset, WANG_SET_NAME) + print("\nDone!") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/demo/screens/tiled_demo.py b/tests/demo/screens/tiled_demo.py new file mode 100644 index 0000000..e59eb48 --- /dev/null +++ b/tests/demo/screens/tiled_demo.py @@ -0,0 +1,504 @@ +# tiled_demo.py - Visual demo of Tiled integration +# Shows premade maps, Wang auto-tiling, and procgen terrain +# +# Usage: +# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_demo.py +# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/tiled_demo.py + +import mcrfpy +from mcrfpy import automation +import sys + +# -- Asset Paths ------------------------------------------------------- +PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" +TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" + +# -- Load Shared Assets ------------------------------------------------ +print("Loading Puny World tileset...") +tileset = mcrfpy.TileSetFile(TSX_PATH) +texture = tileset.to_texture() +overworld_ws = tileset.wang_set("overworld") +Terrain = overworld_ws.terrain_enum() + +print(f" Tileset: {tileset.name}") +print(f" Tiles: {tileset.tile_count} ({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") +print(f" Wang set: {overworld_ws.name} ({overworld_ws.type}, {overworld_ws.color_count} colors)") +print(f" Terrain enum members: {[t.name for t in Terrain]}") + +# -- Helper: Iterative terrain expansion ---------------------------------- +def iterative_terrain(hm, wang_set, width, height, passes): + """Build a DiscreteMap by iteratively splitting terrains outward from + a valid binary map. Each pass splits one terrain on each end of the + chain, validates with wang_set.resolve(), and reverts invalid cells + to their previous value. + + hm: HeightMap (normalized 0-1) + passes: list of (threshold, lo_old, lo_new, hi_old, hi_new) tuples. + Each pass says: cells currently == lo_old with height < threshold + become lo_new; cells currently == hi_old with height >= threshold + become hi_new. + + Returns (DiscreteMap, stats_dict). + """ + dm = mcrfpy.DiscreteMap((width, height)) + + # Pass 0: binary split - everything is one of two terrains + p0 = passes[0] + thresh, lo_terrain, hi_terrain = p0 + for y in range(height): + for x in range(width): + if hm.get(x, y) < thresh: + dm.set(x, y, int(lo_terrain)) + else: + dm.set(x, y, int(hi_terrain)) + + # Validate pass 0 and fix any invalid cells (rare edge cases like + # checkerboard patterns at the binary boundary) + results = wang_set.resolve(dm) + inv = sum(1 for r in results if r == -1) + if inv > 0: + # Fix by flipping invalid cells to the other terrain + for y in range(height): + for x in range(width): + if results[y * width + x] == -1: + val = dm.get(x, y) + if val == int(lo_terrain): + dm.set(x, y, int(hi_terrain)) + else: + dm.set(x, y, int(lo_terrain)) + results2 = wang_set.resolve(dm) + inv = sum(1 for r in results2 if r == -1) + stats = {"pass0_invalid": inv} + + # Subsequent passes: split outward + for pi, (thresh, lo_old, lo_new, hi_old, hi_new) in enumerate(passes[1:], 1): + # Save current state so we can revert invalid cells + prev = [dm.get(x, y) for y in range(height) for x in range(width)] + + # Track which cells were changed this pass + changed = set() + for y in range(height): + for x in range(width): + val = dm.get(x, y) + h = hm.get(x, y) + if val == int(lo_old) and h < thresh: + dm.set(x, y, int(lo_new)) + changed.add((x, y)) + elif val == int(hi_old) and h >= thresh: + dm.set(x, y, int(hi_new)) + changed.add((x, y)) + + # Iteratively revert changed cells that cause invalid tiles. + # A changed cell should be reverted if: + # - It is itself invalid, OR + # - It is a neighbor of an invalid UN-changed cell (it broke + # a pre-existing valid cell by being placed next to it) + dirs8 = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)] + total_reverted = 0 + for revert_round in range(30): + results = wang_set.resolve(dm) + to_revert = set() + for y in range(height): + for x in range(width): + if results[y * width + x] != -1: + continue + if (x, y) in changed: + # This changed cell is invalid - revert it + to_revert.add((x, y)) + else: + # Pre-existing cell is now invalid - revert its + # changed neighbors to restore it + for dx, dy in dirs8: + nx, ny = x+dx, y+dy + if (nx, ny) in changed: + to_revert.add((nx, ny)) + + if not to_revert: + break + + for (x, y) in to_revert: + dm.set(x, y, prev[y * width + x]) + changed.discard((x, y)) + total_reverted += 1 + + results_final = wang_set.resolve(dm) + remaining = sum(1 for r in results_final if r == -1) + stats[f"pass{pi}_kept"] = len(changed) + stats[f"pass{pi}_reverted"] = total_reverted + stats[f"pass{pi}_remaining"] = remaining + + return dm, stats + + +# -- Helper: Info Panel ------------------------------------------------- +def make_info_panel(scene, lines, x=560, y=60, w=220, h=None): + """Create a semi-transparent info panel with text lines.""" + if h is None: + h = len(lines) * 22 + 20 + panel = mcrfpy.Frame(pos=(x, y), size=(w, h), + fill_color=mcrfpy.Color(20, 20, 30, 220), + outline_color=mcrfpy.Color(80, 80, 120), + outline=1.5) + scene.children.append(panel) + for i, text in enumerate(lines): + cap = mcrfpy.Caption(text=text, pos=(10, 10 + i * 22)) + cap.fill_color = mcrfpy.Color(200, 200, 220) + panel.children.append(cap) + return panel + + +# ====================================================================== +# SCREEN 1: Premade Tiled Map +# ====================================================================== +print("\nSetting up Screen 1: Premade Map...") +scene1 = mcrfpy.Scene("tiled_premade") + +bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene1.children.append(bg1) + +title1 = mcrfpy.Caption(text="Premade Tiled Map (50x50, 3 layers)", pos=(20, 10)) +title1.fill_color = mcrfpy.Color(255, 255, 255) +scene1.children.append(title1) + +# Load samplemap1 +tm1 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap1.tmj") +print(f" Map: {tm1.width}x{tm1.height}, layers: {tm1.tile_layer_names}") + +grid1 = mcrfpy.Grid(grid_size=(tm1.width, tm1.height), + pos=(20, 50), size=(520, 520), layers=[]) +grid1.fill_color = mcrfpy.Color(30, 30, 50) + +# Add a tile layer for each map layer, bottom-up z ordering +layer_names_1 = tm1.tile_layer_names +for i, name in enumerate(layer_names_1): + z = -(len(layer_names_1) - i) + layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) + grid1.add_layer(layer) + tm1.apply_to_tile_layer(layer, name, tileset_index=0) + print(f" Applied layer '{name}' (z_index={z})") + +# Center camera on map center (pixels = tiles * tile_size) +grid1.center = (tm1.width * tileset.tile_width // 2, + tm1.height * tileset.tile_height // 2) +scene1.children.append(grid1) + +make_info_panel(scene1, [ + f"Tileset: {tileset.name}", + f"Tile size: {tileset.tile_width}x{tileset.tile_height}", + f"Tile count: {tileset.tile_count}", + f"Map size: {tm1.width}x{tm1.height}", + "", + "Layers:", +] + [f" {name}" for name in layer_names_1] + [ + "", + "Wang sets:", + f" {overworld_ws.name} ({overworld_ws.type})", + f" pathways (edge)", +]) + +nav1 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav1.fill_color = mcrfpy.Color(120, 120, 150) +scene1.children.append(nav1) + + +# ====================================================================== +# SCREEN 2: Procedural Wang Auto-Tile (2-layer approach) +# ====================================================================== +print("\nSetting up Screen 2: Procgen Wang Terrain (2-layer)...") +scene2 = mcrfpy.Scene("tiled_procgen") + +bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene2.children.append(bg2) + +title2 = mcrfpy.Caption(text="Procgen Wang Auto-Tile (60x60, 2 layers)", pos=(20, 10)) +title2.fill_color = mcrfpy.Color(255, 255, 255) +scene2.children.append(title2) + +W, H = 60, 60 +T = Terrain # shorthand + +# Generate terrain heightmap using NoiseSource +noise = mcrfpy.NoiseSource(dimensions=2, seed=42) +hm = noise.sample(size=(W, H), mode="fbm", octaves=4, world_size=(4.0, 4.0)) +hm.normalize(0.0, 1.0) + +# -- Base terrain: iterative expansion from binary map -- +# Pass 0: binary split at median -> SEAWATER_LIGHT / SAND +# Pass 1: split outward -> SEAWATER_MEDIUM from LIGHT, GRASS from SAND +# Pass 2: split outward -> SEAWATER_DEEP from MEDIUM, CLIFF from GRASS +base_passes = [ + # Pass 0: (threshold, lo_terrain, hi_terrain) + (0.45, T.SEAWATER_LIGHT, T.SAND), + # Pass 1+: (threshold, lo_old, lo_new, hi_old, hi_new) + (0.30, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), + (0.20, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), +] +base_dm, base_stats = iterative_terrain(hm, overworld_ws, W, H, base_passes) +base_dm.enum_type = T +print(f" Base terrain stats: {base_stats}") + +# -- Tree overlay: separate noise, binary TREES/AIR -- +tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=999) +tree_hm = tree_noise.sample(size=(W, H), mode="fbm", octaves=3, world_size=(6.0, 6.0)) +tree_hm.normalize(0.0, 1.0) + +overlay_dm = mcrfpy.DiscreteMap((W, H)) +overlay_dm.enum_type = T +for y in range(H): + for x in range(W): + base_val = base_dm.get(x, y) + tree_h = tree_hm.get(x, y) + # Trees only on GRASS, driven by separate noise + if base_val == int(T.GRASS) and tree_h > 0.45: + overlay_dm.set(x, y, int(T.TREES)) + else: + overlay_dm.set(x, y, int(T.AIR)) + +# Validate overlay and revert invalid to AIR +overlay_results = overworld_ws.resolve(overlay_dm) +overlay_reverted = 0 +for y in range(H): + for x in range(W): + if overlay_results[y * W + x] == -1: + overlay_dm.set(x, y, int(T.AIR)) + overlay_reverted += 1 +print(f" Overlay: {overlay_reverted} tree cells reverted to AIR") + +# Count terrain distribution +terrain_counts = {} +for t in T: + if t == T.NONE: + continue + c = base_dm.count(int(t)) + if c > 0: + terrain_counts[t.name] = c +tree_count = overlay_dm.count(int(T.TREES)) +terrain_counts["TREES(overlay)"] = tree_count + +print(f" Terrain distribution: {terrain_counts}") + +# Create grid with 2 layers and apply Wang auto-tiling +grid2 = mcrfpy.Grid(grid_size=(W, H), pos=(20, 50), size=(520, 520), layers=[]) +grid2.fill_color = mcrfpy.Color(30, 30, 50) + +base_layer2 = mcrfpy.TileLayer(name="base", z_index=-2, texture=texture) +grid2.add_layer(base_layer2) +overworld_ws.apply(base_dm, base_layer2) + +overlay_layer2 = mcrfpy.TileLayer(name="trees", z_index=-1, texture=texture) +grid2.add_layer(overlay_layer2) +overworld_ws.apply(overlay_dm, overlay_layer2) + +# Post-process overlay: AIR resolves to an opaque tile, set to -1 (transparent) +for y in range(H): + for x in range(W): + if overlay_dm.get(x, y) == int(T.AIR): + overlay_layer2.set((x, y), -1) + +grid2.center = (W * tileset.tile_width // 2, H * tileset.tile_height // 2) +scene2.children.append(grid2) + +# Info panel +info_lines = [ + "Iterative terrain expansion", + f"Seed: 42 (base), 999 (trees)", + f"Grid: {W}x{H}, 2 layers", + "", + "Base (3 passes):", +] +for name in ["SEAWATER_DEEP", "SEAWATER_MEDIUM", "SEAWATER_LIGHT", + "SAND", "GRASS", "CLIFF"]: + count = terrain_counts.get(name, 0) + info_lines.append(f" {name}: {count}") +info_lines.append("") +info_lines.append("Tree Overlay:") +info_lines.append(f" TREES: {tree_count}") +info_lines.append(f" reverted: {overlay_reverted}") + +make_info_panel(scene2, info_lines) + +nav2 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav2.fill_color = mcrfpy.Color(120, 120, 150) +scene2.children.append(nav2) + + +# ====================================================================== +# SCREEN 3: Side-by-Side Comparison +# ====================================================================== +print("\nSetting up Screen 3: Side-by-Side...") +scene3 = mcrfpy.Scene("tiled_compare") + +bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene3.children.append(bg3) + +title3 = mcrfpy.Caption(text="Premade vs Procedural", pos=(20, 10)) +title3.fill_color = mcrfpy.Color(255, 255, 255) +scene3.children.append(title3) + +# Left: Premade map (samplemap2, 30x30) +tm2 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap2.tmj") +print(f" Map2: {tm2.width}x{tm2.height}, layers: {tm2.tile_layer_names}") + +left_label = mcrfpy.Caption(text="Premade (samplemap2)", pos=(20, 38)) +left_label.fill_color = mcrfpy.Color(180, 220, 255) +scene3.children.append(left_label) + +grid_left = mcrfpy.Grid(grid_size=(tm2.width, tm2.height), + pos=(20, 60), size=(380, 380), layers=[]) +grid_left.fill_color = mcrfpy.Color(30, 30, 50) + +for i, name in enumerate(tm2.tile_layer_names): + z = -(len(tm2.tile_layer_names) - i) + layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) + grid_left.add_layer(layer) + tm2.apply_to_tile_layer(layer, name, tileset_index=0) + +grid_left.center = (tm2.width * tileset.tile_width // 2, + tm2.height * tileset.tile_height // 2) +scene3.children.append(grid_left) + +# Right: Procgen island +right_label = mcrfpy.Caption(text="Procgen Island (2-layer Wang)", pos=(420, 38)) +right_label.fill_color = mcrfpy.Color(180, 255, 220) +scene3.children.append(right_label) + +IW, IH = 30, 30 +island_noise = mcrfpy.NoiseSource(dimensions=2, seed=7777) +island_hm = island_noise.sample(size=(IW, IH), mode="fbm", octaves=3, world_size=(3.0, 3.0)) +island_hm.normalize(0.0, 1.0) + +# Create island shape: attenuate edges with radial gradient +for y in range(IH): + for x in range(IW): + dx = (x - IW / 2.0) / (IW / 2.0) + dy = (y - IH / 2.0) / (IH / 2.0) + dist = (dx * dx + dy * dy) ** 0.5 + falloff = max(0.0, 1.0 - dist * 1.2) + h = island_hm.get(x, y) * falloff + island_hm[x, y] = h + +island_hm.normalize(0.0, 1.0) + +# Iterative base terrain expansion (same technique as Screen 2) +island_passes_def = [ + (0.40, T.SEAWATER_LIGHT, T.SAND), + (0.25, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), + (0.15, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), +] +island_base_dm, island_stats = iterative_terrain( + island_hm, overworld_ws, IW, IH, island_passes_def) +island_base_dm.enum_type = T +print(f" Island base stats: {island_stats}") + +# Tree overlay with separate noise +island_tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=8888) +island_tree_hm = island_tree_noise.sample( + size=(IW, IH), mode="fbm", octaves=3, world_size=(4.0, 4.0)) +island_tree_hm.normalize(0.0, 1.0) + +island_overlay_dm = mcrfpy.DiscreteMap((IW, IH)) +island_overlay_dm.enum_type = T +for y in range(IH): + for x in range(IW): + base_val = island_base_dm.get(x, y) + tree_h = island_tree_hm.get(x, y) + if base_val == int(T.GRASS) and tree_h > 0.50: + island_overlay_dm.set(x, y, int(T.TREES)) + else: + island_overlay_dm.set(x, y, int(T.AIR)) + +# Validate overlay +island_ov_results = overworld_ws.resolve(island_overlay_dm) +for y in range(IH): + for x in range(IW): + if island_ov_results[y * IW + x] == -1: + island_overlay_dm.set(x, y, int(T.AIR)) + +grid_right = mcrfpy.Grid(grid_size=(IW, IH), + pos=(420, 60), size=(380, 380), layers=[]) +grid_right.fill_color = mcrfpy.Color(30, 30, 50) + +island_base_layer = mcrfpy.TileLayer(name="island_base", z_index=-2, texture=texture) +grid_right.add_layer(island_base_layer) +overworld_ws.apply(island_base_dm, island_base_layer) + +island_overlay_layer = mcrfpy.TileLayer(name="island_trees", z_index=-1, texture=texture) +grid_right.add_layer(island_overlay_layer) +overworld_ws.apply(island_overlay_dm, island_overlay_layer) + +# Post-process: make AIR cells transparent +for y in range(IH): + for x in range(IW): + if island_overlay_dm.get(x, y) == int(T.AIR): + island_overlay_layer.set((x, y), -1) + +grid_right.center = (IW * tileset.tile_width // 2, IH * tileset.tile_height // 2) +scene3.children.append(grid_right) + +# Info for both +make_info_panel(scene3, [ + "Left: Premade Map", + f" samplemap2.tmj", + f" {tm2.width}x{tm2.height}, {len(tm2.tile_layer_names)} layers", + "", + "Right: Procgen Island", + f" {IW}x{IH}, seed=7777", + " Iterative terrain expansion", + " 2-layer Wang auto-tile", + "", + "Same tileset, same engine", + "Different workflows", +], x=200, y=460, w=400, h=None) + +nav3 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav3.fill_color = mcrfpy.Color(120, 120, 150) +scene3.children.append(nav3) + + +# ====================================================================== +# Navigation & Screenshots +# ====================================================================== +scenes = [scene1, scene2, scene3] +scene_names = ["premade", "procgen", "compare"] + +# Keyboard navigation (all scenes share the same handler) +def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if key == mcrfpy.Key.NUM_1: + mcrfpy.current_scene = scene1 + elif key == mcrfpy.Key.NUM_2: + mcrfpy.current_scene = scene2 + elif key == mcrfpy.Key.NUM_3: + mcrfpy.current_scene = scene3 + elif key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + +for s in scenes: + s.on_key = on_key + +# Detect headless mode and take screenshots synchronously +is_headless = False +try: + win = mcrfpy.Window.get() + is_headless = "headless" in str(win).lower() +except: + is_headless = True + +if is_headless: + # Headless: use step() to advance simulation and take screenshots directly + for i, (sc, name) in enumerate(zip(scenes, scene_names)): + mcrfpy.current_scene = sc + # Step a few frames to let the scene render + for _ in range(3): + mcrfpy.step(0.016) + fname = f"tiled_demo_{name}.png" + automation.screenshot(fname) + print(f" Screenshot: {fname}") + print("\nAll screenshots captured. Done!") + sys.exit(0) +else: + # Interactive: start on screen 1 + mcrfpy.current_scene = scene1 + print("\nTiled Demo ready!") + print("Press [1] [2] [3] to switch screens, [ESC] to quit") diff --git a/tests/unit/wang_resolve_test.py b/tests/unit/wang_resolve_test.py new file mode 100644 index 0000000..21107a3 --- /dev/null +++ b/tests/unit/wang_resolve_test.py @@ -0,0 +1,153 @@ +"""Unit tests for WangSet terrain_enum, resolve, and apply""" +import mcrfpy +import sys + +PASS_COUNT = 0 +FAIL_COUNT = 0 + +def check(condition, msg): + global PASS_COUNT, FAIL_COUNT + if condition: + PASS_COUNT += 1 + print(f" PASS: {msg}") + else: + FAIL_COUNT += 1 + print(f" FAIL: {msg}") + +def test_terrain_enum(): + """Test IntEnum generation from WangSet colors""" + print("=== Terrain Enum ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + Terrain = ws.terrain_enum() + + check(Terrain is not None, "terrain_enum() returns something") + check(hasattr(Terrain, "NONE"), "has NONE member") + check(hasattr(Terrain, "GRASS"), "has GRASS member") + check(hasattr(Terrain, "DIRT"), "has DIRT member") + check(int(Terrain.NONE) == 0, f"NONE = {int(Terrain.NONE)}") + check(int(Terrain.GRASS) == 1, f"GRASS = {int(Terrain.GRASS)}") + check(int(Terrain.DIRT) == 2, f"DIRT = {int(Terrain.DIRT)}") + + # Check it's an IntEnum + import enum + check(issubclass(Terrain, enum.IntEnum), "is IntEnum subclass") + return Terrain + +def test_enum_with_discrete_map(Terrain): + """Test that terrain enum is compatible with DiscreteMap""" + print("\n=== Enum + DiscreteMap ===") + dm = mcrfpy.DiscreteMap((4, 4)) + dm.enum_type = Terrain + check(dm.enum_type == Terrain, "DiscreteMap accepts terrain enum") + + # Set values using enum + dm.set(0, 0, Terrain.GRASS) + dm.set(1, 0, Terrain.DIRT) + val = dm.get(0, 0) + check(int(val) == int(Terrain.GRASS), f"get(0,0) = {val}") + val = dm.get(1, 0) + check(int(val) == int(Terrain.DIRT), f"get(1,0) = {val}") + +def test_resolve_uniform(): + """Test resolve with uniform terrain""" + print("\n=== Resolve Uniform ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # All grass (terrain ID 1) + dm = mcrfpy.DiscreteMap((3, 3)) + dm.fill(1) # All grass + + tiles = ws.resolve(dm) + check(isinstance(tiles, list), f"resolve returns list: {type(tiles)}") + check(len(tiles) == 9, f"resolve length = {len(tiles)}") + + # All cells should map to the "all grass corners" tile (id=0) + # wangid [0,1,0,1,0,1,0,1] = tile 0 + # Note: border cells will see 0 (NONE) on their outer edges, so may not match + # Center cell (1,1) sees all grass neighbors -> should be tile 0 + center = tiles[4] # (1,1) in 3x3 + check(center == 0, f"center tile (uniform grass) = {center}") + +def test_resolve_mixed(): + """Test resolve with mixed terrain""" + print("\n=== Resolve Mixed ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # Create a 3x3 grid: grass everywhere except center = dirt + dm = mcrfpy.DiscreteMap((3, 3)) + dm.fill(1) # All grass + dm.set(1, 1, 2) # Center = dirt + + tiles = ws.resolve(dm) + check(len(tiles) == 9, f"resolve length = {len(tiles)}") + + # The center cell has grass neighbors and is dirt itself + # Corners depend on the max of surrounding cells + center = tiles[4] + # Center: all 4 corners should be max(dirt, grass neighbors) = 2 (dirt) + # wangid [0,2,0,2,0,2,0,2] = tile 1 (all-dirt) + check(center == 1, f"center (dirt surrounded by grass) = {center}") + +def test_resolve_returns_negative_for_unknown(): + """Test that unknown wangid combinations return -1""" + print("\n=== Unknown WangID ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # Use terrain ID 3 which doesn't exist in the wang set + dm = mcrfpy.DiscreteMap((2, 2)) + dm.fill(3) # Terrain 3 not in wang set + + tiles = ws.resolve(dm) + # All should be -1 since terrain 3 has no matching wangid + all_neg = all(t == -1 for t in tiles) + check(all_neg, f"all tiles = -1 for unknown terrain: {tiles}") + +def test_resolve_border_handling(): + """Test that border cells handle out-of-bounds correctly""" + print("\n=== Border Handling ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # 1x1 grid - all neighbors are out-of-bounds (0) + dm = mcrfpy.DiscreteMap((1, 1)) + dm.set(0, 0, 1) # Single grass cell + + tiles = ws.resolve(dm) + check(len(tiles) == 1, f"1x1 resolve length = {len(tiles)}") + # Corner terrain: max(0, 0, 0, grass) = 1 for each corner -> all grass + # wangid [0,1,0,1,0,1,0,1] = tile 0 + check(tiles[0] == 0, f"1x1 grass tile = {tiles[0]}") + +def test_wang_set_repr(): + """Test WangSet repr""" + print("\n=== WangSet Repr ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + r = repr(ws) + check("WangSet" in r, f"repr contains 'WangSet': {r}") + check("terrain" in r, f"repr contains name: {r}") + check("corner" in r, f"repr contains type: {r}") + +def main(): + Terrain = test_terrain_enum() + test_enum_with_discrete_map(Terrain) + test_resolve_uniform() + test_resolve_mixed() + test_resolve_returns_negative_for_unknown() + test_resolve_border_handling() + test_wang_set_repr() + + print(f"\n{'='*40}") + print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") + if FAIL_COUNT > 0: + sys.exit(1) + else: + print("ALL TESTS PASSED") + sys.exit(0) + +if __name__ == "__main__": + main() From 322beeaf785418a3301516fe1e38f1ef952c7b94 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 6 Feb 2026 21:43:52 -0500 Subject: [PATCH 3/3] add __ne__ support to enum types for input --- src/McRFPy_API.cpp | 16 ++++++++++++++++ src/PyInputState.cpp | 8 ++++++++ src/PyKey.cpp | 8 ++++++++ src/PyMouseButton.cpp | 8 ++++++++ tests/cookbook/cookbook_main.py | 12 ++++++------ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index ec4ffe1..d9e266c 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -37,6 +37,9 @@ #include "3d/Model3D.h" // 3D model resource #include "3d/Billboard.h" // Billboard sprites #include "3d/PyVoxelGrid.h" // Voxel grid for 3D structures (Milestone 9) +#include "tiled/PyTileSetFile.h" // Tiled tileset loading +#include "tiled/PyTileMapFile.h" // Tiled tilemap loading +#include "tiled/PyWangSet.h" // Wang auto-tile sets #include "McRogueFaceVersion.h" #include "GameEngine.h" // ImGui is only available for SFML builds @@ -486,6 +489,11 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyPropertyBindingType, &mcrfpydef::PyCallableBindingType, + /*tiled map/tileset loading*/ + &mcrfpydef::PyTileSetFileType, + &mcrfpydef::PyTileMapFileType, + &mcrfpydef::PyWangSetType, + nullptr}; // Types that are used internally but NOT exported to module namespace (#189) @@ -559,6 +567,14 @@ PyObject* PyInit_mcrfpy() // Set up PyUniformCollectionType methods (#106) mcrfpydef::PyUniformCollectionType.tp_methods = ::PyUniformCollectionType::methods; + // Set up Tiled types methods and getsetters + mcrfpydef::PyTileSetFileType.tp_methods = PyTileSetFile::methods; + mcrfpydef::PyTileSetFileType.tp_getset = PyTileSetFile::getsetters; + mcrfpydef::PyTileMapFileType.tp_methods = PyTileMapFile::methods; + mcrfpydef::PyTileMapFileType.tp_getset = PyTileMapFile::getsetters; + mcrfpydef::PyWangSetType.tp_methods = PyWangSet::methods; + mcrfpydef::PyWangSetType.tp_getset = PyWangSet::getsetters; + // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/PyInputState.cpp b/src/PyInputState.cpp index ea8fe55..ea7392f 100644 --- a/src/PyInputState.cpp +++ b/src/PyInputState.cpp @@ -69,6 +69,14 @@ def _InputState_eq(self, other): return int.__eq__(int(self), other) InputState.__eq__ = _InputState_eq + +def _InputState_ne(self, other): + result = type(self).__eq__(self, other) + if result is NotImplemented: + return result + return not result + +InputState.__ne__ = _InputState_ne InputState.__hash__ = lambda self: hash(int(self)) InputState.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" InputState.__str__ = lambda self: self.name diff --git a/src/PyKey.cpp b/src/PyKey.cpp index 8cdefb3..54ea5ea 100644 --- a/src/PyKey.cpp +++ b/src/PyKey.cpp @@ -217,6 +217,14 @@ def _Key_eq(self, other): return int.__eq__(int(self), other) Key.__eq__ = _Key_eq + +def _Key_ne(self, other): + result = type(self).__eq__(self, other) + if result is NotImplemented: + return result + return not result + +Key.__ne__ = _Key_ne Key.__hash__ = lambda self: hash(int(self)) Key.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" Key.__str__ = lambda self: self.name diff --git a/src/PyMouseButton.cpp b/src/PyMouseButton.cpp index 3f6ca74..95b85b4 100644 --- a/src/PyMouseButton.cpp +++ b/src/PyMouseButton.cpp @@ -89,6 +89,14 @@ def _MouseButton_eq(self, other): return int.__eq__(int(self), other) MouseButton.__eq__ = _MouseButton_eq + +def _MouseButton_ne(self, other): + result = type(self).__eq__(self, other) + if result is NotImplemented: + return result + return not result + +MouseButton.__ne__ = _MouseButton_ne MouseButton.__hash__ = lambda self: hash(int(self)) MouseButton.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" MouseButton.__str__ = lambda self: self.name diff --git a/tests/cookbook/cookbook_main.py b/tests/cookbook/cookbook_main.py index c48c65b..b21150a 100644 --- a/tests/cookbook/cookbook_main.py +++ b/tests/cookbook/cookbook_main.py @@ -212,26 +212,26 @@ class CookbookLauncher: category = self.categories[self.selected_category] items = self.DEMOS[category] - if key == "Escape": + if key == mcrfpy.Key.ESCAPE: sys.exit(0) - elif key == "Left": + elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A: self.selected_category = (self.selected_category - 1) % len(self.categories) # Clamp item selection to new category new_category = self.categories[self.selected_category] self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) self._update_selection() - elif key == "Right": + elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D: self.selected_category = (self.selected_category + 1) % len(self.categories) new_category = self.categories[self.selected_category] self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1) self._update_selection() - elif key == "Up": + elif key == mcrfpy.Key.UP or key == mcrfpy.Key.W: self.selected_item = (self.selected_item - 1) % len(items) self._update_selection() - elif key == "Down": + elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S: self.selected_item = (self.selected_item + 1) % len(items) self._update_selection() - elif key == "Enter": + elif key == mcrfpy.Key.ENTER or key == mcrfpy.Key.SPACE: self._run_selected_demo() def activate(self):