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