spriteforge/tests/test_blender_backend.py

110 lines
6.5 KiB
Python
Raw Permalink Normal View History

import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from spriteforge.backends.base import BackendContext
from spriteforge.backends.blender import BlenderBackend,resolve_executable
from spriteforge.backends.layering import analyze_depth_order
from spriteforge.manifest import AssetSpec
def layer(color,depth_value):
rgba=np.zeros((4,4,4),dtype=np.uint8);rgba[1:3,1:3]=(*color,255)
depth=np.full((4,4),np.inf,dtype=np.float32);depth[1:3,1:3]=depth_value
return rgba,depth
def test_depth_order_is_back_to_front():
far,far_z=layer((10,20,30),5);near,near_z=layer((200,100,50),2)
analysis=analyze_depth_order(["far","near"],[far,near],[far_z,near_z])
assert analysis.order==(0,1) and not analysis.warnings
def test_crossing_depth_ranges_warn_with_context():
a,az=layer((1,1,1),2);b,bz=layer((2,2,2),3)
az[1,1]=4;az[1,2]=2;az[2,1]=4;az[2,2]=2
analysis=analyze_depth_order(["arm","body"],[a,b],[az,bz],crossing_ratio=.1,context="asset=test direction=0 frame=2")
assert analysis.warnings and "split into passes" in analysis.warnings[0] and "frame=2" in analysis.warnings[0]
def test_blender_protocol_without_real_blender(tmp_path,monkeypatch):
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake blend dependency")
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body","head":"Head"},anims=["idle","walk"],dirs=2,fps=10)
def fake_run(command,**kwargs):
request_path=Path(command[command.index("--request")+1]);output=Path(command[command.index("--output")+1]);result_path=Path(command[command.index("--result")+1])
request=json.loads(request_path.read_text());output.mkdir()
frames=[]
for animation in request["animations"]:
for direction in range(request["dirs"]):
parts=[]
for index,name in enumerate(request["parts"]):
rgba,depth=layer((50+index*100,40,30),5-index*3)
normal=np.zeros((4,4,2),dtype=np.uint8)
stem=f"{animation}_{direction}_{index}"
np.save(output/f"{stem}_r.npy",rgba);np.save(output/f"{stem}_d.npy",depth);np.save(output/f"{stem}_n.npy",normal)
parts.append({"name":name,"rgba":f"{stem}_r.npy","depth":f"{stem}_d.npy","normal":f"{stem}_n.npy"})
frames.append({"animation":animation,"direction_index":direction,"frame_index":0,"duration_ms":100,"parts":parts})
result={"version":1,"directions_centidegrees":[0,18000],
"animations":[{"name":"idle","frames_per_direction":1},{"name":"walk","frames_per_direction":1}],"frames":frames}
result_path.write_text(json.dumps(result));return SimpleNamespace(returncode=0,stdout="",stderr="")
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",fake_run)
backend=BlenderBackend();raw=backend.generate(spec,BackendContext(tmp_path,"actor"))
assert len(raw.frames)==4 and raw.animations==(("idle",1),("walk",1))
assert raw.layer_count==2 and all(order==(0,1) for order in raw.layer_orders)
assert raw.frames[0].depth is not None and raw.frames[0].normals_xy is not None
assert backend.dependencies(spec,BackendContext(tmp_path,"actor"))==(rig.resolve(),)
def test_blender_forwards_clip_sampling_and_layer_groups(tmp_path,monkeypatch):
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
captured={}
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle","walk"],dirs=8,fps=12,
params={"actions":{"idle":"Idle_Loop","walk":"Walk_Loop"},
"frames":{"idle":4,"walk":8},"part_members":{"body":["Body","Helmet"]},
"animation_fps":{"idle":8,"walk":12},
"part_prefixes":{"body":"actor_"},
"direction_offset_deg":90,"camera_target":[0,0,1],"model_rotation_deg":-90,
"loop_sampling":{"idle":True,"walk":False}})
def fake_run(command,**kwargs):
request_path=Path(command[command.index("--request")+1]);captured.update(json.loads(request_path.read_text()))
return SimpleNamespace(returncode=1,stdout="expected stop",stderr="")
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",fake_run)
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
except ValueError:pass
assert captured["actions"]=={"idle":"Idle_Loop","walk":"Walk_Loop"}
assert captured["frames"]=={"idle":4,"walk":8}
assert captured["part_members"]=={"body":["Body","Helmet"]}
assert captured["part_prefixes"]=={"body":"actor_"}
assert captured["animation_fps"]=={"idle":8,"walk":12}
assert captured["direction_offset_deg"]==90 and captured["camera_target"]==[0,0,1]
assert captured["loop_sampling"]=={"idle":True,"walk":False}
def test_blender_rejects_sampling_for_undeclared_clip(tmp_path):
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle"],
params={"frames":{"walk":8}})
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
except ValueError as error:assert "declared anims" in str(error)
else:raise AssertionError("undeclared sampling clip was accepted")
def test_blender_executable_uses_path(monkeypatch):
monkeypatch.setattr("spriteforge.backends.blender.shutil.which",lambda value:"/tools/blender" if value=="blender" else None)
assert resolve_executable("blender")=="/tools/blender"
def test_blender_executable_reports_configuration(monkeypatch):
monkeypatch.setattr("spriteforge.backends.blender.shutil.which",lambda value:None)
try:resolve_executable("missing-blender")
except ValueError as error:assert "BLENDER_BIN" in str(error)
else:raise AssertionError("missing Blender executable was accepted")
def test_missing_driver_result_includes_blender_log(tmp_path,monkeypatch):
rig=tmp_path/"rig.blend";rig.write_bytes(b"fake")
spec=AssetSpec(backend="blender",rig=rig.name,parts={"body":"Body"},anims=["idle"])
monkeypatch.setattr("spriteforge.backends.blender.resolve_executable",lambda value:"blender")
monkeypatch.setattr("spriteforge.backends.blender.subprocess.run",lambda *args,**kwargs:
SimpleNamespace(returncode=0,stdout="driver traceback marker",stderr=""))
try:BlenderBackend().generate(spec,BackendContext(tmp_path,"actor"))
except ValueError as error:assert "driver traceback marker" in str(error)
else:raise AssertionError("missing driver result was accepted")