#!/usr/bin/env python3 """ Remove bones from a text-format animation file and redistribute their vertex weights to the nearest surviving ancestor bone. Default: removes all pinky-finger bones (LeftHandPinky* and RightHandPinky*), reducing a 63-bone rig to 55 bones to stay within WebGL uniform limits. Usage: python reduce_bones_txt.py [--remove Bone1,Bone2,...] """ import re import sys import argparse DEFAULT_REMOVE = [ 'LeftHandPinky1', 'LeftHandPinky2', 'LeftHandPinky3', 'LeftHandPinky3_end', 'RightHandPinky1', 'RightHandPinky2', 'RightHandPinky3', 'RightHandPinky3_end', ] def parse_children_line(line): return re.findall(r"'([^']+)'", line) def format_children_line(names): if not names: return " Children: []\n" return " Children: [" + ', '.join(f"'{n}'" for n in names) + "]\n" def build_remap(bone_parent, remove_set): """Map each removed bone to its nearest non-removed ancestor (or None).""" remap = {} for bone in remove_set: cur = bone_parent.get(bone) while cur is not None and cur in remove_set: cur = bone_parent.get(cur) remap[bone] = cur return remap def process(lines, remove_set): # ── Pass 1: collect bone parent/children to build weight remap ──────────── bone_parent = {} i = 0 while i < len(lines) and not lines[i].startswith('=== Armature Bones:'): i += 1 i += 1 # skip header current_bone = None while i < len(lines) and not lines[i].startswith('=== TOTAL MESHES'): line = lines[i].rstrip() if line.startswith('Bone: '): current_bone = line[6:] bone_parent.setdefault(current_bone, None) elif current_bone and line.startswith(' Parent:'): p = line[9:].strip() bone_parent[current_bone] = None if p == 'None' else p i += 1 remap = build_remap(bone_parent, remove_set) missing = remove_set - set(bone_parent.keys()) if missing: print(f'Warning: bones not found in file and will be skipped: {missing}', file=sys.stderr) # ── Pass 2: emit modified output ────────────────────────────────────────── out = [] i = 0 # Armature matrix — verbatim until bone header while i < len(lines) and not lines[i].startswith('=== Armature Bones:'): out.append(lines[i]) i += 1 # Updated bone count original_count = int(re.search(r'\d+', lines[i]).group()) new_count = original_count - len(remove_set & set(bone_parent.keys())) out.append(f'=== Armature Bones: {new_count}\n') i += 1 # Bone blocks while i < len(lines) and not lines[i].startswith('=== TOTAL MESHES'): line = lines[i] if line.startswith('Bone: '): bone_name = line[6:].rstrip() # Collect the whole block up to the next bone or section boundary i += 1 block = [] while i < len(lines) and not lines[i].startswith('Bone: ') \ and not lines[i].startswith('=== TOTAL MESHES'): block.append(lines[i]) i += 1 if bone_name not in remove_set: out.append(f'Bone: {bone_name}\n') for bline in block: if bline.rstrip().startswith(' Children:'): children = [c for c in parse_children_line(bline) if c not in remove_set] out.append(format_children_line(children)) else: out.append(bline) else: out.append(line) i += 1 # Mesh geometry sections and vertex weights in_vertex_weights = False while i < len(lines) and not lines[i].startswith('=== Animation Keyframes'): line = lines[i] if line.startswith('=== Vertex Weights'): in_vertex_weights = True out.append(line) i += 1 continue if in_vertex_weights: # A new '=== ' header means we've left this mesh's vertex weights if line.startswith('=== '): in_vertex_weights = False out.append(line) i += 1 continue # Vertex block: 'Vertex N:\n' if re.match(r'Vertex \d+:\s*$', line): out.append(line) i += 1 groups_line = lines[i] k = int(re.search(r'\d+', groups_line).group()) i += 1 weights = {} for _ in range(k): m = re.match(r"\s*Group: '([^']+)', Weight: ([\d.]+)", lines[i]) i += 1 if m: bname, w = m.group(1), float(m.group(2)) target = remap.get(bname, bname) if target is not None: weights[target] = weights.get(target, 0.0) + w total = sum(weights.values()) if total > 0: weights = {b: w / total for b, w in weights.items()} out.append(f'Vertex groups: {len(weights)}\n') for bname, w in weights.items(): out.append(f" Group: '{bname}', Weight: {w:.6f}\n") continue out.append(line) i += 1 continue out.append(line) i += 1 # Animation section header lines ('=== Animation Keyframes ===', # '=== Bone Transforms per Keyframe ===', 'Keyframes: N') while i < len(lines) and not lines[i].startswith('Frame: '): out.append(lines[i]) i += 1 # Per-frame bone blocks while i < len(lines): line = lines[i] if line.startswith('Frame: '): out.append(line) i += 1 continue if line.startswith(' Bone: '): bone_name = line[8:].rstrip() i += 1 block = [] while i < len(lines) and not lines[i].startswith(' Bone: ') \ and not lines[i].startswith('Frame: '): block.append(lines[i]) i += 1 if bone_name not in remove_set: out.append(f' Bone: {bone_name}\n') out.extend(block) continue out.append(line) i += 1 return out def main(): parser = argparse.ArgumentParser( description='Remove bones from a text animation file and redistribute weights.') parser.add_argument('input', help='Input .txt animation file') parser.add_argument('output', help='Output .txt animation file') parser.add_argument('--remove', default=','.join(DEFAULT_REMOVE), help='Comma-separated bone names to remove (default: all pinky bones)') args = parser.parse_args() remove_set = set(args.remove.split(',')) with open(args.input, 'r', encoding='utf-8') as f: lines = f.readlines() out_lines = process(lines, remove_set) with open(args.output, 'w', encoding='utf-8') as f: f.writelines(out_lines) removed_count = len(remove_set) print(f'Done. Removed {removed_count} bones. Output written to: {args.output}') if __name__ == '__main__': main()