"""Read-only Blender audit of the CampGuide SOMA trial; writes JSON diagnostics.""" import json import sys from pathlib import Path import bpy import numpy as np def points(mesh, evaluated=False): obj = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else mesh data = obj.to_mesh() if evaluated else obj.data result = np.array([obj.matrix_world @ v.co for v in data.vertices]) if evaluated: obj.to_mesh_clear() return result def audit(path): bpy.ops.wm.read_factory_settings(use_empty=True) if path.suffix == '.fbx': bpy.ops.import_scene.fbx(filepath=str(path)) elif path.suffix == '.bvh': bpy.ops.import_anim.bvh(filepath=str(path), global_scale=0.01, rotate_mode='QUATERNION', update_scene_fps=True) else: bpy.context.scene.render.fps = 30 bpy.ops.import_scene.gltf(filepath=str(path)) arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None) result = {'path': str(path)} if arm: result['bones'] = {b.name: {'parent': b.parent.name if b.parent else None, 'head': list(arm.matrix_world @ b.head_local), 'tail': list(arm.matrix_world @ b.tail_local), 'world_basis': [list((arm.matrix_world @ b.matrix_local).to_3x3().col[i].normalized()) for i in range(3)], 'deform': b.use_deform} for b in arm.data.bones} result['gaps'] = {b.name: (arm.matrix_world @ b.head_local - arm.matrix_world @ b.parent.tail_local).length for b in arm.data.bones if b.parent} meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] if meshes: mesh = max(meshes, key=lambda o: len(o.data.vertices)) rest = points(mesh) lo, hi = rest.min(0), rest.max(0) height = float(hi[2]-lo[2]) weights = [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-5} for v in mesh.data.vertices] groups = sorted({g for w in weights for g in w}) def region(mask): ids = np.flatnonzero(mask) return {'vertices': len(ids), 'weights': {g: round(sum(weights[i].get(g, 0) for i in ids)/max(1, len(ids)), 5) for g in groups if any(weights[i].get(g, 0) > .01 for i in ids)}} result['mesh'] = {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons), 'bounds': [lo.tolist(), hi.tolist()], 'head_region': region(rest[:, 2] > lo[2]+height*.78), 'outer_hands': region(np.abs(rest[:, 0]) > max(abs(lo[0]), abs(hi[0]))*.78), 'weight_sums': [min(sum(w.values()) for w in weights), max(sum(w.values()) for w in weights)]} # Weld coincident UV/normal seam vertices only for connectivity statistics. _, inverse = np.unique(np.round(rest, 5), axis=0, return_inverse=True) parent = np.arange(inverse.max()+1) def root(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x edges = np.array([e.vertices[:] for e in mesh.data.edges]) for a, b in inverse[edges]: parent[root(a)] = root(b) labels = np.array([root(v) for v in inverse]) result['mesh']['component_sizes'] = sorted(np.unique(labels, return_counts=True)[1].tolist(), reverse=True) result['mesh']['components'] = [dict(region(labels == label), bounds=[rest[labels == label].min(0).tolist(), rest[labels == label].max(0).tolist()]) for label, count in zip(*np.unique(labels, return_counts=True)) if count >= 25] lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1) valid = lengths > height*.002 action = arm.animation_data.action if arm and arm.animation_data else None if action: first, last = map(int, action.frame_range) result['animation'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'frames': []} endpoints = [] for frame in range(first, last+1): bpy.context.scene.frame_set(frame) deformed = points(mesh, True) ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12) ratios[~valid] = 0 worst = np.argsort(ratios)[-5:][::-1] hand_mask = valid & (np.abs(rest[edges[:, 0], 0]) > height*.255) & (rest[edges[:, 0], 2] > height*.55) & (rest[edges[:, 0], 2] < height*.67) result['animation']['frames'].append({'frame': frame, 'bounds': [deformed.min(0).tolist(), deformed.max(0).tolist()], 'hips_location': list(arm.pose.bones['Hips'].location), 'hand_max_stretch': float(ratios[hand_mask].max()), 'edges_over_2x': int((ratios > 2).sum()), 'max_stretch': float(ratios.max()), 'worst': [{'ratio': float(ratios[e]), 'ids': edges[e].tolist(), 'rest': rest[edges[e]].tolist(), 'weights': [weights[v] for v in edges[e]]} for e in worst]}) if frame in (first, last): endpoints.append({b.name: b.rotation_quaternion.copy() for b in arm.pose.bones}) if len(endpoints) == 2: result['animation']['loop_rotation_gap_degrees'] = {n: float(endpoints[0][n].rotation_difference(endpoints[1][n]).angle*180/np.pi) for n in endpoints[0]} elif arm and arm.animation_data and arm.animation_data.action: first, last = map(int, arm.animation_data.action.frame_range) samples = [] for frame in range(first, last+1): bpy.context.scene.frame_set(frame) samples.append({n: {'location': list(arm.pose.bones[n].location), 'world_head': list(arm.matrix_world @ arm.pose.bones[n].head)} for n in ('Root', 'Hips', 'LeftFoot', 'RightFoot')}) result['motion'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'samples': samples} return result args = sys.argv[sys.argv.index('--')+1:] out = Path(args[0]) result = [] for filename in args[1:]: item = audit(Path(filename)) result.append(item) print('AUDIT', filename, 'bones', len(item.get('bones', {})), 'mesh', item.get('mesh')) if 'animation' in item: worst = max(item['animation']['frames'], key=lambda f: f['max_stretch']) print('WORST', json.dumps(worst)) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')