335 lines
11 KiB
Python
335 lines
11 KiB
Python
|
|
#!/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())
|