Files
AIGame/Tools/model_tools/bl_export_lmgirl_tokenrig_fbx.py

191 lines
9.0 KiB
Python

"""Export the selected TokenRig GLB as a textured, skinned Unity FBX.
Run in Blender. Does not change the source rig, weights, or prior review assets.
"""
import hashlib
import json
import math
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'Tools/output/lmgirl_tpose_studio/rig_mixamo41/lmgirl_local.glb'
STATIC = ROOT / 'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
OUT = SOURCE.parent / 'fbx'
FBX = OUT / 'LmGirlTokenRig.fbx'
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def points(mesh):
return np.array([mesh.matrix_world @ v.co for v in mesh.data.vertices])
def nearest_error(a, b):
tree = KDTree(len(b))
for i, p in enumerate(b):
tree.insert(Vector(p), i)
tree.balance()
return max(tree.find(Vector(p))[2] for p in a)
def snapshot(arm, mesh):
mesh.data.calc_loop_triangles()
return {
'points': points(mesh),
'uv': np.array([list(d.uv) for d in mesh.data.uv_layers.active.data]),
'triangles': len(mesh.data.loop_triangles),
'bones': {b.name: {'parent': b.parent.name if b.parent else None,
'matrix': np.array(arm.matrix_world @ b.matrix_local)}
for b in arm.data.bones},
'weights': [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-8}
for v in mesh.data.vertices],
}
def make_material(mesh):
mat = bpy.data.materials.new('LmGirlTokenRig_Body')
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get('Principled BSDF')
images = {}
for suffix, socket in [('color', 'Base Color'), ('roughness', 'Roughness'), ('metallic', 'Metallic'), ('normal', None)]:
path = STATIC.parent / ('LmGirlTpose_' + suffix + '.png')
image = bpy.data.images.load(str(path), check_existing=True)
if suffix != 'color':
image.colorspace_settings.name = 'Non-Color'
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
node.image = image
if suffix == 'normal':
normal = mat.node_tree.nodes.new('ShaderNodeNormalMap')
mat.node_tree.links.new(node.outputs['Color'], normal.inputs['Color'])
mat.node_tree.links.new(normal.outputs['Normal'], bsdf.inputs['Normal'])
else:
mat.node_tree.links.new(node.outputs['Color'], bsdf.inputs[socket])
images[suffix] = str(path.relative_to(ROOT))
mesh.data.materials.clear()
mesh.data.materials.append(mat)
return images
def main():
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(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')
arm.animation_data_clear()
assert len(arm.data.bones) == 41
original = snapshot(arm, mesh)
print('SOURCE', mesh.name, len(original['points']), original['triangles'],
original['points'].min(0), original['points'].max(0), 'ARM_MATRIX', arm.matrix_world)
# The current static FBX has independently reviewed anatomical +Z front.
# Match its complete imported geometry rather than infer direction from metadata.
existing = set(bpy.context.scene.objects)
bpy.ops.import_scene.fbx(filepath=str(STATIC))
reference = next(o for o in bpy.context.scene.objects if o not in existing and o.type == 'MESH')
ref = points(reference)
candidates = []
for degrees in [0, 180]:
rotate = Matrix.Rotation(math.radians(degrees), 4, 'Z')
candidate = np.array([rotate @ Vector(p) for p in original['points']])
candidates.append((nearest_error(candidate, ref), degrees, rotate))
error, degrees, conversion = min(candidates, key=lambda item: item[0])
if error > 1e-5:
raise RuntimeError('Cannot match reviewed static geometry: ' + str([(e, d) for e, d, _ in candidates]))
for obj in list(bpy.context.scene.objects):
if obj not in existing:
bpy.data.objects.remove(obj, do_unlink=True)
converted = np.array([conversion @ Vector(p) for p in original['points']])
lo, hi = converted.min(0), converted.max(0)
shift = Vector((-(lo[0]+hi[0])/2, -(lo[1]+hi[1])/2, -lo[2]))
conversion = Matrix.Translation(shift) @ conversion
matrices = {o: conversion @ o.matrix_world for o in [arm, mesh]}
mesh.parent = None
for obj, matrix in matrices.items():
obj.matrix_world = matrix
for obj in [arm, mesh]:
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)
arm.name = 'LmGirlTokenRig'
mesh.name = 'LmGirlTokenRigMesh'
texture_paths = make_material(mesh)
bpy.context.view_layer.update()
expected = snapshot(arm, mesh)
assert max(abs(sum(row.values())-1) for row in expected['weights']) < 1e-5
assert max(len(row) for row in expected['weights']) <= 4
bpy.ops.object.select_all(action='DESELECT')
for obj in [arm, mesh]:
obj.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.fbx(
filepath=str(FBX), use_selection=True, object_types={'ARMATURE', 'MESH'},
axis_forward='-Z', axis_up='Y', global_scale=1,
apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL',
bake_space_transform=False, add_leaf_bones=False, use_armature_deform_only=False,
armature_nodetype='NULL', bake_anim=False, mesh_smooth_type='OFF',
use_mesh_modifiers=False, path_mode='COPY', embed_textures=True)
# Reimport through Blender's FBX importer, independently of the GLB importer.
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(FBX))
actual_arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
actual_mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
actual = snapshot(actual_arm, actual_mesh)
assert actual['triangles'] == original['triangles'] == 4999
assert set(actual['bones']) == set(expected['bones'])
assert all(actual['bones'][n]['parent'] == expected['bones'][n]['parent'] for n in actual['bones'])
# FBX may split vertices at corners. Match geometry+UV+weights using nearest positions.
tree = KDTree(len(expected['points']))
for i, p in enumerate(expected['points']):
tree.insert(Vector(p), i)
tree.balance()
max_geometry_error = 0.
max_weight_error = 0.
mapping = []
for p, row in zip(actual['points'], actual['weights']):
_, index, distance = tree.find(Vector(p))
mapping.append(index)
max_geometry_error = max(max_geometry_error, distance)
source_row = expected['weights'][index]
max_weight_error = max(max_weight_error, max(abs(row.get(n, 0)-source_row.get(n, 0)) for n in set(row)|set(source_row)))
max_bone_head_error = max(float(np.linalg.norm(actual['bones'][n]['matrix'][:3,3]-expected['bones'][n]['matrix'][:3,3])) for n in actual['bones'])
# UV distributions are independent of corner ordering. Quantization allows FBX float rounding.
uv_reference = sorted(map(tuple, np.round(expected['uv'], 5)))
uv_roundtrip = sorted(map(tuple, np.round(actual['uv'], 5)))
uv_preserved = uv_reference == uv_roundtrip
assert max_geometry_error < 1e-5, max_geometry_error
assert max_weight_error < 1e-5, max_weight_error
assert max_bone_head_error < 1e-5, max_bone_head_error
assert uv_preserved
lo, hi = actual['points'].min(0), actual['points'].max(0)
report = {
'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': sha(SOURCE),
'fbx': str(FBX.relative_to(ROOT)), 'fbx_sha256': sha(FBX),
'variant': 'TokenRig local constraints, 41 bones, top4; no website weights',
'sceneUp': '+Y', 'anatomicalFront': '+Z', 'unit': 'meter',
'conversionAboutBlenderZDegrees': degrees, 'originShiftBlender': list(shift),
'geometryMatchToReviewedStaticM': error, 'textures': texture_paths,
'sourceVertices': len(original['points']), 'roundtripVertices': len(actual['points']),
'triangles': actual['triangles'], 'bones': len(actual['bones']),
'maxInfluences': max(len(row) for row in actual['weights']),
'roundtripGeometryErrorM': max_geometry_error, 'roundtripWeightError': max_weight_error,
'roundtripBoneHeadErrorM': max_bone_head_error, 'uvPreserved': uv_preserved,
'roundtripBoundsBlender': [lo.tolist(), hi.tolist()],
'animationIncluded': False,
'notes': 'Blender workspace is Z-up. Standard skinned FBX axis conversion is retained on the armature node; verify full imported transforms, not raw mesh coordinates alone.'}
(OUT/'export_verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('EXPORTED', json.dumps(report))
if __name__ == '__main__':
main()