Merge branch 'Alpha-4' into Alpha-3
This commit is contained in:
+217
-23
@@ -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")
|
||||
|
||||
@@ -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
@@ -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'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user