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