space-game001/reduce_bones_txt.py
2026-06-14 19:02:06 +03:00

223 lines
7.2 KiB
Python

#!/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 <input.txt> <output.txt> [--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()