Files
AIGame/Tools/model_tools/tests/bl_test_fbx_axes.py
T

84 lines
4.7 KiB
Python

"""Blender regression: real characters, legacy input and repeated FBX conversion."""
import sys,json,math,hashlib
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[3]
sys.path.insert(0,str(ROOT/'Tools/model_tools'))
from bl_decimate import prepare_game_export,export_fbx_source_yup,validate_fbx_yup_scene
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/regression'
OUT.mkdir(parents=True,exist_ok=True)
def points(obj):return np.array([obj.matrix_world@v.co for v in obj.data.vertices])
def uv(obj):return np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data])
def snapshot(obj):return dict(name=obj.name,vertices=np.array([tuple(v.co) for v in obj.data.vertices]),uv=uv(obj),matrix=np.array(obj.matrix_world),objects=len(bpy.data.objects),meshes=len(bpy.data.meshes))
def unchanged(obj,before):
assert obj.name==before['name']
assert np.array_equal(np.array([tuple(v.co) for v in obj.data.vertices]),before['vertices'])
assert np.array_equal(uv(obj),before['uv'])
assert np.array_equal(np.array(obj.matrix_world),before['matrix'])
assert len(bpy.data.objects)==before['objects'] and len(bpy.data.meshes)==before['meshes']
assert bpy.context.view_layer.objects.active==obj and set(bpy.context.selected_objects)=={obj}
def run(label,path,restore):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.fbx':bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False)
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
prepare_game_export(obj,restore_axis=restore)
before=snapshot(obj)
conversion=Matrix.Rotation(math.pi/2,4,'X')
expected=np.array([conversion@obj.matrix_world@v.co for v in obj.data.vertices])
target=OUT/(label+'.fbx')
export_fbx_source_yup(str(target));unchanged(obj,before)
tree,_=parse_fbx.parse(str(target))
settings=next(e for e in tree.elems if e.id==b'GlobalSettings')
props=next(e for e in settings.elems if e.id==b'Properties70')
globals={p.props[0].decode():list(p.props[4:]) for p in props.elems}
assert globals['UpAxis']==[1] and globals['UpAxisSign']==[1]
objects=next(e for e in tree.elems if e.id==b'Objects')
for model in [e for e in objects.elems if e.id==b'Model']:
props=next(e for e in model.elems if e.id==b'Properties70')
values={p.props[0].decode():list(p.props[4:]) for p in props.elems}
assert max(abs(v) for v in values.get('Lcl Rotation',[0,0,0]))<1e-5
assert all(abs(v-1)<1e-5 for v in values.get('Lcl Scaling',[1,1,1]))
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(target),use_anim=False)
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
actual=points(obj);error=float(np.max(np.abs(actual-expected)))
assert actual.shape==expected.shape and error<2e-6,(label,error)
assert np.array_equal(uv(obj),before['uv']),(label,'UV changed')
assert np.argmax(np.ptp(actual,axis=0))==2,(label,'horizontal world geometry')
assert abs(actual[:,2].min())<2e-6
assert abs((actual[:,0].min()+actual[:,0].max())/2)<2e-6
assert abs((actual[:,1].min()+actual[:,1].max())/2)<2e-6
result=dict(case=label,path=str(target.relative_to(ROOT)),vertices=len(actual),max_position_error_m=error,
uv_unchanged=True,source_unchanged=True,upright=True,origin_bottom_center=True,zero_fbx_node_rotation=True)
return target,result
results=[]
girl=ROOT/'Tools/output/lmgirl_tpose_studio/model_ex.glb'
boy=ROOT/'Tools/output/campguide_studio/model_ex.glb'
_,r=run('boy_from_glb',boy,True);results.append(r)
path,r=run('girl_from_glb',girl,True);results.append(r)
for i in range(1,3):path,r=run('girl_repeat_'+str(i),path,None);results.append(r)
_,r=run('girl_from_legacy',ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx',None);results.append(r)
# Export failure must also restore selection, names, geometry and temporary data.
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
prepare_game_export(obj);before=snapshot(obj)
try:export_fbx_source_yup(str(OUT/'missing_directory'/'fail.fbx'))
except (RuntimeError,OSError):pass
else:raise AssertionError('Expected export failure')
unchanged(obj,before)
results.append(dict(case='failure_cleanup',passed=True))
try:validate_fbx_yup_scene(str(ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'))
except ValueError:pass
else:raise AssertionError('Legacy rotated FBX passed the export guard')
results.append(dict(case='reject_legacy_rotation',passed=True))
(OUT/'results.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('FBX_AXIS_REGRESSION '+json.dumps(results))