diff --git a/README.md b/README.md index b961eb6..74c8a9e 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/lega ## Requirements 1) Blender Version -- Blender 4.4 or newer is required -- Blender 4.4 is the current recommended version +- Blender 4.5 or newer is required +- Blender 4.5 is the current recommended version 2) Python Requirements @@ -42,7 +42,7 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/lega 3) Recommended Setup - Download Blender directly from https://blender.org -- Use Blender 4.4 for the best experience +- Use Blender 4.5 for the best experience #### Additional Plugins Requirements. Currently None. diff --git a/blender_manifest.toml b/blender_manifest.toml index 77dd551..c61abd6 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -3,13 +3,13 @@ schema_version = "1.0.0" id = "avatar_toolkit" -version = "0.3.0" +version = "0.4.0" name = "Avatar Toolkit" tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games." maintainer = "Team NekoNeo" type = "add-on" -blender_version_min = "4.4.0" +blender_version_min = "4.5.0" license = [ "SPDX:GPL-3.0-or-later", diff --git a/core/armature_validation.py b/core/armature_validation.py index 9abf0d7..570c098 100644 --- a/core/armature_validation.py +++ b/core/armature_validation.py @@ -10,16 +10,17 @@ from ..core.dictionaries import ( bone_hierarchy, finger_hierarchy, acceptable_bone_hierarchy, - acceptable_bone_names + acceptable_bone_names, + simplify_bonename ) from ..core.logging_setup import logger -def validate_armature(armature: Object, detailed_messages: bool = False) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]: +def validate_armature(armature: Object, detailed_messages: bool = False, override_mode: Optional[str] = None) -> Union[Tuple[bool, List[str], bool], Tuple[bool, List[str], bool, List[str], List[str], List[str]]]: """ Validates armature and returns validation results """ logger.debug(f"Validating armature: {armature.name if armature else 'None'}") - validation_mode = bpy.context.scene.avatar_toolkit.validation_mode + validation_mode = override_mode if override_mode else bpy.context.scene.avatar_toolkit.validation_mode messages: List[str] = [] hierarchy_messages: List[str] = [] non_standard_messages: List[str] = [] @@ -104,17 +105,41 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio # Non-standard bones check non_standard_bones = [] - required_patterns = [ - 'Hips', 'Spine', 'Chest', 'Neck', 'Head', - 'Upper', 'Lower', 'Hand', 'Foot', 'Toe', - 'Thumb', 'Index', 'Middle', 'Ring', 'Pinky', - 'Eye' + + # Bones to ignore + ignore_patterns = [ + 'tail', 'skirt', 'dress', 'hair', 'ribbon', 'bow', 'hat', 'cap', + 'butt', 'breast', 'boob', 'chest_', 'belly', 'stomach', + 'wing', 'fin', 'horn', 'ear_', 'accessory', 'extra', + 'cloth', 'fabric', 'cape', 'coat', 'jacket', 'shirt', + 'pants', 'shoe', 'boot', 'sock', 'glove', 'mitten', + 'belt', 'strap', 'buckle', 'button', 'zipper', + 'jewel', 'gem', 'ring', 'necklace', 'earring', + 'flower', 'leaf', 'feather', 'fur', 'scale', + 'bangs', 'sideburn', 'bell', 'leash', 'ears', 'chain', + 'headband', 'necklace', 'necktie', 'strapNeck', 'ring', + 'pin', 'hair', + ] + # Create normalized lookup sets for faster comparison + normalized_standard_bones = {simplify_bonename(name) for name in standard_bones.values()} + normalized_acceptable_bones = set() + for names in acceptable_bone_names.values(): + normalized_acceptable_bones.update(simplify_bonename(name) for name in names) + for bone_name in found_bones: - if any(pattern in bone_name for pattern in required_patterns): - is_standard = bone_name in standard_bones.values() - is_acceptable_bone = any(bone_name in names for names in acceptable_bone_names.values()) + # Normalize bone name for comparison + normalized_bone_name = simplify_bonename(bone_name) + + # Check if bone should be ignored (accessory bone) + is_ignored = any(pattern in normalized_bone_name for pattern in ignore_patterns) + + if not is_ignored: + # Check if bone is in standard or acceptable lists + is_standard = normalized_bone_name in normalized_standard_bones + is_acceptable_bone = normalized_bone_name in normalized_acceptable_bones + if not (is_standard or is_acceptable_bone): non_standard_bones.append(bone_name) @@ -186,31 +211,188 @@ def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name return False return bones[child_name].parent == bones[parent_name] +def extract_bone_side_info(bone_name: str) -> Tuple[str, str]: + """ + Extract base bone name and side indicator from a bone name. + Returns (base_name, side) where side is 'L', 'R', or '' + """ + normalized = simplify_bonename(bone_name) + original = bone_name + + # Common left/right patterns to check + left_patterns = [ + 'left', 'l', 'lft', 'lt', + '.l', '_l', '-l', ' l', + '左', 'ひだり' + ] + + right_patterns = [ + 'right', 'r', 'rgt', 'rt', + '.r', '_r', '-r', ' r', + '右', 'みぎ' + ] + + # Check for left patterns + for pattern in left_patterns: + pattern_norm = simplify_bonename(pattern) + if normalized.startswith(pattern_norm): + base = normalized[len(pattern_norm):] + if base: # Make sure there's something left + return base, 'L' + elif normalized.endswith(pattern_norm): + base = normalized[:-len(pattern_norm)] + if base: + return base, 'L' + elif pattern_norm in normalized: + # Handle cases like ArmLeft + parts = normalized.split(pattern_norm) + if len(parts) == 2: + base = parts[0] + parts[1] + if base: + return base, 'L' + + # Check for right patterns + for pattern in right_patterns: + pattern_norm = simplify_bonename(pattern) + if normalized.startswith(pattern_norm): + base = normalized[len(pattern_norm):] + if base: + return base, 'R' + elif normalized.endswith(pattern_norm): + base = normalized[:-len(pattern_norm)] + if base: + return base, 'R' + elif pattern_norm in normalized: + parts = normalized.split(pattern_norm) + if len(parts) == 2: + base = parts[0] + parts[1] + if base: + return base, 'R' + + return normalized, '' + +def find_symmetric_bone_pairs(bones: Dict[str, Bone]) -> Dict[str, Tuple[List[str], List[str]]]: + """ + Automatically find symmetric bone pairs in the armature. + Returns dict mapping base_name to (left_bones, right_bones) + """ + bone_groups = {} + + for bone_name in bones.keys(): + base, side = extract_bone_side_info(bone_name) + + if side: + if base not in bone_groups: + bone_groups[base] = {'L': [], 'R': []} + bone_groups[base][side].append(bone_name) + + symmetric_pairs = {} + for base, sides in bone_groups.items(): + if sides['L'] and sides['R']: + symmetric_pairs[base] = (sides['L'], sides['R']) + + return symmetric_pairs + +def validate_armature_symmetry(armature: Object) -> Tuple[bool, List[str]]: + """ + Comprehensive symmetry validation that provides detailed feedback + """ + if not armature or armature.type != 'ARMATURE': + return False, ["Invalid armature"] + + bones = {bone.name: bone for bone in armature.data.bones} + symmetric_pairs = find_symmetric_bone_pairs(bones) + + messages = [] + is_symmetric = True + + if symmetric_pairs: + messages.append("Found symmetric bone pairs:") + for base, (left_bones, right_bones) in symmetric_pairs.items(): + left_count = len(left_bones) + right_count = len(right_bones) + + if left_count == right_count: + messages.append(f" ✓ {base}: {left_count} bones on each side") + for l_bone, r_bone in zip(sorted(left_bones), sorted(right_bones)): + messages.append(f" {l_bone} ↔ {r_bone}") + else: + is_symmetric = False + messages.append(f" ✗ {base}: {left_count} left, {right_count} right bones") + messages.append(f" Left: {', '.join(sorted(left_bones))}") + messages.append(f" Right: {', '.join(sorted(right_bones))}") + else: + messages.append("No symmetric bone pairs detected") + is_symmetric = False + + return is_symmetric, messages + def validate_symmetry(bones: Dict[str, Bone], base: str, left: str, right: str) -> bool: """Validate if matching left and right bones exist for a given base bone name""" - # Extract left and right bone names from both hierarchies + # First try the new intelligent detection + symmetric_pairs = find_symmetric_bone_pairs(bones) + + # Look for bones that match the requested base type + matching_left_bones = [] + matching_right_bones = [] + + # Check each detected symmetric pair + for pair_base, (left_bones, right_bones) in symmetric_pairs.items(): + if base.lower() in pair_base.lower() or pair_base.lower() in base.lower(): + matching_left_bones.extend(left_bones) + matching_right_bones.extend(right_bones) + + if matching_left_bones or matching_right_bones: + left_bases = {} + right_bases = {} + + for bone_name in matching_left_bones: + bone_base, side = extract_bone_side_info(bone_name) + if bone_base not in left_bases: + left_bases[bone_base] = [] + left_bases[bone_base].append(bone_name) + + for bone_name in matching_right_bones: + bone_base, side = extract_bone_side_info(bone_name) + if bone_base not in right_bases: + right_bases[bone_base] = [] + right_bases[bone_base].append(bone_name) + + all_bases = set(left_bases.keys()) | set(right_bases.keys()) + for bone_base in all_bases: + left_count = len(left_bases.get(bone_base, [])) + right_count = len(right_bases.get(bone_base, [])) + if left_count != right_count: + return False + + return len(all_bases) > 0 + + # Fallback to original dictionary-based method left_bone_names = set() right_bone_names = set() + # Normalize bone names in the bones dict for comparison + normalized_bones = {simplify_bonename(name): name for name in bones.keys()} + # Add standard bones for key, value in standard_bones.items(): if base in key.lower(): if '_l' in key.lower(): - left_bone_names.add(value) + left_bone_names.add(simplify_bonename(value)) elif '_r' in key.lower(): - right_bone_names.add(value) + right_bone_names.add(simplify_bonename(value)) # Add acceptable bones for key, names in acceptable_bone_names.items(): if base in key.lower(): if '_l' in key.lower(): - left_bone_names.update(names) + left_bone_names.update(simplify_bonename(name) for name in names) elif '_r' in key.lower(): - right_bone_names.update(names) + right_bone_names.update(simplify_bonename(name) for name in names) # Check if at least one pair exists and matches - left_exists = any(name in bones for name in left_bone_names) - right_exists = any(name in bones for name in right_bone_names) + left_exists = any(name in normalized_bones for name in left_bone_names) + right_exists = any(name in normalized_bones for name in right_bone_names) return left_exists == right_exists @@ -224,22 +406,34 @@ def validate_finger_chain(bones: Dict[str, Bone], chain: Tuple[str, ...]) -> boo def check_acceptable_standards(bones: Dict[str, Bone]) -> bool: """Check if armature matches acceptable non-standard hierarchy""" logger.debug("Checking for acceptable standards") + + # Create normalized lookup for existing bones + normalized_bones = {simplify_bonename(name): name for name in bones.keys()} + # Check if bones exist in acceptable list for bone_category, acceptable_names in acceptable_bone_names.items(): found = False for name in acceptable_names: - if name in bones: + normalized_name = simplify_bonename(name) + if normalized_name in normalized_bones: found = True break if not found: logger.debug(f"Missing acceptable bone for category: {bone_category}") return False - # Validate acceptable hierarchy + # Validate acceptable hierarchy using normalized names for parent, child in acceptable_bone_hierarchy: - if parent in bones and child in bones: - if not validate_bone_hierarchy(bones, parent, child): - logger.debug(f"Invalid acceptable hierarchy: {parent} -> {child}") + parent_normalized = simplify_bonename(parent) + child_normalized = simplify_bonename(child) + + # Find actual bone names from normalized names + actual_parent = normalized_bones.get(parent_normalized) + actual_child = normalized_bones.get(child_normalized) + + if actual_parent and actual_child: + if not validate_bone_hierarchy(bones, actual_parent, actual_child): + logger.debug(f"Invalid acceptable hierarchy: {actual_parent} -> {actual_child}") return False logger.debug("Armature meets acceptable standards") diff --git a/core/common.py b/core/common.py index e942caa..4b2e39f 100644 --- a/core/common.py +++ b/core/common.py @@ -140,6 +140,12 @@ def get_all_meshes(context: Context) -> List[Object]: return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] return [] +def get_meshes_for_armature(armature: Object) -> List[Object]: + """Get all mesh objects parented to a specific armature""" + if armature and armature.type == 'ARMATURE': + return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] + return [] + def validate_mesh_for_pose(mesh_obj: Object) -> Tuple[bool, str]: """Validate mesh object for pose operations""" if not mesh_obj.data: @@ -655,6 +661,9 @@ def store_breaking_settings_armature(armature: bpy.types.Object) -> ArmatureData return data def restore_breaking_settings_armature(armature: bpy.types.Object, data: ArmatureData) -> None: + # Check if armature object is still valid (not removed) + if not armature or armature.name not in bpy.data.objects: + return armature_data: bpy.types.Armature = armature.data armature_data.use_mirror_x, armature.pose.use_mirror_x = data diff --git a/core/dictionaries.py b/core/dictionaries.py index 8e11fdf..198919e 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', 'jbipllclavicle'], + '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', 'jbiprrclavicle'], + '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', 'jseclbust1'], + 'breast_2_l': bone_names['breast_2_l'] + ['jbipcbreast2l', 'jlbreast2', 'jseclbust2'], + 'breast_3_l': bone_names['breast_3_l'] + ['jbipcbreast3l', 'jlbreast3', 'jseclbust3'], + 'breast_1_r': bone_names['breast_1_r'] + ['jbipcbreast1r', 'jrbreast1', 'jsecrbust1'], + 'breast_2_r': bone_names['breast_2_r'] + ['jbipcbreast2r', 'jrbreast2', 'jsecrbust2'], + 'breast_3_r': bone_names['breast_3_r'] + ['jbipcbreast3r', 'jrbreast3', 'jsecrbust3'], + + # 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 @@ -367,113 +452,125 @@ standard_bones = { 'hips': 'Hips', 'spine': 'Spine', 'chest': 'Chest', - 'upper_chest': 'Chest.Up', + 'upper_chest': 'UpperChest', 'neck': 'Neck', 'head': 'Head', # Arms - 'left_arm': 'UpperArm.L', - 'left_elbow': 'LowerArm.L', - 'left_wrist': 'Hand.L', - 'right_arm': 'UpperArm.R', - 'right_elbow': 'LowerArm.R', - 'right_wrist': 'Hand.R', + 'left_shoulder': 'Shoulder_L', + 'left_arm': 'UpperArm_L', + 'left_elbow': 'LowerArm_L', + 'left_wrist': 'Hand_L', + 'right_shoulder': 'Shoulder_R', + 'right_arm': 'UpperArm_R', + 'right_elbow': 'LowerArm_R', + 'right_wrist': 'Hand_R', # Legs - 'left_leg': 'UpperLeg.L', - 'left_knee': 'LowerLeg.L', - 'left_ankle': 'Foot.L', - 'left_toe': 'Toes.L', - 'right_leg': 'UpperLeg.R', - 'right_knee': 'LowerLeg.R', - 'right_ankle': 'Foot.R', - 'right_toe': 'Toes.R', + 'left_leg': 'UpperLeg_L', + 'left_knee': 'LowerLeg_L', + 'left_ankle': 'Foot_L', + 'left_toe': 'Toe_L', + 'right_leg': 'UpperLeg_R', + 'right_knee': 'LowerLeg_R', + 'right_ankle': 'Foot_R', + 'right_toe': 'Toe_R', # Fingers Left - 'thumb_1_l': 'Thumb1.L', - 'thumb_2_l': 'Thumb2.L', - 'thumb_3_l': 'Thumb3.L', - 'index_1_l': 'Index1.L', - 'index_2_l': 'Index2.L', - 'index_3_l': 'Index3.L', - 'middle_1_l': 'Middle1.L', - 'middle_2_l': 'Middle2.L', - 'middle_3_l': 'Middle3.L', - 'ring_1_l': 'Ring1.L', - 'ring_2_l': 'Ring2.L', - 'ring_3_l': 'Ring3.L', - 'pinkie_1_l': 'Pinky1.L', - 'pinkie_2_l': 'Pinky2.L', - 'pinkie_3_l': 'Pinky3.L', + 'thumb_1_l': 'Thumb_L', + 'thumb_2_l': 'Thumb_L.001', + 'thumb_3_l': 'Thumb_L.002', + 'index_1_l': 'Index_L', + 'index_2_l': 'Index_L.001', + 'index_3_l': 'Index_L.002', + 'middle_1_l': 'Middle_L', + 'middle_2_l': 'Middle_L.001', + 'middle_3_l': 'Middle_L.002', + 'ring_1_l': 'Ring_L', + 'ring_2_l': 'Ring_L.001', + 'ring_3_l': 'Ring_L.002', + 'pinkie_1_l': 'Pinky_L', + 'pinkie_2_l': 'Pinky_L.001', + 'pinkie_3_l': 'Pinky_L.002', # Fingers Right - 'thumb_1_r': 'Thumb1.R', - 'thumb_2_r': 'Thumb2.R', - 'thumb_3_r': 'Thumb3.R', - 'index_1_r': 'Index1.R', - 'index_2_r': 'Index2.R', - 'index_3_r': 'Index3.R', - 'middle_1_r': 'Middle1.R', - 'middle_2_r': 'Middle2.R', - 'middle_3_r': 'Middle3.R', - 'ring_1_r': 'Ring1.R', - 'ring_2_r': 'Ring2.R', - 'ring_3_r': 'Ring3.R', - 'pinkie_1_r': 'Pinky1.R', - 'pinkie_2_r': 'Pinky2.R', - 'pinkie_3_r': 'Pinky3.R', + 'thumb_1_r': 'Thumb_R', + 'thumb_2_r': 'Thumb_R.001', + 'thumb_3_r': 'Thumb_R.002', + 'index_1_r': 'Index_R', + 'index_2_r': 'Index_R.001', + 'index_3_r': 'Index_R.002', + 'middle_1_r': 'Middle_R', + 'middle_2_r': 'Middle_R.001', + 'middle_3_r': 'Middle_R.002', + 'ring_1_r': 'Ring_R', + 'ring_2_r': 'Ring_R.001', + 'ring_3_r': 'Ring_R.002', + 'pinkie_1_r': 'Pinky_R', + 'pinkie_2_r': 'Pinky_R.001', + 'pinkie_3_r': 'Pinky_R.002', # Eyes - 'left_eye': 'Eye.L', - 'right_eye': 'Eye.R' + '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 = [ ('Hips', 'Spine'), ('Spine', 'Chest'), - ('Chest', 'Chest.Up'), - ('Chest.Up', 'Neck'), + ('Chest', 'UpperChest'), + ('UpperChest', 'Neck'), ('Neck', 'Head'), - ('Head', 'Eye.L'), - ('Head', 'Eye.R'), + ('Head', 'Eye_L'), + ('Head', 'Eye_R'), # Left Arm Chain - ('Chest.Up', 'UpperArm.L'), - ('UpperArm.L', 'LowerArm.L'), - ('LowerArm.L', 'Hand.L'), + ('UpperChest', 'Shoulder_L'), + ('Shoulder_L', 'UpperArm_L'), + ('UpperArm_L', 'LowerArm_L'), + ('LowerArm_L', 'Hand_L'), # Right Arm Chain - ('Chest.Up', 'UpperArm.R'), - ('UpperArm.R', 'LowerArm.R'), - ('LowerArm.R', 'Hand.R'), + ('UpperChest', 'Shoulder_R'), + ('Shoulder_R', 'UpperArm_R'), + ('UpperArm_R', 'LowerArm_R'), + ('LowerArm_R', 'Hand_R'), # Left Leg Chain - ('Hips', 'UpperLeg.L'), - ('UpperLeg.L', 'LowerLeg.L'), - ('LowerLeg.L', 'Foot.L'), - ('Foot.L', 'Toes.L'), + ('Hips', 'UpperLeg_L'), + ('UpperLeg_L', 'LowerLeg_L'), + ('LowerLeg_L', 'Foot_L'), + ('Foot_L', 'Toe_L'), # Right Leg Chain - ('Hips', 'UpperLeg.R'), - ('UpperLeg.R', 'LowerLeg.R'), - ('LowerLeg.R', 'Foot.R'), - ('Foot.R', 'Toes.R') + ('Hips', 'UpperLeg_R'), + ('UpperLeg_R', 'LowerLeg_R'), + ('LowerLeg_R', 'Foot_R'), + ('Foot_R', 'Toe_R') ] finger_hierarchy = { 'left': [ - ('Hand.L', 'Thumb1.L', 'Thumb2.L', 'Thumb3.L'), - ('Hand.L', 'Index1.L', 'Index2.L', 'Index3.L'), - ('Hand.L', 'Middle1.L', 'Middle2.L', 'Middle3.L'), - ('Hand.L', 'Ring1.L', 'Ring2.L', 'Ring3.L'), - ('Hand.L', 'Pinky1.L', 'Pinky2.L', 'Pinky3.L') + ('Hand_L', 'Thumb_L', 'Thumb_L.001', 'Thumb_L.002'), + ('Hand_L', 'Index_L', 'Index_L.001', 'Index_L.002'), + ('Hand_L', 'Middle_L', 'Middle_L.001', 'Middle_L.002'), + ('Hand_L', 'Ring_L', 'Ring_L.001', 'Ring_L.002'), + ('Hand_L', 'Pinky_L', 'Pinky_L.001', 'Pinky_L.002') ], 'right': [ - ('Hand.R', 'Thumb1.R', 'Thumb2.R', 'Thumb3.R'), - ('Hand.R', 'Index1.R', 'Index2.R', 'Index3.R'), - ('Hand.R', 'Middle1.R', 'Middle2.R', 'Middle3.R'), - ('Hand.R', 'Ring1.R', 'Ring2.R', 'Ring3.R'), - ('Hand.R', 'Pinky1.R', 'Pinky2.R', 'Pinky3.R') + ('Hand_R', 'Thumb_R', 'Thumb_R.001', 'Thumb_R.002'), + ('Hand_R', 'Index_R', 'Index_R.001', 'Index_R.002'), + ('Hand_R', 'Middle_R', 'Middle_R.001', 'Middle_R.002'), + ('Hand_R', 'Ring_R', 'Ring_R.001', 'Ring_R.002'), + ('Hand_R', 'Pinky_R', 'Pinky_R.001', 'Pinky_R.002') ] } @@ -506,6 +603,8 @@ acceptable_bone_hierarchy = [ ('Head', 'Eye_R'), ('Head', 'LeftEye'), ('Head', 'RightEye'), + ('Head', 'Eye.L'), + ('Head', 'Eye.R'), # Unity humanoid naming ('Hips', 'Spine'), @@ -516,6 +615,40 @@ acceptable_bone_hierarchy = [ ('Head', 'LeftEye'), ('Head', 'RightEye'), + # Old standard bone hierarchy patterns + ('UpperChest', 'UpperArm.L'), + ('UpperArm.L', 'LowerArm.L'), + ('LowerArm.L', 'Hand.L'), + ('UpperChest', 'UpperArm.R'), + ('UpperArm.R', 'LowerArm.R'), + ('LowerArm.R', 'Hand.R'), + ('Hips', 'UpperLeg.L'), + ('UpperLeg.L', 'LowerLeg.L'), + ('LowerLeg.L', 'Foot.L'), + ('Foot.L', 'Toes.L'), + ('Hips', 'UpperLeg.R'), + ('UpperLeg.R', 'LowerLeg.R'), + ('LowerLeg.R', 'Foot.R'), + ('Foot.R', 'Toes.R'), + + # New standard bone hierarchy patterns (with shoulders) + ('UpperChest', 'Shoulder_L'), + ('Shoulder_L', 'UpperArm_L'), + ('UpperArm_L', 'LowerArm_L'), + ('LowerArm_L', 'Hand_L'), + ('UpperChest', 'Shoulder_R'), + ('Shoulder_R', 'UpperArm_R'), + ('UpperArm_R', 'LowerArm_R'), + ('LowerArm_R', 'Hand_R'), + ('Hips', 'UpperLeg_L'), + ('UpperLeg_L', 'LowerLeg_L'), + ('LowerLeg_L', 'Foot_L'), + ('Foot_L', 'Toe_L'), + ('Hips', 'UpperLeg_R'), + ('UpperLeg_R', 'LowerLeg_R'), + ('LowerLeg_R', 'Foot_R'), + ('Foot_R', 'Toe_R'), + ] acceptable_bone_names = { @@ -523,59 +656,75 @@ acceptable_bone_names = { 'chest': ['Chest', 'spine1', 'Spine1', 'spine_01', 'SPINE1', 'Spine01'], 'neck': ['Neck', 'neck_01', 'Neck01'], 'head': ['Head', 'head_01', 'Head01'], - 'eye_l': ['Eye_L', 'LeftEye', 'lefteye', 'eye_left', 'EyeLeft'], - 'eye_r': ['Eye_R', 'RightEye', 'righteye', 'eye_right', 'EyeRight'], + 'eye_l': ['Eye_L', 'LeftEye', 'lefteye', 'eye_left', 'EyeLeft', 'Eye.L'], + 'eye_r': ['Eye_R', 'RightEye', 'righteye', 'eye_right', 'EyeRight', 'Eye.R'], - 'shoulder_r': ['Shoulder.R', 'clavicle_r', 'ClavicleRight', 'RightShoulder'], - 'arm_r': ['Arm.R', 'upperarm_r', 'UpperArmRight', 'RightArm'], - 'elbow_r': ['Elbow.R', 'lowerarm_r', 'ForearmRight', 'RightForeArm'], - 'wrist_r': ['Wrist.R', 'hand_r', 'HandRight', 'RightHand'], - 'leg_r': ['Leg.R', 'thigh_r', 'ThighRight', 'RightLeg', 'RightUpLeg'], - 'knee_r': ['Knee.R', 'calf_r', 'CalfRight', 'RightShin', 'RightLowerLeg'], - 'foot_r': ['Foot.R', 'foot_r', 'FootRight', 'RightFoot'], - 'toes_r': ['Toes.R', 'ball_r', 'ToeRight', 'RightToeBase'], + 'shoulder_r': ['Shoulder.R', 'clavicle_r', 'ClavicleRight', 'RightShoulder', 'Shoulder_R'], + 'arm_r': ['Arm.R', 'upperarm_r', 'UpperArmRight', 'RightArm', 'UpperArm.R', 'UpperArm_R'], + 'elbow_r': ['Elbow.R', 'lowerarm_r', 'ForearmRight', 'RightForeArm', 'LowerArm.R', 'LowerArm_R'], + 'wrist_r': ['Wrist.R', 'hand_r', 'HandRight', 'RightHand', 'Hand.R', 'Hand_R'], + 'leg_r': ['Leg.R', 'thigh_r', 'ThighRight', 'RightLeg', 'RightUpLeg', 'UpperLeg.R', 'UpperLeg_R'], + 'knee_r': ['Knee.R', 'calf_r', 'CalfRight', 'RightShin', 'RightLowerLeg', 'LowerLeg.R', 'LowerLeg_R'], + 'foot_r': ['Foot.R', 'foot_r', 'FootRight', 'RightFoot', 'Foot_R'], + 'toes_r': ['Toes.R', 'ball_r', 'ToeRight', 'RightToeBase', 'Toe_R'], - 'shoulder_l': ['Shoulder.L', 'clavicle_l', 'ClavicleLeft', 'LeftShoulder'], - 'arm_l': ['Arm.L', 'upperarm_l', 'UpperArmLeft', 'LeftArm'], - 'elbow_l': ['Elbow.L', 'lowerarm_l', 'ForearmLeft', 'LeftForeArm'], - 'wrist_l': ['Wrist.L', 'hand_l', 'HandLeft', 'LeftHand'], - 'leg_l': ['Leg.L', 'thigh_l', 'ThighLeft', 'LeftLeg', 'LeftUpLeg'], - 'knee_l': ['Knee.L', 'calf_l', 'CalfLeft', 'LeftShin', 'LeftLowerLeg'], - 'foot_l': ['Foot.L', 'foot_l', 'FootLeft', 'LeftFoot'], - 'toes_l': ['Toes.L', 'ball_l', 'ToeLeft', 'LeftToeBase'], + 'shoulder_l': ['Shoulder.L', 'clavicle_l', 'ClavicleLeft', 'LeftShoulder', 'Shoulder_L'], + 'arm_l': ['Arm.L', 'upperarm_l', 'UpperArmLeft', 'LeftArm', 'UpperArm.L', 'UpperArm_L'], + 'elbow_l': ['Elbow.L', 'lowerarm_l', 'ForearmLeft', 'LeftForeArm', 'LowerArm.L', 'LowerArm_L'], + 'wrist_l': ['Wrist.L', 'hand_l', 'HandLeft', 'LeftHand', 'Hand.L', 'Hand_L'], + 'leg_l': ['Leg.L', 'thigh_l', 'ThighLeft', 'LeftLeg', 'LeftUpLeg', 'UpperLeg.L', 'UpperLeg_L'], + 'knee_l': ['Knee.L', 'calf_l', 'CalfLeft', 'LeftShin', 'LeftLowerLeg', 'LowerLeg.L', 'LowerLeg_L'], + 'foot_l': ['Foot.L', 'foot_l', 'FootLeft', 'LeftFoot', 'Foot_L'], + 'toes_l': ['Toes.L', 'ball_l', 'ToeLeft', 'LeftToeBase', 'Toe_L'], # Add finger bones for left hand - 'thumb_0_l': ['Thumb0_L'], - 'thumb_1_l': ['Thumb1_L'], - 'thumb_2_l': ['Thumb2_L'], - 'index_1_l': ['IndexFinger1_L'], - 'index_2_l': ['IndexFinger2_L'], - 'index_3_l': ['IndexFinger3_L'], - 'middle_1_l': ['MiddleFinger1_L'], - 'middle_2_l': ['MiddleFinger2_L'], - 'middle_3_l': ['MiddleFinger3_L'], - 'ring_1_l': ['RingFinger1_L'], - 'ring_2_l': ['RingFinger2_L'], - 'ring_3_l': ['RingFinger3_L'], + 'thumb_0_l': ['Thumb0_L', 'Thumb0.L'], + 'thumb_1_l': ['Thumb1_L', 'Thumb1.L', 'Thumb_L'], + 'thumb_2_l': ['Thumb2_L', 'Thumb2.L', 'Thumb_L.001'], + 'thumb_3_l': ['Thumb3_L', 'Thumb3.L', 'Thumb_L.002'], + 'index_1_l': ['IndexFinger1_L', 'IndexFinger1.L', 'Index1.L', 'Index_L'], + 'index_2_l': ['IndexFinger2_L', 'IndexFinger2.L', 'Index2.L', 'Index_L.001'], + 'index_3_l': ['IndexFinger3_L', 'IndexFinger3.L', 'Index3.L', 'Index_L.002'], + 'middle_1_l': ['MiddleFinger1_L', 'MiddleFinger1.L', 'Middle1.L', 'Middle_L'], + 'middle_2_l': ['MiddleFinger2_L', 'MiddleFinger2.L', 'Middle2.L', 'Middle_L.001'], + 'middle_3_l': ['MiddleFinger3_L', 'MiddleFinger3.L', 'Middle3.L', 'Middle_L.002'], + 'ring_1_l': ['RingFinger1_L', 'RingFinger1.L', 'Ring1.L', 'Ring_L'], + 'ring_2_l': ['RingFinger2_L', 'RingFinger2.L', 'Ring2.L', 'Ring_L.001'], + 'ring_3_l': ['RingFinger3_L', 'RingFinger3.L', 'Ring3.L', 'Ring_L.002'], + 'pinky_1_l': ['Pinky1_L', 'Pinky1.L', 'Pinky_L'], + 'pinky_2_l': ['Pinky2_L', 'Pinky2.L', 'Pinky_L.001'], + 'pinky_3_l': ['Pinky3_L', 'Pinky3.L', 'Pinky_L.002'], # Add finger bones for right hand - 'thumb_0_r': ['Thumb0_R', 'ThumbO_R'], - 'thumb_1_r': ['Thumb1_R'], - 'thumb_2_r': ['Thumb2_R'], - 'index_1_r': ['IndexFinger1_R'], - 'index_2_r': ['IndexFinger2_R'], - 'index_3_r': ['IndexFinger3_R'], - 'middle_1_r': ['MiddleFinger1_R'], - 'middle_2_r': ['MiddleFinger2_R'], - 'middle_3_r': ['MiddleFinger3_R'], - 'ring_1_r': ['RingFinger1_R'], - 'ring_2_r': ['RingFinger2_R'], - 'ring_3_r': ['RingFinger3_R'], + 'thumb_0_r': ['Thumb0_R', 'Thumb0.R', 'ThumbO_R'], + 'thumb_1_r': ['Thumb1_R', 'Thumb1.R', 'Thumb_R'], + 'thumb_2_r': ['Thumb2_R', 'Thumb2.R', 'Thumb_R.001'], + 'thumb_3_r': ['Thumb3_R', 'Thumb3.R', 'Thumb_R.002'], + 'index_1_r': ['IndexFinger1_R', 'IndexFinger1.R', 'Index1.R', 'Index_R'], + 'index_2_r': ['IndexFinger2_R', 'IndexFinger2.R', 'Index2.R', 'Index_R.001'], + 'index_3_r': ['IndexFinger3_R', 'IndexFinger3.R', 'Index3.R', 'Index_R.002'], + 'middle_1_r': ['MiddleFinger1_R', 'MiddleFinger1.R', 'Middle1.R', 'Middle_R'], + 'middle_2_r': ['MiddleFinger2_R', 'MiddleFinger2.R', 'Middle2.R', 'Middle_R.001'], + 'middle_3_r': ['MiddleFinger3_R', 'MiddleFinger3.R', 'Middle3.R', 'Middle_R.002'], + 'ring_1_r': ['RingFinger1_R', 'RingFinger1.R', 'Ring1.R', 'Ring_R'], + 'ring_2_r': ['RingFinger2_R', 'RingFinger2.R', 'Ring2.R', 'Ring_R.001'], + 'ring_3_r': ['RingFinger3_R', 'RingFinger3.R', 'Ring3.R', 'Ring_R.002'], + 'pinky_1_r': ['Pinky1_R', 'Pinky1.R', 'Pinky_R'], + 'pinky_2_r': ['Pinky2_R', 'Pinky2.R', 'Pinky_R.001'], + 'pinky_3_r': ['Pinky3_R', 'Pinky3.R', 'Pinky_R.002'], - 'breast_upper_1_l': ['BreastUpper1_L'], - 'breast_upper_2_l': ['BreastUpper2_L'], - 'breast_upper_1_r': ['BreastUpper1_R'], - 'breast_upper_2_r': ['BreastUpper2_R'], + 'breast_upper_1_l': ['BreastUpper1_L', 'BreastUpper1.L'], + 'breast_upper_2_l': ['BreastUpper2_L', 'BreastUpper2.L'], + 'breast_upper_1_r': ['BreastUpper1_R', 'BreastUpper1.R'], + 'breast_upper_2_r': ['BreastUpper2_R', 'BreastUpper2.R'], + + # Little finger bones + 'little_finger_1_l': ['LittleFinger1_L', 'LittleFinger1.L'], + 'little_finger_2_l': ['LittleFinger2_L', 'LittleFinger2.L'], + 'little_finger_3_l': ['LittleFinger3_L', 'LittleFinger3.L'], + 'little_finger_1_r': ['LittleFinger1_R', 'LittleFinger1.R'], + 'little_finger_2_r': ['LittleFinger2_R', 'LittleFinger2.R'], + 'little_finger_3_r': ['LittleFinger3_R', 'LittleFinger3.R'], 'ear_upper_l': ['UpperEar.L', 'Upper Ear.L', 'Upper Ear_L'], 'ear_upper_r': ['UpperEar.R', 'Upper Ear.R', 'Upper Ear_R'], @@ -695,17 +844,17 @@ non_standard_mappings = { 'left_arm': [ 'mixamorig:LeftArm', 'mixamorig_LeftArm', 'ORG-upper_arm.L', 'upper_arm.L', - 'lShldrBend', 'lShldrTwist', 'lArm' + 'lShldrBend', 'lShldrTwist', 'lArm', 'UpperArm.L' ], 'left_elbow': [ 'mixamorig:LeftForeArm', 'mixamorig_LeftForeArm', 'ORG-forearm.L', 'forearm.L', - 'lForearmBend', 'lElbow', 'lForeArm' + 'lForearmBend', 'lElbow', 'lForeArm', 'LowerArm.L' ], 'left_wrist': [ 'mixamorig:LeftHand', 'mixamorig_LeftHand', 'ORG-hand.L', 'hand.L', - 'lHand', 'lWrist' + 'lHand', 'lWrist', 'Hand.L' ], 'right_shoulder': [ @@ -716,59 +865,59 @@ non_standard_mappings = { 'right_arm': [ 'mixamorig:RightArm', 'mixamorig_RightArm', 'ORG-upper_arm.R', 'upper_arm.R', - 'rShldrBend', 'rShldrTwist', 'rArm' + 'rShldrBend', 'rShldrTwist', 'rArm', 'UpperArm.R' ], 'right_elbow': [ 'mixamorig:RightForeArm', 'mixamorig_RightForeArm', 'ORG-forearm.R', 'forearm.R', - 'rForearmBend', 'rElbow', 'rForeArm' + 'rForearmBend', 'rElbow', 'rForeArm', 'LowerArm.R' ], 'right_wrist': [ 'mixamorig:RightHand', 'mixamorig_RightHand', 'ORG-hand.R', 'hand.R', - 'rHand', 'rWrist' + 'rHand', 'rWrist', 'Hand.R' ], 'left_leg': [ 'mixamorig:LeftUpLeg', 'mixamorig_LeftUpLeg', 'ORG-thigh.L', 'thigh.L', - 'lThighBend', 'lThigh' + 'lThighBend', 'lThigh', 'UpperLeg.L' ], 'left_knee': [ 'mixamorig:LeftLeg', 'mixamorig_LeftLeg', 'ORG-shin.L', 'shin.L', - 'lShin', 'lKnee', 'lLeg' + 'lShin', 'lKnee', 'lLeg', 'LowerLeg.L' ], 'left_ankle': [ 'mixamorig:LeftFoot', 'mixamorig_LeftFoot', 'ORG-foot.L', 'foot.L', - 'lFoot', 'lAnkle' + 'lFoot', 'lAnkle', 'Foot.L' ], 'left_toe': [ 'mixamorig:LeftToeBase', 'mixamorig_LeftToeBase', 'ORG-toe.L', 'toe.L', - 'lToe' + 'lToe', 'Toes.L' ], 'right_leg': [ 'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg', 'ORG-thigh.R', 'thigh.R', - 'rThighBend', 'rThigh' + 'rThighBend', 'rThigh', 'UpperLeg.R' ], 'right_knee': [ 'mixamorig:RightLeg', 'mixamorig_RightLeg', 'ORG-shin.R', 'shin.R', - 'rShin', 'rKnee', 'rLeg' + 'rShin', 'rKnee', 'rLeg', 'LowerLeg.R' ], 'right_ankle': [ 'mixamorig:RightFoot', 'mixamorig_RightFoot', 'ORG-foot.R', 'foot.R', - 'rFoot', 'rAnkle' + 'rFoot', 'rAnkle', 'Foot.R' ], 'right_toe': [ 'mixamorig:RightToeBase', 'mixamorig_RightToeBase', 'ORG-toe.R', 'toe.R', - 'rToe' + 'rToe', 'Toes.R' ], 'thumb_1_l': [ @@ -934,12 +1083,12 @@ non_standard_mappings = { 'left_eye': [ 'mixamorig:LeftEye', 'mixamorig_LeftEye', 'ORG-eye.L', 'eye.L', - 'lEye' + 'lEye', 'Eye.L' ], 'right_eye': [ 'mixamorig:RightEye', 'mixamorig_RightEye', 'ORG-eye.R', 'eye.R', - 'rEye' + 'rEye', 'Eye.R' ] } diff --git a/core/enhanced_dictionaries.py b/core/enhanced_dictionaries.py new file mode 100644 index 0000000..e35c9a7 --- /dev/null +++ b/core/enhanced_dictionaries.py @@ -0,0 +1,372 @@ +# GPL License + +from typing import Dict, List, Optional, Set, Tuple +from .dictionaries import bone_names, reverse_bone_lookup, simplify_bonename +from .logging_setup import logger + +# Enhanced dictionaries for comprehensive translation support + +# Shapekey/Morph name translations (Japanese to English) +shapekey_names: Dict[str, List[str]] = { + # Basic facial expressions + "neutral": ["ニュートラル", "中立", "通常", "普通", "デフォルト", "basis"], + "smile": ["笑顔", "スマイル", "えがお", "笑い", "にこり", "ほほえみ", "smile", "happy"], + "angry": ["怒り", "怒る", "アングリー", "いかり", "おこり", "むかつき", "angry", "mad"], + "sad": ["悲しい", "かなしい", "悲哀", "サッド", "sad", "sorrow"], + "surprised": ["驚き", "びっくり", "おどろき", "サプライズ", "surprised", "shock"], + "disgusted": ["嫌悪", "いやがり", "きもち悪い", "disgusted"], + "fearful": ["恐怖", "怖い", "こわい", "恐れ", "fearful", "scared"], + "blink": ["瞬き", "まばたき", "ブリンク", "目閉じ", "blink", "eyeclose"], + "wink_left": ["ウィンク左", "左目ウィンク", "ひだりめうぃんく", "winkleft", "wink_l"], + "wink_right": ["ウィンク右", "右目ウィンク", "みぎめうぃんく", "winkright", "wink_r"], + "eye_close": ["目閉じ", "目を閉じる", "めとじ", "eyeclose", "closedeyes"], + "eye_wide": ["目見開き", "目を見開く", "びっくり目", "eyewide", "wideeyes"], + "eye_narrow": ["細目", "目細め", "ほそめ", "eyenarrow", "narroweyes"], + "mouth_open": ["口開け", "口を開ける", "くちあけ", "mouthopen", "openmouth"], + "mouth_smile": ["口角上げ", "口笑顔", "くちえがお", "mouthsmile"], + "mouth_frown": ["口角下げ", "への字口", "くちしかめ", "mouthfrown"], + "mouth_pout": ["すぼめ口", "とがらせ口", "mouthpout"], + "eyebrow_up": ["眉上げ", "眉毛上げ", "まゆあげ", "eyebrowup", "raiseeyebrow"], + "eyebrow_down": ["眉下げ", "眉寄せ", "まゆさげ", "eyebrowdown", "lowereyebrow"], + "eyebrow_angry": ["怒り眉", "眉怒り", "まゆいかり", "angrybrow"], + "cheek_puff": ["頬膨らまし", "ほほふくらまし", "cheekpuff"], + "cheek_suck": ["頬すぼめ", "ほほすぼめ", "cheeksuck"], + "joy": ["喜び", "よろこび", "ジョイ", "joy", "happiness"], + "contempt": ["軽蔑", "けいべつ", "contempt"], + "confusion": ["困惑", "こんわく", "confusion", "confused"], + "concentration": ["集中", "しゅうちゅう", "concentration", "focused"], + + # VRC Visemes + "viseme_sil": ["無音", "むおん", "サイレンス", "silence", "sil"], + "viseme_aa": ["あ", "aa", "mouth_a"], + "viseme_ih": ["い", "ih", "mouth_i"], + "viseme_ou": ["う", "ou", "mouth_u"], + "viseme_e": ["え", "e", "mouth_e"], + "viseme_oh": ["お", "oh", "mouth_o"], + "viseme_ch": ["ち", "ch"], + "viseme_dd": ["だ", "dd"], + "viseme_ff": ["ふ", "ff"], + "viseme_kk": ["か", "kk"], + "viseme_nn": ["ん", "nn"], + "viseme_pp": ["ぱ", "pp"], + "viseme_rr": ["ら", "rr"], + "viseme_ss": ["さ", "ss"], + "viseme_th": ["た", "th"], + + "basis": ["基本", "きほん", "ベース", "base", "basis", "default"], + "reset": ["リセット", "初期化", "しょきか", "reset", "clear"], +} + +# Material name translations (Japanese to English) +material_names: Dict[str, List[str]] = { + # Basic materials + "skin": ["肌", "はだ", "皮膚", "ひふ", "スキン", "skin", "flesh"], + "hair": ["髪", "かみ", "毛髪", "もうはつ", "ヘア", "hair"], + "eyes": ["目", "め", "眼", "がん", "アイ", "eye", "iris"], + "eyebrow": ["眉", "まゆ", "眉毛", "まゆげ", "eyebrow", "brow"], + "eyelash": ["まつ毛", "まつげ", "睫毛", "eyelash", "lash"], + "teeth": ["歯", "は", "歯列", "しれつ", "tooth", "teeth"], + "tongue": ["舌", "した", "tongue"], + "nails": ["爪", "つめ", "nail", "nails"], + "shirt": ["シャツ", "上着", "うわぎ", "shirt", "top"], + "pants": ["パンツ", "ズボン", "下着", "したぎ", "pants", "trousers"], + "skirt": ["スカート", "skirt"], + "dress": ["ドレス", "ワンピース", "dress"], + "shoes": ["靴", "くつ", "シューズ", "shoe", "shoes"], + "socks": ["靴下", "くつした", "ソックス", "sock", "socks"], + "gloves": ["手袋", "てぶくろ", "グローブ", "glove", "gloves"], + "hat": ["帽子", "ぼうし", "ハット", "hat", "cap"], + "jacket": ["ジャケット", "上着", "うわぎ", "jacket", "coat"], + "underwear": ["下着", "したぎ", "パンティー", "underwear", "panties"], + "bra": ["ブラ", "ブラジャー", "胸当て", "bra", "brassiere"], + "glasses": ["眼鏡", "めがね", "メガネ", "glasses", "spectacles"], + "earring": ["イヤリング", "耳飾り", "みみかざり", "earring"], + "necklace": ["ネックレス", "首飾り", "くびかざり", "necklace"], + "bracelet": ["ブレスレット", "腕輪", "うでわ", "bracelet"], + "ring": ["指輪", "ゆびわ", "リング", "ring"], + "watch": ["時計", "とけい", "ウォッチ", "watch"], + "bag": ["鞄", "かばん", "バッグ", "bag", "purse"], + "belt": ["ベルト", "帯", "おび", "belt"], + "transparent": ["透明", "とうめい", "クリア", "transparent", "clear"], + "metal": ["金属", "きんぞく", "メタル", "metal"], + "fabric": ["布", "ぬの", "生地", "きじ", "fabric", "cloth"], + "leather": ["革", "かわ", "皮", "ひ", "レザー", "leather"], + "plastic": ["プラスチック", "プラ", "plastic"], + "glass": ["ガラス", "硝子", "glass"], + "rubber": ["ゴム", "ラバー", "rubber"], + "wood": ["木", "き", "木材", "もくざい", "wood", "wooden"], + "diffuse": ["ディフューズ", "基本色", "きほんしょく", "diffuse", "albedo"], + "normal": ["ノーマル", "法線", "ほうせん", "normal", "bump"], + "specular": ["スペキュラー", "反射", "はんしゃ", "specular", "reflection"], + "emission": ["発光", "はっこう", "エミッション", "emission", "glow"], + "roughness": ["粗さ", "あらさ", "ラフネス", "roughness"], + "metallic": ["メタリック", "金属性", "きんぞくせい", "metallic"], + "subsurface": ["表面下散乱", "サブサーフェス", "subsurface", "sss"], + + # Common naming patterns + "main": ["メイン", "主要", "しゅよう", "main", "primary"], + "sub": ["サブ", "副", "ふく", "sub", "secondary"], + "detail": ["詳細", "しょうさい", "ディテール", "detail"], + "shadow": ["影", "かげ", "シャドウ", "shadow"], + "highlight": ["ハイライト", "強調", "きょうちょう", "highlight"], +} + +# Object name translations (Japanese to English) +object_names: Dict[str, List[str]] = { + + "body": ["体", "からだ", "身体", "しんたい", "ボディ", "body", "torso"], + "head": ["頭", "あたま", "ヘッド", "head"], + "face": ["顔", "かお", "フェイス", "face"], + "neck": ["首", "くび", "ネック", "neck"], + "chest": ["胸", "むね", "チェスト", "chest", "breast"], + "back": ["背中", "せなか", "バック", "back"], + "waist": ["腰", "こし", "ウエスト", "waist"], + "hip": ["腰", "こし", "ヒップ", "hip"], + "arm": ["腕", "うで", "アーム", "arm"], + "hand": ["手", "て", "ハンド", "hand"], + "finger": ["指", "ゆび", "フィンガー", "finger"], + "leg": ["足", "あし", "脚", "レッグ", "leg"], + "foot": ["足", "あし", "フット", "foot"], + "toe": ["つま先", "つまさき", "トゥ", "toe"], + "clothing": ["服", "ふく", "衣服", "いふく", "クロージング", "clothing", "clothes"], + "outfit": ["服装", "ふくそう", "アウトフィット", "outfit"], + "accessory": ["アクセサリー", "装身具", "そうしんぐ", "accessory"], + "decoration": ["装飾", "そうしょく", "デコレーション", "decoration"], + "hair_front": ["前髪", "まえがみ", "フロント髪", "hairfront"], + "hair_back": ["後ろ髪", "うしろがみ", "バック髪", "hairback"], + "hair_side": ["横髪", "よこがみ", "サイド髪", "hairside"], + "ponytail": ["ポニーテール", "一つ結び", "ひとつむすび", "ponytail"], + "twintail": ["ツインテール", "二つ結び", "ふたつむすび", "twintail"], + "ahoge": ["あほ毛", "アホ毛", "はね毛", "ahoge", "antenna"], + "eyeball": ["眼球", "がんきゅう", "目玉", "めだま", "eyeball"], + "pupil": ["瞳", "ひとみ", "瞳孔", "どうこう", "pupil"], + "iris": ["虹彩", "こうさい", "アイリス", "iris"], + "eyelid": ["まぶた", "眼瞼", "がんけん", "eyelid"], + "nose": ["鼻", "はな", "ノーズ", "nose"], + "mouth": ["口", "くち", "マウス", "mouth"], + "lip": ["唇", "くちびる", "リップ", "lip"], + "ear": ["耳", "みみ", "イヤー", "ear"], + + # Common object suffixes + "left": ["左", "ひだり", "レフト", "left", "l"], + "right": ["右", "みぎ", "ライト", "right", "r"], + "upper": ["上", "うえ", "アッパー", "upper", "top"], + "lower": ["下", "した", "ロワー", "lower", "bottom"], + "inner": ["内", "うち", "インナー", "inner", "inside"], + "outer": ["外", "そと", "アウター", "outer", "outside"], + "front": ["前", "まえ", "フロント", "front"], + "back": ["後ろ", "うしろ", "バック", "back", "rear"], +} + +# Physics object names (for MMD rigid bodies and joints) +physics_names: Dict[str, List[str]] = { + # Rigid body types + "rigidbody": ["剛体", "ごうたい", "リジッドボディ", "rigidbody", "rigid"], + "joint": ["ジョイント", "関節", "かんせつ", "joint", "constraint"], + "collision": ["当たり判定", "あたりはんてい", "コリジョン", "collision"], + "hair_physics": ["髪物理", "かみぶつり", "ヘアフィジックス", "hairphys"], + "hair_root": ["髪根元", "かみねもと", "ヘアルート", "hairroot"], + "hair_tip": ["髪先", "かみさき", "ヘアティップ", "hairtip"], + "cloth_physics": ["布物理", "ぬのぶつり", "クロスフィジックス", "clothphys"], + "skirt_physics": ["スカート物理", "スカートフィジックス", "skirtphys"], + "breast_physics": ["胸物理", "むねぶつり", "ブレストフィジックス", "breastphys"], + "breast_root": ["胸根元", "むねねもと", "ブレストルート", "breastroot"], + "breast_tip": ["胸先", "むねさき", "ブレストティップ", "breasttip"], +} + +# Create reverse lookup dictionaries +reverse_shapekey_lookup: Dict[str, str] = {} +reverse_material_lookup: Dict[str, str] = {} +reverse_object_lookup: Dict[str, str] = {} +reverse_physics_lookup: Dict[str, str] = {} + +def _build_reverse_lookups(): + """Build reverse lookup dictionaries for fast translation""" + global reverse_shapekey_lookup, reverse_material_lookup, reverse_object_lookup, reverse_physics_lookup + + for standard_name, variations in shapekey_names.items(): + for variation in variations: + simplified = simplify_bonename(variation) + reverse_shapekey_lookup[simplified] = standard_name + + for standard_name, variations in material_names.items(): + for variation in variations: + simplified = simplify_bonename(variation) + reverse_material_lookup[simplified] = standard_name + + for standard_name, variations in object_names.items(): + for variation in variations: + simplified = simplify_bonename(variation) + reverse_object_lookup[simplified] = standard_name + + for standard_name, variations in physics_names.items(): + for variation in variations: + simplified = simplify_bonename(variation) + reverse_physics_lookup[simplified] = standard_name + +_build_reverse_lookups() + + +class EnhancedDictionaryTranslator: + """Enhanced dictionary translator with support for bones, shapekeys, materials, and objects""" + + def __init__(self): + self.translation_stats = { + 'bones': 0, + 'shapekeys': 0, + 'materials': 0, + 'objects': 0, + 'physics': 0, + 'total': 0 + } + + def translate_bone_name(self, name: str) -> Optional[str]: + """Translate bone name using existing bone dictionary""" + simplified = simplify_bonename(name) + if simplified in reverse_bone_lookup: + self.translation_stats['bones'] += 1 + self.translation_stats['total'] += 1 + return reverse_bone_lookup[simplified] + return None + + def translate_shapekey_name(self, name: str) -> Optional[str]: + """Translate shapekey/morph name using shapekey dictionary""" + simplified = simplify_bonename(name) + if simplified in reverse_shapekey_lookup: + self.translation_stats['shapekeys'] += 1 + self.translation_stats['total'] += 1 + return reverse_shapekey_lookup[simplified] + return None + + def translate_material_name(self, name: str) -> Optional[str]: + """Translate material name using material dictionary""" + simplified = simplify_bonename(name) + if simplified in reverse_material_lookup: + self.translation_stats['materials'] += 1 + self.translation_stats['total'] += 1 + return reverse_material_lookup[simplified] + return None + + def translate_object_name(self, name: str) -> Optional[str]: + """Translate object name using object dictionary""" + simplified = simplify_bonename(name) + if simplified in reverse_object_lookup: + self.translation_stats['objects'] += 1 + self.translation_stats['total'] += 1 + return reverse_object_lookup[simplified] + return None + + def translate_physics_name(self, name: str) -> Optional[str]: + """Translate physics object name using physics dictionary""" + simplified = simplify_bonename(name) + if simplified in reverse_physics_lookup: + self.translation_stats['physics'] += 1 + self.translation_stats['total'] += 1 + return reverse_physics_lookup[simplified] + return None + + def translate_name(self, name: str, category: str = "auto") -> Tuple[Optional[str], str]: + """ + Translate name with automatic category detection or specified category + Returns (translated_name, detected_category) + """ + if not name or not name.strip(): + return None, "none" + + if category == "bones": + result = self.translate_bone_name(name) + return (result, "bones") if result else (None, "unknown") + elif category == "shapekeys": + result = self.translate_shapekey_name(name) + return (result, "shapekeys") if result else (None, "unknown") + elif category == "materials": + result = self.translate_material_name(name) + return (result, "materials") if result else (None, "unknown") + elif category == "objects": + result = self.translate_object_name(name) + return (result, "objects") if result else (None, "unknown") + elif category == "physics": + result = self.translate_physics_name(name) + return (result, "physics") if result else (None, "unknown") + elif category == "auto": + # Try all categories in order of likelihood + for cat_name, translate_func in [ + ("bones", self.translate_bone_name), + ("shapekeys", self.translate_shapekey_name), + ("materials", self.translate_material_name), + ("objects", self.translate_object_name), + ("physics", self.translate_physics_name) + ]: + result = translate_func(name) + if result: + return result, cat_name + return None, "unknown" + else: + return None, "invalid_category" + + def get_statistics(self) -> Dict[str, int]: + """Get translation statistics""" + return self.translation_stats.copy() + + def reset_statistics(self) -> None: + """Reset translation statistics""" + for key in self.translation_stats: + self.translation_stats[key] = 0 + + +# Global enhanced dictionary translator instance +_enhanced_translator: Optional[EnhancedDictionaryTranslator] = None + + +def get_enhanced_translator() -> EnhancedDictionaryTranslator: + """Get the global enhanced dictionary translator""" + global _enhanced_translator + if _enhanced_translator is None: + _enhanced_translator = EnhancedDictionaryTranslator() + return _enhanced_translator + + +def get_all_dictionary_names() -> Dict[str, Dict[str, List[str]]]: + """Get all dictionary names for reference""" + return { + "bones": bone_names, + "shapekeys": shapekey_names, + "materials": material_names, + "objects": object_names, + "physics": physics_names + } + + +def add_custom_translation(category: str, standard_name: str, variations: List[str]) -> bool: + """Add custom translation to the dictionaries""" + try: + if category == "bones": + if standard_name not in bone_names: + bone_names[standard_name] = [] + bone_names[standard_name].extend(variations) + elif category == "shapekeys": + if standard_name not in shapekey_names: + shapekey_names[standard_name] = [] + shapekey_names[standard_name].extend(variations) + elif category == "materials": + if standard_name not in material_names: + material_names[standard_name] = [] + material_names[standard_name].extend(variations) + elif category == "objects": + if standard_name not in object_names: + object_names[standard_name] = [] + object_names[standard_name].extend(variations) + elif category == "physics": + if standard_name not in physics_names: + physics_names[standard_name] = [] + physics_names[standard_name].extend(variations) + else: + return False + + _build_reverse_lookups() + logger.info(f"Added custom translation for {category}: {standard_name}") + return True + + except Exception as e: + logger.error(f"Failed to add custom translation: {e}") + return False \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index ef9243d..4e6ea22 100644 --- a/core/properties.py +++ b/core/properties.py @@ -67,6 +67,74 @@ def get_mesh_objects(self, context): return [('NONE', t("Visemes.no_meshes"), '')] return meshes +def auto_populate_merge_armatures(context: Context) -> None: + """Auto-populate merge armature fields when there are 2+ armatures""" + armatures = [obj for obj in bpy.data.objects if obj.type == 'ARMATURE'] + + if len(armatures) >= 2: + toolkit = context.scene.avatar_toolkit + + if not toolkit.merge_armature_into and not toolkit.merge_armature: + toolkit.merge_armature_into = armatures[0].name + toolkit.merge_armature = armatures[1].name + logger.debug(f"Auto-populated merge armatures: {armatures[0].name} <- {armatures[1].name}") + + elif toolkit.merge_armature_into and not toolkit.merge_armature: + for armature in armatures: + if armature.name != toolkit.merge_armature_into: + toolkit.merge_armature = armature.name + logger.debug(f"Auto-populated merge_armature: {armature.name}") + break + + elif not toolkit.merge_armature_into and toolkit.merge_armature: + for armature in armatures: + if armature.name != toolkit.merge_armature: + toolkit.merge_armature_into = armature.name + logger.debug(f"Auto-populated merge_armature_into: {armature.name}") + break + +def update_merge_armature_into(self: PropertyGroup, context: Context) -> None: + """Update function for merge_armature_into property""" + auto_populate_merge_armatures(context) + +def update_merge_armature(self: PropertyGroup, context: Context) -> None: + """Update function for merge_armature property""" + auto_populate_merge_armatures(context) + +@bpy.app.handlers.persistent +def depsgraph_update_handler(scene: Scene, depsgraph) -> None: + """Handler to auto-populate merge armatures when objects change""" + # Check for any armature-related updates + armature_updated = False + for update in depsgraph.updates: + if hasattr(update, 'id') and update.id and hasattr(update.id, 'type'): + if update.id.type == 'ARMATURE': + armature_updated = True + break + + if armature_updated: + # Use a timer to defer the update to avoid context issues + bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1) + +def auto_populate_safe() -> None: + """Safe auto-populate function that can be called from timer""" + try: + if bpy.context and hasattr(bpy.context, 'scene') and hasattr(bpy.context.scene, 'avatar_toolkit'): + auto_populate_merge_armatures(bpy.context) + except (AttributeError, ReferenceError): + pass + return None # Don't repeat the timer + +@bpy.app.handlers.persistent +def undo_post_handler(scene: Scene) -> None: + """Handler for undo operations that might add/remove armatures""" + bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1) + +@bpy.app.handlers.persistent +def redo_post_handler(scene: Scene) -> None: + """Handler for redo operations that might add/remove armatures""" + bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=0.1) + class AvatarToolkitSceneProperties(PropertyGroup): """Property group containing Avatar Toolkit scene-level settings and properties""" @@ -197,6 +265,7 @@ class AvatarToolkitSceneProperties(PropertyGroup): items=get_armature_list, name=t("QuickAccess.select_armature"), description=t("QuickAccess.select_armature"), + update=lambda self, context: update_active_armature(self, context) ) language: EnumProperty( @@ -465,13 +534,15 @@ class AvatarToolkitSceneProperties(PropertyGroup): merge_armature_into: StringProperty( name=t('MergeArmature.into'), description=t('MergeArmature.into_desc'), - default="" + default="", + update=update_merge_armature_into ) merge_armature: StringProperty( name=t('MergeArmature.from'), description=t('MergeArmature.from_desc'), - default="" + default="", + update=update_merge_armature ) attach_mesh: StringProperty( @@ -608,12 +679,166 @@ class AvatarToolkitSceneProperties(PropertyGroup): update=update_log_level ) + # VRM Conversion Properties + vrm_remove_colliders: BoolProperty( + name=t("VRM.remove_colliders"), + description=t("VRM.remove_colliders_desc"), + default=True + ) + + vrm_remove_root: BoolProperty( + name=t("VRM.remove_root"), + description=t("VRM.remove_root_desc"), + default=True + ) + + # Translation System Properties + translation_service: EnumProperty( + name=t("Translation.service"), + description=t("Translation.service_desc"), + items=[ + ('mymemory', t("Translation.service.mymemory"), t("Translation.service.mymemory_desc")), + ('libretranslate', t("Translation.service.libretranslate"), t("Translation.service.libretranslate_desc")), + ('deepl', t("Translation.service.deepl"), t("Translation.service.deepl_desc")) + ], + default=get_preference("translation_service", "mymemory"), + update=lambda self, context: update_translation_service(self, context) + ) + + translation_mode: EnumProperty( + name=t("Translation.mode"), + description=t("Translation.mode_desc"), + items=[ + ('hybrid', t("Translation.mode.hybrid"), t("Translation.mode.hybrid_desc")), + ('dictionary_only', t("Translation.mode.dictionary_only"), t("Translation.mode.dictionary_only_desc")), + ('api_only', t("Translation.mode.api_only"), t("Translation.mode.api_only_desc")) + ], + default=get_preference("translation_mode", "hybrid"), + update=lambda self, context: update_translation_mode(self, context) + ) + + translation_expand: BoolProperty( + name="Translation Settings Expanded", + default=False + ) + + + translation_target_language: EnumProperty( + name=t("Translation.target_language"), + description=t("Translation.target_language_desc"), + items=[ + ('en', 'English', 'Translate to English'), + ('ja', 'Japanese', 'Translate to Japanese'), + ('ko', 'Korean', 'Translate to Korean'), + ('zh', 'Chinese', 'Translate to Chinese'), + ('es', 'Spanish', 'Translate to Spanish'), + ('fr', 'French', 'Translate to French'), + ('de', 'German', 'Translate to German') + ], + default='en' + ) + + translation_source_language: EnumProperty( + name=t("Translation.source_language"), + description=t("Translation.source_language_desc"), + items=[ + ('auto', 'Auto-detect', 'Automatically detect source language'), + ('ja', 'Japanese', 'Source is Japanese'), + ('en', 'English', 'Source is English'), + ('ko', 'Korean', 'Source is Korean'), + ('zh', 'Chinese', 'Source is Chinese') + ], + default='ja' + ) + + +def update_translation_service(self: PropertyGroup, context: Context) -> None: + """Update translation service preference""" + logger.info(f"Updating translation service to: {self.translation_service}") + save_preference("translation_service", self.translation_service) + + # Clear module-level translation caches when service changes + try: + from ..ui.translation_panel import _ui_cache + _ui_cache['deepl_config'].clear() + _ui_cache['libretranslate_config'].clear() + _ui_cache['translation_status'].clear() + if 'batch_info' in _ui_cache: + del _ui_cache['batch_info'] # Clear batch info cache when service changes + except ImportError: + pass # UI module might not be loaded yet + + # Set the primary service + try: + from .translation_manager import get_avatar_translation_manager + manager = get_avatar_translation_manager() + manager.service_manager.set_primary_service(self.translation_service) + except Exception as e: + logger.error(f"Failed to update translation service: {e}") + + +def update_translation_mode(self: PropertyGroup, context: Context) -> None: + """Update translation mode preference""" + logger.info(f"Updating translation mode to: {self.translation_mode}") + save_preference("translation_mode", self.translation_mode) + + # Clear module-level translation status cache when mode changes + try: + from ..ui.translation_panel import _ui_cache + _ui_cache['translation_status'].clear() + if 'batch_info' in _ui_cache: + del _ui_cache['batch_info'] # Clear batch info cache when mode changes + except ImportError: + pass # UI module might not be loaded yet + + try: + from .translation_manager import get_avatar_translation_manager, TranslationMode + manager = get_avatar_translation_manager() + manager.set_translation_mode(TranslationMode(self.translation_mode)) + except Exception as e: + logger.error(f"Failed to update translation mode: {e}") + + +def update_active_armature(self: PropertyGroup, context: Context) -> None: + """Update the active armature when selection changes""" + if self.active_armature: + logger.info(f"Active armature set to: {self.active_armature}") + # Deselect all objects first + bpy.ops.object.select_all(action='DESELECT') + # Select and make active the chosen armature + self.active_armature.select_set(True) + context.view_layer.objects.active = self.active_armature + logger.info(f"Selected and activated armature: {self.active_armature.name}") + + # Clear armature caches when armature changes to ensure fresh validation + try: + from ..ui.quick_access_panel import clear_armature_caches + clear_armature_caches() + except ImportError: + pass # UI module might not be loaded yet + else: + logger.info("No armature selected") + + + + + + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") # Only register the property, not the classes (auto_load will handle that) bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties) + + # Register handlers for auto-populating merge armatures + bpy.app.handlers.depsgraph_update_post.append(depsgraph_update_handler) + bpy.app.handlers.undo_post.append(undo_post_handler) + bpy.app.handlers.redo_post.append(redo_post_handler) + + # Initial auto-populate + bpy.app.timers.register(lambda: auto_populate_safe(), first_interval=1.0) + logger.debug("Properties registered successfully") @@ -621,6 +846,14 @@ def unregister() -> None: """Unregister the Avatar Toolkit property group""" logger.info("Unregistering Avatar Toolkit properties") + # Remove handlers + if depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(depsgraph_update_handler) + if undo_post_handler in bpy.app.handlers.undo_post: + bpy.app.handlers.undo_post.remove(undo_post_handler) + if redo_post_handler in bpy.app.handlers.redo_post: + bpy.app.handlers.redo_post.remove(redo_post_handler) + # Remove the property if hasattr(bpy.types.Scene, "avatar_toolkit"): try: diff --git a/core/translation_manager.py b/core/translation_manager.py new file mode 100644 index 0000000..ef18c96 --- /dev/null +++ b/core/translation_manager.py @@ -0,0 +1,600 @@ +# GPL License + +import json +import os +import time +import threading +from typing import Dict, List, Optional, Tuple, Set, Any, Callable +from dataclasses import dataclass, asdict +from enum import Enum + +import bpy +from bpy.types import Object, Material, ShapeKey + +from .translation_service import get_translation_manager, TranslationServiceManager +from .enhanced_dictionaries import get_enhanced_translator, EnhancedDictionaryTranslator +from .logging_setup import logger +from .addon_preferences import get_preference, save_preference +from .translations import t + + +class TranslationMode(Enum): + """Translation modes for different approaches""" + DICTIONARY_ONLY = "dictionary_only" + API_ONLY = "api_only" + HYBRID = "hybrid" # Default: Dictionary first, then API fallback + + +@dataclass +class TranslationJob: + """Represents a translation job for batch processing""" + name: str + category: str + source_lang: str = "ja" + target_lang: str = "en" + object_ref: Optional[Any] = None + property_name: Optional[str] = None + + +@dataclass +class TranslationResult: + """Result of a translation operation""" + original: str + translated: str + method: str # "dictionary", "api", "failed" + service: Optional[str] = None + category: str = "unknown" + confidence: float = 1.0 + + +class TranslationCache: + """Persistent translation cache with file storage""" + + def __init__(self): + self._cache: Dict[str, Dict[str, str]] = {} + self._cache_file = self._get_cache_file_path() + self._cache_lock = threading.Lock() + self._load_cache() + + def _get_cache_file_path(self) -> str: + """Get the cache file path in user preferences directory""" + user_path = bpy.utils.resource_path('USER') + cache_dir = os.path.join(user_path, "config", "avatar_toolkit_prefs") + os.makedirs(cache_dir, exist_ok=True) + return os.path.join(cache_dir, "translation_cache.json") + + def _load_cache(self) -> None: + """Load cache from file""" + try: + if os.path.exists(self._cache_file): + with open(self._cache_file, 'r', encoding='utf-8') as f: + self._cache = json.load(f) + logger.debug(f"Loaded translation cache with {len(self._cache)} entries") + else: + self._cache = {} + except Exception as e: + logger.warning(f"Failed to load translation cache: {e}") + self._cache = {} + + def _save_cache(self) -> None: + """Save cache to file""" + try: + with open(self._cache_file, 'w', encoding='utf-8') as f: + json.dump(self._cache, f, indent=2, ensure_ascii=False) + logger.debug(f"Saved translation cache with {len(self._cache)} entries") + except Exception as e: + logger.error(f"Failed to save translation cache: {e}") + + def get(self, text: str, source_lang: str, target_lang: str) -> Optional[str]: + """Get cached translation""" + cache_key = f"{source_lang}_{target_lang}" + with self._cache_lock: + if cache_key in self._cache and text in self._cache[cache_key]: + return self._cache[cache_key][text] + return None + + def put(self, text: str, translation: str, source_lang: str, target_lang: str) -> None: + """Store translation in cache""" + cache_key = f"{source_lang}_{target_lang}" + with self._cache_lock: + if cache_key not in self._cache: + self._cache[cache_key] = {} + self._cache[cache_key][text] = translation + + # Save cache periodically (every 10 new entries) + if len(self._cache.get(cache_key, {})) % 10 == 0: + self._save_cache() + + def clear(self) -> None: + """Clear all cached translations""" + with self._cache_lock: + self._cache.clear() + self._save_cache() + logger.info("Translation cache cleared") + + def get_stats(self) -> Dict[str, int]: + """Get cache statistics""" + with self._cache_lock: + total_entries = sum(len(lang_cache) for lang_cache in self._cache.values()) + return { + "language_pairs": len(self._cache), + "total_entries": total_entries + } + + +class AvatarToolkitTranslationManager: + """Main translation manager for Avatar Toolkit""" + + def __init__(self): + self.service_manager: TranslationServiceManager = get_translation_manager() + self.dictionary_translator: EnhancedDictionaryTranslator = get_enhanced_translator() + self.cache: TranslationCache = TranslationCache() + self.translation_mode: TranslationMode = TranslationMode( + get_preference("translation_mode", "hybrid") + ) + self._progress_callback: Optional[Callable[[int, int, str], None]] = None + + def set_translation_mode(self, mode: TranslationMode) -> None: + """Set the translation mode""" + self.translation_mode = mode + save_preference("translation_mode", mode.value) + logger.info(f"Translation mode set to: {mode.value}") + + def set_progress_callback(self, callback: Optional[Callable[[int, int, str], None]]) -> None: + """Set progress callback for batch operations""" + self._progress_callback = callback + + def translate_single(self, name: str, category: str = "auto", + source_lang: str = "ja", target_lang: str = "en") -> TranslationResult: + """Translate a single name with comprehensive fallback logic""" + if not name or not name.strip(): + return TranslationResult(name, name, "skipped") + + original_name = name.strip() + + # Check cache first + cached_result = self.cache.get(original_name, source_lang, target_lang) + if cached_result: + return TranslationResult(original_name, cached_result, "cache", category=category) + + # Dictionary translation (always try first in hybrid mode) + if self.translation_mode in [TranslationMode.DICTIONARY_ONLY, TranslationMode.HYBRID]: + dict_result, detected_category = self.dictionary_translator.translate_name(original_name, category) + if dict_result: + self.cache.put(original_name, dict_result, source_lang, target_lang) + return TranslationResult(original_name, dict_result, "dictionary", + category=detected_category, confidence=1.0) + + if self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID]: + try: + api_result, service_name = self.service_manager.translate_with_fallback( + original_name, source_lang, target_lang + ) + if api_result != original_name: # Translation succeeded + self.cache.put(original_name, api_result, source_lang, target_lang) + return TranslationResult(original_name, api_result, "api", + service=service_name, category=category, confidence=0.8) + except Exception as e: + logger.warning(f"API translation failed for '{original_name}': {e}") + + # No translation available + return TranslationResult(original_name, original_name, "failed", category=category) + + def translate_batch(self, jobs: List[TranslationJob], + apply_results: bool = True) -> List[TranslationResult]: + """Translate multiple items in batch with progress reporting and interruption handling""" + results = [] + total_jobs = len(jobs) + + logger.info(f"Starting batch translation of {total_jobs} items") + + # Group jobs by category for more efficient processing + jobs_by_category: Dict[str, List[TranslationJob]] = {} + for job in jobs: + if job.category not in jobs_by_category: + jobs_by_category[job.category] = [] + jobs_by_category[job.category].append(job) + + completed = 0 + start_time = time.time() + + for category, category_jobs in jobs_by_category.items(): + logger.debug(f"Processing {len(category_jobs)} {category} translations") + + # Check if we can use optimized batch translation for API calls + can_use_api_batch = (self.translation_mode in [TranslationMode.API_ONLY, TranslationMode.HYBRID] and + len(category_jobs) > 3) + + if can_use_api_batch: + # Try optimized batch translation with API + batch_results = self._process_category_batch_optimized(category_jobs, completed, total_jobs, start_time) + if batch_results: + # Apply results to Blender objects if requested + for i, (job, result) in enumerate(zip(category_jobs, batch_results)): + if apply_results and result.method != "failed" and job.object_ref: + try: + self._apply_translation_to_object(job, result) + logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}") + except Exception as e: + logger.error(f"Failed to apply translation to object {job.name}: {e}") + result.method = "apply_failed" + result.translated = job.name + + results.extend(batch_results) + completed += len(category_jobs) + + progress_percent = (completed / total_jobs) * 100 + logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%) - completed {category} batch") + continue + + # Fallback to individual processing + for job in category_jobs: + # Check if we should continue (for potential cancellation support) + current_time = time.time() + elapsed_time = current_time - start_time + + # Progress callback with detailed status + if self._progress_callback: + avg_time_per_item = elapsed_time / max(completed, 1) + remaining_items = total_jobs - completed + estimated_remaining = avg_time_per_item * remaining_items + + status_msg = f"Translating {job.name}" + if completed > 0: + status_msg += f" (ETA: {estimated_remaining:.1f}s)" + + self._progress_callback(completed, total_jobs, status_msg) + + try: + logger.debug(f"Translating job {completed + 1}/{total_jobs}: {job.name} ({job.category})") + + result = self.translate_single(job.name, job.category, + job.source_lang, job.target_lang) + + if apply_results and result.method != "failed" and job.object_ref: + try: + self._apply_translation_to_object(job, result) + logger.debug(f"Successfully applied translation: {job.name} -> {result.translated}") + except Exception as e: + logger.error(f"Failed to apply translation to object {job.name}: {e}") + result.method = "apply_failed" + result.translated = job.name + + results.append(result) + + except Exception as e: + logger.error(f"Translation failed for job {job.name}: {e}") + # Create a failed result + failed_result = TranslationResult( + original=job.name, + translated=job.name, + method="failed", + category=job.category + ) + results.append(failed_result) + + completed += 1 + + # Log progress periodically + if completed % 10 == 0 or completed == total_jobs: + progress_percent = (completed / total_jobs) * 100 + logger.info(f"Batch translation progress: {completed}/{total_jobs} ({progress_percent:.1f}%)") + + if self._progress_callback: + total_time = time.time() - start_time + self._progress_callback(total_jobs, total_jobs, f"Translation complete ({total_time:.1f}s)") + + successful = sum(1 for r in results if r.method not in ["failed", "skipped", "apply_failed"]) + failed = sum(1 for r in results if r.method in ["failed", "apply_failed"]) + skipped = sum(1 for r in results if r.method == "skipped") + + dictionary_count = sum(1 for r in results if r.method == "dictionary") + api_count = sum(1 for r in results if r.method == "api") + cache_count = sum(1 for r in results if r.method == "cache") + + logger.info(f"Batch translation complete: {successful}/{total_jobs} successful, {failed} failed, {skipped} skipped") + logger.info(f"Translation methods used: Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}") + + return results + + def _process_category_batch_optimized(self, category_jobs: List[TranslationJob], + completed: int, total_jobs: int, start_time: float) -> Optional[List[TranslationResult]]: + """Process a batch of jobs from the same category using optimized API batch translation""" + if not category_jobs: + return [] + + logger.info(f"Starting optimized batch translation for {len(category_jobs)} {category_jobs[0].category} items") + + api_batch_jobs = [] + api_batch_texts = [] + results = [None] * len(category_jobs) + + # First pass: try dictionary translations and collect API candidates + for i, job in enumerate(category_jobs): + if not job.name or not job.name.strip(): + results[i] = TranslationResult(job.name, job.name, "skipped", category=job.category) + continue + + original_name = job.name.strip() + + # Check cache first + cached_result = self.cache.get(original_name, job.source_lang, job.target_lang) + if cached_result: + results[i] = TranslationResult(original_name, cached_result, "cache", category=job.category) + continue + + # Try dictionary translation first (if in hybrid mode) + if self.translation_mode == TranslationMode.HYBRID: + dict_result, detected_category = self.dictionary_translator.translate_name(original_name, job.category) + if dict_result: + self.cache.put(original_name, dict_result, job.source_lang, job.target_lang) + results[i] = TranslationResult(original_name, dict_result, "dictionary", + category=detected_category, confidence=1.0) + continue + + # Add to API batch candidates + api_batch_jobs.append((i, job)) + api_batch_texts.append(original_name) + + # Process API batch if we have candidates + if api_batch_texts: + logger.info(f"Sending {len(api_batch_texts)} items to API batch translation") + + if self._progress_callback: + elapsed_time = time.time() - start_time + avg_time_per_item = elapsed_time / max(completed, 1) if completed > 0 else 1.0 + remaining_items = total_jobs - completed + estimated_remaining = avg_time_per_item * remaining_items + + status_msg = f"Batch translating {len(api_batch_texts)} {category_jobs[0].category} items" + if completed > 0: + status_msg += f" (ETA: {estimated_remaining:.1f}s)" + + self._progress_callback(completed, total_jobs, status_msg) + + try: + # Use the service manager's optimized batch translation + if len(set(job.source_lang for _, job in api_batch_jobs)) == 1 and len(set(job.target_lang for _, job in api_batch_jobs)) == 1: + source_lang = api_batch_jobs[0][1].source_lang + target_lang = api_batch_jobs[0][1].target_lang + + batch_results = self.service_manager.batch_translate_with_fallback( + api_batch_texts, source_lang, target_lang + ) + + for j, (result_idx, job) in enumerate(api_batch_jobs): + if j < len(batch_results): + translated_text, service_name = batch_results[j] + + # Cache successful translations + if translated_text != job.name: + self.cache.put(job.name.strip(), translated_text, job.source_lang, job.target_lang) + + results[result_idx] = TranslationResult( + original=job.name.strip(), + translated=translated_text, + method="api" if translated_text != job.name else "failed", + service=service_name, + category=job.category, + confidence=0.8 + ) + else: + # Fallback for missing results + results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category) + else: + # Mixed language pairs - fallback to individual translations + logger.info("Mixed language pairs detected, falling back to individual API translations") + for result_idx, job in api_batch_jobs: + try: + result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang) + results[result_idx] = result + except Exception as e: + logger.error(f"Individual API translation failed for {job.name}: {e}") + results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category) + + except Exception as e: + logger.error(f"Batch API translation failed: {e}") + # Fallback to individual translations + for result_idx, job in api_batch_jobs: + try: + result = self.translate_single(job.name, job.category, job.source_lang, job.target_lang) + results[result_idx] = result + except Exception as individual_e: + logger.error(f"Individual fallback translation failed for {job.name}: {individual_e}") + results[result_idx] = TranslationResult(job.name, job.name, "failed", category=job.category) + + for i, result in enumerate(results): + if result is None: + results[i] = TranslationResult(category_jobs[i].name, category_jobs[i].name, "failed", category=category_jobs[i].category) + + successful_batch = sum(1 for r in results if r.method not in ["failed", "skipped"]) + logger.info(f"Optimized batch complete: {successful_batch}/{len(category_jobs)} successful") + + return results + + def _apply_translation_to_object(self, job: TranslationJob, result: TranslationResult) -> None: + """Apply translation result to a Blender object""" + if not job.object_ref or not job.property_name: + return + + try: + setattr(job.object_ref, job.property_name, result.translated) + logger.debug(f"Applied translation: {job.object_ref.name}.{job.property_name} = '{result.translated}'") + except Exception as e: + logger.error(f"Failed to set property {job.property_name}: {e}") + raise + + def translate_armature_bones(self, armature: Object, apply_results: bool = True) -> List[TranslationResult]: + """Translate all bone names in an armature""" + if not armature or armature.type != 'ARMATURE': + return [] + + jobs = [] + for bone in armature.data.bones: + jobs.append(TranslationJob( + name=bone.name, + category="bones", + object_ref=bone, + property_name="name" + )) + + return self.translate_batch(jobs, apply_results) + + def translate_object_shapekeys(self, mesh_obj: Object, apply_results: bool = True) -> List[TranslationResult]: + """Translate all shape key names in a mesh object""" + if not mesh_obj or mesh_obj.type != 'MESH' or not mesh_obj.data.shape_keys: + return [] + + jobs = [] + for shape_key in mesh_obj.data.shape_keys.key_blocks: + jobs.append(TranslationJob( + name=shape_key.name, + category="shapekeys", + object_ref=shape_key, + property_name="name" + )) + + return self.translate_batch(jobs, apply_results) + + def translate_scene_materials(self, apply_results: bool = True) -> List[TranslationResult]: + """Translate all material names in the scene""" + jobs = [] + processed_materials: Set[str] = set() + + for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.data.materials: + for material in obj.data.materials: + if material and material.name not in processed_materials: + jobs.append(TranslationJob( + name=material.name, + category="materials", + object_ref=material, + property_name="name" + )) + processed_materials.add(material.name) + + return self.translate_batch(jobs, apply_results) + + def translate_scene_objects(self, object_types: Optional[Set[str]] = None, + apply_results: bool = True) -> List[TranslationResult]: + """Translate all object names in the scene""" + if object_types is None: + object_types = {'MESH', 'ARMATURE', 'EMPTY'} + + jobs = [] + for obj in bpy.data.objects: + if obj.type in object_types: + jobs.append(TranslationJob( + name=obj.name, + category="objects", + object_ref=obj, + property_name="name" + )) + + return self.translate_batch(jobs, apply_results) + + def get_translation_stats(self) -> Dict[str, Any]: + """Get comprehensive translation statistics""" + dict_stats = self.dictionary_translator.get_statistics() + cache_stats = self.cache.get_stats() + available_services = self.service_manager.get_available_services() + + return { + "dictionary_translations": dict_stats, + "cache_stats": cache_stats, + "available_services": available_services, + "current_mode": self.translation_mode.value, + "primary_service": get_preference("translation_service", "microsoft") + } + + def clear_all_caches(self) -> None: + """Clear all translation caches""" + self.cache.clear() + for service_id, service in self.service_manager._services.items(): + service.clear_cache() + logger.info("All translation caches cleared") + + +_translation_manager: Optional[AvatarToolkitTranslationManager] = None + + +def get_avatar_translation_manager() -> AvatarToolkitTranslationManager: + """Get the global Avatar Toolkit translation manager""" + global _translation_manager + if _translation_manager is None: + _translation_manager = AvatarToolkitTranslationManager() + return _translation_manager + + +def translate_name_simple(name: str, category: str = "auto") -> str: + """Simple translation function for quick use""" + manager = get_avatar_translation_manager() + result = manager.translate_single(name, category) + return result.translated + + +def is_translation_service_available(service_name: str) -> bool: + """Check if a specific translation service is available""" + manager = get_avatar_translation_manager() + available_services = manager.service_manager.get_available_services() + return any(service_id == service_name for service_id, _ in available_services) + + +def get_available_translation_services() -> List[Tuple[str, str]]: + """Get list of available translation services""" + manager = get_avatar_translation_manager() + return manager.service_manager.get_available_services() + + +def get_batch_translation_info() -> Dict[str, Dict[str, Any]]: + """Get information about batch translation capabilities of available services""" + manager = get_avatar_translation_manager() + batch_info = {} + + for service_id, service_name in manager.service_manager.get_available_services(): + service = manager.service_manager.get_service(service_id) + if service: + batch_info[service_id] = { + 'name': service_name, + 'supports_batch': service.supports_batch_translation(), + 'batch_type': 'native' if service_id == 'deepl' else 'concurrent' if service_id in ['libretranslate', 'mymemory'] else 'individual' + } + + return batch_info + + +def configure_translation_service(service_id: str, **config) -> bool: + """Configure a translation service with the provided settings (now with batch support)""" + try: + success = False + if service_id == "deepl": + from .translation_service import configure_deepl_translator + success = configure_deepl_translator( + config.get("api_key", ""), + config.get("use_free_api", True) + ) + if success: + logger.info("DeepL configured with native batch translation support (up to 50 texts per request)") + elif service_id == "libretranslate": + from .translation_service import configure_libretranslate_server + success = configure_libretranslate_server( + config.get("server_url", "https://libretranslate.com"), + config.get("api_key", None) + ) + if success: + logger.info("LibreTranslate configured with concurrent batch processing (3x faster)") + elif service_id == "microsoft": + from .translation_service import configure_microsoft_translator + success = configure_microsoft_translator( + config.get("api_key", ""), + config.get("region", "global") + ) + + else: + logger.error(f"Unknown translation service: {service_id}") + success = False + + return success + except Exception as e: + logger.error(f"Failed to configure translation service {service_id}: {e}") + return False \ No newline at end of file diff --git a/core/translation_service.py b/core/translation_service.py new file mode 100644 index 0000000..d7edb19 --- /dev/null +++ b/core/translation_service.py @@ -0,0 +1,942 @@ +# GPL License + +import json +import time +import requests +import threading +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple, Any, Set +from dataclasses import dataclass +from urllib.parse import urlencode +import uuid + +from .logging_setup import logger +from .addon_preferences import save_preference, get_preference + + +@dataclass +class TranslationRequest: + """Represents a translation request""" + text: str + source_lang: str = "ja" + target_lang: str = "en" + category: str = "general" + + +@dataclass +class TranslationResult: + """Represents a translation result""" + original: str + translated: str + service: str + confidence: float = 1.0 + cached: bool = False + + +class TranslationError(Exception): + """Custom exception for translation errors""" + pass + + +class TranslationService(ABC): + """Abstract base class for translation services""" + + def __init__(self, name: str): + self.name = name + self._cache: Dict[str, str] = {} + self._rate_limit_lock = threading.Lock() + self._last_request_time = 0.0 + self._request_count = 0 + self._rate_limit_per_second = 10 # Default rate limit + + @abstractmethod + def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str: + """Translate a single text string""" + pass + + @abstractmethod + def is_available(self) -> bool: + """Check if the service is available""" + pass + + @abstractmethod + def get_supported_languages(self) -> List[Tuple[str, str]]: + """Get list of supported language pairs (code, name)""" + pass + + def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]: + """Translate multiple texts with rate limiting - base implementation for services without native batch support""" + results = [] + for text in texts: + # Check cache first + cache_key = f"{source_lang}_{target_lang}_{text}" + if cache_key in self._cache: + results.append(self._cache[cache_key]) + continue + + # Rate limiting + with self._rate_limit_lock: + current_time = time.time() + if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second): + time.sleep((1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time)) + + try: + translated = self.translate_text(text, source_lang, target_lang) + self._cache[cache_key] = translated + results.append(translated) + self._last_request_time = time.time() + + except Exception as e: + logger.warning(f"Translation failed for '{text}': {e}") + results.append(text) + + return results + + def supports_batch_translation(self) -> bool: + """Check if service supports native batch translation""" + return False + + def clear_cache(self) -> None: + """Clear the translation cache""" + self._cache.clear() + logger.info(f"Cleared cache for {self.name}") + + + + +class DeepLService(TranslationService): + """DeepL translation service - requires API key""" + + def __init__(self, api_key: str = "", use_free_api: bool = True): + super().__init__("DeepL" + (" (Free)" if use_free_api else " (Pro)")) + self.api_key = api_key + self.use_free_api = use_free_api + self._rate_limit_per_second = 5 # DeepL allows more requests + self._base_url = "https://api-free.deepl.com" if use_free_api else "https://api.deepl.com" + + def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str: + """Translate text using DeepL API""" + logger.info(f"DeepL: Starting translation of '{text}' from {source_lang} to {target_lang}") + + if not text or not text.strip(): + logger.debug("Empty text provided, returning as-is") + return text + + if not self.api_key: + raise TranslationError("DeepL API key is required") + + # DeepL language codes mapping + lang_map = { + "ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH", + "es": "ES", "fr": "FR", "de": "DE", "it": "IT", + "pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL" + } + source_lang = lang_map.get(source_lang, source_lang.upper()) + target_lang = lang_map.get(target_lang, target_lang.upper()) + + endpoint = f"{self._base_url}/v2/translate" + headers = { + "Authorization": f"DeepL-Auth-Key {self.api_key}", + "Content-Type": "application/x-www-form-urlencoded" + } + + data = { + "text": text, + "source_lang": source_lang, + "target_lang": target_lang + } + + try: + logger.debug(f"Making request to DeepL API: {endpoint}") + response = requests.post(endpoint, headers=headers, data=data, timeout=15) + logger.debug(f"DeepL response status: {response.status_code}") + response.raise_for_status() + + result = response.json() + logger.debug(f"DeepL response: {result}") + + if "translations" in result and len(result["translations"]) > 0: + translated_text = result["translations"][0]["text"] + logger.info(f"DeepL SUCCESS: '{text}' -> '{translated_text}'") + return translated_text + else: + raise TranslationError("DeepL API returned no translations") + + except requests.HTTPError as e: + if e.response.status_code == 401: + raise TranslationError("DeepL API key is invalid") + elif e.response.status_code == 403: + raise TranslationError("DeepL API key access denied or quota exceeded") + elif e.response.status_code == 456: + raise TranslationError("DeepL quota exceeded") + else: + logger.error(f"DeepL HTTP error: {e}") + raise TranslationError(f"DeepL API error: {e}") + except requests.Timeout: + logger.error("DeepL request timed out") + raise TranslationError("DeepL request timed out after 15 seconds") + except requests.RequestException as e: + logger.error(f"DeepL API request failed: {e}") + raise TranslationError(f"DeepL API request failed: {e}") + except Exception as e: + logger.error(f"Unexpected error in DeepL: {e}") + raise TranslationError(f"Unexpected error: {e}") + + def is_available(self) -> bool: + """Check if DeepL service is available""" + if not self.api_key: + return False + + try: + headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"} + response = requests.get(f"{self._base_url}/v2/usage", headers=headers, timeout=5) + return response.status_code == 200 + except: + return False + + def get_supported_languages(self) -> List[Tuple[str, str]]: + """Get supported languages for DeepL""" + return [ + ("ja", "Japanese"), + ("en", "English"), + ("ko", "Korean"), + ("zh", "Chinese"), + ("es", "Spanish"), + ("fr", "French"), + ("de", "German"), + ("it", "Italian"), + ("pt", "Portuguese"), + ("ru", "Russian"), + ("nl", "Dutch"), + ("pl", "Polish") + ] + + def supports_batch_translation(self) -> bool: + """DeepL supports native batch translation""" + return True + + def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]: + """Translate multiple texts using DeepL batch API""" + if not texts: + return [] + + logger.info(f"DeepL: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}") + + results = [None] * len(texts) + uncached_indices = [] + uncached_texts = [] + + for i, text in enumerate(texts): + if not text or not text.strip(): + results[i] = text + continue + + cache_key = f"{source_lang}_{target_lang}_{text}" + if cache_key in self._cache: + results[i] = self._cache[cache_key] + continue + + uncached_indices.append(i) + uncached_texts.append(text) + + if not uncached_texts: + logger.info(f"DeepL: All {len(texts)} texts found in cache") + return results + + logger.info(f"DeepL: Translating {len(uncached_texts)} uncached texts") + + if not self.api_key: + logger.error("DeepL API key is required for batch translation") + for i, idx in enumerate(uncached_indices): + results[idx] = texts[idx] + return results + + # DeepL language codes mapping + lang_map = { + "ja": "JA", "en": "EN", "ko": "KO", "zh": "ZH", + "es": "ES", "fr": "FR", "de": "DE", "it": "IT", + "pt": "PT", "ru": "RU", "nl": "NL", "pl": "PL" + } + source_lang_code = lang_map.get(source_lang, source_lang.upper()) + target_lang_code = lang_map.get(target_lang, target_lang.upper()) + + # Batch size limit for DeepL + batch_size = 50 + + for batch_start in range(0, len(uncached_texts), batch_size): + batch_end = min(batch_start + batch_size, len(uncached_texts)) + batch_texts = uncached_texts[batch_start:batch_end] + batch_indices = uncached_indices[batch_start:batch_end] + + logger.debug(f"DeepL batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts") + + endpoint = f"{self._base_url}/v2/translate" + headers = { + "Authorization": f"DeepL-Auth-Key {self.api_key}", + "Content-Type": "application/x-www-form-urlencoded" + } + + # Build form data with multiple text parameters (DeepL supports multiple 'text' params) + form_data = [ + ('source_lang', source_lang_code), + ('target_lang', target_lang_code) + ] + for text in batch_texts: + form_data.append(('text', text)) + + try: + logger.debug(f"Making batch request to DeepL API: {endpoint}") + + import requests + response = requests.post(endpoint, headers=headers, data=form_data, timeout=30) + logger.debug(f"DeepL batch response status: {response.status_code}") + response.raise_for_status() + + result = response.json() + logger.debug(f"DeepL batch response: {result}") + + if "translations" in result and len(result["translations"]) == len(batch_texts): + for i, translation_data in enumerate(result["translations"]): + original_text = batch_texts[i] + translated_text = translation_data["text"] + original_idx = batch_indices[i] + + cache_key = f"{source_lang}_{target_lang}_{original_text}" + self._cache[cache_key] = translated_text + + results[original_idx] = translated_text + logger.debug(f"DeepL batch SUCCESS: '{original_text}' -> '{translated_text}'") + else: + logger.error(f"DeepL batch API returned unexpected response: {result}") + for i, idx in enumerate(batch_indices): + results[idx] = batch_texts[i] + + # Rate limiting between batches + if batch_end < len(uncached_texts): + time.sleep(1.0 / self._rate_limit_per_second) + + except Exception as e: + logger.error(f"DeepL batch translation failed: {e}") + for i, idx in enumerate(batch_indices): + results[idx] = batch_texts[i] + + # Ensure all results are filled + for i, result in enumerate(results): + if result is None: + results[i] = texts[i] + + successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i]) + logger.info(f"DeepL batch translation complete: {successful_translations}/{len(texts)} successfully translated") + + return results + + +class MyMemoryService(TranslationService): + """MyMemory free translation service - no API key required""" + + def __init__(self): + super().__init__("MyMemory (Free)") + self._rate_limit_per_second = 1 # Conservative rate limiting for free service + self._base_url = "https://api.mymemory.translated.net" + + def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str: + """Translate text using MyMemory free API""" + logger.info(f"MyMemory: Starting translation of '{text}' from {source_lang} to {target_lang}") + + if not text or not text.strip(): + logger.debug("Empty text provided, returning as-is") + return text + + # MyMemory uses different language codes + lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de"} + source_lang = lang_map.get(source_lang, source_lang) + target_lang = lang_map.get(target_lang, target_lang) + + endpoint = f"{self._base_url}/get" + params = { + 'q': text, + 'langpair': f"{source_lang}|{target_lang}", + 'de': 'neoneko@avatartoolkit.com' # Optional email for higher quotas + } + + try: + logger.debug(f"Making request to MyMemory API: {endpoint} with params: {params}") + response = requests.get(endpoint, params=params, timeout=15) # Increased timeout + logger.debug(f"MyMemory response status: {response.status_code}") + response.raise_for_status() + + result = response.json() + logger.debug(f"MyMemory response: {result}") + + if result.get('responseStatus') == 200 and 'responseData' in result: + translated_text = result['responseData']['translatedText'] + matches = result.get('matches', []) + if matches and len(matches) > 0: + match_quality = matches[0].get('quality', '0') + logger.debug(f"MyMemory translation quality: {match_quality}") + + logger.info(f"MyMemory SUCCESS: '{text}' -> '{translated_text}'") + return translated_text + else: + error_msg = result.get('responseDetails', 'Unknown error') + logger.error(f"MyMemory API error: {error_msg}") + + if 'QUOTA_EXCEEDED' in error_msg: + raise TranslationError(f"MyMemory daily quota (1000 requests) exceeded. Try again tomorrow or switch to another service.") + else: + raise TranslationError(f"MyMemory API error: {error_msg}") + + except requests.Timeout as e: + logger.error(f"MyMemory request timed out: {e}") + raise TranslationError(f"MyMemory request timed out after 15 seconds") + except requests.RequestException as e: + logger.error(f"MyMemory API request failed: {e}") + raise TranslationError(f"MyMemory API request failed: {e}") + except Exception as e: + logger.error(f"Unexpected error in MyMemory: {e}") + raise TranslationError(f"Unexpected error: {e}") + + def is_available(self) -> bool: + """Check if MyMemory service is available""" + try: + response = requests.get(f"{self._base_url}/get", + params={'q': 'test', 'langpair': 'en|en'}, + timeout=5) + return response.status_code == 200 + except: + return False + + def get_supported_languages(self) -> List[Tuple[str, str]]: + """Get supported languages for MyMemory""" + return [ + ("ja", "Japanese"), + ("en", "English"), + ("ko", "Korean"), + ("zh", "Chinese"), + ("es", "Spanish"), + ("fr", "French"), + ("de", "German"), + ("it", "Italian"), + ("pt", "Portuguese"), + ("ru", "Russian") + ] + + def supports_batch_translation(self) -> bool: + """MyMemory optimized batch processing""" + return True + + def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]: + """Translate multiple texts using MyMemory with optimized batching and caching""" + if not texts: + return [] + + logger.info(f"MyMemory: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}") + + results = [None] * len(texts) + uncached_indices = [] + uncached_texts = [] + + for i, text in enumerate(texts): + if not text or not text.strip(): + results[i] = text + continue + + cache_key = f"{source_lang}_{target_lang}_{text}" + if cache_key in self._cache: + results[i] = self._cache[cache_key] + continue + + uncached_indices.append(i) + uncached_texts.append(text) + + if not uncached_texts: + logger.info(f"MyMemory: All {len(texts)} texts found in cache") + return results + + logger.info(f"MyMemory: Translating {len(uncached_texts)} uncached texts using concurrent processing") + + # Use concurrent processing for MyMemory to speed up translations + import concurrent.futures + from concurrent.futures import ThreadPoolExecutor + import threading + + def translate_single_text(text_info): + idx, text = text_info + try: + with self._rate_limit_lock: + current_time = time.time() + if current_time - self._last_request_time < (1.0 / self._rate_limit_per_second): + sleep_time = (1.0 / self._rate_limit_per_second) - (current_time - self._last_request_time) + time.sleep(sleep_time) + self._last_request_time = time.time() + + translated = self.translate_text(text, source_lang, target_lang) + + cache_key = f"{source_lang}_{target_lang}_{text}" + self._cache[cache_key] = translated + + return idx, translated, None + + except Exception as e: + logger.warning(f"MyMemory concurrent translation failed for '{text}': {e}") + return idx, text, e + + # Use conservative concurrent processing (2 workers max for free service) + max_workers = min(len(uncached_texts), 2) + batch_size = 8 + + for batch_start in range(0, len(uncached_texts), batch_size): + batch_end = min(batch_start + batch_size, len(uncached_texts)) + batch_texts = uncached_texts[batch_start:batch_end] + batch_indices = uncached_indices[batch_start:batch_end] + + text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)] + + logger.debug(f"MyMemory concurrent batch {batch_start//batch_size + 1}: Processing {len(batch_texts)} texts with {max_workers} workers") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch} + + for future in concurrent.futures.as_completed(future_to_text): + try: + original_idx, translated_text, error = future.result(timeout=25) + results[original_idx] = translated_text + + if error is None: + logger.debug(f"MyMemory concurrent SUCCESS: -> '{translated_text}'") + else: + logger.debug(f"MyMemory concurrent FAILED: {error}") + + except concurrent.futures.TimeoutError: + text_info = future_to_text[future] + original_idx, original_text = text_info + results[original_idx] = original_text + logger.warning(f"MyMemory concurrent timeout for text: '{original_text}'") + except Exception as e: + text_info = future_to_text[future] + original_idx, original_text = text_info + results[original_idx] = original_text + logger.error(f"MyMemory concurrent thread error for '{original_text}': {e}") + + # Shorter pause between batches since we're not hammering the API + if batch_end < len(uncached_texts): + time.sleep(0.5) + + for i, result in enumerate(results): + if result is None: + results[i] = texts[i] + + successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i]) + logger.info(f"MyMemory concurrent batch translation complete: {successful_translations}/{len(texts)} successfully translated") + + return results + + +class LibreTranslateService(TranslationService): + """LibreTranslate translation service with configurable server""" + + def __init__(self, api_url: str = "https://libretranslate.com", api_key: str = None): + super().__init__("LibreTranslate") + # Ensure URL has trailing slash like official implementation + self.api_url = api_url.rstrip('/') + '/' + self.api_key = api_key + self._rate_limit_per_second = 2 # Conservative rate limiting + self._is_paid_service = "libretranslate.com" in api_url.lower() + + def translate_text(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> str: + """Translate text using LibreTranslate API""" + logger.info(f"LibreTranslate: Starting translation of '{text}' from {source_lang} to {target_lang}") + + if not text or not text.strip(): + logger.debug("Empty text provided, returning as-is") + return text + + lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"} + source_lang = lang_map.get(source_lang, source_lang) + target_lang = lang_map.get(target_lang, target_lang) + + endpoint = f"{self.api_url}translate" + data = { + "q": text, + "source": source_lang, + "target": target_lang + } + # Add API key if available (required for libretranslate.com, optional for self-hosted) + if self.api_key: + data["api_key"] = self.api_key + + headers = { + "Content-Type": "application/json" + } + + try: + logger.debug(f"Making request to LibreTranslate API: {endpoint}") + # Use JSON format like official API documentation + response = requests.post(endpoint, json=data, headers=headers, timeout=15) + logger.debug(f"LibreTranslate response status: {response.status_code}") + response.raise_for_status() + + result = response.json() + logger.debug(f"LibreTranslate response: {result}") + + if "translatedText" in result: + translated_text = result["translatedText"] + logger.info(f"LibreTranslate SUCCESS: '{text}' -> '{translated_text}'") + return translated_text + else: + raise TranslationError("LibreTranslate API returned no translation") + + except requests.HTTPError as e: + if e.response.status_code == 429: + raise TranslationError("LibreTranslate rate limit exceeded") + elif e.response.status_code == 400: + raise TranslationError("LibreTranslate: Invalid language pair or text") + else: + logger.error(f"LibreTranslate HTTP error: {e}") + raise TranslationError(f"LibreTranslate API error: {e}") + except requests.Timeout: + logger.error("LibreTranslate request timed out") + raise TranslationError("LibreTranslate request timed out after 15 seconds") + except requests.RequestException as e: + logger.error(f"LibreTranslate API request failed: {e}") + raise TranslationError(f"LibreTranslate API request failed: {e}") + except Exception as e: + logger.error(f"Unexpected error in LibreTranslate: {e}") + raise TranslationError(f"Unexpected error: {e}") + + def is_available(self) -> bool: + """Check if LibreTranslate service is available""" + try: + endpoint = f"{self.api_url}languages" + + params = {} + if self.api_key: + params["api_key"] = self.api_key + + response = requests.get(endpoint, params=params if params else None, timeout=5) + return response.status_code == 200 + except: + return False + + def get_supported_languages(self) -> List[Tuple[str, str]]: + """Get supported languages for LibreTranslate""" + try: + endpoint = f"{self.api_url}languages" + + params = {} + if self.api_key: + params["api_key"] = self.api_key + + response = requests.get(endpoint, params=params if params else None, timeout=5) + + if response.status_code == 200: + languages = response.json() + return [(lang["code"], lang["name"]) for lang in languages] + except: + pass + + # Fallback to common languages + return [ + ("ja", "Japanese"), + ("en", "English"), + ("ko", "Korean"), + ("zh", "Chinese"), + ("es", "Spanish"), + ("fr", "French"), + ("de", "German"), + ("it", "Italian"), + ("pt", "Portuguese"), + ("ru", "Russian") + ] + + def supports_batch_translation(self) -> bool: + """LibreTranslate optimized batch processing (concurrent requests)""" + return True + + def batch_translate(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[str]: + """Translate multiple texts using LibreTranslate with optimized concurrent requests""" + if not texts: + return [] + + logger.info(f"LibreTranslate: Starting batch translation of {len(texts)} texts from {source_lang} to {target_lang}") + + # Check cache and separate cached vs uncached texts + results = [None] * len(texts) + uncached_indices = [] + uncached_texts = [] + + for i, text in enumerate(texts): + if not text or not text.strip(): + results[i] = text + continue + + cache_key = f"{source_lang}_{target_lang}_{text}" + if cache_key in self._cache: + results[i] = self._cache[cache_key] + continue + + uncached_indices.append(i) + uncached_texts.append(text) + + if not uncached_texts: + logger.info(f"LibreTranslate: All {len(texts)} texts found in cache") + return results + + logger.info(f"LibreTranslate: Translating {len(uncached_texts)} uncached texts") + + # LibreTranslate language mapping + lang_map = {"ja": "ja", "en": "en", "ko": "ko", "zh": "zh", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt", "ru": "ru"} + source_lang_code = lang_map.get(source_lang, source_lang) + target_lang_code = lang_map.get(target_lang, target_lang) + + # Batch process in groups to avoid overwhelming the server + import concurrent.futures + from concurrent.futures import ThreadPoolExecutor + + def translate_single_text(text_info): + idx, text = text_info + try: + translated = self.translate_text(text, source_lang, target_lang) + cache_key = f"{source_lang}_{target_lang}_{text}" + self._cache[cache_key] = translated + return idx, translated, None + except Exception as e: + logger.warning(f"LibreTranslate translation failed for '{text}': {e}") + return idx, text, e + + # Use thread pool for concurrent requests (limited to avoid server overload) + max_workers = min(len(uncached_texts), 3) + batch_size = 10 # Process in smaller batches + + for batch_start in range(0, len(uncached_texts), batch_size): + batch_end = min(batch_start + batch_size, len(uncached_texts)) + batch_texts = uncached_texts[batch_start:batch_end] + batch_indices = uncached_indices[batch_start:batch_end] + + text_info_batch = [(batch_indices[i], text) for i, text in enumerate(batch_texts)] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_text = {executor.submit(translate_single_text, text_info): text_info for text_info in text_info_batch} + + for future in concurrent.futures.as_completed(future_to_text): + try: + original_idx, translated_text, error = future.result(timeout=30) + results[original_idx] = translated_text + + if error is None: + logger.debug(f"LibreTranslate SUCCESS: -> '{translated_text}'") + else: + logger.debug(f"LibreTranslate FAILED: {error}") + + except concurrent.futures.TimeoutError: + text_info = future_to_text[future] + original_idx, original_text = text_info + results[original_idx] = original_text + logger.warning(f"LibreTranslate timeout for text: '{original_text}'") + except Exception as e: + text_info = future_to_text[future] + original_idx, original_text = text_info + results[original_idx] = original_text + logger.error(f"LibreTranslate thread error for '{original_text}': {e}") + + if batch_end < len(uncached_texts): + time.sleep(0.5) + + for i, result in enumerate(results): + if result is None: + results[i] = texts[i] + + successful_translations = sum(1 for i, result in enumerate(results) if result != texts[i]) + logger.info(f"LibreTranslate batch translation complete: {successful_translations}/{len(texts)} successfully translated") + + return results + + +class TranslationServiceManager: + """Manages multiple translation services with fallback logic""" + + def __init__(self): + self._services: Dict[str, TranslationService] = {} + self._primary_service: Optional[str] = None + self._initialize_services() + + def _initialize_services(self): + """Initialize available translation services""" + mymemory = MyMemoryService() + self._services["mymemory"] = mymemory + + libretranslate_url = get_preference("libretranslate_url", "https://libretranslate.com") + libretranslate_api_key = get_preference("libretranslate_api_key", "") + libretranslate = LibreTranslateService(api_url=libretranslate_url, api_key=libretranslate_api_key if libretranslate_api_key else None) + self._services["libretranslate"] = libretranslate + + deepl_api_key = get_preference("deepl_api_key", "") + if deepl_api_key: + deepl = DeepLService(api_key=deepl_api_key, use_free_api=True) + self._services["deepl"] = deepl + + # Set primary service from preferences (default to free service) + self._primary_service = get_preference("translation_service", "mymemory") + + logger.info(f"Initialized translation services: {list(self._services.keys())}") + logger.info(f"Primary service: {self._primary_service}") + + def get_available_services(self) -> List[Tuple[str, str]]: + """Get list of available translation services""" + available = [] + for service_id, service in self._services.items(): + if service.is_available(): + available.append((service_id, service.name)) + else: + logger.debug(f"Service {service.name} is not available") + return available + + def set_primary_service(self, service_id: str) -> bool: + """Set the primary translation service""" + if service_id in self._services: + self._primary_service = service_id + save_preference("translation_service", service_id) + logger.info(f"Set primary translation service to: {service_id}") + return True + return False + + def get_service(self, service_id: Optional[str] = None) -> Optional[TranslationService]: + """Get a translation service by ID""" + if service_id is None: + service_id = self._primary_service + + if service_id and service_id in self._services: + service = self._services[service_id] + if service.is_available(): + return service + + return None + + def translate_with_fallback(self, text: str, source_lang: str = "ja", target_lang: str = "en") -> Tuple[str, str]: + """Translate text with automatic fallback to other services""" + if not text or not text.strip(): + return text, "none" + + # Try primary service first + primary_service = self.get_service() + if primary_service: + try: + result = primary_service.translate_text(text, source_lang, target_lang) + return result, primary_service.name + except Exception as e: + logger.warning(f"Primary service {primary_service.name} failed: {e}") + + for service_id, service in self._services.items(): + if service_id == self._primary_service: + continue + + if service.is_available(): + try: + result = service.translate_text(text, source_lang, target_lang) + logger.info(f"Fallback to {service.name} successful") + return result, service.name + except Exception as e: + logger.warning(f"Fallback service {service.name} failed: {e}") + + logger.error(f"All translation services failed for: {text}") + return text, "failed" + + def batch_translate_with_fallback(self, texts: List[str], source_lang: str = "ja", target_lang: str = "en") -> List[Tuple[str, str]]: + """Batch translate with fallback - uses optimized batch processing when available""" + if not texts: + return [] + + logger.info(f"Starting batch translation of {len(texts)} texts using service manager") + + primary_service = self.get_service() + if primary_service: + try: + if primary_service.supports_batch_translation(): + logger.info(f"Using native batch translation with {primary_service.name}") + translations = primary_service.batch_translate(texts, source_lang, target_lang) + return [(translation, primary_service.name) for translation in translations] + else: + logger.info(f"Service {primary_service.name} does not support batch translation, using individual requests") + # Use the base implementation for services without batch support + translations = [] + for text in texts: + translated = primary_service.translate_text(text, source_lang, target_lang) + translations.append(translated) + return [(translation, primary_service.name) for translation in translations] + + except Exception as e: + logger.warning(f"Batch translation failed with {primary_service.name}: {e}") + + results = [] + for text in texts: + translation, service_name = self.translate_with_fallback(text, source_lang, target_lang) + results.append((translation, service_name)) + + return results + + +# Global translation service manager instance +_translation_manager: Optional[TranslationServiceManager] = None + + +def get_translation_manager() -> TranslationServiceManager: + """Get the global translation service manager""" + global _translation_manager + if _translation_manager is None: + _translation_manager = TranslationServiceManager() + return _translation_manager + + +def configure_deepl_translator(api_key: str, use_free_api: bool = True) -> bool: + """Configure DeepL translation service""" + try: + save_preference("deepl_api_key", api_key) + save_preference("deepl_use_free_api", use_free_api) + + # Test the API key + deepl = DeepLService(api_key=api_key, use_free_api=use_free_api) + if deepl.is_available(): + # Re-initialize the global manager to pick up new service + global _translation_manager + _translation_manager = None + logger.info("DeepL translator configured successfully") + return True + else: + logger.error("DeepL API key test failed") + return False + except Exception as e: + logger.error(f"Failed to configure DeepL translator: {e}") + return False + + +def configure_libretranslate_server(server_url: str, api_key: str = None) -> bool: + """Configure LibreTranslate server URL and optional API key""" + try: + if not server_url.strip(): + server_url = "https://libretranslate.com" + + # Ensure proper URL format + if not server_url.startswith(('http://', 'https://')): + server_url = 'https://' + server_url + + save_preference("libretranslate_url", server_url) + save_preference("libretranslate_api_key", api_key if api_key else "") + + # Test the server + libretranslate = LibreTranslateService(api_url=server_url, api_key=api_key) + if libretranslate.is_available(): + # Re-initialize the global manager to pick up new service + global _translation_manager + _translation_manager = None + logger.info(f"LibreTranslate server configured successfully: {server_url}") + return True + else: + logger.error(f"LibreTranslate server test failed: {server_url}") + return False + except Exception as e: + logger.error(f"Failed to configure LibreTranslate server: {e}") + return False + + + + diff --git a/core/updater.py b/core/updater.py index e1c30ec..068baeb 100644 --- a/core/updater.py +++ b/core/updater.py @@ -20,7 +20,7 @@ GITHUB_REPO = "teamneoneko/Avatar-Toolkit" # Define which version series this installation can update to # For example: ["0.1"] means only look for 0.1.x updates # ["0.2", "0.3"] would look for both 0.2.x and 0.3.x updates -ALLOWED_VERSION_SERIES = ["0.3"] +ALLOWED_VERSION_SERIES = ["0.4"] is_checking_for_update: bool = False update_needed: bool = False diff --git a/core/vrm_unity_converter.py b/core/vrm_unity_converter.py new file mode 100644 index 0000000..f3bacdf --- /dev/null +++ b/core/vrm_unity_converter.py @@ -0,0 +1,486 @@ +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 +from .translations import t + + +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 using dictionary lookup + """ + if vrm_simplified in reverse_bone_lookup: + standard_bone_key = reverse_bone_lookup[vrm_simplified] + + if standard_bone_key in standard_bones: + return standard_bones[standard_bone_key] + + return None + + +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/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index a171c35..f11d075 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -8,6 +8,7 @@ from ...core.translations import t import traceback from ...core.common import ( get_all_meshes, + get_meshes_for_armature, fix_zero_length_bones, remove_unused_vertex_groups, clear_unused_data_blocks, @@ -28,10 +29,32 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): @classmethod def poll(cls, context: Context) -> bool: - return len(get_all_meshes(context)) > 1 + # Check if we have valid armature selections for merging + base_armature_name: str = context.scene.avatar_toolkit.merge_armature_into + merge_armature_name: str = context.scene.avatar_toolkit.merge_armature + + if not base_armature_name or not merge_armature_name: + return False + + base_armature: Optional[Object] = bpy.data.objects.get(base_armature_name) + merge_armature: Optional[Object] = bpy.data.objects.get(merge_armature_name) + + return (base_armature is not None and + merge_armature is not None and + base_armature.type == 'ARMATURE' and + merge_armature.type == 'ARMATURE' and + base_armature != merge_armature) def execute(self, context: Context) -> Set[str]: try: + # Store original mode to restore later + original_mode: str = context.mode + logger.debug(f"Original mode: {original_mode}") + + # Switch to object mode if not already + if context.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + wm = context.window_manager wm.progress_begin(0, 100) @@ -48,6 +71,9 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): #Store current armature settings that can mess us up. data_breaking_base = store_breaking_settings_armature(base_armature) data_breaking_merge = store_breaking_settings_armature(merge_armature) + + # Store the merge armature name before it gets removed during join + merge_armature_name_stored = merge_armature.name # Remove Rigid Bodies and Joints delete_rigidbodies_and_joints(base_armature) @@ -77,14 +103,40 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): wm.progress_end() restore_breaking_settings_armature(base_armature, data_breaking_base) + if merge_armature_name_stored in bpy.data.objects: + merge_armature_obj = bpy.data.objects[merge_armature_name_stored] + restore_breaking_settings_armature(merge_armature_obj, data_breaking_merge) + + # Restore original mode if it wasn't OBJECT + try: + if original_mode == 'EDIT_ARMATURE': + bpy.ops.object.mode_set(mode='EDIT') + elif original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') + elif original_mode != 'OBJECT': + logger.debug(f"Restoring to original mode: {original_mode}") + # For other modes, stay in object mode as it's safest + except Exception: + logger.warning(f"Could not restore original mode: {original_mode}") self.report({'INFO'}, t('MergeArmature.success')) return {'FINISHED'} except Exception as e: - logger.error(f"Error merging armatures:", exception=e) + logger.error(f"Error merging armatures: {str(e)}\n{traceback.format_exc()}") self.report({'ERROR'}, traceback.format_exc()) + + # Try to restore original mode even on error + try: + if 'original_mode' in locals() and original_mode != 'OBJECT': + if original_mode == 'EDIT_ARMATURE': + bpy.ops.object.mode_set(mode='EDIT') + elif original_mode == 'POSE': + bpy.ops.object.mode_set(mode='POSE') + except Exception: + logger.warning("Could not restore mode after error") + return {'CANCELLED'} def delete_rigidbodies_and_joints(armature: Object) -> None: diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 667286d..b185dc3 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -186,7 +186,6 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): if not armature: return {'CANCELLED'} - # Store initial transforms bpy.ops.object.mode_set(mode='EDIT') initial_transforms: Dict[str, Dict[str, Any]] = {} data_breaking = store_breaking_settings_armature(armature) @@ -200,56 +199,61 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): 'parent': bone.parent.name if bone.parent else None } - # Get weighted bones + # Get bones with any weight weighted_bones: List[str] = [] meshes = get_all_meshes(context) - zero_weight_bones: List[str] = [] - for mesh in meshes: - mesh_data: Mesh = mesh.data - for vertex in mesh_data.vertices: + for vertex in mesh.data.vertices: for group in vertex.groups: if group.weight > context.scene.avatar_toolkit.merge_weights_threshold: - weighted_bones.append(mesh.vertex_groups[group.group].name) + vg = mesh.vertex_groups[group.group] + if vg.name not in weighted_bones: + weighted_bones.append(vg.name) - # Process bone removal - bpy.ops.object.mode_set(mode='EDIT') - armature_data: Armature = armature.data + armature_data = armature.data removed_count = 0 + zero_weight_bones: List[str] = [] - for bone in armature_data.edit_bones[:]: # Create a copy of the list - if (bone.name not in weighted_bones and - not self.should_preserve_bone(bone.name, context)): - - if context.scene.avatar_toolkit.list_only_mode: - zero_weight_bones.append(bone.name) - continue + def is_zero_weight_chain(bone, weighted_bones, preserve_check_fn): + if bone.name in weighted_bones or preserve_check_fn(bone.name, context): + return False + return all(is_zero_weight_chain(child, weighted_bones, preserve_check_fn) for child in bone.children) - # Store children data - children = bone.children - children_data = {child.name: initial_transforms[child.name] for child in children} + for bone in armature_data.edit_bones[:]: + if bone.name in weighted_bones or self.should_preserve_bone(bone.name, context): + continue - # Reparent children - for child in children: + if not is_zero_weight_chain(bone, weighted_bones, self.should_preserve_bone): + continue + + if context.scene.avatar_toolkit.list_only_mode: + zero_weight_bones.append(bone.name) + continue + + # Traverse and collect the full empty chain + stack = [bone] + chain = [] + + while stack: + b = stack.pop() + chain.append(b) + stack.extend(b.children) + + for b in reversed(chain): # Remove children before parents + for child in b.children: child.use_connect = False - if bone.parent: - child.parent = bone.parent - - # Remove bone - armature_data.edit_bones.remove(bone) - removed_count += 1 - - # Restore children positions - for child_name, data in children_data.items(): - if child_name in armature_data.edit_bones: - child = armature_data.edit_bones[child_name] - restore_bone_transforms(child, data) + if b.parent: + child.parent = b.parent + if b.name in armature_data.edit_bones: + armature_data.edit_bones.remove(b) + removed_count += 1 bpy.ops.object.mode_set(mode='OBJECT') - + if context.scene.avatar_toolkit.list_only_mode: self.populate_bone_list(context, zero_weight_bones) return {'FINISHED'} + restore_breaking_settings_armature(armature, data_breaking) self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) return {'FINISHED'} 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/general_mesh_tools.py b/functions/tools/general_mesh_tools.py index 5695f15..b43512c 100644 --- a/functions/tools/general_mesh_tools.py +++ b/functions/tools/general_mesh_tools.py @@ -119,8 +119,10 @@ class AvatarToolkit_OT_ExplodeMesh(Operator): @classmethod def poll(cls, context: Context) -> bool: - - return context.view_layer.objects.active.type == "MESH" and len(context.view_layer.objects.selected) == 1 + active_obj = context.view_layer.objects.active + return (active_obj is not None and + active_obj.type == "MESH" and + len(context.view_layer.objects.selected) == 1) diff --git a/functions/tools/rigify_converter.py b/functions/tools/rigify_converter.py index 5401f18..f15c7e4 100644 --- a/functions/tools/rigify_converter.py +++ b/functions/tools/rigify_converter.py @@ -2,7 +2,7 @@ import traceback import bpy from typing import Dict, List, Set, Optional, Tuple, Any from bpy.types import Operator, Context, Object, PoseBone, EditBone, Bone, Constraint -from ...core.common import get_active_armature +from ...core.common import get_active_armature, transfer_vertex_weights, get_all_meshes from ...core.logging_setup import logger from ...core.translations import t from ...core.dictionaries import rigify_unity_names, rigify_basic_unity_names, rigify_unnecessary_bones @@ -69,19 +69,50 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): # Set armature as active object before mode switch bpy.context.view_layer.objects.active = armature + + # Get all meshes for weight transfer + meshes = get_all_meshes(bpy.context) + bpy.ops.object.mode_set(mode='EDIT') bones_to_remove: List[str] = [] for bone in armature.data.edit_bones: - if any(pattern in bone.name.lower() for pattern in rigify_unnecessary_bones): + bone_name_lower = bone.name.lower() + if any(bone_name_lower.startswith(pattern) or bone_name_lower == pattern + for pattern in rigify_unnecessary_bones): bones_to_remove.append(bone.name) - + + # Check for neck bones that need merging + merge_neck_bones = 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones + + bpy.ops.object.mode_set(mode='OBJECT') + + # Transfer weights from bones being removed + for bone_name in bones_to_remove: + if bone_name in armature.data.bones: + logger.debug(f"Transferring weights from bone: {bone_name}") + for mesh in meshes: + if bone_name in mesh.vertex_groups: + # Remove the vertex group since we don't need the weights + mesh.vertex_groups.remove(mesh.vertex_groups[bone_name]) + + # Transfer weights for neck bone merging + if merge_neck_bones: + logger.debug("Transferring weights from spine.005 to spine.004") + for mesh in meshes: + if 'spine.005' in mesh.vertex_groups: + transfer_vertex_weights(mesh, 'spine.005', 'spine.004') + + bpy.ops.object.mode_set(mode='EDIT') + + # Remove unnecessary bones for bone_name in bones_to_remove: if bone_name in armature.data.edit_bones: logger.debug(f"Removing bone: {bone_name}") armature.data.edit_bones.remove(armature.data.edit_bones[bone_name]) - if 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones: + # Merge neck bones + if merge_neck_bones: logger.debug("Merging neck bones") neck_start = armature.data.edit_bones['spine.004'] neck_end = armature.data.edit_bones['spine.005'] @@ -89,6 +120,7 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): armature.data.edit_bones.remove(neck_end) neck_start.name = "Neck" + # Rename head bone if 'spine.006' in armature.data.edit_bones: logger.debug("Renaming head bone") head_bone = armature.data.edit_bones['spine.006'] @@ -137,6 +169,22 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): if bone_name in armature.data.bones: armature.data.bones[bone_name].use_deform = False + # Get all meshes for weight transfer + meshes = get_all_meshes(bpy.context) + + bpy.ops.object.mode_set(mode='OBJECT') + for bone_name in remove_bones_in_chain: + if bone_name in armature.data.bones: + parent_name = armature.data.bones[bone_name].parent.name if armature.data.bones[bone_name].parent else None + if parent_name: + logger.debug(f"Transferring weights from {bone_name} to {parent_name}") + for mesh in meshes: + if bone_name in mesh.vertex_groups and parent_name in mesh.vertex_groups: + transfer_vertex_weights(mesh, bone_name, parent_name) + elif bone_name in mesh.vertex_groups: + # Remove weights if no parent to merge to + mesh.vertex_groups.remove(mesh.vertex_groups[bone_name]) + bpy.ops.object.mode_set(mode='EDIT') for bone_name in remove_bones_in_chain: if bone_name in armature.data.bones: @@ -190,6 +238,17 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): ("DEF-thigh_twist.R", "DEF-thigh.R") ] + # Get all meshes for weight transfer + meshes = get_all_meshes(bpy.context) + + bpy.ops.object.mode_set(mode='OBJECT') + for twist_bone, parent_bone in twist_bones: + if twist_bone in armature.data.bones and parent_bone in armature.data.bones: + logger.debug(f"Transferring weights from {twist_bone} to {parent_bone}") + for mesh in meshes: + if twist_bone in mesh.vertex_groups: + transfer_vertex_weights(mesh, twist_bone, parent_bone) + bpy.ops.object.mode_set(mode='EDIT') for twist_bone, parent_bone in twist_bones: if twist_bone in armature.data.edit_bones and parent_bone in armature.data.edit_bones: diff --git a/functions/tools/standardize_armature.py b/functions/tools/standardize_armature.py index e88558d..23039c3 100644 --- a/functions/tools/standardize_armature.py +++ b/functions/tools/standardize_armature.py @@ -13,7 +13,9 @@ from ...core.dictionaries import ( bone_hierarchy, acceptable_bone_names, acceptable_bone_hierarchy, - non_standard_mappings + non_standard_mappings, + reverse_bone_lookup, + simplify_bonename ) class AvatarToolkit_OT_StandardizeArmature(Operator): @@ -53,12 +55,6 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): logger.info(f"Starting armature standardization for {armature.name}") - is_valid, _, _ = validate_armature(armature) - if is_valid: - logger.info("Armature already meets standards, no changes needed") - self.report({'INFO'}, t("Tools.standardize_already_valid")) - return {'FINISHED'} - original_mode: str = context.mode logger.debug(f"Original mode: {original_mode}") bpy.ops.object.mode_set(mode='OBJECT') @@ -88,7 +84,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): logger.info(f"Fixed {fixed_scale} scale issues") bpy.ops.object.mode_set(mode='OBJECT') - is_valid, messages, _ = validate_armature(armature) + is_valid, messages, _ = validate_armature(armature, override_mode='STRICT') if is_valid: logger.info("Armature successfully standardized") @@ -134,17 +130,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): existing_standard_bones.add(bone.name) logger.debug(f"Found existing standard bone: {bone.name}") - # Build a mapping of non-standard bone names to standard names + # Use the reverse bone lookup that's already built and simplified name_mapping: Dict[str, str] = {} - for category, standard_name in standard_bones.items(): - # Skip if this standard bone already exists - if standard_name in existing_standard_bones: - continue - - # Get all variants for this category - if category in non_standard_mappings: - for variant in non_standard_mappings[category]: - name_mapping[variant.lower()] = standard_name + for simplified_name, category in reverse_bone_lookup.items(): + if category in standard_bones: + standard_name = standard_bones[category] + # Skip if this standard bone already exists + if standard_name not in existing_standard_bones: + name_mapping[simplified_name] = standard_name # First pass: identify bones to rename bones_to_rename: Dict[str, str] = {} @@ -155,20 +148,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator): if original_name in standard_bones.values(): continue - simplified_name: str = original_name.lower().replace(' ', '').replace('_', '').replace('.', '') + simplified_name: str = simplify_bonename(original_name) - # Check if this bone matches any known pattern - for variant, standard_name in name_mapping.items(): - # More precise matching - exact match or with common separators - if (variant == simplified_name or - variant == original_name.lower() or - f"{variant}_" in simplified_name or - f"{variant}." in simplified_name): - - if original_name != standard_name: - bones_to_rename[original_name] = standard_name - logger.debug(f"Identified bone to rename: {original_name} -> {standard_name}") - break + # Check if this simplified bone name has a standard mapping + if simplified_name in name_mapping: + standard_name = name_mapping[simplified_name] + if original_name != standard_name: + bones_to_rename[original_name] = standard_name + logger.debug(f"Identified bone to rename: {original_name} -> {standard_name}") # Special case for spine/chest hierarchy # If we don't have an upper chest, don't rename chest to upper chest because it will break hierarchy diff --git a/functions/tools/vrm_unity_conversion.py b/functions/tools/vrm_unity_conversion.py new file mode 100644 index 0000000..c4faaa6 --- /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 = t("VRM.convert_to_unity.label") + bl_description = t("VRM.convert_to_unity.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 VRM conversion") + self.report({'ERROR'}, t("VRM.no_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'}, t("VRM.validation.hierarchy_passed")) + else: + logger.warning("Unity hierarchy validation found issues") + self.report({'WARNING'}, t("VRM.validation.hierarchy_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'}, t("VRM.validation.armature_passed")) + 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'}, t("VRM.validation.failed", error=str(e))) + + return {'FINISHED'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index e4d2136..86fcdd3 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.3.0)", + "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.4.0)", "AvatarToolkit.desc1": "Avatar Toolkit is in Early Access there", "AvatarToolkit.desc2": "will be issues, if you find any issues,", "AvatarToolkit.desc3": "please report it on our Github.", @@ -325,6 +325,7 @@ "Visemes.success": "Visemes created successfully", "Visemes.mesh_select": "Select Mesh", "Visemes.mesh_select_desc": "Select the mesh to create visemes on", + "Visemes.no_meshes": "No meshes found", "EyeTracking.label": "Eye Tracking", "EyeTracking.setup": "Eye Tracking Setup", @@ -517,6 +518,8 @@ "TextureAtlas.save_file_instructions": "Use File > Save As... or click the button below:", "TextureAtlas.save_file_button": "Save Blender File", "TextureAtlas.save_file_required": "Save File Required", + "TextureAtlas.search_materials": "Search Materials", + "TextureAtlas.search_materials_desc": "Filter materials by name", "Settings.label": "Settings", "Settings.language": "Language", @@ -554,6 +557,99 @@ "Language.ko_KR": "Korean", "Language.changed.title": "Language Changed", "Language.changed.success": "Language changed successfully!", - "Language.changed.restart": "Some UI elements may require restarting Blender" - } + "Language.changed.restart": "Some UI elements may require restarting Blender", + + "VRM.panel.label": "VRM to Unity", + "VRM.converter.title": "VRM Converter", + "VRM.no_armature_selected": "No armature selected", + "VRM.select_armature_to_convert": "Select an armature to convert", + "VRM.armature_name": "Armature: {name}", + "VRM.armature_detected": "VRM armature detected", + "VRM.no_vrm_bones_detected": "No VRM bones detected", + "VRM.remove_colliders": "Remove Colliders", + "VRM.remove_root_bone": "Remove Root Bone", + "VRM.convert_to_unity_format": "Convert to Unity Format", + "VRM.convert_to_unity.label": "Convert VRM to Unity", + "VRM.convert_to_unity.desc": "Convert VRM armature bone names to Unity humanoid naming convention", + "VRM.conversion_info.title": "Conversion Info:", + "VRM.conversion_info.renames_bones": "• Renames VRM bones to Unity format", + "VRM.conversion_info.removes_colliders": "• Removes collider bones (optional)", + "VRM.conversion_info.removes_root": "• Removes root bone, makes Hips root (optional)", + "VRM.conversion_info.maintains_hierarchy": "• Maintains bone hierarchy", + "VRM.conversion_info.validates_results": "• Validates conversion results", + "VRM.conversion_info.preserves_animations": "• Preserves all animations", + "VRM.detection_failed.title": "VRM Detection Failed:", + "VRM.detection_failed.not_vrm_format": "• Selected armature is not VRM format", + "VRM.detection_failed.bones_start_with": "• VRM bones start with 'J_Bip_C_'", + "VRM.detection_failed.need_five_bones": "• Need at least 5 VRM bones detected", + "VRM.detection_failed.check_bone_names": "• Check armature bone names", + "VRM.validation.hierarchy_passed": "Unity hierarchy validation passed", + "VRM.validation.hierarchy_issues": "Conversion completed but hierarchy validation found issues:", + "VRM.validation.armature_passed": "Armature passes standard validation", + "VRM.validation.failed": "Conversion completed but validation failed: {error}", + "VRM.remove_colliders": "Remove Colliders", + "VRM.remove_colliders_desc": "Remove VRM collider bones during conversion", + "VRM.remove_root": "Remove Root Bone", + "VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone", + + "Translation.label": "Translation", + "Translation.service": "Translation Service", + "Translation.service_desc": "Choose the translation service to use", + "Translation.mode": "Translation Mode", + "Translation.mode_desc": "Select how translation should work", + "Translation.mode.hybrid": "Hybrid (Dictionary + API)", + "Translation.mode.hybrid_desc": "Try dictionary first, then use API service as fallback", + "Translation.mode.dictionary_only": "Dictionary Only", + "Translation.mode.dictionary_only_desc": "Only use built-in dictionaries for translation", + "Translation.mode.api_only": "API Only", + "Translation.mode.api_only_desc": "Only use online translation services", + "Translation.service_settings": "Translation Service", + "Translation.language_settings": "Language Settings", + "Translation.quick_actions": "Quick Actions", + "Translation.utilities": "Utilities", + "Translation.advanced_settings": "Advanced Settings", + "Translation.source_language": "Source Language", + "Translation.source_language_desc": "Language to translate from", + "Translation.target_language": "Target Language", + "Translation.target_language_desc": "Language to translate to", + "Translation.translate_names": "Translate Names", + "Translation.translate_names_desc": "Translate names using the selected service and settings", + "Translation.test_service": "Test Service", + "Translation.test_service_desc": "Test the currently selected translation service", + "Translation.clear_cache": "Clear Cache", + "Translation.clear_cache_desc": "Clear all cached translations", + "Translation.show_stats": "Show Statistics", + "Translation.show_stats_desc": "Show translation statistics and information", + "Translation.no_armature": "No armature selected", + "Translation.test_failed": "Translation service test failed - check configuration", + "Translation.cache_cleared": "Translation cache cleared successfully", + "Translation.mymemory_info": "MyMemory is completely free with no API key required. Provides 1000 translations per day.", + "Translation.service.mymemory": "MyMemory (Free)", + "Translation.service.mymemory_desc": "Completely free service - no API key needed!", + "Translation.service.libretranslate": "LibreTranslate", + "Translation.service.libretranslate_desc": "Configurable server - can be self-hosted", + "Translation.service.deepl": "DeepL", + "Translation.service.deepl_desc": "High-quality translations - API key required", + "Translation.type.bones": "Bones", + "Translation.type.bones_desc": "Translate bone names", + "Translation.type.shapekeys": "Shape Keys", + "Translation.type.shapekeys_desc": "Translate shape key names", + "Translation.type.materials": "Materials", + "Translation.type.materials_desc": "Translate material names", + "Translation.type.objects": "Objects", + "Translation.type.objects_desc": "Translate object names", + "Translation.type.all": "All", + "Translation.type.all_desc": "Translate all supported types", + "Translation.configure_deepl": "Configure DeepL API", + "Translation.configure_deepl_desc": "Configure DeepL translation service API key", + "Translation.deepl_api_key": "DeepL API Key", + "Translation.deepl_api_key_desc": "Your DeepL API key (get free key at deepl.com/pro)", + "Translation.configure_libretranslate": "Configure LibreTranslate Server", + "Translation.configure_libretranslate_desc": "Configure LibreTranslate translation service server URL", + "Translation.server_url": "Server URL", + "Translation.server_url_desc": "LibreTranslate server URL (e.g., https://your-server.com)", + "Translation.api_key": "API Key", + "Translation.api_key_desc": "API key for LibreTranslate server (optional for some servers)" + + } } diff --git a/resources/translations/ja_JP.json b/resources/translations/ja_JP.json index 7429eec..7044eff 100644 --- a/resources/translations/ja_JP.json +++ b/resources/translations/ja_JP.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "アバターツールキット (アルファ 0.3.0)", + "AvatarToolkit.label": "アバターツールキット (アルファ 0.4.0)", "AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、", "AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、", "AvatarToolkit.desc3": "GitHubで報告してください。", @@ -308,6 +308,7 @@ "Visemes.success": "口形素が正常に作成されました", "Visemes.mesh_select": "メッシュを選択", "Visemes.mesh_select_desc": "口形素を作成するメッシュを選択", + "Visemes.no_meshes": "メッシュが見つかりません", "EyeTracking.label": "アイトラッキング", "EyeTracking.setup": "アイトラッキング設定", @@ -500,6 +501,8 @@ "TextureAtlas.save_file_instructions": "ファイル > 名前を付けて保存... を使用するか、下のボタンをクリックしてください:", "TextureAtlas.save_file_button": "Blenderファイルを保存", "TextureAtlas.save_file_required": "ファイルの保存が必要です", + "TextureAtlas.search_materials": "マテリアルを検索", + "TextureAtlas.search_materials_desc": "名前でマテリアルをフィルタリング", "Settings.label": "設定", "Settings.language": "言語", @@ -537,6 +540,98 @@ "Language.ko_KR": "韓国語", "Language.changed.title": "言語が変更されました", "Language.changed.success": "言語が正常に変更されました!", - "Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります" + "Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります", + + "VRM.panel.label": "VRMからUnityへ", + "VRM.converter.title": "VRMコンバーター", + "VRM.no_armature_selected": "アーマチュアが選択されていません", + "VRM.select_armature_to_convert": "変換するアーマチュアを選択してください", + "VRM.armature_name": "アーマチュア: {name}", + "VRM.armature_detected": "VRMアーマチュアが検出されました", + "VRM.no_vrm_bones_detected": "VRMボーンが検出されませんでした", + "VRM.remove_colliders": "コライダーを削除", + "VRM.remove_root_bone": "ルートボーンを削除", + "VRM.convert_to_unity_format": "Unity形式に変換", + "VRM.convert_to_unity.label": "VRMをUnityに変換", + "VRM.convert_to_unity.desc": "VRMアーマチュアのボーン名をUnityヒューマノイド命名規則に変換", + "VRM.conversion_info.title": "変換情報:", + "VRM.conversion_info.renames_bones": "• VRMボーンをUnity形式にリネーム", + "VRM.conversion_info.removes_colliders": "• コライダーボーンを削除(オプション)", + "VRM.conversion_info.removes_root": "• ルートボーンを削除し、Hipsをルートにする(オプション)", + "VRM.conversion_info.maintains_hierarchy": "• ボーン階層を維持", + "VRM.conversion_info.validates_results": "• 変換結果を検証", + "VRM.conversion_info.preserves_animations": "• すべてのアニメーションを保持", + "VRM.detection_failed.title": "VRM検出失敗:", + "VRM.detection_failed.not_vrm_format": "• 選択されたアーマチュアはVRM形式ではありません", + "VRM.detection_failed.bones_start_with": "• VRMボーンは'J_Bip_C_'で始まります", + "VRM.detection_failed.need_five_bones": "• 少なくとも5つのVRMボーンが検出される必要があります", + "VRM.detection_failed.check_bone_names": "• アーマチュアのボーン名を確認してください", + "VRM.validation.hierarchy_passed": "Unity階層検証に合格しました", + "VRM.validation.hierarchy_issues": "変換は完了しましたが、階層検証で問題が見つかりました:", + "VRM.validation.armature_passed": "アーマチュアは標準検証に合格しました", + "VRM.validation.failed": "変換は完了しましたが、検証に失敗しました: {error}", + "VRM.remove_colliders": "コライダーを削除", + "VRM.remove_colliders_desc": "変換中にVRMコライダーボーンを削除", + "VRM.remove_root": "ルートボーンを削除", + "VRM.remove_root_desc": "不要なVRMルートボーンを削除し、ヒップをルートボーンにする", + + "Translation.label": "翻訳", + "Translation.service": "翻訳サービス", + "Translation.service_desc": "使用する翻訳サービスを選択", + "Translation.mode": "翻訳モード", + "Translation.mode_desc": "翻訳の動作方法を選択", + "Translation.mode.hybrid": "ハイブリッド(辞書 + API)", + "Translation.mode.hybrid_desc": "まず辞書を試し、その後APIサービスをフォールバックとして使用", + "Translation.mode.dictionary_only": "辞書のみ", + "Translation.mode.dictionary_only_desc": "翻訳には組み込み辞書のみを使用", + "Translation.mode.api_only": "APIのみ", + "Translation.mode.api_only_desc": "オンライン翻訳サービスのみを使用", + "Translation.service_settings": "翻訳サービス", + "Translation.language_settings": "言語設定", + "Translation.quick_actions": "クイックアクション", + "Translation.utilities": "ユーティリティ", + "Translation.advanced_settings": "詳細設定", + "Translation.source_language": "ソース言語", + "Translation.source_language_desc": "翻訳元の言語", + "Translation.target_language": "ターゲット言語", + "Translation.target_language_desc": "翻訳先の言語", + "Translation.translate_names": "名前を翻訳", + "Translation.translate_names_desc": "選択したサービスと設定を使用して名前を翻訳", + "Translation.test_service": "サービスをテスト", + "Translation.test_service_desc": "現在選択されている翻訳サービスをテスト", + "Translation.clear_cache": "キャッシュをクリア", + "Translation.clear_cache_desc": "すべてのキャッシュされた翻訳をクリア", + "Translation.show_stats": "統計を表示", + "Translation.show_stats_desc": "翻訳統計と情報を表示", + "Translation.no_armature": "アーマチュアが選択されていません", + "Translation.test_failed": "翻訳サービステストが失敗しました - 設定を確認してください", + "Translation.cache_cleared": "翻訳キャッシュが正常にクリアされました", + "Translation.mymemory_info": "MyMemoryは完全に無料でAPIキー不要です。1日1000回の翻訳を提供します。", + "Translation.service.mymemory": "MyMemory(無料)", + "Translation.service.mymemory_desc": "完全に無料のサービス - APIキー不要!", + "Translation.service.libretranslate": "LibreTranslate", + "Translation.service.libretranslate_desc": "設定可能なサーバー - セルフホスト可能", + "Translation.service.deepl": "DeepL", + "Translation.service.deepl_desc": "高品質な翻訳 - APIキーが必要", + "Translation.type.bones": "ボーン", + "Translation.type.bones_desc": "ボーン名を翻訳", + "Translation.type.shapekeys": "シェイプキー", + "Translation.type.shapekeys_desc": "シェイプキー名を翻訳", + "Translation.type.materials": "マテリアル", + "Translation.type.materials_desc": "マテリアル名を翻訳", + "Translation.type.objects": "オブジェクト", + "Translation.type.objects_desc": "オブジェクト名を翻訳", + "Translation.type.all": "すべて", + "Translation.type.all_desc": "サポートされているすべてのタイプを翻訳", + "Translation.configure_deepl": "DeepL APIを設定", + "Translation.configure_deepl_desc": "DeepL翻訳サービスAPIキーを設定", + "Translation.deepl_api_key": "DeepL APIキー", + "Translation.deepl_api_key_desc": "あなたのDeepL APIキー(deepl.com/proで無料キーを取得)", + "Translation.configure_libretranslate": "LibreTranslateサーバーを設定", + "Translation.configure_libretranslate_desc": "LibreTranslate翻訳サービスサーバーURLを設定", + "Translation.server_url": "サーバーURL", + "Translation.server_url_desc": "LibreTranslateサーバーURL(例:https://your-server.com)", + "Translation.api_key": "APIキー", + "Translation.api_key_desc": "LibreTranslateサーバー用のAPIキー(一部のサーバーでは任意)" } } diff --git a/resources/translations/ko_KR.json b/resources/translations/ko_KR.json index 38e2938..15c8874 100644 --- a/resources/translations/ko_KR.json +++ b/resources/translations/ko_KR.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "아바타 툴킷 (알파 0.3.0)", + "AvatarToolkit.label": "아바타 툴킷 (알파 0.4.0)", "AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로", "AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면", "AvatarToolkit.desc3": "Github에 보고해 주세요.", @@ -308,6 +308,7 @@ "Visemes.success": "비셈 생성 성공", "Visemes.mesh_select": "메시 선택", "Visemes.mesh_select_desc": "비셈을 생성할 메시 선택", + "Visemes.no_meshes": "메시를 찾을 수 없음", "EyeTracking.label": "시선 추적", "EyeTracking.setup": "시선 추적 설정", @@ -500,6 +501,8 @@ "TextureAtlas.save_file_instructions": "파일 > 다른 이름으로 저장... 을 사용하거나 아래 버튼을 클릭하세요:", "TextureAtlas.save_file_button": "Blender 파일 저장", "TextureAtlas.save_file_required": "파일 저장 필요", + "TextureAtlas.search_materials": "재질 검색", + "TextureAtlas.search_materials_desc": "이름으로 재질 필터링", "Settings.label": "설정", "Settings.language": "언어", @@ -537,6 +540,98 @@ "Language.ko_KR": "한국어", "Language.changed.title": "언어 변경됨", "Language.changed.success": "언어가 성공적으로 변경되었습니다!", - "Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다" + "Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다", + + "VRM.panel.label": "VRM에서 Unity로", + "VRM.converter.title": "VRM 변환기", + "VRM.no_armature_selected": "선택된 아마추어 없음", + "VRM.select_armature_to_convert": "변환할 아마추어를 선택하세요", + "VRM.armature_name": "아마추어: {name}", + "VRM.armature_detected": "VRM 아마추어 감지됨", + "VRM.no_vrm_bones_detected": "VRM 본이 감지되지 않음", + "VRM.remove_colliders": "콜라이더 제거", + "VRM.remove_root_bone": "루트 본 제거", + "VRM.convert_to_unity_format": "Unity 형식으로 변환", + "VRM.convert_to_unity.label": "VRM을 Unity로 변환", + "VRM.convert_to_unity.desc": "VRM 아마추어 본 이름을 Unity 휴머노이드 명명 규칙으로 변환", + "VRM.conversion_info.title": "변환 정보:", + "VRM.conversion_info.renames_bones": "• VRM 본을 Unity 형식으로 이름 변경", + "VRM.conversion_info.removes_colliders": "• 콜라이더 본 제거 (선택사항)", + "VRM.conversion_info.removes_root": "• 루트 본 제거, Hips를 루트로 설정 (선택사항)", + "VRM.conversion_info.maintains_hierarchy": "• 본 계층 구조 유지", + "VRM.conversion_info.validates_results": "• 변환 결과 검증", + "VRM.conversion_info.preserves_animations": "• 모든 애니메이션 보존", + "VRM.detection_failed.title": "VRM 감지 실패:", + "VRM.detection_failed.not_vrm_format": "• 선택된 아마추어가 VRM 형식이 아님", + "VRM.detection_failed.bones_start_with": "• VRM 본은 'J_Bip_C_'로 시작함", + "VRM.detection_failed.need_five_bones": "• 최소 5개의 VRM 본이 감지되어야 함", + "VRM.detection_failed.check_bone_names": "• 아마추어 본 이름을 확인하세요", + "VRM.validation.hierarchy_passed": "Unity 계층 구조 검증 통과", + "VRM.validation.hierarchy_issues": "변환은 완료되었지만 계층 구조 검증에서 문제를 발견했습니다:", + "VRM.validation.armature_passed": "아마추어가 표준 검증을 통과했습니다", + "VRM.validation.failed": "변환은 완료되었지만 검증에 실패했습니다: {error}", + "VRM.remove_colliders": "콜라이더 제거", + "VRM.remove_colliders_desc": "변환 중 VRM 콜라이더 본 제거", + "VRM.remove_root": "루트 본 제거", + "VRM.remove_root_desc": "불필요한 VRM 루트 본을 제거하고 힙을 루트 본으로 설정", + + "Translation.label": "번역", + "Translation.service": "번역 서비스", + "Translation.service_desc": "사용할 번역 서비스 선택", + "Translation.mode": "번역 모드", + "Translation.mode_desc": "번역 동작 방식 선택", + "Translation.mode.hybrid": "하이브리드 (사전 + API)", + "Translation.mode.hybrid_desc": "먼저 사전을 시도하고, 그 다음 API 서비스를 폴백으로 사용", + "Translation.mode.dictionary_only": "사전만", + "Translation.mode.dictionary_only_desc": "번역에 내장 사전만 사용", + "Translation.mode.api_only": "API만", + "Translation.mode.api_only_desc": "온라인 번역 서비스만 사용", + "Translation.service_settings": "번역 서비스", + "Translation.language_settings": "언어 설정", + "Translation.quick_actions": "빠른 작업", + "Translation.utilities": "유틸리티", + "Translation.advanced_settings": "고급 설정", + "Translation.source_language": "소스 언어", + "Translation.source_language_desc": "번역할 원본 언어", + "Translation.target_language": "대상 언어", + "Translation.target_language_desc": "번역할 대상 언어", + "Translation.translate_names": "이름 번역", + "Translation.translate_names_desc": "선택한 서비스와 설정을 사용하여 이름 번역", + "Translation.test_service": "서비스 테스트", + "Translation.test_service_desc": "현재 선택된 번역 서비스 테스트", + "Translation.clear_cache": "캐시 지우기", + "Translation.clear_cache_desc": "모든 캐시된 번역 지우기", + "Translation.show_stats": "통계 표시", + "Translation.show_stats_desc": "번역 통계 및 정보 표시", + "Translation.no_armature": "선택된 아마추어 없음", + "Translation.test_failed": "번역 서비스 테스트 실패 - 구성을 확인하세요", + "Translation.cache_cleared": "번역 캐시가 성공적으로 지워졌습니다", + "Translation.mymemory_info": "MyMemory는 API 키 없이 완전히 무료입니다. 하루 1000회 번역을 제공합니다.", + "Translation.service.mymemory": "MyMemory (무료)", + "Translation.service.mymemory_desc": "완전히 무료 서비스 - API 키 불필요!", + "Translation.service.libretranslate": "LibreTranslate", + "Translation.service.libretranslate_desc": "구성 가능한 서버 - 셀프 호스팅 가능", + "Translation.service.deepl": "DeepL", + "Translation.service.deepl_desc": "고품질 번역 - API 키 필요", + "Translation.type.bones": "본", + "Translation.type.bones_desc": "본 이름 번역", + "Translation.type.shapekeys": "쉐이프 키", + "Translation.type.shapekeys_desc": "쉐이프 키 이름 번역", + "Translation.type.materials": "재질", + "Translation.type.materials_desc": "재질 이름 번역", + "Translation.type.objects": "객체", + "Translation.type.objects_desc": "객체 이름 번역", + "Translation.type.all": "모두", + "Translation.type.all_desc": "지원되는 모든 유형 번역", + "Translation.configure_deepl": "DeepL API 구성", + "Translation.configure_deepl_desc": "DeepL 번역 서비스 API 키 구성", + "Translation.deepl_api_key": "DeepL API 키", + "Translation.deepl_api_key_desc": "당신의 DeepL API 키 (deepl.com/pro에서 무료 키 획득)", + "Translation.configure_libretranslate": "LibreTranslate 서버 구성", + "Translation.configure_libretranslate_desc": "LibreTranslate 번역 서비스 서버 URL 구성", + "Translation.server_url": "서버 URL", + "Translation.server_url_desc": "LibreTranslate 서버 URL (예: https://your-server.com)", + "Translation.api_key": "API 키", + "Translation.api_key_desc": "LibreTranslate 서버용 API 키 (일부 서버는 선택사항)" } } diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 2f8f625..6e43e69 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -17,6 +17,17 @@ from ..core.common import ( get_armature_list, get_armature_stats ) + +# Module-level cache for UI performance (avoids Blender scene property write restrictions) +_validation_cache = {} +_stats_cache = {} + +def clear_armature_caches(): + """Clear all armature-related caches - called when armature changes""" + global _validation_cache, _stats_cache + _validation_cache.clear() + _stats_cache.clear() + from ..functions.pose_mode import ( AvatarToolkit_OT_StartPoseMode, AvatarToolkit_OT_StopPoseMode, @@ -84,10 +95,16 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Armature Selection col.prop(context.scene.avatar_toolkit, "active_armature", text="") - # Armature Validation + # Armature Validation (cached to improve performance) active_armature: Optional[Object] = get_active_armature(context) if active_armature: - is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True) + # Cache validation results to avoid expensive recalculations on every draw + cache_key = f"validation_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}" + + if cache_key not in _validation_cache: + _validation_cache[cache_key] = validate_armature(active_armature, detailed_messages=True) + + is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = _validation_cache[cache_key] # Check if this is a PMX model is_pmx_model = False @@ -235,7 +252,14 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): row = info_box.row() split = row.split(factor=0.6) split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK') - stats = get_armature_stats(active_armature) + + # Cache armature stats to avoid expensive recalculations + stats_cache_key = f"stats_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}" + + if stats_cache_key not in _stats_cache: + _stats_cache[stats_cache_key] = get_armature_stats(active_armature) + + stats = _stats_cache[stats_cache_key] split.label(text=t("QuickAccess.bones_count", count=stats['bone_count'])) if stats['has_pose']: diff --git a/ui/translation_panel.py b/ui/translation_panel.py new file mode 100644 index 0000000..8ea5cb9 --- /dev/null +++ b/ui/translation_panel.py @@ -0,0 +1,731 @@ +# GPL License + +import bpy +from typing import Set, Dict, List, Optional, Any +from bpy.types import ( + Operator, + Panel, + Context, + UILayout, + WindowManager, + Event, + Object +) +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.logging_setup import logger +from ..core.common import get_active_armature, ProgressTracker + +# Module-level cache for UI performance (avoids Blender scene property write restrictions) +_ui_cache = { + 'translation_status': {}, + 'deepl_config': {}, + 'libretranslate_config': {}, + 'last_refresh_frame': 0, + 'cache_refresh_interval': 30 +} + + +class AvatarToolkit_OT_TranslateNames(Operator): + """Translate names using the translation system""" + bl_idname: str = "avatar_toolkit.translate_names" + bl_label: str = t("Translation.translate_names") + bl_description: str = t("Translation.translate_names_desc") + + translation_type: bpy.props.EnumProperty( + items=[ + ('bones', t("Translation.type.bones"), t("Translation.type.bones_desc")), + ('shapekeys', t("Translation.type.shapekeys"), t("Translation.type.shapekeys_desc")), + ('materials', t("Translation.type.materials"), t("Translation.type.materials_desc")), + ('objects', t("Translation.type.objects"), t("Translation.type.objects_desc")), + ('all', t("Translation.type.all"), t("Translation.type.all_desc")) + ], + default='bones' + ) + + def execute(self, context: Context) -> Set[str]: + logger.info(f"Starting translation operation: {self.translation_type}") + + try: + from ..core.translation_manager import get_avatar_translation_manager + manager = get_avatar_translation_manager() + + # Set up progress callback for detailed feedback + def progress_callback(current: int, total: int, message: str): + progress_percent = (current / max(total, 1)) * 100 + logger.info(f"Translation progress: {current}/{total} ({progress_percent:.1f}%) - {message}") + context.area.header_text_set(f"Translating: {current}/{total} - {message}") + + manager.set_progress_callback(progress_callback) + + results = [] + armature = get_active_armature(context) + + total_steps = 0 + if self.translation_type == 'bones' or self.translation_type == 'all': + if armature: + total_steps += len(armature.data.bones) + if self.translation_type == 'shapekeys' or self.translation_type == 'all': + meshes = [obj for obj in context.scene.objects if obj.type == 'MESH'] + for mesh in meshes: + if mesh.data.shape_keys: + total_steps += len(mesh.data.shape_keys.key_blocks) + if self.translation_type == 'materials' or self.translation_type == 'all': + materials = set() + for obj in context.scene.objects: + if obj.type == 'MESH' and obj.data.materials: + for mat in obj.data.materials: + if mat: + materials.add(mat) + total_steps += len(materials) + if self.translation_type == 'objects' or self.translation_type == 'all': + objects = [obj for obj in context.scene.objects if obj.type in {'MESH', 'ARMATURE', 'EMPTY'}] + total_steps += len(objects) + + logger.info(f"Translation operation will process approximately {total_steps} items") + + with ProgressTracker(context, total_steps, "Translation") as progress: + if self.translation_type == 'bones' or self.translation_type == 'all': + if armature: + logger.info(f"Starting bone translation for armature: {armature.name}") + self.report({'INFO'}, f"Translating {len(armature.data.bones)} bones...") + + bone_results = manager.translate_armature_bones(armature, apply_results=True) + results.extend(bone_results) + + successful_bones = sum(1 for r in bone_results if r.method not in ['failed', 'skipped']) + progress.step(f"Bones: {successful_bones}/{len(bone_results)} translated") + logger.info(f"Bone translation complete: {successful_bones}/{len(bone_results)} successful") + else: + self.report({'WARNING'}, t("Translation.no_armature")) + logger.warning("No armature selected for bone translation") + + if self.translation_type == 'shapekeys' or self.translation_type == 'all': + meshes = [obj for obj in context.scene.objects if obj.type == 'MESH'] + logger.info(f"Starting shape key translation for {len(meshes)} mesh objects") + + total_shapekeys = 0 + for mesh in meshes: + if mesh.data.shape_keys: + shapekey_count = len(mesh.data.shape_keys.key_blocks) + self.report({'INFO'}, f"Translating {shapekey_count} shape keys in {mesh.name}...") + + shapekey_results = manager.translate_object_shapekeys(mesh, apply_results=True) + results.extend(shapekey_results) + total_shapekeys += len(shapekey_results) + + successful_shapekeys = sum(1 for r in results[-total_shapekeys:] if r.method not in ['failed', 'skipped']) + progress.step(f"Shape keys: {successful_shapekeys}/{total_shapekeys} translated") + logger.info(f"Shape key translation complete: {successful_shapekeys}/{total_shapekeys} successful") + + if self.translation_type == 'materials' or self.translation_type == 'all': + logger.info("Starting material translation") + self.report({'INFO'}, "Translating materials...") + + material_results = manager.translate_scene_materials(apply_results=True) + results.extend(material_results) + + successful_materials = sum(1 for r in material_results if r.method not in ['failed', 'skipped']) + progress.step(f"Materials: {successful_materials}/{len(material_results)} translated") + logger.info(f"Material translation complete: {successful_materials}/{len(material_results)} successful") + + if self.translation_type == 'objects' or self.translation_type == 'all': + logger.info("Starting object translation") + self.report({'INFO'}, "Translating objects...") + + object_results = manager.translate_scene_objects(apply_results=True) + results.extend(object_results) + + successful_objects = sum(1 for r in object_results if r.method not in ['failed', 'skipped']) + progress.step(f"Objects: {successful_objects}/{len(object_results)} translated") + logger.info(f"Object translation complete: {successful_objects}/{len(object_results)} successful") + + manager.set_progress_callback(None) + context.area.header_text_set(None) + + # Final results summary + successful = sum(1 for r in results if r.method not in ['failed', 'skipped']) + total = len(results) + + dictionary_count = sum(1 for r in results if r.method == 'dictionary') + api_count = sum(1 for r in results if r.method == 'api') + cache_count = sum(1 for r in results if r.method == 'cache') + failed_count = sum(1 for r in results if r.method == 'failed') + + logger.info(f"Translation summary: {successful}/{total} successful (Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}, Failed: {failed_count})") + + if successful > 0: + success_msg = f"Successfully translated {successful}/{total} items" + if dictionary_count > 0: + success_msg += f" (Dictionary: {dictionary_count}" + if api_count > 0: + success_msg += f", API: {api_count}" + if cache_count > 0: + success_msg += f", Cache: {cache_count}" + if dictionary_count > 0 or api_count > 0 or cache_count > 0: + success_msg += ")" + + self.report({'INFO'}, success_msg) + else: + if total > 0: + self.report({'WARNING'}, f"No translations were applied ({total} items checked)") + else: + self.report({'WARNING'}, "No items found to translate") + + return {'FINISHED'} + + except Exception as e: + try: + manager.set_progress_callback(None) + context.area.header_text_set(None) + except: + pass + + logger.error(f"Translation operation failed: {e}", exc_info=True) + self.report({'ERROR'}, f"Translation failed: {str(e)}") + return {'CANCELLED'} + + +class AvatarToolkit_OT_TestTranslationService(Operator): + """Test the currently selected translation service""" + bl_idname: str = "avatar_toolkit.test_translation_service" + bl_label: str = t("Translation.test_service") + bl_description: str = t("Translation.test_service_desc") + + def execute(self, context: Context) -> Set[str]: + logger.info("Starting translation service test") + + try: + from ..core.translation_manager import get_avatar_translation_manager + manager = get_avatar_translation_manager() + + self.report({'INFO'}, "Testing translation service...") + context.area.header_text_set("Testing translation service...") + + # Test translation with a simple word + test_word = "テスト" # "Test" in Japanese + logger.info(f"Testing translation of '{test_word}'") + + result = manager.translate_single(test_word, "auto") + + # Clear status + context.area.header_text_set(None) + + if result.method == "failed": + logger.error(f"Translation test failed: {result}") + self.report({'ERROR'}, t("Translation.test_failed")) + else: + service_info = f" ({result.service})" if result.service else "" + success_msg = f"Translation test successful: '{test_word}' → '{result.translated}' via {result.method}{service_info}" + logger.info(f"Translation test successful: {result}") + self.report({'INFO'}, success_msg) + + return {'FINISHED'} + + except Exception as e: + try: + context.area.header_text_set(None) + except: + pass + + logger.error(f"Translation service test failed: {e}", exc_info=True) + self.report({'ERROR'}, f"Service test failed: {str(e)}") + return {'CANCELLED'} + + +class AvatarToolkit_OT_ClearTranslationCache(Operator): + """Clear all translation caches""" + bl_idname: str = "avatar_toolkit.clear_translation_cache" + bl_label: str = t("Translation.clear_cache") + bl_description: str = t("Translation.clear_cache_desc") + + def execute(self, context: Context) -> Set[str]: + try: + from ..core.translation_manager import get_avatar_translation_manager + manager = get_avatar_translation_manager() + manager.clear_all_caches() + + self.report({'INFO'}, t("Translation.cache_cleared")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to clear translation cache: {e}") + self.report({'ERROR'}, f"Failed to clear cache: {str(e)}") + return {'CANCELLED'} + + +class AvatarToolkit_OT_ConfigureDeepL(Operator): + """Configure DeepL API settings""" + bl_idname: str = "avatar_toolkit.configure_deepl" + bl_label: str = t("Translation.configure_deepl") + bl_description: str = t("Translation.configure_deepl_desc") + + api_key: bpy.props.StringProperty( + name=t("Translation.deepl_api_key"), + description=t("Translation.deepl_api_key_desc"), + default="", + subtype='PASSWORD' + ) + + def execute(self, context: Context) -> Set[str]: + try: + if not self.api_key.strip(): + self.report({'ERROR'}, "API key cannot be empty") + return {'CANCELLED'} + + from ..core.translation_manager import configure_translation_service + success = configure_translation_service("deepl", api_key=self.api_key.strip()) + + if success: + _ui_cache['deepl_config'].clear() + _ui_cache['translation_status'].clear() + if 'batch_info' in _ui_cache: + del _ui_cache['batch_info'] + self.report({'INFO'}, "DeepL API configured successfully") + return {'FINISHED'} + else: + self.report({'ERROR'}, "Failed to configure DeepL API - check your API key") + return {'CANCELLED'} + + except Exception as e: + logger.error(f"DeepL configuration failed: {e}") + self.report({'ERROR'}, f"Configuration failed: {str(e)}") + return {'CANCELLED'} + + def invoke(self, context: Context, event: Event) -> Set[str]: + # Load existing API key if available + try: + from ..core.addon_preferences import get_preference + existing_key = get_preference("deepl_api_key", "") + if existing_key: + # Show only first/last few characters for security + if len(existing_key) > 8: + display_key = existing_key[:4] + "..." + existing_key[-4:] + self.api_key = existing_key # Keep full key for editing + else: + self.api_key = existing_key + except: + pass + + wm: WindowManager = context.window_manager + return wm.invoke_props_dialog(self, width=400) + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + + info_box = layout.box() + info_col = info_box.column() + info_col.label(text="DeepL API Configuration", icon='SETTINGS') + info_col.separator() + info_col.label(text="1. Visit deepl.com/pro to get your free API key") + info_col.label(text="2. Free tier: 500,000 characters/month") + info_col.label(text="3. Higher quality than other services") + info_col.label(text="4. The Fastest Option due to native batching support") + + layout.separator() + layout.prop(self, "api_key") + + +class AvatarToolkit_OT_ConfigureLibreTranslate(Operator): + """Configure LibreTranslate server settings""" + bl_idname: str = "avatar_toolkit.configure_libretranslate" + bl_label: str = t("Translation.configure_libretranslate") + bl_description: str = t("Translation.configure_libretranslate_desc") + + server_url: bpy.props.StringProperty( + name=t("Translation.server_url"), + description=t("Translation.server_url_desc"), + default="https://libretranslate.com" + ) + + api_key: bpy.props.StringProperty( + name=t("Translation.api_key"), + description=t("Translation.api_key_desc"), + default="", + subtype='PASSWORD' + ) + + def execute(self, context: Context) -> Set[str]: + try: + if not self.server_url.strip(): + self.report({'ERROR'}, "Server URL cannot be empty") + return {'CANCELLED'} + + from ..core.translation_manager import configure_translation_service + success = configure_translation_service("libretranslate", + server_url=self.server_url.strip(), + api_key=self.api_key.strip() if self.api_key.strip() else None) + + if success: + _ui_cache['libretranslate_config'].clear() + _ui_cache['translation_status'].clear() + if 'batch_info' in _ui_cache: + del _ui_cache['batch_info'] + self.report({'INFO'}, f"LibreTranslate server configured: {self.server_url}") + return {'FINISHED'} + else: + self.report({'ERROR'}, "Failed to connect to LibreTranslate server") + return {'CANCELLED'} + + except Exception as e: + logger.error(f"LibreTranslate configuration failed: {e}") + self.report({'ERROR'}, f"Configuration failed: {str(e)}") + return {'CANCELLED'} + + def invoke(self, context: Context, event: Event) -> Set[str]: + # Load existing server URL and API key if available + try: + from ..core.addon_preferences import get_preference + existing_url = get_preference("libretranslate_url", "https://libretranslate.com") + existing_api_key = get_preference("libretranslate_api_key", "") + self.server_url = existing_url + self.api_key = existing_api_key + except: + pass + + wm: WindowManager = context.window_manager + return wm.invoke_props_dialog(self, width=500) + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + + info_box = layout.box() + info_col = info_box.column() + info_col.label(text="LibreTranslate Server Configuration", icon='SETTINGS') + info_col.separator() + info_col.label(text="⚠ libretranslate.com requires payment for API access") + info_col.label(text="✓ You can run your own LibreTranslate server") + info_col.label(text="✓ Or find community-hosted instances") + info_col.separator() + info_col.label(text="Examples:") + info_col.label(text=" • Your server: https://translate.yoursite.com") + info_col.label(text=" • Docker local: http://localhost:5000") + + layout.separator() + layout.prop(self, "server_url") + layout.prop(self, "api_key") + + +class AvatarToolkit_OT_TranslationStats(Operator): + """Show translation statistics""" + bl_idname: str = "avatar_toolkit.translation_stats" + bl_label: str = t("Translation.show_stats") + bl_description: str = t("Translation.show_stats_desc") + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + + def invoke(self, context: Context, event: Event) -> Set[str]: + wm: WindowManager = context.window_manager + return wm.invoke_props_dialog(self, width=400) + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + + try: + from ..core.translation_manager import get_avatar_translation_manager + manager = get_avatar_translation_manager() + stats = manager.get_translation_stats() + + dict_box = layout.box() + dict_box.label(text="Dictionary Translations", icon='BOOKMARKS') + dict_stats = stats['dictionary_translations'] + for category, count in dict_stats.items(): + if count > 0: + dict_box.label(text=f"{category.title()}: {count}") + + cache_box = layout.box() + cache_box.label(text="Translation Cache", icon='FILE_CACHE') + cache_stats = stats['cache_stats'] + cache_box.label(text=f"Language pairs: {cache_stats['language_pairs']}") + cache_box.label(text=f"Total cached: {cache_stats['total_entries']}") + + service_box = layout.box() + service_box.label(text="Translation Services", icon='WORLD') + service_box.label(text=f"Current mode: {stats['current_mode']}") + service_box.label(text=f"Primary service: {stats['primary_service']}") + + available_services = stats['available_services'] + if available_services: + service_box.label(text="Available services:") + for service_id, service_name in available_services: + service_box.label(text=f" • {service_name}") + else: + service_box.label(text="No services available", icon='ERROR') + + except Exception as e: + layout.label(text=f"Error loading stats: {str(e)}", icon='ERROR') + + +class AvatarToolKit_PT_TranslationPanel(Panel): + """Translation panel for Avatar Toolkit""" + bl_label: str = t("Translation.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_translation" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 9 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the translation panel layout""" + layout: UILayout = self.layout + props = context.scene.avatar_toolkit + + # Translation Service Settings + service_box: UILayout = layout.box() + col: UILayout = service_box.column(align=True) + row: UILayout = col.row() + row.scale_y = 1.2 + row.label(text=t("Translation.service_settings"), icon='WORLD') + col.separator() + + col.prop(props, "translation_service", text="") + + col.prop(props, "translation_mode", text="") + + row = col.row(align=True) + row.prop(props, "translation_expand", + icon="TRIA_DOWN" if props.translation_expand else "TRIA_RIGHT", + icon_only=True, emboss=False) + row.label(text=t("Translation.advanced_settings")) + + if props.translation_expand: + config_col = service_box.column(align=True) + + # MyMemory settings (no configuration needed) + if props.translation_service == 'mymemory': + config_col.separator() + config_col.label(text="MyMemory Configuration:", icon='CHECKMARK') + success_col = config_col.column() + success_col.alert = False + success_col.label(text="✓ No API key required!", icon='CHECKMARK') + success_col.label(text="✓ Completely free service") + success_col.label(text="✓ 1000 translations per day") + success_col.label(text="✓ Slowest Option due to no native batching") + success_col.label(text="✓ Ready to use!") + + elif props.translation_service == 'libretranslate': + config_col.separator() + config_col.label(text="LibreTranslate Configuration:", icon='SETTINGS') + + # Check current server configuration (cached to avoid performance issues) + try: + if 'libretranslate_url' not in _ui_cache['libretranslate_config']: + from ..core.addon_preferences import get_preference + _ui_cache['libretranslate_config']['libretranslate_url'] = get_preference("libretranslate_url", "https://libretranslate.com") + + server_url = _ui_cache['libretranslate_config']['libretranslate_url'] + + info_col = config_col.column() + info_col.alert = False + info_col.label(text=f"Server: {server_url}", icon='URL') + + if "libretranslate.com" in server_url.lower(): + warning_col = config_col.column() + warning_col.alert = True + warning_col.label(text="⚠ Default server requires payment", icon='ERROR') + warning_col.label(text="Configure your own LibreTranslate server") + else: + success_col = config_col.column() + success_col.alert = False + success_col.label(text="✓ Custom server configured", icon='CHECKMARK') + + config_row = config_col.row() + config_row.operator("avatar_toolkit.configure_libretranslate", text="Configure Server", icon='SETTINGS') + except Exception as e: + config_col.label(text="LibreTranslate configuration error", icon='ERROR') + + elif props.translation_service == 'deepl': + config_col.separator() + config_col.label(text="DeepL Configuration:", icon='SETTINGS') + + # Check if API key is configured (cached to avoid performance issues) + try: + if 'deepl_api_key' not in _ui_cache['deepl_config']: + from ..core.addon_preferences import get_preference + _ui_cache['deepl_config']['deepl_api_key'] = get_preference("deepl_api_key", "") + + deepl_api_key = _ui_cache['deepl_config']['deepl_api_key'] + + if deepl_api_key and deepl_api_key.strip(): + success_col = config_col.column() + success_col.alert = False + success_col.label(text="✓ API key configured", icon='CHECKMARK') + success_col.label(text="✓ High quality translations") + success_col.label(text="✓ 500,000 chars/month free") + success_col.label(text="✓ Ready to use!") + + reconfig_row = config_col.row() + reconfig_row.operator("avatar_toolkit.configure_deepl", text="Reconfigure API Key", icon='SETTINGS') + else: + warning_col = config_col.column() + warning_col.alert = True + warning_col.label(text="⚠ API key required!", icon='ERROR') + warning_col.label(text="Get free key at deepl.com/pro") + warning_col.label(text="500,000 characters/month free") + + config_row = config_col.row() + config_row.operator("avatar_toolkit.configure_deepl", text="Configure API Key", icon='PLUS') + except Exception as e: + config_col.label(text="DeepL configuration error", icon='ERROR') + + + + # Language Settings + lang_box: UILayout = layout.box() + col = lang_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=t("Translation.language_settings"), icon='SYNTAX_ON') + col.separator() + col.prop(props, "translation_source_language", text="From") + col.prop(props, "translation_target_language", text="To") + + # Quick Actions + action_box: UILayout = layout.box() + col = action_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=t("Translation.quick_actions"), icon='PLAY') + col.separator() + + # Translate buttons + row = col.row(align=True) + op_bones = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Bones", icon='BONE_DATA') + op_bones.translation_type = 'bones' + + op_shapes = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Shape Keys", icon='SHAPEKEY_DATA') + op_shapes.translation_type = 'shapekeys' + + row = col.row(align=True) + op_mats = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Materials", icon='MATERIAL_DATA') + op_mats.translation_type = 'materials' + + op_objs = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Objects", icon='OBJECT_DATA') + op_objs.translation_type = 'objects' + + col.separator() + op_all = col.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Translate All", icon='WORLD') + op_all.translation_type = 'all' + + # Utility buttons + util_box: UILayout = layout.box() + col = util_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=t("Translation.utilities"), icon='TOOL_SETTINGS') + col.separator() + + row = col.row(align=True) + row.operator(AvatarToolkit_OT_TestTranslationService.bl_idname, icon='PLAY') + row.operator(AvatarToolkit_OT_TranslationStats.bl_idname, icon='INFO') + + col.operator(AvatarToolkit_OT_ClearTranslationCache.bl_idname, icon='TRASH') + + status_box = layout.box() + status_col = status_box.column() + + try: + status_cache_key = f"translation_status_{props.translation_service}_{props.translation_mode}" + + # Refresh cache periodically + frame = context.scene.frame_current + cache_expired = (frame - _ui_cache['last_refresh_frame'] >= _ui_cache['cache_refresh_interval']) or status_cache_key not in _ui_cache['translation_status'] + + if cache_expired: + from ..core.translation_manager import get_available_translation_services, get_avatar_translation_manager + + manager = get_avatar_translation_manager() + available_services = get_available_translation_services() + + _ui_cache['translation_status'][status_cache_key] = { + 'available_services': available_services, + 'manager': manager, + 'cache_stats': None + } + _ui_cache['last_refresh_frame'] = frame + + try: + stats = manager.get_translation_stats() + _ui_cache['translation_status'][status_cache_key]['cache_stats'] = stats['cache_stats'] + except: + pass + + # Use cached data + cached_data = _ui_cache['translation_status'].get(status_cache_key, {}) + available_services = cached_data.get('available_services', []) + cache_stats = cached_data.get('cache_stats') + + if available_services: + status_col.label(text="Translation services ready", icon='CHECKMARK') + + # Show current service status + current_service = props.translation_service + service_available = any(service_id == current_service for service_id, _ in available_services) + + if service_available: + service_name = next((name for sid, name in available_services if sid == current_service), current_service) + status_col.label(text=f"Active: {service_name}", icon='WORLD') + + # Show translation mode + mode_display = { + 'hybrid': 'Dictionary + API', + 'dictionary_only': 'Dictionary Only', + 'api_only': 'API Only' + }.get(props.translation_mode, props.translation_mode) + status_col.label(text=f"Mode: {mode_display}", icon='SETTINGS') + + # Show cache status + if cache_stats and cache_stats['total_entries'] > 0: + status_col.label(text=f"Cache: {cache_stats['total_entries']} translations", icon='FILE_CACHE') + + # Show batch translation capability + try: + if 'batch_info' not in _ui_cache: + from ..core.translation_manager import get_batch_translation_info + _ui_cache['batch_info'] = get_batch_translation_info() + + batch_info = _ui_cache['batch_info'].get(current_service, {}) + if batch_info.get('supports_batch', False): + batch_type = batch_info.get('batch_type', 'individual') + if batch_type == 'native': + status_col.label(text="⚡ DeepL Native batch translation (up to 50x faster)", icon='LIGHT') + elif batch_type == 'concurrent': + if current_service == 'mymemory': + status_col.label(text="⚡ Slowest Option, no native Batching", icon='LIGHT') + else: + status_col.label(text="⚡ Slightly Faster then MyMemory processing (3x faster)", icon='LIGHT') + except: + pass + + else: + warning_col = status_col.column() + warning_col.alert = True + warning_col.label(text=f"Service unavailable: {props.translation_service}", icon='ERROR') + + + else: + warning_col = status_col.column() + warning_col.alert = True + warning_col.label(text="No translation services available", icon='ERROR') + + if props.translation_service == 'mymemory': + warning_col.label(text="Internet connection required") + + except Exception as e: + error_col = status_col.column() + error_col.alert = True + error_col.label(text="Translation system error", icon='ERROR') + logger.error(f"Status display error: {e}") + + try: + if hasattr(context.area, 'header_text') and context.area.header_text: + progress_col = status_col.column() + progress_col.alert = False + progress_col.label(text=context.area.header_text, icon='TIME') + except: + pass + + diff --git a/ui/vrm_unity_panel.py b/ui/vrm_unity_panel.py new file mode 100644 index 0000000..f1565ab --- /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 = t("VRM.panel.label") + 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=t("VRM.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("VRM.no_armature_selected"), icon='ERROR') + col.label(text=t("VRM.select_armature_to_convert")) + return + + # Check if the armature appears to be VRM + is_vrm = detect_vrm_armature(armature) + + if is_vrm: + col.label(text=t("VRM.armature_name", name=armature.name), icon='CHECKMARK') + col.label(text=t("VRM.armature_detected"), icon='INFO') + col.separator(factor=0.3) + + toolkit = context.scene.avatar_toolkit + col.prop(toolkit, 'vrm_remove_colliders', text=t("VRM.remove_colliders")) + col.prop(toolkit, 'vrm_remove_root', text=t("VRM.remove_root_bone")) + col.separator(factor=0.2) + + col.operator( + AvatarToolkit_OT_ConvertVRMToUnity.bl_idname, + text=t("VRM.convert_to_unity_format"), + icon='EXPORT' + ) + + info_box = vrm_box.box() + info_col = info_box.column(align=True) + info_col.label(text=t("VRM.conversion_info.title"), icon='INFO') + info_col.label(text=t("VRM.conversion_info.renames_bones")) + info_col.label(text=t("VRM.conversion_info.removes_colliders")) + info_col.label(text=t("VRM.conversion_info.removes_root")) + info_col.label(text=t("VRM.conversion_info.maintains_hierarchy")) + info_col.label(text=t("VRM.conversion_info.validates_results")) + info_col.label(text=t("VRM.conversion_info.preserves_animations")) + + else: + col.label(text=t("VRM.armature_name", name=armature.name), icon='ERROR') + col.label(text=t("VRM.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=t("VRM.convert_to_unity_format"), + icon='CANCEL' + ) + + help_box = vrm_box.box() + help_col = help_box.column(align=True) + help_col.label(text=t("VRM.detection_failed.title"), icon='QUESTION') + help_col.label(text=t("VRM.detection_failed.not_vrm_format")) + help_col.label(text=t("VRM.detection_failed.bones_start_with")) + help_col.label(text=t("VRM.detection_failed.need_five_bones")) + help_col.label(text=t("VRM.detection_failed.check_bone_names")) \ No newline at end of file