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:
@@ -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'
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user