Files
AIGame/Tools/model_tools/bl_lmgirl_mixamo41.py

373 lines
24 KiB
Python

"""LmGirl fixed Mixamo-41 preparation, weight variants and motion review data.
All website weights are discarded. Fitting preserves bone axes and hierarchy;
the source animation can keep its rotations with target bind translations.
"""
import json
import importlib.util
import hashlib
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
STATIC=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
REFERENCE=ROOT/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx'
def write(name,data):
OUT.mkdir(parents=True,exist_ok=True)
(OUT/name).write_text(json.dumps(data,indent=2),encoding='utf-8')
def mesh_points(mesh):return np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
def load():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(STATIC))
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
assert not any(o.type=='ARMATURE' for o in bpy.context.scene.objects)
# Project static FBX contains Y-up mesh coordinates; glTF needs Blender Z-up.
if np.argmax(np.ptp(mesh_points(mesh),axis=0))==1:
mesh.matrix_world=Matrix.Rotation(math.pi/2,4,'X')@mesh.matrix_world
bpy.ops.object.select_all(action='DESELECT');mesh.select_set(True);bpy.context.view_layer.objects.active=mesh
bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
pts=mesh_points(mesh)
lo,hi=pts.min(0),pts.max(0)
mesh.location-=Vector(((lo[0]+hi[0])/2,(lo[1]+hi[1])/2,lo[2]))
bpy.ops.object.transform_apply(location=True,rotation=False,scale=False)
bpy.ops.import_scene.fbx(filepath=str(REFERENCE))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
for obj in list(bpy.context.scene.objects):
if obj not in (mesh,arm):bpy.data.objects.remove(obj,do_unlink=True)
arm.animation_data_clear()
arm.matrix_world=Matrix.Rotation(math.pi/2,4,'X')@arm.matrix_world
bpy.context.view_layer.update()
return mesh,arm
def inspect():
mesh,arm=load();pts=mesh_points(mesh);lo,hi=pts.min(0),pts.max(0);h=hi[2]-lo[2]
bands=[]
for z in np.linspace(.03,.97,48):
a=pts[np.abs(pts[:,2]-z*h)<h*.012]
if len(a):bands.append({'z':round(float(z),3),'n':len(a),'min':a.min(0).tolist(),'max':a.max(0).tolist(),'mean':a.mean(0).tolist()})
data={'bounds':[lo.tolist(),hi.tolist()],'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),'bands':bands,
'reference_bones':{b.name:list(arm.matrix_world@b.head_local) for b in arm.data.bones}}
write('inspection.json',data)
print('INSPECT',json.dumps(data))
# Plain clay inspection; source UV remains untouched.
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH'
scene.display.shading.light='STUDIO';scene.display.shading.color_type='SINGLE';scene.display.shading.single_color=(.65,.68,.72)
scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True
camera=bpy.data.objects.new('InspectionCamera',bpy.data.cameras.new('InspectionCamera'));scene.collection.objects.link(camera)
camera.location=(0,-h*3,h*.5);camera.rotation_euler=(Vector((0,0,h*.5))-camera.location).to_track_quat('-Z','Y').to_euler()
camera.data.type='ORTHO';camera.data.ortho_scale=h*1.45;scene.camera=camera
scene.render.resolution_x=1100;scene.render.resolution_y=1000;scene.render.resolution_percentage=100
scene.render.filepath=str(OUT/'static_front.png');bpy.ops.render.render(write_still=True)
def prepare():
mesh,arm=load()
old={b.name:arm.matrix_world@b.matrix_local for b in arm.data.bones}
positions={
'Hips':(0,-.005,.610),'Spine':(0,-.005,.660),'Spine1':(0,-.005,.730),
'Spine2':(0,.005,.825),'Neck':(0,.010,.925),'Head':(0,.010,.965),'HeadTop_End':(0,.010,1.150)}
for side,sign in [('Left',1),('Right',-1)]:
for suffix,p in {'Shoulder':(.043,.018,.900),'Arm':(.120,.018,.877),
'ForeArm':(.248,.018,.862),'Hand':(.352,.018,.866),
'UpLeg':(.057,-.005,.571),'Leg':(.060,.006,.312),
'Foot':(.056,.018,.060),'ToeBase':(.056,-.039,.016),
'Toe_End':(.056,-.058,.016)}.items():
positions[side+suffix]=(p[0]*sign,p[1],p[2])
anchor=old['mixamorig:'+side+'Hand'].translation
for name,m in old.items():
if name.startswith('mixamorig:'+side+'Hand') and not name.endswith('Hand'):
d=m.translation-anchor
positions[name.split(':')[-1]]=Vector(positions[side+'Hand'])+Vector((d.x*.95,d.y*.35,d.z*.6))
bpy.context.view_layer.objects.active=arm
bpy.ops.object.mode_set(mode='EDIT')
inv=arm.matrix_world.inverted()
shapes={b.name:(b.tail-b.head,b.roll) for b in arm.data.edit_bones}
for b in arm.data.edit_bones:b.use_connect=False
for bone in arm.data.edit_bones:
target=inv@Vector(positions[bone.name.split(':')[-1]])
bone.head=target;bone.tail=target+shapes[bone.name][0];bone.roll=shapes[bone.name][1]
bpy.ops.object.mode_set(mode='OBJECT');bpy.context.view_layer.update()
drift=max(old[b.name].to_quaternion().rotation_difference((arm.matrix_world@b.matrix_local).to_quaternion()).angle for b in arm.data.bones)
assert drift<.001,drift
for modifier in list(mesh.modifiers):
if modifier.type=='ARMATURE':mesh.modifiers.remove(modifier)
mesh.vertex_groups.clear()
for b in arm.data.bones:mesh.vertex_groups.new(name=b.name)
mesh.vertex_groups['mixamorig:Hips'].add(list(range(len(mesh.data.vertices))),1,'REPLACE')
for i,b in enumerate(arm.data.bones):
if b.name!='mixamorig:Hips':
mesh.vertex_groups['mixamorig:Hips'].remove([i]);mesh.vertex_groups[b.name].add([i],1,'REPLACE')
world=mesh.matrix_world.copy();mesh.parent=arm;mesh.matrix_world=world
modifier=mesh.modifiers.new('TokenRigInputSkeleton','ARMATURE');modifier.object=arm
arm.data.pose_position='REST'
bpy.context.view_layer.update()
pts=mesh_points(mesh)
# Keep material payload small; final textured assets reuse the exact source
# UV and local baked maps. Transfer is still exercised on this original mesh.
write('input_manifest.json',{'static_mesh_source':str(STATIC),'skeleton_source':str(REFERENCE),
'bones':{b.name:{'parent':b.parent.name if b.parent else None,'world_matrix':[list(row) for row in arm.matrix_world@b.matrix_local],
'head':list(arm.matrix_world@b.head_local)} for b in arm.data.bones},
'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'height':float(np.ptp(pts[:,2])),'max_preserved_axis_rotation_error_radians':float(drift),
'input_skin':'Only non-anatomical keepalive placeholders; no website weights',
'fit':'Manually specified anatomical landmark positions for this mesh; original bone axes and hierarchy retained',
'landmarks':{n:list(p) for n,p in positions.items()},'blender_up':'Z','blender_front':'-Y'})
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'prepared.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(OUT/'source.glb'),export_format='GLB',export_yup=True,export_skins=True,
export_animations=False,export_def_bones=False)
print('PREPARED',len(arm.data.bones),len(pts),'axis drift',drift)
def process():
from mathutils.kdtree import KDTree
from collections import deque
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
arm.data.pose_position='POSE'
names=[b.name for b in arm.data.bones];pts=mesh_points(mesh)
parents=[names.index(b.parent.name) if b.parent else -1 for b in arm.data.bones]
heads=np.array([arm.matrix_world@b.head_local for b in arm.data.bones])
matrices={b.name:arm.matrix_world@b.matrix_local for b in arm.data.bones}
variants={}
for tag in ['raw','post']:
decoded=json.loads((OUT/(tag+'_decoded_weights.json')).read_text())
tree=KDTree(len(decoded))
for i,d in enumerate(decoded):
x,y,z=d['position_y_up'];tree.insert(Vector((x,-z,y)),i)
tree.balance();w=np.zeros((len(pts),41));dist=[]
for i,p in enumerate(pts):
_,j,distance=tree.find(Vector(p));dist.append(distance)
for name,value in decoded[j]['weights'].items():w[i,names.index(name)]+=value
assert max(dist)<1e-5
w/=w.sum(1,keepdims=True);variants[tag]=w
# Run the upstream pure-array repair on the RESTORED original graph and
# world coordinates. TokenRig's temporary graph/quantized axes are excluded.
spec=importlib.util.spec_from_file_location('weight_repair_upstream',OUT/'weight_repair_upstream.py')
repair=importlib.util.module_from_spec(spec);sys.modules[spec.name]=repair;spec.loader.exec_module(repair)
hop=np.full((41,41),99,dtype=int);adj=[[] for _ in names]
for i,p in enumerate(parents):
if p>=0:adj[i].append(p);adj[p].append(i)
for i in range(41):
hop[i,i]=0;q=deque([i])
while q:
a=q.popleft()
for b in adj[a]:
if hop[i,b]==99:hop[i,b]=hop[i,a]+1;q.append(b)
ids=np.argsort(-variants['post'],axis=1)[:,:4]
values=np.take_along_axis(variants['post'],ids,axis=1)
clean_ids,clean_weights,stats=repair.repair_weights_array(pts,ids,values,heads,hop,joint_names=names)
clean=np.zeros_like(variants['post'])
np.put_along_axis(clean,clean_ids,clean_weights,axis=1);clean/=clean.sum(1,keepdims=True)
variants['clean']=clean
eps=clean.copy();eps[eps<.01]=0;eps/=eps.sum(1,keepdims=True);variants['clean_eps']=eps
# Conservative character-specific alternative: the low-poly hands have no
# separated finger loops, and the braid must follow the head. Preserve 41
# joints, but use rigid hand/head influences in those identifiable regions.
local=variants['raw'].copy()
local_changes=[]
for i,p in enumerate(pts):
before=local[i].copy()
if p[2]>.945:
local[i]=0;local[i,names.index('mixamorig:Head')]=1
elif abs(p[0])>.335:
side='Left' if p[0]>0 else 'Right'
local[i]=0;local[i,names.index('mixamorig:'+side+'Hand')]=1
elif abs(p[0])>.15 and .77<p[2]<.94:
side='Left' if p[0]>0 else 'Right'
allowed=np.array([n in ['mixamorig:'+side+'Arm','mixamorig:'+side+'ForeArm','mixamorig:'+side+'Hand'] for n in names])
# Merge finger channels into wrist before applying the sleeve mask.
amount=sum(local[i,j] for j,n in enumerate(names) if n.startswith('mixamorig:'+side+'Hand'))
local[i,~allowed]=0;local[i,names.index('mixamorig:'+side+'Hand')]=amount
if local[i].sum()<1e-8:local[i,names.index('mixamorig:'+side+'ForeArm')]=1
local[i]/=local[i].sum()
elif p[2]<.48:
side='Left' if p[0]>0 else 'Right'
allowed=np.array([n=='mixamorig:Hips' or n in ['mixamorig:'+side+x for x in ['UpLeg','Leg','Foot','ToeBase','Toe_End']] for n in names])
local[i,~allowed]=0
if local[i].sum()<1e-8:local[i,names.index('mixamorig:'+side+'Leg')]=1
local[i]/=local[i].sum()
if np.abs(before-local[i]).sum()>.001:local_changes.append(i)
local[local<.01]=0;local/=local.sum(1,keepdims=True);variants['local']=local
write('cleanup.json',{'source':'https://github.com/stevelittlefish/ComfyUI-SkinTokens-NoBlender/blob/main/skintokens/weight_repair.py',
'algorithm':'Upstream repair_weights_array, original restored skeleton graph and world-space coordinates',
'min_hops':5,'repaired_vertices':stats.n_vertices_repaired,'removed_influences':stats.n_influences_removed,
'removed_by_bone':stats.removed_by_bone,'pairs':{' | '.join(k):v for k,v in stats.comingled_pairs.items()},
'epsilon':.01,'epsilon_changed_vertices':int(np.any(np.abs(eps-clean)>1e-7,axis=1).sum())})
write('local_constraints.json',{'base':'raw predicted weights; no voxel sharpening',
'rules':['head/braid above 0.945m rigid to Head','hands outside |x|=0.335m rigid to same-side Hand',
'sleeves outside |x|=0.15m and 0.77<z<0.94m allow same-side Arm/ForeArm/Hand only',
'below z=0.48m allow Hips and same-side UpLeg/Leg/Foot/Toe only; crotch blending retained','epsilon=0.01; renormalize'],
'changed_vertices_over_0_001':len(local_changes),'vertices':local_changes,
'limitation':'41 bones retained; separated finger articulation is intentionally not used for this mitten-like low-poly hand mesh; no mesh/topology changes'})
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
def trs(m):
p,q,s=m.decompose();return list(p)+[q.x,q.y,q.z,q.w]+list(s)
world={n:C@m@C for n,m in matrices.items()}
bones=[]
for i,n in enumerate(names):
local=world[names[parents[i]]].inverted()@world[n] if parents[i]>=0 else world[n]
bones.append({'name':n,'parent':parents[i],'rest':trs(local),'bindpose':[list(r) for r in world[n].inverted()]})
source=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
assert names==[b['name'] for b in source['bones']]
# Foot/hip span ratio determines root travel and vertical motion scaling.
ratio=(.610-.060)/(.5381249189-.0935430303)
offsets=[(np.array(b['rest'][:3])-np.array(s['rest'][:3])*ratio).tolist() for b,s in zip(bones,source['bones'])]
from mathutils import Quaternion
clip=source['clips'][0]
poses=[]
for sample in clip['samples']:
m=[]
for i,values in enumerate(sample):
p=Vector(np.array(values[:3])*ratio+offsets[i]);q=Quaternion((values[6],*values[3:6]))
local=Matrix.LocRotScale(p,q,Vector(values[7:10]));m.append(m[parents[i]]@local if parents[i]>=0 else local)
poses.append(m)
rest=np.array([C@Vector(p) for p in pts]);h=float(np.ptp(pts[:,2]))
edges=np.array([list(e.vertices) for e in mesh.data.edges]);lengths=np.linalg.norm(rest[edges[:,0]]-rest[edges[:,1]],axis=1)
masks={'all':lengths>h*.002,'hands':(np.abs(pts[:,0])>.32),'neck':(pts[:,2]>.91)&(pts[:,2]<.99)&(np.abs(pts[:,0])<.1)}
masks={k:(v if k=='all' else v[edges[:,0]]&v[edges[:,1]]&(lengths>h*.002)) for k,v in masks.items()}
report={};result={'bones':bones,'translationScale':ratio,'translationOffsets':offsets,'variants':[],
'height':h,'walkingFrames':len(poses),'reuseClips':'CampGuide original native AnimationClips, angular curves unchanged'}
for tag,w in variants.items():
mesh.vertex_groups.clear()
for n in names:mesh.vertex_groups.new(name=n)
for i,row in enumerate(w):
for j in np.flatnonzero(row>1e-8):mesh.vertex_groups[names[j]].add([i],float(row[j]),'REPLACE')
arm.animation_data_clear()
for b in arm.pose.bones:b.matrix_basis=Matrix.Identity(4)
bpy.context.view_layer.update()
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/('lmgirl_'+tag+'.blend')),compress=True)
bpy.ops.export_scene.gltf(filepath=str(OUT/('lmgirl_'+tag+'.glb')),export_format='GLB',export_yup=True,export_skins=True,export_animations=False,export_def_bones=False)
maxs={k:0 for k in masks};over={k:0 for k in masks};samples=[];floors=[];worst={};p99=0
p4=np.column_stack((rest,np.ones(len(rest))))
for frame,pose in enumerate(poses):
deformed=np.zeros_like(rest)
for j,n in enumerate(names):
transform=np.array(pose[j]@world[n].inverted())
deformed+=w[:,j,None]*(p4@transform.T)[:,:3]
ratios=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(lengths,1e-10)
floors.append(float(deformed[:,1].min()))
ei=int(np.argmax(np.where(masks['all'],ratios,0)))
if float(ratios[ei])>worst.get('ratio',0):
worst={'frame':frame+1,'ratio':float(ratios[ei]),'vertices':edges[ei].tolist(),
'rest':pts[edges[ei]].tolist(),'weights':[{names[j]:float(w[i,j]) for j in np.flatnonzero(w[i]>1e-8)} for i in edges[ei]]}
p99=max(p99,float(np.percentile(ratios[masks['all']],99)))
for k,mask in masks.items():
maxs[k]=max(maxs[k],float(ratios[mask].max()));over[k]=max(over[k],int((ratios[mask]>2).sum()))
samples.append({'frame':frame+1,'maxStretch':float(ratios[masks['all']].max()),'floorMin':floors[-1]})
# Mesh exporter: exact source topology/UV/corner normals, reflected Y-up.
transform=C@mesh.matrix_world;normal_matrix=transform.to_3x3().inverted().transposed()
mesh.data.calc_loop_triangles();uv_layer=mesh.data.uv_layers.active
vertex=[];normal=[];uv=[];weights=[];tri=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for li in triangle.loops:
vi=mesh.data.loops[li].vertex_index;n=normal_matrix@mesh.data.corner_normals[li].vector;n.normalize()
tex=list(uv_layer.data[li].uv);key=(vi,*[round(v,6) for v in tex],*[round(v,6) for v in n])
if key not in lookup:
index=len(vertex);lookup[key]=index;first.setdefault(vi,index)
vertex.append(list(transform@mesh.data.vertices[vi].co));normal.append(list(n));uv.append(tex)
weights.append([[int(j),float(w[vi,j])] for j in np.argsort(-w[vi]) if w[vi,j]>1e-8])
ids.append(lookup[key])
tri.extend([ids[0],ids[2],ids[1]])
audit_edges=[[first[a],first[b],float(lengths[i]),1|(2 if masks['hands'][i] else 0)|(4 if masks['neck'][i] else 0)] for i,(a,b) in enumerate(edges) if masks['all'][i]]
result['variants'].append({'name':tag,'vertices':vertex,'normals':normal,'uv':uv,'triangles':tri,'weights':weights,
'auditEdges':audit_edges,'originalVertices':len(pts),'trianglesCount':len(tri)//3})
foot_ids=[i for i,n in enumerate(names) if any(x in n for x in ['Leg','Foot','Toe'])]
report[tag]={'maxStretch':maxs,'maxEdgesOver2x':over,'floorMinRange':[min(floors),max(floors)],'samples':samples,'worstEdge':worst,'maxP99':p99,
'handMaxLowerBodyInfluence':float(w[np.abs(pts[:,0])>.32][:,foot_ids].sum(1).max()),
'maxInfluences':int((w>1e-8).sum(1).max()),'minimumNonzeroWeight':float(w[w>1e-8].min())}
# Bake Walking for external review; native Unity uses the shared clip.
for b in arm.pose.bones:b.rotation_mode='QUATERNION'
for f,pose in enumerate(poses,1):
bpy.context.scene.frame_set(f)
for i,b in enumerate(arm.pose.bones):
b.matrix=arm.matrix_world.inverted()@C@pose[i]@C;bpy.context.view_layer.update()
for attr in ['location','rotation_quaternion','scale']:b.keyframe_insert(attr,frame=f)
bpy.context.scene.frame_start=1;bpy.context.scene.frame_end=len(poses);bpy.context.scene.render.fps=30
bpy.ops.export_scene.gltf(filepath=str(OUT/('Walking_'+tag+'.glb')),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
print('VARIANT',tag,json.dumps({k:v for k,v in report[tag].items() if k!='samples'}))
write('walking_comparison.json',report)
(OUT/'unity_review_data.json').write_text(json.dumps(result,separators=(',',':')),encoding='utf-8')
print('PROCESSED',len(variants),'variants')
def topology():
from collections import deque
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH');pts=mesh_points(mesh)
# Restrict the graph BELOW the crotch: otherwise two separate pant legs
# would legitimately connect through the pelvis.
allowed=pts[:,2]<.50;adj=[[] for _ in pts]
for e in mesh.data.edges:
a,b=e.vertices
if allowed[a] and allowed[b]:adj[a].append(b);adj[b].append(a)
left=int(np.argmin(np.linalg.norm(pts-np.array((.06,.01,.26)),axis=1)))
right=int(np.argmin(np.linalg.norm(pts-np.array((-.06,.01,.26)),axis=1)))
prev={left:None};queue=deque([left])
while queue and right not in prev:
a=queue.popleft()
for b in adj[a]:
if b not in prev:prev[b]=a;queue.append(b)
path=[];a=right if right in prev else None
while a is not None:path.append(a);a=prev[a]
path.reverse()
faces=[p.index for p in mesh.data.polygons if all(pts[i,2]<.5 for i in p.vertices)
and min(pts[i,0] for i in p.vertices)<=1e-6 and max(pts[i,0] for i in p.vertices)>=-1e-6]
write('topology_audit.json',{'source_sha256':hashlib.sha256(STATIC.read_bytes()).hexdigest(),
'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'test':'Mesh-edge connectivity restricted to z<0.50m, below the 0.56m crotch',
'left_right_lower_legs_connected_below_crotch':right in prev,'path_vertices':path,'path_positions':pts[path].tolist(),
'faces_touching_or_crossing_center_below_0_5m':faces,
'source_uv_sha256':hashlib.sha256(np.array([tuple(v.uv) for v in mesh.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest(),
'conclusion':'Lower pant legs are topologically joined; separate the inner-leg mesh before final weight painting. Original mesh was not changed.' if right in prev else 'No below-crotch connection detected.'})
print('TOPOLOGY',right in prev,'path',len(path),'central faces',len(faces))
def verify_exports():
from mathutils.kdtree import KDTree
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
original=next(o for o in bpy.context.scene.objects if o.type=='MESH');rest=mesh_points(original)
edges=np.array([list(e.vertices) for e in original.data.edges]);length=np.linalg.norm(rest[edges[:,0]]-rest[edges[:,1]],axis=1)
valid=length>np.ptp(rest[:,2])*.002
expected=json.loads((OUT/'walking_comparison.json').read_text())
results=[]
for tag in ['raw','post','clean','clean_eps','local']:
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(OUT/('Walking_'+tag+'.glb')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH');points=mesh_points(mesh)
tree=KDTree(len(points))
for i,p in enumerate(points):tree.insert(Vector(p),i)
tree.balance();mapping=[];dist=[]
for p in rest:
_,index,distance=tree.find(Vector(p));mapping.append(index);dist.append(distance)
assert max(dist)<1e-5,(tag,'rest mismatch',max(dist))
actual=0;first,last=map(int,arm.animation_data.action.frame_range)
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
obj=mesh.evaluated_get(bpy.context.evaluated_depsgraph_get());geometry=obj.to_mesh()
deformed=np.array([obj.matrix_world@v.co for v in geometry.vertices])[mapping];obj.to_mesh_clear()
ratios=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(length,1e-10)
actual=max(actual,float(ratios[valid].max()))
diff=abs(actual-expected[tag]['maxStretch']['all'])
assert diff<.003,(tag,actual,diff)
assert [first,last]==[1,32]
results.append({'variant':tag,'frames':[1,32],'maxStretch':actual,'difference':diff})
write('export_verification.json',{'passed':True,'results':results})
if __name__=='__main__':
args=sys.argv[sys.argv.index('--')+1:]
if args[0]=='inspect':inspect()
elif args[0]=='prepare':prepare()
elif args[0]=='process':process()
elif args[0]=='topology':topology()
elif args[0]=='verify_exports':verify_exports()
else:raise SystemExit('Unknown stage')