191 lines
9.0 KiB
Python
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()
|