diff --git a/core/enhanced_dictionaries.py b/core/enhanced_dictionaries.py index e35c9a7..889ffe9 100644 --- a/core/enhanced_dictionaries.py +++ b/core/enhanced_dictionaries.py @@ -174,6 +174,26 @@ physics_names: Dict[str, List[str]] = { "breast_tip": ["胸先", "むねさき", "ブレストティップ", "breasttip"], } +# MMD bone name patterns (for detection) +mmd_bone_patterns: List[str] = [ + # Japanese bone names + '全ての親', 'センター', '上半身', '下半身', '首', '頭', + '右腕', '左腕', '右ひじ', '左ひじ', '右手首', '左手首', + '右足', '左足', '右ひざ', '左ひざ', '右足首', '左足首', + '両目', '左目', '右目', '右肩', '左肩', + # English bone names (common in MMD exports) + 'center', 'groove', 'waist', 'upperbody', 'upperbody2', 'lowerbody', + 'neck', 'head', + 'shoulder_r', 'shoulder_l', 'arm_r', 'arm_l', + 'elbow_r', 'elbow_l', 'wrist_r', 'wrist_l', + 'leg_r', 'leg_l', 'knee_r', 'knee_l', + 'ankle_r', 'ankle_l', 'toe_r', 'toe_l', + # Mixed/Romanized patterns + '센터', 'グルーブ', 'ウエスト', + # Common MMD suffixes + '_r', '_l', '.r', '.l' +] + # Create reverse lookup dictionaries reverse_shapekey_lookup: Dict[str, str] = {} reverse_material_lookup: Dict[str, str] = {} diff --git a/core/mmd_converter.py b/core/mmd_converter.py new file mode 100644 index 0000000..d260097 --- /dev/null +++ b/core/mmd_converter.py @@ -0,0 +1,166 @@ +""" +MMD Converter - Core conversion logic for MMD models +Handles armature hierarchy and naming conventions +""" +import bpy +from typing import Dict, List, Optional, Tuple, Set +from bpy.types import Object, Bone, Collection +from .common import get_active_armature +from .dictionaries import simplify_bonename +from .enhanced_dictionaries import mmd_bone_patterns +from .logging_setup import logger +from .translations import t + + +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 diff --git a/core/properties.py b/core/properties.py index 7c14f56..5c9dbab 100644 --- a/core/properties.py +++ b/core/properties.py @@ -703,6 +703,19 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=True ) + # MMD Conversion Properties + mmd_make_parent: BoolProperty( + name=t("MMD.make_armature_parent"), + description="Remove parent Empty object and make armature the main parent", + default=True + ) + + mmd_rename_armature: BoolProperty( + name=t("MMD.rename_to_armature"), + description="Rename the armature object to 'Armature'", + default=True + ) + # Translation System Properties translation_service: EnumProperty( name=t("Translation.service"), diff --git a/functions/tools/mmd_conversion.py b/functions/tools/mmd_conversion.py new file mode 100644 index 0000000..0f238d6 --- /dev/null +++ b/functions/tools/mmd_conversion.py @@ -0,0 +1,58 @@ +""" +MMD Conversion Operator +Converts MMD armatures to standard Blender format +""" +import bpy +from bpy.types import Operator +from ...core.common import get_active_armature +from ...core.translations import t +from ...core.mmd_converter import convert_mmd_armature, detect_mmd_armature +from ...core.logging_setup import logger + + +class AvatarToolkit_OT_ConvertMMDArmature(Operator): + """Convert MMD armature to standard Blender format""" + bl_idname = "avatar_toolkit.convert_mmd_armature" + bl_label = t("MMD.convert_armature.label") + bl_description = t("MMD.convert_armature.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature is not None + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + logger.warning("No active armature found for MMD conversion") + self.report({'ERROR'}, t("MMD.no_armature_selected")) + return {'CANCELLED'} + + logger.info(f"Starting MMD conversion for armature: {armature.name}") + + # Check if it's an MMD armature + if not detect_mmd_armature(armature): + logger.warning(f"Armature '{armature.name}' does not appear to be an MMD armature") + self.report({'WARNING'}, t("MMD.not_mmd_armature")) + return {'CANCELLED'} + + # conversion settings + toolkit = context.scene.avatar_toolkit + make_parent = toolkit.mmd_make_parent + rename_armature = toolkit.mmd_rename_armature + + logger.info(f"Conversion settings - Make parent: {make_parent}, Rename: {rename_armature}") + success, messages = convert_mmd_armature(armature, make_parent, rename_armature) + + if not success: + logger.warning(f"MMD conversion failed: {messages}") + for msg in messages: + self.report({'WARNING'}, msg) + return {'CANCELLED'} + + logger.info(f"MMD conversion completed successfully") + for msg in messages: + self.report({'INFO'}, msg) + + return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index bcb8ec5..80a1f3b 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -601,6 +601,39 @@ "VRM.remove_root": "Remove Root Bone", "VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone", + "MMD.panel.label": "MMD Converter", + "MMD.converter.title": "MMD Armature Converter", + "MMD.no_armature_selected": "No armature selected", + "MMD.select_armature_to_convert": "Select an armature to convert", + "MMD.armature_name": "Armature: {name}", + "MMD.armature_detected": "MMD armature detected", + "MMD.no_mmd_bones_detected": "No MMD bones detected", + "MMD.not_mmd_armature": "Selected armature does not appear to be MMD format", + "MMD.make_armature_parent": "Make Armature Main Parent", + "MMD.rename_to_armature": "Rename to 'Armature'", + "MMD.convert_armature_button": "Convert MMD Armature", + "MMD.convert_armature.label": "Convert MMD Armature", + "MMD.convert_armature.desc": "Convert MMD armature to standard Blender format", + "MMD.conversion_info.title": "Conversion Info:", + "MMD.conversion_info.removes_parent": "• Removes parent Empty object", + "MMD.conversion_info.renames_armature": "• Renames armature to 'Armature'", + "MMD.conversion_info.maintains_hierarchy": "• Maintains object hierarchy", + "MMD.detection_failed.title": "MMD Detection Failed:", + "MMD.detection_failed.not_mmd_format": "• Selected armature is not MMD format", + "MMD.detection_failed.need_mmd_bones": "• Need at least 5 MMD bones detected", + "MMD.detection_failed.check_bone_names": "• Check armature bone names", + "MMD.error.invalid_armature": "Invalid armature object", + "MMD.error.not_mmd_armature": "Armature does not appear to be MMD format", + "MMD.error.rename_failed": "Failed to rename armature: {error}", + "MMD.armature_already_root": "Armature already has no parent", + "MMD.armature_already_named": "Armature is already named 'Armature'", + "MMD.parent_removed_and_reparented": "Removed parent '{parent_name}' and reparented {count} objects to armature", + "MMD.parent_unlinked_and_reparented": "Unlinked from parent '{parent_name}' and reparented {count} objects", + "MMD.parent_unlinked": "Unlinked armature from parent '{parent_name}'", + "MMD.armature_renamed": "Renamed armature from '{old_name}' to '{new_name}'", + "MMD.armature_renamed_with_suffix": "Renamed armature from '{old_name}' to '{new_name}' (name collision)", + "MMD.conversion_complete": "MMD armature conversion completed successfully", + "Translation.label": "Translation", "Translation.service": "Translation Service", "Translation.service_desc": "Choose the translation service to use", diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py new file mode 100644 index 0000000..39baf4f --- /dev/null +++ b/ui/mmd_panel.py @@ -0,0 +1,87 @@ +""" +MMD Converter Panel - UI for MMD conversion tools +""" +import bpy +from bpy.types import Panel, Context, UILayout +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .panel_layout import get_panel_order, should_open_by_default +from ..core.translations import t +from ..core.common import get_active_armature +from ..core.mmd_converter import detect_mmd_armature +from ..functions.tools.mmd_conversion import AvatarToolkit_OT_ConvertMMDArmature + + +class AvatarToolKit_PT_MMDPanel(Panel): + """Panel for MMD conversion tools""" + bl_label = t("MMD.panel.label") + bl_idname = "OBJECT_PT_avatar_toolkit_mmd" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = get_panel_order('mmd') + bl_options = set() if not should_open_by_default('MMD') else {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the MMD conversion panel interface""" + layout: UILayout = self.layout + + # MMD Conversion Tools + mmd_box: UILayout = layout.box() + col: UILayout = mmd_box.column(align=True) + col.label(text=t("MMD.converter.title"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + + # Check if we have an active armature + armature = get_active_armature(context) + + if not armature: + col.label(text=t("MMD.no_armature_selected"), icon='ERROR') + col.label(text=t("MMD.select_armature_to_convert")) + return + + # Check if the armature appears to be MMD + is_mmd = detect_mmd_armature(armature) + + if is_mmd: + col.label(text=t("MMD.armature_name", name=armature.name), icon='CHECKMARK') + col.label(text=t("MMD.armature_detected"), icon='INFO') + col.separator(factor=0.3) + + toolkit = context.scene.avatar_toolkit + col.prop(toolkit, 'mmd_make_parent', text=t("MMD.make_armature_parent")) + col.prop(toolkit, 'mmd_rename_armature', text=t("MMD.rename_to_armature")) + col.separator(factor=0.2) + + col.operator( + AvatarToolkit_OT_ConvertMMDArmature.bl_idname, + text=t("MMD.convert_armature_button"), + icon='EXPORT' + ) + + info_box = mmd_box.box() + info_col = info_box.column(align=True) + info_col.label(text=t("MMD.conversion_info.title"), icon='INFO') + info_col.label(text=t("MMD.conversion_info.removes_parent")) + info_col.label(text=t("MMD.conversion_info.renames_armature")) + info_col.label(text=t("MMD.conversion_info.maintains_hierarchy")) + + else: + col.label(text=t("MMD.armature_name", name=armature.name), icon='ERROR') + col.label(text=t("MMD.no_mmd_bones_detected"), icon='CANCEL') + col.separator(factor=0.3) + + row = col.row() + row.enabled = False + row.operator( + AvatarToolkit_OT_ConvertMMDArmature.bl_idname, + text=t("MMD.convert_armature_button"), + icon='CANCEL' + ) + + help_box = mmd_box.box() + help_col = help_box.column(align=True) + help_col.label(text=t("MMD.detection_failed.title"), icon='QUESTION') + help_col.label(text=t("MMD.detection_failed.not_mmd_format")) + help_col.label(text=t("MMD.detection_failed.need_mmd_bones")) + help_col.label(text=t("MMD.detection_failed.check_bone_names")) diff --git a/ui/panel_layout.py b/ui/panel_layout.py index 90ccc32..b6e36cf 100644 --- a/ui/panel_layout.py +++ b/ui/panel_layout.py @@ -14,7 +14,8 @@ VISEMES_ORDER = 6 EYE_TRACKING_ORDER = 7 TEXTURE_ATLAS_ORDER = 8 VRM_UNITY_ORDER = 9 -SETTINGS_ORDER = 10 +MMD_ORDER = 10 +SETTINGS_ORDER = 11 # Panel open/closed by default PANELS_OPEN_BY_DEFAULT = { @@ -27,6 +28,7 @@ PANELS_OPEN_BY_DEFAULT = { 'EYE_TRACKING': True, 'TEXTURE_ATLAS': True, 'VRM_UNITY': True, + 'MMD': True, 'SETTINGS': True, 'TRANSLATION': True, } @@ -44,6 +46,7 @@ def get_panel_order(panel_name: str) -> int: 'eye_tracking': EYE_TRACKING_ORDER, 'texture_atlas': TEXTURE_ATLAS_ORDER, 'vrm_unity': VRM_UNITY_ORDER, + 'mmd': MMD_ORDER, 'settings': SETTINGS_ORDER, } return order_map.get(panel_name.lower(), 99)