Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-24 18:04:41 +08:00
co-authored by Cursor
parent 6ebc9401ce
commit 45e62132c8
1432 changed files with 2971856 additions and 2798 deletions
+12
View File
@@ -19,6 +19,18 @@
- Pillow(UV 观察图):`pip install Pillow`
- UVPackmaster 4 可选
## FBX 轴向约定
Studio 五步的 GLB 使用标准 glTF Y-up;Blender 默认导入后应在 Z-up 场景中站立。
`prepare_game_export` 为历史调用保留 Y-up 顶点缓冲;最终必须由
`export_fbx_source_yup` 在临时副本上恢复 Blender Z-up,再烘焙为 Y-up FBX。
不要直接把准备后的 Y-up 顶点交给默认 FBX 导出器,否则会重复转换,留下 X=-90° 节点旋转。
版本 2 导出器会校验场景 +Y-up、节点旋转 0 和缩放 1;静态导出回执写入 `fbxAxes.exportVersion`。
`lmgirl_tpose_mesh.py` 只复用版本匹配的 FBX 缓存,历史资源不会在此次修复中批量重导。
Blender 回归入口:`Tools/model_tools/tests/bl_test_fbx_axes.py`。验收读取完整节点变换后的世界几何,
同时检查脚底中心原点、UV、源网格不变及重复导入导出,不以顶点包围盒或 `axis_up` 参数单独判定成功。
## 一键(推荐)
```powershell
@@ -0,0 +1,41 @@
"""Audit FBX axis metadata, raw geometry and evaluated Blender world bounds."""
import sys,json
from pathlib import Path
import bpy
import numpy as np
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
def properties(element):
block=next((e for e in element.elems if e.id==b'Properties70'),None)
return {p.props[0].decode():[v.decode(errors='replace') if isinstance(v,bytes) else v for v in p.props[4:]] for p in block.elems} if block else {}
def bounds(points):
p=np.asarray(points,dtype=float)
return dict(min=p.min(0).tolist(),max=p.max(0).tolist(),span=np.ptp(p,axis=0).tolist())
def audit(path):
tree,version=parse_fbx.parse(str(path))
settings=next(e for e in tree.elems if e.id==b'GlobalSettings')
objects=next(e for e in tree.elems if e.id==b'Objects')
raw=[]
for e in objects.elems:
if e.id==b'Model':raw.append(dict(type='Model',name=str(e.props[1]),properties=properties(e)))
if e.id==b'Geometry':
vs=next((x for x in e.elems if x.id==b'Vertices'),None)
if vs:raw.append(dict(type='Geometry',name=str(e.props[1]),bounds=bounds(np.asarray(vs.props[0]).reshape(-1,3))))
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
imported=[]
for o in bpy.context.scene.objects:
if o.type=='MESH':imported.append(dict(name=o.name,vertices=len(o.data.vertices),triangles=sum(len(p.vertices)-2 for p in o.data.polygons),
rotation=list(o.rotation_euler),matrix=[list(r) for r in o.matrix_world],worldBounds=bounds([o.matrix_world@v.co for v in o.data.vertices])))
return dict(path=str(path),fbxVersion=version,globalSettings=properties(settings),raw=raw,imported=imported)
paths=[Path(p) for p in sys.argv[sys.argv.index('--')+1:]]
OUT.mkdir(parents=True,exist_ok=True)
report=[audit(p) for p in paths]
(OUT/'axis_audit.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
@@ -0,0 +1,47 @@
"""Inspect the completed five-step outputs using each format's default importer."""
import json,sys,hashlib
from pathlib import Path
import bpy
import numpy as np
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio'
OUT=FOLDER/'axis_pipeline_audit'
def inspect(stage,path):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix.lower()=='.fbx':bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False)
objects=[o for o in bpy.context.scene.objects if o.type=='MESH']
points=np.array([o.matrix_world@v.co for o in objects for v in o.data.vertices])
lo=points.min(0);hi=points.max(0);span=hi-lo
result=dict(stage=stage,path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
vertices=sum(len(o.data.vertices) for o in objects),triangles=sum(len(p.vertices)-2 for o in objects for p in o.data.polygons),
blender_world_min=lo.tolist(),blender_world_max=hi.tolist(),blender_world_span=span.tolist(),
longest_axis_in_blender='XYZ'[int(np.argmax(span))],
upright_in_blender=bool(np.argmax(span)==2))
if path.suffix.lower()=='.fbx':
root,_=parse_fbx.parse(str(path));objects=next(e for e in root.elems if e.id==b'Objects')
result['fbx_nodes']=[]
for e in objects.elems:
if e.id!=b'Model':continue
props=next((p for p in e.elems if p.id==b'Properties70'),None)
values={p.props[0].decode():list(p.props[4:]) for p in props.elems} if props else {}
result['fbx_nodes'].append(dict(name=str(e.props[1]),rotation=values.get('Lcl Rotation',[0,0,0]),scale=values.get('Lcl Scaling',[1,1,1])))
return result
stages=[('01 Hunyuan high',FOLDER/'model.glb'),('02 Studio low',FOLDER/'model_lowpoly.glb'),
('03 Studio UV',FOLDER/'model_lowpoly_uv.glb'),('03b alignment',FOLDER/'model_lowpoly_uv_aligned.glb'),
('04 bake',FOLDER/'model_baked.glb'),('05 UV-preserving decimation',FOLDER/'model_decimated.glb'),
('05 final GLB',FOLDER/'model_ex.glb'),('After 05 static FBX',FOLDER/'static/LmGirlTpose.fbx'),
('XPbr FBX',FOLDER/'xpbr/LmGirlTpose.fbx'),('Corrected upload FBX',FOLDER/'mixamo_upload/LmGirlTpose_Mixamo.fbx'),
('Fixed production static FBX',FOLDER/'axis_pipeline_audit/production/static/LmGirlTpose.fbx'),
('Fixed production XPbr FBX',FOLDER/'axis_pipeline_audit/production/xpbr/LmGirlTpose.fbx')]
OUT.mkdir(parents=True,exist_ok=True)
report=[inspect(stage,path) for stage,path in stages]
(OUT/'stages.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
for r in report:print('STAGE',r['stage'],r['triangles'],r['longest_axis_in_blender'],[round(x,5) for x in r['blender_world_span']],r.get('fbx_nodes',[]))
assert all(r['upright_in_blender'] for r in report[:7]),'Earlier GLB stage has a tilt'
assert not report[7]['upright_in_blender'],'Expected original static FBX regression'
assert all(r['upright_in_blender'] for r in report[9:]),'A repaired FBX is not upright'
+119
View File
@@ -0,0 +1,119 @@
"""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')
@@ -0,0 +1,56 @@
"""Render high/final Studio accessory and audit geometry/UV without changing it."""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Vector
folder = Path(sys.argv[sys.argv.index('--') + 1]).resolve()
report = []
filenames = ('model.glb',) if '--high-only' in sys.argv else ('model_ex.glb',) if '--final-only' in sys.argv else ('model.glb', 'model_ex.glb')
for filename in filenames:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(folder / filename))
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
points = [o.matrix_world @ v.co for o in objects for v in o.data.vertices]
lo = Vector([min(v[i] for v in points) for i in range(3)])
hi = Vector([max(v[i] for v in points) for i in range(3)])
uv = np.array([tuple(v.uv) for o in objects if o.data.uv_layers.active for v in o.data.uv_layers.active.data])
assert len(objects) == 1 and np.isfinite(uv).all() and len(uv) > 0
triangles = sum(len(p.vertices) - 2 for o in objects for p in o.data.polygons)
report.append(dict(file=filename, objects=len(objects), triangles=triangles,
vertices=sum(len(o.data.vertices) for o in objects), minimum=list(lo), maximum=list(hi),
uvLoops=len(uv), finiteUV=True))
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 16
scene.render.resolution_x = scene.render.resolution_y = 640
scene.render.resolution_percentage = 100
world = bpy.data.worlds.new('ReviewWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.32, .38, .44, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .6
scene.world = world
center = (lo + hi) / 2
extent = max(hi - lo)
camera = bpy.data.objects.new('ReviewCamera', bpy.data.cameras.new('ReviewCamera'))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = 'ORTHO'
camera.data.ortho_scale = extent * 1.3
for direction, power in (((2, -3, 4), 400), ((-3, -1, 2), 250)):
light = bpy.data.objects.new('ReviewLight', bpy.data.lights.new('ReviewLight', 'AREA'))
scene.collection.objects.link(light)
light.location = center + Vector(direction) * extent
light.rotation_euler = (center - light.location).to_track_quat('-Z', 'Y').to_euler()
light.data.energy = power * extent ** 2
light.data.size = extent * 3
for name, direction in [('front', (0, -3, .5)), ('side', (3, 0, .5)), ('back', (0, 3, .5)), ('perspective', (2, -3, 1))]:
camera.location = center + Vector(direction) * extent
camera.rotation_euler = (center - camera.location).to_track_quat('-Z', 'Y').to_euler()
scene.render.filepath = str(folder / ('audit_' + Path(filename).stem + '_' + name + '.png'))
bpy.ops.render.render(write_still=True)
(folder / ('geometry_high.json' if '--high-only' in sys.argv else 'geometry_audit.json')).write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print('MT_SUMMARY ' + json.dumps(report))
@@ -0,0 +1,24 @@
"""Run identical Mixamo motions on website and TokenRig skin weights."""
import json
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_compare_mixamo_soma import audit
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
out=folder/'review'
out.mkdir(parents=True,exist_ok=True)
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
results=[]
jobs=[('Mixamo',base/'CampGuideStudio.fbx'),('TokenRig41',folder/'tokenrig_mixamo41.blend')]
if '--local-only' in sys.argv:
jobs=[('TokenRigLocal',folder/'tokenrig_mixamo41_local.blend')]
for skin,mesh in jobs:
for name in ['Walking','Breathing Idle','Happy Idle']:
result=audit(skin+' '+name,mesh,base/('CampGuideStudio@'+name+'.fbx'),out)
results.append(result)
result_name='comparison_local.json' if '--local-only' in sys.argv else 'comparison.json'
(folder/result_name).write_text(json.dumps(results,indent=2),encoding='utf-8')
print('COMPARISON_DONE',folder/result_name)
@@ -0,0 +1,47 @@
"""Bake supplied Mixamo clips onto both TokenRig skin variants for review."""
import json
import sys
from pathlib import Path
import bpy
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
manifest=[]
for variant,filename in [('raw','tokenrig_mixamo41.blend'),('local','tokenrig_mixamo41_local.blend')]:
for label in ['Walking','Breathing Idle','Happy Idle']:
bpy.ops.wm.open_mainfile(filepath=str(folder/filename))
target=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
before=set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(base/('CampGuideStudio@'+label+'.fbx')))
source=next(o for o in bpy.data.objects if o not in before and o.type=='ARMATURE')
first,last=map(int,source.animation_data.action.frame_range)
for b in target.pose.bones:
b.rotation_mode='QUATERNION'
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
matrices={b.name:target.matrix_world.inverted()@source.matrix_world@b.matrix for b in source.pose.bones}
for b in target.pose.bones:
b.matrix=matrices[b.name]
bpy.context.view_layer.update()
b.keyframe_insert('location',frame=frame)
b.keyframe_insert('rotation_quaternion',frame=frame)
b.keyframe_insert('scale',frame=frame)
action=target.animation_data.action
action.name=variant+'_'+label.replace(' ','_')
bpy.data.objects.remove(source,do_unlink=True)
for a in list(bpy.data.actions):
if a!=action:
bpy.data.actions.remove(a)
scene=bpy.context.scene
scene.frame_start,scene.frame_end=first,last
scene.render.fps=30
dst=folder/'animations'/variant/(label.replace(' ','_')+'.glb')
dst.parent.mkdir(parents=True,exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
manifest.append({'file':str(dst),'skin':variant,'frames':[first,last],'fps':30,'source_clip':str(base/('CampGuideStudio@'+label+'.fbx'))})
print('BAKED',dst)
(folder/'animations/manifest.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
@@ -0,0 +1,34 @@
"""Controlled local-influence cleanup of TokenRig-41 hand weights only."""
import json
import sys
from pathlib import Path
import bpy
folder=Path(sys.argv[sys.argv.index('--')+1])
bpy.ops.wm.open_mainfile(filepath=str(folder/'tokenrig_mixamo41.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')
height=max((mesh.matrix_world@v.co).z for v in mesh.data.vertices)
changes=[]
for v in mesh.data.vertices:
p=mesh.matrix_world@v.co
if not (abs(p.x)>height*.255 and height*.55<p.z<height*.67):
continue
side='Left' if p.x*(arm.matrix_world@arm.data.bones['mixamorig:LeftHand'].head_local).x>0 else 'Right'
old={mesh.vertex_groups[g.group].name:g.weight for g in v.groups}
allowed={n:w for n,w in old.items() if n in (f'mixamorig:{side}Arm',f'mixamorig:{side}ForeArm') or n.startswith(f'mixamorig:{side}Hand')}
total=sum(allowed.values())
rejected=sum(w for n,w in old.items() if n not in allowed)
assert total>1e-8
for g in list(v.groups):
mesh.vertex_groups[g.group].remove([v.index])
for n,w in allowed.items():
mesh.vertex_groups[n].add([v.index],w/total,'REPLACE')
if rejected>.001:
changes.append({'vertex':v.index,'removed':rejected,'old':old})
(folder/'local_cleanup.json').write_text(json.dumps({'method':'CampGuide-specific T-pose hand-region whitelist; same-side Arm/ForeArm/Hand/fingers only; renormalize; no reference weights',
'changed_vertices_over_0_001':len(changes),'changes':changes},indent=2),encoding='utf-8')
bpy.ops.wm.save_as_mainfile(filepath=str(folder/'tokenrig_mixamo41_local.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(folder/'tokenrig_mixamo41_local.glb'),export_format='GLB',export_skins=True,export_animations=False,export_def_bones=False)
print('LOCAL_CLEANUP',len(changes))
+243
View File
@@ -0,0 +1,243 @@
"""Offline skin/motion comparison. Never modifies the source FBXs or Unity assets.
Mixamo clips are skeleton-only. Their evaluated world bone matrices are applied
to the same-name bones of the original skinned model, preserving its bind pose.
This explicitly handles the differing bind/rest poses; it is not Unity Humanoid.
"""
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
REFERENCE_POINTS = None
GEOMETRY_CHECKS = {}
def coords(obj, evaluated=False):
item = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else obj
data = item.to_mesh() if evaluated else item.data
result = np.array([item.matrix_world @ v.co for v in data.vertices], dtype=np.float64)
if evaluated:
item.to_mesh_clear()
return result
def imported(path):
before = set(bpy.data.objects)
if path.suffix.lower() == '.blend':
bpy.ops.wm.open_mainfile(filepath=str(path))
elif path.suffix.lower() == '.fbx':
bpy.ops.import_scene.fbx(filepath=str(path))
else:
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
return [o for o in bpy.data.objects if o not in before]
def configure_render(height, center):
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 16
scene.render.resolution_x, scene.render.resolution_y = 512, 640
scene.render.resolution_percentage = 100
scene.view_settings.view_transform = 'AgX'
world = bpy.data.worlds.new('AuditWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.35, .38, .42, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .7
scene.world = world
camera = bpy.data.objects.new('AuditCamera', bpy.data.cameras.new('AuditCamera'))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = 'ORTHO'
camera.data.ortho_scale = height*1.3
for direction, power in [((2, -3, 4), 180), ((-3, 1, 2), 120)]:
lamp = bpy.data.objects.new('AuditLight', bpy.data.lights.new('AuditLight', 'AREA'))
scene.collection.objects.link(lamp)
lamp.location = center + Vector(direction)*height
lamp.rotation_euler = (center-lamp.location).to_track_quat('-Z', 'Y').to_euler()
lamp.data.energy, lamp.data.size = power*height**2, height*3
return camera
def audit(label, mesh_path, clip_path, out):
global REFERENCE_POINTS
bpy.ops.wm.read_factory_settings(use_empty=True)
objects = imported(mesh_path)
arm = next(o for o in objects if o.type == 'ARMATURE')
mesh = max((o for o in objects if o.type == 'MESH'), key=lambda o: len(o.data.vertices))
rest = coords(mesh)
up = int(np.argmax(np.ptp(rest, axis=0)))
normalized = rest[:, [0, 2, 1]].copy() if up == 1 else rest.copy()
if up == 1:
normalized[:, 1] *= -1
lo, hi = normalized.min(0), normalized.max(0)
height = float(hi[2]-lo[2])
if REFERENCE_POINTS is None:
REFERENCE_POINTS = normalized.copy()
if str(mesh_path) not in GEOMETRY_CHECKS:
matches=[]
for sign in (1,-1):
candidate=normalized.copy()
candidate[:,:2] *= sign
distances=[]
for start in range(0,len(candidate),128):
distance2=((candidate[start:start+128,None,:]-REFERENCE_POINTS[None,:,:])**2).sum(2)
distances.extend(np.sqrt(distance2.min(1)).tolist())
matches.append({'yaw_degrees': 0 if sign==1 else 180, 'max_nearest_vertex_distance_m': max(distances),
'mean_nearest_vertex_distance_m': float(np.mean(distances))})
GEOMETRY_CHECKS[str(mesh_path)] = min(matches,key=lambda m:m['mean_nearest_vertex_distance_m'])
groups = [g.name for g in mesh.vertex_groups]
weights = np.zeros((len(rest), len(groups)))
for v in mesh.data.vertices:
for g in v.groups:
weights[v.index, g.group] = g.weight
edges = np.array([e.vertices[:] for e in mesh.data.edges])
lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1)
valid = lengths > height*.002
z = (normalized[:, 2]-lo[2])/height
hand = (np.abs(normalized[:, 0]) > height*.255) & (z > .55) & (z < .67)
neck = (z > .67) & (z < .78) & (np.abs(normalized[:, 0]) < height*.12)
head = z > .78
regions = {'all': np.ones(len(rest), dtype=bool), 'hands': hand, 'neck': neck, 'head': head}
region_edges = {n: valid & mask[edges[:, 0]] & mask[edges[:, 1]] for n, mask in regions.items()}
foot_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Foot', 'Toe', 'Shin', 'UpLeg')) or n.endswith('Leg')]
head_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Head', 'Neck'))]
def region_summary(mask):
w = weights[mask]
return {'vertices': int(mask.sum()), 'average_weights': {n: float(w[:,i].mean()) for i,n in enumerate(groups) if w[:,i].max() > .01},
'max_foot_influence': float(w[:,foot_groups].sum(1).max()),
'max_head_neck_influence': float(w[:,head_groups].sum(1).max())}
def norm_point(p):
return [p.x, -p.z, p.y] if up == 1 else list(p)
bones = {b.name: {'head': norm_point(arm.matrix_world @ b.head_local), 'tail': norm_point(arm.matrix_world @ b.tail_local),
'parent': b.parent.name if b.parent else None} for b in arm.data.bones}
result = {'label': label, 'mesh_source': str(mesh_path), 'clip_source': str(clip_path) if clip_path else None,
'geometry_match_to_first_mixamo': GEOMETRY_CHECKS[str(mesh_path)],
'mesh': {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons), 'height_m': height,
'bounds_normalized_z_up': [lo.tolist(), hi.tolist()], 'hands': region_summary(hand), 'head': region_summary(head),
'max_influences_over_0_00001': int((weights > .00001).sum(1).max()),
'max_influences_over_0_01': int((weights > .01).sum(1).max()),
'max_weight_discarded_if_top4': float(np.sort(weights,axis=1)[:,:-4].sum(1).max()),
'weight_sum_range': [float(weights.sum(1).min()), float(weights.sum(1).max())],
'rest_edge_lengths_sha256': hashlib.sha256(np.round(np.sort(lengths), 5).tobytes()).hexdigest()},
'bones': bones}
# Motion-independent LBS stress test: head yaw 30 degrees and both upper arms
# down 70 degrees, in normalized world coordinates. Same angles for both rigs.
# Descendants inherit their parent's deformation; joint pivots remain those
# of each delivered rig, so this tests the combined bind placement + weights.
canonical = Matrix.Rotation(math.pi/2,4,'X') if up == 1 else Matrix.Identity(4)
bind = {b.name: canonical @ arm.matrix_world @ b.matrix_local for b in arm.data.bones}
def stress(arm_down, head_yaw):
deform = {}
for b in arm.data.bones:
inherited = deform[b.parent.name] if b.parent else Matrix.Identity(4)
posed = inherited @ bind[b.name]
suffix = b.name.split(':')[-1]
angle, axis = 0, 'Z'
if suffix == 'Head':
angle = head_yaw
elif suffix in ('LeftArm','RightArm'):
angle, axis = arm_down*(1 if bind[b.name].translation.x > 0 else -1), 'Y'
if angle:
pivot = posed.translation
posed = Matrix.Translation(pivot) @ Matrix.Rotation(math.radians(angle),4,axis) @ Matrix.Translation(-pivot) @ posed
deform[b.name] = posed @ bind[b.name].inverted()
points4 = np.column_stack((normalized,np.ones(len(normalized))))
deformed = np.zeros((len(rest),3))
for i,name in enumerate(groups):
deformed += weights[:,i,None]*(points4 @ np.array(deform[name]).T)[:,:3]
ratios = np.linalg.norm(deformed[edges[:,1]]-deformed[edges[:,0]],axis=1)/np.maximum(lengths,1e-12)
return {n: {'max_edge_stretch': float(ratios[mask].max()), 'edges_over_2x': int((ratios[mask] > 2).sum())}
for n,mask in region_edges.items()}
result['controlled_lbs_stress'] = {'neutral': stress(0,0), 'arms_down_70_head_yaw_30': stress(70,30)}
source = None
if clip_path:
objects = imported(clip_path)
source = next(o for o in objects if o.type == 'ARMATURE')
assert set(arm.pose.bones.keys()) == set(source.pose.bones.keys()), 'skeleton names differ'
action_arm = source or arm
action = action_arm.animation_data.action if action_arm.animation_data else None
if not action:
return result
first, last = map(int, action.frame_range)
fps = bpy.context.scene.render.fps/bpy.context.scene.render.fps_base
samples, endposes, worldposes = [], [], []
named = lambda suffix: next((b.name for b in action_arm.pose.bones if b.name == suffix or b.name.endswith(':'+suffix)), None)
hips_name = named('Hips')
tracks = {s: named(s) for s in ['Hips','Head','LeftFoot','RightFoot','LeftToeBase','RightToeBase']}
for frame in range(first, last+1):
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
if source:
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
deformed = coords(mesh, True)
ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12)
worst = int(np.argmax(np.where(valid, ratios, 0)))
values = {n: {'max': float(ratios[mask].max()) if mask.any() else None,
'p99': float(np.percentile(ratios[mask],99)) if mask.any() else None,
'over_2x': int((ratios[mask] > 2).sum())} for n,mask in region_edges.items()}
feet = {s: [float(deformed[weights[:,i] > .25,2].min()) for i,n in enumerate(groups) if s+'Foot' in n and (weights[:,i] > .25).any()]
for s in ['Left','Right']}
samples.append({'frame': frame, 'regions': values,
'floor_min_z': float(deformed[:,2].min()), 'feet_min_z': feet,
'hips_world': list(action_arm.matrix_world @ action_arm.pose.bones[hips_name].head),
'worst_edge': {'ids': edges[worst].tolist(), 'rest': normalized[edges[worst]].tolist(),
'ratio': float(ratios[worst]), 'weights': [{n: float(weights[v,i]) for i,n in enumerate(groups) if weights[v,i]>.00001} for v in edges[worst]]}})
poses = {b.name: b.matrix_basis.to_quaternion().copy() for b in action_arm.pose.bones}
world = {n: list(action_arm.matrix_world @ action_arm.pose.bones[b].head) for n,b in tracks.items() if b}
worldposes.append(world)
if frame in (first, last):
endposes.append(poses)
seam = {n: min(float(endposes[0][n].rotation_difference(endposes[-1][n]).angle),2*math.pi-float(endposes[0][n].rotation_difference(endposes[-1][n]).angle))*180/math.pi for n in endposes[0]}
result['animation'] = {'range': [first,last], 'fps': fps, 'duration_between_endpoints_s': (last-first)/fps,
'samples': samples, 'joint_tracks': worldposes, 'loop_local_rotation_gap_degrees': seam,
'summary': {n: {'max_edge_stretch': max(s['regions'][n]['max'] for s in samples if s['regions'][n]['max'] is not None),
'max_p99_edge_stretch': max(s['regions'][n]['p99'] for s in samples if s['regions'][n]['p99'] is not None),
'max_edges_over_2x': max(s['regions'][n]['over_2x'] for s in samples)} for n in regions}}
print('METRICS', label, result['animation']['summary'], 'SEAM', sorted(seam.items(),key=lambda x:x[1],reverse=True)[:4])
if label.startswith(('Mixamo','TokenRig41','TokenRigLocal')) and '--metrics-only' not in sys.argv:
mid = first+(last-first)//3
# Re-evaluate one representative source frame before rendering.
bpy.context.scene.frame_set(mid)
bpy.context.view_layer.update()
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
points = coords(mesh, True)
center = Vector(((points[:,0].min()+points[:,0].max())/2, (points[:,1].min()+points[:,1].max())/2, (points[:,2].min()+points[:,2].max())/2))
camera = configure_render(height, center)
for view, direction in [('front',(0,-3,.12)), ('side',(3,0,.12)), ('back',(0,3,.12))]:
camera.location = center+Vector(direction)*height
camera.rotation_euler = (center-camera.location).to_track_quat('-Z','Y').to_euler()
bpy.context.scene.render.filepath = str(out/(label.replace(' ','_')+'_'+view+'.png'))
bpy.ops.render.render(write_still=True)
return result
def main():
root, out = map(Path, sys.argv[sys.argv.index('--')+1:][:2])
out.mkdir(parents=True, exist_ok=True)
base = root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
soma = root/'Tools/output/campguide_studio/rig_soma'
jobs = [('Mixamo '+name, base/'CampGuideStudio.fbx', base/('CampGuideStudio@'+name+'.fbx')) for name in ['Walking','Breathing Idle','Happy Idle']]
jobs += [('SOMA '+name, soma/'kimodo'/name/'baked.glb',None) for name in ['Idle','Walk']]
results=[]
for label,mesh,clip in jobs:
result=audit(label,mesh,clip,out)
results.append(result)
(out/'comparison.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('AUDIT_DONE',out/'comparison.json')
if __name__ == '__main__':
main()
+81 -11
View File
@@ -7,6 +7,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SUPPORTED_MODEL_EXTENSIONS = (".fbx", ".glb", ".gltf")
FBX_YUP_EXPORT_VERSION = 2
def model_format(path):
@@ -87,18 +88,87 @@ def prepare_game_export(obj, restore_axis=None):
def export_fbx_source_yup(filepath):
"""导出与混元 GLB 相同的 Y-up FBX。调用前顶点须已是 Y-up。"""
"""Export prepared Y-up geometry as an actual Y-up FBX scene.
prepare_game_export stores Y-up vertices inside Blender's Z-up workspace.
Passing those straight to the FBX exporter adds a -90 X object rotation,
so consumers applying the complete FBX transform see a horizontal model.
Restore Blender Z-up on temporary copies, then bake the export conversion.
Leave source meshes, UVs, transforms and selection unchanged for callers.
"""
import math
import bpy
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={"MESH"},
axis_forward="-Z",
axis_up="Y",
bake_space_transform=False,
path_mode="STRIP",
embed_textures=False,
)
from mathutils import Matrix
selected = list(bpy.context.selected_objects)
active = bpy.context.view_layer.objects.active
meshes = [obj for obj in selected if obj.type == "MESH"]
if not meshes:
raise ValueError("Select at least one prepared Y-up mesh for FBX export")
copies = []
source_names = [(source, source.name) for source in meshes]
try:
bpy.ops.object.select_all(action="DESELECT")
for source, source_name in source_names:
duplicate = source.copy()
duplicate.data = source.data.copy()
copies.append(duplicate)
bpy.context.scene.collection.objects.link(duplicate)
source.name = source_name + "__fbx_export_source"
duplicate.name = source_name
duplicate.parent = None
duplicate.matrix_world = Matrix.Rotation(math.pi / 2, 4, "X") @ source.matrix_world
duplicate.select_set(True)
bpy.context.view_layer.objects.active = copies[0]
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={"MESH"},
axis_forward="-Z",
axis_up="Y",
bake_space_transform=True,
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_ALL",
bake_anim=False,
path_mode="STRIP",
embed_textures=False,
)
return validate_fbx_yup_scene(filepath)
finally:
for duplicate in copies:
mesh = duplicate.data
bpy.data.objects.remove(duplicate, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
for obj in selected:
obj.select_set(True)
for source, source_name in source_names:
source.name = source_name
bpy.context.view_layer.objects.active = active
def validate_fbx_yup_scene(filepath):
"""Reject the old extra node rotation even when raw vertices look Y-up."""
from io_scene_fbx import parse_fbx
root, _ = parse_fbx.parse(str(filepath))
def properties(element):
block = next(e for e in element.elems if e.id == b"Properties70")
return {p.props[0].decode(): list(p.props[4:]) for p in block.elems}
settings = properties(next(e for e in root.elems if e.id == b"GlobalSettings"))
if settings.get("UpAxis") != [1] or settings.get("UpAxisSign") != [1]:
raise ValueError("Exported FBX scene is not +Y-up")
objects = next(e for e in root.elems if e.id == b"Objects")
for model in (e for e in objects.elems if e.id == b"Model"):
values = properties(model)
if any(abs(v) > 1e-5 for v in values.get("Lcl Rotation", [0, 0, 0])):
raise ValueError("Exported FBX retains an unbaked node rotation")
if any(abs(v - 1) > 1e-5 for v in values.get("Lcl Scaling", [1, 1, 1])):
raise ValueError("Exported FBX retains an unbaked node scale")
return {"exportVersion": FBX_YUP_EXPORT_VERSION, "sceneUp": "+Y",
"nodeRotationBaked": True, "nodeScaleBaked": True,
"unitScaleFactor": settings.get("UnitScaleFactor", [1])[0]}
TOLERANCE = 0.03 # 面数相对误差容忍
MAX_RETRY = 2 # ratio 修正轮数上限
@@ -0,0 +1,81 @@
"""Weld coincident geometry, retain corner UV seams, then collapse to target.
Studio GLBs split vertices at normals and UV seams. Decimating those as loose
triangles drops patches rather than simplifying a continuous surface.
"""
import hashlib
import json
import sys
from pathlib import Path
import bpy
import bmesh
source, target, requested = sys.argv[sys.argv.index('--') + 1:][:3]
source, target, requested = Path(source), Path(target), int(requested)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(source), merge_vertices=False)
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
assert len(objects) == 1
obj = objects[0]
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
assert obj.data.uv_layers.active
def corners():
# Reordering by bmesh is harmless; the (position, per-corner UV) multiset
# must stay the same through welding, including UV seam discontinuities.
uv = obj.data.uv_layers.active.data
return sorted(tuple(round(c, 6) for c in (*obj.data.vertices[l.vertex_index].co, *uv[l.index].uv)) for l in obj.data.loops)
before_corners = corners()
vertices_before = len(obj.data.vertices)
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=max(obj.dimensions) * 1e-7)
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
assert before_corners == corners(), 'Welding changed the existing corner UV mapping'
vertices_welded = len(obj.data.vertices)
tri = obj.modifiers.new('Triangulate', 'TRIANGULATE')
bpy.ops.object.modifier_apply(modifier=tri.name)
before = len(obj.data.polygons)
area_before = sum(p.area for p in obj.data.polygons)
modifier = obj.modifiers.new('PreserveCornerUVDecimate', 'DECIMATE')
modifier.decimate_type = 'COLLAPSE'
modifier.use_collapse_triangulate = True
lower, upper = 0.0, 1.0
candidate = min(1.0, requested / before)
best = None
for _ in range(22):
modifier.ratio = candidate
bpy.context.view_layer.update()
count = len(obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).data.polygons)
rank = (abs(count - requested), count > requested)
if best is None or rank < best[0]: best = (rank, candidate)
if count == requested: break
if count > requested: upper = candidate
else: lower = candidate
candidate = (lower + upper) / 2
modifier.ratio = best[1]
bpy.ops.object.modifier_apply(modifier=modifier.name)
after = len(obj.data.polygons)
area_after = sum(p.area for p in obj.data.polygons)
print('Final faces', after, 'target', requested, 'best', best, flush=True)
assert after <= requested * 1.30, (after, requested, best)
assert area_after >= area_before * .80, 'Reduction lost too much surface area'
assert obj.data.uv_layers.active
bpy.ops.export_scene.gltf(filepath=str(target), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_image_format='AUTO')
report = dict(faces_before=before, faces_after=after, target_faces=requested,
vertices_before=vertices_before, vertices_after_weld=vertices_welded,
cornerUVPreservedDuringWeld=True, uv_mode='preserved_no_unwrap',
area_before=area_before, area_after=area_after, area_ratio=area_after / area_before,
ratio=best[1], threshold_percent=30, triggered=True)
target.with_suffix('.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('MT_SUMMARY ' + json.dumps(report), flush=True)
@@ -0,0 +1,194 @@
"""Measure bone trajectories independently of skinning for the shared walk."""
import json
import sys
import math
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix,Vector,Quaternion
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
SOURCE=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
def quaternion(q):return Quaternion((q[3],*q[:3]))
def sample(data,frame):
source_indices={b['name']:i for i,b in enumerate(SOURCE['bones'])}
values=[SOURCE['clips'][0]['samples'][frame][source_indices[b['name']]] for b in data['bones']]
source_rot=[];mapped=[];world=[]
for i,(b,v) in enumerate(zip(data['bones'],values)):
parent=b['parent'];q=quaternion(v[3:7]);source_rot.append(source_rot[parent]@q if parent>=0 else q)
mapped.append(source_rot[i]@quaternion(data['bindRotationCorrections'][i]) if data.get('bindRotationCorrections') else source_rot[i])
q=mapped[parent].inverted()@mapped[i] if parent>=0 else mapped[i]
p=Vector(v[:3])
if data.get('translationOffsets'):
p*=data['translationScale']
if data.get('translationDeltaRotations'):p=quaternion(data['translationDeltaRotations'][i])@p
p+=Vector(data['translationOffsets'][i])
local=Matrix.LocRotScale(p,q,Vector(v[7:10]));world.append(world[parent]@local if parent>=0 else local)
return {b['name'].split(':')[-1]:world[i] for i,b in enumerate(data['bones'])}
def audit():
results={}
for label,data in [('boy',SOURCE),('girl_tokenrig',json.loads((FOLDER/'unity_review_data.json').read_text())),
('girl_mixamo',json.loads((FOLDER/'website/unity_review_data.json').read_text()))]:
samples=[]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
for f in range(32):
pose=sample(data,f);hips=pose['Hips'].translation
lateral=(pose['LeftUpLeg'].translation-pose['RightUpLeg'].translation).normalized()
feet={side:list(pose[side+'Foot'].translation-hips) for side in ['Left','Right']}
gap=(pose['LeftFoot'].translation-pose['RightFoot'].translation).dot(lateral)
knees=(pose['LeftLeg'].translation-pose['RightLeg'].translation).dot(lateral)
samples.append({'frame':f+1,'footSignedGap':gap,'kneeSignedGap':knees,'feetRelativeHips':feet})
results[label]={'restHipWidth':(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length,
'restAnkleWidth':(rest['LeftFoot'].translation-rest['RightFoot'].translation).length,
'leftRestLegVector':list(rest['LeftFoot'].translation-rest['LeftUpLeg'].translation),
'minimumFootSignedGap':min(s['footSignedGap'] for s in samples),'minimumKneeSignedGap':min(s['kneeSignedGap'] for s in samples),
'crossedFrames':[s['frame'] for s in samples if s['footSignedGap']<0],
'samples':samples}
(FOLDER/'gait_diagnosis_before.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='samples'} for k,d in results.items()},indent=2))
def corrected_motion(data,label):
names=[b['name'].split(':')[-1] for b in data['bones']];indices={n:i for i,n in enumerate(names)}
parents=[b['parent'] for b in data['bones']]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
source_rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in SOURCE['bones']}
target_width=(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length
source_width=(source_rest['LeftUpLeg'].translation-source_rest['RightUpLeg'].translation).length
scale=Vector((target_width/source_width,data['translationScale'],data['translationScale']))
samples=[];tracks=[];previous={}
for frame in range(32):
source=sample(SOURCE,frame);initial=sample(data,frame)
local=[]
for i,n in enumerate(names):
m=initial[names[parents[i]]].inverted()@initial[n] if parents[i]>=0 else initial[n]
p,q,s=m.decompose();local.append([p.copy(),q.copy(),s.copy()])
def fk():
result=[]
for i,(p,q,s) in enumerate(local):
m=Matrix.LocRotScale(p,q,s);result.append(result[parents[i]]@m if parents[i]>=0 else m)
return result
world=fk();target_hips=world[indices['Hips']].translation.copy();source_hips=source['Hips'].translation.copy()
goals={};errors={}
for side in ['Left','Right']:
upper=indices[side+'UpLeg'];lower=indices[side+'Leg'];foot=indices[side+'Foot']
source_foot=source[side+'Foot'].translation-source_hips;source_knee=source[side+'Leg'].translation-source_hips
goal=target_hips+Vector(tuple(source_foot[i]*scale[i] for i in range(3)))
hint=target_hips+Vector(tuple(source_knee[i]*scale[i] for i in range(3)))
h=world[upper].translation.copy();k=world[lower].translation.copy();f=world[foot].translation.copy()
foot_q=world[foot].to_quaternion().copy();a=(k-h).length;b=(f-k).length
direction=goal-h;wanted_distance=direction.length;direction.normalize()
distance=max(abs(a-b)+1e-7,min(wanted_distance,a+b-1e-7))
reachable=h+direction*distance
bend=hint-h-direction*(hint-h).dot(direction)
if bend.length<1e-7:bend=k-h-direction*(k-h).dot(direction)
if bend.length<1e-7:bend=Vector((0,0,-1))-direction*direction.dot(Vector((0,0,-1)))
bend.normalize();along=(a*a+distance*distance-b*b)/(2*distance)
desired_knee=h+direction*along+bend*math.sqrt(max(0,a*a-along*along))
upper_q=(k-h).rotation_difference(desired_knee-h)@world[upper].to_quaternion()
local[upper][1]=world[parents[upper]].to_quaternion().inverted()@upper_q
world=fk()
lower_q=(world[foot].translation-world[lower].translation).rotation_difference(reachable-world[lower].translation)@world[lower].to_quaternion()
local[lower][1]=world[parents[lower]].to_quaternion().inverted()@lower_q
world=fk();local[foot][1]=world[parents[foot]].to_quaternion().inverted()@foot_q;world=fk()
goals[side]=list(goal);errors[side]=(world[foot].translation-goal).length
values=[]
for i,(p,q,s) in enumerate(local):
if i in previous and previous[i].dot(q)<0:q.negate()
previous[i]=q.copy();values.append(list(p)+[q.x,q.y,q.z,q.w]+list(s))
samples.append(values)
lateral=(world[indices['LeftUpLeg']].translation-world[indices['RightUpLeg']].translation).normalized()
gap=(world[indices['LeftFoot']].translation-world[indices['RightFoot']].translation).dot(lateral)
tracks.append({'frame':frame+1,'footSignedGap':gap,'goalErrors':errors,'goals':goals,
'positions':{n:list(world[i].translation) for n,i in indices.items() if n in ['Hips','LeftUpLeg','RightUpLeg','LeftLeg','RightLeg','LeftFoot','RightFoot']}})
return {'name':'Walking_LmGirl'+('Mixamo33' if label=='mixamo' else '41'),'fps':30,'samples':samples}, {
'footMotionScale':list(scale),'minimumFootSignedGap':min(t['footSignedGap'] for t in tracks),
'crossedFrames':[t['frame'] for t in tracks if t['footSignedGap']<0],
'maxUnreachableGoalError':max(max(t['goalErrors'].values()) for t in tracks),'tracks':tracks}
def correct():
result={'method':'Pelvis-relative source ankle paths scaled by target hip width and hip-to-ankle span; analytic two-bone IK with source knee hints, fixed segment lengths and preserved mapped foot world rotations.',
'sourceClipUnmodified':True,'sourceClips':'CampGuide native Walking','motions':{},'reports':{}}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
result['motions'][label],result['reports'][label]=corrected_motion(json.loads(path.read_text()),label)
result['deformation']={}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
data=json.loads(path.read_text());motion=result['motions'][label];parents=[b['parent'] for b in data['bones']]
for variant in data['variants']:
points=np.array(variant['vertices']);p4=np.column_stack((points,np.ones(len(points))))
weights=np.zeros((len(points),len(parents)))
for i,row in enumerate(variant['weights']):
for j,w in row:weights[i,j]=w
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);lengths=edges[:,2];masks=edges[:,3].astype(int)
maxima={name:0 for name in ['all','hands','neck']};over=maxima.copy();floors=[]
for values in motion['samples']:
world=[]
for i,v in enumerate(values):
local=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@local if parents[i]>=0 else local)
deformed=np.zeros_like(points)
for i,b in enumerate(data['bones']):deformed+=weights[:,i,None]*(p4@np.array(world[i]@Matrix(b['bindpose'])).T)[:,:3]
ratios=np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/lengths;floors.append(float(deformed[:,1].min()))
for name,mask in [('all',1),('hands',2),('neck',4)]:
region=ratios[(masks&mask)!=0];maxima[name]=max(maxima[name],float(region.max()));over[name]=max(over[name],int((region>2).sum()))
result['deformation'][variant['name']]={'maxStretch':maxima,'maxEdgesOver2x':over,'floorMinRange':[min(floors),max(floors)]}
(FOLDER/'gait_correction.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='tracks'} for k,d in result['reports'].items()},indent=2))
def export_corrected():
result=json.loads((FOLDER/'gait_correction.json').read_text());C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
bpy.ops.wm.open_mainfile(filepath=str(FOLDER/('lmgirl_'+tag+'.blend')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');arm.animation_data_clear();arm.data.pose_position='POSE'
names=[b.name for b in arm.data.bones];data=json.loads((FOLDER/'unity_review_data.json').read_text());parents=[b['parent'] for b in data['bones']]
for b in arm.pose.bones:b.rotation_mode='QUATERNION'
for f,values in enumerate(result['motions']['tokenrig']['samples'],1):
bpy.context.scene.frame_set(f);world=[]
for i,v in enumerate(values):
m=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@m if parents[i]>=0 else m)
for i,n in enumerate(names):
b=arm.pose.bones[n];b.matrix=arm.matrix_world.inverted()@C@world[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=32;bpy.context.scene.render.fps=30
path=FOLDER/('Walking_'+tag+'_corrected.glb')
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
receipts.append({'file':str(path),'frames':[1,32],'source':result['motions']['tokenrig']['name']})
(FOLDER/'gait_export_manifest.json').write_text(json.dumps(receipts,indent=2),encoding='utf-8')
def verify_exports():
from mathutils.kdtree import KDTree
data=json.loads((FOLDER/'unity_review_data.json').read_text());result=json.loads((FOLDER/'gait_correction.json').read_text());receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
variant=next(v for v in data['variants'] if v['name']==tag)
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(FOLDER/('Walking_'+tag+'_corrected.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')
# Mesh raw geometry is Blender Z-up; convert to native review Y-up.
points=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices]);points=points[:,[0,2,1]]
tree=KDTree(len(points))
for i,p in enumerate(points):tree.insert(Vector(p),i)
tree.balance();mapping=[]
for p in variant['vertices']:
_,index,distance=tree.find(Vector(p));assert distance<1e-5;mapping.append(index)
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);max_stretch=0;min_gap=100
for frame in range(1,33):
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()
max_stretch=max(max_stretch,float((np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/edges[:,2]).max()))
p={n:arm.matrix_world@arm.pose.bones['mixamorig:'+n].head for n in ['LeftUpLeg','RightUpLeg','LeftFoot','RightFoot']}
lateral=(p['LeftUpLeg']-p['RightUpLeg']).normalized();min_gap=min(min_gap,(p['LeftFoot']-p['RightFoot']).dot(lateral))
error=abs(max_stretch-result['deformation'][tag]['maxStretch']['all']);assert error<.003,(tag,error)
assert min_gap>.08,(tag,min_gap)
receipts.append({'variant':tag,'maxStretch':max_stretch,'error':error,'minimumFootSignedGap':min_gap})
(FOLDER/'gait_export_verification.json').write_text(json.dumps({'passed':True,'results':receipts},indent=2),encoding='utf-8')
print('GAIT_EXPORTS_VERIFIED',len(receipts))
if __name__=='__main__':
if '--correct' in sys.argv:correct()
elif '--export' in sys.argv:export_corrected()
elif '--verify-exports' in sys.argv:verify_exports()
else:audit()
@@ -0,0 +1,109 @@
"""Export exact comparison mesh/bind/pose data for Pipeline-authored Unity assets.
Blender Z-up -> Unity Y-up is (x,z,y), so anatomical -Y faces Unity -Z.
All asset creation is subsequently performed inside Unity through Pipeline.
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
out=folder/'unity_review'
out.mkdir(exist_ok=True)
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)
result={'coordinateSystem':'Unity Y-up, -Z front; positions in metres','variants':[],'bones':[],'clips':[]}
names=None
for label,path in [('Mixamo',base/'CampGuideStudio.fbx'),('TokenRigRaw',folder/'tokenrig_mixamo41.blend'),('TokenRigLocal',folder/'tokenrig_mixamo41_local.blend')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.blend':bpy.ops.wm.open_mainfile(filepath=str(path))
else:bpy.ops.import_scene.fbx(filepath=str(path))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.data.objects if o.type=='MESH')
pts=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(pi/2,4,'X') if np.argmax(np.ptp(pts,axis=0))==1 else Matrix.Identity(4)
world={b.name:C@normalize@arm.matrix_world@b.matrix_local@C for b in arm.data.bones}
if names is None:
names=[b.name for b in arm.data.bones]
for b in arm.data.bones:
parent=b.parent.name if b.parent else None
local=world[parent].inverted()@world[b.name] if parent else world[b.name]
result['bones'].append({'name':b.name,'parent':names.index(parent) if parent else -1,
'rest':trs(local),'bindpose':[list(row) for row in world[b.name].inverted()]})
assert set(names)==set(arm.data.bones.keys())
weights=[]
for v in mesh.data.vertices:
w={mesh.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-8}
total=sum(w.values())
weights.append([[names.index(n),value/total] for n,value in sorted(w.items(),key=lambda x:-x[1])])
transform=C@normalize@mesh.matrix_world
normal_transform=transform.to_3x3().inverted().transposed()
mesh.data.calc_loop_triangles()
uv_layer=mesh.data.uv_layers.active
vertices=[];normals=[];uv=[];influences=[];triangles=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for loop_index in triangle.loops:
loop=mesh.data.loops[loop_index]
vi=loop.vertex_index
n=normal_transform@mesh.data.corner_normals[loop_index].vector
n.normalize()
tex=list(uv_layer.data[loop_index].uv) if uv_layer else [0,0]
key=(vi,*[round(x,6) for x in tex],*[round(x,6) for x in n])
if key not in lookup:
index=len(vertices);lookup[key]=index;first.setdefault(vi,index)
vertices.append(list(transform@mesh.data.vertices[vi].co));normals.append(list(n));uv.append(tex);influences.append(weights[vi])
ids.append(lookup[key])
triangles.extend([ids[0],ids[2],ids[1]])
# A reflection reverses the winding; the reversed triangle order above is
# checked visually in Unity and does not alter the comparison edge lengths.
raw=np.array([normalize@mesh.matrix_world@v.co for v in mesh.data.vertices])
height=float(np.ptp(raw[:,2]));z=(raw[:,2]-raw[:,2].min())/height
hand=(np.abs(raw[:,0])>height*.255)&(z>.55)&(z<.67)
neck=(z>.67)&(z<.78)&(np.abs(raw[:,0])<height*.12)
head=z>.78
edges=[]
for edge in mesh.data.edges:
a,b=edge.vertices
length=float(np.linalg.norm(raw[a]-raw[b]))
if length<=height*.002:continue
mask=1|(2 if hand[a] and hand[b] else 0)|(4 if neck[a] and neck[b] else 0)|(8 if head[a] and head[b] else 0)
edges.append([first[a],first[b],length,mask])
result['variants'].append({'name':label,'vertices':vertices,'normals':normals,'uv':uv,'triangles':triangles,
'weights':influences,'auditEdges':edges,'originalVertices':len(mesh.data.vertices),'trianglesCount':len(triangles)//3})
print('MESH',label,len(vertices),len(triangles)//3)
for label in ['Walking','Breathing Idle','Happy Idle']:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(base/('CampGuideStudio@'+label+'.fbx')))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
first,last=map(int,arm.animation_data.action.frame_range)
samples=[];previous={}
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
world={n:C@arm.matrix_world@arm.pose.bones[n].matrix@C for n in names}
sample=[]
for i,n in enumerate(names):
parent=result['bones'][i]['parent']
local=world[names[parent]].inverted()@world[n] if parent>=0 else world[n]
values=trs(local)
if n in previous and sum(a*b for a,b in zip(previous[n],values[3:7]))<0:
values[3:7]=[-x for x in values[3:7]]
previous[n]=values[3:7]
sample.append(values)
samples.append(sample)
result['clips'].append({'name':label.replace(' ','_'),'fps':30,'samples':samples})
print('CLIP',label,len(samples))
(out/'review_data.json').write_text(json.dumps(result,separators=(',',':')),encoding='utf-8')
print('EXPORTED',out/'review_data.json')
+387
View File
@@ -0,0 +1,387 @@
"""Export a reusable Mixamo rest pose, or fit it to a humanoid mesh.
Blender:
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- template <src.fbx> <dst.fbx>
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- fit <template.fbx> <mesh.fbx> <out.glb> [palm|full]
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- remap <fitted.glb> <tokenrig.glb> <out.glb>
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix, Vector
BODY = [
"mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2",
"mixamorig:Neck", "mixamorig:Head", "mixamorig:HeadTop_End",
"mixamorig:LeftShoulder", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
"mixamorig:RightShoulder", "mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot", "mixamorig:LeftToeBase", "mixamorig:LeftToe_End",
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot", "mixamorig:RightToeBase", "mixamorig:RightToe_End",
]
def argv():
return sys.argv[sys.argv.index("--") + 1:]
def mesh_objects():
return [o for o in bpy.context.scene.objects if o.type == "MESH"]
def armature():
arms = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise SystemExit(f"expected one armature, found {len(arms)}")
return arms[0]
def apply_transform(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def bone_heads(arm):
return {b.name: arm.matrix_world @ b.head_local for b in arm.data.bones}
def points_of(obj):
if obj.type == "MESH":
return np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=np.float64)
return np.array([obj.matrix_world @ b.head_local for b in obj.data.bones], dtype=np.float64)
def rotate_axis_to_y(obj, axis):
if axis == 1:
return
rotation = Matrix.Rotation(-pi / 2, 4, "X") if axis == 2 else Matrix.Rotation(pi / 2, 4, "Z")
obj.matrix_world = rotation @ obj.matrix_world
apply_transform(obj)
def put_height_on_y(obj):
if obj.type == "ARMATURE":
heads = bone_heads(obj)
delta = heads["mixamorig:Head"] - heads["mixamorig:Hips"]
axis = int(np.argmax(np.abs([delta.x, delta.y, delta.z])))
else:
span = points_of(obj).max(0) - points_of(obj).min(0)
order = np.argsort(span)
axis = int(order[-1] if span[order[-1]] > span[order[-2]] * 1.25 else order[-2])
rotate_axis_to_y(obj, axis)
def face_negative_z(arm):
heads = bone_heads(arm)
forward = heads["mixamorig:LeftToeBase"] - heads["mixamorig:LeftFoot"]
if abs(forward.x) > abs(forward.z):
arm.matrix_world = Matrix.Rotation(pi / 2 if forward.x > 0 else -pi / 2, 4, "Y") @ arm.matrix_world
apply_transform(arm)
heads = bone_heads(arm)
forward = heads["mixamorig:LeftToeBase"] - heads["mixamorig:LeftFoot"]
if forward.z > 0:
arm.matrix_world = Matrix.Rotation(pi, 4, "Y") @ arm.matrix_world
apply_transform(arm)
def clear_motion(arm):
if arm.animation_data:
arm.animation_data_clear()
arm.data.pose_position = "REST"
def export_fbx(arm, path):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.fbx(
filepath=str(path), use_selection=True, object_types={"ARMATURE"},
add_leaf_bones=False, bake_anim=False,
axis_forward="-Z", axis_up="Y", bake_space_transform=False,
)
def template(src, dst):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(src))
for obj in list(mesh_objects()):
bpy.data.objects.remove(obj, do_unlink=True)
arm = armature()
clear_motion(arm)
put_height_on_y(arm)
face_negative_z(arm)
heads = bone_heads(arm)
feet = [heads[n] for n in heads if n.endswith("Foot") or n.endswith("ToeBase") or n.endswith("Toe_End")]
min_y = min(p.y for p in feet)
center_x = (heads["mixamorig:LeftUpLeg"].x + heads["mixamorig:RightUpLeg"].x) * 0.5
center_z = heads["mixamorig:Hips"].z
arm.location = Vector((-center_x, -min_y, -center_z))
apply_transform(arm)
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
export_fbx(arm, dst)
heads = bone_heads(arm)
top = max(p.y for p in heads.values())
info = {
"bones": len(arm.data.bones),
"names": [b.name for b in arm.data.bones],
"height": round(top, 4),
"up": "Y",
"front": "-Z",
"left": "-X",
"source": str(src),
}
dst.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print("TEMPLATE", json.dumps({k: info[k] for k in ("bones", "height", "up", "front")}))
def slice_centers(pts, y0, y1):
sl = pts[(pts[:, 1] >= y0) & (pts[:, 1] < y1)]
return sl
def landmarks(pts):
lo, hi = pts.min(0), pts.max(0)
height = hi[1] - lo[1]
bands = np.linspace(lo[1], hi[1], 36)
spans = []
for a, b in zip(bands[:-1], bands[1:]):
sl = slice_centers(pts, a, b)
spans.append(0 if len(sl) < 8 else sl[:, 0].max() - sl[:, 0].min())
arm_i = int(np.argmax(spans))
arm_y = (bands[arm_i] + bands[arm_i + 1]) * 0.5
arm = slice_centers(pts, arm_y - height * 0.04, arm_y + height * 0.04)
reach = max(abs(arm[:, 0].min()), abs(arm[:, 0].max()))
left_hand = arm[arm[:, 0] < -reach * 0.55].mean(0)
right_hand = arm[arm[:, 0] > reach * 0.55].mean(0)
torso = arm[np.abs(arm[:, 0]) < reach * 0.38]
if len(torso) < 8:
torso = arm
shoulders = np.array([torso[:, 0].min(), arm_y, torso[:, 2].mean()])
feet = pts[pts[:, 1] < lo[1] + height * 0.12]
left_foot = feet[feet[:, 0] < 0].mean(0)
right_foot = feet[feet[:, 0] > 0].mean(0)
crotch_y = arm_y
for a, b in zip(bands[:arm_i][::-1], bands[1:arm_i + 1][::-1]):
sl = slice_centers(pts, a, b)
if len(sl) < 8:
continue
xs = np.sort(sl[:, 0])
if np.diff(xs).max() > reach * 0.18:
crotch_y = (a + b) * 0.5
break
hip_y = min(arm_y - height * 0.05, crotch_y + height * 0.06)
head = pts[pts[:, 1] > lo[1] + height * 0.78]
return {
"height": height,
"arm_y": arm_y,
"hip_y": hip_y,
"head": head.mean(0),
"top": hi[1],
"left_hand": left_hand,
"right_hand": right_hand,
"left_shoulder_x": float(torso[:, 0].min()),
"right_shoulder_x": float(torso[:, 0].max()),
"left_foot": left_foot,
"right_foot": right_foot,
"depth": head[:, 2].mean() if False else float(head[:, 2].mean()),
}
def chain_fraction(arm, names):
ys = [(arm.matrix_world @ arm.data.bones[n].head_local).y for n in names if n in arm.data.bones]
span = ys[-1] - ys[0]
if abs(span) < 1e-6:
return [0.0 for _ in ys]
return [(y - ys[0]) / span for y in ys]
def move_bones(arm, marks):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
torso_names = [n for n in ("mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2", "mixamorig:Neck", "mixamorig:Head", "mixamorig:HeadTop_End") if n in arm.data.bones]
torso_fr = chain_fraction(arm, torso_names)
side_fr = {}
for side in ("Left", "Right"):
arm_names = [f"mixamorig:{side}{p}" for p in ("Shoulder", "Arm", "ForeArm", "Hand") if f"mixamorig:{side}{p}" in arm.data.bones]
leg_names = [n for n in (f"mixamorig:{side}UpLeg", f"mixamorig:{side}Leg", f"mixamorig:{side}Foot") if n in arm.data.bones]
side_fr[side] = (arm_names, chain_fraction(arm, arm_names), leg_names, chain_fraction(arm, leg_names))
bpy.ops.object.mode_set(mode="EDIT")
bones = arm.data.edit_bones
rolls = {b.name: b.roll for b in bones}
present = {b.name: b for b in bones}
def put(name, head, tail):
bone = present[name]
bone.use_connect = False
bone.head = Vector(head)
bone.tail = Vector(tail)
bone.roll = rolls[name]
def between(a, b, t):
return a * (1 - t) + b * t
fr = torso_fr
hip = np.array([0.0, marks["hip_y"], marks["depth"]])
crown = np.array([marks["head"][0] * 0.2, marks["top"], marks["head"][2]])
placed = {}
for name, t in zip(torso_names, fr):
placed[name] = between(hip, crown, t)
for i, name in enumerate(torso_names):
nxt = placed[torso_names[i + 1]] if i + 1 < len(torso_names) else placed[name] + np.array([0, marks["height"] * 0.03, 0])
put(name, placed[name], nxt)
for side, sign, hand, foot, shoulder_x in (
("Left", -1, marks["left_hand"], marks["left_foot"], marks["left_shoulder_x"]),
("Right", 1, marks["right_hand"], marks["right_foot"], marks["right_shoulder_x"]),
):
shoulder = np.array([shoulder_x, marks["arm_y"], marks["depth"]])
hand = np.array(hand)
arm_names, afr, leg_core, lfr = side_fr[side]
joints = [between(shoulder, hand, t) for t in afr]
for i, name in enumerate(arm_names):
nxt = joints[i + 1] if i + 1 < len(joints) else joints[i] + (hand - shoulder) * 0.08
put(name, joints[i], nxt)
hip_j = np.array([foot[0] * 0.72, marks["hip_y"], marks["depth"]])
foot_j = np.array([foot[0], max(foot[1], 0.0), foot[2]])
leg_names = leg_core + [n for n in (f"mixamorig:{side}ToeBase", f"mixamorig:{side}Toe_End") if n in present]
leg = [between(hip_j, foot_j, t) for t in lfr]
toe = foot_j + np.array([0.0, 0.0, -abs(marks["height"]) * 0.06])
toe_end = toe + np.array([0.0, 0.0, -abs(marks["height"]) * 0.035])
extra = {f"mixamorig:{side}ToeBase": toe, f"mixamorig:{side}Toe_End": toe_end}
seq = leg + [extra[n] for n in leg_names[3:]]
for i, name in enumerate(leg_names):
nxt = seq[i + 1] if i + 1 < len(seq) else seq[i] + np.array([0, 0, -0.02])
put(name, seq[i], nxt)
bpy.ops.object.mode_set(mode="OBJECT")
def drop_fingers(arm):
bpy.ops.object.mode_set(mode="EDIT")
keys = ("Thumb", "Index", "Middle", "Ring", "Pinky")
guard = 0
while guard < 8:
guard += 1
bones = list(arm.data.edit_bones)
leaves = [b for b in bones if any(k in b.name for k in keys) and not any(c.parent == b for c in bones)]
if not leaves:
break
for bone in leaves:
arm.data.edit_bones.remove(bone)
bpy.ops.object.mode_set(mode="OBJECT")
def fit(template_path, mesh_path, out_path, fingers):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(mesh_path))
mesh = mesh_objects()[0]
put_height_on_y(mesh)
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(template_path))
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
clear_motion(arm)
put_height_on_y(arm)
face_negative_z(arm)
if fingers == "palm":
drop_fingers(arm)
marks = landmarks(points_of(mesh))
move_bones(arm, marks)
heads = bone_heads(arm)
print("MARKS", json.dumps({k: (round(float(v), 3) if np.ndim(v) == 0 else [round(float(x), 3) for x in v]) for k, v in marks.items() if k != "head"}))
for name in ("mixamorig:Hips", "mixamorig:Head", "mixamorig:LeftHand", "mixamorig:LeftFoot", "mixamorig:Spine2"):
p = heads[name]
print(name, round(p.x, 3), round(p.y, 3), round(p.z, 3))
mesh.parent = arm
arm.matrix_world = Matrix.Rotation(pi / 2, 4, "X") @ arm.matrix_world
apply_transform(arm)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
names = [b.name for b in arm.data.bones]
print("FIT", json.dumps({"bones": len(names), "fingers": fingers, "out": str(out)}))
def remap_names(source_glb, rigged_glb, out_glb):
"""TokenRig --use_skeleton renames joints to bone_* and can reverse a limb chain.
Match each output joint to the fitted Mixamo bone by head position, then copy
that bone's parent, head, tail and roll. Vertex groups follow the new names.
"""
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(source_glb))
src = armature()
bpy.context.view_layer.objects.active = src
bpy.ops.object.mode_set(mode="EDIT")
src_pose = {}
for bone in src.data.edit_bones:
src_pose[bone.name] = {
"parent": bone.parent.name if bone.parent else None,
"head": src.matrix_world @ bone.head,
"tail": src.matrix_world @ bone.tail,
"roll": bone.roll,
}
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(rigged_glb))
arm = armature()
mesh = mesh_objects()[0]
used = set()
mapping = {}
for bone in arm.data.bones:
head = arm.matrix_world @ bone.head_local
name, dist = min(
((n, (head - pose["head"]).length) for n, pose in src_pose.items() if n not in used),
key=lambda item: item[1],
)
used.add(name)
mapping[bone.name] = (name, dist)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
for old, (new, _dist) in mapping.items():
arm.data.edit_bones[old].name = new
for bone in arm.data.edit_bones:
bone.use_connect = False
bone.parent = None
for bone in arm.data.edit_bones:
pose = src_pose[bone.name]
bone.head = arm.matrix_world.inverted() @ pose["head"]
bone.tail = arm.matrix_world.inverted() @ pose["tail"]
bone.roll = pose["roll"]
for bone in arm.data.edit_bones:
parent = src_pose[bone.name]["parent"]
bone.parent = arm.data.edit_bones[parent] if parent else None
bpy.ops.object.mode_set(mode="OBJECT")
for group in mesh.vertex_groups:
if group.name in mapping:
group.name = mapping[group.name][0]
worst = max(dist for _, dist in mapping.values())
if worst > 0.05:
raise SystemExit(f"bone heads moved, worst={worst:.4f}")
out = Path(out_glb)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
print("REMAP", json.dumps({"bones": len(mapping), "worst": round(worst, 4), "out": str(out)}))
def main():
args = argv()
if args[0] == "template":
template(args[1], args[2])
elif args[0] == "fit":
fit(args[1], args[2], args[3], args[4] if len(args) > 4 else "palm")
elif args[0] == "remap":
remap_names(args[1], args[2], args[3])
else:
raise SystemExit("use template, fit, or remap")
if __name__ == "__main__":
main()
+316
View File
@@ -0,0 +1,316 @@
"""Fit a Kimodo SOMA rest pose onto a humanoid mesh without changing bone axes.
Blender:
blender -b --python Tools/model_tools/bl_fit_soma.py -- template <motion.bvh> <dst.fbx>
blender -b --python Tools/model_tools/bl_fit_soma.py -- fit <template.fbx> <mesh.fbx> <out.glb> [palm|full]
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix, Vector
DROP = ("Thumb", "Index", "Middle", "Ring", "Pinky", "Jaw", "Eye")
def argv():
return sys.argv[sys.argv.index("--") + 1:]
def meshes():
return [o for o in bpy.context.scene.objects if o.type == "MESH"]
def armature():
arms = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise SystemExit(f"expected one armature, found {len(arms)}")
return arms[0]
def apply_transform(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def bone_heads(arm):
return {b.name: arm.matrix_world @ b.head_local for b in arm.data.bones}
def points_of(obj):
return np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=np.float64)
def rotate_up_to_z(obj):
if obj.type == "ARMATURE":
heads = bone_heads(obj)
delta = heads["Head"] - heads["Hips"]
axis = int(np.argmax(np.abs([delta.x, delta.y, delta.z])))
else:
span = points_of(obj).max(0) - points_of(obj).min(0)
axis = int(np.argmax(span))
if axis == 2:
return
rotation = Matrix.Rotation(pi / 2, 4, "X") if axis == 1 else Matrix.Rotation(-pi / 2, 4, "Y")
obj.matrix_world = rotation @ obj.matrix_world
apply_transform(obj)
def face_positive_y(arm):
heads = bone_heads(arm)
forward = heads["LeftToeBase"] - heads["LeftFoot"]
flat = Vector((forward.x, forward.y, 0.0))
if flat.length < 1e-6:
return
angle = flat.angle(Vector((0.0, 1.0, 0.0)), 0.0)
cross = flat.x * 1.0 - flat.y * 0.0
signed = angle if cross >= 0 else -angle
arm.matrix_world = Matrix.Rotation(signed, 4, "Z") @ arm.matrix_world
apply_transform(arm)
heads = bone_heads(arm)
if heads["LeftHand"].x > heads["RightHand"].x:
arm.matrix_world = Matrix.Rotation(pi, 4, "Z") @ arm.matrix_world
apply_transform(arm)
def clear_motion(arm):
if arm.animation_data:
arm.animation_data_clear()
arm.data.pose_position = "REST"
def plant(arm):
heads = bone_heads(arm)
feet = [heads[n] for n in heads if n.endswith("Foot") or "Toe" in n]
hips = heads["Hips"]
arm.location = Vector((-hips.x, -hips.y, -min(p.z for p in feet)))
apply_transform(arm)
def extend_stubs(arm):
bpy.ops.object.mode_set(mode="EDIT")
for bone in arm.data.edit_bones:
if (bone.tail - bone.head).length > 1e-4:
continue
direction = bone.parent.vector.normalized() if bone.parent and bone.parent.vector.length > 1e-6 else Vector((0.0, 1.0, 0.0))
bone.tail = bone.head + direction * 0.015
bpy.ops.object.mode_set(mode="OBJECT")
def export_fbx(arm, path):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.fbx(
filepath=str(path), use_selection=True, object_types={"ARMATURE"},
add_leaf_bones=False, bake_anim=False,
axis_forward="Y", axis_up="Z",
)
def template(bvh, dst):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_anim.bvh(filepath=str(bvh), global_scale=0.01, rotate_mode="QUATERNION")
arm = armature()
clear_motion(arm)
rotate_up_to_z(arm)
plant(arm)
extend_stubs(arm)
export_fbx(arm, dst)
heads = bone_heads(arm)
info = {
"bones": len(arm.data.bones),
"names": [b.name for b in arm.data.bones],
"height": round(max(p.z for p in heads.values()) - min(p.z for p in heads.values()), 4),
"up": "Z-in-blender",
"front": "+Y-in-blender",
"left": "-X",
"source": str(bvh),
}
Path(dst).with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
hand = heads["LeftHand"]
toe = heads["LeftToeBase"] - heads["LeftFoot"]
print("TEMPLATE", json.dumps({"bones": info["bones"], "height": info["height"], "leftHandX": round(hand.x, 3), "toeY": round(toe.y, 3)}))
def landmarks(pts):
lo, hi = pts.min(0), pts.max(0)
height = hi[2] - lo[2]
bands = np.linspace(lo[2] + height * 0.25, lo[2] + height * 0.8, 12)
spans = []
for a, b in zip(bands[:-1], bands[1:]):
sl = pts[(pts[:, 2] >= a) & (pts[:, 2] < b)]
spans.append(0 if len(sl) < 8 else sl[:, 0].max() - sl[:, 0].min())
arm_i = int(np.argmax(spans))
arm_z = (bands[arm_i] + bands[arm_i + 1]) * 0.5
arm = pts[np.abs(pts[:, 2] - arm_z) < height * 0.04]
reach = max(abs(arm[:, 0].min()), abs(arm[:, 0].max()))
torso = arm[np.abs(arm[:, 0]) < reach * 0.38]
if len(torso) < 8:
torso = arm
feet = pts[pts[:, 2] < lo[2] + height * 0.12]
head = pts[pts[:, 2] > lo[2] + height * 0.78]
hip_z = arm_z - height * 0.16
return {
"height": float(height),
"arm_z": float(arm_z),
"hip_z": float(hip_z),
"head": head.mean(0),
"top": float(hi[2]),
"left_hand": arm[arm[:, 0] > reach * 0.55].mean(0),
"right_hand": arm[arm[:, 0] < -reach * 0.55].mean(0),
"left_foot": feet[feet[:, 0] > 0].mean(0),
"right_foot": feet[feet[:, 0] < 0].mean(0),
"depth": float(torso[:, 1].mean()),
}
def subtree(bones, root):
names = [root]
pending = [root]
while pending:
parent = pending.pop()
for bone in bones:
if bone.parent and bone.parent.name == parent and bone.name not in names:
names.append(bone.name)
pending.append(bone.name)
return names
def scale_from(bones, names, pivot, factor):
for name in names:
bone = bones[name]
bone.head = pivot + (bone.head - pivot) * factor
bone.tail = pivot + (bone.tail - pivot) * factor
def drop_extras(arm):
bpy.ops.object.mode_set(mode="EDIT")
guard = 0
while guard < 8:
guard += 1
bones = list(arm.data.edit_bones)
leaves = [b for b in bones if any(k in b.name for k in DROP) and not any(c.parent == b for c in bones)]
if not leaves:
break
for bone in leaves:
arm.data.edit_bones.remove(bone)
bpy.ops.object.mode_set(mode="OBJECT")
def fit(template_path, mesh_path, out_path, fingers):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(mesh_path))
mesh = max(meshes(), key=lambda item: len(item.data.vertices))
for extra in list(meshes()):
if extra != mesh:
bpy.data.objects.remove(extra, do_unlink=True)
rotate_up_to_z(mesh)
mesh.matrix_world = Matrix.Rotation(pi, 4, "Z") @ mesh.matrix_world
apply_transform(mesh)
before = set(bpy.data.objects)
if str(template_path).lower().endswith(".bvh"):
bpy.ops.import_anim.bvh(filepath=str(template_path), global_scale=0.01, rotate_mode="QUATERNION")
else:
bpy.ops.import_scene.fbx(filepath=str(template_path))
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
clear_motion(arm)
rotate_up_to_z(arm)
plant(arm)
if fingers == "palm":
drop_extras(arm)
extend_stubs(arm)
marks = landmarks(points_of(mesh))
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
bones = arm.data.edit_bones
for bone in bones:
bone.use_connect = False
height = max(b.head.z for b in bones) - min(b.head.z for b in bones)
scale_from(bones, [b.name for b in bones], Vector((0.0, 0.0, 0.0)), marks["height"] / height)
hips = bones["Hips"].head.copy()
shift = Vector((0.0, marks["depth"] - hips.y, marks["hip_z"] - hips.z))
for bone in bones:
bone.head += shift
bone.tail += shift
before_dirs = {b.name: b.vector.normalized() for b in bones if b.vector.length > 1e-6}
hip = bones["Hips"].head.copy()
torso_names = subtree(bones, "Spine1")
head_z = bones["Head"].head.z
torso_factor = (marks["head"][2] - hip.z) / (head_z - hip.z)
print("FACTOR torso", round(torso_factor, 3))
if not 0.4 <= torso_factor <= 2.5:
raise SystemExit(f"torso factor {torso_factor}")
scale_from(bones, torso_names, hip, torso_factor)
for side, target in (("Left", marks["left_hand"]), ("Right", marks["right_hand"])):
pivot = bones[f"{side}Arm"].head.copy()
hand = bones[f"{side}Hand"].head
print("HAND", side, "bone", round(hand.x, 3), "target", round(float(target[0]), 3), "pivot", round(pivot.x, 3))
factor = (target[0] - pivot.x) / (hand.x - pivot.x)
print("FACTOR", side, "arm", round(factor, 3))
if not 0.2 <= factor <= 2.5:
raise SystemExit(f"{side} arm factor {factor}")
names = subtree(bones, f"{side}Arm")
scale_from(bones, names, pivot, factor)
hand = bones[f"{side}Hand"].head
delta = Vector((0.0, target[1] - hand.y, target[2] - hand.z))
for name in names:
bones[name].head += delta
bones[name].tail += delta
for side, target in (("Left", marks["left_foot"]), ("Right", marks["right_foot"])):
pivot = bones[f"{side}Leg"].head.copy()
foot = bones[f"{side}Foot"].head
factor = (target[2] - pivot.z) / (foot.z - pivot.z)
print("FACTOR", side, "leg", round(factor, 3))
if not 0.2 <= factor <= 2.5:
raise SystemExit(f"{side} leg factor {factor}")
names = subtree(bones, f"{side}Leg")
scale_from(bones, names, pivot, factor)
foot = bones[f"{side}Foot"].head
delta = Vector((target[0] - foot.x, target[1] - foot.y, 0.0))
for name in names:
bones[name].head += delta
bones[name].tail += delta
worst = 0.0
for name, direction in before_dirs.items():
if name in ("LeftShoulder", "RightShoulder"):
continue
now = bones[name].vector.normalized()
worst = max(worst, direction.angle(now, 0.0))
bpy.ops.object.mode_set(mode="OBJECT")
heads = bone_heads(arm)
print("MARKS", json.dumps({k: (round(float(v), 3) if np.ndim(v) == 0 else [round(float(x), 3) for x in v]) for k, v in marks.items() if k != "head"}))
for name in ("Hips", "Head", "LeftHand", "LeftFoot", "Chest"):
p = heads[name]
print(name, round(p.x, 3), round(p.y, 3), round(p.z, 3))
print("AXIS_DRIFT_DEG", round(worst * 180 / pi, 3))
if worst > 0.02:
raise SystemExit("bone axes moved")
mesh.parent = arm
arm.name = "Armature"
arm.data.name = "Armature"
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
print("FIT", json.dumps({"bones": len(arm.data.bones), "fingers": fingers, "out": str(out)}))
def main():
args = argv()
if args[0] == "template":
template(args[1], args[2])
elif args[0] == "fit":
fit(args[1], args[2], args[3], args[4] if len(args) > 4 else "palm")
else:
raise SystemExit("use template or fit")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
"""Create an upright Mixamo upload FBX from the legacy Y-up-in-Blender mesh.
Do not replace the original: existing rigs and review assets use its coordinates.
"""
import sys,math,json,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[2]
SOURCE=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
TARGET=OUT/'LmGirlTpose_Mixamo.fbx'
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
def vertices(o):return np.array([o.matrix_world@v.co for v in o.data.vertices],dtype=float)
def uv(o):return np.array([tuple(v.uv) for v in o.data.uv_layers.active.data],dtype=np.float32)
def triangles(o):return sum(len(p.vertices)-2 for p in o.data.polygons)
OUT.mkdir(parents=True,exist_ok=True)
before_hash=digest(SOURCE)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(SOURCE),use_anim=False)
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
assert len(meshes)==1 and not any(o.type=='ARMATURE' for o in bpy.context.scene.objects)
obj=meshes[0];old_points=vertices(obj);old_uv=uv(obj);old_triangles=triangles(obj)
assert np.argmax(np.ptp(old_points,axis=0))==1,'This repair is only for the legacy Y-up Blender source'
# Blender's exporter expects a Z-up scene. Restore that before its Y-up conversion.
rotation=Matrix.Rotation(math.pi/2,4,'X')
obj.matrix_world=rotation@obj.matrix_world
bpy.context.view_layer.update()
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True);bpy.context.view_layer.objects.active=obj
bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
expected=vertices(obj)
assert np.argmax(np.ptp(expected,axis=0))==2
bpy.ops.export_scene.fbx(filepath=str(TARGET),use_selection=True,object_types={'MESH'},
axis_forward='-Z',axis_up='Y',bake_space_transform=True,
apply_unit_scale=True,apply_scale_options='FBX_SCALE_ALL',bake_anim=False,
path_mode='STRIP',embed_textures=False)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(TARGET),use_anim=False)
restored=next(o for o in bpy.context.scene.objects if o.type=='MESH')
points=vertices(restored);new_uv=uv(restored)
assert points.shape==expected.shape and np.max(np.abs(points-expected))<2e-6
assert np.array_equal(new_uv,old_uv),'UVs changed'
assert triangles(restored)==old_triangles
tree,_=parse_fbx.parse(str(TARGET))
objects=next(e for e in tree.elems if e.id==b'Objects')
model=next(e for e in objects.elems if e.id==b'Model')
props=next(e for e in model.elems if e.id==b'Properties70')
node_props={e.props[0].decode():list(e.props[4:]) for e in props.elems}
assert max(abs(v) for v in node_props.get('Lcl Rotation',[0,0,0]))<1e-5
assert all(abs(v-1)<1e-5 for v in node_props.get('Lcl Scaling',[1,1,1]))
geometry=next(e for e in objects.elems if e.id==b'Geometry')
raw_points=np.array(next(e for e in geometry.elems if e.id==b'Vertices').props[0]).reshape(-1,3)
assert np.max(np.abs(raw_points-old_points))<2e-6,'Y-up geometry/front changed'
assert digest(SOURCE)==before_hash,'Original source changed'
report=dict(source=str(SOURCE.relative_to(ROOT)),output=str(TARGET.relative_to(ROOT)),
source_sha256=before_hash,output_sha256=digest(TARGET),source_unchanged=True,
repair='Restore Blender Z-up scene (+90 X), apply object transforms, export Y-up with baked space conversion',
vertices=len(points),triangles=old_triangles,uv_unchanged=True,
raw_fbx_node_rotation_degrees=node_props.get('Lcl Rotation',[0,0,0]),
raw_fbx_node_scale=node_props.get('Lcl Scaling',[1,1,1]),
raw_yup_geometry_max_error_m=float(np.max(np.abs(raw_points-old_points))),
max_roundtrip_position_error_m=float(np.max(np.abs(points-expected))),
blender_roundtrip_rotation_degrees=[math.degrees(v) for v in restored.rotation_euler],
blender_roundtrip_bounds_min=points.min(0).tolist(),blender_roundtrip_bounds_max=points.max(0).tolist(),
online_validation='Not uploaded to Mixamo; local FBX metadata and independent reimport checked')
(OUT/'repair.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
@@ -0,0 +1,30 @@
"""Inventory local Mixamo FBX files without editing or exporting the inputs."""
import json
import sys
from pathlib import Path
import bpy
src, out = map(Path, sys.argv[sys.argv.index('--')+1:])
results = []
for path in sorted(src.glob('*.fbx')):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path))
item = {'file': str(path), 'fps': bpy.context.scene.render.fps, 'objects': [],
'actions': [{'name': a.name, 'frames': list(a.frame_range)} for a in bpy.data.actions]}
for obj in bpy.context.scene.objects:
entry = {'name': obj.name, 'type': obj.type, 'matrix_world': [list(row) for row in obj.matrix_world]}
if obj.type == 'ARMATURE':
entry['bones'] = {b.name: {'head': list(obj.matrix_world @ b.head_local), 'tail': list(obj.matrix_world @ b.tail_local),
'parent': b.parent.name if b.parent else None} for b in obj.data.bones}
entry['action'] = obj.animation_data.action.name if obj.animation_data and obj.animation_data.action else None
elif obj.type == 'MESH':
coords = [obj.matrix_world @ v.co for v in obj.data.vertices]
entry.update(vertices=len(coords), triangles=sum(len(p.vertices)-2 for p in obj.data.polygons),
bounds=[[min(p[i] for p in coords) for i in range(3)], [max(p[i] for p in coords) for i in range(3)]],
groups=len(obj.vertex_groups), modifiers=[{'type': m.type, 'target': m.object.name if m.type == 'ARMATURE' and m.object else None} for m in obj.modifiers])
item['objects'].append(entry)
results.append(item)
print('INVENTORY', path.name, item['fps'], item['actions'], [(o['type'], o['name'], o.get('triangles'), len(o.get('bones', {}))) for o in item['objects']])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2), encoding='utf-8')
+372
View File
@@ -0,0 +1,372 @@
"""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')
@@ -0,0 +1,122 @@
"""Inspect/export the user's Mixamo LmGirl without changing its skin weights."""
import json
import sys
from pathlib import Path
import math
import bpy
import numpy as np
from mathutils import Matrix, Vector, Quaternion
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41/website'
SOURCE=ROOT/'AIClient/Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx'
def inspect():
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(SOURCE))
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')
p=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(math.pi/2,4,'X') if np.argmax(np.ptp(p,axis=0))==1 else Matrix.Identity(4)
p=np.array([normalize@mesh.matrix_world@v.co for v in mesh.data.vertices])
info={'source':str(SOURCE),'vertices':len(p),'triangles':sum(len(x.vertices)-2 for x in mesh.data.polygons),
'bounds':[p.min(0).tolist(),p.max(0).tolist()],
'bones':{b.name:{'parent':b.parent.name if b.parent else None,'matrix':[list(r) for r in normalize@arm.matrix_world@b.matrix_local]} for b in arm.data.bones},
'maxInfluences':max(len(v.groups) for v in mesh.data.vertices),'hasAnimation':bool(arm.animation_data and arm.animation_data.action)}
OUT.mkdir(parents=True,exist_ok=True);(OUT/'inspection.json').write_text(json.dumps(info,indent=2),encoding='utf-8')
print(json.dumps({k:v for k,v in info.items() if k!='bones'}));print('BONES',len(info['bones']),list(info['bones']))
def export():
from mathutils.kdtree import KDTree
inspect()
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.animation_data_clear()
points=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(math.pi/2,4,'X') if np.argmax(np.ptp(points,axis=0))==1 else Matrix.Identity(4)
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
transform=C@normalize@mesh.matrix_world
points=np.array([transform@v.co for v in mesh.data.vertices])
names=[b.name for b in arm.data.bones]
world={b.name:C@normalize@arm.matrix_world@b.matrix_local@C for b in arm.data.bones}
source=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
source_index={b['name']:i for i,b in enumerate(source['bones'])}
source_world={b['name']:Matrix(b['bindpose']).inverted() for b in source['bones']}
assert set(names).issubset(source_index)
bones=[];offsets=[];delta_rotations=[];corrections=[]
def trs(m):
p,q,s=m.decompose();return list(p)+[q.x,q.y,q.z,q.w]+list(s)
def xyzw(q):return [q.x,q.y,q.z,q.w]
target_span=world['mixamorig:Hips'].translation.y-sum(world['mixamorig:'+s+'Foot'].translation.y for s in ['Left','Right'])/2
source_span=source_world['mixamorig:Hips'].translation.y-sum(source_world['mixamorig:'+s+'Foot'].translation.y for s in ['Left','Right'])/2
ratio=target_span/source_span
for b in arm.data.bones:
parent=b.parent.name if b.parent else None
local=world[parent].inverted()@world[b.name] if parent else world[b.name]
values=trs(local);src=source['bones'][source_index[b.name]]['rest']
delta=(world[parent].to_quaternion().inverted()@source_world[parent].to_quaternion()) if parent else Quaternion()
correction=source_world[b.name].to_quaternion().inverted()@world[b.name].to_quaternion()
bones.append({'name':b.name,'parent':names.index(parent) if parent else -1,'rest':values,'bindpose':[list(r) for r in world[b.name].inverted()]})
offsets.append(list(Vector(values[:3])-(delta@Vector(src[:3]))*ratio));delta_rotations.append(xyzw(delta));corrections.append(xyzw(correction))
weights=[]
for v in mesh.data.vertices:
row={mesh.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-8}
assert all(n in names for n in row)
total=sum(row.values());weights.append([[names.index(n),float(w/total)] for n,w in sorted(row.items(),key=lambda x:-x[1])])
# Confirm identical geometry to the exact static/TokenRig input.
target=json.loads((OUT.parent/'unity_review_data.json').read_text())
reference=target['variants'][0]['vertices'];tree=KDTree(len(reference))
for i,p in enumerate(reference):tree.insert(Vector(p),i)
tree.balance();maximum=max(tree.find(Vector(p))[2] for p in points);assert maximum<1e-5,maximum
normal_matrix=transform.to_3x3().inverted().transposed();mesh.data.calc_loop_triangles();uv_layer=mesh.data.uv_layers.active
vertices=[];normals=[];uv=[];influences=[];triangles=[];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(x,6) for x in tex],*[round(x,6) for x in n])
if key not in lookup:
index=len(vertices);lookup[key]=index;first.setdefault(vi,index)
vertices.append(points[vi].tolist());normals.append(list(n));uv.append(tex);influences.append(weights[vi])
ids.append(lookup[key])
triangles.extend([ids[0],ids[2],ids[1]])
height=float(np.ptp(points[:,1]));edges=np.array([list(e.vertices) for e in mesh.data.edges])
lengths=np.linalg.norm(points[edges[:,0]]-points[edges[:,1]],axis=1);valid=lengths>height*.002
hands=np.abs(points[:,0])>.32;neck=(points[:,1]>.91)&(points[:,1]<.99)&(np.abs(points[:,0])<.1)
masks={'all':valid,'hands':valid&hands[edges[:,0]]&hands[edges[:,1]],'neck':valid&neck[edges[:,0]]&neck[edges[:,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 valid[i]]
variant={'name':'mixamo','vertices':vertices,'normals':normals,'uv':uv,'triangles':triangles,'weights':influences,
'auditEdges':audit_edges,'originalVertices':len(points),'trianglesCount':len(triangles)//3}
data={'bones':bones,'variants':[variant],'height':height,'translationScale':ratio,'translationOffsets':offsets,
'translationDeltaRotations':delta_rotations,'bindRotationCorrections':corrections,'rotationParents':[b['parent'] for b in bones]}
(OUT/'unity_review_data.json').write_text(json.dumps(data,separators=(',',':')),encoding='utf-8')
# Independent matrix-based LBS reference for native Unity verification.
dense=np.zeros((len(points),len(names)))
for i,row in enumerate(weights):
for j,w in row:dense[i,j]=w
p4=np.column_stack((points,np.ones(len(points))));maxs={k:0 for k in masks};over={k:0 for k in masks};samples=[]
for frame,sample in enumerate(source['clips'][0]['samples']):
source_pose=[]
for i,values in enumerate(sample):
local=Matrix.LocRotScale(Vector(values[:3]),Quaternion((values[6],*values[3:6])),Vector(values[7:10]))
parent=source['bones'][i]['parent'];source_pose.append(source_pose[parent]@local if parent>=0 else local)
target_pose=[];mapped_q=[]
for i,b in enumerate(bones):
n=b['name'];si=source_index[n];values=sample[si];parent=b['parent']
rot=source_pose[si].to_quaternion()@Quaternion((corrections[i][3],*corrections[i][:3]));mapped_q.append(rot)
local_q=mapped_q[parent].inverted()@rot if parent>=0 else rot
delta=Quaternion((delta_rotations[i][3],*delta_rotations[i][:3]))
local_p=delta@(Vector(values[:3])*ratio)+Vector(offsets[i])
local=Matrix.LocRotScale(local_p,local_q,Vector(values[7:10]));target_pose.append(target_pose[parent]@local if parent>=0 else local)
deformed=np.zeros_like(points)
for i,n in enumerate(names):deformed+=dense[:,i,None]*(p4@np.array(target_pose[i]@world[n].inverted()).T)[:,:3]
stretch=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(lengths,1e-10)
for k,mask in masks.items():maxs[k]=max(maxs[k],float(stretch[mask].max()));over[k]=max(over[k],int((stretch[mask]>2).sum()))
samples.append({'frame':frame+1,'maxStretch':float(stretch[valid].max())})
report={'source':str(SOURCE),'bones':len(names),'triangles':4999,'maxInfluences':max(len(w) for w in weights),
'geometryNearestError':maximum,'translationScale':ratio,'maxStretch':maxs,'maxEdgesOver2x':over,'samples':samples,
'weights':'Original website weights, normalized only; no top4 truncation or cleanup',
'motion':'Same shared Walking; source bind-relative world rotations mapped to website rest axes; target bind translations preserved',
'missingSourceBones':sorted(set(source_index)-set(names))}
(OUT/'walking_comparison.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('WEBSITE_EXPORTED',json.dumps({k:v for k,v in report.items() if k!='samples'}))
if __name__=='__main__':
export() if '--export' in sys.argv else inspect()
+2 -2
View File
@@ -26,12 +26,12 @@ low.data.materials.clear()
prepare_game_export(low,restore_axis=True)
assert uv_hash()==before,'Coordinate conversion changed Studio UVs'
bpy.ops.object.select_all(action='DESELECT');low.select_set(True);bpy.context.view_layer.objects.active=low
export_fbx_source_yup(str(out/(name+'.fbx')))
axes=export_fbx_source_yup(str(out/(name+'.fbx')))
mapping={'diffuse':'color','normal':'normal','ao':'ao','metallic':'metallic','roughness':'roughness'}
for src,dest in mapping.items():
candidates=list((folder/'bake_maps').glob(src+'*.png'))
assert len(candidates)==1,(src,candidates)
shutil.copy2(candidates[0],out/(name+'_'+dest+'.png'))
report=dict(uvHashBefore=before,uvHashAfter=uv_hash(),uvPreserved=True,vertices=len(low.data.vertices),triangles=sum(len(p.vertices)-2 for p in low.data.polygons))
report=dict(uvHashBefore=before,uvHashAfter=uv_hash(),uvPreserved=True,vertices=len(low.data.vertices),triangles=sum(len(p.vertices)-2 for p in low.data.polygons),fbxAxes=axes)
(folder/'static_export.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('MT_SUMMARY '+json.dumps(report))
+18
View File
@@ -0,0 +1,18 @@
import bpy
import sys
from pathlib import Path
folder=Path(sys.argv[sys.argv.index('--')+1])
for p in [folder/'CampGuideStudio.fbx', folder/'CampGuideStudio@Walking.fbx']:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(p))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
print('FILE',p.name,'arm pose',arm.data.pose_position)
for n in ['Hips','Head','LeftArm','LeftHand','LeftFoot']:
b=arm.pose.bones['mixamorig:'+n]
print(n,'rest',tuple(arm.matrix_world@b.bone.head_local),'POSE',tuple(arm.matrix_world@b.head),'basis',list(b.matrix_basis.to_quaternion()),'loc',list(b.location))
for o in bpy.context.scene.objects:
if o.type=='MESH':
data=o.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
pts=[o.matrix_world@v.co for v in data.vertices]
print('EVALBOUND',[[min(v[i] for v in pts) for i in range(3)],[max(v[i] for v in pts) for i in range(3)]])
@@ -0,0 +1,41 @@
"""Render identical poses/cameras for SOMA baseline and hand-only trial."""
import sys
from pathlib import Path
import bpy
from mathutils import Vector
folder = Path(sys.argv[sys.argv.index('--')+1]).resolve()
for label, path in [('baseline', folder/'kimodo/Walk/baked.glb'), ('hand_cleanup', folder/'audit/hand_cleanup/Walk.glb')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(path))
scene = bpy.context.scene
# glTF seconds are imported at Blender's current 24 fps; frame 24 = 1 second.
scene.frame_set(24)
scene.render.engine = 'CYCLES'
scene.cycles.samples = 12
scene.render.resolution_x, scene.render.resolution_y = 512, 640
scene.render.resolution_percentage = 100
scene.view_settings.view_transform = 'AgX'
world = bpy.data.worlds.new('AuditWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.35, .38, .42, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .7
scene.world = world
center = Vector((0, 0, .56))
cam = bpy.data.objects.new('AuditCamera', bpy.data.cameras.new('AuditCamera'))
scene.collection.objects.link(cam)
scene.camera = cam
cam.data.type = 'ORTHO'
cam.data.ortho_scale = 1.43
for direction, power in [((2, 3, 4), 180), ((-3, -1, 2), 120)]:
light = bpy.data.objects.new('AuditLight', bpy.data.lights.new('AuditLight', 'AREA'))
scene.collection.objects.link(light)
light.location = center + Vector(direction)
light.rotation_euler = (center-light.location).to_track_quat('-Z', 'Y').to_euler()
light.data.energy, light.data.size = power, 3
for view, direction in [('side', (3, 0, .1)), ('back', (0, -3, .1))]:
cam.location = center + Vector(direction)
cam.rotation_euler = (center-cam.location).to_track_quat('-Z', 'Y').to_euler()
scene.render.filepath = str(folder/'audit'/f'{label}_{view}.png')
bpy.ops.render.render(write_still=True)
@@ -0,0 +1,42 @@
"""Repair Studio face winding on welded topology without generating UVs."""
import json
import sys
from pathlib import Path
import bpy
import bmesh
source, destination, kind = sys.argv[sys.argv.index('--') + 1:][:3]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=source, merge_vertices=False)
obj = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action='DESELECT'); obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
uv = obj.data.uv_layers.active
uv_before = sorted(tuple(round(c, 6) for c in v.uv) for v in uv.data)
bm = bmesh.new(); bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=max(obj.dimensions) * 1e-7)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
flipped = 0
if kind == 'ground':
# Thin terrain is intentionally authored with a top and underside. Some
# Studio patches are disconnected, so orient each flat patch explicitly.
low = min(v.co.z for v in bm.verts); high = max(v.co.z for v in bm.verts)
midpoint = (low + high) / 2
for face in bm.faces:
face.normal_update()
if abs(face.normal.z) > .35:
# Terrain is viewed and collided from above. Its paving can be
# lower than raised grass, so a global height midpoint cannot
# distinguish the paving top from an underside patch.
if face.normal.z < 0:
face.normal_flip(); flipped += 1
bm.to_mesh(obj.data); bm.free(); obj.data.update()
uv_after = sorted(tuple(round(c, 6) for c in v.uv) for v in obj.data.uv_layers.active.data)
assert uv_before == uv_after, 'UV coordinates changed during winding repair'
bpy.ops.export_scene.gltf(filepath=destination, export_format='GLB', use_selection=True,
export_materials='EXPORT', export_image_format='AUTO')
report = dict(kind=kind, uvPreserved=True, flatTerrainFacesFlipped=flipped,
triangles=sum(len(p.vertices)-2 for p in obj.data.polygons))
Path(destination).with_suffix('.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('MT_SUMMARY '+json.dumps(report), flush=True)
@@ -0,0 +1,43 @@
"""CampGuide-only hand influence isolation experiment, preserving the fitted rig.
This intentionally does not fix joint placement, facing, feet, or neck weights.
Usage: blender -b --python <script> -- <skinned.glb> <out.glb>
"""
import json
import sys
from pathlib import Path
import bpy
src, dst = map(Path, sys.argv[sys.argv.index('--')+1:])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(src))
mesh = max((o for o in bpy.context.scene.objects if o.type == 'MESH'), key=lambda o: len(o.data.vertices))
arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
height = max((mesh.matrix_world @ v.co).z for v in mesh.data.vertices)
changed = []
for v in mesh.data.vertices:
p = mesh.matrix_world @ v.co
# Restrict to the hands in this particular T-pose mesh.
if not (abs(p.x) > height*.255 and height*.55 < p.z < height*.67):
continue
side = 'Left' if p.x * (arm.matrix_world @ arm.data.bones['LeftHand'].head_local).x > 0 else 'Right'
allowed = {side+'Arm', side+'ForeArm', side+'Hand'}
old = {mesh.vertex_groups[g.group].name: g.weight for g in v.groups}
new = {name: value for name, value in old.items() if name in allowed}
total = sum(new.values())
if total < 1e-8:
new, total = {side+'Hand': 1.0}, 1.0
rejected = sum(value for name, value in old.items() if name not in allowed)
for g in list(v.groups):
mesh.vertex_groups[g.group].remove([v.index])
for name, value in new.items():
mesh.vertex_groups[name].add([v.index], value/total, 'REPLACE')
if rejected > .001:
changed.append({'vertex': v.index, 'rejected': rejected, 'old': old})
dst.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst), export_format='GLB', export_yup=True, export_skins=True, export_animations=False)
dst.with_suffix('.json').write_text(json.dumps({'purpose': 'hand-only nonlocal influence isolation; not production-ready',
'source': str(src), 'changed_over_0_001': len(changed), 'changes': changed}, indent=2), encoding='utf-8')
print('HAND_CLEANUP', len(changed))
@@ -0,0 +1,138 @@
"""Prepare a weight-blind fixed Mixamo-41 TokenRig input from the static FBX.
No website skin weights are retained. Non-anatomical placeholder weights keep
all 41 joints from being trimmed by TokenRig. TokenRig predicts new weights.
"""
import itertools
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
def world_points(mesh):
return [mesh.matrix_world @ v.co for v in mesh.data.vertices]
def prepare(static, reference, out):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(reference))
arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
assert len(arm.data.bones) == 41
refmesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
refpoints = world_points(refmesh)
refarray = np.array(refpoints)
tree = KDTree(len(refpoints))
for i,p in enumerate(refpoints):
tree.insert(p,i)
tree.balance()
for obj in list(bpy.data.objects):
if obj != arm:
bpy.data.objects.remove(obj,do_unlink=True)
arm.animation_data_clear()
bpy.ops.import_scene.fbx(filepath=str(static))
mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
assert len(mesh.data.vertices) == len(refpoints)
rawpoints = world_points(mesh)
center = (refarray.min(0)+refarray.max(0))*.5
candidates=[]
for perm in itertools.permutations(range(3)):
for signs in itertools.product((-1,1),repeat=3):
m = np.zeros((3,3))
for i,j in enumerate(perm):
m[i,j] = signs[i]
if np.linalg.det(m) < .5:
continue
points = np.array(rawpoints) @ m.T
if np.max(np.abs(np.ptp(points,axis=0)-np.ptp(refarray,axis=0))) > 1e-4:
continue
shift = center-(points.min(0)+points.max(0))*.5
distances=[tree.find(Vector(p+shift))[2] for p in points]
candidates.append((max(distances),float(np.mean(distances)),m,shift))
worst,mean,rotation,shift = min(candidates,key=lambda x:x[1])
assert worst < 1e-5, f'static/reference geometry mismatch {worst}'
alignment = Matrix(rotation.tolist()).to_4x4()
alignment.translation = Vector(shift)
mesh.matrix_world = alignment @ mesh.matrix_world
for modifier in list(mesh.modifiers):
if modifier.type == 'ARMATURE':
mesh.modifiers.remove(modifier)
mesh.vertex_groups.clear()
mesh_world = mesh.matrix_world.copy()
mesh.parent = arm
mesh.matrix_world = mesh_world
for bone in arm.data.bones:
mesh.vertex_groups.new(name=bone.name)
mesh.vertex_groups['mixamorig:Hips'].add(list(range(len(mesh.data.vertices))),1.0,'REPLACE')
# TokenRig's Asset.trim_skeleton() removes unweighted joints. Give each bone
# one arbitrary vertex, without supplying any anatomical weighting prior.
for i,bone in enumerate(arm.data.bones):
if bone.name != 'mixamorig:Hips':
mesh.vertex_groups['mixamorig:Hips'].remove([i])
mesh.vertex_groups[bone.name].add([i],1.0,'REPLACE')
modifier = mesh.modifiers.new('TokenRigInputSkeleton','ARMATURE')
modifier.object = arm
arm.matrix_world = Matrix.Rotation(math.pi/2,4,'X') @ arm.matrix_world
bpy.context.view_layer.update()
out.mkdir(parents=True,exist_ok=True)
manifest = {'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(mesh.data.vertices),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'alignment_max_nearest_vertex_distance_m':worst,
'input_skin':'Non-anatomical keepalive placeholders: one arbitrary vertex per bone, remaining vertices Hips=1; all website weights removed',
'blender_up':'Z','blender_front':'-Y', 'alignment':[list(row) for row in alignment]}
(out/'input_manifest.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
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(mesh.data.vertices),manifest['triangles'],'alignment',worst)
return arm,mesh
def restore(static,reference,out):
arm,mesh=prepare(static,reference,out)
decoded=json.loads((out/'decoded_weights.json').read_text(encoding='utf-8'))
tree=KDTree(len(decoded))
for i,item in enumerate(decoded):
x,y,z=item['position_y_up']
tree.insert(Vector((x,-z,y)),i)
tree.balance()
mesh.vertex_groups.clear()
for bone in arm.data.bones:
mesh.vertex_groups.new(name=bone.name)
distances=[]
for v in mesh.data.vertices:
_,index,distance=tree.find(mesh.matrix_world @ v.co)
distances.append(distance)
weights=decoded[index]['weights']
total=sum(weights.values())
assert total>.9999
for name,weight in weights.items():
mesh.vertex_groups[name].add([v.index],weight/total,'REPLACE')
print('RESTORE_DISTANCE',max(distances),'TARGET_BOUNDS',np.array(world_points(mesh)).min(0),np.array(world_points(mesh)).max(0),
'DECODED_BOUNDS',np.array([item['position_y_up'] for item in decoded]).min(0),np.array([item['position_y_up'] for item in decoded]).max(0))
assert max(distances)<1e-5
(out/'restore_manifest.json').write_text(json.dumps({'vertex_nearest_max_distance_m':max(distances),
'bones':len(arm.data.bones),'vertices':len(mesh.data.vertices),
'skeleton':'Original website FBX skeleton, exact original rest matrices after common world-axis normalization',
'weights':'Only TokenRig predicted weights; no filtering, locality correction or website weight transfer',
'blender_up':'Z','blender_front':'-Y'},indent=2),encoding='utf-8')
bpy.ops.wm.save_as_mainfile(filepath=str(out/'tokenrig_mixamo41.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(out/'tokenrig_mixamo41.glb'),export_format='GLB',export_yup=True,
export_skins=True,export_animations=False,export_def_bones=False)
print('RESTORED',len(arm.data.bones),max(distances))
args=sys.argv[sys.argv.index('--')+1:]
if args[0]=='prepare':
prepare(*map(Path,args[1:]))
elif args[0]=='restore':
restore(*map(Path,args[1:]))
else:
raise SystemExit('use prepare|restore <static.fbx> <Mixamo.fbx> <out_dir>')
@@ -0,0 +1,23 @@
"""Reimport the six review GLBs and compare their deformation to source audits."""
import json
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_compare_mixamo_soma import audit
folder=Path(sys.argv[sys.argv.index('--')+1])
source=json.loads((folder/'comparison.json').read_text())+json.loads((folder/'comparison_local.json').read_text())
results=[]
for variant,prefix in [('raw','TokenRig41'),('local','TokenRigLocal')]:
for label in ['Walking','Breathing Idle','Happy Idle']:
path=folder/'animations'/variant/(label.replace(' ','_')+'.glb')
item=audit('Export '+variant+' '+label,path,None,folder/'review')
expected=next(x for x in source if x['label']==prefix+' '+label)
actual=item['animation']
assert actual['range']==expected['animation']['range'],(path,actual['range'])
errors={n:abs(actual['summary'][n]['max_edge_stretch']-expected['animation']['summary'][n]['max_edge_stretch']) for n in actual['summary']}
assert max(errors.values())<.001,(path,errors)
results.append({'file':str(path),'frames':actual['range'],'max_stretch_difference_from_source':errors,'bones':len(item['bones'])})
(folder/'export_verification.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('EXPORTS_VERIFIED',len(results))
@@ -0,0 +1,83 @@
"""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))