#!/usr/bin/env python3 """ Put the animation library onto a character WITHOUT touching how it deforms. This replaces the old strip_rig -> autorig -> merge_animations route, which solved a naming problem by destroying the asset. That route threw away the character's skeleton, joined every mesh into one blob, and rebound the result with nearest-four-bones Euclidean weights. Measured on the shipped taila.glb it produced 2817 vertices pulled by BOTH legs (worst a dead 50/50 split) and 86% of all vertices carrying the full four influences — while the ORIGINAL file it was built from had zero cross-leg bleed, one mesh per material, and dedicated bone chains for the skirt and the hair. Every runtime "repair" in characters/skin_leg_repair.gd exists to undo damage done right here. So: keep the character's own rig, weights, per-part meshes and cloth chains, and move the ANIMATION onto it instead. 1. Rebuild parenting. A Rigify DEF-rig exports its chain roots parented straight to the armature root, because Rigify drives them by constraint rather than hierarchy. Left that way, rotating the hips would leave the legs, skirt and hair floating in place. Orphans are re-attached by anatomy where it is known and by rest geometry otherwise — and cloth may only ever attach to the trunk, never to a limb, or a skirt would ride one thigh. 2. Retarget by ROLE, not by name (see tools/rig_map.py). 3. Bake each clip as a rest-relative delta: R_world = src_pose_rot * src_rest_rot^-1 (what the clip does) tgt_rot = R_world * tgt_rest_rot (done to THIS rig) Copying absolute world orientation instead — which is what the old constraint bake did — forces the library's bone roll onto a mesh that was bound with a different one, and twists every limb by a constant offset. 4. Drive ONLY the body. Skirt, hair, twist and face bones are left with no keys at all, so they rest relative to their parents and are free for the spring solver at runtime. That split — clips animate the body, physics animates the cloth — is the whole point. Usage: blender --background --python tools/retarget.py -- \ [--height 1.75] [--keep-root-motion] Writes .rig.json beside the GLB: resolved bone roles, cloth chains and twist pairs, so the runtime never has to re-guess the skeleton's anatomy. """ import bpy import json import os import sys import tempfile from collections import defaultdict from mathutils import Matrix, Quaternion, Vector sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gltf_fix import rig_map from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens argv = sys.argv argv = argv[argv.index("--") + 1:] if "--" in argv else [] if len(argv) < 3: print(__doc__) sys.exit(1) CHARACTER, ANIM_DIR, OUTPUT = argv[0], argv[1], argv[2] STRIP_ROOT_MOTION = "--keep-root-motion" not in argv TARGET_HEIGHT = 1.75 if "--height" in argv: TARGET_HEIGHT = float(argv[argv.index("--height") + 1]) OVERRIDES = {} if "--bone-map" in argv: with open(argv[argv.index("--bone-map") + 1], "r", encoding="utf-8") as f: OVERRIDES = json.load(f) # Library clip -> the game's canonical clip name. Unmapped clips are skipped so # character GLBs stay small. LIBRARY_CLIP_MAP = { "Idle_Loop": "Idle", "Walk_Loop": "Walk", "Jog_Fwd_Loop": "Run", "Sprint_Loop": "Sprint", "Jump_Start": "Jump", "Jump_Loop": "Fall", "Jump_Land": "Land", "Crouch_Idle_Loop": "CrouchIdle", "Crouch_Fwd_Loop": "CrouchWalk", "Roll": "Dash", "Death01": "Death", "Hit_Chest": "Hit", "Dance_Loop": "Dance", "Swim_Fwd_Loop": "Grapple", "Pistol_Idle_Loop": "PistolIdle", "Pistol_Shoot": "PistolShoot", "Pistol_Reload": "PistolReload", "Sword_Attack": "Throw", } UP = Vector((0.0, 0.0, 1.0)) # Which cosmetic chains get secondary motion. A face-shape or eye chain is # cosmetic but must never swing, so this is deliberately narrower than # rig_map.COSMETIC. SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat", "scarf", "sleeve", "breast", "bust", "feather", "strap", "antenna", "wing"} # --------------------------------------------------------------------- import def clear_scene(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete() for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.actions): for b in list(blocks): if b.users == 0: blocks.remove(b) def import_any(path): ext = os.path.splitext(path)[1].lower() if ext in (".glb", ".gltf"): bpy.ops.import_scene.gltf(filepath=path) elif ext == ".fbx": bpy.ops.import_scene.fbx(filepath=path) else: print(f"ERROR: unsupported character format {ext}") sys.exit(1) def skinned_meshes(arm): """Meshes actually driven by this armature. Anything else in the file is scene dressing — Sketchfab models routinely ship a display base or a diorama, and those must not become part of the player. """ out = [] for o in [o for o in bpy.data.objects if o.type == "MESH"]: driven = any(m.type == "ARMATURE" and m.object is arm for m in o.modifiers) if driven or (o.parent is arm and o.vertex_groups): out.append(o) return out def strip_import_suffixes(arm, meshes): """`DEF-thigh.L_16` -> `DEF-thigh.L`. The glTF importer appends the node index to every bone name. Those names ship in the exported GLB and are what the game code matches on, so clean them up here rather than teaching every consumer about the suffix. """ renames = {} taken = set(b.name for b in arm.data.bones) for bone in arm.data.bones: clean = rig_map.strip_gltf_suffix(bone.name) if clean != bone.name and clean not in taken: renames[bone.name] = clean taken.discard(bone.name) taken.add(clean) for old, new in renames.items(): arm.data.bones[old].name = new # Vertex groups are matched to bones by NAME and are not renamed for us. for m in meshes: for vg in m.vertex_groups: new = renames.get(vg.name) if new and new not in m.vertex_groups: vg.name = new print(f"Cleaned {len(renames)} bone names") # ----------------------------------------------------------------- hierarchy def _seg_distance(p, a, b): ab = b - a d2 = ab.dot(ab) t = 0.0 if d2 < 1e-12 else max(0.0, min(1.0, (p - a).dot(ab) / d2)) return (p - (a + ab * t)).length def rebuild_hierarchy(arm, roles): """Re-attach chain roots that exported parented to the armature root. Anatomy first (a thigh belongs to the hips, a forearm to the upper arm), rest geometry second. Cloth and hair are only ever allowed to attach to the TRUNK: pick anchors by raw proximity and a skirt panel hanging beside a leg attaches to that thigh and rides it like a trouser leg. """ trunk = [roles.hips] + list(roles.spine) trunk = [n for n in trunk if n] limb = roles.limb def anat(role, side): return limb.get((role, side)) fixed = {} for side in ("L", "R"): chest = trunk[-3] if len(trunk) >= 3 else (trunk[-1] if trunk else None) pairs = [ (("thigh", side), roles.hips), (("shin", side), anat("thigh", side)), (("foot", side), anat("shin", side)), (("toe", side), anat("foot", side)), (("shoulder", side), chest), (("upper_arm", side), anat("shoulder", side) or chest), (("forearm", side), anat("upper_arm", side)), (("hand", side), anat("forearm", side)), ] for key, parent in pairs: name = limb.get(key) if name and parent: fixed[name] = parent bpy.context.view_layer.objects.active = arm bpy.ops.object.mode_set(mode="EDIT") eb = arm.data.edit_bones # Where each anchor bone actually EXTENDS TO, from authored data only. # # glTF stores joints as nodes with no tail, so the tails Blender reports are # invented — every one of Taila's skirt bones comes back 0.78 m long. Using # them, DEF-spine's phantom tail runs straight down through the whole skirt # and every panel measures ~0.05 m from "the hips", which beat each panel's # real 0.15 m link to its own chain root and flattened all 7 chains. # A bone's true extent is the head of the next bone along. span = {} for i, name in enumerate(trunk): nxt = trunk[i + 1] if i + 1 < len(trunk) else None span[name] = (eb[name].head, eb[nxt].head if nxt and nxt in eb else eb[name].tail) for name in limb.values(): if name not in eb: continue kids = [c for c in eb[name].children if not is_cosmetic(c.name)] span[name] = (eb[name].head, kids[0].head if kids else eb[name].tail) def anchor_distance(point, name): a, b = span.get(name, (eb[name].head, eb[name].tail)) return _seg_distance(point, a, b) def is_root_like(bone): return bone is None or any( t in ("root", "master", "armature", "scene", "rootjoint") for t in tokens(bone.name)) def descendants(bone): out = {bone.name} stack = list(bone.children) while stack: b = stack.pop() out.add(b.name) stack.extend(b.children) return out trunk_set = set(trunk) core = trunk_set | set(limb.values()) orphans = [b for b in eb if b.name != roles.hips and b.name not in trunk_set and is_root_like(b.parent)] reparented = 0 cosmetic_left = [] for bone in orphans: if is_cosmetic(bone.name): cosmetic_left.append(bone) continue target = fixed.get(bone.name) if target is None: banned = descendants(bone) cands = [n for n in core if n in eb and n not in banned] if not cands: continue target = min(cands, key=lambda n: anchor_distance(bone.head, n)) if target in eb and target != bone.name: bone.parent = eb[target] bone.use_connect = False reparented += 1 # Cloth and hair hang from the TRUNK — never from a limb, and never from # each other. # # Attaching cloth to whatever bone is nearest puts 16 of Taila's 21 skirt # bones on a thigh, where the panel rides one leg like a trouser leg. The # trunk restriction fixes that. # # Reconstructing multi-bone chains is deliberately NOT attempted. The # temptation is obvious — Taila's skirt is really 7 panels of 3 — but the # information is not in the file. glTF stores no bone tails, the panel # numbering is not sequential (`skirt` -> `skirt.011` -> `skirt.002`), and # neighbouring panel roots ring the waist 0.04 m apart, far closer than any # of them is to the trunk. Successive attempts at distance, chain-direction # and grow-outward rules each produced a topology that was still wrong # somewhere — stitching panels together sideways, or hanging hair off an # eye bone. A wrong chain is worse than no chain: the solver then swings # bones along axes the mesh was never weighted for, and tears it. # # So each orphan becomes its own pendulum from the body. Chains the ARTIST # authored survive untouched, because only chain ROOTS are orphans — which # is why Taila's hair keeps its real 4-7 bone strands while her # flat-exported skirt becomes per-panel pendulums. Both look like cloth; # only the authored one gets true multi-segment drape. for bone in cosmetic_left: cands = [n for n in trunk_set if n in eb] if not cands: break bone.parent = eb[min(cands, key=lambda n: anchor_distance(bone.head, n))] bone.use_connect = False reparented += 1 bpy.ops.object.mode_set(mode="OBJECT") print(f"Re-attached {reparented} orphaned bones") # ----------------------------------------------------------------- normalize def flatten_and_scale(arm, meshes, target_height): """Bake the import hierarchy away and set the character's real-world size. Sketchfab wraps everything in scaled/rotated empties. Left in place they turn up as a scale on the exported Skeleton3D, and every measurement the game makes off bone rests reads in the wrong units. """ if arm.animation_data: arm.animation_data_clear() for pb in arm.pose.bones: pb.matrix_basis = Matrix() for obj in [arm] + meshes: world = obj.matrix_world.copy() obj.parent = None obj.matrix_world = world for o in [o for o in bpy.data.objects if o.type == "EMPTY"]: bpy.data.objects.remove(o, do_unlink=True) def apply_all(): bpy.ops.object.select_all(action="DESELECT") for obj in [arm] + meshes: obj.select_set(True) bpy.context.view_layer.objects.active = arm bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) apply_all() lo = Vector((1e9, 1e9, 1e9)) hi = -lo.copy() for m in meshes: for corner in m.bound_box: p = m.matrix_world @ Vector(corner) lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) height = hi.z - lo.z if height > 1e-4: s = target_height / height for obj in [arm] + meshes: obj.scale = (s, s, s) apply_all() lo *= s hi *= s print(f"Scaled by {s:.4f} to {target_height:.2f} m") offset = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) for obj in [arm] + meshes: obj.location = offset apply_all() # Re-parent meshes under the armature so the export writes one clean skin. for m in meshes: m.parent = arm m.matrix_parent_inverse = Matrix() if not any(mod.type == "ARMATURE" and mod.object is arm for mod in m.modifiers): mod = m.modifiers.new("Armature", "ARMATURE") mod.object = arm def fix_unlit_materials(meshes): """Route each material's texture into Base Color. Anime models are commonly authored UNLIT: black base colour with the albedo wired to emission. Our toon shader reads ALBEDO, so left alone the character renders pitch black. """ seen = set() for m in meshes: for mat in m.data.materials: if not mat or not mat.use_nodes or mat.name in seen: continue seen.add(mat.name) nt = mat.node_tree tex = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None) bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None) if not bsdf or not tex: continue base = bsdf.inputs["Base Color"] if not base.links: nt.links.new(tex.outputs["Color"], base) print(f"Material '{mat.name}': routed '{tex.image.name}' to base colour") if "Emission Strength" in bsdf.inputs: bsdf.inputs["Emission Strength"].default_value = 0.0 # ------------------------------------------------------------------ retarget def world_rest(arm): mw = arm.matrix_world return {b.name: mw @ b.matrix_local for b in arm.data.bones} def rig_forward(arm, roles): """Which way the rest pose faces, on the ground plane, from the feet.""" mw = arm.matrix_world acc = Vector((0.0, 0.0, 0.0)) for side in ("L", "R"): for role in ("toe", "foot"): name = roles.limb.get((role, side)) if not name: continue b = arm.data.bones[name] v = (mw @ b.tail_local) - (mw @ b.matrix_local.translation) v.z = 0.0 if v.length > 1e-5: acc += v.normalized() break return acc.normalized() if acc.length > 1e-5 else None def facing_correction(src_arm, src_roles, tgt_arm, tgt_roles): """Yaw that carries the source rig's forward onto the target's. Without it a library that rests facing -Y drives a character that rests facing +Y and every clip plays backwards. """ a = rig_forward(src_arm, src_roles) b = rig_forward(tgt_arm, tgt_roles) if a is None or b is None: return Quaternion() # angle_signed is 2D-only in mathutils, which is what we want anyway: the # correction is a yaw about world up, never a tilt. angle = Vector((a.x, a.y)).angle_signed(Vector((b.x, b.y)), 0.0) if abs(angle) < 1e-4: return Quaternion() print(f"Facing correction: {angle * 57.2958:.1f} deg") return Quaternion(UP, angle) def bone_order(arm): """Every bone, parents before children.""" out = [] def walk(b): out.append(b.name) for c in b.children: walk(c) for b in arm.data.bones: if b.parent is None: walk(b) return out def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head): """Turn desired WORLD orientations into per-bone local basis transforms. Done arithmetically rather than by setting `pose_bone.matrix` and letting Blender solve, because that needs a depsgraph update per bone — 150 bones across 18 clips is tens of thousands of scene evaluations. Blender relates pose to rest as pose = parent_pose * parent_rest^-1 * rest * basis so with M standing for everything left of `basis`, a rotation-only basis of M.rot^-1 * desired lands the bone on `desired` exactly. """ pose_w = {} basis = {} bones = arm.data.bones for name in order: b = bones[name] rest = rest_w[name] if b.parent is not None: M = pose_w[b.parent.name] @ rest_w[b.parent.name].inverted() @ rest else: M = rest q = Quaternion() if name in desired_rot: q = M.to_quaternion().inverted() @ desired_rot[name] loc = Vector((0.0, 0.0, 0.0)) if name == hips and hips_head is not None: loc = M.inverted() @ hips_head basis[name] = (loc, q) pose_w[name] = M @ Matrix.Translation(loc) @ q.to_matrix().to_4x4() return basis def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action, clip_name, yaw, scale): src_rest = world_rest(src_arm) tgt_rest = world_rest(tgt_arm) order = bone_order(tgt_arm) src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()} tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()} yaw_inv = yaw.inverted() src_hips = src_roles.hips tgt_hips = tgt_roles.hips src_hips_rest = src_rest[src_hips].translation.copy() tgt_hips_rest = tgt_rest[tgt_hips].translation.copy() assign_action(src_arm, action) f0, f1 = (int(round(v)) for v in action.frame_range) baked = bpy.data.actions.new(clip_name) assign_action(tgt_arm, baked) for pb in tgt_arm.pose.bones: pb.rotation_mode = "QUATERNION" scene = bpy.context.scene for frame in range(f0, f1 + 1): scene.frame_set(frame) dg = bpy.context.evaluated_depsgraph_get() src_eval = src_arm.evaluated_get(dg) smw = src_eval.matrix_world desired = {} for tgt_name, src_name in mapping.items(): if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot: continue pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion() delta = pose_rot @ src_rest_rot[src_name].inverted() desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name] hips_head = None if src_hips in src_eval.pose.bones: moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation d = yaw @ ((moved - src_hips_rest) * scale) if STRIP_ROOT_MOTION: d.x = 0.0 d.y = 0.0 # gameplay code moves the body; keep the vertical bob hips_head = tgt_hips_rest + d basis = solve_pose(tgt_arm, order, tgt_rest, desired, tgt_hips, hips_head) for name in mapping: if name not in basis: continue pb = tgt_arm.pose.bones[name] pb.rotation_quaternion = basis[name][1] pb.keyframe_insert("rotation_quaternion", frame=frame) if hips_head is not None: pb = tgt_arm.pose.bones[tgt_hips] pb.location = basis[tgt_hips][0] pb.keyframe_insert("location", frame=frame) assign_action(tgt_arm, None) return baked def assign_action(obj, action): if not obj.animation_data: obj.animation_data_create() obj.animation_data.action = action if action is None: return try: # Blender 4.4+ slotted actions if not obj.animation_data.action_slot and len(action.slots): obj.animation_data.action_slot = action.slots[0] except (AttributeError, TypeError): pass def add_nla_clip(arm, action, name): action.name = name track = arm.animation_data.nla_tracks.new() track.name = name strip = track.strips.new(name, 0, action) strip.name = name track.mute = True action.use_fake_user = True # -------------------------------------------------------------------- sidecar def _dominant_vertices(meshes, arm): """bone name -> world positions of the vertices it mostly owns. "Mostly" as in holds the largest share — a vertex belongs to one bone for the purpose of measuring what that bone covers, even though it is skinned to several. """ out = defaultdict(list) for m in meshes: gname = {g.index: g.name for g in m.vertex_groups} mw = m.matrix_world for v in m.data.vertices: best = None for g in v.groups: if best is None or g.weight > best.weight: best = g if best is not None and best.weight > 0.25: out[gname.get(best.group, "")].append(mw @ v.co) return out def _bone_tip(arm, bone, chain, index, owned, fallback): """Where a bone effectively points, in its own rest space. The next bone along when there is one. Otherwise the centroid of the geometry this bone actually drives — which is the only real answer for Taila's skirt, whose 21 panel bones export with no children and no usable tail, so there is nothing in the skeleton to say which way a panel hangs. """ rest_world = arm.matrix_world @ arm.data.bones[bone].matrix_local if index + 1 < len(chain): nxt = arm.matrix_world @ arm.data.bones[chain[index + 1]].matrix_local return rest_world.inverted() @ nxt.translation pts = owned.get(bone, []) if pts: centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts) local = rest_world.inverted() @ centroid # The centroid sits mid-panel, so the far edge is roughly twice out. if local.length > 1e-4: return local * 2.0 return fallback def _leg_colliders(arm, roles, owned): """Capsules for the legs, sized from the body geometry itself. The skirt has to be kept off the thighs, and a guessed radius either lets it clip through or holds it out in a bell. The 70th percentile of how far a leg bone's own vertices sit from its axis measures the actual limb. """ out = [] for role, child_role in (("thigh", "shin"), ("shin", "foot")): for side in ("L", "R"): name = roles.limb.get((role, side)) child = roles.limb.get((child_role, side)) if not name or not child: continue a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation pts = owned.get(name, []) if len(pts) < 8: continue radii = sorted(_seg_distance(p, a, b) for p in pts) out.append({ "bone": name, "child": child, "radius": round(radii[int(len(radii) * 0.7)], 4), }) return out def describe_rig(arm, roles, mapping, meshes): """Record what we worked out, so the runtime never re-guesses anatomy.""" names = set(b.name for b in arm.data.bones) owned = _dominant_vertices(meshes, arm) roles_out = {"hips": roles.hips, "head": roles.head, "neck": roles.neck, "spine": list(roles.spine)} for (role, side), name in roles.limb.items(): roles_out[f"{role}.{side}"] = name driven = set(mapping) chains = [] springy = {b.name: b for b in arm.data.bones if b.name not in driven and any(t in SPRING_CLASSES for t in tokens(b.name))} for name, bone in springy.items(): if bone.parent is not None and bone.parent.name in springy: continue # not the root of a chain # One chain per leaf path, so each strand solves independently. stack = [[name]] while stack: path = stack.pop() kids = [c.name for c in arm.data.bones[path[-1]].children if c.name in springy] if not kids: tips = [] fallback = Vector((0.0, 0.0, -0.06)) for i in range(len(path)): t = _bone_tip(arm, path[i], path, i, owned, fallback) tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)]) fallback = t chains.append({ "class": next((t for t in tokens(path[0]) if t in SPRING_CLASSES), "cloth"), "root_parent": bone.parent.name if bone.parent else None, "bones": path, "tips": tips, }) continue for k in kids: stack.append(path + [k]) # Twist bones only — a hair link is also `X.001`, but it is cloth, and # listing it here would have the twist distributor and the spring solver # both writing the same bone. twist = [] for b in arm.data.bones: if b.name in driven or is_cosmetic(b.name) or not is_segment_of(b.name, names): continue if b.parent is not None: twist.append({"bone": b.name, "parent": b.parent.name, "child": b.children[0].name if b.children else None}) return {"roles": roles_out, "chains": chains, "twist": twist, "colliders": _leg_colliders(arm, roles, owned), "weights_authored": _weights_look_authored(meshes, roles), "driven_bones": sorted(driven)} def _weights_look_authored(meshes, roles): """Were these weights painted, or solved by a nearest-bone fit? The runtime decides from this whether to run its destructive load-time weight repair, so it is MEASURED rather than inferred from which pipeline branch ran — a model that arrives unrigged still goes through autorig and out through this same tool, and must not be handed a sidecar that says its weights are fine when they are not. Two signatures, both taken from the shipped-vs-source comparison that started this rework: the nearest-four-bones fit left 16% of vertices pulled by BOTH legs and gave 86% of them the full four influences, where the artist's own weights had 0.1% and 26%. """ legs = {} for (role, side), name in roles.limb.items(): if role in ("thigh", "shin", "foot", "toe"): legs[name] = -1 if side == "L" else 1 def side_of(group_name): for name, s in legs.items(): if group_name == name or group_name.startswith(name + "."): return s return 0 total = 0 bleeding = 0 four = 0 for m in meshes: gside = {g.index: side_of(g.name) for g in m.vertex_groups} for v in m.data.vertices: wl = wr = 0.0 n = 0 for g in v.groups: if g.weight <= 0.005: continue n += 1 s = gside.get(g.group, 0) if s == -1: wl += g.weight elif s == 1: wr += g.weight total += 1 if n >= 4: four += 1 if wl > 0.005 and wr > 0.005: bleeding += 1 if total == 0: return False authored = bleeding / total < 0.02 and four / total < 0.5 print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), " f"{four / total * 100:.0f}% at four influences — " f"{'authored' if authored else 'solved, runtime repair stays on'}") return authored # ------------------------------------------------------------------------ main def main(): clear_scene() print(f"Importing character {CHARACTER}") # Normalise emissive-albedo materials FIRST. Blender honours # KHR_materials_unlit and reads only base colour, so an unlit model with its # texture in the emissive slot imports with no images at all and exports a # black silhouette — there is no node graph left to repair afterwards. import_any(gltf_fix.prepare(CHARACTER, tempfile.gettempdir())) arms = [o for o in bpy.data.objects if o.type == "ARMATURE"] if not arms: print("ERROR: character has no armature. Rig it first (see docs/3D_ASSET_PIPELINE.md).") sys.exit(1) arm = max(arms, key=lambda a: len(a.data.bones)) meshes = skinned_meshes(arm) if not meshes: print("ERROR: no skinned meshes bound to the armature") sys.exit(1) for o in [o for o in bpy.data.objects if o.type == "MESH" and o not in meshes]: print(f"Dropping unskinned prop mesh: {o.name}") bpy.data.objects.remove(o, do_unlink=True) print(f"Character: {len(arm.data.bones)} bones, {len(meshes)} meshes " f"({sum(len(m.data.vertices) for m in meshes)} verts) — weights kept as authored") strip_import_suffixes(arm, meshes) for a in list(bpy.data.actions): bpy.data.actions.remove(a) # the character's own clip is not ours roles = RigRoles(arm) missing = roles.missing_core() if missing: print(f"ERROR: could not identify these bones on the character rig: {missing}") print("Resolved so far:\n" + roles.describe()) sys.exit(1) rebuild_hierarchy(arm, roles) flatten_and_scale(arm, meshes, TARGET_HEIGHT) fix_unlit_materials(meshes) roles = RigRoles(arm) # rest positions moved; re-read library = os.path.join(ANIM_DIR, "_library.glb") if not os.path.exists(library): print(f"ERROR: animation library not found: {library}") sys.exit(1) before = set(bpy.data.objects) before_actions = set(bpy.data.actions) bpy.ops.import_scene.gltf(filepath=library) new_objects = [o for o in bpy.data.objects if o not in before] src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None) if not src_arm: print("ERROR: no armature in the animation library") sys.exit(1) src_roles = RigRoles(src_arm) mapping = build_map(src_roles, roles, OVERRIDES) print("\nLibrary rig:\n" + src_roles.describe()) print("\nCharacter rig:\n" + roles.describe()) print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; " f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.") yaw = facing_correction(src_arm, src_roles, arm, roles) src_h = (src_arm.matrix_world @ src_arm.data.bones[src_roles.hips].matrix_local).translation.z tgt_h = (arm.matrix_world @ arm.data.bones[roles.hips].matrix_local).translation.z scale = tgt_h / src_h if src_h > 1e-5 else 1.0 print(f"Hips height: library {src_h:.3f} m, character {tgt_h:.3f} m (scale {scale:.3f})") if not arm.animation_data: arm.animation_data_create() for track in list(arm.animation_data.nla_tracks): arm.animation_data.nla_tracks.remove(track) merged = 0 for action in [a for a in bpy.data.actions if a not in before_actions]: clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0]) if not clip: continue print(f" {action.name} -> {clip}") baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action, clip, yaw, scale) add_nla_clip(arm, baked, clip) merged += 1 if merged == 0: print("ERROR: no clips retargeted") sys.exit(1) for o in new_objects: bpy.data.objects.remove(o, do_unlink=True) os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True) sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json" info = describe_rig(arm, roles, mapping, meshes) with open(sidecar, "w", encoding="utf-8") as f: json.dump(info, f, indent=2) print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, " f"{len(info['twist'])} twist bones, {len(info['colliders'])} leg colliders") print(f"Merged {merged} clips. Exporting {OUTPUT}") bpy.ops.export_scene.gltf( filepath=OUTPUT, export_format="GLB", export_yup=True, export_apply=False, export_skins=True, export_animations=True, export_animation_mode="NLA_TRACKS", # Export ONLY the bones the clips actually key. Baking every bone # writes rest-pose tracks for the skirt and hair too, which triples the # clip data and — worse — has the AnimationPlayer overwrite exactly the # bones the spring solver is meant to own. export_bake_animation=False, export_optimize_animation_size=True, # "keep_anim_armature" is what forces a track onto every bone whether # or not the clip touches it. Off, so the skirt and hair export with no # tracks at all and belong entirely to the spring solver. export_optimize_animation_keep_anim_armature=False, ) print("Done.") main()