"""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)=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 .770 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=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='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')