1080 lines
44 KiB
Python
1080 lines
44 KiB
Python
"""
|
|
MMD Converter - Core conversion logic for MMD models
|
|
Handles armature hierarchy and naming conventions
|
|
"""
|
|
import bpy
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple, Set
|
|
from bpy.types import Object, Bone, Collection, Material, ShapeKey
|
|
from .common import get_active_armature
|
|
from .dictionaries import simplify_bonename
|
|
from .enhanced_dictionaries import mmd_bone_patterns, mmd_to_unity_bone_map, unity_bone_hierarchy
|
|
from .logging_setup import logger
|
|
from .translations import t
|
|
from .mmd.translations import jp_to_en_tuples, translateFromJp
|
|
|
|
|
|
def detect_mmd_armature(armature: Object) -> bool:
|
|
"""Detect if armature uses MMD bone naming conventions"""
|
|
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False
|
|
|
|
found_mmd_bones = 0
|
|
for bone in armature.data.bones:
|
|
bone_name_lower = bone.name.lower()
|
|
if any(pattern.lower() in bone_name_lower for pattern in mmd_bone_patterns):
|
|
found_mmd_bones += 1
|
|
logger.debug(f"Found MMD bone: {bone.name}")
|
|
|
|
# Consider it MMD if we find at least 5 MMD bones
|
|
logger.debug(f"Found {found_mmd_bones} MMD bones in armature {armature.name}")
|
|
return found_mmd_bones >= 5
|
|
|
|
|
|
def get_armature_parent_object(armature: Object) -> Optional[Object]:
|
|
"""Get the parent object of the armature (typically an Empty in MMD imports)"""
|
|
if armature and armature.parent:
|
|
return armature.parent
|
|
return None
|
|
|
|
|
|
def make_armature_main_parent(armature: Object) -> Tuple[bool, str]:
|
|
"""Make the armature the main parent object by removing any parent empties
|
|
and reparenting all children to the armature."""
|
|
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, t("MMD.error.invalid_armature")
|
|
|
|
logger.info(f"Making armature '{armature.name}' the main parent")
|
|
|
|
# Store original parent
|
|
original_parent = armature.parent
|
|
|
|
if not original_parent:
|
|
logger.info("Armature already has no parent")
|
|
return True, t("MMD.armature_already_root")
|
|
|
|
parent_name = original_parent.name
|
|
parent_type = original_parent.type
|
|
|
|
logger.info(f"Found parent: {parent_name} (type: {parent_type})")
|
|
|
|
# Get all children of the parent
|
|
siblings = [child for child in original_parent.children if child != armature]
|
|
|
|
armature.parent = None
|
|
|
|
# Reparent siblings to the armature
|
|
reparented_count = 0
|
|
for sibling in siblings:
|
|
sibling.parent = armature
|
|
reparented_count += 1
|
|
logger.debug(f"Reparented {sibling.name} to armature")
|
|
|
|
# If the parent was an Empty and now has no children, remove it
|
|
if parent_type == 'EMPTY' and len(original_parent.children) == 0:
|
|
try:
|
|
bpy.data.objects.remove(original_parent, do_unlink=True)
|
|
logger.info(f"Removed empty parent object: {parent_name}")
|
|
message = t("MMD.parent_removed_and_reparented",
|
|
parent_name=parent_name,
|
|
count=reparented_count)
|
|
except Exception as e:
|
|
logger.warning(f"Could not remove parent empty: {str(e)}")
|
|
message = t("MMD.parent_unlinked_and_reparented",
|
|
parent_name=parent_name,
|
|
count=reparented_count)
|
|
else:
|
|
message = t("MMD.parent_unlinked", parent_name=parent_name)
|
|
|
|
logger.info(f"Successfully made armature the main parent. Reparented {reparented_count} objects")
|
|
return True, message
|
|
|
|
|
|
def rename_armature_to_standard(armature: Object) -> Tuple[bool, str]:
|
|
"""Rename the armature object to 'Armature' (standard Blender convention)"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, t("MMD.error.invalid_armature")
|
|
|
|
old_name = armature.name
|
|
|
|
# Check if already named 'Armature'
|
|
if old_name == 'Armature':
|
|
logger.info("Armature already named 'Armature'")
|
|
return True, t("MMD.armature_already_named")
|
|
|
|
logger.info(f"Renaming armature from '{old_name}' to 'Armature'")
|
|
|
|
try:
|
|
armature.name = 'Armature'
|
|
# Blender might append .001 if name exists, check actual result (Wonder if needed)
|
|
actual_name = armature.name
|
|
|
|
if actual_name == 'Armature':
|
|
message = t("MMD.armature_renamed", old_name=old_name, new_name='Armature')
|
|
else:
|
|
message = t("MMD.armature_renamed_with_suffix",
|
|
old_name=old_name,
|
|
new_name=actual_name)
|
|
logger.warning(f"Name collision, armature named: {actual_name}")
|
|
|
|
logger.info(f"Successfully renamed armature to: {actual_name}")
|
|
return True, message
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename armature: {str(e)}")
|
|
return False, t("MMD.error.rename_failed", error=str(e))
|
|
|
|
|
|
def convert_mmd_armature(armature: Object,
|
|
make_parent: bool = True,
|
|
rename_armature: bool = True) -> Tuple[bool, List[str]]:
|
|
"""Convert MMD armature to standard Blender format"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting MMD armature conversion for: {armature.name}")
|
|
|
|
# Check if this is an MMD armature
|
|
if not detect_mmd_armature(armature):
|
|
return False, [t("MMD.error.not_mmd_armature")]
|
|
|
|
messages = []
|
|
overall_success = True
|
|
|
|
# Step 1: Make armature the main parent
|
|
if make_parent:
|
|
success, message = make_armature_main_parent(armature)
|
|
messages.append(message)
|
|
if not success:
|
|
overall_success = False
|
|
logger.warning("Failed to make armature main parent")
|
|
|
|
# Step 2: Rename armature
|
|
if rename_armature:
|
|
success, message = rename_armature_to_standard(armature)
|
|
messages.append(message)
|
|
if not success:
|
|
overall_success = False
|
|
logger.warning("Failed to rename armature")
|
|
|
|
if overall_success:
|
|
logger.info("MMD armature conversion completed successfully")
|
|
messages.append(t("MMD.conversion_complete"))
|
|
else:
|
|
logger.warning("MMD armature conversion completed with errors")
|
|
|
|
return overall_success, messages
|
|
|
|
|
|
def translate_mmd_name(name: str, category: str = "auto") -> Tuple[str, str]:
|
|
"""Translate MMD name using MMD dictionary first, then translation services"""
|
|
if not name or not name.strip():
|
|
return name, "unchanged"
|
|
|
|
original_name = name.strip()
|
|
|
|
# Step 1: Try MMD built-in dictionary translation
|
|
mmd_translated = translateFromJp(original_name)
|
|
|
|
# Check if MMD dictionary actually translated something
|
|
if mmd_translated != original_name and mmd_translated:
|
|
logger.debug(f"MMD dictionary translated: '{original_name}' -> '{mmd_translated}'")
|
|
return mmd_translated, "mmd_dictionary"
|
|
|
|
# Step 2: If MMD dictionary didn't translate or only partially translated,
|
|
# use Avatar Toolkit translation services
|
|
try:
|
|
from .translation_manager import get_avatar_translation_manager
|
|
|
|
manager = get_avatar_translation_manager()
|
|
result = manager.translate_single(original_name, category=category, source_lang="ja", target_lang="en")
|
|
|
|
if result.translated != original_name:
|
|
logger.debug(f"API translated: '{original_name}' -> '{result.translated}' (method: {result.method})")
|
|
return result.translated, "api_translation"
|
|
except Exception as e:
|
|
logger.warning(f"Translation service failed for '{original_name}': {e}")
|
|
|
|
# Step 3: No translation available
|
|
logger.debug(f"No translation available for: '{original_name}'")
|
|
return original_name, "unchanged"
|
|
|
|
|
|
def translate_mmd_armature_bones(armature: Object, apply_translation: bool = True) -> Tuple[int, int, List[str]]:
|
|
"""Translate all bone names in an MMD armature"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return 0, 0, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting bone translation for armature: {armature.name}")
|
|
|
|
successful = 0
|
|
failed = 0
|
|
messages = []
|
|
bone_translations = {}
|
|
|
|
# Store the current mode
|
|
current_mode = bpy.context.mode
|
|
if current_mode != 'EDIT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
|
|
try:
|
|
for bone in armature.data.edit_bones:
|
|
original_name = bone.name
|
|
translated_name, method = translate_mmd_name(original_name, category="bones")
|
|
|
|
if translated_name != original_name:
|
|
bone_translations[original_name] = (translated_name, method)
|
|
|
|
if apply_translation:
|
|
try:
|
|
bone.name = translated_name
|
|
logger.info(f"Translated bone: '{original_name}' -> '{translated_name}' ({method})")
|
|
successful += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename bone '{original_name}': {e}")
|
|
failed += 1
|
|
else:
|
|
successful += 1
|
|
else:
|
|
logger.debug(f"Bone '{original_name}' not translated")
|
|
|
|
finally:
|
|
# Restore original mode
|
|
if current_mode != 'EDIT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Generate summary messages
|
|
if successful > 0:
|
|
messages.append(t("MMD.bones_translated", count=successful))
|
|
if failed > 0:
|
|
messages.append(t("MMD.bones_failed", count=failed))
|
|
|
|
mmd_dict_count = sum(1 for _, (_, method) in bone_translations.items() if method == "mmd_dictionary")
|
|
api_count = sum(1 for _, (_, method) in bone_translations.items() if method == "api_translation")
|
|
|
|
logger.info(f"Bone translation complete: {successful} successful, {failed} failed")
|
|
logger.info(f"Translation methods: MMD Dictionary: {mmd_dict_count}, API: {api_count}")
|
|
|
|
return successful, failed, messages
|
|
|
|
|
|
def translate_mmd_materials(armature: Object, apply_translation: bool = True) -> Tuple[int, int, List[str]]:
|
|
"""Translate all material names for meshes parented to the armature"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return 0, 0, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting material translation for armature: {armature.name}")
|
|
|
|
successful = 0
|
|
failed = 0
|
|
messages = []
|
|
processed_materials = set()
|
|
|
|
# Get all mesh objects parented to this armature
|
|
for obj in bpy.data.objects:
|
|
if obj.type == 'MESH' and obj.parent == armature and obj.data.materials:
|
|
for mat in obj.data.materials:
|
|
if mat and mat.name not in processed_materials:
|
|
processed_materials.add(mat.name)
|
|
original_name = mat.name
|
|
translated_name, method = translate_mmd_name(original_name, category="materials")
|
|
|
|
if translated_name != original_name and apply_translation:
|
|
try:
|
|
mat.name = translated_name
|
|
logger.info(f"Translated material: '{original_name}' -> '{translated_name}' ({method})")
|
|
successful += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename material '{original_name}': {e}")
|
|
failed += 1
|
|
elif translated_name != original_name:
|
|
successful += 1
|
|
|
|
if successful > 0:
|
|
messages.append(t("MMD.materials_translated", count=successful))
|
|
if failed > 0:
|
|
messages.append(t("MMD.materials_failed", count=failed))
|
|
|
|
logger.info(f"Material translation complete: {successful} successful, {failed} failed")
|
|
|
|
return successful, failed, messages
|
|
|
|
|
|
def translate_mmd_shapekeys(armature: Object, apply_translation: bool = True) -> Tuple[int, int, List[str]]:
|
|
"""Translate all shape key names for meshes parented to the armature"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return 0, 0, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting shape key translation for armature: {armature.name}")
|
|
|
|
successful = 0
|
|
failed = 0
|
|
messages = []
|
|
|
|
# Get all mesh objects parented to this armature
|
|
for obj in bpy.data.objects:
|
|
if obj.type == 'MESH' and obj.parent == armature and obj.data.shape_keys:
|
|
for shape_key in obj.data.shape_keys.key_blocks:
|
|
original_name = shape_key.name
|
|
translated_name, method = translate_mmd_name(original_name, category="shapekeys")
|
|
|
|
if translated_name != original_name and apply_translation:
|
|
try:
|
|
shape_key.name = translated_name
|
|
logger.info(f"Translated shape key: '{original_name}' -> '{translated_name}' ({method})")
|
|
successful += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename shape key '{original_name}': {e}")
|
|
failed += 1
|
|
elif translated_name != original_name:
|
|
successful += 1
|
|
|
|
if successful > 0:
|
|
messages.append(t("MMD.shapekeys_translated", count=successful))
|
|
if failed > 0:
|
|
messages.append(t("MMD.shapekeys_failed", count=failed))
|
|
|
|
logger.info(f"Shape key translation complete: {successful} successful, {failed} failed")
|
|
|
|
return successful, failed, messages
|
|
|
|
|
|
def translate_mmd_objects(armature: Object, apply_translation: bool = True) -> Tuple[int, int, List[str]]:
|
|
"""Translate object names parented to the armature"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return 0, 0, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting object name translation for armature: {armature.name}")
|
|
|
|
successful = 0
|
|
failed = 0
|
|
messages = []
|
|
|
|
# Get all objects parented to this armature
|
|
for obj in bpy.data.objects:
|
|
if obj.parent == armature:
|
|
original_name = obj.name
|
|
translated_name, method = translate_mmd_name(original_name, category="objects")
|
|
|
|
if translated_name != original_name and apply_translation:
|
|
try:
|
|
obj.name = translated_name
|
|
logger.info(f"Translated object: '{original_name}' -> '{translated_name}' ({method})")
|
|
successful += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename object '{original_name}': {e}")
|
|
failed += 1
|
|
elif translated_name != original_name:
|
|
successful += 1
|
|
|
|
if successful > 0:
|
|
messages.append(t("MMD.objects_translated", count=successful))
|
|
if failed > 0:
|
|
messages.append(t("MMD.objects_failed", count=failed))
|
|
|
|
logger.info(f"Object translation complete: {successful} successful, {failed} failed")
|
|
|
|
return successful, failed, messages
|
|
|
|
|
|
def translate_mmd_everything(armature: Object,
|
|
translate_bones: bool = True,
|
|
translate_materials: bool = True,
|
|
translate_shapekeys: bool = True,
|
|
translate_objects: bool = True) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Translate all MMD names (bones, materials, shape keys, objects)
|
|
|
|
Args:
|
|
armature: The armature object
|
|
translate_bones: Whether to translate bone names
|
|
translate_materials: Whether to translate material names
|
|
translate_shapekeys: Whether to translate shape key names
|
|
translate_objects: Whether to translate object names
|
|
|
|
Returns:
|
|
Tuple of (success, messages)
|
|
"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting comprehensive MMD translation for: {armature.name}")
|
|
|
|
all_messages = []
|
|
total_successful = 0
|
|
total_failed = 0
|
|
|
|
# Translate bones
|
|
if translate_bones:
|
|
success, failed, messages = translate_mmd_armature_bones(armature, apply_translation=True)
|
|
total_successful += success
|
|
total_failed += failed
|
|
all_messages.extend(messages)
|
|
|
|
# Translate materials
|
|
if translate_materials:
|
|
success, failed, messages = translate_mmd_materials(armature, apply_translation=True)
|
|
total_successful += success
|
|
total_failed += failed
|
|
all_messages.extend(messages)
|
|
|
|
# Translate shape keys
|
|
if translate_shapekeys:
|
|
success, failed, messages = translate_mmd_shapekeys(armature, apply_translation=True)
|
|
total_successful += success
|
|
total_failed += failed
|
|
all_messages.extend(messages)
|
|
|
|
# Translate objects
|
|
if translate_objects:
|
|
success, failed, messages = translate_mmd_objects(armature, apply_translation=True)
|
|
total_successful += success
|
|
total_failed += failed
|
|
all_messages.extend(messages)
|
|
|
|
# Summary
|
|
if total_successful > 0:
|
|
all_messages.append(t("MMD.translation_complete", total=total_successful))
|
|
|
|
logger.info(f"Comprehensive MMD translation complete: {total_successful} successful, {total_failed} failed")
|
|
|
|
return total_failed == 0, all_messages
|
|
|
|
|
|
def restructure_mmd_to_unity_bones(armature: Object) -> Tuple[bool, List[str]]:
|
|
"""Restructure MMD bone hierarchy to Unity humanoid format."""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting MMD to Unity bone restructuring for: {armature.name}")
|
|
|
|
messages = []
|
|
renamed_count = 0
|
|
removed_count = 0
|
|
reparented_count = 0
|
|
|
|
# Store the current mode
|
|
current_mode = bpy.context.mode
|
|
if current_mode != 'EDIT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
|
|
try:
|
|
edit_bones = armature.data.edit_bones
|
|
bones_to_remove = []
|
|
bone_renames = {}
|
|
|
|
# Protected bone name patterns (never remove or modify these)
|
|
protected_patterns = [
|
|
r'.*[bB]reast.*', r'.*[bB]ust.*', r'.*[tT]its.*', # Breast bones
|
|
r'.*[sS]kirt.*', # Skirt bones
|
|
r'.*[hH]air.*', # Hair bones
|
|
r'.*[bB]ag.*', # Bag/accessory bones
|
|
r'.*[rR]ibbon.*', # Ribbon bones
|
|
r'.*[tT]ail.*', # Tail bones
|
|
r'.*[wW]ing.*', # Wing bones
|
|
r'.*[eE]ar.*', # Ear bones
|
|
r'.*[sS]leeve.*', # Sleeve bones
|
|
r'.*[cC]ape.*', r'.*[sS]carf.*', # Cape/Scarf bones
|
|
r'.*[cC]oat.*', r'.*[dD]ress.*', # Coat/Dress bones
|
|
r'.*[fF]inger.*', r'.*[tT]humb.*', # Finger bones
|
|
r'.*[aA]ccessor.*', # Accessory bones
|
|
r'.*[jJ]oint.*', # Joint bones
|
|
r'.*[cC]loth.*', r'.*[pP]hys.*', # Cloth/Physics bones
|
|
r'^tf_.*', # tf_ prefixed bones (clothing/accessories)
|
|
r'^\+.*', # + prefixed bones (accessories)
|
|
r'.*[tT]ooth.*', # Tooth bones
|
|
]
|
|
compiled_protected = [re.compile(pattern) for pattern in protected_patterns]
|
|
|
|
# Step 1: Identify and map bones (but only rename/remove bones explicitly in the map)
|
|
for bone in edit_bones:
|
|
bone_name = bone.name
|
|
|
|
# Check if bone is protected - never touch these
|
|
is_protected = any(pattern.match(bone_name) for pattern in compiled_protected)
|
|
if is_protected:
|
|
logger.debug(f"Protected bone (keeping): {bone_name}")
|
|
continue
|
|
|
|
# Only process bones that are EXPLICITLY in the map
|
|
unity_name = mmd_to_unity_bone_map.get(bone_name)
|
|
|
|
if unity_name is None:
|
|
# Only mark for removal if explicitly mapped to None
|
|
if bone_name in mmd_to_unity_bone_map:
|
|
bones_to_remove.append(bone_name)
|
|
logger.debug(f"Marking bone for removal: {bone_name}")
|
|
# Otherwise, keep the bone as-is
|
|
elif unity_name != bone_name:
|
|
# Mark for rename only if explicitly mapped
|
|
bone_renames[bone_name] = unity_name
|
|
logger.debug(f"Planning rename: {bone_name} -> {unity_name}")
|
|
|
|
# Step 2: Handle bone merging (e.g., LowerBody + Center -> Hips)
|
|
unity_bone_sources = {}
|
|
for old_name, new_name in bone_renames.items():
|
|
if new_name not in unity_bone_sources:
|
|
unity_bone_sources[new_name] = []
|
|
unity_bone_sources[new_name].append(old_name)
|
|
|
|
# For bones with multiple sources, keep the first one and remove others
|
|
for unity_name, sources in unity_bone_sources.items():
|
|
if len(sources) > 1:
|
|
logger.info(f"Multiple bones map to '{unity_name}': {sources}")
|
|
# Keep the first, mark others for removal and reparent their children
|
|
keep_bone = sources[0]
|
|
for source in sources[1:]:
|
|
if source in edit_bones:
|
|
# Reparent children to the kept bone
|
|
bone_to_remove = edit_bones[source]
|
|
keep_bone_obj = edit_bones[keep_bone]
|
|
for child in bone_to_remove.children:
|
|
child.parent = keep_bone_obj
|
|
reparented_count += 1
|
|
bones_to_remove.append(source)
|
|
if source in bone_renames:
|
|
del bone_renames[source]
|
|
|
|
# Step 3: Reparent bones to be removed (move children to parent)
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
bone = edit_bones[bone_name]
|
|
parent_bone = bone.parent
|
|
for child in bone.children:
|
|
child.parent = parent_bone
|
|
reparented_count += 1
|
|
logger.debug(f"Reparented {child.name} from {bone_name} to {parent_bone.name if parent_bone else 'None'}")
|
|
|
|
# Step 4: Remove marked bones
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
edit_bones.remove(edit_bones[bone_name])
|
|
removed_count += 1
|
|
logger.info(f"Removed bone: {bone_name}")
|
|
|
|
# Step 5: Rename bones
|
|
for old_name, new_name in bone_renames.items():
|
|
if old_name in edit_bones:
|
|
bone = edit_bones[old_name]
|
|
try:
|
|
bone.name = new_name
|
|
renamed_count += 1
|
|
logger.info(f"Renamed bone: {old_name} -> {new_name}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to rename bone {old_name}: {e}")
|
|
|
|
# Step 6: Fix hierarchy to match Unity standard
|
|
for bone in edit_bones:
|
|
expected_parent_name = unity_bone_hierarchy.get(bone.name)
|
|
if expected_parent_name is not None:
|
|
# This bone should have a specific parent
|
|
if expected_parent_name in edit_bones:
|
|
expected_parent = edit_bones[expected_parent_name]
|
|
if bone.parent != expected_parent:
|
|
bone.parent = expected_parent
|
|
reparented_count += 1
|
|
logger.debug(f"Fixed hierarchy: {bone.name} -> parent: {expected_parent_name}")
|
|
elif expected_parent_name is None and unity_bone_hierarchy.get(bone.name) is not None:
|
|
# This should be a root bone
|
|
if bone.parent is not None:
|
|
bone.parent = None
|
|
reparented_count += 1
|
|
logger.debug(f"Made {bone.name} a root bone")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during bone restructuring: {e}", exc_info=True)
|
|
messages.append(t("MMD.restructure_failed", error=str(e)))
|
|
return False, messages
|
|
|
|
finally:
|
|
# Restore original mode
|
|
if current_mode != 'EDIT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Generate messages
|
|
if renamed_count > 0:
|
|
messages.append(t("MMD.bones_restructured", count=renamed_count))
|
|
if removed_count > 0:
|
|
messages.append(t("MMD.bones_removed", count=removed_count))
|
|
if reparented_count > 0:
|
|
messages.append(t("MMD.bones_reparented", count=reparented_count))
|
|
|
|
logger.info(f"Bone restructuring complete: {renamed_count} renamed, {removed_count} removed, {reparented_count} reparented")
|
|
|
|
return True, messages
|
|
|
|
|
|
def remove_mmd_ik_bones(armature: Object) -> Tuple[bool, List[str]]:
|
|
"""Remove MMD IK (Inverse Kinematics) and helper bones."""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting MMD IK bone removal for: {armature.name}")
|
|
|
|
messages = []
|
|
removed_count = 0
|
|
reparented_count = 0
|
|
|
|
# Store the current mode
|
|
current_mode = bpy.context.mode
|
|
|
|
try:
|
|
# Switch to object mode to check weights
|
|
if bpy.context.mode != 'OBJECT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Get all meshes using this armature
|
|
meshes = [obj for obj in bpy.data.objects
|
|
if obj.type == 'MESH' and obj.parent == armature]
|
|
|
|
# Track bones with weights
|
|
bones_with_weights = set()
|
|
|
|
for mesh in meshes:
|
|
for vertex_group in mesh.vertex_groups:
|
|
# Check if any vertices have non-zero weights
|
|
has_weight = False
|
|
for vert in mesh.data.vertices:
|
|
try:
|
|
weight = vertex_group.weight(vert.index)
|
|
if weight > 0.001: # Threshold for "zero" weight
|
|
has_weight = True
|
|
break
|
|
except:
|
|
continue
|
|
|
|
if has_weight:
|
|
bones_with_weights.add(vertex_group.name)
|
|
|
|
logger.info(f"Found {len(bones_with_weights)} bones with vertex weights")
|
|
|
|
# Set armature as active object before switching modes
|
|
bpy.context.view_layer.objects.active = armature
|
|
|
|
# Patterns to identify IK and helper bones (Japanese and English)
|
|
# These are control/helper bones that typically have zero weights
|
|
ik_helper_patterns = [
|
|
r'.*[iI][kK].*', # Contains IK (IK, ik, etc.)
|
|
r'.*IK.*', # Japanese fullwidth IK
|
|
r'.*親.*', # Japanese "parent"
|
|
r'.*[DCd]$', # D/C suffix (D-bones, control bones like RightKneeD, RightAnkleD)
|
|
r'.*[Ee][Xx]$', # EX suffix (extra bones like RightLegTipEX)
|
|
r'.*[Pp]arent$', # Ends with Parent
|
|
r'.*[Pp]$', # P suffix (helper bones like ShoulderP)
|
|
r'.*[Cc]$', # C suffix (control bones like ShoulderC)
|
|
r'^_dummy_.*', # Dummy bones
|
|
r'^_shadow_.*', # Shadow bones
|
|
r'.*ダミー.*', # Japanese "dummy"
|
|
r'.*補助.*', # Japanese "auxiliary/helper"
|
|
r'.*操作.*', # Japanese "control/operation"
|
|
r'.*[Tt]arget$', # Target bones
|
|
r'.*[Gg]roup$', # Group bones
|
|
]
|
|
|
|
# Compile patterns
|
|
compiled_patterns = [re.compile(pattern) for pattern in ik_helper_patterns]
|
|
|
|
# Protected bone names (main skeleton - never remove these even with zero weights)
|
|
protected_bones = {
|
|
"Hips", "Spine", "Chest", "UpperChest", "Upper Chest", "Neck", "Head",
|
|
"Left shoulder", "Right shoulder", "Shoulder_L", "Shoulder_R",
|
|
"Left arm", "Right arm", "UpperArm_L", "UpperArm_R",
|
|
"Left elbow", "Right elbow", "LowerArm_L", "LowerArm_R",
|
|
"Left wrist", "Right wrist", "Hand_L", "Hand_R",
|
|
"Left leg", "Right leg", "UpperLeg_L", "UpperLeg_R",
|
|
"Left knee", "Right knee", "LowerLeg_L", "LowerLeg_R",
|
|
"Left ankle", "Right ankle", "Foot_L", "Foot_R",
|
|
"Left toe", "Right toe", "Toe_L", "Toe_R",
|
|
"Left eye", "Right eye", "Eye_L", "Eye_R"
|
|
}
|
|
|
|
# Protected bone name patterns (never remove these)
|
|
protected_patterns = [
|
|
r'.*[bB]reast.*', # Breast bones
|
|
r'.*[bB]ust.*', # Bust bones
|
|
r'.*[sS]kirt.*', # Skirt bones
|
|
r'.*[hH]air.*', # Hair bones
|
|
r'.*[bB]ag.*', # Bag/accessory bones
|
|
r'.*[rR]ibbon.*', # Ribbon bones
|
|
r'.*[tT]ail.*', # Tail bones
|
|
r'.*[wW]ing.*', # Wing bones
|
|
r'.*[sS]leeve.*', # Sleeve bones
|
|
r'.*[cC]ape.*', # Cape bones
|
|
r'.*[sS]carf.*', # Scarf bones
|
|
r'.*[cC]oat.*', # Coat bones
|
|
r'.*[dD]ress.*', # Dress bones
|
|
r'.*[fF]inger.*', # Finger bones
|
|
r'.*[tT]humb.*', # Thumb bones
|
|
r'.*[aA]ccessor.*', # Accessory bones
|
|
r'.*[cC]loth.*', # Cloth bones
|
|
r'.*[pP]hys.*', # Physics bones
|
|
]
|
|
compiled_protected = [re.compile(pattern) for pattern in protected_patterns]
|
|
|
|
# Switch to pose mode to remove constraints first
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
pose_bones = armature.pose.bones
|
|
|
|
# Identify IK/helper bones to remove (zero weight + matches pattern)
|
|
bones_to_remove = []
|
|
|
|
for bone in armature.data.bones:
|
|
bone_name = bone.name
|
|
|
|
# Check if it matches IK/helper pattern FIRST (before protection checks)
|
|
matches_pattern = any(pattern.match(bone_name) for pattern in compiled_patterns)
|
|
|
|
# Skip if bone has weights (it's actually used by the mesh)
|
|
if bone_name in bones_with_weights:
|
|
if matches_pattern:
|
|
logger.debug(f"IK pattern match but has weights (keeping): {bone_name}")
|
|
continue
|
|
|
|
# If matches IK pattern, remove regardless of other checks (except weights)
|
|
if matches_pattern:
|
|
bones_to_remove.append(bone_name)
|
|
logger.debug(f"IK/helper bone identified (zero weight): {bone_name}")
|
|
|
|
# Remove constraints from this bone
|
|
if bone_name in pose_bones:
|
|
pose_bone = pose_bones[bone_name]
|
|
for constraint in list(pose_bone.constraints):
|
|
constraint_name = constraint.name
|
|
pose_bone.constraints.remove(constraint)
|
|
logger.debug(f"Removed constraint '{constraint_name}' from {bone_name}")
|
|
continue
|
|
|
|
# Skip if in protected set
|
|
if bone_name in protected_bones:
|
|
continue
|
|
|
|
# Skip if matches protected pattern
|
|
is_protected = any(pattern.match(bone_name) for pattern in compiled_protected)
|
|
if is_protected:
|
|
logger.debug(f"Protected bone (keeping): {bone_name}")
|
|
continue
|
|
|
|
# Remove constraints that reference bones we're about to delete
|
|
for pose_bone in pose_bones:
|
|
for constraint in list(pose_bone.constraints):
|
|
if hasattr(constraint, 'target') and constraint.target:
|
|
if hasattr(constraint, 'subtarget') and constraint.subtarget in bones_to_remove:
|
|
constraint_name = constraint.name
|
|
pose_bone_name = pose_bone.name
|
|
pose_bone.constraints.remove(constraint)
|
|
logger.debug(f"Removed constraint '{constraint_name}' from {pose_bone_name} (referenced deleted bone)")
|
|
|
|
# Switch to edit mode for bone removal
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
edit_bones = armature.data.edit_bones
|
|
|
|
# Reparent children before removing
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
bone = edit_bones[bone_name]
|
|
parent_bone = bone.parent
|
|
for child in bone.children:
|
|
child.parent = parent_bone
|
|
reparented_count += 1
|
|
logger.debug(f"Reparented {child.name} from {bone_name} to {parent_bone.name if parent_bone else 'None'}")
|
|
|
|
# Remove IK/helper bones
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
edit_bones.remove(edit_bones[bone_name])
|
|
removed_count += 1
|
|
logger.info(f"Removed IK/helper bone: {bone_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during IK bone removal: {e}", exc_info=True)
|
|
messages.append(t("MMD.ik_removal_failed", error=str(e)))
|
|
return False, messages
|
|
|
|
finally:
|
|
# Restore original mode
|
|
if current_mode != 'OBJECT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Generate messages
|
|
if removed_count > 0:
|
|
messages.append(t("MMD.ik_bones_removed", count=removed_count))
|
|
else:
|
|
messages.append(t("MMD.no_ik_bones_found"))
|
|
|
|
if reparented_count > 0:
|
|
messages.append(t("MMD.bones_reparented", count=reparented_count))
|
|
|
|
logger.info(f"IK bone removal complete: {removed_count} removed, {reparented_count} reparented")
|
|
|
|
return True, messages
|
|
|
|
|
|
def remove_mmd_twist_bones(armature: Object) -> Tuple[bool, List[str]]:
|
|
"""Remove MMD twist bones."""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting MMD twist bone removal for: {armature.name}")
|
|
|
|
messages = []
|
|
removed_count = 0
|
|
reparented_count = 0
|
|
|
|
# Store the current mode
|
|
current_mode = bpy.context.mode
|
|
if current_mode != 'EDIT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
|
|
try:
|
|
edit_bones = armature.data.edit_bones
|
|
bones_to_remove = []
|
|
|
|
# Patterns to identify twist bones - be specific about twist markers
|
|
twist_patterns = [
|
|
r'.*_[tT]wist$', # Ends with _twist or _Twist
|
|
r'^[tT]wist_', # Starts with twist_ or Twist_
|
|
r'.*\.[tT]wist$', # Ends with .twist or .Twist
|
|
r'^[tT]wist\.', # Starts with twist. or Twist.
|
|
r'.*[tT]wist$', # Ends with Twist (no underscore/dot required)
|
|
r'.*捩.*', # Contains Japanese twist character
|
|
]
|
|
|
|
# Compile patterns
|
|
compiled_patterns = [re.compile(pattern) for pattern in twist_patterns]
|
|
|
|
# Protected bone name patterns (never remove these)
|
|
protected_patterns = [
|
|
r'.*[bB]reast.*', # Breast bones
|
|
r'.*[bB]ust.*', # Bust bones
|
|
r'.*[sS]kirt.*', # Skirt bones
|
|
r'.*[hH]air.*', # Hair bones
|
|
r'.*[bB]ag.*', # Bag/accessory bones
|
|
r'.*[rR]ibbon.*', # Ribbon bones
|
|
r'.*[tT]ail.*', # Tail bones
|
|
r'.*[wW]ing.*', # Wing bones
|
|
r'.*[eE]ar.*', # Ear bones
|
|
r'.*[sS]leeve.*', # Sleeve bones
|
|
r'.*[cC]ape.*', # Cape bones
|
|
r'.*[sS]carf.*', # Scarf bones
|
|
r'.*[cC]oat.*', # Coat bones
|
|
r'.*[dD]ress.*', # Dress bones
|
|
]
|
|
compiled_protected = [re.compile(pattern) for pattern in protected_patterns]
|
|
|
|
# Identify twist bones (but exclude protected bones)
|
|
for bone in edit_bones:
|
|
bone_name = bone.name
|
|
|
|
# Check if bone is protected
|
|
is_protected = any(pattern.match(bone_name) for pattern in compiled_protected)
|
|
if is_protected:
|
|
continue
|
|
|
|
# Check if it's a twist bone
|
|
for pattern in compiled_patterns:
|
|
if pattern.match(bone_name):
|
|
bones_to_remove.append(bone_name)
|
|
logger.debug(f"Twist bone identified: {bone_name}")
|
|
break
|
|
|
|
# Reparent children before removing
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
bone = edit_bones[bone_name]
|
|
parent_bone = bone.parent
|
|
for child in bone.children:
|
|
child.parent = parent_bone
|
|
reparented_count += 1
|
|
logger.debug(f"Reparented {child.name} from {bone_name} to {parent_bone.name if parent_bone else 'None'}")
|
|
|
|
# Remove twist bones
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
edit_bones.remove(edit_bones[bone_name])
|
|
removed_count += 1
|
|
logger.info(f"Removed twist bone: {bone_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during twist bone removal: {e}", exc_info=True)
|
|
messages.append(t("MMD.twist_removal_failed", error=str(e)))
|
|
return False, messages
|
|
|
|
finally:
|
|
# Restore original mode
|
|
if current_mode != 'EDIT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Generate messages
|
|
if removed_count > 0:
|
|
messages.append(t("MMD.twist_bones_removed", count=removed_count))
|
|
else:
|
|
messages.append(t("MMD.no_twist_bones_found"))
|
|
|
|
if reparented_count > 0:
|
|
messages.append(t("MMD.bones_reparented", count=reparented_count))
|
|
|
|
logger.info(f"Twist bone removal complete: {removed_count} removed, {reparented_count} reparented")
|
|
|
|
return True, messages
|
|
|
|
|
|
def remove_mmd_zero_weight_bones(armature: Object) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Remove bones with zero or near-zero vertex weights.
|
|
Protects main skeleton bones from removal.
|
|
"""
|
|
if not armature or armature.type != 'ARMATURE':
|
|
return False, [t("MMD.error.invalid_armature")]
|
|
|
|
logger.info(f"Starting zero weight bone removal for: {armature.name}")
|
|
|
|
messages = []
|
|
removed_count = 0
|
|
reparented_count = 0
|
|
|
|
# Store the current mode
|
|
current_mode = bpy.context.mode
|
|
|
|
try:
|
|
# Switch to object mode to check weights
|
|
if bpy.context.mode != 'OBJECT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Get all meshes using this armature
|
|
meshes = [obj for obj in bpy.data.objects
|
|
if obj.type == 'MESH' and obj.parent == armature]
|
|
|
|
# Track bones with weights
|
|
bones_with_weights = set()
|
|
|
|
for mesh in meshes:
|
|
for vertex_group in mesh.vertex_groups:
|
|
# Check if any vertices have non-zero weights
|
|
has_weight = False
|
|
for vert in mesh.data.vertices:
|
|
try:
|
|
weight = vertex_group.weight(vert.index)
|
|
if weight > 0.001: # Threshold for "zero" weight
|
|
has_weight = True
|
|
break
|
|
except:
|
|
continue
|
|
|
|
if has_weight:
|
|
bones_with_weights.add(vertex_group.name)
|
|
|
|
# Switch to edit mode
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
edit_bones = armature.data.edit_bones
|
|
|
|
# Protected bone names (main skeleton)
|
|
protected_bones = {
|
|
"Hips", "Spine", "Chest", "UpperChest", "Neck", "Head",
|
|
"Left shoulder", "Right shoulder", "Shoulder_L", "Shoulder_R",
|
|
"Left arm", "Right arm", "UpperArm_L", "UpperArm_R",
|
|
"Left elbow", "Right elbow", "LowerArm_L", "LowerArm_R",
|
|
"Left wrist", "Right wrist", "Hand_L", "Hand_R",
|
|
"Left leg", "Right leg", "UpperLeg_L", "UpperLeg_R",
|
|
"Left knee", "Right knee", "LowerLeg_L", "LowerLeg_R",
|
|
"Left ankle", "Right ankle", "Foot_L", "Foot_R",
|
|
"Left toe", "Right toe", "Toe_L", "Toe_R",
|
|
"Left eye", "Right eye", "Eye_L", "Eye_R"
|
|
}
|
|
|
|
# Protected bone name patterns (never remove these even with zero weights)
|
|
protected_patterns = [
|
|
r'.*[bB]reast.*', # Breast bones
|
|
r'.*[bB]ust.*', # Bust bones
|
|
r'.*[sS]kirt.*', # Skirt bones
|
|
r'.*[hH]air.*', # Hair bones
|
|
r'.*[bB]ag.*', # Bag/accessory bones
|
|
r'.*[rR]ibbon.*', # Ribbon bones
|
|
r'.*[tT]ail.*', # Tail bones
|
|
r'.*[wW]ing.*', # Wing bones
|
|
r'.*[eE]ar.*', # Ear bones (not Eye)
|
|
r'.*[sS]leeve.*', # Sleeve bones
|
|
r'.*[cC]ape.*', # Cape bones
|
|
r'.*[sS]carf.*', # Scarf bones
|
|
r'.*[cC]oat.*', # Coat bones
|
|
r'.*[dD]ress.*', # Dress bones
|
|
r'.*[fF]inger.*', # Finger bones
|
|
r'.*[tT]humb.*', # Thumb bones
|
|
r'.*[iI]ndex.*', # Index finger bones
|
|
r'.*[mM]iddle.*', # Middle finger bones
|
|
r'.*[rR]ing.*', # Ring finger bones
|
|
r'.*[pP]ink.*', # Pinky bones
|
|
r'.*[tT]oe.*', # Toe bones
|
|
r'.*[aA]ccessor.*', # Accessory bones
|
|
r'.*[jJ]oint.*', # Joint bones
|
|
r'.*[cC]loth.*', # Cloth bones
|
|
r'.*[pP]hys.*', # Physics bones
|
|
]
|
|
compiled_protected = [re.compile(pattern) for pattern in protected_patterns]
|
|
|
|
# Identify zero weight bones (but exclude protected bones)
|
|
bones_to_remove = []
|
|
for bone in edit_bones:
|
|
# Skip if bone has weights
|
|
if bone.name in bones_with_weights:
|
|
continue
|
|
|
|
# Skip if in protected set
|
|
if bone.name in protected_bones:
|
|
continue
|
|
|
|
# Skip if matches protected pattern
|
|
is_protected = any(pattern.match(bone.name) for pattern in compiled_protected)
|
|
if is_protected:
|
|
logger.debug(f"Protected bone (zero weight but keeping): {bone.name}")
|
|
continue
|
|
|
|
bones_to_remove.append(bone.name)
|
|
logger.debug(f"Zero weight bone identified: {bone.name}")
|
|
|
|
# Reparent children before removing
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
bone = edit_bones[bone_name]
|
|
parent_bone = bone.parent
|
|
for child in bone.children:
|
|
child.parent = parent_bone
|
|
reparented_count += 1
|
|
logger.debug(f"Reparented {child.name} from {bone_name} to {parent_bone.name if parent_bone else 'None'}")
|
|
|
|
# Remove zero weight bones
|
|
for bone_name in bones_to_remove:
|
|
if bone_name in edit_bones:
|
|
edit_bones.remove(edit_bones[bone_name])
|
|
removed_count += 1
|
|
logger.info(f"Removed zero weight bone: {bone_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during zero weight bone removal: {e}", exc_info=True)
|
|
messages.append(t("MMD.zero_weight_removal_failed", error=str(e)))
|
|
return False, messages
|
|
|
|
finally:
|
|
# Restore original mode
|
|
if current_mode != 'OBJECT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Generate messages
|
|
if removed_count > 0:
|
|
messages.append(t("MMD.zero_weight_bones_removed", count=removed_count))
|
|
else:
|
|
messages.append(t("MMD.no_zero_weight_bones_found"))
|
|
|
|
if reparented_count > 0:
|
|
messages.append(t("MMD.bones_reparented", count=reparented_count))
|
|
|
|
logger.info(f"Zero weight bone removal complete: {removed_count} removed, {reparented_count} reparented")
|
|
|
|
return True, messages
|