#!/usr/bin/env python3
"""Tests for tile_baker.py — standalone, no Blender required.
Run: python3 test/test_tile_baker.py
"""
import importlib.util
import os
import sys
import struct
import numpy as np
# Load shm_protocol directly (avoid bpy import from __init__.py)
_addon_dir = os.path.join(os.path.dirname(__file__), "..", "blender_addon")
_shm_spec = importlib.util.spec_from_file_location(
"blender_addon.shm_protocol",
os.path.join(_addon_dir, "shm_protocol.py"),
submodule_search_locations=[],
)
shm_protocol = importlib.util.module_from_spec(_shm_spec)
sys.modules["blender_addon"] = type(sys)("blender_addon")
sys.modules["blender_addon"].__path__ = [_addon_dir]
sys.modules["blender_addon.shm_protocol"] = shm_protocol
_shm_spec.loader.exec_module(shm_protocol)
# Now we can import tile_baker (it imports from .shm_protocol via relative import)
_tb_spec = importlib.util.spec_from_file_location(
"blender_addon.tile_baker",
os.path.join(_addon_dir, "tile_baker.py"),
submodule_search_locations=[],
)
tile_baker = importlib.util.module_from_spec(_tb_spec)
tile_baker.__package__ = "blender_addon"
sys.modules["blender_addon.tile_baker"] = tile_baker
_tb_spec.loader.exec_module(tile_baker)
ShmRegion = shm_protocol.ShmRegion
TileBaker = tile_baker.TileBaker
SHM_NAME = "/test_tile_baker"
SHM_SIZE = 4 * 1024 * 1024 # 4 MB
TILE_SIZE = 128
TILE_BYTES = TILE_SIZE * TILE_SIZE * 4 # RGBA8
passed = 0
failed = 0
def check(name: str, condition: bool, detail: str = ""):
global passed, failed
if condition:
print(f" {name:<50s} PASS")
passed += 1
else:
print(f" {name:<50s} FAIL {detail}")
failed += 1
def make_shm():
shm = ShmRegion()
assert shm.create(SHM_NAME, SHM_SIZE), "Failed to create SHM"
return shm
def make_baker(shm):
baker = TileBaker(shm, tile_size=TILE_SIZE, max_mips=4)
ring_base = SHM_SIZE // 2
ring_size = SHM_SIZE // 2
baker.init_ring_buffer(ring_base, ring_size)
return baker
def make_test_image(w, h):
"""Create a gradient test image (H, W, 4) float32."""
img = np.zeros((h, w, 4), dtype=np.float32)
for y in range(h):
for x in range(w):
img[y, x, 0] = x / w # R = horizontal gradient
img[y, x, 1] = y / h # G = vertical gradient
img[y, x, 2] = 0.5 # B = constant
img[y, x, 3] = 1.0 # A = opaque
return img
# ── Test 1: Ring buffer write ────────────────────────────────────────────────
print("=== Tile Baker Tests ===")
shm = make_shm()
try:
baker = make_baker(shm)
# Write some data to the ring
test_data = b"\xAA" * 256
offset = baker._write_to_ring(test_data)
check("ring_buffer_write", offset == SHM_SIZE // 2)
# Read it back
raw = bytes(shm._mm[offset : offset + 256])
check("ring_buffer_readback", raw == test_data)
# Second write should be sequential
offset2 = baker._write_to_ring(test_data)
check("ring_buffer_sequential", offset2 == SHM_SIZE // 2 + 256)
finally:
shm.close()
# ── Test 2: Ring buffer wrap-around ──────────────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
ring_size = SHM_SIZE // 2
# Fill most of the ring
big_data = b"\xBB" * (ring_size - 100)
offset = baker._write_to_ring(big_data)
check("ring_fill", offset >= 0)
# Next write won't fit — should wrap
wrap_data = b"\xCC" * 200
offset2 = baker._write_to_ring(wrap_data)
check("ring_wrap_around", offset2 == SHM_SIZE // 2, f"got {offset2}")
finally:
shm.close()
# ── Test 3: Extract single tile from image ───────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
# Create a 256x256 image (2x2 tiles at 128px)
img = make_test_image(256, 256)
# Extract tile (0,0)
tile_data = baker._extract_tile(img, 256, 256, 0, 0, TILE_SIZE)
check("extract_tile_size", len(tile_data) == TILE_BYTES, f"got {len(tile_data)}")
# First pixel should be near (0, 0, 128, 255) for the gradient
r, g, b, a = tile_data[0], tile_data[1], tile_data[2], tile_data[3]
check("extract_tile_first_pixel", r == 0 and g == 0 and b == 128 and a == 255,
f"got ({r},{g},{b},{a})")
# Extract tile (1,1) — bottom-right quadrant
tile_data_11 = baker._extract_tile(img, 256, 256, 1, 1, TILE_SIZE)
# Last pixel of tile (1,1) = image pixel (255, 255), which is ~(1.0, 1.0, 0.5, 1.0)
last = TILE_BYTES - 4
r2, g2, b2, a2 = tile_data_11[last], tile_data_11[last + 1], tile_data_11[last + 2], tile_data_11[last + 3]
check("extract_tile_last_pixel", r2 >= 254 and g2 >= 254 and b2 == 128 and a2 == 255,
f"got ({r2},{g2},{b2},{a2})")
finally:
shm.close()
# ── Test 4: Edge tile padding ────────────────────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
# Create a 200x150 image (not a multiple of 128)
img = make_test_image(200, 150)
# Tile (1,1) covers pixels (128..199, 128..149) — only 72x22, padded to 128x128
tile_data = baker._extract_tile(img, 200, 150, 1, 1, TILE_SIZE)
check("edge_tile_padded_size", len(tile_data) == TILE_BYTES)
# Pixel at (72, 0) in the tile should be zero (padding)
idx = (0 * TILE_SIZE + 72) * 4 # row 0, col 72
r = tile_data[idx]
check("edge_tile_padding_zero", r == 0, f"got r={r}")
finally:
shm.close()
# ── Test 5: Bake tiles in dirty rect ────────────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
baker.register_texture("test_image", 1)
# Create 512x512 image (4x4 tiles)
img = make_test_image(512, 512)
# Dirty rect covering tiles (1,1) and (2,1) — x=128..383, y=128..255
baker.bake_from_array(img, 512, 512, 1, 128, 128, 256, 128)
messages = baker.get_pending_messages()
# Should have mip 0 tiles + mip parents
mip0_msgs = [m for m in messages if m.endswith(",0,") is False and ",0," in m.split(":")[-1].split(",")[3] == "0"]
# Simpler: count mip 0 messages
mip0_count = sum(1 for m in messages if m.split(",")[3] == "0")
check("bake_dirty_rect_mip0_count", mip0_count == 2, f"got {mip0_count}")
# Verify message format: tile_ready:tex_id,tx,ty,mip,offset,size
first = messages[0]
check("message_format", first.startswith("tile_ready:1,"), f"got: {first}")
# All mip 0 tiles should have correct size
for msg in messages:
parts = msg.split(":")[1].split(",")
if parts[3] == "0":
size = int(parts[5])
check(f"tile_size_mip0_({parts[1]},{parts[2]})", size == TILE_BYTES,
f"got {size}")
finally:
shm.close()
# ── Test 6: Mip generation ──────────────────────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
baker.register_texture("test_image", 1)
# Create 256x256 image (2x2 tiles) and bake all
img = make_test_image(256, 256)
baker.bake_from_array(img, 256, 256, 1, 0, 0, 256, 256)
messages = baker.get_pending_messages()
mip_counts = {}
for msg in messages:
parts = msg.split(":")[1].split(",")
mip = int(parts[3])
mip_counts[mip] = mip_counts.get(mip, 0) + 1
# Mip 0: 4 tiles (2x2), Mip 1: 1 tile (1x1)
check("mip0_tile_count", mip_counts.get(0, 0) == 4, f"got {mip_counts.get(0, 0)}")
check("mip1_tile_count", mip_counts.get(1, 0) == 1, f"got {mip_counts.get(1, 0)}")
# Verify mip 1 tile is at position (0,0)
mip1_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "1"]
if mip1_msgs:
parts = mip1_msgs[0].split(":")[1].split(",")
check("mip1_position", parts[1] == "0" and parts[2] == "0",
f"got ({parts[1]},{parts[2]})")
finally:
shm.close()
# ── Test 7: Mip tile pixel averaging ────────────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
# Create a uniform red 256x256 image
img = np.zeros((256, 256, 4), dtype=np.float32)
img[:, :, 0] = 1.0 # R
img[:, :, 3] = 1.0 # A
baker.bake_from_array(img, 256, 256, 1, 0, 0, 256, 256)
messages = baker.get_pending_messages()
# Find the mip 1 tile and read it back from SHM
mip1_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "1"]
check("mip1_exists", len(mip1_msgs) > 0)
if mip1_msgs:
parts = mip1_msgs[0].split(":")[1].split(",")
offset = int(parts[4])
size = int(parts[5])
# Read tile from SHM
tile_bytes = bytes(shm._mm[offset : offset + size])
tile = np.frombuffer(tile_bytes, dtype=np.uint8).reshape(TILE_SIZE, TILE_SIZE, 4)
# Should be solid red (255, 0, 0, 255) since all children are red
check("mip1_red_channel", tile[0, 0, 0] == 255, f"got {tile[0, 0, 0]}")
check("mip1_green_channel", tile[0, 0, 1] == 0, f"got {tile[0, 0, 1]}")
check("mip1_alpha_channel", tile[0, 0, 3] == 255, f"got {tile[0, 0, 3]}")
finally:
shm.close()
# ── Test 8: No messages for unregistered textures ───────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
# Don't register any texture
img = make_test_image(256, 256)
baker.bake_from_array(img, 256, 256, 99, 0, 0, 256, 256)
# bake_from_array takes tex_id directly, so this still works
messages = baker.get_pending_messages()
check("direct_bake_works", len(messages) > 0)
# But bake_dirty_region without registration should produce nothing
# (can't test without bpy, but verify the lookup logic)
check("unregistered_lookup", baker._texture_ids.get("nonexistent") is None)
finally:
shm.close()
# ── Test 9: SHM ring buffer data integrity ──────────────────────────────────
shm = make_shm()
try:
baker = make_baker(shm)
# Create a small checkerboard pattern
img = np.zeros((128, 128, 4), dtype=np.float32)
img[::2, ::2] = [1.0, 1.0, 1.0, 1.0] # White pixels at even coords
img[1::2, 1::2] = [1.0, 1.0, 1.0, 1.0] # White pixels at odd coords
baker.bake_from_array(img, 128, 128, 1, 0, 0, 128, 128)
messages = baker.get_pending_messages()
# Read the mip 0 tile from SHM and verify pattern
mip0_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "0"]
if mip0_msgs:
parts = mip0_msgs[0].split(":")[1].split(",")
offset = int(parts[4])
size = int(parts[5])
tile_bytes = bytes(shm._mm[offset : offset + size])
tile = np.frombuffer(tile_bytes, dtype=np.uint8).reshape(TILE_SIZE, TILE_SIZE, 4)
# Pixel (0,0) = white, pixel (0,1) = black, pixel (1,0) = black, pixel (1,1) = white
check("checkerboard_0_0", tile[0, 0, 0] == 255, f"got {tile[0, 0, 0]}")
check("checkerboard_0_1", tile[0, 1, 0] == 0, f"got {tile[0, 1, 0]}")
check("checkerboard_1_1", tile[1, 1, 0] == 255, f"got {tile[1, 1, 0]}")
finally:
shm.close()
# ── Summary ──────────────────────────────────────────────────────────────────
print()
if failed == 0:
print(f"All {passed} tests passed.")
else:
print(f"{passed} passed, {failed} FAILED.")
sys.exit(1)