Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb533ff64 | |||
| 69cae02160 | |||
| 5496078a39 | |||
| dbf2fb77f9 | |||
| 3de600cf64 | |||
| ba9d579176 | |||
| 35458f9aed | |||
| d2c30caef5 | |||
| 54a1dff122 | |||
| 7dc74964e8 | |||
| 00a015a8d3 | |||
| b9f7a4acd0 | |||
| e626bdc5c5 | |||
| da2bfeb2fc | |||
| 2b53146e83 | |||
| 444554528d | |||
| cae6ce4301 | |||
| 74716b187f | |||
| fbb4569e99 | |||
| 56967fc9a9 | |||
| 5881180e69 | |||
| 4ba594d712 | |||
| 031b78ee7b | |||
| 61c77cf756 | |||
| e19dd78557 | |||
| d820edfc64 | |||
| b39e20e647 | |||
| 929cadd596 | |||
| f90efb549a | |||
| 3e8ab41ab9 | |||
| c28cfe1d1d | |||
| 15ce911256 | |||
| 2f3b8ab0ee | |||
| 7b58f25913 | |||
| d25543d95b | |||
| ba9a7a8af3 | |||
| 408d3f24f7 | |||
| bd33efe7ae | |||
| c50f275b1b | |||
| 1ddda1336a | |||
| 634563afb3 | |||
| 543869218c | |||
| e5e09e2cf3 | |||
| 29f728442a | |||
| 8c2c52f882 | |||
| 6f5e7a394d | |||
| 6eb253be17 | |||
| 5276aa0fe0 | |||
| c830938dce | |||
| 60ba1b363f | |||
| e3052d867d | |||
| 08082501c9 | |||
| a8482a87f3 | |||
| 482fe1b593 |
@@ -1,4 +1,5 @@
|
||||
# Avatar Toolkit
|
||||
We are aware the wiki is down and are working on a new one, please don't report this.
|
||||
|
||||
## Avatar Toolkit is in Alpha, There will be issues, please ensure you report them!. If using a Alpha plugin isn't your fancy you can find Cats Blender Plugin [HERE](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-)!
|
||||
#### Avatar Toolkit is in Alpha and will contain issues, please ensure you report them!
|
||||
@@ -21,20 +22,19 @@ Need a more stable toolset while Avatar Toolkit is in Alpha? Then please use Ble
|
||||
### Support us:
|
||||
If you like what we do and want to help support the development of cats you can do it on our pally.gg [here](https://pally.gg/p/teamneoneko) all money is split automatically between all developers and any support is appreciated.
|
||||
|
||||
## Blender version support policies.
|
||||
## Blender version support policies.
|
||||
|
||||
You can find them on the wiki here [HERE](https://avatartoolkit.xyz/legacywiki.html?version=0.2.1#what-is-avatar-toolkits-version-support-policy)
|
||||
|
||||
## Features
|
||||
## Features
|
||||
|
||||
See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/legacywiki.html)
|
||||
|
||||
## Requirements
|
||||
|
||||
1) Blender Version
|
||||
- Blender 4.4 or newer is required
|
||||
- Blender 4.4 is the current recommended version
|
||||
|
||||
- Blender 4.5 or newer is required
|
||||
- Blender 4.5 is the current recommended version
|
||||
|
||||
2) Python Requirements
|
||||
- If using a custom Python installation with Blender, ensure NumPy is installed
|
||||
@@ -42,7 +42,17 @@ See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/lega
|
||||
|
||||
3) Recommended Setup
|
||||
- Download Blender directly from https://blender.org
|
||||
- Use Blender 4.4 for the best experience
|
||||
- Use Blender 4.5 for the best experience
|
||||
|
||||
#### Unfortunately, due to the increased number of people complaining to me (yes, we get DMs about this) that AT or CATS is broken when it's not, we are going to have to be a bit more strict about which Blender releases we will provide support for.
|
||||
|
||||
#### We only support the following Blender releases:
|
||||
- Steam release
|
||||
- The Blender website releases (there are downloads for Linux, Mac, and Windows)
|
||||
|
||||
#### We do not support the following what so ever and we will not give help if your running the following.
|
||||
- We do not support the Windows Store due to it causing issues, and we also don't support the Snap Store for Linux.
|
||||
- We do not support package manager releases on Linux. This is because package managers are normally run by the distro, and a lot of the time the distro will build Blender themselves and make their own changes which are not sanctioned by Blender (for example, bundling a newer version of Python which tends to break plugins). If you report a bug from anything apart from the Blender versions we support, you will be told we can't help you from now on.
|
||||
|
||||
#### Additional Plugins Requirements.
|
||||
Currently None.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "avatar_toolkit"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
name = "Avatar Toolkit"
|
||||
tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games."
|
||||
maintainer = "Team NekoNeo"
|
||||
type = "add-on"
|
||||
|
||||
blender_version_min = "4.4.0"
|
||||
blender_version_min = "4.5.0"
|
||||
|
||||
license = [
|
||||
"SPDX:GPL-3.0-or-later",
|
||||
|
||||
+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")
|
||||
|
||||
+12
-1
@@ -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:
|
||||
@@ -650,9 +656,14 @@ class ArmatureData(Tuple[bool,bool]):
|
||||
|
||||
def store_breaking_settings_armature(armature: bpy.types.Object) -> ArmatureData:
|
||||
armature_data: bpy.types.Armature = armature.data
|
||||
return (armature_data.use_mirror_x, armature.pose.use_mirror_x)
|
||||
data: ArmatureData = (armature_data.use_mirror_x, armature.pose.use_mirror_x)
|
||||
armature_data.use_mirror_x, armature.pose.use_mirror_x = (False, False)
|
||||
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
|
||||
|
||||
|
||||
+305
-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,61 @@ 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',
|
||||
'LeftUpperLeg'
|
||||
],
|
||||
'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', 'LeftToeBase'
|
||||
],
|
||||
|
||||
'right_leg': [
|
||||
'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg',
|
||||
'ORG-thigh.R', 'thigh.R',
|
||||
'rThighBend', 'rThigh'
|
||||
'rThighBend', 'rThigh', 'UpperLeg.R',
|
||||
'RightUpperLeg'
|
||||
],
|
||||
'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', 'RightToeBase'
|
||||
],
|
||||
|
||||
'thumb_1_l': [
|
||||
@@ -934,12 +1085,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
|
||||
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ 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"]
|
||||
# ["0.2", "0.3"] would look for both 0.2.x and 0.3.x
|
||||
ALLOWED_ = ["0.3, 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
|
||||
@@ -8,6 +8,7 @@ from ...core.translations import t
|
||||
import traceback
|
||||
from ...core.common import (
|
||||
get_all_meshes,
|
||||
get_meshes_for_armature,
|
||||
fix_zero_length_bones,
|
||||
remove_unused_vertex_groups,
|
||||
clear_unused_data_blocks,
|
||||
@@ -28,10 +29,32 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return len(get_all_meshes(context)) > 1
|
||||
# Check if we have valid armature selections for merging
|
||||
base_armature_name: str = context.scene.avatar_toolkit.merge_armature_into
|
||||
merge_armature_name: str = context.scene.avatar_toolkit.merge_armature
|
||||
|
||||
if not base_armature_name or not merge_armature_name:
|
||||
return False
|
||||
|
||||
base_armature: Optional[Object] = bpy.data.objects.get(base_armature_name)
|
||||
merge_armature: Optional[Object] = bpy.data.objects.get(merge_armature_name)
|
||||
|
||||
return (base_armature is not None and
|
||||
merge_armature is not None and
|
||||
base_armature.type == 'ARMATURE' and
|
||||
merge_armature.type == 'ARMATURE' and
|
||||
base_armature != merge_armature)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
# Store original mode to restore later
|
||||
original_mode: str = context.mode
|
||||
logger.debug(f"Original mode: {original_mode}")
|
||||
|
||||
# Switch to object mode if not already
|
||||
if context.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
wm = context.window_manager
|
||||
wm.progress_begin(0, 100)
|
||||
|
||||
@@ -48,6 +71,9 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
#Store current armature settings that can mess us up.
|
||||
data_breaking_base = store_breaking_settings_armature(base_armature)
|
||||
data_breaking_merge = store_breaking_settings_armature(merge_armature)
|
||||
|
||||
# Store the merge armature name before it gets removed during join
|
||||
merge_armature_name_stored = merge_armature.name
|
||||
|
||||
# Remove Rigid Bodies and Joints
|
||||
delete_rigidbodies_and_joints(base_armature)
|
||||
@@ -76,16 +102,43 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator):
|
||||
wm.progress_update(100)
|
||||
wm.progress_end()
|
||||
|
||||
# Restore settings only for the base armature since merge_armature is removed during join
|
||||
restore_breaking_settings_armature(base_armature, data_breaking_base)
|
||||
restore_breaking_settings_armature(merge_armature, data_breaking_merge)
|
||||
if merge_armature_name_stored in bpy.data.objects:
|
||||
merge_armature_obj = bpy.data.objects[merge_armature_name_stored]
|
||||
restore_breaking_settings_armature(merge_armature_obj, data_breaking_merge)
|
||||
|
||||
# Restore original mode if it wasn't OBJECT
|
||||
try:
|
||||
if original_mode == 'EDIT_ARMATURE':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
elif original_mode == 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
elif original_mode != 'OBJECT':
|
||||
logger.debug(f"Restoring to original mode: {original_mode}")
|
||||
# For other modes, stay in object mode as it's safest
|
||||
except Exception:
|
||||
logger.warning(f"Could not restore original mode: {original_mode}")
|
||||
|
||||
self.report({'INFO'}, t('MergeArmature.success'))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error merging armatures:", exception=e)
|
||||
self.report({'ERROR'}, traceback.format_exc())
|
||||
errormessage: str = traceback.format_exc()
|
||||
logger.error(f"Error merging armatures: {str(e)}\n{errormessage}")
|
||||
self.report({'ERROR'}, f"Error merging armatures: {errormessage}")
|
||||
|
||||
# Try to restore original mode even on error
|
||||
try:
|
||||
if 'original_mode' in locals() and original_mode != 'OBJECT':
|
||||
if original_mode == 'EDIT_ARMATURE':
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
elif original_mode == 'POSE':
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
except Exception:
|
||||
logger.warning("Could not restore mode after error")
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
def delete_rigidbodies_and_joints(armature: Object) -> None:
|
||||
|
||||
@@ -186,7 +186,6 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
if not armature:
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Store initial transforms
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
initial_transforms: Dict[str, Dict[str, Any]] = {}
|
||||
data_breaking = store_breaking_settings_armature(armature)
|
||||
@@ -200,56 +199,61 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
'parent': bone.parent.name if bone.parent else None
|
||||
}
|
||||
|
||||
# Get weighted bones
|
||||
# Get bones with any weight
|
||||
weighted_bones: List[str] = []
|
||||
meshes = get_all_meshes(context)
|
||||
zero_weight_bones: List[str] = []
|
||||
|
||||
for mesh in meshes:
|
||||
mesh_data: Mesh = mesh.data
|
||||
for vertex in mesh_data.vertices:
|
||||
for vertex in mesh.data.vertices:
|
||||
for group in vertex.groups:
|
||||
if group.weight > context.scene.avatar_toolkit.merge_weights_threshold:
|
||||
weighted_bones.append(mesh.vertex_groups[group.group].name)
|
||||
vg = mesh.vertex_groups[group.group]
|
||||
if vg.name not in weighted_bones:
|
||||
weighted_bones.append(vg.name)
|
||||
|
||||
# Process bone removal
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
armature_data: Armature = armature.data
|
||||
armature_data = armature.data
|
||||
removed_count = 0
|
||||
zero_weight_bones: List[str] = []
|
||||
|
||||
for bone in armature_data.edit_bones[:]: # Create a copy of the list
|
||||
if (bone.name not in weighted_bones and
|
||||
not self.should_preserve_bone(bone.name, context)):
|
||||
|
||||
if context.scene.avatar_toolkit.list_only_mode:
|
||||
zero_weight_bones.append(bone.name)
|
||||
continue
|
||||
def is_zero_weight_chain(bone, weighted_bones, preserve_check_fn):
|
||||
if bone.name in weighted_bones or preserve_check_fn(bone.name, context):
|
||||
return False
|
||||
return all(is_zero_weight_chain(child, weighted_bones, preserve_check_fn) for child in bone.children)
|
||||
|
||||
# Store children data
|
||||
children = bone.children
|
||||
children_data = {child.name: initial_transforms[child.name] for child in children}
|
||||
for bone in armature_data.edit_bones[:]:
|
||||
if bone.name in weighted_bones or self.should_preserve_bone(bone.name, context):
|
||||
continue
|
||||
|
||||
# Reparent children
|
||||
for child in children:
|
||||
if not is_zero_weight_chain(bone, weighted_bones, self.should_preserve_bone):
|
||||
continue
|
||||
|
||||
if context.scene.avatar_toolkit.list_only_mode:
|
||||
zero_weight_bones.append(bone.name)
|
||||
continue
|
||||
|
||||
# Traverse and collect the full empty chain
|
||||
stack = [bone]
|
||||
chain = []
|
||||
|
||||
while stack:
|
||||
b = stack.pop()
|
||||
chain.append(b)
|
||||
stack.extend(b.children)
|
||||
|
||||
for b in reversed(chain): # Remove children before parents
|
||||
for child in b.children:
|
||||
child.use_connect = False
|
||||
if bone.parent:
|
||||
child.parent = bone.parent
|
||||
|
||||
# Remove bone
|
||||
armature_data.edit_bones.remove(bone)
|
||||
removed_count += 1
|
||||
|
||||
# Restore children positions
|
||||
for child_name, data in children_data.items():
|
||||
if child_name in armature_data.edit_bones:
|
||||
child = armature_data.edit_bones[child_name]
|
||||
restore_bone_transforms(child, data)
|
||||
if b.parent:
|
||||
child.parent = b.parent
|
||||
if b.name in armature_data.edit_bones:
|
||||
armature_data.edit_bones.remove(b)
|
||||
removed_count += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
if context.scene.avatar_toolkit.list_only_mode:
|
||||
self.populate_bone_list(context, zero_weight_bones)
|
||||
return {'FINISHED'}
|
||||
|
||||
restore_breaking_settings_armature(armature, data_breaking)
|
||||
self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count))
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from ...core.logging_setup import logger
|
||||
|
||||
|
||||
class AvatarToolkit_OT_RemoveAllColliders(Operator):
|
||||
"""Remove all objects with 'collider' in their name"""
|
||||
bl_idname = "avatar_toolkit.remove_all_colliders"
|
||||
bl_label = "Remove All Colliders"
|
||||
bl_description = "Remove all objects that have 'collider' in their name"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
logger.info("Starting standalone collider removal")
|
||||
|
||||
# Store current mode and active object
|
||||
current_mode = bpy.context.mode
|
||||
original_active = bpy.context.view_layer.objects.active
|
||||
|
||||
# Switch to object mode
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
try:
|
||||
# Find all collider objects
|
||||
collider_names = []
|
||||
all_objects = list(bpy.data.objects)
|
||||
|
||||
logger.info(f"Scanning {len(all_objects)} objects for colliders")
|
||||
|
||||
for obj in all_objects:
|
||||
if 'collider' in obj.name.lower():
|
||||
collider_names.append(obj.name)
|
||||
logger.info(f"Found collider: {obj.name}")
|
||||
|
||||
if not collider_names:
|
||||
self.report({'INFO'}, "No collider objects found")
|
||||
logger.info("No collider objects found")
|
||||
return {'FINISHED'}
|
||||
|
||||
logger.info(f"Found {len(collider_names)} collider objects to remove")
|
||||
self.report({'INFO'}, f"Found {len(collider_names)} collider objects")
|
||||
|
||||
# Remove each collider
|
||||
removed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for obj_name in collider_names:
|
||||
try:
|
||||
if obj_name in bpy.data.objects:
|
||||
obj = bpy.data.objects[obj_name]
|
||||
|
||||
# Deselect all objects first
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Select and make active
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
# Delete the object
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
removed_count += 1
|
||||
logger.info(f"Removed collider: {obj_name}")
|
||||
|
||||
else:
|
||||
logger.debug(f"Object {obj_name} no longer exists")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Failed to remove {obj_name}: {str(e)}")
|
||||
self.report({'WARNING'}, f"Failed to remove {obj_name}: {str(e)}")
|
||||
|
||||
# Report results
|
||||
if removed_count > 0:
|
||||
success_msg = f"Successfully removed {removed_count} collider objects"
|
||||
logger.info(success_msg)
|
||||
self.report({'INFO'}, success_msg)
|
||||
|
||||
if failed_count > 0:
|
||||
failure_msg = f"Failed to remove {failed_count} collider objects"
|
||||
logger.warning(failure_msg)
|
||||
self.report({'WARNING'}, failure_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during collider removal: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
self.report({'ERROR'}, error_msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
finally:
|
||||
# Restore original state
|
||||
try:
|
||||
if original_active and original_active.name in bpy.data.objects:
|
||||
bpy.context.view_layer.objects.active = original_active
|
||||
|
||||
if current_mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode=current_mode)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -119,8 +119,10 @@ class AvatarToolkit_OT_ExplodeMesh(Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
|
||||
return context.view_layer.objects.active.type == "MESH" and len(context.view_layer.objects.selected) == 1
|
||||
active_obj = context.view_layer.objects.active
|
||||
return (active_obj is not None and
|
||||
active_obj.type == "MESH" and
|
||||
len(context.view_layer.objects.selected) == 1)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import traceback
|
||||
import bpy
|
||||
from typing import Dict, List, Set, Optional, Tuple, Any
|
||||
from bpy.types import Operator, Context, Object, PoseBone, EditBone, Bone, Constraint
|
||||
from ...core.common import get_active_armature
|
||||
from ...core.common import get_active_armature, transfer_vertex_weights, get_all_meshes
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.dictionaries import rigify_unity_names, rigify_basic_unity_names, rigify_unnecessary_bones
|
||||
@@ -69,19 +69,50 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
|
||||
|
||||
# Set armature as active object before mode switch
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bones_to_remove: List[str] = []
|
||||
for bone in armature.data.edit_bones:
|
||||
if any(pattern in bone.name.lower() for pattern in rigify_unnecessary_bones):
|
||||
bone_name_lower = bone.name.lower()
|
||||
if any(bone_name_lower.startswith(pattern) or bone_name_lower == pattern
|
||||
for pattern in rigify_unnecessary_bones):
|
||||
bones_to_remove.append(bone.name)
|
||||
|
||||
|
||||
# Check for neck bones that need merging
|
||||
merge_neck_bones = 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Transfer weights from bones being removed
|
||||
for bone_name in bones_to_remove:
|
||||
if bone_name in armature.data.bones:
|
||||
logger.debug(f"Transferring weights from bone: {bone_name}")
|
||||
for mesh in meshes:
|
||||
if bone_name in mesh.vertex_groups:
|
||||
# Remove the vertex group since we don't need the weights
|
||||
mesh.vertex_groups.remove(mesh.vertex_groups[bone_name])
|
||||
|
||||
# Transfer weights for neck bone merging
|
||||
if merge_neck_bones:
|
||||
logger.debug("Transferring weights from spine.005 to spine.004")
|
||||
for mesh in meshes:
|
||||
if 'spine.005' in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, 'spine.005', 'spine.004')
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Remove unnecessary bones
|
||||
for bone_name in bones_to_remove:
|
||||
if bone_name in armature.data.edit_bones:
|
||||
logger.debug(f"Removing bone: {bone_name}")
|
||||
armature.data.edit_bones.remove(armature.data.edit_bones[bone_name])
|
||||
|
||||
if 'spine.004' in armature.data.edit_bones and 'spine.005' in armature.data.edit_bones:
|
||||
# Merge neck bones
|
||||
if merge_neck_bones:
|
||||
logger.debug("Merging neck bones")
|
||||
neck_start = armature.data.edit_bones['spine.004']
|
||||
neck_end = armature.data.edit_bones['spine.005']
|
||||
@@ -89,6 +120,7 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
|
||||
armature.data.edit_bones.remove(neck_end)
|
||||
neck_start.name = "Neck"
|
||||
|
||||
# Rename head bone
|
||||
if 'spine.006' in armature.data.edit_bones:
|
||||
logger.debug("Renaming head bone")
|
||||
head_bone = armature.data.edit_bones['spine.006']
|
||||
@@ -137,6 +169,22 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
|
||||
if bone_name in armature.data.bones:
|
||||
armature.data.bones[bone_name].use_deform = False
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
parent_name = armature.data.bones[bone_name].parent.name if armature.data.bones[bone_name].parent else None
|
||||
if parent_name:
|
||||
logger.debug(f"Transferring weights from {bone_name} to {parent_name}")
|
||||
for mesh in meshes:
|
||||
if bone_name in mesh.vertex_groups and parent_name in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, bone_name, parent_name)
|
||||
elif bone_name in mesh.vertex_groups:
|
||||
# Remove weights if no parent to merge to
|
||||
mesh.vertex_groups.remove(mesh.vertex_groups[bone_name])
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
@@ -190,6 +238,17 @@ class AvatarToolkit_OT_ConvertRigifyToUnity(Operator):
|
||||
("DEF-thigh_twist.R", "DEF-thigh.R")
|
||||
]
|
||||
|
||||
# Get all meshes for weight transfer
|
||||
meshes = get_all_meshes(bpy.context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for twist_bone, parent_bone in twist_bones:
|
||||
if twist_bone in armature.data.bones and parent_bone in armature.data.bones:
|
||||
logger.debug(f"Transferring weights from {twist_bone} to {parent_bone}")
|
||||
for mesh in meshes:
|
||||
if twist_bone in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, twist_bone, parent_bone)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for twist_bone, parent_bone in twist_bones:
|
||||
if twist_bone in armature.data.edit_bones and parent_bone in armature.data.edit_bones:
|
||||
|
||||
@@ -13,7 +13,9 @@ from ...core.dictionaries import (
|
||||
bone_hierarchy,
|
||||
acceptable_bone_names,
|
||||
acceptable_bone_hierarchy,
|
||||
non_standard_mappings
|
||||
non_standard_mappings,
|
||||
reverse_bone_lookup,
|
||||
simplify_bonename
|
||||
)
|
||||
|
||||
class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
@@ -53,12 +55,6 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
|
||||
logger.info(f"Starting armature standardization for {armature.name}")
|
||||
|
||||
is_valid, _, _ = validate_armature(armature)
|
||||
if is_valid:
|
||||
logger.info("Armature already meets standards, no changes needed")
|
||||
self.report({'INFO'}, t("Tools.standardize_already_valid"))
|
||||
return {'FINISHED'}
|
||||
|
||||
original_mode: str = context.mode
|
||||
logger.debug(f"Original mode: {original_mode}")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
@@ -88,7 +84,7 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
logger.info(f"Fixed {fixed_scale} scale issues")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
is_valid, messages, _ = validate_armature(armature)
|
||||
is_valid, messages, _ = validate_armature(armature, override_mode='STRICT')
|
||||
|
||||
if is_valid:
|
||||
logger.info("Armature successfully standardized")
|
||||
@@ -134,17 +130,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
existing_standard_bones.add(bone.name)
|
||||
logger.debug(f"Found existing standard bone: {bone.name}")
|
||||
|
||||
# Build a mapping of non-standard bone names to standard names
|
||||
# Use the reverse bone lookup that's already built and simplified
|
||||
name_mapping: Dict[str, str] = {}
|
||||
for category, standard_name in standard_bones.items():
|
||||
# Skip if this standard bone already exists
|
||||
if standard_name in existing_standard_bones:
|
||||
continue
|
||||
|
||||
# Get all variants for this category
|
||||
if category in non_standard_mappings:
|
||||
for variant in non_standard_mappings[category]:
|
||||
name_mapping[variant.lower()] = standard_name
|
||||
for simplified_name, category in reverse_bone_lookup.items():
|
||||
if category in standard_bones:
|
||||
standard_name = standard_bones[category]
|
||||
# Skip if this standard bone already exists
|
||||
if standard_name not in existing_standard_bones:
|
||||
name_mapping[simplified_name] = standard_name
|
||||
|
||||
# First pass: identify bones to rename
|
||||
bones_to_rename: Dict[str, str] = {}
|
||||
@@ -155,20 +148,14 @@ class AvatarToolkit_OT_StandardizeArmature(Operator):
|
||||
if original_name in standard_bones.values():
|
||||
continue
|
||||
|
||||
simplified_name: str = original_name.lower().replace(' ', '').replace('_', '').replace('.', '')
|
||||
simplified_name: str = simplify_bonename(original_name)
|
||||
|
||||
# Check if this bone matches any known pattern
|
||||
for variant, standard_name in name_mapping.items():
|
||||
# More precise matching - exact match or with common separators
|
||||
if (variant == simplified_name or
|
||||
variant == original_name.lower() or
|
||||
f"{variant}_" in simplified_name or
|
||||
f"{variant}." in simplified_name):
|
||||
|
||||
if original_name != standard_name:
|
||||
bones_to_rename[original_name] = standard_name
|
||||
logger.debug(f"Identified bone to rename: {original_name} -> {standard_name}")
|
||||
break
|
||||
# Check if this simplified bone name has a standard mapping
|
||||
if simplified_name in name_mapping:
|
||||
standard_name = name_mapping[simplified_name]
|
||||
if original_name != standard_name:
|
||||
bones_to_rename[original_name] = standard_name
|
||||
logger.debug(f"Identified bone to rename: {original_name} -> {standard_name}")
|
||||
|
||||
# Special case for spine/chest hierarchy
|
||||
# If we don't have an upper chest, don't rename chest to upper chest because it will break hierarchy
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from ...core.common import get_active_armature
|
||||
from ...core.translations import t
|
||||
from ...core.vrm_unity_converter import convert_vrm_to_unity, validate_unity_hierarchy
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.armature_validation import validate_armature
|
||||
|
||||
|
||||
class AvatarToolkit_OT_ConvertVRMToUnity(Operator):
|
||||
"""Convert VRM armature bone names to Unity humanoid format"""
|
||||
bl_idname = "avatar_toolkit.convert_vrm_to_unity"
|
||||
bl_label = t("VRM.convert_to_unity.label")
|
||||
bl_description = t("VRM.convert_to_unity.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No active armature found for VRM conversion")
|
||||
self.report({'ERROR'}, t("VRM.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Starting VRM to Unity conversion for armature: {armature.name}")
|
||||
|
||||
# Get conversion settings
|
||||
remove_colliders = context.scene.avatar_toolkit.vrm_remove_colliders
|
||||
remove_root = context.scene.avatar_toolkit.vrm_remove_root
|
||||
logger.info(f"Collider removal setting: {remove_colliders}")
|
||||
logger.info(f"Root bone removal setting: {remove_root}")
|
||||
|
||||
# Log all objects with 'collider' in name for debugging
|
||||
collider_objects = [obj.name for obj in bpy.data.objects if 'collider' in obj.name.lower()]
|
||||
if collider_objects:
|
||||
logger.info(f"Found {len(collider_objects)} objects with 'collider' in name:")
|
||||
for obj_name in collider_objects:
|
||||
logger.info(f" - {obj_name}")
|
||||
|
||||
success, messages, converted_count = convert_vrm_to_unity(armature, remove_colliders, remove_root)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"VRM conversion failed: {messages}")
|
||||
for msg in messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"VRM conversion completed successfully. Converted {converted_count} bones")
|
||||
for msg in messages:
|
||||
self.report({'INFO'}, msg)
|
||||
|
||||
# Validate the converted armature
|
||||
try:
|
||||
is_valid, validation_messages = validate_unity_hierarchy(armature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("Unity hierarchy validation passed")
|
||||
self.report({'INFO'}, t("VRM.validation.hierarchy_passed"))
|
||||
else:
|
||||
logger.warning("Unity hierarchy validation found issues")
|
||||
self.report({'WARNING'}, t("VRM.validation.hierarchy_issues"))
|
||||
for msg in validation_messages:
|
||||
self.report({'WARNING'}, msg)
|
||||
|
||||
try:
|
||||
armature_valid, armature_messages, _ = validate_armature(armature)
|
||||
if armature_valid:
|
||||
logger.info("Full armature validation passed")
|
||||
self.report({'INFO'}, t("VRM.validation.armature_passed"))
|
||||
else:
|
||||
logger.info("Full armature validation found minor issues")
|
||||
# Don't report these as errors since the conversion was successful
|
||||
# Just log them for debugging
|
||||
for msg in armature_messages[:3]:
|
||||
logger.debug(f"Armature validation: {msg}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during full armature validation: {str(e)}")
|
||||
# Don't fail the operation for validation errors
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during hierarchy validation: {str(e)}")
|
||||
self.report({'WARNING'}, t("VRM.validation.failed", error=str(e)))
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.3.0)",
|
||||
"AvatarToolkit.label": "Avatar Toolkit (Alpha 0.4.0)",
|
||||
"AvatarToolkit.desc1": "Avatar Toolkit is in Early Access there",
|
||||
"AvatarToolkit.desc2": "will be issues, if you find any issues,",
|
||||
"AvatarToolkit.desc3": "please report it on our Github.",
|
||||
@@ -325,6 +325,7 @@
|
||||
"Visemes.success": "Visemes created successfully",
|
||||
"Visemes.mesh_select": "Select Mesh",
|
||||
"Visemes.mesh_select_desc": "Select the mesh to create visemes on",
|
||||
"Visemes.no_meshes": "No meshes found",
|
||||
|
||||
"EyeTracking.label": "Eye Tracking",
|
||||
"EyeTracking.setup": "Eye Tracking Setup",
|
||||
@@ -517,6 +518,8 @@
|
||||
"TextureAtlas.save_file_instructions": "Use File > Save As... or click the button below:",
|
||||
"TextureAtlas.save_file_button": "Save Blender File",
|
||||
"TextureAtlas.save_file_required": "Save File Required",
|
||||
"TextureAtlas.search_materials": "Search Materials",
|
||||
"TextureAtlas.search_materials_desc": "Filter materials by name",
|
||||
|
||||
"Settings.label": "Settings",
|
||||
"Settings.language": "Language",
|
||||
@@ -554,6 +557,99 @@
|
||||
"Language.ko_KR": "Korean",
|
||||
"Language.changed.title": "Language Changed",
|
||||
"Language.changed.success": "Language changed successfully!",
|
||||
"Language.changed.restart": "Some UI elements may require restarting Blender"
|
||||
}
|
||||
"Language.changed.restart": "Some UI elements may require restarting Blender",
|
||||
|
||||
"VRM.panel.label": "VRM to Unity",
|
||||
"VRM.converter.title": "VRM Converter",
|
||||
"VRM.no_armature_selected": "No armature selected",
|
||||
"VRM.select_armature_to_convert": "Select an armature to convert",
|
||||
"VRM.armature_name": "Armature: {name}",
|
||||
"VRM.armature_detected": "VRM armature detected",
|
||||
"VRM.no_vrm_bones_detected": "No VRM bones detected",
|
||||
"VRM.remove_colliders": "Remove Colliders",
|
||||
"VRM.remove_root_bone": "Remove Root Bone",
|
||||
"VRM.convert_to_unity_format": "Convert to Unity Format",
|
||||
"VRM.convert_to_unity.label": "Convert VRM to Unity",
|
||||
"VRM.convert_to_unity.desc": "Convert VRM armature bone names to Unity humanoid naming convention",
|
||||
"VRM.conversion_info.title": "Conversion Info:",
|
||||
"VRM.conversion_info.renames_bones": "• Renames VRM bones to Unity format",
|
||||
"VRM.conversion_info.removes_colliders": "• Removes collider bones (optional)",
|
||||
"VRM.conversion_info.removes_root": "• Removes root bone, makes Hips root (optional)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• Maintains bone hierarchy",
|
||||
"VRM.conversion_info.validates_results": "• Validates conversion results",
|
||||
"VRM.conversion_info.preserves_animations": "• Preserves all animations",
|
||||
"VRM.detection_failed.title": "VRM Detection Failed:",
|
||||
"VRM.detection_failed.not_vrm_format": "• Selected armature is not VRM format",
|
||||
"VRM.detection_failed.bones_start_with": "• VRM bones start with 'J_Bip_C_'",
|
||||
"VRM.detection_failed.need_five_bones": "• Need at least 5 VRM bones detected",
|
||||
"VRM.detection_failed.check_bone_names": "• Check armature bone names",
|
||||
"VRM.validation.hierarchy_passed": "Unity hierarchy validation passed",
|
||||
"VRM.validation.hierarchy_issues": "Conversion completed but hierarchy validation found issues:",
|
||||
"VRM.validation.armature_passed": "Armature passes standard validation",
|
||||
"VRM.validation.failed": "Conversion completed but validation failed: {error}",
|
||||
"VRM.remove_colliders": "Remove Colliders",
|
||||
"VRM.remove_colliders_desc": "Remove VRM collider bones during conversion",
|
||||
"VRM.remove_root": "Remove Root Bone",
|
||||
"VRM.remove_root_desc": "Remove unnecessary VRM root bone and make Hips the root bone",
|
||||
|
||||
"Translation.label": "Translation",
|
||||
"Translation.service": "Translation Service",
|
||||
"Translation.service_desc": "Choose the translation service to use",
|
||||
"Translation.mode": "Translation Mode",
|
||||
"Translation.mode_desc": "Select how translation should work",
|
||||
"Translation.mode.hybrid": "Hybrid (Dictionary + API)",
|
||||
"Translation.mode.hybrid_desc": "Try dictionary first, then use API service as fallback",
|
||||
"Translation.mode.dictionary_only": "Dictionary Only",
|
||||
"Translation.mode.dictionary_only_desc": "Only use built-in dictionaries for translation",
|
||||
"Translation.mode.api_only": "API Only",
|
||||
"Translation.mode.api_only_desc": "Only use online translation services",
|
||||
"Translation.service_settings": "Translation Service",
|
||||
"Translation.language_settings": "Language Settings",
|
||||
"Translation.quick_actions": "Quick Actions",
|
||||
"Translation.utilities": "Utilities",
|
||||
"Translation.advanced_settings": "Advanced Settings",
|
||||
"Translation.source_language": "Source Language",
|
||||
"Translation.source_language_desc": "Language to translate from",
|
||||
"Translation.target_language": "Target Language",
|
||||
"Translation.target_language_desc": "Language to translate to",
|
||||
"Translation.translate_names": "Translate Names",
|
||||
"Translation.translate_names_desc": "Translate names using the selected service and settings",
|
||||
"Translation.test_service": "Test Service",
|
||||
"Translation.test_service_desc": "Test the currently selected translation service",
|
||||
"Translation.clear_cache": "Clear Cache",
|
||||
"Translation.clear_cache_desc": "Clear all cached translations",
|
||||
"Translation.show_stats": "Show Statistics",
|
||||
"Translation.show_stats_desc": "Show translation statistics and information",
|
||||
"Translation.no_armature": "No armature selected",
|
||||
"Translation.test_failed": "Translation service test failed - check configuration",
|
||||
"Translation.cache_cleared": "Translation cache cleared successfully",
|
||||
"Translation.mymemory_info": "MyMemory is completely free with no API key required. Provides 1000 translations per day.",
|
||||
"Translation.service.mymemory": "MyMemory (Free)",
|
||||
"Translation.service.mymemory_desc": "Completely free service - no API key needed!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "Configurable server - can be self-hosted",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "High-quality translations - API key required",
|
||||
"Translation.type.bones": "Bones",
|
||||
"Translation.type.bones_desc": "Translate bone names",
|
||||
"Translation.type.shapekeys": "Shape Keys",
|
||||
"Translation.type.shapekeys_desc": "Translate shape key names",
|
||||
"Translation.type.materials": "Materials",
|
||||
"Translation.type.materials_desc": "Translate material names",
|
||||
"Translation.type.objects": "Objects",
|
||||
"Translation.type.objects_desc": "Translate object names",
|
||||
"Translation.type.all": "All",
|
||||
"Translation.type.all_desc": "Translate all supported types",
|
||||
"Translation.configure_deepl": "Configure DeepL API",
|
||||
"Translation.configure_deepl_desc": "Configure DeepL translation service API key",
|
||||
"Translation.deepl_api_key": "DeepL API Key",
|
||||
"Translation.deepl_api_key_desc": "Your DeepL API key (get free key at deepl.com/pro)",
|
||||
"Translation.configure_libretranslate": "Configure LibreTranslate Server",
|
||||
"Translation.configure_libretranslate_desc": "Configure LibreTranslate translation service server URL",
|
||||
"Translation.server_url": "Server URL",
|
||||
"Translation.server_url_desc": "LibreTranslate server URL (e.g., https://your-server.com)",
|
||||
"Translation.api_key": "API Key",
|
||||
"Translation.api_key_desc": "API key for LibreTranslate server (optional for some servers)"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "アバターツールキット (アルファ 0.3.0)",
|
||||
"AvatarToolkit.label": "アバターツールキット (アルファ 0.4.0)",
|
||||
"AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、",
|
||||
"AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、",
|
||||
"AvatarToolkit.desc3": "GitHubで報告してください。",
|
||||
@@ -308,6 +308,7 @@
|
||||
"Visemes.success": "口形素が正常に作成されました",
|
||||
"Visemes.mesh_select": "メッシュを選択",
|
||||
"Visemes.mesh_select_desc": "口形素を作成するメッシュを選択",
|
||||
"Visemes.no_meshes": "メッシュが見つかりません",
|
||||
|
||||
"EyeTracking.label": "アイトラッキング",
|
||||
"EyeTracking.setup": "アイトラッキング設定",
|
||||
@@ -500,6 +501,8 @@
|
||||
"TextureAtlas.save_file_instructions": "ファイル > 名前を付けて保存... を使用するか、下のボタンをクリックしてください:",
|
||||
"TextureAtlas.save_file_button": "Blenderファイルを保存",
|
||||
"TextureAtlas.save_file_required": "ファイルの保存が必要です",
|
||||
"TextureAtlas.search_materials": "マテリアルを検索",
|
||||
"TextureAtlas.search_materials_desc": "名前でマテリアルをフィルタリング",
|
||||
|
||||
"Settings.label": "設定",
|
||||
"Settings.language": "言語",
|
||||
@@ -537,6 +540,98 @@
|
||||
"Language.ko_KR": "韓国語",
|
||||
"Language.changed.title": "言語が変更されました",
|
||||
"Language.changed.success": "言語が正常に変更されました!",
|
||||
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります"
|
||||
"Language.changed.restart": "一部のUI要素はBlenderの再起動が必要な場合があります",
|
||||
|
||||
"VRM.panel.label": "VRMからUnityへ",
|
||||
"VRM.converter.title": "VRMコンバーター",
|
||||
"VRM.no_armature_selected": "アーマチュアが選択されていません",
|
||||
"VRM.select_armature_to_convert": "変換するアーマチュアを選択してください",
|
||||
"VRM.armature_name": "アーマチュア: {name}",
|
||||
"VRM.armature_detected": "VRMアーマチュアが検出されました",
|
||||
"VRM.no_vrm_bones_detected": "VRMボーンが検出されませんでした",
|
||||
"VRM.remove_colliders": "コライダーを削除",
|
||||
"VRM.remove_root_bone": "ルートボーンを削除",
|
||||
"VRM.convert_to_unity_format": "Unity形式に変換",
|
||||
"VRM.convert_to_unity.label": "VRMをUnityに変換",
|
||||
"VRM.convert_to_unity.desc": "VRMアーマチュアのボーン名をUnityヒューマノイド命名規則に変換",
|
||||
"VRM.conversion_info.title": "変換情報:",
|
||||
"VRM.conversion_info.renames_bones": "• VRMボーンをUnity形式にリネーム",
|
||||
"VRM.conversion_info.removes_colliders": "• コライダーボーンを削除(オプション)",
|
||||
"VRM.conversion_info.removes_root": "• ルートボーンを削除し、Hipsをルートにする(オプション)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• ボーン階層を維持",
|
||||
"VRM.conversion_info.validates_results": "• 変換結果を検証",
|
||||
"VRM.conversion_info.preserves_animations": "• すべてのアニメーションを保持",
|
||||
"VRM.detection_failed.title": "VRM検出失敗:",
|
||||
"VRM.detection_failed.not_vrm_format": "• 選択されたアーマチュアはVRM形式ではありません",
|
||||
"VRM.detection_failed.bones_start_with": "• VRMボーンは'J_Bip_C_'で始まります",
|
||||
"VRM.detection_failed.need_five_bones": "• 少なくとも5つのVRMボーンが検出される必要があります",
|
||||
"VRM.detection_failed.check_bone_names": "• アーマチュアのボーン名を確認してください",
|
||||
"VRM.validation.hierarchy_passed": "Unity階層検証に合格しました",
|
||||
"VRM.validation.hierarchy_issues": "変換は完了しましたが、階層検証で問題が見つかりました:",
|
||||
"VRM.validation.armature_passed": "アーマチュアは標準検証に合格しました",
|
||||
"VRM.validation.failed": "変換は完了しましたが、検証に失敗しました: {error}",
|
||||
"VRM.remove_colliders": "コライダーを削除",
|
||||
"VRM.remove_colliders_desc": "変換中にVRMコライダーボーンを削除",
|
||||
"VRM.remove_root": "ルートボーンを削除",
|
||||
"VRM.remove_root_desc": "不要なVRMルートボーンを削除し、ヒップをルートボーンにする",
|
||||
|
||||
"Translation.label": "翻訳",
|
||||
"Translation.service": "翻訳サービス",
|
||||
"Translation.service_desc": "使用する翻訳サービスを選択",
|
||||
"Translation.mode": "翻訳モード",
|
||||
"Translation.mode_desc": "翻訳の動作方法を選択",
|
||||
"Translation.mode.hybrid": "ハイブリッド(辞書 + API)",
|
||||
"Translation.mode.hybrid_desc": "まず辞書を試し、その後APIサービスをフォールバックとして使用",
|
||||
"Translation.mode.dictionary_only": "辞書のみ",
|
||||
"Translation.mode.dictionary_only_desc": "翻訳には組み込み辞書のみを使用",
|
||||
"Translation.mode.api_only": "APIのみ",
|
||||
"Translation.mode.api_only_desc": "オンライン翻訳サービスのみを使用",
|
||||
"Translation.service_settings": "翻訳サービス",
|
||||
"Translation.language_settings": "言語設定",
|
||||
"Translation.quick_actions": "クイックアクション",
|
||||
"Translation.utilities": "ユーティリティ",
|
||||
"Translation.advanced_settings": "詳細設定",
|
||||
"Translation.source_language": "ソース言語",
|
||||
"Translation.source_language_desc": "翻訳元の言語",
|
||||
"Translation.target_language": "ターゲット言語",
|
||||
"Translation.target_language_desc": "翻訳先の言語",
|
||||
"Translation.translate_names": "名前を翻訳",
|
||||
"Translation.translate_names_desc": "選択したサービスと設定を使用して名前を翻訳",
|
||||
"Translation.test_service": "サービスをテスト",
|
||||
"Translation.test_service_desc": "現在選択されている翻訳サービスをテスト",
|
||||
"Translation.clear_cache": "キャッシュをクリア",
|
||||
"Translation.clear_cache_desc": "すべてのキャッシュされた翻訳をクリア",
|
||||
"Translation.show_stats": "統計を表示",
|
||||
"Translation.show_stats_desc": "翻訳統計と情報を表示",
|
||||
"Translation.no_armature": "アーマチュアが選択されていません",
|
||||
"Translation.test_failed": "翻訳サービステストが失敗しました - 設定を確認してください",
|
||||
"Translation.cache_cleared": "翻訳キャッシュが正常にクリアされました",
|
||||
"Translation.mymemory_info": "MyMemoryは完全に無料でAPIキー不要です。1日1000回の翻訳を提供します。",
|
||||
"Translation.service.mymemory": "MyMemory(無料)",
|
||||
"Translation.service.mymemory_desc": "完全に無料のサービス - APIキー不要!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "設定可能なサーバー - セルフホスト可能",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "高品質な翻訳 - APIキーが必要",
|
||||
"Translation.type.bones": "ボーン",
|
||||
"Translation.type.bones_desc": "ボーン名を翻訳",
|
||||
"Translation.type.shapekeys": "シェイプキー",
|
||||
"Translation.type.shapekeys_desc": "シェイプキー名を翻訳",
|
||||
"Translation.type.materials": "マテリアル",
|
||||
"Translation.type.materials_desc": "マテリアル名を翻訳",
|
||||
"Translation.type.objects": "オブジェクト",
|
||||
"Translation.type.objects_desc": "オブジェクト名を翻訳",
|
||||
"Translation.type.all": "すべて",
|
||||
"Translation.type.all_desc": "サポートされているすべてのタイプを翻訳",
|
||||
"Translation.configure_deepl": "DeepL APIを設定",
|
||||
"Translation.configure_deepl_desc": "DeepL翻訳サービスAPIキーを設定",
|
||||
"Translation.deepl_api_key": "DeepL APIキー",
|
||||
"Translation.deepl_api_key_desc": "あなたのDeepL APIキー(deepl.com/proで無料キーを取得)",
|
||||
"Translation.configure_libretranslate": "LibreTranslateサーバーを設定",
|
||||
"Translation.configure_libretranslate_desc": "LibreTranslate翻訳サービスサーバーURLを設定",
|
||||
"Translation.server_url": "サーバーURL",
|
||||
"Translation.server_url_desc": "LibreTranslateサーバーURL(例:https://your-server.com)",
|
||||
"Translation.api_key": "APIキー",
|
||||
"Translation.api_key_desc": "LibreTranslateサーバー用のAPIキー(一部のサーバーでは任意)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"authors": ["Avatar Toolkit Team"],
|
||||
"messages": {
|
||||
"AvatarToolkit.label": "아바타 툴킷 (알파 0.3.0)",
|
||||
"AvatarToolkit.label": "아바타 툴킷 (알파 0.4.0)",
|
||||
"AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로",
|
||||
"AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면",
|
||||
"AvatarToolkit.desc3": "Github에 보고해 주세요.",
|
||||
@@ -308,6 +308,7 @@
|
||||
"Visemes.success": "비셈 생성 성공",
|
||||
"Visemes.mesh_select": "메시 선택",
|
||||
"Visemes.mesh_select_desc": "비셈을 생성할 메시 선택",
|
||||
"Visemes.no_meshes": "메시를 찾을 수 없음",
|
||||
|
||||
"EyeTracking.label": "시선 추적",
|
||||
"EyeTracking.setup": "시선 추적 설정",
|
||||
@@ -500,6 +501,8 @@
|
||||
"TextureAtlas.save_file_instructions": "파일 > 다른 이름으로 저장... 을 사용하거나 아래 버튼을 클릭하세요:",
|
||||
"TextureAtlas.save_file_button": "Blender 파일 저장",
|
||||
"TextureAtlas.save_file_required": "파일 저장 필요",
|
||||
"TextureAtlas.search_materials": "재질 검색",
|
||||
"TextureAtlas.search_materials_desc": "이름으로 재질 필터링",
|
||||
|
||||
"Settings.label": "설정",
|
||||
"Settings.language": "언어",
|
||||
@@ -537,6 +540,98 @@
|
||||
"Language.ko_KR": "한국어",
|
||||
"Language.changed.title": "언어 변경됨",
|
||||
"Language.changed.success": "언어가 성공적으로 변경되었습니다!",
|
||||
"Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다"
|
||||
"Language.changed.restart": "일부 UI 요소는 블렌더를 다시 시작해야 할 수 있습니다",
|
||||
|
||||
"VRM.panel.label": "VRM에서 Unity로",
|
||||
"VRM.converter.title": "VRM 변환기",
|
||||
"VRM.no_armature_selected": "선택된 아마추어 없음",
|
||||
"VRM.select_armature_to_convert": "변환할 아마추어를 선택하세요",
|
||||
"VRM.armature_name": "아마추어: {name}",
|
||||
"VRM.armature_detected": "VRM 아마추어 감지됨",
|
||||
"VRM.no_vrm_bones_detected": "VRM 본이 감지되지 않음",
|
||||
"VRM.remove_colliders": "콜라이더 제거",
|
||||
"VRM.remove_root_bone": "루트 본 제거",
|
||||
"VRM.convert_to_unity_format": "Unity 형식으로 변환",
|
||||
"VRM.convert_to_unity.label": "VRM을 Unity로 변환",
|
||||
"VRM.convert_to_unity.desc": "VRM 아마추어 본 이름을 Unity 휴머노이드 명명 규칙으로 변환",
|
||||
"VRM.conversion_info.title": "변환 정보:",
|
||||
"VRM.conversion_info.renames_bones": "• VRM 본을 Unity 형식으로 이름 변경",
|
||||
"VRM.conversion_info.removes_colliders": "• 콜라이더 본 제거 (선택사항)",
|
||||
"VRM.conversion_info.removes_root": "• 루트 본 제거, Hips를 루트로 설정 (선택사항)",
|
||||
"VRM.conversion_info.maintains_hierarchy": "• 본 계층 구조 유지",
|
||||
"VRM.conversion_info.validates_results": "• 변환 결과 검증",
|
||||
"VRM.conversion_info.preserves_animations": "• 모든 애니메이션 보존",
|
||||
"VRM.detection_failed.title": "VRM 감지 실패:",
|
||||
"VRM.detection_failed.not_vrm_format": "• 선택된 아마추어가 VRM 형식이 아님",
|
||||
"VRM.detection_failed.bones_start_with": "• VRM 본은 'J_Bip_C_'로 시작함",
|
||||
"VRM.detection_failed.need_five_bones": "• 최소 5개의 VRM 본이 감지되어야 함",
|
||||
"VRM.detection_failed.check_bone_names": "• 아마추어 본 이름을 확인하세요",
|
||||
"VRM.validation.hierarchy_passed": "Unity 계층 구조 검증 통과",
|
||||
"VRM.validation.hierarchy_issues": "변환은 완료되었지만 계층 구조 검증에서 문제를 발견했습니다:",
|
||||
"VRM.validation.armature_passed": "아마추어가 표준 검증을 통과했습니다",
|
||||
"VRM.validation.failed": "변환은 완료되었지만 검증에 실패했습니다: {error}",
|
||||
"VRM.remove_colliders": "콜라이더 제거",
|
||||
"VRM.remove_colliders_desc": "변환 중 VRM 콜라이더 본 제거",
|
||||
"VRM.remove_root": "루트 본 제거",
|
||||
"VRM.remove_root_desc": "불필요한 VRM 루트 본을 제거하고 힙을 루트 본으로 설정",
|
||||
|
||||
"Translation.label": "번역",
|
||||
"Translation.service": "번역 서비스",
|
||||
"Translation.service_desc": "사용할 번역 서비스 선택",
|
||||
"Translation.mode": "번역 모드",
|
||||
"Translation.mode_desc": "번역 동작 방식 선택",
|
||||
"Translation.mode.hybrid": "하이브리드 (사전 + API)",
|
||||
"Translation.mode.hybrid_desc": "먼저 사전을 시도하고, 그 다음 API 서비스를 폴백으로 사용",
|
||||
"Translation.mode.dictionary_only": "사전만",
|
||||
"Translation.mode.dictionary_only_desc": "번역에 내장 사전만 사용",
|
||||
"Translation.mode.api_only": "API만",
|
||||
"Translation.mode.api_only_desc": "온라인 번역 서비스만 사용",
|
||||
"Translation.service_settings": "번역 서비스",
|
||||
"Translation.language_settings": "언어 설정",
|
||||
"Translation.quick_actions": "빠른 작업",
|
||||
"Translation.utilities": "유틸리티",
|
||||
"Translation.advanced_settings": "고급 설정",
|
||||
"Translation.source_language": "소스 언어",
|
||||
"Translation.source_language_desc": "번역할 원본 언어",
|
||||
"Translation.target_language": "대상 언어",
|
||||
"Translation.target_language_desc": "번역할 대상 언어",
|
||||
"Translation.translate_names": "이름 번역",
|
||||
"Translation.translate_names_desc": "선택한 서비스와 설정을 사용하여 이름 번역",
|
||||
"Translation.test_service": "서비스 테스트",
|
||||
"Translation.test_service_desc": "현재 선택된 번역 서비스 테스트",
|
||||
"Translation.clear_cache": "캐시 지우기",
|
||||
"Translation.clear_cache_desc": "모든 캐시된 번역 지우기",
|
||||
"Translation.show_stats": "통계 표시",
|
||||
"Translation.show_stats_desc": "번역 통계 및 정보 표시",
|
||||
"Translation.no_armature": "선택된 아마추어 없음",
|
||||
"Translation.test_failed": "번역 서비스 테스트 실패 - 구성을 확인하세요",
|
||||
"Translation.cache_cleared": "번역 캐시가 성공적으로 지워졌습니다",
|
||||
"Translation.mymemory_info": "MyMemory는 API 키 없이 완전히 무료입니다. 하루 1000회 번역을 제공합니다.",
|
||||
"Translation.service.mymemory": "MyMemory (무료)",
|
||||
"Translation.service.mymemory_desc": "완전히 무료 서비스 - API 키 불필요!",
|
||||
"Translation.service.libretranslate": "LibreTranslate",
|
||||
"Translation.service.libretranslate_desc": "구성 가능한 서버 - 셀프 호스팅 가능",
|
||||
"Translation.service.deepl": "DeepL",
|
||||
"Translation.service.deepl_desc": "고품질 번역 - API 키 필요",
|
||||
"Translation.type.bones": "본",
|
||||
"Translation.type.bones_desc": "본 이름 번역",
|
||||
"Translation.type.shapekeys": "쉐이프 키",
|
||||
"Translation.type.shapekeys_desc": "쉐이프 키 이름 번역",
|
||||
"Translation.type.materials": "재질",
|
||||
"Translation.type.materials_desc": "재질 이름 번역",
|
||||
"Translation.type.objects": "객체",
|
||||
"Translation.type.objects_desc": "객체 이름 번역",
|
||||
"Translation.type.all": "모두",
|
||||
"Translation.type.all_desc": "지원되는 모든 유형 번역",
|
||||
"Translation.configure_deepl": "DeepL API 구성",
|
||||
"Translation.configure_deepl_desc": "DeepL 번역 서비스 API 키 구성",
|
||||
"Translation.deepl_api_key": "DeepL API 키",
|
||||
"Translation.deepl_api_key_desc": "당신의 DeepL API 키 (deepl.com/pro에서 무료 키 획득)",
|
||||
"Translation.configure_libretranslate": "LibreTranslate 서버 구성",
|
||||
"Translation.configure_libretranslate_desc": "LibreTranslate 번역 서비스 서버 URL 구성",
|
||||
"Translation.server_url": "서버 URL",
|
||||
"Translation.server_url_desc": "LibreTranslate 서버 URL (예: https://your-server.com)",
|
||||
"Translation.api_key": "API 키",
|
||||
"Translation.api_key_desc": "LibreTranslate 서버용 API 키 (일부 서버는 선택사항)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ from ..core.common import (
|
||||
get_armature_list,
|
||||
get_armature_stats
|
||||
)
|
||||
|
||||
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
|
||||
_validation_cache = {}
|
||||
_stats_cache = {}
|
||||
|
||||
def clear_armature_caches():
|
||||
"""Clear all armature-related caches - called when armature changes"""
|
||||
global _validation_cache, _stats_cache
|
||||
_validation_cache.clear()
|
||||
_stats_cache.clear()
|
||||
|
||||
from ..functions.pose_mode import (
|
||||
AvatarToolkit_OT_StartPoseMode,
|
||||
AvatarToolkit_OT_StopPoseMode,
|
||||
@@ -84,10 +95,16 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
|
||||
# Armature Selection
|
||||
col.prop(context.scene.avatar_toolkit, "active_armature", text="")
|
||||
|
||||
# Armature Validation
|
||||
# Armature Validation (cached to improve performance)
|
||||
active_armature: Optional[Object] = get_active_armature(context)
|
||||
if active_armature:
|
||||
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True)
|
||||
# Cache validation results to avoid expensive recalculations on every draw
|
||||
cache_key = f"validation_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||
|
||||
if cache_key not in _validation_cache:
|
||||
_validation_cache[cache_key] = validate_armature(active_armature, detailed_messages=True)
|
||||
|
||||
is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = _validation_cache[cache_key]
|
||||
|
||||
# Check if this is a PMX model
|
||||
is_pmx_model = False
|
||||
@@ -235,7 +252,14 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel):
|
||||
row = info_box.row()
|
||||
split = row.split(factor=0.6)
|
||||
split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK')
|
||||
stats = get_armature_stats(active_armature)
|
||||
|
||||
# Cache armature stats to avoid expensive recalculations
|
||||
stats_cache_key = f"stats_{active_armature.name}_{active_armature.data.name}_{len(active_armature.data.bones)}"
|
||||
|
||||
if stats_cache_key not in _stats_cache:
|
||||
_stats_cache[stats_cache_key] = get_armature_stats(active_armature)
|
||||
|
||||
stats = _stats_cache[stats_cache_key]
|
||||
split.label(text=t("QuickAccess.bones_count", count=stats['bone_count']))
|
||||
|
||||
if stats['has_pose']:
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
# GPL License
|
||||
|
||||
import bpy
|
||||
from typing import Set, Dict, List, Optional, Any
|
||||
from bpy.types import (
|
||||
Operator,
|
||||
Panel,
|
||||
Context,
|
||||
UILayout,
|
||||
WindowManager,
|
||||
Event,
|
||||
Object
|
||||
)
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from ..core.translations import t
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.common import get_active_armature, ProgressTracker
|
||||
|
||||
# Module-level cache for UI performance (avoids Blender scene property write restrictions)
|
||||
_ui_cache = {
|
||||
'translation_status': {},
|
||||
'deepl_config': {},
|
||||
'libretranslate_config': {},
|
||||
'last_refresh_frame': 0,
|
||||
'cache_refresh_interval': 30
|
||||
}
|
||||
|
||||
|
||||
class AvatarToolkit_OT_TranslateNames(Operator):
|
||||
"""Translate names using the translation system"""
|
||||
bl_idname: str = "avatar_toolkit.translate_names"
|
||||
bl_label: str = t("Translation.translate_names")
|
||||
bl_description: str = t("Translation.translate_names_desc")
|
||||
|
||||
translation_type: bpy.props.EnumProperty(
|
||||
items=[
|
||||
('bones', t("Translation.type.bones"), t("Translation.type.bones_desc")),
|
||||
('shapekeys', t("Translation.type.shapekeys"), t("Translation.type.shapekeys_desc")),
|
||||
('materials', t("Translation.type.materials"), t("Translation.type.materials_desc")),
|
||||
('objects', t("Translation.type.objects"), t("Translation.type.objects_desc")),
|
||||
('all', t("Translation.type.all"), t("Translation.type.all_desc"))
|
||||
],
|
||||
default='bones'
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
logger.info(f"Starting translation operation: {self.translation_type}")
|
||||
|
||||
try:
|
||||
from ..core.translation_manager import get_avatar_translation_manager
|
||||
manager = get_avatar_translation_manager()
|
||||
|
||||
# Set up progress callback for detailed feedback
|
||||
def progress_callback(current: int, total: int, message: str):
|
||||
progress_percent = (current / max(total, 1)) * 100
|
||||
logger.info(f"Translation progress: {current}/{total} ({progress_percent:.1f}%) - {message}")
|
||||
context.area.header_text_set(f"Translating: {current}/{total} - {message}")
|
||||
|
||||
manager.set_progress_callback(progress_callback)
|
||||
|
||||
results = []
|
||||
armature = get_active_armature(context)
|
||||
|
||||
total_steps = 0
|
||||
if self.translation_type == 'bones' or self.translation_type == 'all':
|
||||
if armature:
|
||||
total_steps += len(armature.data.bones)
|
||||
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
|
||||
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
|
||||
for mesh in meshes:
|
||||
if mesh.data.shape_keys:
|
||||
total_steps += len(mesh.data.shape_keys.key_blocks)
|
||||
if self.translation_type == 'materials' or self.translation_type == 'all':
|
||||
materials = set()
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'MESH' and obj.data.materials:
|
||||
for mat in obj.data.materials:
|
||||
if mat:
|
||||
materials.add(mat)
|
||||
total_steps += len(materials)
|
||||
if self.translation_type == 'objects' or self.translation_type == 'all':
|
||||
objects = [obj for obj in context.scene.objects if obj.type in {'MESH', 'ARMATURE', 'EMPTY'}]
|
||||
total_steps += len(objects)
|
||||
|
||||
logger.info(f"Translation operation will process approximately {total_steps} items")
|
||||
|
||||
with ProgressTracker(context, total_steps, "Translation") as progress:
|
||||
if self.translation_type == 'bones' or self.translation_type == 'all':
|
||||
if armature:
|
||||
logger.info(f"Starting bone translation for armature: {armature.name}")
|
||||
self.report({'INFO'}, f"Translating {len(armature.data.bones)} bones...")
|
||||
|
||||
bone_results = manager.translate_armature_bones(armature, apply_results=True)
|
||||
results.extend(bone_results)
|
||||
|
||||
successful_bones = sum(1 for r in bone_results if r.method not in ['failed', 'skipped'])
|
||||
progress.step(f"Bones: {successful_bones}/{len(bone_results)} translated")
|
||||
logger.info(f"Bone translation complete: {successful_bones}/{len(bone_results)} successful")
|
||||
else:
|
||||
self.report({'WARNING'}, t("Translation.no_armature"))
|
||||
logger.warning("No armature selected for bone translation")
|
||||
|
||||
if self.translation_type == 'shapekeys' or self.translation_type == 'all':
|
||||
meshes = [obj for obj in context.scene.objects if obj.type == 'MESH']
|
||||
logger.info(f"Starting shape key translation for {len(meshes)} mesh objects")
|
||||
|
||||
total_shapekeys = 0
|
||||
for mesh in meshes:
|
||||
if mesh.data.shape_keys:
|
||||
shapekey_count = len(mesh.data.shape_keys.key_blocks)
|
||||
self.report({'INFO'}, f"Translating {shapekey_count} shape keys in {mesh.name}...")
|
||||
|
||||
shapekey_results = manager.translate_object_shapekeys(mesh, apply_results=True)
|
||||
results.extend(shapekey_results)
|
||||
total_shapekeys += len(shapekey_results)
|
||||
|
||||
successful_shapekeys = sum(1 for r in results[-total_shapekeys:] if r.method not in ['failed', 'skipped'])
|
||||
progress.step(f"Shape keys: {successful_shapekeys}/{total_shapekeys} translated")
|
||||
logger.info(f"Shape key translation complete: {successful_shapekeys}/{total_shapekeys} successful")
|
||||
|
||||
if self.translation_type == 'materials' or self.translation_type == 'all':
|
||||
logger.info("Starting material translation")
|
||||
self.report({'INFO'}, "Translating materials...")
|
||||
|
||||
material_results = manager.translate_scene_materials(apply_results=True)
|
||||
results.extend(material_results)
|
||||
|
||||
successful_materials = sum(1 for r in material_results if r.method not in ['failed', 'skipped'])
|
||||
progress.step(f"Materials: {successful_materials}/{len(material_results)} translated")
|
||||
logger.info(f"Material translation complete: {successful_materials}/{len(material_results)} successful")
|
||||
|
||||
if self.translation_type == 'objects' or self.translation_type == 'all':
|
||||
logger.info("Starting object translation")
|
||||
self.report({'INFO'}, "Translating objects...")
|
||||
|
||||
object_results = manager.translate_scene_objects(apply_results=True)
|
||||
results.extend(object_results)
|
||||
|
||||
successful_objects = sum(1 for r in object_results if r.method not in ['failed', 'skipped'])
|
||||
progress.step(f"Objects: {successful_objects}/{len(object_results)} translated")
|
||||
logger.info(f"Object translation complete: {successful_objects}/{len(object_results)} successful")
|
||||
|
||||
manager.set_progress_callback(None)
|
||||
context.area.header_text_set(None)
|
||||
|
||||
# Final results summary
|
||||
successful = sum(1 for r in results if r.method not in ['failed', 'skipped'])
|
||||
total = len(results)
|
||||
|
||||
dictionary_count = sum(1 for r in results if r.method == 'dictionary')
|
||||
api_count = sum(1 for r in results if r.method == 'api')
|
||||
cache_count = sum(1 for r in results if r.method == 'cache')
|
||||
failed_count = sum(1 for r in results if r.method == 'failed')
|
||||
|
||||
logger.info(f"Translation summary: {successful}/{total} successful (Dictionary: {dictionary_count}, API: {api_count}, Cache: {cache_count}, Failed: {failed_count})")
|
||||
|
||||
if successful > 0:
|
||||
success_msg = f"Successfully translated {successful}/{total} items"
|
||||
if dictionary_count > 0:
|
||||
success_msg += f" (Dictionary: {dictionary_count}"
|
||||
if api_count > 0:
|
||||
success_msg += f", API: {api_count}"
|
||||
if cache_count > 0:
|
||||
success_msg += f", Cache: {cache_count}"
|
||||
if dictionary_count > 0 or api_count > 0 or cache_count > 0:
|
||||
success_msg += ")"
|
||||
|
||||
self.report({'INFO'}, success_msg)
|
||||
else:
|
||||
if total > 0:
|
||||
self.report({'WARNING'}, f"No translations were applied ({total} items checked)")
|
||||
else:
|
||||
self.report({'WARNING'}, "No items found to translate")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
manager.set_progress_callback(None)
|
||||
context.area.header_text_set(None)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.error(f"Translation operation failed: {e}", exc_info=True)
|
||||
self.report({'ERROR'}, f"Translation failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
class AvatarToolkit_OT_TestTranslationService(Operator):
|
||||
"""Test the currently selected translation service"""
|
||||
bl_idname: str = "avatar_toolkit.test_translation_service"
|
||||
bl_label: str = t("Translation.test_service")
|
||||
bl_description: str = t("Translation.test_service_desc")
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
logger.info("Starting translation service test")
|
||||
|
||||
try:
|
||||
from ..core.translation_manager import get_avatar_translation_manager
|
||||
manager = get_avatar_translation_manager()
|
||||
|
||||
self.report({'INFO'}, "Testing translation service...")
|
||||
context.area.header_text_set("Testing translation service...")
|
||||
|
||||
# Test translation with a simple word
|
||||
test_word = "テスト" # "Test" in Japanese
|
||||
logger.info(f"Testing translation of '{test_word}'")
|
||||
|
||||
result = manager.translate_single(test_word, "auto")
|
||||
|
||||
# Clear status
|
||||
context.area.header_text_set(None)
|
||||
|
||||
if result.method == "failed":
|
||||
logger.error(f"Translation test failed: {result}")
|
||||
self.report({'ERROR'}, t("Translation.test_failed"))
|
||||
else:
|
||||
service_info = f" ({result.service})" if result.service else ""
|
||||
success_msg = f"Translation test successful: '{test_word}' → '{result.translated}' via {result.method}{service_info}"
|
||||
logger.info(f"Translation test successful: {result}")
|
||||
self.report({'INFO'}, success_msg)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
context.area.header_text_set(None)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.error(f"Translation service test failed: {e}", exc_info=True)
|
||||
self.report({'ERROR'}, f"Service test failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
class AvatarToolkit_OT_ClearTranslationCache(Operator):
|
||||
"""Clear all translation caches"""
|
||||
bl_idname: str = "avatar_toolkit.clear_translation_cache"
|
||||
bl_label: str = t("Translation.clear_cache")
|
||||
bl_description: str = t("Translation.clear_cache_desc")
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
from ..core.translation_manager import get_avatar_translation_manager
|
||||
manager = get_avatar_translation_manager()
|
||||
manager.clear_all_caches()
|
||||
|
||||
self.report({'INFO'}, t("Translation.cache_cleared"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear translation cache: {e}")
|
||||
self.report({'ERROR'}, f"Failed to clear cache: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
class AvatarToolkit_OT_ConfigureDeepL(Operator):
|
||||
"""Configure DeepL API settings"""
|
||||
bl_idname: str = "avatar_toolkit.configure_deepl"
|
||||
bl_label: str = t("Translation.configure_deepl")
|
||||
bl_description: str = t("Translation.configure_deepl_desc")
|
||||
|
||||
api_key: bpy.props.StringProperty(
|
||||
name=t("Translation.deepl_api_key"),
|
||||
description=t("Translation.deepl_api_key_desc"),
|
||||
default="",
|
||||
subtype='PASSWORD'
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
if not self.api_key.strip():
|
||||
self.report({'ERROR'}, "API key cannot be empty")
|
||||
return {'CANCELLED'}
|
||||
|
||||
from ..core.translation_manager import configure_translation_service
|
||||
success = configure_translation_service("deepl", api_key=self.api_key.strip())
|
||||
|
||||
if success:
|
||||
_ui_cache['deepl_config'].clear()
|
||||
_ui_cache['translation_status'].clear()
|
||||
if 'batch_info' in _ui_cache:
|
||||
del _ui_cache['batch_info']
|
||||
self.report({'INFO'}, "DeepL API configured successfully")
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, "Failed to configure DeepL API - check your API key")
|
||||
return {'CANCELLED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DeepL configuration failed: {e}")
|
||||
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
# Load existing API key if available
|
||||
try:
|
||||
from ..core.addon_preferences import get_preference
|
||||
existing_key = get_preference("deepl_api_key", "")
|
||||
if existing_key:
|
||||
# Show only first/last few characters for security
|
||||
if len(existing_key) > 8:
|
||||
display_key = existing_key[:4] + "..." + existing_key[-4:]
|
||||
self.api_key = existing_key # Keep full key for editing
|
||||
else:
|
||||
self.api_key = existing_key
|
||||
except:
|
||||
pass
|
||||
|
||||
wm: WindowManager = context.window_manager
|
||||
return wm.invoke_props_dialog(self, width=400)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
|
||||
info_box = layout.box()
|
||||
info_col = info_box.column()
|
||||
info_col.label(text="DeepL API Configuration", icon='SETTINGS')
|
||||
info_col.separator()
|
||||
info_col.label(text="1. Visit deepl.com/pro to get your free API key")
|
||||
info_col.label(text="2. Free tier: 500,000 characters/month")
|
||||
info_col.label(text="3. Higher quality than other services")
|
||||
info_col.label(text="4. The Fastest Option due to native batching support")
|
||||
|
||||
layout.separator()
|
||||
layout.prop(self, "api_key")
|
||||
|
||||
|
||||
class AvatarToolkit_OT_ConfigureLibreTranslate(Operator):
|
||||
"""Configure LibreTranslate server settings"""
|
||||
bl_idname: str = "avatar_toolkit.configure_libretranslate"
|
||||
bl_label: str = t("Translation.configure_libretranslate")
|
||||
bl_description: str = t("Translation.configure_libretranslate_desc")
|
||||
|
||||
server_url: bpy.props.StringProperty(
|
||||
name=t("Translation.server_url"),
|
||||
description=t("Translation.server_url_desc"),
|
||||
default="https://libretranslate.com"
|
||||
)
|
||||
|
||||
api_key: bpy.props.StringProperty(
|
||||
name=t("Translation.api_key"),
|
||||
description=t("Translation.api_key_desc"),
|
||||
default="",
|
||||
subtype='PASSWORD'
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
if not self.server_url.strip():
|
||||
self.report({'ERROR'}, "Server URL cannot be empty")
|
||||
return {'CANCELLED'}
|
||||
|
||||
from ..core.translation_manager import configure_translation_service
|
||||
success = configure_translation_service("libretranslate",
|
||||
server_url=self.server_url.strip(),
|
||||
api_key=self.api_key.strip() if self.api_key.strip() else None)
|
||||
|
||||
if success:
|
||||
_ui_cache['libretranslate_config'].clear()
|
||||
_ui_cache['translation_status'].clear()
|
||||
if 'batch_info' in _ui_cache:
|
||||
del _ui_cache['batch_info']
|
||||
self.report({'INFO'}, f"LibreTranslate server configured: {self.server_url}")
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, "Failed to connect to LibreTranslate server")
|
||||
return {'CANCELLED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LibreTranslate configuration failed: {e}")
|
||||
self.report({'ERROR'}, f"Configuration failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
# Load existing server URL and API key if available
|
||||
try:
|
||||
from ..core.addon_preferences import get_preference
|
||||
existing_url = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||
existing_api_key = get_preference("libretranslate_api_key", "")
|
||||
self.server_url = existing_url
|
||||
self.api_key = existing_api_key
|
||||
except:
|
||||
pass
|
||||
|
||||
wm: WindowManager = context.window_manager
|
||||
return wm.invoke_props_dialog(self, width=500)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
|
||||
info_box = layout.box()
|
||||
info_col = info_box.column()
|
||||
info_col.label(text="LibreTranslate Server Configuration", icon='SETTINGS')
|
||||
info_col.separator()
|
||||
info_col.label(text="⚠ libretranslate.com requires payment for API access")
|
||||
info_col.label(text="✓ You can run your own LibreTranslate server")
|
||||
info_col.label(text="✓ Or find community-hosted instances")
|
||||
info_col.separator()
|
||||
info_col.label(text="Examples:")
|
||||
info_col.label(text=" • Your server: https://translate.yoursite.com")
|
||||
info_col.label(text=" • Docker local: http://localhost:5000")
|
||||
|
||||
layout.separator()
|
||||
layout.prop(self, "server_url")
|
||||
layout.prop(self, "api_key")
|
||||
|
||||
|
||||
class AvatarToolkit_OT_TranslationStats(Operator):
|
||||
"""Show translation statistics"""
|
||||
bl_idname: str = "avatar_toolkit.translation_stats"
|
||||
bl_label: str = t("Translation.show_stats")
|
||||
bl_description: str = t("Translation.show_stats_desc")
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
wm: WindowManager = context.window_manager
|
||||
return wm.invoke_props_dialog(self, width=400)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
|
||||
try:
|
||||
from ..core.translation_manager import get_avatar_translation_manager
|
||||
manager = get_avatar_translation_manager()
|
||||
stats = manager.get_translation_stats()
|
||||
|
||||
dict_box = layout.box()
|
||||
dict_box.label(text="Dictionary Translations", icon='BOOKMARKS')
|
||||
dict_stats = stats['dictionary_translations']
|
||||
for category, count in dict_stats.items():
|
||||
if count > 0:
|
||||
dict_box.label(text=f"{category.title()}: {count}")
|
||||
|
||||
cache_box = layout.box()
|
||||
cache_box.label(text="Translation Cache", icon='FILE_CACHE')
|
||||
cache_stats = stats['cache_stats']
|
||||
cache_box.label(text=f"Language pairs: {cache_stats['language_pairs']}")
|
||||
cache_box.label(text=f"Total cached: {cache_stats['total_entries']}")
|
||||
|
||||
service_box = layout.box()
|
||||
service_box.label(text="Translation Services", icon='WORLD')
|
||||
service_box.label(text=f"Current mode: {stats['current_mode']}")
|
||||
service_box.label(text=f"Primary service: {stats['primary_service']}")
|
||||
|
||||
available_services = stats['available_services']
|
||||
if available_services:
|
||||
service_box.label(text="Available services:")
|
||||
for service_id, service_name in available_services:
|
||||
service_box.label(text=f" • {service_name}")
|
||||
else:
|
||||
service_box.label(text="No services available", icon='ERROR')
|
||||
|
||||
except Exception as e:
|
||||
layout.label(text=f"Error loading stats: {str(e)}", icon='ERROR')
|
||||
|
||||
|
||||
class AvatarToolKit_PT_TranslationPanel(Panel):
|
||||
"""Translation panel for Avatar Toolkit"""
|
||||
bl_label: str = t("Translation.label")
|
||||
bl_idname: str = "OBJECT_PT_avatar_toolkit_translation"
|
||||
bl_space_type: str = 'VIEW_3D'
|
||||
bl_region_type: str = 'UI'
|
||||
bl_category: str = CATEGORY_NAME
|
||||
bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order: int = 9
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the translation panel layout"""
|
||||
layout: UILayout = self.layout
|
||||
props = context.scene.avatar_toolkit
|
||||
|
||||
# Translation Service Settings
|
||||
service_box: UILayout = layout.box()
|
||||
col: UILayout = service_box.column(align=True)
|
||||
row: UILayout = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Translation.service_settings"), icon='WORLD')
|
||||
col.separator()
|
||||
|
||||
col.prop(props, "translation_service", text="")
|
||||
|
||||
col.prop(props, "translation_mode", text="")
|
||||
|
||||
row = col.row(align=True)
|
||||
row.prop(props, "translation_expand",
|
||||
icon="TRIA_DOWN" if props.translation_expand else "TRIA_RIGHT",
|
||||
icon_only=True, emboss=False)
|
||||
row.label(text=t("Translation.advanced_settings"))
|
||||
|
||||
if props.translation_expand:
|
||||
config_col = service_box.column(align=True)
|
||||
|
||||
# MyMemory settings (no configuration needed)
|
||||
if props.translation_service == 'mymemory':
|
||||
config_col.separator()
|
||||
config_col.label(text="MyMemory Configuration:", icon='CHECKMARK')
|
||||
success_col = config_col.column()
|
||||
success_col.alert = False
|
||||
success_col.label(text="✓ No API key required!", icon='CHECKMARK')
|
||||
success_col.label(text="✓ Completely free service")
|
||||
success_col.label(text="✓ 1000 translations per day")
|
||||
success_col.label(text="✓ Slowest Option due to no native batching")
|
||||
success_col.label(text="✓ Ready to use!")
|
||||
|
||||
elif props.translation_service == 'libretranslate':
|
||||
config_col.separator()
|
||||
config_col.label(text="LibreTranslate Configuration:", icon='SETTINGS')
|
||||
|
||||
# Check current server configuration (cached to avoid performance issues)
|
||||
try:
|
||||
if 'libretranslate_url' not in _ui_cache['libretranslate_config']:
|
||||
from ..core.addon_preferences import get_preference
|
||||
_ui_cache['libretranslate_config']['libretranslate_url'] = get_preference("libretranslate_url", "https://libretranslate.com")
|
||||
|
||||
server_url = _ui_cache['libretranslate_config']['libretranslate_url']
|
||||
|
||||
info_col = config_col.column()
|
||||
info_col.alert = False
|
||||
info_col.label(text=f"Server: {server_url}", icon='URL')
|
||||
|
||||
if "libretranslate.com" in server_url.lower():
|
||||
warning_col = config_col.column()
|
||||
warning_col.alert = True
|
||||
warning_col.label(text="⚠ Default server requires payment", icon='ERROR')
|
||||
warning_col.label(text="Configure your own LibreTranslate server")
|
||||
else:
|
||||
success_col = config_col.column()
|
||||
success_col.alert = False
|
||||
success_col.label(text="✓ Custom server configured", icon='CHECKMARK')
|
||||
|
||||
config_row = config_col.row()
|
||||
config_row.operator("avatar_toolkit.configure_libretranslate", text="Configure Server", icon='SETTINGS')
|
||||
except Exception as e:
|
||||
config_col.label(text="LibreTranslate configuration error", icon='ERROR')
|
||||
|
||||
elif props.translation_service == 'deepl':
|
||||
config_col.separator()
|
||||
config_col.label(text="DeepL Configuration:", icon='SETTINGS')
|
||||
|
||||
# Check if API key is configured (cached to avoid performance issues)
|
||||
try:
|
||||
if 'deepl_api_key' not in _ui_cache['deepl_config']:
|
||||
from ..core.addon_preferences import get_preference
|
||||
_ui_cache['deepl_config']['deepl_api_key'] = get_preference("deepl_api_key", "")
|
||||
|
||||
deepl_api_key = _ui_cache['deepl_config']['deepl_api_key']
|
||||
|
||||
if deepl_api_key and deepl_api_key.strip():
|
||||
success_col = config_col.column()
|
||||
success_col.alert = False
|
||||
success_col.label(text="✓ API key configured", icon='CHECKMARK')
|
||||
success_col.label(text="✓ High quality translations")
|
||||
success_col.label(text="✓ 500,000 chars/month free")
|
||||
success_col.label(text="✓ Ready to use!")
|
||||
|
||||
reconfig_row = config_col.row()
|
||||
reconfig_row.operator("avatar_toolkit.configure_deepl", text="Reconfigure API Key", icon='SETTINGS')
|
||||
else:
|
||||
warning_col = config_col.column()
|
||||
warning_col.alert = True
|
||||
warning_col.label(text="⚠ API key required!", icon='ERROR')
|
||||
warning_col.label(text="Get free key at deepl.com/pro")
|
||||
warning_col.label(text="500,000 characters/month free")
|
||||
|
||||
config_row = config_col.row()
|
||||
config_row.operator("avatar_toolkit.configure_deepl", text="Configure API Key", icon='PLUS')
|
||||
except Exception as e:
|
||||
config_col.label(text="DeepL configuration error", icon='ERROR')
|
||||
|
||||
|
||||
|
||||
# Language Settings
|
||||
lang_box: UILayout = layout.box()
|
||||
col = lang_box.column(align=True)
|
||||
row = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Translation.language_settings"), icon='SYNTAX_ON')
|
||||
col.separator()
|
||||
col.prop(props, "translation_source_language", text="From")
|
||||
col.prop(props, "translation_target_language", text="To")
|
||||
|
||||
# Quick Actions
|
||||
action_box: UILayout = layout.box()
|
||||
col = action_box.column(align=True)
|
||||
row = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Translation.quick_actions"), icon='PLAY')
|
||||
col.separator()
|
||||
|
||||
# Translate buttons
|
||||
row = col.row(align=True)
|
||||
op_bones = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Bones", icon='BONE_DATA')
|
||||
op_bones.translation_type = 'bones'
|
||||
|
||||
op_shapes = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Shape Keys", icon='SHAPEKEY_DATA')
|
||||
op_shapes.translation_type = 'shapekeys'
|
||||
|
||||
row = col.row(align=True)
|
||||
op_mats = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Materials", icon='MATERIAL_DATA')
|
||||
op_mats.translation_type = 'materials'
|
||||
|
||||
op_objs = row.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Objects", icon='OBJECT_DATA')
|
||||
op_objs.translation_type = 'objects'
|
||||
|
||||
col.separator()
|
||||
op_all = col.operator(AvatarToolkit_OT_TranslateNames.bl_idname, text="Translate All", icon='WORLD')
|
||||
op_all.translation_type = 'all'
|
||||
|
||||
# Utility buttons
|
||||
util_box: UILayout = layout.box()
|
||||
col = util_box.column(align=True)
|
||||
row = col.row()
|
||||
row.scale_y = 1.2
|
||||
row.label(text=t("Translation.utilities"), icon='TOOL_SETTINGS')
|
||||
col.separator()
|
||||
|
||||
row = col.row(align=True)
|
||||
row.operator(AvatarToolkit_OT_TestTranslationService.bl_idname, icon='PLAY')
|
||||
row.operator(AvatarToolkit_OT_TranslationStats.bl_idname, icon='INFO')
|
||||
|
||||
col.operator(AvatarToolkit_OT_ClearTranslationCache.bl_idname, icon='TRASH')
|
||||
|
||||
status_box = layout.box()
|
||||
status_col = status_box.column()
|
||||
|
||||
try:
|
||||
status_cache_key = f"translation_status_{props.translation_service}_{props.translation_mode}"
|
||||
|
||||
# Refresh cache periodically
|
||||
frame = context.scene.frame_current
|
||||
cache_expired = (frame - _ui_cache['last_refresh_frame'] >= _ui_cache['cache_refresh_interval']) or status_cache_key not in _ui_cache['translation_status']
|
||||
|
||||
if cache_expired:
|
||||
from ..core.translation_manager import get_available_translation_services, get_avatar_translation_manager
|
||||
|
||||
manager = get_avatar_translation_manager()
|
||||
available_services = get_available_translation_services()
|
||||
|
||||
_ui_cache['translation_status'][status_cache_key] = {
|
||||
'available_services': available_services,
|
||||
'manager': manager,
|
||||
'cache_stats': None
|
||||
}
|
||||
_ui_cache['last_refresh_frame'] = frame
|
||||
|
||||
try:
|
||||
stats = manager.get_translation_stats()
|
||||
_ui_cache['translation_status'][status_cache_key]['cache_stats'] = stats['cache_stats']
|
||||
except:
|
||||
pass
|
||||
|
||||
# Use cached data
|
||||
cached_data = _ui_cache['translation_status'].get(status_cache_key, {})
|
||||
available_services = cached_data.get('available_services', [])
|
||||
cache_stats = cached_data.get('cache_stats')
|
||||
|
||||
if available_services:
|
||||
status_col.label(text="Translation services ready", icon='CHECKMARK')
|
||||
|
||||
# Show current service status
|
||||
current_service = props.translation_service
|
||||
service_available = any(service_id == current_service for service_id, _ in available_services)
|
||||
|
||||
if service_available:
|
||||
service_name = next((name for sid, name in available_services if sid == current_service), current_service)
|
||||
status_col.label(text=f"Active: {service_name}", icon='WORLD')
|
||||
|
||||
# Show translation mode
|
||||
mode_display = {
|
||||
'hybrid': 'Dictionary + API',
|
||||
'dictionary_only': 'Dictionary Only',
|
||||
'api_only': 'API Only'
|
||||
}.get(props.translation_mode, props.translation_mode)
|
||||
status_col.label(text=f"Mode: {mode_display}", icon='SETTINGS')
|
||||
|
||||
# Show cache status
|
||||
if cache_stats and cache_stats['total_entries'] > 0:
|
||||
status_col.label(text=f"Cache: {cache_stats['total_entries']} translations", icon='FILE_CACHE')
|
||||
|
||||
# Show batch translation capability
|
||||
try:
|
||||
if 'batch_info' not in _ui_cache:
|
||||
from ..core.translation_manager import get_batch_translation_info
|
||||
_ui_cache['batch_info'] = get_batch_translation_info()
|
||||
|
||||
batch_info = _ui_cache['batch_info'].get(current_service, {})
|
||||
if batch_info.get('supports_batch', False):
|
||||
batch_type = batch_info.get('batch_type', 'individual')
|
||||
if batch_type == 'native':
|
||||
status_col.label(text="⚡ DeepL Native batch translation (up to 50x faster)", icon='LIGHT')
|
||||
elif batch_type == 'concurrent':
|
||||
if current_service == 'mymemory':
|
||||
status_col.label(text="⚡ Slowest Option, no native Batching", icon='LIGHT')
|
||||
else:
|
||||
status_col.label(text="⚡ Slightly Faster then MyMemory processing (3x faster)", icon='LIGHT')
|
||||
except:
|
||||
pass
|
||||
|
||||
else:
|
||||
warning_col = status_col.column()
|
||||
warning_col.alert = True
|
||||
warning_col.label(text=f"Service unavailable: {props.translation_service}", icon='ERROR')
|
||||
|
||||
|
||||
else:
|
||||
warning_col = status_col.column()
|
||||
warning_col.alert = True
|
||||
warning_col.label(text="No translation services available", icon='ERROR')
|
||||
|
||||
if props.translation_service == 'mymemory':
|
||||
warning_col.label(text="Internet connection required")
|
||||
|
||||
except Exception as e:
|
||||
error_col = status_col.column()
|
||||
error_col.alert = True
|
||||
error_col.label(text="Translation system error", icon='ERROR')
|
||||
logger.error(f"Status display error: {e}")
|
||||
|
||||
try:
|
||||
if hasattr(context.area, 'header_text') and context.area.header_text:
|
||||
progress_col = status_col.column()
|
||||
progress_col.alert = False
|
||||
progress_col.label(text=context.area.header_text, icon='TIME')
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import bpy
|
||||
from bpy.types import Panel, Context, UILayout
|
||||
from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME
|
||||
from ..core.translations import t
|
||||
from ..core.common import get_active_armature
|
||||
from ..core.vrm_unity_converter import detect_vrm_armature
|
||||
from ..functions.tools.vrm_unity_conversion import AvatarToolkit_OT_ConvertVRMToUnity
|
||||
|
||||
|
||||
class AvatarToolKit_PT_VRMUnityPanel(Panel):
|
||||
"""Panel for VRM to Unity conversion tools"""
|
||||
bl_label = t("VRM.panel.label")
|
||||
bl_idname = "OBJECT_PT_avatar_toolkit_vrm_unity"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = CATEGORY_NAME
|
||||
bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname
|
||||
bl_order = 3
|
||||
bl_options = {'DEFAULT_CLOSED'}
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the VRM to Unity conversion panel interface"""
|
||||
layout: UILayout = self.layout
|
||||
|
||||
# VRM Conversion Tools
|
||||
vrm_box: UILayout = layout.box()
|
||||
col: UILayout = vrm_box.column(align=True)
|
||||
col.label(text=t("VRM.converter.title"), icon='ARMATURE_DATA')
|
||||
col.separator(factor=0.5)
|
||||
|
||||
# Check if we have an active armature
|
||||
armature = get_active_armature(context)
|
||||
|
||||
if not armature:
|
||||
col.label(text=t("VRM.no_armature_selected"), icon='ERROR')
|
||||
col.label(text=t("VRM.select_armature_to_convert"))
|
||||
return
|
||||
|
||||
# Check if the armature appears to be VRM
|
||||
is_vrm = detect_vrm_armature(armature)
|
||||
|
||||
if is_vrm:
|
||||
col.label(text=t("VRM.armature_name", name=armature.name), icon='CHECKMARK')
|
||||
col.label(text=t("VRM.armature_detected"), icon='INFO')
|
||||
col.separator(factor=0.3)
|
||||
|
||||
toolkit = context.scene.avatar_toolkit
|
||||
col.prop(toolkit, 'vrm_remove_colliders', text=t("VRM.remove_colliders"))
|
||||
col.prop(toolkit, 'vrm_remove_root', text=t("VRM.remove_root_bone"))
|
||||
col.separator(factor=0.2)
|
||||
|
||||
col.operator(
|
||||
AvatarToolkit_OT_ConvertVRMToUnity.bl_idname,
|
||||
text=t("VRM.convert_to_unity_format"),
|
||||
icon='EXPORT'
|
||||
)
|
||||
|
||||
info_box = vrm_box.box()
|
||||
info_col = info_box.column(align=True)
|
||||
info_col.label(text=t("VRM.conversion_info.title"), icon='INFO')
|
||||
info_col.label(text=t("VRM.conversion_info.renames_bones"))
|
||||
info_col.label(text=t("VRM.conversion_info.removes_colliders"))
|
||||
info_col.label(text=t("VRM.conversion_info.removes_root"))
|
||||
info_col.label(text=t("VRM.conversion_info.maintains_hierarchy"))
|
||||
info_col.label(text=t("VRM.conversion_info.validates_results"))
|
||||
info_col.label(text=t("VRM.conversion_info.preserves_animations"))
|
||||
|
||||
else:
|
||||
col.label(text=t("VRM.armature_name", name=armature.name), icon='ERROR')
|
||||
col.label(text=t("VRM.no_vrm_bones_detected"), icon='CANCEL')
|
||||
col.separator(factor=0.3)
|
||||
|
||||
row = col.row()
|
||||
row.enabled = False
|
||||
row.operator(
|
||||
AvatarToolkit_OT_ConvertVRMToUnity.bl_idname,
|
||||
text=t("VRM.convert_to_unity_format"),
|
||||
icon='CANCEL'
|
||||
)
|
||||
|
||||
help_box = vrm_box.box()
|
||||
help_col = help_box.column(align=True)
|
||||
help_col.label(text=t("VRM.detection_failed.title"), icon='QUESTION')
|
||||
help_col.label(text=t("VRM.detection_failed.not_vrm_format"))
|
||||
help_col.label(text=t("VRM.detection_failed.bones_start_with"))
|
||||
help_col.label(text=t("VRM.detection_failed.need_five_bones"))
|
||||
help_col.label(text=t("VRM.detection_failed.check_bone_names"))
|
||||
Reference in New Issue
Block a user