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