spriteforge/tests/test_blender_backend.py

110 lines
6.5 KiB
Python

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")