Merge branch 'Alpha-4' into Alpha-3

This commit is contained in:
Onan Chew
2025-10-06 19:28:01 -04:00
committed by GitHub
24 changed files with 4676 additions and 269 deletions
+217 -23
View File
@@ -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")
+9
View File
@@ -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
+303 -154
View File
@@ -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'
]
}
+372
View File
@@ -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
+235 -2
View File
@@ -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:
+600
View File
@@ -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
+942
View File
@@ -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
+1 -1
View File
@@ -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
+486
View File
@@ -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