223 lines
7.2 KiB
Python
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()
|