diff --git a/core/dictionaries.py b/core/dictionaries.py index 82394b5..3e4be29 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -255,30 +255,115 @@ bone_names = { "right_eye": [ "eyeright", "righteye", "eyer", "reye", "右目", "ik_右目" ], + "breast_1_l": [ + "j_sec_l_bust1", "breast1_l", "leftbreast1", "lbreast1", "bust1_l" + ], + "breast_2_l": [ + "j_sec_l_bust2", "breast2_l", "leftbreast2", "lbreast2", "bust2_l" + ], + "breast_3_l": [ + "j_sec_l_bust3", "breast3_l", "leftbreast3", "lbreast3", "bust3_l" + ], + "breast_1_r": [ + "j_sec_r_bust1", "breast1_r", "rightbreast1", "rbreast1", "bust1_r" + ], + "breast_2_r": [ + "j_sec_r_bust2", "breast2_r", "rightbreast2", "rbreast2", "bust2_r" + ], + "breast_3_r": [ + "j_sec_r_bust3", "breast3_r", "rightbreast3", "rbreast3", "bust3_r" + ] } -# Add VRM bone name variations +# Add VRM bone name variations bone_names.update({ - 'hips': bone_names['hips'] + ['jbipchips', 'jhips', 'vrmhips'], + 'hips': bone_names['hips'] + ['jbipchips', 'jhips', 'vrmhips', 'leftupperleg', 'rightupperleg'], 'spine': bone_names['spine'] + ['jbipcspine', 'jspine', 'vrmspine'], - 'chest': bone_names['chest'] + ['jbipcchest', 'jchest', 'vrmchest'], - 'upper_chest': bone_names['upper_chest'] + ['jbipcupperchest', 'jupperchest', 'vrmupperchest'], + 'chest': bone_names['chest'] + ['jbipcchest', 'jchest', 'vrmchest', 'upperchest'], + 'upper_chest': bone_names['upper_chest'] + ['jbipcupperchest', 'jupperchest', 'vrmupperchest', 'upperchest'], 'neck': bone_names['neck'] + ['jbipcneck', 'jneck', 'vrmneck'], - 'head': bone_names['head'] + ['jbipchead', 'jhead', 'vrmhead'], + 'head': bone_names['head'] + ['jbipchead', 'jhead', 'vrmhead', 'lefteye', 'righteye'], - # VRM specific finger naming - 'thumb_0_l': bone_names['thumb_0_l'] + ['thumbmetacarpall', 'jthumb1l'], - 'index_0_l': bone_names['index_0_l'] + ['indexmetacarpall', 'jindex1l'], - 'middle_0_l': bone_names['middle_0_l'] + ['middlemetacarpall', 'jmiddle1l'], - 'ring_0_l': bone_names['ring_0_l'] + ['ringmetacarpall', 'jring1l'], - 'pinkie_0_l': bone_names['pinkie_0_l'] + ['littlemetacarpall', 'jlittle1l'], + # VRM arms - both simplified patterns + 'left_shoulder': bone_names['left_shoulder'] + ['jbipllshoulder', 'jlshoulder', 'jbiplshoulder', 'leftshoulder'], + 'left_arm': bone_names['left_arm'] + ['jbiplupperarm', 'jlupperarm', 'leftupperarm'], + 'left_elbow': bone_names['left_elbow'] + ['jbipllforearm', 'jlforearm', 'jbipllowerarm', 'leftlowerarm'], + 'left_wrist': bone_names['left_wrist'] + ['jbipllhand', 'jlhand', 'jbiplhand', 'lefthand'], - # Mirror for right side - 'thumb_0_r': bone_names['thumb_0_r'] + ['thumbmetacarpalr', 'jthumb1r'], - 'index_0_r': bone_names['index_0_r'] + ['indexmetacarpalr', 'jindex1r'], - 'middle_0_r': bone_names['middle_0_r'] + ['middlemetacarpalr', 'jmiddle1r'], - 'ring_0_r': bone_names['ring_0_r'] + ['ringmetacarpalr', 'jring1r'], - 'pinkie_0_r': bone_names['pinkie_0_r'] + ['littlemetacarpalr', 'jlittle1r'] + 'right_shoulder': bone_names['right_shoulder'] + ['jbiprlshoulder', 'jrshoulder', 'jbiprshoulder', 'rightshoulder'], + 'right_arm': bone_names['right_arm'] + ['jbiprrupperarm', 'jrupperarm', 'jbiprupperarm', 'rightupperarm'], + 'right_elbow': bone_names['right_elbow'] + ['jbiprrforearm', 'jrforearm', 'jbiprforearm', 'jbiprlowerarm', 'rightlowerarm'], + 'right_wrist': bone_names['right_wrist'] + ['jbiprrhand', 'jrhand', 'jbiprhand', 'righthand'], + + # VRM legs - both simplified patterns + 'left_leg': bone_names['left_leg'] + ['jbiplupperleg', 'jlupperleg', 'leftupperleg'], + 'left_knee': bone_names['left_knee'] + ['jbipllowerleg', 'jllowerleg', 'leftlowerleg'], + 'left_ankle': bone_names['left_ankle'] + ['jbipllfoot', 'jlfoot', 'jbiplfoot', 'leftfoot'], + 'left_toe': bone_names['left_toe'] + ['jbiplltoe', 'jltoe', 'jbipltoebase', 'lefttoes'], + + 'right_leg': bone_names['right_leg'] + ['jbiprrupperleg', 'jrupperleg', 'jbiprupperleg', 'rightupperleg'], + 'right_knee': bone_names['right_knee'] + ['jbiprrlowerleg', 'jrlowerleg', 'jbiprlowerleg', 'rightlowerleg'], + 'right_ankle': bone_names['right_ankle'] + ['jbiprrfoot', 'jrfoot', 'jbiprfoot', 'rightfoot'], + 'right_toe': bone_names['right_toe'] + ['jbiprrtoe', 'jrtoe', 'jbiprtoebase', 'righttoes'], + + # VRM eyes + 'left_eye': bone_names['left_eye'] + ['jbipcleye', 'jleye', 'jadjlfaceeye'], + 'right_eye': bone_names['right_eye'] + ['jbipcreye', 'jreye', 'jadjrfaceeye'], + + # VRM jaw + 'jaw': ['jaw', 'mandible', 'lowerjaw', 'chin', 'あご', 'ik_あご'], + + # Breast bones + 'breast_1_l': bone_names['breast_1_l'] + ['jbipcbreast1l', 'jlbreast1'], + 'breast_2_l': bone_names['breast_2_l'] + ['jbipcbreast2l', 'jlbreast2'], + 'breast_3_l': bone_names['breast_3_l'] + ['jbipcbreast3l', 'jlbreast3'], + 'breast_1_r': bone_names['breast_1_r'] + ['jbipcbreast1r', 'jrbreast1'], + 'breast_2_r': bone_names['breast_2_r'] + ['jbipcbreast2r', 'jrbreast2'], + 'breast_3_r': bone_names['breast_3_r'] + ['jbipcbreast3r', 'jrbreast3'], + + # VRM fingers - Left (including Little finger variations) + 'thumb_0_l': bone_names['thumb_0_l'] + ['jbipllthumb0', 'jlthumb0', 'jbipllthumbmetacarpal', 'jlthumbmetacarpal', 'leftthumbmetacarpal'], + 'thumb_1_l': bone_names['thumb_1_l'] + ['jbipllthumb1', 'jlthumb1', 'jbiplthumb1', 'leftthumbproximal'], + 'thumb_2_l': bone_names['thumb_2_l'] + ['jbipllthumb2', 'jlthumb2', 'jbiplthumb2', 'leftthumbintermediate'], + 'thumb_3_l': bone_names['thumb_3_l'] + ['jbipllthumb3', 'jlthumb3', 'jbiplthumb3', 'leftthumbdistal'], + + 'index_1_l': bone_names['index_1_l'] + ['jbipllindex1', 'jlindex1', 'jbiplindex1', 'leftindexproximal'], + 'index_2_l': bone_names['index_2_l'] + ['jbipllindex2', 'jlindex2', 'jbiplindex2', 'leftindexintermediate'], + 'index_3_l': bone_names['index_3_l'] + ['jbipllindex3', 'jlindex3', 'jbiplindex3', 'leftindexdistal'], + + 'middle_1_l': bone_names['middle_1_l'] + ['jbipllmiddle1', 'jlmiddle1', 'jbiplmiddle1', 'leftmiddleproximal'], + 'middle_2_l': bone_names['middle_2_l'] + ['jbipllmiddle2', 'jlmiddle2', 'jbiplmiddle2', 'leftmiddleintermediate'], + 'middle_3_l': bone_names['middle_3_l'] + ['jbipllmiddle3', 'jlmiddle3', 'jbiplmiddle3', 'leftmiddledistal'], + + 'ring_1_l': bone_names['ring_1_l'] + ['jbipllring1', 'jlring1', 'jbiplring1', 'leftringproximal'], + 'ring_2_l': bone_names['ring_2_l'] + ['jbipllring2', 'jlring2', 'jbiplring2', 'leftringintermediate'], + 'ring_3_l': bone_names['ring_3_l'] + ['jbipllring3', 'jlring3', 'jbiplring3', 'leftringdistal'], + + 'pinkie_1_l': bone_names['pinkie_1_l'] + ['jbipllpinky1', 'jlpinky1', 'jbipllittle1', 'jbipllpinkie1', 'leftlittleproximal'], + 'pinkie_2_l': bone_names['pinkie_2_l'] + ['jbipllpinky2', 'jlpinky2', 'jbipllittle2', 'jbipllpinkie2', 'leftlittleintermediate'], + 'pinkie_3_l': bone_names['pinkie_3_l'] + ['jbipllpinky3', 'jlpinky3', 'jbipllittle3', 'jbipllpinkie3', 'leftlittledistal'], + + # VRM fingers - Right (including Little finger variations) + 'thumb_0_r': bone_names['thumb_0_r'] + ['jbiprthumb0', 'jrthumb0', 'jbiprthumbmetacarpal', 'jrthumbmetacarpal', 'rightthumbmetacarpal'], + 'thumb_1_r': bone_names['thumb_1_r'] + ['jbiprthumb1', 'jrthumb1', 'jbiprrrthumb1', 'rightthumbproximal'], + 'thumb_2_r': bone_names['thumb_2_r'] + ['jbiprthumb2', 'jrthumb2', 'jbiprrrthumb2', 'rightthumbintermediate'], + 'thumb_3_r': bone_names['thumb_3_r'] + ['jbiprthumb3', 'jrthumb3', 'jbiprrrthumb3', 'rightthumbdistal'], + + 'index_1_r': bone_names['index_1_r'] + ['jbiprindex1', 'jrindex1', 'jbiprrrindex1', 'rightindexproximal'], + 'index_2_r': bone_names['index_2_r'] + ['jbiprindex2', 'jrindex2', 'jbiprrrindex2', 'rightindexintermediate'], + 'index_3_r': bone_names['index_3_r'] + ['jbiprindex3', 'jrindex3', 'jbiprrrindex3', 'rightindexdistal'], + + 'middle_1_r': bone_names['middle_1_r'] + ['jbiprmiddle1', 'jrmiddle1', 'jbiprrmiddle1', 'rightmiddleproximal'], + 'middle_2_r': bone_names['middle_2_r'] + ['jbiprmiddle2', 'jrmiddle2', 'jbiprrmiddle2', 'rightmiddleintermediate'], + 'middle_3_r': bone_names['middle_3_r'] + ['jbiprmiddle3', 'jrmiddle3', 'jbiprrmiddle3', 'rightmiddledistal'], + + 'ring_1_r': bone_names['ring_1_r'] + ['jbiprring1', 'jrring1', 'jbiprrrring1', 'rightringproximal'], + 'ring_2_r': bone_names['ring_2_r'] + ['jbiprring2', 'jrring2', 'jbiprrrring2', 'rightringintermediate'], + 'ring_3_r': bone_names['ring_3_r'] + ['jbiprring3', 'jrring3', 'jbiprrrring3', 'rightringdistal'], + + 'pinkie_1_r': bone_names['pinkie_1_r'] + ['jbiprpinky1', 'jrpinky1', 'jbiprlittle1', 'jbiprrrpinky1', 'rightlittleproximal'], + 'pinkie_2_r': bone_names['pinkie_2_r'] + ['jbiprpinky2', 'jrpinky2', 'jbiprlittle2', 'jbiprrrpinky2', 'rightlittleintermediate'], + 'pinkie_3_r': bone_names['pinkie_3_r'] + ['jbiprpinky3', 'jrpinky3', 'jbiprlittle3', 'jbiprrrpinky3', 'rightlittledistal'] }) # array taken from cats @@ -428,6 +513,14 @@ standard_bones = { # Eyes 'left_eye': 'Eye_L', 'right_eye': 'Eye_R' + + # Breast bones + 'breast_1_l': 'Breast1_L', + 'breast_2_l': 'Breast2_L', + 'breast_3_l': 'Breast3_L', + 'breast_1_r': 'Breast1_R', + 'breast_2_r': 'Breast2_R', + 'breast_3_r': 'Breast3_R' } bone_hierarchy = [ diff --git a/core/properties.py b/core/properties.py index ef9243d..79eed6e 100644 --- a/core/properties.py +++ b/core/properties.py @@ -608,6 +608,19 @@ class AvatarToolkitSceneProperties(PropertyGroup): update=update_log_level ) + # VRM Conversion Properties + vrm_remove_colliders: BoolProperty( + name="Remove Colliders", + description="Remove VRM collider bones during conversion", + default=True + ) + + vrm_remove_root: BoolProperty( + name="Remove Root Bone", + description="Remove unnecessary VRM root bone and make Hips the root bone", + default=True + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/core/vrm_unity_converter.py b/core/vrm_unity_converter.py new file mode 100644 index 0000000..73e6458 --- /dev/null +++ b/core/vrm_unity_converter.py @@ -0,0 +1,564 @@ +import bpy +from typing import Dict, List, Optional, Tuple, Set +from bpy.types import Object, Bone +from .common import get_active_armature +from .dictionaries import simplify_bonename, standard_bones, bone_hierarchy, reverse_bone_lookup +from .logging_setup import logger + + +def detect_vrm_armature(armature: Object) -> bool: + """ + Detect if armature uses VRM bone naming conventions + """ + if not armature or armature.type != 'ARMATURE': + return False + + vrm_patterns = [ + 'jbipchips', 'jbipcspine', 'jbipcchest', 'jbipcneck', 'jbipchead', + # Right arm patterns (both single and double R) + 'jbiprlshoulder', 'jbiprshoulder', 'jbiprupperarm', 'jbiprforearm', 'jbiprhand', 'jbiprlowerarm', + 'jbiprrupperarm', 'jbiprrforearm', 'jbiprrhand', + # Left arm patterns + 'jbipllshoulder', 'jbiplshoulder', 'jbiplupperarm', 'jbipllforearm', 'jbipllhand', 'jbipllowerarm', 'jbiplhand', + # Right leg patterns (both single and double R) + 'jbiprupperleg', 'jbiprlowerleg', 'jbiprfoot', 'jbiprtoe', 'jbiprtoebase', + 'jbiprrupperleg', 'jbiprrlowerleg', 'jbiprrfoot', 'jbiprrtoe', + # Left leg patterns + 'jbiplupperleg', 'jbipllowerleg', 'jbipllfoot', 'jbiplfoot', 'jbiplltoe', 'jbipltoebase', + # Finger patterns + 'jbipllittle1', 'jbiprlittle1', + 'jbiplthumb1', 'jbiplthumb2', 'jbiplthumb3', + 'jbiplindex1', 'jbiplindex2', 'jbiplindex3', + 'jbiplmiddle1', 'jbiplmiddle2', 'jbiplmiddle3', + 'jbiplring1', 'jbiplring2', 'jbiplring3', + # Face eye patterns + 'jadjlfaceeye', 'jadjrfaceeye', + # Breast patterns + 'jseclbust1', 'jseclbust2', 'jseclbust3', + 'jsecrbust1', 'jsecrbust2', 'jsecrbust3', + 'jbipc', 'jbipr', 'jbipl' + ] + + found_vrm_bones = 0 + for bone_name in armature.data.bones.keys(): + simplified_name = simplify_bonename(bone_name) + if simplified_name.startswith('jbip') or any(pattern in simplified_name for pattern in vrm_patterns): + found_vrm_bones += 1 + + # Consider it VRM if we find at least 5 VRM bones + logger.debug(f"Found {found_vrm_bones} VRM bones in armature {armature.name}") + return found_vrm_bones >= 5 + + + + +def find_vrm_bones_in_armature(armature: Object) -> Dict[str, str]: + """ + Find VRM bones in armature and return mapping to their actual names using dictionary lookup + """ + found_bones = {} + + for bone_name in armature.data.bones.keys(): + simplified_name = simplify_bonename(bone_name) + + # Check if this bone exists in our reverse lookup dictionary + if simplified_name in reverse_bone_lookup: + standard_bone_key = reverse_bone_lookup[simplified_name] + + # Get the Unity name from standard_bones + if standard_bone_key in standard_bones: + unity_name = standard_bones[standard_bone_key] + found_bones[bone_name] = unity_name + logger.debug(f"Found VRM bone via dictionary: {bone_name} -> {unity_name}") + else: + logger.debug(f"Standard bone key '{standard_bone_key}' not found in standard_bones for bone '{bone_name}'") + + # Fallback for unrecognized VRM bones that start with 'jbip' + elif simplified_name.startswith('jbip') and bone_name not in found_bones: + unity_equivalent = guess_unity_name_from_vrm(simplified_name) + if unity_equivalent: + found_bones[bone_name] = unity_equivalent + logger.debug(f"Guessed VRM bone mapping: {bone_name} -> {unity_equivalent}") + + return found_bones + + +def guess_unity_name_from_vrm(vrm_simplified: str) -> Optional[str]: + """ + Attempt to guess Unity bone name from VRM simplified name + """ + # Map common VRM patterns to Unity equivalents + pattern_mappings = { + 'jbipcupperchest': 'UpperChest', + 'jbipcchest': 'Chest', + 'jbipcspine': 'Spine', + 'jbipchips': 'Hips', + 'jbipcneck': 'Neck', + 'jbipchead': 'Head', + + # Left arm + 'jbipllclavicle': 'LeftShoulder', + 'jbipllshoulder': 'LeftShoulder', + 'jbiplshoulder': 'LeftShoulder', + 'jbiplupperarm': 'LeftUpperArm', + 'jbipllforearm': 'LeftLowerArm', + 'jbipllowerarm': 'LeftLowerArm', + 'jbipllhand': 'LeftHand', + 'jbiplhand': 'LeftHand', + + # Right arm (both single and double R patterns) + 'jbiprrclavicle': 'RightShoulder', + 'jbiprlshoulder': 'RightShoulder', + 'jbiprshoulder': 'RightShoulder', + 'jbiprupperarm': 'RightUpperArm', + 'jbiprrupperarm': 'RightUpperArm', + 'jbiprforearm': 'RightLowerArm', + 'jbiprrforearm': 'RightLowerArm', + 'jbiprlowerarm': 'RightLowerArm', + 'jbiprhand': 'RightHand', + 'jbiprrhand': 'RightHand', + + # Left leg + 'jbiplupperleg': 'LeftUpperLeg', + 'jbipllowerleg': 'LeftLowerLeg', + 'jbipllfoot': 'LeftFoot', + 'jbiplfoot': 'LeftFoot', + 'jbiplltoe': 'LeftToes', + 'jbipltoebase': 'LeftToes', + + # Right leg (both single and double R patterns) + 'jbiprupperleg': 'RightUpperLeg', + 'jbiprrupperleg': 'RightUpperLeg', + 'jbiprlowerleg': 'RightLowerLeg', + 'jbiprrlowerleg': 'RightLowerLeg', + 'jbiprfoot': 'RightFoot', + 'jbiprrfoot': 'RightFoot', + 'jbiprtoe': 'RightToes', + 'jbiprrtoe': 'RightToes', + 'jbiprtoebase': 'RightToes', + + # Eyes + 'jbipcleye': 'LeftEye', + 'jbipcreye': 'RightEye', + 'jadjlfaceeye': 'LeftEye', + 'jadjrfaceeye': 'RightEye', + + # Fingers - Left + 'jbiplthumb1': 'LeftThumb1', + 'jbiplthumb2': 'LeftThumb2', + 'jbiplthumb3': 'LeftThumb3', + 'jbiplindex1': 'LeftIndex1', + 'jbiplindex2': 'LeftIndex2', + 'jbiplindex3': 'LeftIndex3', + 'jbiplmiddle1': 'LeftMiddle1', + 'jbiplmiddle2': 'LeftMiddle2', + 'jbiplmiddle3': 'LeftMiddle3', + 'jbiplring1': 'LeftRing1', + 'jbiplring2': 'LeftRing2', + 'jbiplring3': 'LeftRing3', + 'jbipllittle1': 'LeftPinky1', + 'jbipllittle2': 'LeftPinky2', + 'jbipllittle3': 'LeftPinky3', + 'jbiprlittle1': 'RightPinky1', + 'jbiprlittle2': 'RightPinky2', + 'jbiprlittle3': 'RightPinky3', + + # Breast bones + 'jseclbust1': 'Breast1_L', + 'jseclbust2': 'Breast2_L', + 'jseclbust3': 'Breast3_L', + 'jsecrbust1': 'Breast1_R', + 'jsecrbust2': 'Breast2_R', + 'jsecrbust3': 'Breast3_R' + } + + return pattern_mappings.get(vrm_simplified) + + +def is_vrm_collider_object(obj_name: str) -> bool: + """ + Test if an object name represents a VRM collider + """ + obj_name_lower = obj_name.lower() + collider_patterns = ['collider', 'collision', 'dynamic', 'spring', 'physics', 'secondary'] + + # Must contain a collider pattern + contains_collider = any(pattern in obj_name_lower for pattern in collider_patterns) + if not contains_collider: + return False + + # Must be VRM-related (multiple detection methods) + is_vrm = ( + 'j_bip' in obj_name_lower or + 'jbip' in simplify_bonename(obj_name) or + any(vrm_part in obj_name_lower for vrm_part in ['j_bip_c_', 'j_bip_l_', 'j_bip_r_']) + ) + + return is_vrm + + +def remove_collection_from_hierarchy(collection_to_remove) -> bool: + """ + Recursively remove a collection from all parent collections in the hierarchy + """ + removed_from_any_parent = False + + try: + # Check scene collection + scene_collection = bpy.context.scene.collection + if collection_to_remove in scene_collection.children: + scene_collection.children.unlink(collection_to_remove) + logger.debug(f" Unlinked '{collection_to_remove.name}' from scene collection") + removed_from_any_parent = True + + # Check all other collections recursively + for parent_collection in list(bpy.data.collections): + if parent_collection != collection_to_remove and collection_to_remove in parent_collection.children: + try: + parent_collection.children.unlink(collection_to_remove) + logger.debug(f" Unlinked '{collection_to_remove.name}' from parent '{parent_collection.name}'") + removed_from_any_parent = True + except Exception as unlink_error: + logger.warning(f" Failed to unlink '{collection_to_remove.name}' from '{parent_collection.name}': {str(unlink_error)}") + + return removed_from_any_parent + + except Exception as e: + logger.error(f"Error removing collection '{collection_to_remove.name}' from hierarchy: {str(e)}") + return False + + +def remove_vrm_colliders(armature: Object = None) -> Tuple[int, List[str], int]: + """ + Simple approach: Remove ALL objects with 'collider' in their name and clean up empty collections + """ + objects_to_remove = [] + removed_names = [] + collections_to_check = set() + + # Store the current mode and active object + current_mode = bpy.context.mode + original_active = bpy.context.view_layer.objects.active + + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + try: + logger.info("Starting simple collider removal - removing ALL objects with 'collider' in name") + + collider_object_names = [] + for obj in bpy.data.objects: + if 'collider' in obj.name.lower(): + collider_object_names.append(obj.name) + # Track collections this object is in + for collection in obj.users_collection: + collections_to_check.add(collection) + logger.info(f"Found collider object: {obj.name}") + + logger.info(f"Found {len(collider_object_names)} collider objects to remove") + + # Remove collider objects by name + removed_count = 0 + for obj_name in collider_object_names: + try: + # Check if object still exists + if obj_name in bpy.data.objects: + obj = bpy.data.objects[obj_name] + logger.info(f"Removing collider object: {obj_name}") + + # Remove from all collections first + for collection in list(obj.users_collection): + collection.objects.unlink(obj) + logger.debug(f" Unlinked from collection: {collection.name}") + + bpy.data.objects.remove(obj, do_unlink=True) + removed_count += 1 + removed_names.append(obj_name) + logger.info(f" Successfully removed: {obj_name}") + else: + logger.debug(f"Object {obj_name} already removed") + + except Exception as e: + logger.error(f"Failed to remove collider object {obj_name}: {str(e)}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + + logger.info(f"Successfully removed {removed_count} collider objects") + + # Clean up empty collections (prioritize collider-related collections) + empty_collections_removed = 0 + + # Also check all collections in the scene for collider-related names + all_collections_to_check = set(collections_to_check) + for collection in bpy.data.collections: + collection_name_lower = collection.name.lower() + if any(pattern in collection_name_lower for pattern in ['collider', 'collision', 'physics', 'dynamic']): + all_collections_to_check.add(collection) + logger.debug(f"Found collider-related collection to check: {collection.name}") + + for collection in list(all_collections_to_check): + try: + # Check if collection exists and is empty + if collection.name not in bpy.data.collections: + logger.debug(f"Collection {collection.name} already removed") + continue + + collection_name_lower = collection.name.lower() + is_collider_collection = any(pattern in collection_name_lower for pattern in ['collider', 'collision', 'physics', 'dynamic']) + is_empty = len(collection.objects) == 0 and len(collection.children) == 0 + is_protected = collection.name in ["Collection", "Master Collection"] + + # Remove if empty and (was used by colliders OR has collider-related name) + if is_empty and not is_protected and (collection in collections_to_check or is_collider_collection): + logger.info(f"Removing empty {'collider-related ' if is_collider_collection else ''}collection: {collection.name}") + + # Use helper function to remove from all parent collections + removed_from_parents = remove_collection_from_hierarchy(collection) + + if not removed_from_parents: + logger.debug(f" Collection {collection.name} was not found in any parent collections") + + # Remove the collection data + try: + bpy.data.collections.remove(collection) + empty_collections_removed += 1 + logger.info(f" Successfully removed collection: {collection.name}") + except Exception as remove_error: + logger.warning(f" Failed to remove collection {collection.name}: {str(remove_error)}") + # Continue with other collections even if this one fails + + except Exception as e: + logger.warning(f"Failed to remove empty collection {collection.name}: {str(e)}") + import traceback + logger.debug(f"Collection removal traceback: {traceback.format_exc()}") + + if empty_collections_removed > 0: + logger.info(f"Cleaned up {empty_collections_removed} empty collections") + + except Exception as e: + logger.error(f"Error during collider removal: {str(e)}") + return 0, [], 0 + + finally: + if original_active and original_active.name in bpy.data.objects: + bpy.context.view_layer.objects.active = original_active + + if current_mode != 'OBJECT': + try: + bpy.ops.object.mode_set(mode=current_mode) + except: + pass + + logger.info(f"Collider removal complete. Removed {len(removed_names)} objects and {empty_collections_removed} collections") + return len(removed_names), removed_names, empty_collections_removed + + +def remove_vrm_root_bone(armature: Object) -> Tuple[bool, str]: + """ + Remove unnecessary VRM root bone and make Hips the root bone + + """ + if not armature or armature.type != 'ARMATURE': + return False, "No valid armature provided" + + # Look for potential root bones and Hips bone + potential_roots = [] + hips_bone = None + + for bone in armature.data.edit_bones: + bone_name_lower = bone.name.lower() + + # Check if this could be Hips (various naming conventions) + if any(hips_name in bone_name_lower for hips_name in ['hips', 'hip', 'pelvis', 'jbipchips']): + hips_bone = bone + logger.debug(f"Found Hips bone: {bone.name}") + + # Check if this could be a root bone + if bone.parent is None and len(bone.children) > 0: + # Common VRM root bone names + if any(root_name in bone_name_lower for root_name in ['root', 'vrm', 'armature', 'rig']): + potential_roots.append(bone) + logger.debug(f"Found potential root bone: {bone.name}") + + if not hips_bone: + return False, "Could not find Hips bone to promote as root" + + if not potential_roots: + logger.info("No unnecessary root bone found - Hips may already be root") + return True, "No root bone removal needed" + + # Find the root bone that is the parent of Hips + root_to_remove = None + for root_bone in potential_roots: + if hips_bone.parent == root_bone: + root_to_remove = root_bone + break + + if not root_to_remove: + # Check if Hips is already parentless (already root) + if hips_bone.parent is None: + logger.info("Hips bone is already the root bone") + return True, "Hips is already root - no changes needed" + else: + logger.warning(f"Hips bone has parent '{hips_bone.parent.name}' but no matching root found") + return False, "Could not identify safe root bone to remove" + + root_name = root_to_remove.name + logger.info(f"Removing root bone '{root_name}' and promoting Hips to root") + + # Reparent all children of the root bone (except Hips) to Hips + children_to_reparent = [] + for child in root_to_remove.children: + if child != hips_bone: + children_to_reparent.append(child) + + hips_bone.parent = None + + for child in children_to_reparent: + child.parent = hips_bone + logger.debug(f"Reparented {child.name} from {root_name} to {hips_bone.name}") + + armature.data.edit_bones.remove(root_to_remove) + + message = f"Removed root bone '{root_name}' - Hips is now the root bone" + logger.info(message) + return True, message + + +def convert_vrm_to_unity(armature: Object, remove_colliders: bool = True, remove_root: bool = True) -> Tuple[bool, List[str], int]: + """ + Convert VRM armature bone names to Unity humanoid format + """ + if not armature or armature.type != 'ARMATURE': + return False, ["No valid armature selected"], 0 + + logger.info(f"Starting VRM to Unity conversion for armature: {armature.name}") + + # Check if this is a VRM armature + if not detect_vrm_armature(armature): + return False, ["Selected armature does not appear to be a VRM armature"], 0 + + messages = [] + converted_count = 0 + failed_conversions = [] + collider_count = 0 + + current_mode = bpy.context.mode + if current_mode != 'EDIT': + bpy.context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + + try: + # First, remove collider objects and bones if requested + if remove_colliders: + collider_count, removed_colliders, collections_removed = remove_vrm_colliders(armature) + if collider_count > 0 or collections_removed > 0: + if collections_removed > 0: + messages.append(f"Removed {collider_count} VRM collider objects and {collections_removed} empty collections") + else: + messages.append(f"Removed {collider_count} VRM collider objects") + logger.info(f"Removed {collider_count} VRM colliders: {removed_colliders}") + + vrm_bones = find_vrm_bones_in_armature(armature) + + if not vrm_bones: + if remove_colliders and (collider_count > 0 or collections_removed > 0): + messages.append("No VRM bones found to convert (colliders were removed)") + return True, messages, 0 + else: + return False, ["No VRM bones found in armature"], 0 + + if bpy.context.mode != 'EDIT': + bpy.ops.object.mode_set(mode='EDIT') + + # Remove unnecessary root bone if requested + if remove_root: + root_success, root_message = remove_vrm_root_bone(armature) + messages.append(root_message) + if not root_success: + logger.warning(f"Root bone removal failed: {root_message}") + + # Rename bones + for vrm_bone_name, unity_name in vrm_bones.items(): + if vrm_bone_name in armature.data.edit_bones: + bone = armature.data.edit_bones[vrm_bone_name] + + # Check if target name already exists + if unity_name in armature.data.edit_bones and unity_name != vrm_bone_name: + failed_conversions.append(f"{vrm_bone_name} -> {unity_name} (name conflict)") + continue + + # Rename the bone + bone.name = unity_name + converted_count += 1 + logger.debug(f"Renamed bone: {vrm_bone_name} -> {unity_name}") + + messages.append(f"Successfully converted {converted_count} VRM bones to Unity format") + + if failed_conversions: + messages.append("Failed conversions due to name conflicts:") + messages.extend(failed_conversions) + + logger.info(f"VRM to Unity conversion completed. Converted {converted_count} bones") + + except Exception as e: + logger.error(f"Error during VRM conversion: {str(e)}") + messages.append(f"Error during conversion: {str(e)}") + return False, messages, converted_count + + finally: + # Restore original mode + if current_mode != 'EDIT': + bpy.ops.object.mode_set(mode='OBJECT') + + return converted_count > 0 or (remove_colliders and collider_count > 0), messages, converted_count + + +def validate_unity_hierarchy(armature: Object) -> Tuple[bool, List[str]]: + """ + Validate that the converted armature has proper Unity humanoid hierarchy + """ + if not armature or armature.type != 'ARMATURE': + return False, ["No valid armature to validate"] + + messages = [] + is_valid = True + + # Check for essential Unity bones + essential_unity_bones = [ + standard_bones['hips'], + standard_bones['spine'], + standard_bones['chest'], + standard_bones['neck'], + standard_bones['head'] + ] + + missing_bones = [] + for bone_name in essential_unity_bones: + if bone_name not in armature.data.bones: + missing_bones.append(bone_name) + + if missing_bones: + is_valid = False + messages.append("Missing essential Unity bones:") + messages.extend([f"- {bone}" for bone in missing_bones]) + + # Validate basic hierarchy + hierarchy_issues = [] + for parent_name, child_name in bone_hierarchy: + if parent_name in armature.data.bones and child_name in armature.data.bones: + parent_bone = armature.data.bones[parent_name] + child_bone = armature.data.bones[child_name] + + if child_bone.parent != parent_bone: + hierarchy_issues.append(f"{parent_name} -> {child_name}") + + if hierarchy_issues: + is_valid = False + messages.append("Hierarchy issues found:") + messages.extend([f"- {issue}" for issue in hierarchy_issues]) + + if is_valid: + messages.append("Unity hierarchy validation passed") + + return is_valid, messages \ No newline at end of file diff --git a/functions/tools/collider_removal.py b/functions/tools/collider_removal.py new file mode 100644 index 0000000..61b63bd --- /dev/null +++ b/functions/tools/collider_removal.py @@ -0,0 +1,102 @@ +import bpy +from bpy.types import Operator +from ...core.logging_setup import logger + + +class AvatarToolkit_OT_RemoveAllColliders(Operator): + """Remove all objects with 'collider' in their name""" + bl_idname = "avatar_toolkit.remove_all_colliders" + bl_label = "Remove All Colliders" + bl_description = "Remove all objects that have 'collider' in their name" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + logger.info("Starting standalone collider removal") + + # Store current mode and active object + current_mode = bpy.context.mode + original_active = bpy.context.view_layer.objects.active + + # Switch to object mode + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + try: + # Find all collider objects + collider_names = [] + all_objects = list(bpy.data.objects) + + logger.info(f"Scanning {len(all_objects)} objects for colliders") + + for obj in all_objects: + if 'collider' in obj.name.lower(): + collider_names.append(obj.name) + logger.info(f"Found collider: {obj.name}") + + if not collider_names: + self.report({'INFO'}, "No collider objects found") + logger.info("No collider objects found") + return {'FINISHED'} + + logger.info(f"Found {len(collider_names)} collider objects to remove") + self.report({'INFO'}, f"Found {len(collider_names)} collider objects") + + # Remove each collider + removed_count = 0 + failed_count = 0 + + for obj_name in collider_names: + try: + if obj_name in bpy.data.objects: + obj = bpy.data.objects[obj_name] + + # Deselect all objects first + bpy.ops.object.select_all(action='DESELECT') + + # Select and make active + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + # Delete the object + bpy.ops.object.delete(use_global=False) + + removed_count += 1 + logger.info(f"Removed collider: {obj_name}") + + else: + logger.debug(f"Object {obj_name} no longer exists") + + except Exception as e: + failed_count += 1 + logger.error(f"Failed to remove {obj_name}: {str(e)}") + self.report({'WARNING'}, f"Failed to remove {obj_name}: {str(e)}") + + # Report results + if removed_count > 0: + success_msg = f"Successfully removed {removed_count} collider objects" + logger.info(success_msg) + self.report({'INFO'}, success_msg) + + if failed_count > 0: + failure_msg = f"Failed to remove {failed_count} collider objects" + logger.warning(failure_msg) + self.report({'WARNING'}, failure_msg) + + except Exception as e: + error_msg = f"Error during collider removal: {str(e)}" + logger.error(error_msg) + self.report({'ERROR'}, error_msg) + return {'CANCELLED'} + + finally: + # Restore original state + try: + if original_active and original_active.name in bpy.data.objects: + bpy.context.view_layer.objects.active = original_active + + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode=current_mode) + except: + pass + + return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/vrm_unity_conversion.py b/functions/tools/vrm_unity_conversion.py new file mode 100644 index 0000000..48f0738 --- /dev/null +++ b/functions/tools/vrm_unity_conversion.py @@ -0,0 +1,88 @@ +import bpy +from bpy.types import Operator +from ...core.common import get_active_armature +from ...core.translations import t +from ...core.vrm_unity_converter import convert_vrm_to_unity, validate_unity_hierarchy +from ...core.logging_setup import logger +from ...core.armature_validation import validate_armature + + +class AvatarToolkit_OT_ConvertVRMToUnity(Operator): + """Convert VRM armature bone names to Unity humanoid format""" + bl_idname = "avatar_toolkit.convert_vrm_to_unity" + bl_label = "Convert VRM to Unity" + bl_description = "Convert VRM armature bone names to Unity humanoid naming convention" + 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 VRM conversion") + self.report({'ERROR'}, "No active armature selected") + return {'CANCELLED'} + + logger.info(f"Starting VRM to Unity conversion for armature: {armature.name}") + + # Get conversion settings + remove_colliders = context.scene.avatar_toolkit.vrm_remove_colliders + remove_root = context.scene.avatar_toolkit.vrm_remove_root + logger.info(f"Collider removal setting: {remove_colliders}") + logger.info(f"Root bone removal setting: {remove_root}") + + # Log all objects with 'collider' in name for debugging + collider_objects = [obj.name for obj in bpy.data.objects if 'collider' in obj.name.lower()] + if collider_objects: + logger.info(f"Found {len(collider_objects)} objects with 'collider' in name:") + for obj_name in collider_objects: + logger.info(f" - {obj_name}") + + success, messages, converted_count = convert_vrm_to_unity(armature, remove_colliders, remove_root) + + if not success: + logger.warning(f"VRM conversion failed: {messages}") + for msg in messages: + self.report({'WARNING'}, msg) + return {'CANCELLED'} + + logger.info(f"VRM conversion completed successfully. Converted {converted_count} bones") + for msg in messages: + self.report({'INFO'}, msg) + + # Validate the converted armature + try: + is_valid, validation_messages = validate_unity_hierarchy(armature) + + if is_valid: + logger.info("Unity hierarchy validation passed") + self.report({'INFO'}, "Unity hierarchy validation passed") + else: + logger.warning("Unity hierarchy validation found issues") + self.report({'WARNING'}, "Conversion completed but hierarchy validation found issues:") + for msg in validation_messages: + self.report({'WARNING'}, msg) + + try: + armature_valid, armature_messages, _ = validate_armature(armature) + if armature_valid: + logger.info("Full armature validation passed") + self.report({'INFO'}, "Armature passes standard validation") + else: + logger.info("Full armature validation found minor issues") + # Don't report these as errors since the conversion was successful + # Just log them for debugging + for msg in armature_messages[:3]: + logger.debug(f"Armature validation: {msg}") + except Exception as e: + logger.warning(f"Error during full armature validation: {str(e)}") + # Don't fail the operation for validation errors + + except Exception as e: + logger.error(f"Error during hierarchy validation: {str(e)}") + self.report({'WARNING'}, f"Conversion completed but validation failed: {str(e)}") + + return {'FINISHED'} \ No newline at end of file diff --git a/ui/vrm_unity_panel.py b/ui/vrm_unity_panel.py new file mode 100644 index 0000000..edaad43 --- /dev/null +++ b/ui/vrm_unity_panel.py @@ -0,0 +1,87 @@ +import bpy +from bpy.types import Panel, Context, UILayout +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.common import get_active_armature +from ..core.vrm_unity_converter import detect_vrm_armature +from ..functions.tools.vrm_unity_conversion import AvatarToolkit_OT_ConvertVRMToUnity + + +class AvatarToolKit_PT_VRMUnityPanel(Panel): + """Panel for VRM to Unity conversion tools""" + bl_label = "VRM to Unity" + bl_idname = "OBJECT_PT_avatar_toolkit_vrm_unity" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the VRM to Unity conversion panel interface""" + layout: UILayout = self.layout + + # VRM Conversion Tools + vrm_box: UILayout = layout.box() + col: UILayout = vrm_box.column(align=True) + col.label(text="VRM Converter", 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="No armature selected", icon='ERROR') + col.label(text="Select an armature to convert") + return + + # Check if the armature appears to be VRM + is_vrm = detect_vrm_armature(armature) + + if is_vrm: + col.label(text=f"Armature: {armature.name}", icon='CHECKMARK') + col.label(text="VRM armature detected", icon='INFO') + col.separator(factor=0.3) + + toolkit = context.scene.avatar_toolkit + col.prop(toolkit, 'vrm_remove_colliders', text="Remove Colliders") + col.prop(toolkit, 'vrm_remove_root', text="Remove Root Bone") + col.separator(factor=0.2) + + col.operator( + AvatarToolkit_OT_ConvertVRMToUnity.bl_idname, + text="Convert to Unity Format", + icon='EXPORT' + ) + + info_box = vrm_box.box() + info_col = info_box.column(align=True) + info_col.label(text="Conversion Info:", icon='INFO') + info_col.label(text="• Renames VRM bones to Unity format") + info_col.label(text="• Removes collider bones (optional)") + info_col.label(text="• Removes root bone, makes Hips root (optional)") + info_col.label(text="• Maintains bone hierarchy") + info_col.label(text="• Validates conversion results") + info_col.label(text="• Preserves all animations") + + else: + col.label(text=f"Armature: {armature.name}", icon='ERROR') + col.label(text="No VRM bones detected", icon='CANCEL') + col.separator(factor=0.3) + + row = col.row() + row.enabled = False + row.operator( + AvatarToolkit_OT_ConvertVRMToUnity.bl_idname, + text="Convert to Unity Format", + icon='CANCEL' + ) + + help_box = vrm_box.box() + help_col = help_box.column(align=True) + help_col.label(text="VRM Detection Failed:", icon='QUESTION') + help_col.label(text="• Selected armature is not VRM format") + help_col.label(text="• VRM bones start with 'J_Bip_C_'") + help_col.label(text="• Need at least 5 VRM bones detected") + help_col.label(text="• Check armature bone names") \ No newline at end of file