110 lines
6.5 KiB
Python
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")
|