"""Door robot arm: re-imports every FBX in fbx/ into an empty scene, headless, and checks it against what the export recorded (build/export_report.json 'expect'): bone count and names, mesh count, triangles, materials, UCX hulls, the bounds in metres (the scale: no factor of 100 anywhere), no scale on any node, and for the animations the frame range and that the bones move. Stage 'verify_fbx' of dra_build.py -> build/fbx_report.json. """ import os import json import numpy as np import bpy HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) FBX = os.path.join(ROOT, "fbx") def stats(): arms = [o for o in bpy.data.objects if o.type == 'ARMATURE'] meshes = [o for o in bpy.data.objects if o.type == 'MESH' and not o.name.startswith("UCX_")] ucx = [o for o in bpy.data.objects if o.type == 'MESH' and o.name.startswith("UCX_")] tris = 0 mats = set() pts = [] bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() for o in meshes: me = o.data me.calc_loop_triangles() tris += len(me.loop_triangles) mats |= {m.name.split(".")[0] for m in me.materials if m} ev = o.evaluated_get(dg) # as the armature deforms it: the default pose must be the bind pose em = ev.to_mesh() co = np.empty(len(em.vertices) * 3, np.float64) em.vertices.foreach_get("co", co) ev.to_mesh_clear() co = co.reshape(-1, 3) if len(co): A = np.array(o.matrix_world) w = co @ A[:3, :3].T + A[:3, 3] pts += [w.min(0), w.max(0)] scale_dev = max([abs(c - 1.0) for o in bpy.data.objects for c in o.scale] + [0.0]) bones = sorted(b.name for a in arms for b in a.data.bones) pose_dev = 0.0 for a in arms: if a.animation_data and a.animation_data.action: continue for pb in a.pose.bones: m = pb.matrix_basis pose_dev = max(pose_dev, max(abs(m[i][j] - (1.0 if i == j else 0.0)) for i in range(4) for j in range(4))) anim = {} for a in arms: ad = a.animation_data if ad and ad.action: r = ad.action.frame_range anim = dict(frames=[round(r[0]), round(r[1])]) # how far the bones move over the range (world, metres): the animation is really in the file sc = bpy.context.scene sc.frame_start, sc.frame_end = int(r[0]), int(r[1]) pos = {} for f in (int(r[0]), int((r[0] + r[1]) / 2), int(r[1])): sc.frame_set(f) for pb in a.pose.bones: pos.setdefault(pb.name, []).append((a.matrix_world @ pb.head).copy()) mv = max(max((p - ps[0]).length for p in ps) for ps in pos.values()) anim["largest_bone_travel_m"] = round(float(mv), 4) out = dict(armatures=len(arms), bones=bones, meshes=len(meshes), tris=tris, materials=sorted(mats), ucx=len(ucx), default_pose_off_bind=round(float(pose_dev), 5), max_node_scale_deviation=round(float(scale_dev), 6), anim=anim) if pts: P = np.array(pts) out["bounds_m"] = [round(float(x), 4) for x in list(P.min(0)) + list(P.max(0))] return out def run(): rep_p = os.path.join(HERE, "export_report.json") expect = json.load(open(rep_p)).get("expect", {}) if os.path.exists(rep_p) else {} files = sorted(f for f in os.listdir(FBX) if f.lower().endswith(".fbx")) rows = {} ok_all = True for f in files: # 1. as the file reads in metres (the importer applies the file's unit): the size bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(FBX, f), anim_offset=0.0) got = stats() # 2. the skeletal files are written in centimetres: read with a global scale of 100 (which cancels Blender's # own cm -> m root scale) no node may carry a scale; the statics are read as they are if f.startswith(("SK_", "A_")): bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=os.path.join(FBX, f), global_scale=100.0, anim_offset=0.0) raw = stats() else: raw = dict(got) got["max_node_scale_deviation"] = raw["max_node_scale_deviation"] if "bounds_m" in raw and "bounds_m" in got: sz = lambda b: max(b[3] - b[0], b[4] - b[1], b[5] - b[2]) got["file_unit"] = "cm" if sz(raw["bounds_m"]) > 50 * sz(got["bounds_m"]) else "m" exp = expect.get(f) issues = [] if exp is None: issues.append("no expectation recorded") else: if exp["bones"] and got["bones"] != exp["bones"]: issues.append("bones %d, expected %d" % (len(got["bones"]), len(exp["bones"]))) if got["meshes"] != exp["meshes"]: issues.append("meshes %d, expected %d" % (got["meshes"], exp["meshes"])) if exp["meshes"] and abs(got["tris"] - exp["tris"]) > max(2, 0.001 * exp["tris"]): issues.append("tris %d, expected %d" % (got["tris"], exp["tris"])) if exp["materials"] and got["materials"] != exp["materials"]: issues.append("materials %s, expected %s" % (got["materials"], exp["materials"])) if got["ucx"] != exp["ucx"]: issues.append("UCX %d, expected %d" % (got["ucx"], exp["ucx"])) if "bounds_m" in exp and "bounds_m" in got: d = max(abs(a - b) for a, b in zip(got["bounds_m"], exp["bounds_m"])) got["bounds_error_m"] = round(d, 4) if d > 0.003: issues.append("bounds off by %.4f m (scale?)" % d) if exp.get("anim"): if not got["anim"]: issues.append("no animation") elif got["anim"]["frames"] != [1, 120]: issues.append("frames %s" % got["anim"]["frames"]) elif got["anim"]["largest_bone_travel_m"] < 0.5: issues.append("the bones hardly move (%.3f m)" % got["anim"]["largest_bone_travel_m"]) if got.get("default_pose_off_bind", 0) > 1e-3: issues.append("the default pose is not the bind pose (%.3f)" % got["default_pose_off_bind"]) if got["max_node_scale_deviation"] > 1e-4: issues.append("a node carries a scale (%.4f)" % got["max_node_scale_deviation"]) got["issues"] = issues got["verdict"] = "OK" if not issues else "FAILED" ok_all &= not issues got["bones"] = len(got["bones"]) rows[f] = got print("[dra] FBXCHECK %-44s %s %s" % (f, got["verdict"], "; ".join(issues)), flush=True) return dict(files=rows, verdict="ALL OK" if ok_all else "FAILED", count=len(files))