diff --git a/.gitignore b/.gitignore index 5aea8c5..a0efdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.pyc .vscode/settings.json +core/preferences.json diff --git a/README.md b/README.md index 12ce092..36bf703 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Avatar Toolkit -## Avatar Toolkit is classed as unstable, if you want to test it and report issues that is fine, however if you are looking for a more stable expiereince please use the Unoffical Cats Blender Plugin unto we release a stable version of Avatar Toolkit, you can find Cats Blender Plugin [HERE](https://github.com/unofficalcats/Cats-Blender-Plugin-Unofficial-)! -#### Avatar Toolkit is in very early Alpha and will contain issues, please ensure you report them! +## 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! A new modern tool designed to shorten steps needed to import and optimize models into VRChat, Resonite and other similar games. @@ -13,23 +13,32 @@ Need a more stable toolset while Avatar Toolkit is in Alpha? Then please use Ble ## Blender version support policies. -Coming Soon. +You can find them on the wiki here [HERE](https://avatartoolkit.xyz/wiki.html?version=0.2.0#what-is-avatar-toolkits-version-support-policy) ## Features -Coming Soon. +See everything Avatar Toolkit has ot offer [here](https://avatartoolkit.xyz/wiki.html) ## Requirements -While The Avatar Toolkit is in Alpha, we are running on Blender 4.3 Alpha, however no stable releases will happen on Blender 4.x as we are aiming for Blender 5.0 for any type of stable release. -- Blender 4.3 Alpha or above (run as administrator is recommended). -- If you have custom Python installed which Blender might use, you need to have NumPy installed +1) Blender Version +- Blender 4.4 or newer is required +- Blender 4.4 is the current recommended version + + +2) Python Requirements +- If using a custom Python installation with Blender, ensure NumPy is installed +- Default Blender installation includes all required packages + +3) Recommended Setup +- Download Blender directly from https://blender.org +- Use Blender 4.4 for the best experience #### Additional Plugins Requirements. Currently None. ## Installation -Coming Soon +You can find out how to install Avatar Toolkit [here](https://avatartoolkit.xyz/wiki.html?version=0.2.0#how-to-install-avatar-toolkit) ## Help diff --git a/__init__.py b/__init__.py index 59a9275..14dabc2 100644 --- a/__init__.py +++ b/__init__.py @@ -18,10 +18,25 @@ def register(): from .core import auto_load print("Starting registration") + + # Make sure to initialize logging first + from .core.logging_setup import configure_logging + configure_logging(False) + + # Then initialize the addon auto_load.init() + + # Register classes in proper order auto_load.register() + + # Verify property registration + import bpy + if not hasattr(bpy.types.Scene, "avatar_toolkit"): + from .core.properties import register as register_properties + register_properties() + print("Registration complete") def unregister(): from .core import auto_load - auto_load.unregister() + auto_load.unregister() \ No newline at end of file diff --git a/blender_manifest.toml b/blender_manifest.toml index 7b8d826..cd05857 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -3,21 +3,21 @@ schema_version = "1.0.0" id = "avatar_toolkit" -version = "0.1.2" +version = "0.2.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.3.0" +blender_version_min = "4.4.0" license = [ "SPDX:GPL-3.0-or-later", ] wheels = [ - "./wheels/lz4-4.3.3-cp311-cp311-macosx_11_0_arm64.whl", - "./wheels/lz4-4.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", - "./wheels/lz4-4.3.3-cp311-cp311-win_amd64.whl" + "./wheels/lz4-4.4.3-cp311-cp311-macosx_11_0_arm64.whl", + "./wheels/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl", + "./wheels/lz4-4.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "./wheels/lz4-4.4.3-cp311-cp311-win_amd64.whl" ] - diff --git a/core/addon_preferences.py b/core/addon_preferences.py index b9fdd63..1bea83a 100644 --- a/core/addon_preferences.py +++ b/core/addon_preferences.py @@ -59,4 +59,5 @@ def get_addon_preferences(context): if not os.path.exists(PREFERENCES_FILE): save_preference("language", 0) # Set default language to 0 (auto) save_preference("validation_mode", "STRICT") # Set default validation mode - save_preference("enable_logging", False) # Set default logging mode \ No newline at end of file + save_preference("enable_logging", False) # Set default logging mode + save_preference("highlight_problem_bones", True) # Set default bone highlighting \ No newline at end of file diff --git a/core/armature_validation.py b/core/armature_validation.py new file mode 100644 index 0000000..446bac4 --- /dev/null +++ b/core/armature_validation.py @@ -0,0 +1,567 @@ +import bpy +import math +from mathutils import Vector, Color +from typing import Tuple, List, Dict, Set, Optional, Union +from bpy.types import Object, Bone, Operator +from ..core.common import get_armature_list, get_active_armature +from ..core.translations import t +from ..core.dictionaries import ( + standard_bones, + bone_hierarchy, + finger_hierarchy, + acceptable_bone_hierarchy, + acceptable_bone_names +) +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]]]: + """ + 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 + messages: List[str] = [] + hierarchy_messages: List[str] = [] + non_standard_messages: List[str] = [] + scale_messages: List[str] = [] + + if validation_mode == 'NONE': + logger.debug("Validation mode is NONE, skipping validation") + if detailed_messages: + return True, [], False, [], [], [] + else: + return True, [], False + + if not armature or armature.type != 'ARMATURE' or not armature.data.bones: + logger.warning("Basic armature check failed") + if detailed_messages: + return False, [t("Armature.validation.basic_check_failed")], False, [], [], [] + else: + return False, [t("Armature.validation.basic_check_failed")], False + + found_bones: Dict[str, Bone] = {bone.name: bone for bone in armature.data.bones} + logger.debug(f"Found {len(found_bones)} bones in armature") + is_acceptable = check_acceptable_standards(found_bones) + + # List all bones in armature + bone_list = "\n".join([f"- {bone}" for bone in found_bones.keys()]) + messages.append(t("Armature.validation.found_bones", bones=bone_list)) + + # Basic validation for both STRICT and LIMITED modes + # Check for missing required bones + essential_bones = {standard_bones[key] for key in ['hips', 'spine', 'chest', 'neck', 'head']} + missing_bones = [bone for bone in essential_bones if bone not in found_bones] + + if missing_bones: + missing_list = "\n".join([f"- {bone}" for bone in missing_bones]) + logger.warning(f"Missing essential bones: {', '.join(missing_bones)}") + hierarchy_messages.append(t("Armature.validation.missing_bones", bones=missing_list)) + + if validation_mode == 'STRICT': + logger.debug("Performing strict validation") + # Add scale issue detection in STRICT mode + scale_issues = detect_scale_issues(found_bones) + if scale_issues: + logger.warning(f"Found {len(scale_issues)} scale issues") + # CHANGE: Don't combine into a single string, keep as separate items + scale_messages.extend(scale_issues) + + # Validate bone hierarchy + for parent, child in bone_hierarchy: + if parent in found_bones and child in found_bones: + if not validate_bone_hierarchy(found_bones, parent, child): + logger.warning(f"Invalid hierarchy: {parent} -> {child}") + hierarchy_messages.append(t("Armature.validation.invalid_hierarchy", + parent=parent, child=child)) + + # Validate symmetry + logger.debug("Validating bone symmetry") + symmetry_pairs = [('arm', 'L', 'R'), ('leg', 'L', 'R')] + for base, left, right in symmetry_pairs: + if not validate_symmetry(found_bones, base, left, right): + logger.warning(f"Asymmetric bones found: {base}") + hierarchy_messages.append(t("Armature.validation.asymmetric_bones", bone=base)) + + if (not validate_symmetry(found_bones, 'hand', 'L', 'R') and + not validate_symmetry(found_bones, 'wrist', 'L', 'R')): + logger.warning("Asymmetric hand/wrist bones found") + hierarchy_messages.append(t("Armature.validation.asymmetric_hand_wrist")) + + # Validate finger hierarchies + logger.debug("Validating finger hierarchies") + for side in ['left', 'right']: + for finger_chain in finger_hierarchy[side]: + if all(bone in found_bones for bone in finger_chain): + if not validate_finger_chain(found_bones, finger_chain): + logger.warning(f"Invalid finger hierarchy: {finger_chain[0]}") + hierarchy_messages.append(t("Armature.validation.invalid_finger", finger=finger_chain[0])) + + # 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' + ] + + 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()) + if not (is_standard or is_acceptable_bone): + non_standard_bones.append(bone_name) + + if non_standard_bones: + logger.warning(f"Found {len(non_standard_bones)} non-standard bones") + non_standard_list = "\n".join([f"- {bone}" for bone in non_standard_bones]) + non_standard_messages.append(t("Armature.validation.non_standard_bones", bones=non_standard_list)) + + non_standard_messages.append(t("Armature.validation.accessory_bones_note.line1")) + non_standard_messages.append(t("Armature.validation.accessory_bones_note.line2")) + non_standard_messages.append(t("Armature.validation.accessory_bones_note.line3")) + non_standard_messages.append(t("Armature.validation.accessory_bones_note.line4")) + non_standard_messages.append("") # Add a blank line for spacing + non_standard_messages.append(t("Armature.validation.standardize_note.line1")) + non_standard_messages.append(t("Armature.validation.standardize_note.line2")) + non_standard_messages.append(t("Armature.validation.standardize_note.line3")) + + # Combine messages in correct order + messages.extend(non_standard_messages) + + is_valid = len(non_standard_messages) == 0 and len(hierarchy_messages) == 0 and len(scale_messages) == 0 + + if not is_valid and is_acceptable: + if non_standard_bones: + logger.info("Armature has non-standard bones but is acceptable") + if detailed_messages: + return False, messages, False, hierarchy_messages, scale_messages, non_standard_messages + else: + return False, messages, False + + logger.info("Armature meets acceptable standards") + messages = [ + t("Armature.validation.acceptable_standard.success"), + t("Armature.validation.acceptable_standard.note"), + t("Armature.validation.acceptable_standard.option") + ] + if detailed_messages: + return True, messages, True, [], [], [] + else: + return True, messages, True + + logger.info(f"Armature validation complete. Valid: {is_valid}") + if detailed_messages: + return is_valid, messages, False, hierarchy_messages, scale_messages, non_standard_messages + else: + return is_valid, messages, False + +def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name: str) -> bool: + """Validate if there is a valid parent-child relationship between bones""" + if parent_name not in bones or child_name not in bones: + return False + return bones[child_name].parent == bones[parent_name] + +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 + left_bone_names = set() + right_bone_names = set() + + # 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) + elif '_r' in key.lower(): + right_bone_names.add(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) + elif '_r' in key.lower(): + right_bone_names.update(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) + + return left_exists == right_exists + +def validate_finger_chain(bones: Dict[str, Bone], chain: Tuple[str, ...]) -> bool: + """Validate if a finger bone chain has correct hierarchy""" + for i in range(len(chain) - 1): + if not validate_bone_hierarchy(bones, chain[i], chain[i + 1]): + return False + return True + +def check_acceptable_standards(bones: Dict[str, Bone]) -> bool: + """Check if armature matches acceptable non-standard hierarchy""" + logger.debug("Checking for acceptable standards") + # 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: + found = True + break + if not found: + logger.debug(f"Missing acceptable bone for category: {bone_category}") + return False + + # Validate acceptable hierarchy + 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}") + return False + + logger.debug("Armature meets acceptable standards") + return True + +def validate_tpose(armature): + """Validate if armature is in a proper T-pose""" + logger.debug(f"Validating T-pose for armature: {armature.name if armature else 'None'}") + if not armature or armature.type != 'ARMATURE': + logger.warning("No valid armature for T-pose validation") + return False, [t("Validation.tpose.no_armature")] + + issues = [] + + if armature.mode == 'POSE': + bones_collection = armature.pose.bones + get_direction = lambda bone: bone.matrix.to_3x3().col[1].normalized() + else: + bones_collection = armature.data.bones + get_direction = lambda bone: bone.y_axis + + # Get left and right upper arm bones using standard bone names + left_arm = None + right_arm = None + + left_arm_candidates = [standard_bones['left_arm']] # UpperArm.L + if 'arm_l' in acceptable_bone_names: + left_arm_candidates.extend(acceptable_bone_names['arm_l']) + + right_arm_candidates = [standard_bones['right_arm']] # UpperArm.R + if 'arm_r' in acceptable_bone_names: + right_arm_candidates.extend(acceptable_bone_names['arm_r']) + + for name in left_arm_candidates: + if name in armature.data.bones: + left_arm = armature.data.bones[name] + logger.debug(f"Found left arm bone: {name}") + break + + for name in right_arm_candidates: + if name in armature.data.bones: + right_arm = armature.data.bones[name] + logger.debug(f"Found right arm bone: {name}") + break + + # Check arm bones are horizontal + if left_arm: + direction = left_arm.y_axis + if abs(direction.x) < 0.7: # Not pointing mostly along X axis + logger.warning("Left arm is not horizontal") + issues.append(t("Validation.tpose.left_arm_not_horizontal")) + + if right_arm: + direction = right_arm.y_axis + if abs(direction.x) < 0.7: # Not pointing mostly along X axis + logger.warning("Right arm is not horizontal") + issues.append(t("Validation.tpose.right_arm_not_horizontal")) + + spine = None + spine_candidates = [standard_bones['spine']] # Spine + if 'spine' in acceptable_bone_names: + spine_candidates.extend(acceptable_bone_names['spine']) + + for name in spine_candidates: + if name in armature.data.bones: + spine = armature.data.bones[name] + logger.debug(f"Found spine bone: {name}") + break + + if spine: + direction = spine.y_axis + if abs(direction.z) < 0.7: # Not pointing mostly along Z axis + logger.warning("Spine is not vertical") + issues.append(t("Validation.tpose.spine_not_vertical")) + + if issues: + logger.warning(f"T-pose validation failed with {len(issues)} issues") + return False, issues + + logger.info("T-pose validation successful") + return True, [] + +def detect_scale_issues(bones): + """Detect bones with abnormal scale (too small or too large)""" + logger.debug("Detecting scale issues") + scale_issues = [] + + # Calculate median bone length for reference (more robust than average) + lengths = [bone.length for bone in bones.values()] + lengths.sort() + + if not lengths: + logger.debug("No bones with length found") + return [] + + median_length = lengths[len(lengths) // 2] + + # Filter out zero-length bones for standard deviation calculation + non_zero_lengths = [l for l in lengths if l > 0.0001] + + if not non_zero_lengths: + logger.debug("No non-zero length bones found") + return [] + + mean = sum(non_zero_lengths) / len(non_zero_lengths) + variance = sum((l - mean) ** 2 for l in non_zero_lengths) / len(non_zero_lengths) + std_dev = math.sqrt(variance) + + small_threshold = max(median_length * 0.05, mean - 3 * std_dev) + large_threshold = min(median_length * 15, mean + 5 * std_dev) + + logger.debug(f"Scale thresholds - small: {small_threshold}, large: {large_threshold}") + + # Get finger bones from standard and acceptable bone dictionaries + finger_bone_names = set() + + for key in standard_bones: + if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']): + finger_bone_names.add(standard_bones[key]) + + for key, names in acceptable_bone_names.items(): + if any(finger in key.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']): + finger_bone_names.update(names) + + for name, bone in bones.items(): + is_finger = (name in finger_bone_names or + any(finger in name.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger'])) + + if bone.length < small_threshold and not is_finger: + logger.debug(f"Bone {name} is too small: {bone.length}") + scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_small')} ({bone.length:.4f})") + elif bone.length > large_threshold: + logger.debug(f"Bone {name} is too large: {bone.length}") + scale_issues.append(f"- {name}: {t('Validation.scale_issue.too_large')} ({bone.length:.4f})") + + logger.debug(f"Found {len(scale_issues)} scale issues") + return scale_issues + +def clear_bone_highlighting(armature: Object) -> None: + """Clear bone highlighting by removing bone collections and resetting colors""" + logger.debug(f"Clearing bone highlighting for armature: {armature.name if armature else 'None'}") + if not armature or armature.type != 'ARMATURE': + logger.warning("No valid armature for clearing bone highlighting") + return + + current_mode = bpy.context.mode + + collection_name = "Problem Bones" + if collection_name in armature.data.collections: + problem_collection = armature.data.collections[collection_name] + armature.data.collections.remove(problem_collection) + logger.debug("Removed problem bones collection") + + for bone in armature.data.bones: + bone.color.palette = 'DEFAULT' + + if len(armature.data.collections) == 0: + armature.data.show_bone_colors = False + logger.debug("Disabled bone colors display") + + logger.info("Bone highlighting cleared") + return + +class AvatarToolkit_OT_HighlightProblemBones(Operator): + """Highlight bones that fail validation in the 3D viewport""" + bl_idname = "avatar_toolkit.highlight_problem_bones" + bl_label = t("Validation.highlight_problem_bones") + bl_description = t("Validation.highlight_problem_bones_desc") + + @classmethod + def poll(cls, context): + return get_active_armature(context) is not None + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + logger.warning("No active armature found for highlighting problem bones") + self.report({'ERROR'}, t("Validation.no_armature")) + return {'CANCELLED'} + + logger.info(f"Highlighting problem bones for armature: {armature.name}") + + current_mode = context.mode + + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + context.view_layer.objects.active = armature + + # First remove all bone collections + collection_name = "Problem Bones" + if collection_name in armature.data.collections: + problem_collection = armature.data.collections[collection_name] + armature.data.collections.remove(problem_collection) + logger.debug("Removed existing problem bones collection") + + is_valid, messages, _ = validate_armature(armature) + + if is_valid: + logger.info("No validation issues found") + self.report({'INFO'}, t("Validation.no_issues")) + bpy.ops.object.mode_set(mode='EDIT') + return {'FINISHED'} + + problem_collection = armature.data.collections.new(name="Problem Bones") + logger.debug("Created new problem bones collection") + armature.data.show_bone_colors = True + bpy.ops.object.mode_set(mode='EDIT') + + # Extract bone names from validation messages + problem_bones = self._extract_problem_bones(messages) + + # Assign bones to collection and set colors + highlighted_count = 0 + for category, bone_names in problem_bones.items(): + for bone_name in bone_names: + if bone_name in armature.data.edit_bones: + bone = armature.data.edit_bones[bone_name] + problem_collection.assign(bone) + + if 'hierarchy' in category.lower(): + bone.color.palette = 'THEME09' # Orange + elif 'scale' in category.lower(): + bone.color.palette = 'THEME03' # Yellow + else: + bone.color.palette = 'THEME01' # Red + + highlighted_count += 1 + + logger.info(f"Highlighted {highlighted_count} problem bones") + self.report({'INFO'}, t("Validation.highlighting_complete")) + return {'FINISHED'} + + def _extract_problem_bones(self, messages): + problem_bones = { + "Hierarchy Issues": [], + "Scale Issues": [], + "Missing Bones": [] + } + + # Extract bone names from validation messages + for message in messages: + if isinstance(message, str): + # Parse message to extract bone names + for line in message.split('\n'): + if '- ' in line: + bone_name = line.split('- ')[1].strip() + if ':' in bone_name: # Handle "bone_name: message" format + bone_name = bone_name.split(':')[0].strip() + + if 'hierarchy' in message.lower(): + problem_bones["Hierarchy Issues"].append(bone_name) + elif 'scale' in message.lower(): + problem_bones["Scale Issues"].append(bone_name) + else: + problem_bones["Missing Bones"].append(bone_name) + + logger.debug(f"Extracted problem bones: {problem_bones}") + return problem_bones + +class AvatarToolkit_OT_ValidateTPose(Operator): + """Validate if armature is in a proper T-pose""" + bl_idname = "avatar_toolkit.validate_tpose" + bl_label = t("Validation.tpose.label") + bl_description = t("Validation.tpose.desc") + + @classmethod + def poll(cls, context): + return get_active_armature(context) is not None + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + logger.warning("No active armature found for T-pose validation") + self.report({'ERROR'}, t("Validation.no_armature")) + return {'CANCELLED'} + + logger.info(f"Validating T-pose for armature: {armature.name}") + is_valid, messages = validate_tpose(armature) + props = context.scene.avatar_toolkit + props.tpose_validation_result = is_valid + props.tpose_validation_messages.clear() + + for msg in messages: + item = props.tpose_validation_messages.add() + item.name = msg + + props.show_tpose_validation = True + + if is_valid: + logger.info("T-pose validation successful") + self.report({'INFO'}, t("Validation.tpose.valid")) + else: + for msg in messages: + self.report({'WARNING'}, msg) + logger.warning("T-pose validation failed") + self.report({'WARNING'}, t("Validation.tpose.warning")) + + return {'FINISHED'} + +class AvatarToolkit_OT_ClearBoneHighlighting(Operator): + """Clear bone highlighting and reset bone colors""" + bl_idname = "avatar_toolkit.clear_bone_highlighting" + bl_label = t("Validation.clear_bone_highlighting") + bl_description = t("Validation.clear_bone_highlighting_desc") + + @classmethod + def poll(cls, context): + return get_active_armature(context) is not None + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + logger.warning("No active armature found for clearing bone highlighting") + self.report({'ERROR'}, t("Validation.no_armature")) + return {'CANCELLED'} + + logger.info(f"Clearing bone highlighting for armature: {armature.name}") + current_mode = context.mode + + # Switch to object mode as collection editing is not possible in edit mode + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + context.view_layer.objects.active = armature + + collection_name = "Problem Bones" + if collection_name in armature.data.collections: + # Remove the collection + problem_collection = armature.data.collections[collection_name] + armature.data.collections.remove(problem_collection) + logger.debug("Removed problem bones collection") + + bpy.ops.object.mode_set(mode='EDIT') + + # Reset all bone colors + for bone in armature.data.edit_bones: + bone.color.palette = 'DEFAULT' + + # Turn off bone colors display if no other collections are using it + if len(armature.data.collections) == 0: + armature.data.show_bone_colors = False + logger.debug("Disabled bone colors display") + + bpy.ops.object.mode_set(mode='OBJECT') + + logger.info("Bone highlighting cleared") + self.report({'INFO'}, t("Validation.highlighting_cleared")) + return {'FINISHED'} diff --git a/core/common.py b/core/common.py index c232856..452760c 100644 --- a/core/common.py +++ b/core/common.py @@ -10,7 +10,7 @@ import numpy.typing as npt from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type from mathutils import Vector, Matrix -from bpy.types import (Context, Object, Modifier, EditBone, Operator, +from bpy.types import (Context, Object, Modifier, EditBone, Operator, Material, VertexGroup, ShapeKey, Bone, Mesh, Armature, PropertyGroup) from functools import lru_cache from bpy.props import PointerProperty, IntProperty, StringProperty @@ -19,6 +19,47 @@ from ..core.logging_setup import logger from ..core.translations import t from ..core.dictionaries import bone_names +class SceneMatClass(PropertyGroup): + mat: PointerProperty(type=Material) + +register_class(SceneMatClass) + +class MaterialListBool: + #For the love that is holy do not ever touch these. If this was java I would make these private + #They should only be accessed via context.scene.texture_atlas_Has_Mat_List_Shown + #This is so we know if the materials are up to date. messing with these variables directly will make the thing blow up. + #The only exception to this is the ExpandSection_Materials operator which populates this with new data once the materials have changed and need reloading. + old_list: dict[str,list[Material]] = {} + bool_material_list_expand: dict[str,bool] = {} + + def set_bool(self, value: bool) -> None: + MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = value + if value == False: + MaterialListBool.old_list[bpy.context.scene.name] = [] + + def get_bool(self) -> bool: + newlist: list[Material] = [] + for obj in bpy.context.scene.objects: + if len(obj.material_slots)>0: + for mat_slot in obj.material_slots: + if mat_slot.material: + if mat_slot.material not in newlist: + newlist.append(mat_slot.material) + still_the_same: bool = True + if bpy.context.scene.name in MaterialListBool.old_list: + for item in newlist: + if item not in MaterialListBool.old_list[bpy.context.scene.name]: + still_the_same = False + break + for item in MaterialListBool.old_list[bpy.context.scene.name]: + if item not in newlist: + still_the_same = False + break + else: + still_the_same = False + MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = still_the_same + return MaterialListBool.bool_material_list_expand[bpy.context.scene.name] + class ProgressTracker: """Universal progress tracking for Avatar Toolkit operations""" @@ -66,89 +107,6 @@ def get_armature_list(self: Optional[Any] = None, context: Optional[Context] = N if not armatures: return [('NONE', t("Armature.validation.no_armature"), '')] return armatures - -def validate_armature(armature: Object) -> Tuple[bool, List[str]]: - """Enhanced armature validation with multiple validation modes""" - validation_mode = bpy.context.scene.avatar_toolkit.validation_mode - messages: List[str] = [] - - if validation_mode == 'NONE': - return True, [] - - if not armature or armature.type != 'ARMATURE' or not armature.data.bones: - return False, [t("Armature.validation.basic_check_failed")] - - found_bones: Dict[str, Bone] = {bone.name.lower(): bone for bone in armature.data.bones} - essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'} - - missing_bones: List[str] = [] - for bone in essential_bones: - if not any(alt_name in found_bones for alt_name in bone_names[bone]): - missing_bones.append(bone) - - if missing_bones: - messages.append(t("Armature.validation.missing_bones", bones=", ".join(missing_bones))) - - if validation_mode == 'STRICT': - hierarchy: List[Tuple[str, str]] = [ - ('hips', 'spine'), ('spine', 'chest'), - ('chest', 'neck'), ('neck', 'head') - ] - for parent, child in hierarchy: - if not validate_bone_hierarchy(found_bones, parent, child): - messages.append(t("Armature.validation.invalid_hierarchy", - parent=parent, child=child)) - - symmetry_pairs: List[Tuple[str, str, str]] = [('arm', 'l', 'r'), ('leg', 'l', 'r')] - for base, left, right in symmetry_pairs: - if not validate_symmetry(found_bones, base, left, right): - messages.append(t("Armature.validation.asymmetric_bones", bone=base)) - - if (not validate_symmetry(found_bones, 'hand', 'l', 'r') and - not validate_symmetry(found_bones, 'wrist', 'l', 'r')): - messages.append(t("Armature.validation.asymmetric_hand_wrist")) - - is_valid: bool = len(messages) == 0 - return is_valid, messages - -def validate_bone_hierarchy(bones: Dict[str, Bone], parent_name: str, child_name: str) -> bool: - """Validate if there is a valid parent-child relationship between bones""" - parent_bone: Optional[Bone] = None - child_bone: Optional[Bone] = None - - for alt_name in bone_names[parent_name]: - if alt_name in bones: - parent_bone = bones[alt_name] - break - - for alt_name in bone_names[child_name]: - if alt_name in bones: - child_bone = bones[alt_name] - break - - if not parent_bone or not child_bone: - return False - - return child_bone.parent == parent_bone - -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""" - left_patterns: List[str] = [ - f"{base}.{left}", - f"{base}_{left}", - f"{left}_{base}" - ] - - right_patterns: List[str] = [ - f"{base}.{right}", - f"{base}_{right}", - f"{right}_{base}" - ] - - left_exists: bool = any(pattern in bones for pattern in left_patterns) - right_exists: bool = any(pattern in bones for pattern in right_patterns) - - return left_exists and right_exists def auto_select_single_armature(context: Context) -> None: """Automatically select armature if only one exists in scene""" @@ -320,51 +278,67 @@ def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]: return False, t("Optimization.non_mesh_objects") return True, "" -def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Optional[Object]: - """Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing""" - try: - # Store UV maps before joining - uv_maps_data = {} - for mesh in meshes: - uv_maps_data[mesh.name] = {uv.name: uv.data.copy() for uv in mesh.data.uv_layers} +def fast_uv_fix(obj: Object) -> None: + """Fast UV coordinate fixing for joined meshes""" + if not obj or not obj.data or not obj.data.uv_layers: + return + + current_mode = bpy.context.mode + + if current_mode != 'EDIT_MESH': + bpy.ops.object.mode_set(mode='EDIT') + + bpy.ops.mesh.select_all(action='SELECT') + + # Process all UV layers at once + bpy.ops.uv.select_all(action='SELECT') + bpy.ops.uv.pack_islands(margin=0.001) + + if current_mode != 'EDIT_MESH': + bpy.ops.object.mode_set(mode=current_mode) +def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Optional[Object]: + """Combines multiple mesh objects into a single mesh with optimized performance""" + try: + if not meshes: + return None + bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') - for mesh in meshes: + # Create a list of valid meshes + valid_meshes = [mesh for mesh in meshes if mesh.name in bpy.data.objects] + if not valid_meshes: + return None + + for mesh in valid_meshes: mesh.select_set(True) - if context.selected_objects: - context.view_layer.objects.active = context.selected_objects[0] + context.view_layer.objects.active = valid_meshes[0] + + if progress: + progress.step(t("Optimization.joining_meshes")) - if progress: - progress.step(t("Optimization.joining_meshes")) - bpy.ops.object.join() + bpy.ops.object.join() + joined_mesh = context.active_object + + if progress: + progress.step(t("Optimization.applying_transforms")) - if progress: - progress.step(t("Optimization.applying_transforms")) - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + if progress: + progress.step(t("Optimization.fixing_uvs")) - if progress: - progress.step(t("Optimization.fixing_uvs")) - fix_uv_coordinates(context) - - # Restore UV maps after joining - joined_mesh = context.active_object - for uv_name, uv_data in uv_maps_data.items(): - for map_name, map_data in uv_data.items(): - if map_name not in joined_mesh.data.uv_layers: - joined_mesh.data.uv_layers.new(name=map_name) - joined_mesh.data.uv_layers[map_name].data.foreach_set("uv", map_data) - - return context.active_object - - return None + fast_uv_fix(joined_mesh) + + return joined_mesh except Exception as e: logger.error(f"Failed to join meshes: {str(e)}") return None + def fix_uv_coordinates(context: Context) -> None: """Normalizes and fixes UV coordinates for the active mesh object""" obj: Object = context.object diff --git a/core/dictionaries.py b/core/dictionaries.py index 9a54b05..d3f8df5 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -354,3 +354,590 @@ resonite_translations = { 'thumb_2_r': "thumb2.R", 'thumb_3_r': "thumb3.R" } + +standard_bones = { + # Core Structure + 'hips': 'Hips', + 'spine': 'Spine', + 'chest': 'Chest', + 'upper_chest': 'Chest.Up', + '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', + + # 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', + + # 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', + + # 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', + + # Eyes + 'left_eye': 'Eye.L', + 'right_eye': 'Eye.R' +} + +bone_hierarchy = [ + ('Hips', 'Spine'), + ('Spine', 'Chest'), + ('Chest', 'Chest.Up'), + ('Chest.Up', 'Neck'), + ('Neck', 'Head'), + ('Head', 'Eye.L'), + ('Head', 'Eye.R'), + + # Left Arm Chain + ('Chest.Up', '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'), + + # Left Leg Chain + ('Hips', 'UpperLeg.L'), + ('UpperLeg.L', 'LowerLeg.L'), + ('LowerLeg.L', 'Foot.L'), + ('Foot.L', 'Toes.L'), + + # Right Leg Chain + ('Hips', 'UpperLeg.R'), + ('UpperLeg.R', 'LowerLeg.R'), + ('LowerLeg.R', 'Foot.R'), + ('Foot.R', 'Toes.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') + ], + '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') + ] +} + +acceptable_bone_hierarchy = [ + # Right side chain + ('Hips', 'Chest'), + ('Chest', 'Shoulder.R'), + ('Shoulder.R', 'Arm.R'), + ('Arm.R', 'Elbow.R'), + ('Elbow.R', 'Wrist.R'), + ('Hips', 'Leg.R'), + ('Leg.R', 'Knee.R'), + ('Knee.R', 'Foot.R'), + ('Foot.R', 'Toes.R'), + + # Left side chain + ('Chest', 'Shoulder.L'), + ('Shoulder.L', 'Arm.L'), + ('Arm.L', 'Elbow.L'), + ('Elbow.L', 'Wrist.L'), + ('Hips', 'Leg.L'), + ('Leg.L', 'Knee.L'), + ('Knee.L', 'Foot.L'), + ('Foot.L', 'Toes.L'), + + # Head and Eyes + ('Chest', 'Neck'), + ('Neck', 'Head'), + ('Head', 'Eye_L'), + ('Head', 'Eye_R'), + ('Head', 'LeftEye'), + ('Head', 'RightEye'), + + # Unity humanoid naming + ('Hips', 'Spine'), + ('Spine', 'Chest'), + ('Chest', 'UpperChest'), + ('UpperChest', 'Neck'), + ('Neck', 'Head'), + ('Head', 'LeftEye'), + ('Head', 'RightEye'), + +] + +acceptable_bone_names = { + 'hips': ['Hips', 'pelvis', 'root', 'Root', 'ROOT'], + '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'], + + '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_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'], + + # 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'], + + # 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'], + + 'breast_upper_1_l': ['BreastUpper1_L'], + 'breast_upper_2_l': ['BreastUpper2_L'], + 'breast_upper_1_r': ['BreastUpper1_R'], + 'breast_upper_2_r': ['BreastUpper2_R'], + + 'ear_upper_l': ['UpperEar.L', 'Upper Ear.L', 'Upper Ear_L'], + 'ear_upper_r': ['UpperEar.R', 'Upper Ear.R', 'Upper Ear_R'], + 'ear_lower_l': ['LowerEar.L', 'Lower Ear.L', 'Lower Ear_L'], + 'ear_lower_r': ['LowerEar.R', 'Lower Ear.R', 'Lower Ear_R'], + + 'ears_upper': ['Ears Upper', 'EarsUpper', 'ears_upper'], + 'ears_lower': ['Ears Lower', 'EarsLower', 'ears_lower'] +} + +rigify_unity_names = { + "DEF-spine": "Hips", + "DEF-spine.001": "Spine", + "DEF-spine.002": "Chest", + "DEF-spine.003": "UpperChest", + "DEF-neck": "Neck", + "DEF-head": "Head", + "DEF-shoulder.L": "LeftShoulder", + "DEF-upper_arm.L": "LeftUpperArm", + "DEF-forearm.L": "LeftLowerArm", + "DEF-hand.L": "LeftHand", + "DEF-shoulder.R": "RightShoulder", + "DEF-upper_arm.R": "RightUpperArm", + "DEF-forearm.R": "RightLowerArm", + "DEF-hand.R": "RightHand", + "DEF-thigh.L": "LeftUpperLeg", + "DEF-shin.L": "LeftLowerLeg", + "DEF-foot.L": "LeftFoot", + "DEF-toe.L": "LeftToes", + "DEF-thigh.R": "RightUpperLeg", + "DEF-shin.R": "RightLowerLeg", + "DEF-foot.R": "RightFoot", + "DEF-toe.R": "RightToes" +} + +rigify_basic_unity_names = { + "spine": "Hips", + "spine.001": "Spine", + "spine.002": "Chest", + "spine.003": "UpperChest", + "neck": "Neck", + "head": "Head", + "shoulder.L": "LeftShoulder", + "upper_arm.L": "LeftUpperArm", + "forearm.L": "LeftLowerArm", + "hand.L": "LeftHand", + "shoulder.R": "RightShoulder", + "upper_arm.R": "RightUpperArm", + "forearm.R": "RightLowerArm", + "hand.R": "RightHand", + "thigh.L": "LeftUpperLeg", + "shin.L": "LeftLowerLeg", + "foot.L": "LeftFoot", + "toe.L": "LeftToes", + "thigh.R": "RightUpperLeg", + "shin.R": "RightLowerLeg", + "foot.R": "RightFoot", + "toe.R": "RightToes" +} + +rigify_unnecessary_bones = [ + 'face', + 'ear.l', 'ear.r', + 'forehead', + 'cheek.t.l', 'cheek.t.r', + 'cheek.b.l', 'cheek.b.r', + 'brow.t.l', 'brow.t.r', + 'brow.b.l', 'brow.b.r', + 'jaw', + 'chin', + 'nose', + 'temple.l', 'temple.r', + 'teeth', + 'lip', + 'lid', + 'heel', + 'pelvis.' +] + +# Non-standard bone mappings to standard bones +non_standard_mappings = { + 'hips': [ + 'mixamorig:Hips', 'mixamorig_Hips', + 'ORG-spine', 'spine', 'root', + 'hip', 'pelvis' + ], + 'spine': [ + 'mixamorig:Spine', 'mixamorig_Spine', + 'ORG-spine.001', 'spine.001', + 'abdomenLower', 'lowerback' + ], + 'chest': [ + 'mixamorig:Spine1', 'mixamorig_Spine1', + 'ORG-spine.002', 'spine.002', + 'abdomenUpper', 'upperback', 'spine1' + ], + 'upper_chest': [ + 'mixamorig:Spine2', 'mixamorig_Spine2', + 'ORG-spine.003', 'spine.003', + 'chestLower', 'chest', 'spine2' + ], + 'neck': [ + 'mixamorig:Neck', 'mixamorig_Neck', + 'ORG-spine.004', 'spine.004', 'neck', + 'neckLower' + ], + 'head': [ + 'mixamorig:Head', 'mixamorig_Head', + 'ORG-spine.005', 'spine.005', 'face', 'head' + ], + + 'left_shoulder': [ + 'mixamorig:LeftShoulder', 'mixamorig_LeftShoulder', + 'ORG-shoulder.L', 'shoulder.L', + 'lCollar', 'lShldr', 'lClavicle' + ], + 'left_arm': [ + 'mixamorig:LeftArm', 'mixamorig_LeftArm', + 'ORG-upper_arm.L', 'upper_arm.L', + 'lShldrBend', 'lShldrTwist', 'lArm' + ], + 'left_elbow': [ + 'mixamorig:LeftForeArm', 'mixamorig_LeftForeArm', + 'ORG-forearm.L', 'forearm.L', + 'lForearmBend', 'lElbow', 'lForeArm' + ], + 'left_wrist': [ + 'mixamorig:LeftHand', 'mixamorig_LeftHand', + 'ORG-hand.L', 'hand.L', + 'lHand', 'lWrist' + ], + + 'right_shoulder': [ + 'mixamorig:RightShoulder', 'mixamorig_RightShoulder', + 'ORG-shoulder.R', 'shoulder.R', + 'rCollar', 'rShldr', 'rClavicle' + ], + 'right_arm': [ + 'mixamorig:RightArm', 'mixamorig_RightArm', + 'ORG-upper_arm.R', 'upper_arm.R', + 'rShldrBend', 'rShldrTwist', 'rArm' + ], + 'right_elbow': [ + 'mixamorig:RightForeArm', 'mixamorig_RightForeArm', + 'ORG-forearm.R', 'forearm.R', + 'rForearmBend', 'rElbow', 'rForeArm' + ], + 'right_wrist': [ + 'mixamorig:RightHand', 'mixamorig_RightHand', + 'ORG-hand.R', 'hand.R', + 'rHand', 'rWrist' + ], + + 'left_leg': [ + 'mixamorig:LeftUpLeg', 'mixamorig_LeftUpLeg', + 'ORG-thigh.L', 'thigh.L', + 'lThighBend', 'lThigh' + ], + 'left_knee': [ + 'mixamorig:LeftLeg', 'mixamorig_LeftLeg', + 'ORG-shin.L', 'shin.L', + 'lShin', 'lKnee', 'lLeg' + ], + 'left_ankle': [ + 'mixamorig:LeftFoot', 'mixamorig_LeftFoot', + 'ORG-foot.L', 'foot.L', + 'lFoot', 'lAnkle' + ], + 'left_toe': [ + 'mixamorig:LeftToeBase', 'mixamorig_LeftToeBase', + 'ORG-toe.L', 'toe.L', + 'lToe' + ], + + 'right_leg': [ + 'mixamorig:RightUpLeg', 'mixamorig_RightUpLeg', + 'ORG-thigh.R', 'thigh.R', + 'rThighBend', 'rThigh' + ], + 'right_knee': [ + 'mixamorig:RightLeg', 'mixamorig_RightLeg', + 'ORG-shin.R', 'shin.R', + 'rShin', 'rKnee', 'rLeg' + ], + 'right_ankle': [ + 'mixamorig:RightFoot', 'mixamorig_RightFoot', + 'ORG-foot.R', 'foot.R', + 'rFoot', 'rAnkle' + ], + 'right_toe': [ + 'mixamorig:RightToeBase', 'mixamorig_RightToeBase', + 'ORG-toe.R', 'toe.R', + 'rToe' + ], + + 'thumb_1_l': [ + 'mixamorig:LeftHandThumb1', 'mixamorig_LeftHandThumb1', + 'ORG-thumb.01.L', 'thumb.01.L', + 'lThumb1' + ], + 'thumb_2_l': [ + 'mixamorig:LeftHandThumb2', 'mixamorig_LeftHandThumb2', + 'ORG-thumb.02.L', 'thumb.02.L', + 'lThumb2' + ], + 'thumb_3_l': [ + 'mixamorig:LeftHandThumb3', 'mixamorig_LeftHandThumb3', + 'ORG-thumb.03.L', 'thumb.03.L', + 'lThumb3' + ], + + 'index_1_l': [ + 'mixamorig:LeftHandIndex1', 'mixamorig_LeftHandIndex1', + 'ORG-f_index.01.L', 'f_index.01.L', + 'lIndex1' + ], + 'index_2_l': [ + 'mixamorig:LeftHandIndex2', 'mixamorig_LeftHandIndex2', + 'ORG-f_index.02.L', 'f_index.02.L', + 'lIndex2' + ], + 'index_3_l': [ + 'mixamorig:LeftHandIndex3', 'mixamorig_LeftHandIndex3', + 'ORG-f_index.03.L', 'f_index.03.L', + 'lIndex3' + ], + + 'middle_1_l': [ + 'mixamorig:LeftHandMiddle1', 'mixamorig_LeftHandMiddle1', + 'ORG-f_middle.01.L', 'f_middle.01.L', + 'lMid1' + ], + 'middle_2_l': [ + 'mixamorig:LeftHandMiddle2', 'mixamorig_LeftHandMiddle2', + 'ORG-f_middle.02.L', 'f_middle.02.L', + 'lMid2' + ], + 'middle_3_l': [ + 'mixamorig:LeftHandMiddle3', 'mixamorig_LeftHandMiddle3', + 'ORG-f_middle.03.L', 'f_middle.03.L', + 'lMid3' + ], + + 'ring_1_l': [ + 'mixamorig:LeftHandRing1', 'mixamorig_LeftHandRing1', + 'ORG-f_ring.01.L', 'f_ring.01.L', + 'lRing1' + ], + 'ring_2_l': [ + 'mixamorig:LeftHandRing2', 'mixamorig_LeftHandRing2', + 'ORG-f_ring.02.L', 'f_ring.02.L', + 'lRing2' + ], + 'ring_3_l': [ + 'mixamorig:LeftHandRing3', 'mixamorig_LeftHandRing3', + 'ORG-f_ring.03.L', 'f_ring.03.L', + 'lRing3' + ], + + 'pinkie_1_l': [ + 'mixamorig:LeftHandPinky1', 'mixamorig_LeftHandPinky1', + 'ORG-f_pinky.01.L', 'f_pinky.01.L', + 'lPinky1' + ], + 'pinkie_2_l': [ + 'mixamorig:LeftHandPinky2', 'mixamorig_LeftHandPinky2', + 'ORG-f_pinky.02.L', 'f_pinky.02.L', + 'lPinky2' + ], + 'pinkie_3_l': [ + 'mixamorig:LeftHandPinky3', 'mixamorig_LeftHandPinky3', + 'ORG-f_pinky.03.L', 'f_pinky.03.L', + 'lPinky3' + ], + + 'thumb_1_r': [ + 'mixamorig:RightHandThumb1', 'mixamorig_RightHandThumb1', + 'ORG-thumb.01.R', 'thumb.01.R', + 'rThumb1' + ], + 'thumb_2_r': [ + 'mixamorig:RightHandThumb2', 'mixamorig_RightHandThumb2', + 'ORG-thumb.02.R', 'thumb.02.R', + 'rThumb2' + ], + 'thumb_3_r': [ + 'mixamorig:RightHandThumb3', 'mixamorig_RightHandThumb3', + 'ORG-thumb.03.R', 'thumb.03.R', + 'rThumb3' + ], + + 'index_1_r': [ + 'mixamorig:RightHandIndex1', 'mixamorig_RightHandIndex1', + 'ORG-f_index.01.R', 'f_index.01.R', + 'rIndex1' + ], + 'index_2_r': [ + 'mixamorig:RightHandIndex2', 'mixamorig_RightHandIndex2', + 'ORG-f_index.02.R', 'f_index.02.R', + 'rIndex2' + ], + 'index_3_r': [ + 'mixamorig:RightHandIndex3', 'mixamorig_RightHandIndex3', + 'ORG-f_index.03.R', 'f_index.03.R', + 'rIndex3' + ], + + 'middle_1_r': [ + 'mixamorig:RightHandMiddle1', 'mixamorig_RightHandMiddle1', + 'ORG-f_middle.01.R', 'f_middle.01.R', + 'rMid1' + ], + 'middle_2_r': [ + 'mixamorig:RightHandMiddle2', 'mixamorig_RightHandMiddle2', + 'ORG-f_middle.02.R', 'f_middle.02.R', + 'rMid2' + ], + 'middle_3_r': [ + 'mixamorig:RightHandMiddle3', 'mixamorig_RightHandMiddle3', + 'ORG-f_middle.03.R', 'f_middle.03.R', + 'rMid3' + ], + + 'ring_1_r': [ + 'mixamorig:RightHandRing1', 'mixamorig_RightHandRing1', + 'ORG-f_ring.01.R', 'f_ring.01.R', + 'rRing1' + ], + 'ring_2_r': [ + 'mixamorig:RightHandRing2', 'mixamorig_RightHandRing2', + 'ORG-f_ring.02.R', 'f_ring.02.R', + 'rRing2' + ], + 'ring_3_r': [ + 'mixamorig:RightHandRing3', 'mixamorig_RightHandRing3', + 'ORG-f_ring.03.R', 'f_ring.03.R', + 'rRing3' + ], + + 'pinkie_1_r': [ + 'mixamorig:RightHandPinky1', 'mixamorig_RightHandPinky1', + 'ORG-f_pinky.01.R', 'f_pinky.01.R', + 'rPinky1' + ], + 'pinkie_2_r': [ + 'mixamorig:RightHandPinky2', 'mixamorig_RightHandPinky2', + 'ORG-f_pinky.02.R', 'f_pinky.02.R', + 'rPinky2' + ], + 'pinkie_3_r': [ + 'mixamorig:RightHandPinky3', 'mixamorig_RightHandPinky3', + 'ORG-f_pinky.03.R', 'f_pinky.03.R', + 'rPinky3' + ], + + 'left_eye': [ + 'mixamorig:LeftEye', 'mixamorig_LeftEye', + 'ORG-eye.L', 'eye.L', + 'lEye' + ], + 'right_eye': [ + 'mixamorig:RightEye', 'mixamorig_RightEye', + 'ORG-eye.R', 'eye.R', + 'rEye' + ] +} + +for category, mappings in non_standard_mappings.items(): + if category in bone_names: + bone_names[category].extend(mappings) + else: + bone_names[category] = mappings \ No newline at end of file diff --git a/core/importers/import_pmd.py b/core/importers/import_pmd.py deleted file mode 100644 index 33e0e71..0000000 --- a/core/importers/import_pmd.py +++ /dev/null @@ -1,271 +0,0 @@ -import bpy -import struct -import mathutils -import traceback -import os - -from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeOutputMaterial - -def read_pmd_header(file): - # Read PMD header information - magic = file.read(3) - if magic != b'Pmd': - raise ValueError("Invalid PMD file") - - version = struct.unpack('= 0: - constraint = pose_bone.constraints.new('COPY_ROTATION') - constraint.name = "MMD Rotation" - constraint.target = armature_obj - constraint.subtarget = bones[bone_data.inherit_parent_index].name - constraint.influence = bone_data.inherit_influence - constraint.target_space = 'LOCAL' - constraint.owner_space = 'LOCAL' - - # Then handle IK constraints - for bone_data in bones: - pose_bone = armature_obj.pose.bones.get(bone_data.name) - if not pose_bone: - continue - - # Skip non-deforming bones - if not pose_bone.bone.use_deform: - continue - - if bone_data.flag & 0x0020: # IK - if bone_data.ik_target_index >= 0: - constraint = pose_bone.constraints.new('IK') - constraint.name = "MMD IK" - constraint.target = armature_obj - constraint.subtarget = bones[bone_data.ik_target_index].name - constraint.chain_count = min(len(bone_data.ik_links), 3) - constraint.iterations = min(bone_data.ik_loop_count, 8) - constraint.use_tail = False - constraint.use_stretch = False - - # Configure IK chain - for link_bone_index, has_limits, angle_limits in bone_data.ik_links: - link_pose_bone = armature_obj.pose.bones.get(bones[link_bone_index].name) - if link_pose_bone and link_pose_bone.bone.use_deform: - link_pose_bone.rotation_mode = 'XYZ' - link_pose_bone.use_ik_limit_x = True - link_pose_bone.use_ik_limit_y = True - link_pose_bone.use_ik_limit_z = True - - if has_limits and angle_limits: - min_angles, max_angles = angle_limits - link_pose_bone.ik_min_x = max(-1.4, min_angles[0]) - link_pose_bone.ik_max_x = min(1.4, max_angles[0]) - link_pose_bone.ik_min_y = max(-1.4, min_angles[1]) - link_pose_bone.ik_max_y = min(1.4, max_angles[1]) - link_pose_bone.ik_min_z = max(-1.4, min_angles[2]) - link_pose_bone.ik_max_z = min(1.4, max_angles[2]) - - # Reset pose to default state - bpy.ops.pose.select_all(action='SELECT') - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action='DESELECT') - - bpy.ops.object.mode_set(mode='OBJECT') - -def setup_physics(obj: bpy.types.Object, armature_obj: bpy.types.Object, rigid_bodies: list[PMXRigidBody], joints: list[PMXJoint]): - """Set up physics for PMX model""" - # Create rigid body collection if it doesn't exist - if 'RigidBodies' not in bpy.data.collections: - rigid_body_collection = bpy.data.collections.new('RigidBodies') - bpy.context.scene.collection.children.link(rigid_body_collection) - else: - rigid_body_collection = bpy.data.collections['RigidBodies'] - - # Create rigid bodies - for rb in rigid_bodies: - # Create mesh based on shape type - if rb.shape_type == 0: # Sphere - bpy.ops.mesh.primitive_uv_sphere_add(radius=rb.size[0]) - elif rb.shape_type == 1: # Box - bpy.ops.mesh.primitive_cube_add() - bpy.context.active_object.scale = rb.size - elif rb.shape_type == 2: # Capsule - bpy.ops.mesh.primitive_cylinder_add(radius=rb.size[0], depth=rb.size[1]) - - rb_obj = bpy.context.active_object - rb_obj.name = f"RB_{rb.name}" - rb_obj.location = rb.position - rb_obj.rotation_euler = rb.rotation - - # Set up rigid body physics - rb_obj.rigid_body.type = 'ACTIVE' if rb.mode == 0 else 'PASSIVE' - rb_obj.rigid_body.mass = rb.mass - rb_obj.rigid_body.linear_damping = rb.linear_damping - rb_obj.rigid_body.angular_damping = rb.angular_damping - rb_obj.rigid_body.restitution = rb.restitution - rb_obj.rigid_body.friction = rb.friction - - # Parent to bone if specified - if rb.bone_index >= 0: - rb_obj.parent = armature_obj - rb_obj.parent_type = 'BONE' - rb_obj.parent_bone = bones[rb.bone_index].name - - # Move to rigid body collection - rigid_body_collection.objects.link(rb_obj) - bpy.context.scene.collection.objects.unlink(rb_obj) - - # Create joints - for joint in joints: - empty = bpy.data.objects.new(f"Joint_{joint.name}", None) - empty.empty_display_type = 'ARROWS' - empty.location = joint.position - empty.rotation_euler = joint.rotation - bpy.context.scene.collection.objects.link(empty) - - # Set up constraint - constraint = empty.constraints.new('RIGID_BODY_JOINT') - constraint.target = rigid_bodies[joint.rigid_body_a] - constraint.child = rigid_bodies[joint.rigid_body_b] - constraint.use_limit_lin_x = True - constraint.use_limit_lin_y = True - constraint.use_limit_lin_z = True - constraint.use_limit_ang_x = True - constraint.use_limit_ang_y = True - constraint.use_limit_ang_z = True - - # Set limits - constraint.limit_lin_x_lower = joint.linear_limit_min[0] - constraint.limit_lin_x_upper = joint.linear_limit_max[0] - constraint.limit_lin_y_lower = joint.linear_limit_min[1] - constraint.limit_lin_y_upper = joint.linear_limit_max[1] - constraint.limit_lin_z_lower = joint.linear_limit_min[2] - constraint.limit_lin_z_upper = joint.linear_limit_max[2] - constraint.limit_ang_x_lower = joint.angular_limit_min[0] - constraint.limit_ang_x_upper = joint.angular_limit_max[0] - constraint.limit_ang_y_lower = joint.angular_limit_min[1] - constraint.limit_ang_y_upper = joint.angular_limit_max[1] - constraint.limit_ang_z_lower = joint.angular_limit_min[2] - constraint.limit_ang_z_upper = joint.angular_limit_max[2] - -def create_armature(model_name: str, bones: list[PMXBone]) -> bpy.types.Object: - # Handle CJK characters in model name - if isinstance(model_name, bytes): - try: - model_name = model_name.decode('gbk') # Try Chinese encoding first - except UnicodeDecodeError: - try: - model_name = model_name.decode('utf-8') - except UnicodeDecodeError: - try: - model_name = model_name.decode('shift-jis') - except UnicodeDecodeError: - model_name = model_name.decode('latin1') - - armature = bpy.data.armatures.new(f"{model_name}_Armature") - armature_obj = bpy.data.objects.new(f"{model_name}_Armature", armature) - bpy.context.collection.objects.link(armature_obj) - - bpy.context.view_layer.objects.active = armature_obj - bpy.ops.object.mode_set(mode='EDIT') - - # First pass: Create bones with proper names and types - edit_bones = [] - for i, bone_data in enumerate(bones): - bone_name = bone_data.name if bone_data.name else bone_data.english_name - if not bone_name: - bone_name = f"bone_{i}" - - edit_bone = armature.edit_bones.new(bone_name) - edit_bone.head = Vector(bone_data.position) - - # Handle different bone types based on flags and names - is_expression = bool(bone_data.flag & 0x0004) - is_rotation_influenced = bool(bone_data.flag & 0x0100) - is_ik = bool(bone_data.flag & 0x0020) - is_twist = "twist" in bone_name.lower() - - if is_twist: - # Twist bones need specific handling - parent_pos = bones[bone_data.parent_index].position if bone_data.parent_index >= 0 else None - if parent_pos: - direction = Vector(bone_data.position) - Vector(parent_pos) - if direction.length > 0.001: - edit_bone.tail = edit_bone.head + direction.normalized() * 0.1 - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.05, 0)) - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.05, 0)) - - elif is_expression: - edit_bone.tail = edit_bone.head + Vector((0, 0.02, 0)) - edit_bone.use_deform = False - - elif is_ik: - if bone_data.ik_links: - target_pos = bones[bone_data.ik_links[0][0]].position - direction = Vector(target_pos) - Vector(edit_bone.head) - if direction.length > 0.001: - edit_bone.tail = edit_bone.head + direction.normalized() * 0.1 - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.1, 0)) - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.1, 0)) - - elif is_rotation_influenced: - # Handle rotation influenced bones - if bone_data.inherit_parent_index >= 0: - target_pos = bones[bone_data.inherit_parent_index].position - direction = Vector(target_pos) - Vector(edit_bone.head) - if direction.length > 0.001: - edit_bone.tail = edit_bone.head + direction.normalized() * 0.08 - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.08, 0)) - else: - edit_bone.tail = edit_bone.head + Vector((0, 0.08, 0)) - - else: - # Standard bones - if bone_data.tail_position[0] is not None: - edit_bone.tail = Vector(bone_data.tail_position) - else: - child_positions = [bones[j].position for j in range(len(bones)) - if bones[j].parent_index == i] - if child_positions: - avg_child_pos = Vector((0, 0, 0)) - for pos in child_positions: - avg_child_pos += Vector(pos) - avg_child_pos /= len(child_positions) - edit_bone.tail = avg_child_pos - else: - bone_length = 0.1 if bone_data.layer == 0 else 0.05 - edit_bone.tail = edit_bone.head + Vector((0, bone_length, 0)) - - edit_bones.append(edit_bone) - - # Second pass: Set up hierarchy and orientations - for i, bone_data in enumerate(bones): - edit_bone = edit_bones[i] - - # Parent bones - if bone_data.parent_index >= 0: - parent_bone = edit_bones[bone_data.parent_index] - edit_bone.parent = parent_bone - - # Connect bones only if they should be connected - if (Vector(bone_data.position) - Vector(parent_bone.tail)).length < 0.01: - edit_bone.use_connect = True - - # Handle bone orientation - if bone_data.fixed_axis != [0.0, 0.0, 0.0]: - edit_bone.align_roll(Vector(bone_data.fixed_axis)) - elif bone_data.local_x != [0.0, 0.0, 0.0]: - x_axis = Vector(bone_data.local_x).normalized() - z_axis = Vector(bone_data.local_z).normalized() - y_axis = z_axis.cross(x_axis) - - # Create and apply orientation matrix - matrix_3x3 = Matrix((x_axis, y_axis, z_axis)).to_3x3() - matrix_4x4 = matrix_3x3.to_4x4() - edit_bone.matrix = matrix_4x4 - - bpy.ops.object.mode_set(mode='OBJECT') - return armature_obj - - -def assign_vertex_weights(obj: bpy.types.Object, vertices: list[PMXVertex], bones: list[PMXBone]): - # Pre-create vertex groups - vertex_groups = {} - for bone in bones: - vertex_groups[bone.name] = obj.vertex_groups.new(name=bone.name) - - # Batch assign weights - for vertex_index, vertex in enumerate(vertices): - for bone_idx, weight in zip(vertex.bone_indices, vertex.bone_weights): - if bone_idx != -1 and weight > 0: - vertex_groups[bones[bone_idx].name].add([vertex_index], weight, 'REPLACE') - -def assign_materials(obj: bpy.types.Object, materials: list[PMXMaterial], textures: list[str], base_path: str): - current_face_index = 0 - - for material in materials: - # Create or get material - mat_name = material.name or f"Material_{len(obj.data.materials)}" - if mat_name in bpy.data.materials: - mat = bpy.data.materials[mat_name] - else: - mat = bpy.data.materials.new(name=mat_name) - - # Set up material nodes - texture_path = None - if material.texture_index >= 0 and material.texture_index < len(textures): - texture_path = os.path.join(base_path, textures[material.texture_index]) - - create_material_nodes(mat, texture_path, material.diffuse, material.specular, - material.specular_strength) - - # Assign material to mesh - if mat.name not in obj.data.materials: - obj.data.materials.append(mat) - - # Assign faces to material - mat_index = obj.data.materials.find(mat.name) - for face in obj.data.polygons[current_face_index:current_face_index + material.surface_count]: - face.material_index = mat_index - - current_face_index += material.surface_count - -def import_pmx(filepath: str): - wm = bpy.context.window_manager - wm.progress_begin(0, 100) - - try: - with open(filepath, 'rb') as file: - # Read header (5%) - wm.progress_update(5) - header_data = read_pmx_header(file) - version, encoding, additional_uvs, vertex_index_size, texture_index_size, \ - material_index_size, bone_index_size, morph_index_size, rigid_body_index_size, \ - model_name, model_english_name, model_comment, model_english_comment = header_data - - # Set up index size formats (10%) - wm.progress_update(10) - vertex_struct, vertex_size = read_index_size(vertex_index_size, 'BHi') - bone_struct, bone_size = read_index_size(bone_index_size, 'bhi') - texture_struct, texture_size = read_index_size(texture_index_size, 'bhi') - - # Read vertices (25%) - vertex_count = struct.unpack(' None: """Configure logging for Avatar Toolkit""" @@ -18,6 +20,15 @@ def configure_logging(enabled: bool = False) -> None: formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) + + def error_with_traceback(msg, *args, **kwargs): + if kwargs.get('exc_info', False) or isinstance(msg, Exception): + full_msg = f"{msg}\n{traceback.format_exc()}" + _original_error(full_msg, *args, **{**kwargs, 'exc_info': False}) + else: + _original_error(msg, *args, **kwargs) + + logger.error = error_with_traceback def update_logging_state(self: Any, context: Context) -> None: """Update logging state based on user preference""" @@ -25,3 +36,10 @@ def update_logging_state(self: Any, context: Context) -> None: enabled = self.enable_logging save_preference("enable_logging", enabled) configure_logging(enabled) + +def highlight_problem_bones(self: Any, context: Context) -> None: + """Log when problem bones are highlighted""" + from .addon_preferences import save_preference + enabled = self.highlight_problem_bones + save_preference("highlight_problem_bones", enabled) + logger.debug(f"Problem bone highlighting {'enabled' if enabled else 'disabled'}") diff --git a/core/packer/rectangle_packer.py b/core/packer/rectangle_packer.py new file mode 100644 index 0000000..f4fe5ad --- /dev/null +++ b/core/packer/rectangle_packer.py @@ -0,0 +1,152 @@ +# thank you https://stackoverflow.com/a/71432759 +from __future__ import annotations + + +from typing import Optional +from bpy.types import Image, Material + + +# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016 Jake Gordon and contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +class Rectangle_Obj: + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + down: Rectangle_Obj = None + used: bool = False + right: Rectangle_Obj = None + + def __init__(self, x:int, y:int, w:int, h:int, down=None, used =False, right=None): + self.x = x + self.y = y + self.w = w + self.h = h + self.down = down + self.used = used + self.right = right + + def split(self, w, h) -> Rectangle_Obj: + self.used = True + self.down = Rectangle_Obj(x=self.x, y=self.y + h, w=self.w, h=self.h - h) + self.right = Rectangle_Obj(x=self.x + w, y=self.y, w=self.w - w, h=h) + return self + + def find(self, w, h) -> Optional[Rectangle_Obj]: + if self.used: + return self.right.find(w, h) or self.down.find(w, h) + elif (w <= self.w) and (h <= self.h): + return self + return None + +class MaterialImageList: + albedo: Image + normal: Image + emission: Image + ambient_occlusion: Image + height: Image + roughness: Image + fit: Rectangle_Obj + material: Material + + def __init__(self): + pass + + x: int = 0 + y: int = 0 + w: int = 0 + h: int = 0 + + + + + +class BinPacker(object): + root: Rectangle_Obj + bin: list[MaterialImageList] = [] + def __init__(self, structure: list[MaterialImageList]): + self.root = None + self.bin = structure + + def fit(self): + structure = self.bin + structure_len = len(self.bin) + w: int = 0 + h: int = 0 + if structure_len > 0: + w = structure[0].w + h = structure[0].h + self.root = Rectangle_Obj(x=0, y=0, w=w, h=h) + for img in structure: + w = img.w + h = img.h + node = self.root.find(w, h) + if node: + img.fit = node.split(w, h) + else: + img.fit = self.grow_node(w, h) + return structure + + def grow_node(self, w, h) -> Optional[Rectangle_Obj]: + can_grow_right = (h <= self.root.h) + can_grow_down = (w <= self.root.w) + + should_grow_right = can_grow_right and (self.root.h >= (self.root.w + w)) + should_grow_down = can_grow_down and (self.root.w >= (self.root.h + h)) + + if should_grow_right: + return self.grow_right(w, h) + elif should_grow_down: + return self.grow_down(w, h) + elif can_grow_right: + return self.grow_right(w, h) + elif can_grow_down: + return self.grow_down(w, h) + return None + + def grow_right(self, w, h) -> Optional[Rectangle_Obj]: + self.root = Rectangle_Obj( + used=True, + x=0, + y=0, + w=self.root.w + w, + h=self.root.h, + down=self.root, + right=Rectangle_Obj(x=self.root.w, y=0, w=w, h=self.root.h)) + node = self.root.find(w, h) + if node: + return node.split(w, h) + return None + + def grow_down(self, w, h) -> Optional[Rectangle_Obj]: + self.root = Rectangle_Obj( + used=True, + x=0, + y=0, + w=self.root.w, + h=self.root.h + h, + down=Rectangle_Obj(x=0, y=self.root.h, w=self.root.w, h=h), + right=self.root + ) + node = self.root.find(w, h) + if node: + return node.split(w, h) + return None \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index 2ab83e6..e7886d0 100644 --- a/core/properties.py +++ b/core/properties.py @@ -14,15 +14,28 @@ from .logging_setup import logger from .translations import t, get_languages_list, update_language from .addon_preferences import get_preference, save_preference from .updater import get_version_list -from .common import get_armature_list, get_active_armature, get_all_meshes +from .common import get_armature_list, get_active_armature, get_all_meshes, SceneMatClass from ..functions.visemes import VisemePreview from ..functions.eye_tracking import set_rotation +class ValidationMessageItem(PropertyGroup): + """Property group for validation message items""" + name: StringProperty(name="Message") + +class ZeroWeightBoneItem(PropertyGroup): + """Property group for zero weight bone list items""" + name: StringProperty(name="Bone Name") + selected: BoolProperty(name="Selected", default=True) + has_children: BoolProperty(name="Has Children", default=False) + is_deform: BoolProperty(name="Is Deform Bone", default=False) + + def update_validation_mode(self: PropertyGroup, context: Context) -> None: """Updates validation mode and saves preference""" logger.info(f"Updating validation mode to: {self.validation_mode}") save_preference("validation_mode", self.validation_mode) + def update_logging_state(self: PropertyGroup, context: Context) -> None: """Updates logging state and configures logging""" logger.info(f"Updating logging state to: {self.enable_logging}") @@ -30,13 +43,142 @@ def update_logging_state(self: PropertyGroup, context: Context) -> None: from .logging_setup import configure_logging configure_logging(self.enable_logging) + def update_shape_intensity(self: PropertyGroup, context: Context) -> None: """Updates shape key intensity and refreshes preview""" if self.viseme_preview_mode: VisemePreview.update_preview(context) +def highlight_problem_bones(self: PropertyGroup, context: Context) -> None: + """Updates problem bone highlighting state and saves preference""" + logger.info(f"Updating problem bone highlighting to: {self.highlight_problem_bones}") + save_preference("highlight_problem_bones", self.highlight_problem_bones) + +def get_mesh_objects(self, context): + meshes = [(obj.name, obj.name, "") for obj in bpy.data.objects if obj.type == 'MESH'] + if not meshes: + return [('NONE', t("Visemes.no_meshes"), '')] + return meshes + class AvatarToolkitSceneProperties(PropertyGroup): """Property group containing Avatar Toolkit scene-level settings and properties""" + + show_found_bones: BoolProperty( + name="Show Found Bones", + default=False + ) + + show_non_standard: BoolProperty( + name="Show Non-Standard Bones", + default=False + ) + + show_hierarchy: BoolProperty( + name="Show Hierarchy Issues", + default=False + ) + + material_search_filter: StringProperty( + name=t("TextureAtlas.search_materials"), + description=t("TextureAtlas.search_materials_desc"), + default="" + ) + + def get_texture_node_list(self: Material, context: Context) -> list[tuple]: + if self.use_nodes: + Object.Enum = [((i.image.name if i.image else i.name+"_image"), + (i.image.name if i.image else "node with no image..."), + (i.image.name if i.image else i.name), index+1) + for index, i in enumerate(self.node_tree.nodes) + if i.bl_idname == "ShaderNodeTexImage"] + if not len(Object.Enum): + Object.Enum = [(t("TextureAtlas.error.label"), + t("TextureAtlas.no_images_error.desc"), + t("TextureAtlas.error.label"), 0)] + else: + Object.Enum = [(t("TextureAtlas.error.label"), + t("TextureAtlas.no_nodes_error.desc"), + t("TextureAtlas.error.label"), 0)] + Object.Enum.append((t("TextureAtlas.none.label"), + t("TextureAtlas.none.label"), + t("TextureAtlas.none.label"), 0)) + return Object.Enum + + Material.texture_atlas_albedo = EnumProperty( + name=t("TextureAtlas.albedo"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.albedo").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_normal = EnumProperty( + name=t("TextureAtlas.normal"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.normal").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_emission = EnumProperty( + name=t("TextureAtlas.emission"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.emission").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_ambient_occlusion = EnumProperty( + name=t("TextureAtlas.ambient_occlusion"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.ambient_occlusion").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_height = EnumProperty( + name=t("TextureAtlas.height"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.height").lower()), + default=0, + items=get_texture_node_list + ) + + Material.texture_atlas_roughness = EnumProperty( + name=t("TextureAtlas.roughness"), + description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.roughness").lower()), + default=0, + items=get_texture_node_list + ) + + list_only_mode: BoolProperty( + name=t("Tools.list_only_mode"), + description=t("Tools.list_only_mode_desc"), + default=False + ) + + Material.include_in_atlas = BoolProperty( + name=t("TextureAtlas.include_in_atlas"), + description=t("TextureAtlas.include_in_atlas_desc"), + default=False + ) + + Material.material_expanded = BoolProperty( + name=t("TextureAtlas.material_expanded"), + description=t("TextureAtlas.material_expanded_desc"), + default=False + ) + + texture_atlas_Has_Mat_List_Shown: BoolProperty( + name=t("TextureAtlas.list_shown"), + description=t("TextureAtlas.list_shown_desc"), + default=False + ) + + texture_atlas_material_index: IntProperty( + default=-1, + get=lambda self: -1, + set=lambda self, context: None + ) + + materials: CollectionProperty( + type=SceneMatClass + ) avatar_toolkit_updater_version_list: EnumProperty( items=get_version_list, @@ -151,9 +293,10 @@ class AvatarToolkitSceneProperties(PropertyGroup): description=t("Visemes.mouth_ch_desc") ) - viseme_mesh: StringProperty( + viseme_mesh: EnumProperty( name=t("Visemes.mesh_select"), description=t("Visemes.mesh_select_desc"), + items=get_mesh_objects ) shape_intensity: FloatProperty( @@ -167,38 +310,37 @@ class AvatarToolkitSceneProperties(PropertyGroup): ) viseme_preview_selection: EnumProperty( - name=t("Visemes.preview_selection"), - description=t("Visemes.preview_selection_desc"), - items=[ - ('vrc.v_aa', 'AA', 'A as in "bat"'), - ('vrc.v_ch', 'CH', 'Ch as in "choose"'), - ('vrc.v_dd', 'DD', 'D as in "dog"'), - ('vrc.v_ih', 'IH', 'I as in "bit"'), - ('vrc.v_ff', 'FF', 'F as in "fox"'), - ('vrc.v_e', 'E', 'E as in "bet"'), - ('vrc.v_kk', 'KK', 'K as in "cat"'), - ('vrc.v_nn', 'NN', 'N as in "net"'), - ('vrc.v_oh', 'OH', 'O as in "hot"'), - ('vrc.v_ou', 'OU', 'O as in "go"'), - ('vrc.v_pp', 'PP', 'P as in "pat"'), - ('vrc.v_rr', 'RR', 'R as in "red"'), - ('vrc.v_sil', 'SIL', 'Silence'), - ('vrc.v_ss', 'SS', 'S as in "sit"'), - ('vrc.v_th', 'TH', 'Th as in "think"') - ], - update=lambda s, c: VisemePreview.update_preview(c) - -) + name=t("Visemes.preview_selection"), + description=t("Visemes.preview_selection_desc"), + items=[ + ('vrc.v_aa', 'AA', 'A as in "bat"'), + ('vrc.v_ch', 'CH', 'Ch as in "choose"'), + ('vrc.v_dd', 'DD', 'D as in "dog"'), + ('vrc.v_ih', 'IH', 'I as in "bit"'), + ('vrc.v_ff', 'FF', 'F as in "fox"'), + ('vrc.v_e', 'E', 'E as in "bet"'), + ('vrc.v_kk', 'KK', 'K as in "cat"'), + ('vrc.v_nn', 'NN', 'N as in "net"'), + ('vrc.v_oh', 'OH', 'O as in "hot"'), + ('vrc.v_ou', 'OU', 'O as in "go"'), + ('vrc.v_pp', 'PP', 'P as in "pat"'), + ('vrc.v_rr', 'RR', 'R as in "red"'), + ('vrc.v_sil', 'SIL', 'Silence'), + ('vrc.v_ss', 'SS', 'S as in "sit"'), + ('vrc.v_th', 'TH', 'Th as in "think"') + ], + update=lambda s, c: VisemePreview.update_preview(c) + ) eye_tracking_type: EnumProperty( - name=t("EyeTracking.type"), - description=t("EyeTracking.type_desc"), - items=[ - ('AV3', t("EyeTracking.type.av3"), t("EyeTracking.type.av3_desc")), - ('SDK2', t("EyeTracking.type.sdk2"), t("EyeTracking.type.sdk2_desc")) - ], - default='AV3' -) + name=t("EyeTracking.type"), + description=t("EyeTracking.type_desc"), + items=[ + ('AV3', t("EyeTracking.type.av3"), t("EyeTracking.type.av3_desc")), + ('SDK2', t("EyeTracking.type.sdk2"), t("EyeTracking.type.sdk2_desc")) + ], + default='AV3' + ) eye_mode: EnumProperty( name=t("EyeTracking.mode"), @@ -337,12 +479,6 @@ class AvatarToolkitSceneProperties(PropertyGroup): default="" ) - merge_all_bones: BoolProperty( - name=t('MergeArmature.merge_all'), - description=t('MergeArmature.merge_all_desc'), - default=True - ) - apply_transforms: BoolProperty( name=t('MergeArmature.apply_transforms'), description=t('MergeArmature.apply_transforms_desc'), @@ -361,33 +497,163 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=True ) + preserve_parent_bones: BoolProperty( + name=t("Tools.preserve_parent_bones"), + description=t("Tools.preserve_parent_bones_desc"), + default=True + ) + + target_bone_type: EnumProperty( + name=t("Tools.target_bone_type"), + description=t("Tools.target_bone_type_desc"), + items=[ + ('ALL', t("Tools.target_all_bones"), ""), + ('DEFORM', t("Tools.target_deform_bones"), ""), + ('NON_DEFORM', t("Tools.target_non_deform_bones"), "") + ], + default='ALL' + ) + + zero_weight_bones: CollectionProperty( + type=ZeroWeightBoneItem, + name="Zero Weight Bones", + description="List of bones with zero weights" + ) + + zero_weight_bones_index: IntProperty( + name="Zero Weight Bone Index", + default=0 + ) + + list_only_mode: BoolProperty( + name=t("Tools.list_only_mode"), + description=t("Tools.list_only_mode_desc"), + default=False + ) + cleanup_shape_keys: BoolProperty( name=t('MergeArmature.cleanup_shape_keys'), description=t('MergeArmature.cleanup_shape_keys_desc'), default=True ) + + merge_twist_bones: BoolProperty( + name=t("Tools.merge_twist_bones"), + description=t("Tools.merge_twist_bones_desc"), + default=True + ) + + highlight_problem_bones: BoolProperty( + name=t("Settings.highlight_problem_bones"), + description=t("Settings.highlight_problem_bones_desc"), + default=get_preference("highlight_problem_bones", True), + update=highlight_problem_bones + ) + + show_scale_issues: BoolProperty( + name="Show Scale Issues", + default=False + ) + + tpose_validation_result: BoolProperty( + name="T-Pose Validation Result", + default=True + ) + + tpose_validation_messages: CollectionProperty( + type=bpy.types.PropertyGroup, + name="T-Pose Validation Messages" + ) + + show_tpose_validation: BoolProperty( + name="Show T-Pose Validation Results", + default=False + ) + + standardize_fix_names: BoolProperty( + name=t("Tools.standardize_fix_names"), + description=t("Tools.standardize_fix_names_desc"), + default=True + ) + + standardize_fix_hierarchy: BoolProperty( + name=t("Tools.standardize_fix_hierarchy"), + description=t("Tools.standardize_fix_hierarchy_desc"), + default=True + ) + + standardize_fix_scale: BoolProperty( + name=t("Tools.standardize_fix_scale"), + description=t("Tools.standardize_fix_scale_desc"), + default=True + ) def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") + + # Clear any existing registrations to prevent conflicts + if hasattr(bpy.types.Scene, "avatar_toolkit"): + try: + del bpy.types.Scene.avatar_toolkit + except: + logger.warning("Failed to remove existing avatar_toolkit property") + + # Register classes try: + # Try to register all classes at once + bpy.utils.register_class(ZeroWeightBoneItem) + bpy.utils.register_class(ValidationMessageItem) bpy.utils.register_class(AvatarToolkitSceneProperties) - except ValueError: - # Class already registered, we can continue - pass + except ValueError as e: + logger.warning(f"Class registration issue: {e}") + # Try to unregister first in case they're already registered + try: + # Try to unregister in reverse order + try: + bpy.utils.unregister_class(AvatarToolkitSceneProperties) + except: + pass + try: + bpy.utils.unregister_class(ValidationMessageItem) + except: + pass + try: + bpy.utils.unregister_class(ZeroWeightBoneItem) + except: + pass + + # Then register again + bpy.utils.register_class(ZeroWeightBoneItem) + bpy.utils.register_class(ValidationMessageItem) + bpy.utils.register_class(AvatarToolkitSceneProperties) + except Exception as e: + logger.error(f"Failed to recover from registration error: {e}") + raise + + # Register the property bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties) logger.debug("Properties registered successfully") + def unregister() -> None: """Unregister the Avatar Toolkit property group""" logger.info("Unregistering Avatar Toolkit properties") - try: - del bpy.types.Scene.avatar_toolkit - except: - pass + + # Remove the property first + if hasattr(bpy.types.Scene, "avatar_toolkit"): + try: + del bpy.types.Scene.avatar_toolkit + logger.debug("Removed avatar_toolkit property") + except Exception as e: + logger.warning(f"Failed to remove avatar_toolkit property: {e}") + + # Then unregister the classes try: bpy.utils.unregister_class(AvatarToolkitSceneProperties) - except RuntimeError: - pass - logger.debug("Properties unregistered successfully") - + bpy.utils.unregister_class(ValidationMessageItem) + bpy.utils.unregister_class(ZeroWeightBoneItem) + logger.debug("Unregistered property classes") + except (RuntimeError, ValueError) as e: + logger.warning(f"Error during property class unregistration: {e}") + # Not fatal - continue diff --git a/core/resonite_utils.py b/core/resonite_utils.py index f6938d2..998e7ff 100644 --- a/core/resonite_utils.py +++ b/core/resonite_utils.py @@ -4,11 +4,12 @@ import bpy_extras from numpy import double from typing import Set, Dict -from .common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker +from .common import get_active_armature, simplify_bonename, ProgressTracker from bpy.types import Context, Operator from ..core.translations import t from ..core.dictionaries import bone_names, resonite_translations from ..core.logging_setup import logger +from ..core.armature_validation import validate_armature import re from .resonite_loader import resonite_animx, resonite_types @@ -50,7 +51,7 @@ class AvatarToolkit_OT_ConvertResonite(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) + is_valid, _, _ = validate_armature(armature) return is_valid def execute(self, context: Context) -> Set[str]: diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py new file mode 100644 index 0000000..ee807cf --- /dev/null +++ b/functions/atlas_materials.py @@ -0,0 +1,290 @@ +from pathlib import Path +import numpy +import bpy +import os +from typing import List, Optional +from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap +from ..core.common import SceneMatClass, MaterialListBool, ProgressTracker +from ..core.packer.rectangle_packer import MaterialImageList, BinPacker +from ..core.translations import t +from ..core.logging_setup import logger + +class MaterialImageList: + def __init__(self): + self.albedo: Image = None + self.normal: Image = None + self.emission: Image = None + self.ambient_occlusion: Image = None + self.height: Image = None + self.roughness: Image = None + self.material: Material = None + self.parent_mesh: Object = None + self.w: int = 0 + self.h: int = 0 + self.fit = None + +def scale_images_to_largest(images: List[Image]) -> tuple[int, int]: + x: int = 0 + y: int = 0 + + valid_images = [img for img in images if img and img.has_data] + + if not valid_images: + return 0, 0 + + for image in valid_images: + x = max(x, image.size[0]) + y = max(y, image.size[1]) + + for image in valid_images: + image.scale(width=int(x), height=int(y)) + + return x, y + +def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> List[Image]: + return [ + classitem.albedo, + classitem.normal, + classitem.emission, + classitem.ambient_occlusion, + classitem.height, + classitem.roughness + ] + +def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: + material_image_list: list[MaterialImageList] = [] + + with ProgressTracker(context, len(context.scene.objects), "Processing Materials") as progress: + for obj in context.scene.objects: + if obj.type == 'MESH': + for mat_slot in obj.material_slots: + # Only process materials that are selected for atlas + if mat_slot.material and mat_slot.material.include_in_atlas is True: + new_mat_image_item = MaterialImageList() + try: + new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo] + except Exception: + name = mat_slot.material.name + "_albedo_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) + try: + new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal] + except Exception: + name = mat_slot.material.name + "_normal_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32) + try: + new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission] + except Exception: + name = mat_slot.material.name + "_emission_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) + try: + new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion] + except Exception: + name = mat_slot.material.name + "_ambient_occlusion_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32) + try: + new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height] + except Exception: + name = mat_slot.material.name + "_height_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32) + try: + new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness] + except Exception: + name = mat_slot.material.name + "_roughness_replacement" + if name in bpy.data.images: + bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) + new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True) + new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32) + + new_mat_image_item.material = mat_slot.material + new_mat_image_item.parent_mesh = obj + material_image_list.append(new_mat_image_item) + + progress.step(f"Processed {obj.name}") + + return material_image_list + +def prep_images_in_scene(context: Context) -> List[MaterialImageList]: + preped_images = get_material_images_from_scene(context) + + with ProgressTracker(context, len(preped_images), "Preparing Images") as progress: + for MaterialImageClass in preped_images: + ImageList = MaterialImageList_to_Image_list(MaterialImageClass) + MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList) + progress.step(f"Scaled images for {MaterialImageClass.material.name}") + + return preped_images + +class AvatarToolKit_OT_AtlasMaterials(Operator): + bl_idname = "avatar_toolkit.atlas_materials" + bl_label = t("TextureAtlas.atlas_materials") + bl_description = t("TextureAtlas.atlas_materials_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + return context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown + + def execute(self, context: Context) -> set: + try: + selected_materials = [m for m in prep_images_in_scene(context) + if m.material and m.material.include_in_atlas] + + if not selected_materials: + self.report({'WARNING'}, t("TextureAtlas.no_materials_selected")) + return {'CANCELLED'} + + logger.info("Starting material atlas creation") + + packer = BinPacker(selected_materials) + mat_images = packer.fit() + + size = [ + max([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images]), + max([matimg.fit.h + matimg.albedo.size[1] for matimg in mat_images]) + ] + + atlased_mat = MaterialImageList() + + # UV Remapping + with ProgressTracker(context, len(bpy.data.objects), "Remapping UVs") as progress: + for mat in mat_images: + x, y = int(mat.fit.x), int(mat.fit.y) + w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1]) + + for obj in bpy.data.objects: + if obj.type == 'MESH': + mesh = obj.data + for layer in mesh.polygons: + if (obj.material_slots[layer.material_index].material and + obj.material_slots[layer.material_index].material == mat.material): + for loop_idx in layer.loop_indices: + for layer_loops in mesh.uv_layers: + uv_item = layer_loops.uv[loop_idx] + uv_item.vector.x = (uv_item.vector.x*(w/size[0]))+(x/size[0]) + uv_item.vector.y = (uv_item.vector.y*(h/size[1]))+(y/size[1]) + progress.step(f"Processed UVs for {obj.name}") + + # Create atlas textures + texture_types = ["albedo", "normal", "emission", "ambient_occlusion", "height", "roughness"] + + with ProgressTracker(context, len(texture_types), "Creating Atlas Textures") as progress: + for type_name in texture_types: + new_image_name = f"Atlas_{type_name}_{context.scene.name}_{Path(bpy.data.filepath).stem}" + logger.debug(f"Processing {type_name} atlas image") + + if new_image_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[new_image_name]) + + canvas = bpy.data.images.new(name=new_image_name, width=int(size[0]), + height=int(size[1]), alpha=True) + c_w = canvas.size[0] + canvas_pixels = list(canvas.pixels[:]) + + for mat in mat_images: + x, y = int(mat.fit.x), int(mat.fit.y) + w, h = int(mat.albedo.size[0]), int(mat.albedo.size[1]) + image_var = getattr(mat, type_name) + image_pixels = list(image_var.pixels[:]) + + for k in range(h): + for i in range(w): + for channel in range(4): + canvas_pixels[int((((k+y)*c_w)+(i+x))*4)+channel] = \ + image_pixels[int(((k*w)+i)*4)+channel] + + canvas.pixels[:] = canvas_pixels[:] + canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath), + new_image_name+".png")) + setattr(atlased_mat, type_name, canvas) + progress.step(f"Created {type_name} atlas") + + # Create material nodes + atlased_mat.material = bpy.data.materials.new( + name=f"Atlas_Final_{context.scene.name}_{Path(bpy.data.filepath).stem}") + atlased_mat.material.use_nodes = True + atlased_mat.material.node_tree.nodes.clear() + + principled_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled") + principled_node.location.x = 7.29706335067749 + principled_node.location.y = 298.918212890625 + + output_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeOutputMaterial") + output_node.location.x = 297.29705810546875 + output_node.location.y = 298.918212890625 + + albedo_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + albedo_node.location.x = -588.6177978515625 + albedo_node.location.y = 414.1948547363281 + albedo_node.image = atlased_mat.albedo + + emission_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + emission_node.location.x = -588.6177978515625 + emission_node.location.y = -173.9259033203125 + emission_node.image = atlased_mat.emission + + normal_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + normal_node.location.x = -941.4189453125 + normal_node.location.y = -20.8391780853271 + normal_node.image = atlased_mat.normal + + normal_map_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeNormalMap") + normal_map_node.location.x = -545.550537109375 + normal_map_node.location.y = -0.7543716430664062 + + roughness_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + roughness_node.location.x = -592.1703491210938 + roughness_node.location.y = 206.74075317382812 + roughness_node.image = atlased_mat.roughness + + ambient_occlusion_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + ambient_occlusion_node.location.x = -906.4371337890625 + ambient_occlusion_node.location.y = -389.9602355957031 + ambient_occlusion_node.image = atlased_mat.ambient_occlusion + + height_node = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") + height_node.location.x = -1222.383056640625 + height_node.location.y = -375.48406982421875 + height_node.image = atlased_mat.height + + atlased_mat.material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Metallic"], roughness_node.outputs["Alpha"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Roughness"], roughness_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Normal"], normal_map_node.outputs["Normal"]) + atlased_mat.material.node_tree.links.new(principled_node.inputs["Emission Color"], emission_node.outputs["Color"]) + atlased_mat.material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"]) + atlased_mat.material.node_tree.links.new(normal_map_node.inputs["Color"], normal_node.outputs["Color"]) + + # Update materials + with ProgressTracker(context, len(context.scene.objects), "Updating Materials") as progress: + for obj in context.scene.objects: + if obj.type == 'MESH': + mesh = obj.data + for i, mat_slot in enumerate(obj.material_slots): + if mat_slot.material and mat_slot.material.include_in_atlas: + mesh.materials[i] = atlased_mat.material + progress.step(f"Updated materials for {obj.name}") + + logger.info("Material atlas creation completed successfully") + self.report({'INFO'}, t("TextureAtlas.atlas_completed")) + return {"FINISHED"} + + except Exception as e: + logger.error(f"Error creating material atlas: {str(e)}", exc_info=True) + self.report({'ERROR'}, t("TextureAtlas.atlas_error")) + raise e diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index 4713629..cfe197a 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -2,7 +2,7 @@ import bpy import numpy as np from typing import List, Optional, Dict, Set, Tuple, Any from bpy.types import Context, Object, Operator, ArmatureModifier, EditBone, VertexGroup, Mesh, ShapeKey - +from ...core.dictionaries import bone_names from ...core.logging_setup import logger from ...core.translations import t from ...core.common import ( @@ -10,7 +10,8 @@ from ...core.common import ( fix_zero_length_bones, clear_unused_data_blocks, join_mesh_objects, - remove_unused_shapekeys + remove_unused_shapekeys, + simplify_bonename ) class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): @@ -52,7 +53,6 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): wm.progress_update(80) # Get settings from scene properties - merge_all_bones: bool = context.scene.avatar_toolkit.merge_all_bones join_meshes: bool = context.scene.avatar_toolkit.join_meshes # Merge armatures @@ -60,7 +60,6 @@ class AvatarToolkit_OT_MergeArmature(bpy.types.Operator): base_armature_name, merge_armature_name, mesh_only=False, - merge_all_bones=merge_all_bones, join_meshes=join_meshes, operator=self ) @@ -100,16 +99,12 @@ def validate_parents_and_transforms(merge_armature: Object, base_armature: Objec base_parent: Optional[Object] = base_armature.parent if merge_parent or base_parent: - if context.scene.merge_all_bones: - for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]: - if parent: - if not is_transform_clean(parent): - logger.error("Parent transforms are not clean") - return False - bpy.data.objects.remove(parent, do_unlink=True) - else: - logger.error("Parent relationships need fixing") - return False + for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]: + if parent: + if not is_transform_clean(parent): + logger.error("Parent transforms are not clean") + return False + bpy.data.objects.remove(parent, do_unlink=True) return True def is_transform_clean(obj: Object) -> bool: @@ -135,7 +130,6 @@ def merge_armatures( base_armature_name: str, merge_armature_name: str, mesh_only: bool, - merge_all_bones: bool = False, join_meshes: bool = False, operator: Optional[Operator] = None ) -> None: @@ -152,6 +146,9 @@ def merge_armatures( operator.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) return + # Store meshes that need to be reparented + meshes_to_reparent = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == merge_armature] + # Check transforms early if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance): if not bpy.context.scene.avatar_toolkit.apply_transforms: @@ -174,25 +171,49 @@ def merge_armatures( # Store original parent relationships original_parents: Dict[str, Optional[str]] = {} - for bone in merge_armature.data.bones: + merge_armature_data: bpy.types.Armature = merge_armature.data + for bone in merge_armature_data.bones: original_parents[bone.name] = bone.parent.name if bone.parent else None + #create reverse lookup + reverse_bone_lookup = {} + for preferred_name, name_list in bone_names.items(): + for name in name_list: + reverse_bone_lookup[name] = preferred_name + # Get base bone names base_bone_names: Set[str] = {bone.name for bone in base_armature.data.bones} + base_armature_standards: Dict[str,Optional[str]] = {} + for bone in base_bone_names: + if simplify_bonename(bone) in reverse_bone_lookup: + base_armature_standards[reverse_bone_lookup[simplify_bonename(bone)]] = bone + # Switch to edit mode on merge armature and rename bones bpy.context.view_layer.objects.active = merge_armature bpy.ops.object.mode_set(mode='EDIT') - # Handle bone renaming based on merge_all_bones setting - for bone in merge_armature.data.edit_bones: - if not merge_all_bones: - # Only rename bones that don't exist in base armature - if bone.name not in base_bone_names: - bone.name += '.merge' + # Handle bone renaming/removing to target armature. + bone_names_source: list[str] = [bone.name for bone in merge_armature_data.edit_bones] + for bone in bone_names_source: + bone_name = bone + if bone_name not in base_bone_names: #not auto mergable to original + + if simplify_bonename(bone_name) in reverse_bone_lookup: #if is a standard bone through standard translation. + if reverse_bone_lookup[simplify_bonename(bone_name)] in base_armature_standards: #if this bone equals for example, "hips", does a bone that should be "hips" exist on our target armature? + #if so, rename this bone to that one + merge_armature_data.edit_bones[bone_name].name = base_armature_standards[reverse_bone_lookup[simplify_bonename(bone_name)]] + bone_name = merge_armature_data.edit_bones[bone_name].name + #adjust original parents list to point to the new name. + for child_bone in merge_armature_data.edit_bones[bone_name]: + original_parents[child_bone.name] = bone_name + #then remove so it doesn't clash when merged. + merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name]) + continue + + #if it really doesn't have a counter part, just don't bother. else: - # Rename all bones from merge armature - bone.name += '.merge' + merge_armature_data.edit_bones.remove(merge_armature_data.edit_bones[bone_name]) # Return to object mode bpy.ops.object.mode_set(mode='OBJECT') @@ -204,23 +225,28 @@ def merge_armatures( bpy.context.view_layer.objects.active = base_armature bpy.ops.object.join() + # Explicitly set active object after join + bpy.context.view_layer.objects.active = base_armature + base_armature_data: bpy.types.Armature = base_armature.data + # Restore parent relationships bpy.ops.object.mode_set(mode='EDIT') - for bone in base_armature.data.edit_bones: - base_name: str = bone.name.replace('.merge', '') - if base_name in original_parents: - parent_name: Optional[str] = original_parents[base_name] + for bone in base_armature_data.edit_bones: + if bone.name in original_parents: + parent_name: Optional[str] = original_parents[bone.name] if parent_name: - parent_bone: Optional[EditBone] = base_armature.data.edit_bones.get(parent_name) + parent_bone: Optional[EditBone] = base_armature_data.edit_bones.get(parent_name) if parent_bone: bone.parent = parent_bone bpy.ops.object.mode_set(mode='OBJECT') - # Update mesh parenting - for obj in bpy.data.objects: - if obj.type == 'MESH' and obj.parent == merge_armature: - obj.parent = base_armature + for mesh_obj in meshes_to_reparent: + if mesh_obj and mesh_obj.name in bpy.data.objects: + mesh_obj.parent = base_armature + for mod in mesh_obj.modifiers: + if mod.type == 'ARMATURE': + mod.object = base_armature # Process vertex groups if not mesh_only if not mesh_only: @@ -241,6 +267,8 @@ def merge_armatures( joined_mesh: Optional[Object] = join_mesh_objects(bpy.context, meshes_to_join) if joined_mesh: logger.info(f"Joined meshes into {joined_mesh.name}") + # Ensure the joined mesh is properly parented + joined_mesh.parent = base_armature # Clean up shape keys if enabled if bpy.context.scene.avatar_toolkit.cleanup_shape_keys: @@ -250,11 +278,6 @@ def merge_armatures( # Remove any remaining .merge bones bpy.context.view_layer.objects.active = base_armature - bpy.ops.object.mode_set(mode='EDIT') - edit_bones: List[EditBone] = base_armature.data.edit_bones - bones_to_remove: List[EditBone] = [bone for bone in edit_bones if bone.name.endswith('.merge')] - for bone in bones_to_remove: - edit_bones.remove(bone) bpy.ops.object.mode_set(mode='OBJECT') # Final cleanup @@ -298,8 +321,7 @@ def adjust_merge_armature_transforms( def detect_bones_to_merge( base_edit_bones: bpy.types.ArmatureEditBones, merge_edit_bones: bpy.types.ArmatureEditBones, - tolerance: float, - merge_all_bones: bool + tolerance: float ) -> List[str]: """Detect corresponding bones between base and merge armatures using smart detection and position tolerance""" bones_to_merge: List[str] = [] @@ -314,7 +336,7 @@ def detect_bones_to_merge( merge_bone_position: np.ndarray = np.array(merge_bone.head) found_match: bool = False - if merge_all_bones and merge_bone.name in base_bones_positions: + if merge_bone.name in base_bones_positions: # If merging same bones by name bones_to_merge.append(merge_bone.name) found_match = True diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py index 613e5b9..d660e76 100644 --- a/functions/custom_tools/mesh_attachment.py +++ b/functions/custom_tools/mesh_attachment.py @@ -7,12 +7,12 @@ from ...core.logging_setup import logger from ...core.translations import t from ...core.common import ( get_active_armature, - validate_armature, get_all_meshes, ProgressTracker, calculate_bone_orientation, add_armature_modifier ) +from ...core.armature_validation import validate_armature class AvatarToolkit_OT_AttachMesh(Operator): """Operator to attach a mesh to an armature bone with automatic weight setup""" @@ -27,8 +27,8 @@ class AvatarToolkit_OT_AttachMesh(Operator): armature: Optional[Object] = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid + valid, _, _ = validate_armature(armature) + return valid def execute(self, context: Context) -> Set[str]: try: diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py index 6219ac7..e720286 100644 --- a/functions/eye_tracking.py +++ b/functions/eye_tracking.py @@ -18,11 +18,11 @@ from ..core.common import ( get_active_armature, get_all_meshes, get_armature_list, - validate_armature, validate_mesh_for_pose, cache_vertex_positions, apply_vertex_positions ) +from ..core.armature_validation import validate_armature VALID_EYE_NAMES: Dict[str, List[str]] = { 'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'], @@ -406,6 +406,14 @@ def set_rotation(self, context): StartTestingButton.execute(StartTestingButton, context) return None + # Check if rotation data is available + if not eye_left_rot or len(eye_left_rot) < 3 or not eye_right_rot or len(eye_right_rot) < 3: + # Initialize rotation data if missing + eye_left.rotation_mode = 'XYZ' + eye_left_rot = list(eye_left.rotation_euler) + eye_right.rotation_mode = 'XYZ' + eye_right_rot = list(eye_right.rotation_euler) + eye_left.rotation_mode = 'XYZ' eye_right.rotation_mode = 'XYZ' @@ -898,9 +906,24 @@ class ResetEyeTrackingButton(Operator): def execute(self, context): global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot - eye_left = eye_right = eye_left_data = eye_right_data = None - eye_left_rot = eye_right_rot = [] + context.scene.avatar_toolkit.eye_mode = 'CREATION' + context.scene.avatar_toolkit.eye_rotation_x = 0 + context.scene.avatar_toolkit.eye_rotation_y = 0 + + eye_left = None + eye_right = None + eye_left_data = None + eye_right_data = None + eye_left_rot = [] + eye_right_rot = [] + + mesh_name = context.scene.avatar_toolkit.mesh_name_eye + mesh = bpy.data.objects.get(mesh_name) + if mesh and mesh.data.shape_keys: + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + return {'FINISHED'} def validate_weights(mesh_obj: Object, vertex_group: str) -> bool: diff --git a/functions/mmd_tools.py b/functions/mmd_tools.py deleted file mode 100644 index 884509a..0000000 --- a/functions/mmd_tools.py +++ /dev/null @@ -1,792 +0,0 @@ -import bpy -from mathutils import Vector -from typing import Dict, List, Tuple, Set, Optional -from bpy.types import Object, Armature, EditBone, Bone, Operator, Context -from ..core.logging_setup import logger -from ..core.common import ( - ProgressTracker, - get_active_armature, - validate_armature, - get_vertex_weights, - transfer_vertex_weights, - get_all_meshes -) -from ..core.translations import t -from ..core.dictionaries import bone_names, dont_delete_these_main_bones - -class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): - """MMD Bone standardization system""" - bl_idname = "avatar_toolkit.standardize_mmd" - bl_label = t("MMD.standardize") - bl_options = {'REGISTER', 'UNDO'} - - def __init__(self): - self.bone_mapping: Dict[str, str] = {} - self.processed_bones: Set[str] = set() - - def execute(self, context: Context) -> Set[str]: - self.armature = get_active_armature(context) - - if not self.armature: - self.report({'ERROR'}, t("MMD.no_armature")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, 5, "MMD Standardization") as progress: - # Step 1: Process bone names - self.process_bone_names(context) - progress.step("Processed bone names") - - # Step 2: Fix bone structure - self.fix_bone_structure(context) - progress.step("Fixed bone structure") - - # Step 3: Process weights - self.process_weights(context) - progress.step("Processed weights") - - # Step 4: Clean up - self.cleanup_armature(context) - progress.step("Cleaned up armature") - - # Step 5: Final validation - self.validate_results(context) - progress.step("Validated results") - - self.report({'INFO'}, t("MMD.standardization_complete")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"MMD Standardization failed: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def process_bone_names(self, context: Context) -> None: - """Process and standardize bone names""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = self.armature.data.edit_bones - - # First pass - handle IK bones - ik_bones = [bone for bone in edit_bones if 'IK' in bone.name or 'IK' in bone.name] - for bone in ik_bones: - new_name = f"ik_{self.standardize_bone_name(bone.name.replace('IK', '').replace('IK', ''))}" - self.bone_mapping[bone.name] = new_name - bone.name = new_name - - # Second pass - standard bones - for bone in edit_bones: - if bone not in ik_bones: - new_name = self.standardize_bone_name(bone.name) - if new_name != bone.name: - self.bone_mapping[bone.name] = new_name - bone.name = new_name - - def translate_japanese_bone_name(self, name: str) -> str: - """Translate Japanese bone names to English standardized names""" - name_lower = name.lower() - - for bone_category, variations in bone_names.items(): - for variation in variations: - if variation in name_lower: - return bone_category - - return name - - def standardize_bone_name(self, name: str) -> str: - """Standardize individual bone names""" - result = self.translate_japanese_bone_name(name) - - prefixes = ['ValveBiped_', 'Bip01_', 'MMD_', 'Armature|'] - for prefix in prefixes: - if result.lower().startswith(prefix.lower()): - result = result[len(prefix):] - - if result.endswith('_L') or result.endswith('.L'): - result = f"{result[:-2]}.L" - elif result.endswith('_R') or result.endswith('.R'): - result = f"{result[:-2]}.R" - - return result - return result - - def fix_bone_structure(self, context: Context) -> None: - """Fix bone hierarchy and orientations""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = self.armature.data.edit_bones - - self.process_spine_chain(context) - self.fix_bone_orientations(context) - self.connect_bones(context) - - def process_weights(self, context: Context) -> None: - """Process and clean up vertex weights""" - for mesh in self.get_associated_meshes(context): - # Transfer weights based on bone mapping - for old_name, new_name in self.bone_mapping.items(): - if old_name != new_name: - transfer_vertex_weights(mesh, old_name, new_name) - - # Clean up zero weights - self.cleanup_vertex_groups(mesh, context) - - def cleanup_armature(self, context: Context) -> None: - """Perform final cleanup operations""" - self.remove_unused_bones(context) - self.cleanup_constraints(context) - self.fix_zero_length_bones(context) - - def get_associated_meshes(self, context: Context) -> List[Object]: - """Get all mesh objects associated with the armature""" - return [obj for obj in bpy.data.objects - if obj.type == 'MESH' - and obj.parent == self.armature] - - def process_spine_chain(self, context: Context) -> None: - """Process and fix spine bone chain hierarchy""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = self.armature.data.edit_bones - spine_bones = { - 'hips': None, - 'spine': None, - 'chest': None, - 'upper_chest': None, - 'neck': None, - 'head': None - } - - # Find spine bones using bone_names dictionary - for bone in edit_bones: - for spine_part, _ in spine_bones.items(): - if any(alt_name in bone.name.lower() for alt_name in bone_names[spine_part]): - spine_bones[spine_part] = bone - break - - # Set up spine hierarchy - hierarchy = [ - ('hips', 'spine'), - ('spine', 'chest'), - ('chest', 'neck'), - ('neck', 'head') - ] - - for parent_name, child_name in hierarchy: - parent = spine_bones.get(parent_name) - child = spine_bones.get(child_name) - if parent and child: - child.parent = parent - child.use_connect = True - - def fix_bone_orientations(self, context: Context) -> None: - """Fix bone orientations for standard pose compatibility""" - edit_bones = self.armature.data.edit_bones - - # Define standardized roll values for key bones - roll_values = { - 'upper_arm.L': -0.1, - 'upper_arm.R': 0.1, - 'forearm.L': -0.1, - 'forearm.R': 0.1, - 'thigh.L': 0.0, - 'thigh.R': 0.0, - 'shin.L': 0.0, - 'shin.R': 0.0, - 'foot.L': 0.0, - 'foot.R': 0.0, - 'spine': 0.0, - 'chest': 0.0, - 'neck': 0.0 - } - - # Apply roll corrections - for bone in edit_bones: - if bone.name.lower() in roll_values: - bone.roll = roll_values[bone.name.lower()] - - # Process arm chains - arm_pairs = [ - ('upper_arm', 'forearm'), - ('forearm', 'hand') - ] - - for side in ['.L', '.R']: - for parent, child in arm_pairs: - parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None) - child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None) - - if parent_bone and child_bone: - child_bone.use_connect = True - child_bone.use_inherit_rotation = True - - # Process leg chains - leg_pairs = [ - ('thigh', 'shin'), - ('shin', 'foot') - ] - - for side in ['.L', '.R']: - for parent, child in leg_pairs: - parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None) - child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None) - - if parent_bone and child_bone: - child_bone.use_connect = True - child_bone.use_inherit_rotation = True - - # Align twist bones if present - twist_bones = [b for b in edit_bones if 'twist' in b.name.lower()] - for twist_bone in twist_bones: - if twist_bone.parent: - twist_bone.roll = twist_bone.parent.roll - - def remove_unused_bones(self, context: Context) -> None: - """Remove unused and unnecessary bones from the armature""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = self.armature.data.edit_bones - - # Get list of bones that have vertex weights - used_bones = set() - for mesh in self.get_associated_meshes(context): - for group in mesh.vertex_groups: - used_bones.add(group.name) - - # Get list of essential bones to always keep - essential_bones = { - 'hips', 'spine', 'chest', 'upper_chest', 'neck', 'head', - 'left_leg', 'right_leg', 'left_knee', 'right_knee', - 'left_ankle', 'right_ankle', 'left_toe', 'right_toe' - } - - # Add any additional bones you want to preserve - essential_bones.update(dont_delete_these_main_bones) - - # Remove unused bones - for bone in edit_bones: - # Skip if bone is essential - if bone.name.lower() in essential_bones: - continue - - # Skip if bone has weights - if bone.name in used_bones: - continue - - # Remove the bone - edit_bones.remove(bone) - - - def connect_bones(self, context: Context) -> None: - """Connect bones that should be connected in the hierarchy""" - edit_bones = self.armature.data.edit_bones - - connect_chains = [ - ['hips', 'spine', 'chest', 'neck', 'head'], - ['shoulder.L', 'upper_arm.L', 'forearm.L', 'hand.L'], - ['shoulder.R', 'upper_arm.R', 'forearm.R', 'hand.R'], - ['thigh.L', 'shin.L', 'foot.L', 'toe.L'], - ['thigh.R', 'shin.R', 'foot.R', 'toe.R'] - ] - - for chain in connect_chains: - prev_bone = None - for bone_name in chain: - bone = next((b for b in edit_bones if b.name.lower().endswith(bone_name.lower())), None) - if bone and prev_bone: - bone.parent = prev_bone - bone.use_connect = True - prev_bone = bone - - def cleanup_vertex_groups(self, mesh_obj: Object, context: Context) -> None: - """Clean up vertex groups by removing zero weights and merging similar groups""" - threshold = context.scene.avatar_toolkit.merge_weights_threshold - - vertex_groups = mesh_obj.vertex_groups - - groups_to_remove = set() - - for group in vertex_groups: - weights = get_vertex_weights(mesh_obj, group.name) - - if not any(weight > threshold for weight in weights.values()): - groups_to_remove.add(group.name) - - for group_name in groups_to_remove: - group = vertex_groups.get(group_name) - if group: - vertex_groups.remove(group) - - def validate_results(self, context: Context) -> None: - """Validate the results of standardization""" - valid, messages = validate_armature(self.armature) - if not valid: - raise ValueError("\n".join(messages)) - - def cleanup_constraints(self, context: Context) -> None: - """Remove all constraints from the armature.""" - bpy.ops.object.mode_set(mode='POSE') - - for pose_bone in self.armature.pose.bones: - constraints_to_remove = [constraint for constraint in pose_bone.constraints] - for constraint in constraints_to_remove: - pose_bone.constraints.remove(constraint) - - def fix_zero_length_bones(self, context: Context) -> None: - """Fix zero-length bones by setting minimal length""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = self.armature.data.edit_bones - - min_length = 0.01 # Minimum bone length in Blender units - - for bone in edit_bones: - bone_length = (bone.tail - bone.head).length - - if bone_length < min_length: - if bone.parent: - direction = bone.parent.tail - bone.parent.head - direction.normalize() - else: - direction = Vector((0, 0, 1)) - - bone.tail = bone.head + (direction * min_length) - - -class ReparentMeshesOperator(bpy.types.Operator): - bl_idname = "avatar_toolkit.reparent_meshes" - bl_label = t("MMD.reparent_meshes") - bl_description = t("MMD.reparent_meshes_desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - return armature is not None and get_all_meshes(context) - - def execute(self, context): - armature = get_active_armature(context) - if not armature: - self.report({'ERROR'}, t("MMD.no_armature")) - return {'CANCELLED'} - - meshes = get_all_meshes(context) - if not meshes: - self.report({'ERROR'}, t("MMD.no_meshes")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, len(meshes) + 1, "Reparenting Meshes") as progress: - # Get or create main collection - main_collection = self._get_main_collection(context) - progress.step("Setting up collections") - - # Process each mesh - for mesh in meshes: - progress.step(f"Processing {mesh.name}") - self._process_mesh(mesh, armature, main_collection) - - self.report({'INFO'}, t("MMD.reparenting_complete")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error reparenting meshes: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def _get_main_collection(self, context) -> bpy.types.Collection: - """Get or create the main collection for the armature""" - if hasattr(context.scene, 'collection'): - return context.scene.collection - return context.scene.collection - - def _process_mesh(self, mesh: bpy.types.Object, - armature: bpy.types.Object, - main_collection: bpy.types.Collection) -> None: - """Process individual mesh parenting and collection management""" - # Unlink from other collections - for col in mesh.users_collection: - if col != main_collection: - col.objects.unlink(mesh) - - # Ensure mesh is in main collection - if mesh.name not in main_collection.objects: - main_collection.objects.link(mesh) - - # Set parent to armature - mesh.parent = armature - if not mesh.parent_type == 'ARMATURE': - mesh.parent_type = 'ARMATURE' - -class AVATAR_TOOLKIT_OT_ConvertMmdMorphs(Operator): - """Convert MMD morph data to shape keys""" - bl_idname = "avatar_toolkit.convert_mmd_morphs" - bl_label = t("MMD.convert_morphs") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - return armature is not None and get_all_meshes(context) - - def execute(self, context): - armature = get_active_armature(context) - if not armature: - self.report({'ERROR'}, t("MMD.no_armature")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, 3, "Converting MMD Morphs") as progress: - # Convert bone morphs to shape keys - if hasattr(armature, 'mmd_root') and armature.mmd_root.bone_morphs: - self.process_bone_morphs(context, armature, progress) - - progress.step("Processed bone morphs") - - # Clean up unused data - self.cleanup_unused_data(context) - progress.step("Cleaned up data") - - # Validate results - self.validate_results(context) - progress.step("Validated results") - - self.report({'INFO'}, t("MMD.conversion_complete")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error converting MMD morphs: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def process_bone_morphs(self, context, armature, progress): - """Process bone morphs into shape keys""" - for morph in armature.mmd_root.bone_morphs: - for mesh in get_all_meshes(context): - # Create armature modifier - mod = mesh.modifiers.new(morph.name, 'ARMATURE') - mod.object = armature - - # Apply as shape key - with context.temp_override(object=mesh): - bpy.ops.object.modifier_apply(modifier=mod.name) - -class AVATAR_TOOLKIT_OT_CleanupMmdModel(Operator): - """Clean up MMD model by removing unused data and fixing display settings""" - bl_idname = "avatar_toolkit.cleanup_mmd" - bl_label = t("MMD.cleanup") - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - armature = get_active_armature(context) - if not armature: - self.report({'ERROR'}, t("MMD.no_armature")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, 4, "Cleaning MMD Model") as progress: - # Remove rigid bodies and joints - self.remove_physics_objects(armature) - progress.step("Removed physics objects") - - # Clean up collections and hierarchy - self.cleanup_hierarchy(context, armature) - progress.step("Cleaned hierarchy") - - # Fix viewport settings - self.fix_viewport_settings(context) - progress.step("Fixed viewport") - - # Final cleanup - clear_unused_data_blocks() - progress.step("Cleared unused data") - - self.report({'INFO'}, t("MMD.cleanup_complete")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error cleaning MMD model: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def remove_physics_objects(self, armature): - """Remove physics-related objects""" - to_delete = [] - for child in armature.children: - if any(x in child.name.lower() for x in ['rigidbodies', 'joints', 'physics']): - to_delete.append(child) - - for obj in to_delete: - bpy.data.objects.remove(obj, do_unlink=True) - - def cleanup_hierarchy(self, context, armature): - """Clean up object hierarchy and collections""" - meshes = get_all_meshes(context) - for mesh in meshes: - # Ensure proper parenting - mesh.parent = armature - mesh.parent_type = 'ARMATURE' - - # Clean up collections - for col in mesh.users_collection: - if col != context.scene.collection: - col.objects.unlink(mesh) - - if mesh.name not in context.scene.collection.objects: - context.scene.collection.objects.link(mesh) - - def fix_viewport_settings(self, context): - """Fix viewport display settings""" - # Set armature display - armature = get_active_armature(context) - armature.data.display_type = 'OCTAHEDRAL' - armature.show_in_front = True - - # Set viewport shading - for area in context.screen.areas: - if area.type == 'VIEW_3D': - space = area.spaces[0] - space.shading.type = 'MATERIAL' - space.clip_start = 0.01 - space.clip_end = 300 - -class AVATAR_TOOLKIT_OT_FixMeshes(Operator): - """Clean up and optimize mesh materials, shading, and shape keys""" - bl_idname = "avatar_toolkit.fix_meshes" - bl_label = t("Optimization.fix_meshes") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - return armature is not None and get_all_meshes(context) - - def execute(self, context): - try: - meshes = get_all_meshes(context) - if not meshes: - self.report({'ERROR'}, t("Optimization.no_meshes")) - return {'CANCELLED'} - - with ProgressTracker(context, len(meshes), "Fixing Meshes") as progress: - for mesh in meshes: - self.process_mesh(context, mesh) - progress.step(f"Processed {mesh.name}") - - self.report({'INFO'}, t("Optimization.meshes_fixed")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error fixing meshes: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def process_mesh(self, context: Context, mesh: Object) -> None: - """Process and fix individual mesh""" - # Unlock transforms - for i in range(3): - mesh.lock_location[i] = False - mesh.lock_rotation[i] = False - mesh.lock_scale[i] = False - - # Process shape keys - if mesh.data.shape_keys: - self.fix_shape_keys(mesh) - - # Process materials - self.fix_materials(context, mesh) - - def fix_shape_keys(self, mesh: Object) -> None: - """Fix and clean up shape keys""" - if not mesh.data.shape_keys: - return - - shape_keys = mesh.data.shape_keys.key_blocks - - # Rename basis - if shape_keys[0].name != "Basis": - shape_keys[0].name = "Basis" - - # Clean up names - for key in shape_keys: - # Remove common prefixes/suffixes - clean_name = key.name - for prefix in ['Face.M F00 000 Fcl ', 'Face.M F00 000 00 Fcl ']: - clean_name = clean_name.replace(prefix, '') - - # Replace underscores with spaces - clean_name = clean_name.replace('_', ' ') - key.name = clean_name - - # Sort shape keys by category - categories = ['MTH', 'EYE', 'BRW', 'ALL'] - - # Create sorted list of shape key names - ordered_names = [] - - # Add categorized keys first - for category in categories: - category_keys = [key.name for key in shape_keys if key.name.startswith(category)] - ordered_names.extend(sorted(category_keys)) - - # Add remaining keys - remaining = [key.name for key in shape_keys if not any(key.name.startswith(c) for c in categories)] - ordered_names.extend(sorted(remaining)) - - # Reorder using context override - with bpy.context.temp_override(active_object=mesh, selected_objects=[mesh]): - for idx, name in enumerate(ordered_names): - mesh.active_shape_key_index = shape_keys.find(name) - while mesh.active_shape_key_index > idx: - bpy.ops.object.shape_key_move(type='UP') - - - def fix_materials(self, context: Context, mesh: Object) -> None: - """Fix and optimize materials""" - for slot in mesh.material_slots: - if not slot.material: - continue - - material = slot.material - - # Set up basic material properties - material.use_backface_culling = True - material.blend_method = 'HASHED' - material.shadow_method = 'HASHED' - - # Clean up material name - material.name = self.clean_material_name(material.name) - - # Consolidate similar materials - for other_slot in mesh.material_slots: - if other_slot.material and other_slot.material != material: - if materials_match(material, other_slot.material): - other_slot.material = material - - def clean_material_name(self, name: str) -> str: - """Clean up material name""" - # Remove common prefixes/suffixes - prefixes = ['material', 'mat', 'mtl', 'material.'] - for prefix in prefixes: - if name.lower().startswith(prefix): - name = name[len(prefix):] - - # Remove numbers at end - while name and name[-1].isdigit(): - name = name[:-1] - - return name.strip() - -class AVATAR_TOOLKIT_OT_ValidateMeshes(Operator): - """Validate meshes and UV maps for common issues""" - bl_idname = "avatar_toolkit.validate_meshes" - bl_label = t("Validation.check_meshes") - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context): - armature = get_active_armature(context) - if not armature: - self.report({'ERROR'}, t("Validation.no_armature")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, 3, "Validating Meshes") as progress: - # Check bone hierarchy - hierarchy_issues = self.validate_bone_hierarchy(armature) - progress.step("Checked bone hierarchy") - - # Check UV coordinates - uv_issues = self.validate_uv_maps(context) - progress.step("Checked UV maps") - - # Generate report - self.generate_validation_report(context, hierarchy_issues, uv_issues) - progress.step("Generated report") - - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error validating meshes: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def validate_bone_hierarchy(self, armature: Object) -> List[str]: - """Validate bone hierarchy against standard structure""" - issues = [] - - # Define expected hierarchy - hierarchy = [ - ['hips', 'spine', 'chest', 'neck', 'head'], - ['hips', 'left_leg', 'left_knee', 'left_ankle'], - ['hips', 'right_leg', 'right_knee', 'right_ankle'], - ['chest', 'left_shoulder', 'left_arm', 'left_elbow', 'left_wrist'], - ['chest', 'right_shoulder', 'right_arm', 'right_elbow', 'right_wrist'] - ] - - for chain in hierarchy: - previous = None - for bone_name in chain: - # Check if bone exists - bone = None - for alt_name in bone_names[bone_name]: - if alt_name in armature.data.bones: - bone = armature.data.bones[alt_name] - break - - if not bone: - issues.append(t("Validation.missing_bone", bone=bone_name)) - continue - - # Check parent relationship - if previous: - if not bone.parent: - issues.append(t("Validation.no_parent", bone=bone.name)) - elif bone.parent.name != previous.name: - issues.append(t("Validation.wrong_parent", - bone=bone.name, - expected=previous.name, - actual=bone.parent.name)) - previous = bone - - return issues - - def validate_uv_maps(self, context: Context) -> Dict[str, int]: - """Check UV maps for issues""" - issues = {'nan_coords': 0, 'missing_uvs': 0} - - for mesh in get_all_meshes(context): - if not mesh.data.uv_layers: - issues['missing_uvs'] += 1 - continue - - for uv_layer in mesh.data.uv_layers: - for uv in uv_layer.data: - if math.isnan(uv.uv.x): - uv.uv.x = 0 - issues['nan_coords'] += 1 - if math.isnan(uv.uv.y): - uv.uv.y = 0 - issues['nan_coords'] += 1 - - return issues - - def generate_validation_report(self, context: Context, - hierarchy_issues: List[str], - uv_issues: Dict[str, int]) -> None: - """Generate and display validation report""" - report_lines = [] - - # Add hierarchy issues - if hierarchy_issues: - report_lines.append(t("Validation.hierarchy_issues")) - report_lines.extend(hierarchy_issues) - - # Add UV issues - if uv_issues['nan_coords'] > 0: - report_lines.append(t("Validation.uv_nan_coords", - count=uv_issues['nan_coords'])) - - if uv_issues['missing_uvs'] > 0: - report_lines.append(t("Validation.missing_uvs", - count=uv_issues['missing_uvs'])) - - # Show report - if report_lines: - self.report({'WARNING'}, "\n".join(report_lines)) - else: - self.report({'INFO'}, t("Validation.no_issues")) diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py index d4f1607..b6983d4 100644 --- a/functions/optimization/materials_tools.py +++ b/functions/optimization/materials_tools.py @@ -14,10 +14,10 @@ from ...core.translations import t from ...core.common import ( get_active_armature, get_all_meshes, - validate_armature, clear_unused_data_blocks, ProgressTracker ) +from ...core.armature_validation import validate_armature def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool: """Compare two texture nodes for matching properties and image data""" @@ -92,7 +92,7 @@ class AvatarToolkit_OT_CombineMaterials(Operator): armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid def execute(self, context: Context) -> Set[str]: diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py index aac4a02..825b493 100644 --- a/functions/optimization/mesh_tools.py +++ b/functions/optimization/mesh_tools.py @@ -6,11 +6,11 @@ from ...core.translations import t from ...core.common import ( get_active_armature, get_all_meshes, - validate_armature, validate_meshes, join_mesh_objects, ProgressTracker ) +from ...core.armature_validation import validate_armature class AvatarToolkit_OT_JoinAllMeshes(Operator): """Operator to join all meshes in the scene""" @@ -25,7 +25,7 @@ class AvatarToolkit_OT_JoinAllMeshes(Operator): if not armature: return False valid: bool - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid def execute(self, context: Context) -> Set[str]: @@ -69,7 +69,7 @@ class AvatarToolkit_OT_JoinSelectedMeshes(Operator): if not armature: return False valid: bool - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return (valid and context.mode == 'OBJECT' and len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1) diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py index 8714e5b..e5c20e5 100644 --- a/functions/optimization/remove_doubles.py +++ b/functions/optimization/remove_doubles.py @@ -7,8 +7,8 @@ from ...core.translations import t from ...core.common import ( get_active_armature, get_all_meshes, - validate_armature ) +from ...core.armature_validation import validate_armature # Constants MERGE_ITERATION_COUNT = 20 @@ -54,6 +54,28 @@ def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[in return merged_vertices +def vertex_moves(mesh_data: bpy.types.Mesh, vertex: int) -> bool: + + for shapekey in mesh_data.shape_keys.key_blocks: + data: bpy.types.ShapeKey = shapekey + + if data.points[vertex].co.xyz != mesh_data.vertices[vertex].co.xyz: + return True + + return False + +def merge_vertex_at_index(mesh_data: bpy.types.Mesh, index: int, distance: float): + + select_target_vertex = [False]*len(mesh_data.vertices) + select_target_vertex[index] = True + + bpy.ops.object.mode_set(mode='OBJECT') + mesh_data.vertices.foreach_set("select",select_target_vertex) + bpy.ops.object.mode_set(mode='EDIT') + for _ in range(0,20): #for some reason, if using merge to unselected on a vertex, the vertex will only merge to 1 other vertex. so we gotta spam it to fix it. + bpy.ops.mesh.remove_doubles(threshold=distance, use_unselected=True, use_sharp_edge_from_normals=False) + bpy.ops.object.mode_set(mode='OBJECT') + class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator): bl_idname = "avatar_toolkit.remove_doubles_advanced" bl_label = t("Optimization.remove_doubles_advanced") @@ -66,7 +88,7 @@ class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator): armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid def execute(self, context: Context) -> set[str]: @@ -89,7 +111,7 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid def draw(self, context: Context) -> None: @@ -168,7 +190,7 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): except Exception as e: logger.error(f"Error in modify_mesh: {str(e)}") - def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> bool: + def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> int: """Advanced mesh modification with shape key handling""" try: final_merged_vertex_group = [] @@ -179,26 +201,28 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name) vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)} + + merge_vertex_at_index(duplicate.data, mesh_entry["cur_vertex_pass"], merge_distance) #merge the vertex at our pass to find vertices that would merge to our vertex at this shapekey. + # Process merging - merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) + merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) # find what vertices actually merged. if not initialized_final: final_merged_vertex_group = merged_vertices.copy() initialized_final = True else: - final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] - + final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] # remove vertices that merged from the list if they didn't merge during this shapkey. bpy.ops.object.delete() # Apply final merging if final_merged_vertex_group: - self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance) + self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance) # merge all vertices that merged on every shapekey no matter the shapekey during the loop. - return not (len(final_merged_vertex_group) > 1) + return len(final_merged_vertex_group) except Exception as e: logger.error(f"Error in modify_mesh_advanced: {str(e)}") - return True + return 1 def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None: """Apply final vertex merging operations""" @@ -232,16 +256,14 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None: """Complete the mesh processing by performing final merge operations""" logger.debug("Finishing mesh processing") + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action="INVERT") + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) - if not advanced: - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action="INVERT") - bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) - - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) def modal(self, context: Context, event: Event) -> set[ModalReturnType]: """Modal operator execution""" @@ -266,10 +288,21 @@ class AvatarToolkit_OT_RemoveDoubles(Operator): self.process_simple_mesh(context, mesh, merge_distance) self.objects_to_do.pop(0) - elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: - if self.modify_mesh_advanced(context, mesh): + elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: #advanced merging vertex by vertex + if(mesh["cur_vertex_pass"] < 0): #make sure it doesn't go below 0 and explode when advancing backwards from a previous step + mesh["cur_vertex_pass"] = 0 + + if vertex_moves(mesh["mesh"].data, mesh["cur_vertex_pass"]): # do not do advanced merging for vertices that don't move + mesh["cur_vertex_pass"] -= self.modify_mesh_advanced(context, mesh)-2 #advance forward or backwards based on how many vertices actually got merged, changing the list size. + #if above returns 1 (no vertices other than this one being merged to ourselves), advance by 1. else don't advance or go backwards. Makes sure all vertices get merged in the end. + else: mesh["cur_vertex_pass"] += 1 - + + elif (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced and len(mesh['shapekeys']) > 0: #after advanced merging has gone past all the moving vertices, now we need to merge non moving vertices. + shapekeyname = mesh['shapekeys'].pop(0) + mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname) + logger.debug(f"Processing shapekey {shapekeyname}") + self.modify_mesh(context, mesh) else: self.finish_mesh_processing(context, mesh, advanced, merge_distance) self.objects_to_do.pop(0) diff --git a/functions/pose_mode.py b/functions/pose_mode.py index c8fbc15..6cf2b00 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -8,13 +8,13 @@ from ..core.common import ( get_active_armature, get_all_meshes, apply_pose_as_rest, - validate_armature, cache_vertex_positions, apply_vertex_positions, validate_mesh_for_pose, process_armature_modifiers, ProgressTracker ) +from ..core.armature_validation import validate_armature class BatchPoseOperationMixin: """Base class for batch pose operations""" @@ -23,7 +23,7 @@ class BatchPoseOperationMixin: armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid and context.mode == 'POSE' def validate_meshes(self, meshes: List[Object]) -> List[Tuple[Object, str]]: @@ -46,7 +46,7 @@ class AvatarToolkit_OT_StartPoseMode(Operator): armature = get_active_armature(context) if not armature or context.mode == "POSE": return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid def execute(self, context: Context) -> Set[str]: diff --git a/functions/tools/additional_tools.py b/functions/tools/additional_tools.py index 2b3dd1c..91afaee 100644 --- a/functions/tools/additional_tools.py +++ b/functions/tools/additional_tools.py @@ -4,7 +4,8 @@ from bpy.types import Operator, Context from typing import Set from ...core.translations import t from ...core.logging_setup import logger -from ...core.common import get_active_armature, get_all_meshes, validate_armature, remove_unused_shapekeys +from ...core.common import get_active_armature, get_all_meshes, remove_unused_shapekeys +from ...core.armature_validation import validate_armature class AvatarToolkit_OT_ApplyTransforms(Operator): """Apply all transformations to armature and associated meshes""" @@ -18,8 +19,8 @@ class AvatarToolkit_OT_ApplyTransforms(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid and context.mode == 'OBJECT' + valid, _, _ = validate_armature(armature) + return valid and context.mode == 'OBJECT' def execute(self, context: Context) -> Set[str]: try: @@ -66,8 +67,8 @@ class AvatarToolkit_OT_CleanShapekeys(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0 + valid, _, _ = validate_armature(armature) + return valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0 def execute(self, context: Context) -> Set[str]: try: diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index e90ceaf..78e6c72 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -5,12 +5,11 @@ from typing import Optional, Dict, Any, List, Tuple from ...core.translations import t from ...core.common import ( get_active_armature, - validate_armature, get_all_meshes, ProgressTracker, - validate_bone_hierarchy, restore_bone_transforms ) +from ...core.armature_validation import validate_armature, validate_bone_hierarchy def duplicate_bone(bone: EditBone) -> EditBone: """Create a duplicate of the given bone""" @@ -35,8 +34,8 @@ class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return (is_valid and + valid, _, _ = validate_armature(armature) + return (valid and context.mode == 'EDIT_ARMATURE' and context.selected_editable_bones is not None and len(context.selected_editable_bones) == 2) @@ -129,22 +128,16 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid + valid, _, _ = validate_armature(armature) + return valid def execute(self, context: Context) -> set[str]: """Execute the constraint removal operation""" - - # Make sure we are in Object mode first or it will error bpy.ops.object.mode_set(mode='OBJECT') - armature = get_active_armature(context) - - # Select armature and make it active before changing mode bpy.ops.object.select_all(action='DESELECT') armature.select_set(True) context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='POSE') constraints_removed = 0 @@ -157,7 +150,6 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) return {'FINISHED'} - class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): """Operator to remove bones with no vertex weights""" bl_idname = "avatar_toolkit.clean_weights" @@ -167,10 +159,37 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): def should_preserve_bone(self, bone_name: str, context: Context) -> bool: """Check if bone should be preserved based on settings""" - if context.scene.avatar_toolkit.merge_twist_bones: - return "twist" in bone_name.lower() + toolkit = context.scene.avatar_toolkit + bone = context.active_object.data.bones.get(bone_name) + + if not bone: + return False + + if toolkit.preserve_parent_bones and bone.children: + return True + + if toolkit.target_bone_type == 'DEFORM' and not bone.use_deform: + return True + + if toolkit.target_bone_type == 'NON_DEFORM' and bone.use_deform: + return True + return False + def populate_bone_list(self, context: Context, zero_weight_bones: List[str]) -> None: + """Populate the zero weight bones list""" + toolkit = context.scene.avatar_toolkit + toolkit.zero_weight_bones.clear() + + armature = get_active_armature(context) + for bone_name in zero_weight_bones: + bone = armature.data.bones.get(bone_name) + if bone: + item = toolkit.zero_weight_bones.add() + item.name = bone_name + item.has_children = len(bone.children) > 0 + item.is_deform = bone.use_deform + def execute(self, context: Context) -> set[str]: """Execute the zero weight bone removal operation""" armature = get_active_armature(context) @@ -192,6 +211,7 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): # Get weighted bones weighted_bones: List[str] = [] meshes = get_all_meshes(context) + zero_weight_bones: List[str] = [] for mesh in meshes: mesh_data: Mesh = mesh.data @@ -209,6 +229,10 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): 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 + # Store children data children = bone.children children_data = {child.name: initial_transforms[child.name] for child in children} @@ -227,11 +251,38 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): for child_name, data in children_data.items(): if child_name in armature_data.edit_bones: child = armature_data.edit_bones[child_name] - child.head = data['head'] - child.tail = data['tail'] - child.roll = data['roll'] - child.matrix = data['matrix'] + restore_bone_transforms(child, data) 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'} + self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) + return {'FINISHED'} + +class AvatarToolKit_OT_RemoveSelectedBones(Operator): + """Operator to remove selected bones from the zero weight bones list""" + bl_idname = "avatar_toolkit.remove_selected_bones" + bl_label = t("Tools.remove_selected_bones") + bl_description = t("Tools.remove_selected_bones_desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> set[str]: + armature = get_active_armature(context) + toolkit = context.scene.avatar_toolkit + + selected_bones = [item.name for item in toolkit.zero_weight_bones + if item.selected] + + bpy.ops.object.mode_set(mode='EDIT') + for bone_name in selected_bones: + if bone_name in armature.data.edit_bones: + armature.data.edit_bones.remove(armature.data.edit_bones[bone_name]) + + bpy.ops.object.mode_set(mode='OBJECT') + toolkit.zero_weight_bones.clear() + + self.report({'INFO'}, t("Tools.bones_removed", count=len(selected_bones))) return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/convert_resonite.py b/functions/tools/convert_resonite.py index 8ab5d99..a41678a 100644 --- a/functions/tools/convert_resonite.py +++ b/functions/tools/convert_resonite.py @@ -4,8 +4,9 @@ from typing import Set, Dict, Optional from bpy.types import Operator, Context from ...core.translations import t from ...core.logging_setup import logger -from ...core.common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker +from ...core.common import get_active_armature, simplify_bonename, ProgressTracker from ...core.dictionaries import bone_names, resonite_translations +from ...core.armature_validation import validate_armature class AvatarToolkit_OT_ConvertResonite(Operator): """Convert armature bone names to Resonite format with progress tracking and validation""" @@ -19,8 +20,8 @@ class AvatarToolkit_OT_ConvertResonite(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid + valid, _, _ = validate_armature(armature) + return valid def execute(self, context: Context) -> Set[str]: armature = get_active_armature(context) diff --git a/functions/tools/merge_tools.py b/functions/tools/merge_tools.py index b8daada..4078f91 100644 --- a/functions/tools/merge_tools.py +++ b/functions/tools/merge_tools.py @@ -4,7 +4,8 @@ from typing import Set, List from bpy.types import Operator, Context, Armature, EditBone from ...core.translations import t from ...core.logging_setup import logger -from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, validate_armature +from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights +from ...core.armature_validation import validate_armature class AvatarToolkit_OT_ConnectBones(Operator): """Connect disconnected bones in chain""" @@ -18,8 +19,8 @@ class AvatarToolkit_OT_ConnectBones(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) - return is_valid + valid, _, _ = validate_armature(armature) + return valid def execute(self, context: Context) -> Set[str]: try: diff --git a/functions/tools/mesh_separation.py b/functions/tools/mesh_separation.py index 6ffb68d..96d8881 100644 --- a/functions/tools/mesh_separation.py +++ b/functions/tools/mesh_separation.py @@ -1,7 +1,8 @@ import bpy from bpy.types import Operator, Context from ...core.translations import t -from ...core.common import get_active_armature, validate_armature +from ...core.common import get_active_armature +from ...core.armature_validation import validate_armature class AvatarToolKit_OT_SeparateByMaterials(Operator): """Operator to separate mesh by materials""" @@ -16,10 +17,10 @@ class AvatarToolKit_OT_SeparateByMaterials(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return (context.active_object and context.active_object.type == 'MESH' and - is_valid) + valid) def execute(self, context: Context) -> set[str]: """Execute the separation operation""" @@ -48,10 +49,10 @@ class AvatarToolKit_OT_SeparateByLooseParts(Operator): armature = get_active_armature(context) if not armature: return False - is_valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return (context.active_object and context.active_object.type == 'MESH' and - is_valid) + valid) def execute(self, context: Context) -> set[str]: """Execute the separation operation""" diff --git a/functions/tools/rigify_converter.py b/functions/tools/rigify_converter.py new file mode 100644 index 0000000..8737454 --- /dev/null +++ b/functions/tools/rigify_converter.py @@ -0,0 +1,256 @@ +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.logging_setup import logger +from ...core.translations import t +from ...core.dictionaries import rigify_unity_names, rigify_basic_unity_names, rigify_unnecessary_bones +from ...core.armature_validation import validate_armature + +class AvatarToolkit_OT_ConvertRigifyToUnity(Operator): + """Convert Rigify armature to Unity-compatible format""" + bl_idname = "avatar_toolkit.convert_rigify_to_unity" + bl_label = t("Tools.convert_rigify_to_unity") + bl_description = t("Tools.convert_rigify_to_unity_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + return ("DEF-spine" in armature.data.bones or + "spine" in armature.data.bones and "metarig" in armature.name.lower()) + + def execute(self, context: Context) -> Set[str]: + try: + logger.info("Starting Rigify to Unity conversion") + armature = get_active_armature(context) + if not armature: + logger.error("No armature found") + self.report({'ERROR'}, t("Tools.no_armature")) + return {'CANCELLED'} + + logger.debug(f"Converting armature: {armature.name}") + armature.name = "Armature" + armature.data.name = "Armature" + logger.debug("Renamed armature to 'Armature'") + + if "DEF-spine" in armature.data.bones: + logger.info("Processing DEF bones") + self.move_def_bones(armature) + self.rename_bones_for_unity(armature) + else: + logger.info("Processing basic bones") + self.cleanup_extra_bones(armature) + self.rename_basic_bones_for_unity(armature) + + logger.debug("Cleaning up bone collections") + self.cleanup_bone_collections(armature) + + if context.scene.avatar_toolkit.merge_twist_bones: + logger.info("Merging twist bones") + self.handle_twist_bones(armature) + + logger.info("Successfully converted Rigify armature to Unity format") + self.report({'INFO'}, t("Tools.rigify_converted")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to convert Rigify: {str(e)}", exc_info=True) + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def cleanup_extra_bones(self, armature: Object) -> None: + """Remove unnecessary bones and merge neck bones""" + logger.debug("Starting cleanup of extra bones") + + # Set armature as active object before mode switch + bpy.context.view_layer.objects.active = armature + 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): + bones_to_remove.append(bone.name) + + 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: + logger.debug("Merging neck bones") + neck_start = armature.data.edit_bones['spine.004'] + neck_end = armature.data.edit_bones['spine.005'] + neck_start.tail = neck_end.tail + armature.data.edit_bones.remove(neck_end) + neck_start.name = "Neck" + + if 'spine.006' in armature.data.edit_bones: + logger.debug("Renaming head bone") + head_bone = armature.data.edit_bones['spine.006'] + head_bone.name = "Head" + + def move_def_bones(self, armature: Object) -> None: + """Move DEF bones to their correct positions""" + logger.debug("Moving DEF bones to correct positions") + + # Set armature as active object + bpy.context.view_layer.objects.active = armature + remap: Dict[str, str] = self.get_org_remap(armature) + remap.update(self.get_special_remap()) + + remove_bones_in_chain: List[str] = [ + 'DEF-upper_arm.L.001', 'DEF-forearm.L.001', + 'DEF-upper_arm.R.001', 'DEF-forearm.R.001', + 'DEF-thigh.L.001', 'DEF-shin.L.001', + 'DEF-thigh.R.001', 'DEF-shin.R.001' + ] + + transform_copies: List[str] = self.get_transform_copies(armature) + + logger.debug("Setting up transform copies") + bpy.ops.object.mode_set(mode='POSE') + for bone_name in transform_copies: + bone = armature.pose.bones[bone_name] + org_name = 'ORG-' + self.get_proto_name(bone_name) + if org_name in armature.pose.bones: + constraint = bone.constraints.new('COPY_TRANSFORMS') + constraint.target = armature + constraint.subtarget = org_name + constr_count = len(bone.constraints) + if constr_count > 1: + bone.constraints.move(constr_count-1, 0) + + logger.debug("Remapping bone parents") + bpy.ops.object.mode_set(mode='EDIT') + for remap_key in remap: + if remap_key in armature.data.edit_bones and remap[remap_key] in armature.data.edit_bones: + armature.data.edit_bones[remap_key].parent = armature.data.edit_bones[remap[remap_key]] + + logger.debug("Processing bone chain removal") + bpy.ops.object.mode_set(mode='OBJECT') + for bone_name in remove_bones_in_chain: + if bone_name in armature.data.bones: + armature.data.bones[bone_name].use_deform = False + + bpy.ops.object.mode_set(mode='EDIT') + for bone_name in remove_bones_in_chain: + if bone_name in armature.data.bones: + remove_bone = armature.data.edit_bones[bone_name] + parent_bone = remove_bone.parent + parent_bone.tail = remove_bone.tail + retarget_bones = list(remove_bone.children) + for bone in retarget_bones: + bone.parent = parent_bone + armature.data.edit_bones.remove(remove_bone) + + def rename_bones_for_unity(self, armature: Object) -> None: + """Rename bones to Unity-compatible names""" + logger.debug("Renaming bones to Unity format") + for old_name, new_name in rigify_unity_names.items(): + bone = armature.pose.bones.get(old_name) + if bone: + logger.debug(f"Renaming bone: {old_name} -> {new_name}") + bone.name = new_name + + def rename_basic_bones_for_unity(self, armature: Object) -> None: + """Rename basic metarig bones to Unity-compatible names""" + logger.debug("Renaming basic metarig bones") + for old_name, new_name in rigify_basic_unity_names.items(): + bone = armature.pose.bones.get(old_name) + if bone: + logger.debug(f"Renaming basic bone: {old_name} -> {new_name}") + bone.name = new_name + + def cleanup_bone_collections(self, armature: Object) -> None: + """Remove all bone collections since they're not needed for Unity""" + logger.debug("Cleaning up bone collections") + if hasattr(armature.data, 'collections') and armature.data.collections: + while len(armature.data.collections) > 0: + collection = armature.data.collections[0] + armature.data.collections.remove(collection) + + while len(armature.data.collections) > 1: + collection = armature.data.collections[1] + armature.data.collections.remove(collection) + + def handle_twist_bones(self, armature: Object) -> None: + """Handle twist bones during conversion""" + logger.debug("Processing twist bones") + twist_bones: List[Tuple[str, str]] = [ + ("DEF-upper_arm_twist.L", "DEF-upper_arm.L"), + ("DEF-upper_arm_twist.R", "DEF-upper_arm.R"), + ("DEF-forearm_twist.L", "DEF-forearm.L"), + ("DEF-forearm_twist.R", "DEF-forearm.R"), + ("DEF-thigh_twist.L", "DEF-thigh.L"), + ("DEF-thigh_twist.R", "DEF-thigh.R") + ] + + 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: + logger.debug(f"Merging twist bone: {twist_bone} into {parent_bone}") + twist = armature.data.edit_bones[twist_bone] + parent = armature.data.edit_bones[parent_bone] + parent.tail = twist.tail + for child in twist.children: + child.parent = parent + armature.data.edit_bones.remove(twist) + + bpy.ops.object.mode_set(mode='OBJECT') + + def get_org_remap(self, armature: Object) -> Dict[str, str]: + """Get original bone remapping""" + logger.debug("Getting original bone remapping") + remap: Dict[str, str] = {} + for bone in armature.data.bones: + if self.is_def_bone(bone.name): + name = self.get_proto_name(bone.name) + parent = bone.parent + while parent: + parent_name = self.get_proto_name(parent.name) + if parent_name != name: + if ('DEF-' + parent_name) in armature.data.bones: + remap[bone.name] = 'DEF-' + parent_name + break + parent = parent.parent + return remap + + def get_special_remap(self) -> Dict[str, str]: + """Get special bone remapping cases""" + logger.debug("Getting special bone remapping") + return { + 'DEF-thigh.L': 'DEF-pelvis.L', + 'DEF-thigh.R': 'DEF-pelvis.R', + 'DEF-upper_arm.L': 'DEF-shoulder.L', + 'DEF-upper_arm.R': 'DEF-shoulder.R', + } + + def get_transform_copies(self, armature: Object) -> List[str]: + """Get bones that need transform copies""" + logger.debug("Getting transform copy bones") + result: List[str] = [] + for bone in armature.pose.bones: + if self.is_def_bone(bone.name) and not self.has_transform_copies(bone): + result.append(bone.name) + return result + + def has_transform_copies(self, bone: PoseBone) -> bool: + """Check if bone has transform copy constraints""" + return any(constraint.type == 'COPY_TRANSFORMS' for constraint in bone.constraints) + + def is_def_bone(self, bone_name: str) -> bool: + """Check if bone is a DEF bone""" + return bone_name.startswith('DEF-') + + def is_org_bone(self, bone_name: str) -> bool: + """Check if bone is an ORG bone""" + return bone_name.startswith('ORG-') + + def get_proto_name(self, bone_name: str) -> str: + """Get the prototype name of a bone""" + if self.is_def_bone(bone_name) or self.is_org_bone(bone_name): + return bone_name[4:] + return bone_name diff --git a/functions/tools/standardize_armature.py b/functions/tools/standardize_armature.py new file mode 100644 index 0000000..f7ad52a --- /dev/null +++ b/functions/tools/standardize_armature.py @@ -0,0 +1,308 @@ +import bpy +import math +from typing import Dict, List, Set, Tuple, Optional, Any, Union +from bpy.types import Operator, Context, Object, EditBone, Bone +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, ProgressTracker +from ...core.armature_validation import validate_armature +from ...core.dictionaries import ( + standard_bones, + bone_names, + bone_hierarchy, + acceptable_bone_names, + acceptable_bone_hierarchy, + non_standard_mappings +) + +class AvatarToolkit_OT_StandardizeArmature(Operator): + """Standardize armature bone names and hierarchy to match Avatar Toolkit requirements""" + bl_idname: str = "avatar_toolkit.standardize_armature" + bl_label: str = t("Tools.standardize_armature") + bl_description: str = t("Tools.standardize_armature_desc") + bl_options: Set[str] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature: Optional[Object] = get_active_armature(context) + return armature is not None and context.mode in {'OBJECT', 'EDIT_ARMATURE'} + + def invoke(self, context: Context, event: Any) -> Set[str]: + logger.debug("Invoking standardize armature dialog") + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context: Context) -> None: + layout = self.layout + toolkit = context.scene.avatar_toolkit + + layout.prop(toolkit, "standardize_fix_names") + layout.prop(toolkit, "standardize_fix_hierarchy") + layout.prop(toolkit, "standardize_fix_scale") + layout.separator() + layout.label(text=t("Tools.standardize_warning"), icon='ERROR') + + def execute(self, context: Context) -> Set[str]: + armature: Optional[Object] = get_active_armature(context) + toolkit = context.scene.avatar_toolkit + + if not armature: + logger.warning("No active armature found for standardization") + self.report({'ERROR'}, t("Validation.no_armature")) + return {'CANCELLED'} + + 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') + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + + try: + with ProgressTracker(context, 3, "Standardizing Armature") as progress: + # Step 1: Fix bone names + if toolkit.standardize_fix_names: + progress.step("Fixing bone names") + renamed_bones: Dict[str, str] = self.standardize_bone_names(armature) + logger.info(f"Renamed {len(renamed_bones)} bones") + for old_name, new_name in renamed_bones.items(): + logger.debug(f"Renamed bone: {old_name} -> {new_name}") + + # Step 2: Fix hierarchy + if toolkit.standardize_fix_hierarchy: + progress.step("Fixing bone hierarchy") + fixed_hierarchy: int = self.standardize_bone_hierarchy(armature) + logger.info(f"Fixed {fixed_hierarchy} hierarchy relationships") + + # Step 3: Fix scale issues + if toolkit.standardize_fix_scale: + progress.step("Fixing bone scale") + fixed_scale: int = self.standardize_bone_scale(armature) + logger.info(f"Fixed {fixed_scale} scale issues") + + bpy.ops.object.mode_set(mode='OBJECT') + is_valid, messages, _ = validate_armature(armature) + + if is_valid: + logger.info("Armature successfully standardized") + self.report({'INFO'}, t("Tools.standardize_success")) + else: + logger.warning(f"Armature partially standardized. {len(messages)} issues remain") + bpy.ops.avatar_toolkit.standardize_issues_popup('INVOKE_DEFAULT') + self.report({'WARNING'}, t("Tools.standardize_partial")) + + if original_mode == 'EDIT_ARMATURE': + bpy.ops.object.mode_set(mode='EDIT') + + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to standardize armature: {str(e)}") + self.report({'ERROR'}, str(e)) + + try: + if original_mode == 'EDIT_ARMATURE': + bpy.ops.object.mode_set(mode='EDIT') + else: + bpy.ops.object.mode_set(mode='OBJECT') + except Exception as restore_error: + logger.error(f"Failed to restore original mode: {str(restore_error)}") + + return {'CANCELLED'} + + def standardize_bone_names(self, armature: Object) -> Dict[str, str]: + """Rename bones to match standard naming conventions""" + logger.debug("Starting bone name standardization") + renamed_bones: Dict[str, str] = {} + edit_bones = armature.data.edit_bones + + # First, check which standard bones already exist + existing_standard_bones: Set[str] = set() + for bone in edit_bones: + if bone.name in standard_bones.values(): + 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 + 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 + + # First pass: identify bones to rename + bones_to_rename: Dict[str, str] = {} + for bone in edit_bones: + original_name: str = bone.name + + # Skip if this is already a standard bone name + if original_name in standard_bones.values(): + continue + + simplified_name: str = original_name.lower().replace(' ', '').replace('_', '').replace('.', '') + + # 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 + + # 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 + has_chest: bool = False + has_upper_chest: bool = False + + for bone_name in edit_bones.keys(): + if bone_name == standard_bones['chest']: + has_chest = True + elif bone_name == standard_bones['upper_chest']: + has_upper_chest = True + + # If we have a chest but no upper chest, don't rename anything to upper chest + if has_chest and not has_upper_chest: + for original_name, new_name in list(bones_to_rename.items()): + if new_name == standard_bones['upper_chest']: + logger.debug(f"Skipping upper chest rename for {original_name} as chest already exists") + del bones_to_rename[original_name] + + # Second pass: rename bones (in reverse to avoid naming conflicts) + for original_name, new_name in sorted(bones_to_rename.items(), reverse=True): + if original_name in edit_bones: + temp_name: str = f"TEMP_{original_name}" + edit_bones[original_name].name = temp_name + renamed_bones[original_name] = new_name + logger.debug(f"Temporarily renamed: {original_name} -> {temp_name}") + + # Third pass: apply final names + for original_name, new_name in renamed_bones.items(): + temp_name: str = f"TEMP_{original_name}" + if temp_name in edit_bones: + edit_bones[temp_name].name = new_name + logger.debug(f"Applied final rename: {temp_name} -> {new_name}") + + logger.info(f"Standardized {len(renamed_bones)} bone names") + return renamed_bones + + def standardize_bone_hierarchy(self, armature: Object) -> int: + """Fix bone hierarchy to match standard relationships""" + logger.debug("Starting bone hierarchy standardization") + edit_bones = armature.data.edit_bones + fixed_count: int = 0 + + # Build a mapping of standard bone names to their expected parents + hierarchy_map: Dict[str, str] = {} + for parent, child in bone_hierarchy: + if parent in edit_bones and child in edit_bones: + hierarchy_map[child] = parent + logger.debug(f"Found standard hierarchy: {parent} -> {child}") + + for parent, child in acceptable_bone_hierarchy: + if parent in edit_bones and child in edit_bones: + # Only add if not already in the map + if child not in hierarchy_map: + hierarchy_map[child] = parent + logger.debug(f"Found acceptable hierarchy: {parent} -> {child}") + + for child_name, parent_name in hierarchy_map.items(): + if child_name in edit_bones and parent_name in edit_bones: + child_bone: EditBone = edit_bones[child_name] + parent_bone: EditBone = edit_bones[parent_name] + + if child_bone.parent != parent_bone: + logger.debug(f"Fixing hierarchy: {child_name} parent was {child_bone.parent.name if child_bone.parent else 'None'}, setting to {parent_name}") + child_bone.parent = parent_bone + fixed_count += 1 + + logger.info(f"Fixed {fixed_count} bone hierarchy relationships") + return fixed_count + + def standardize_bone_scale(self, armature: Object) -> int: + """Fix bone scale issues by normalizing bone lengths""" + logger.debug("Starting bone scale standardization") + edit_bones = armature.data.edit_bones + fixed_count: int = 0 + + # Calculate median bone length for reference + lengths: List[float] = [bone.length for bone in edit_bones if bone.length > 0.0001] + if not lengths: + logger.warning("No valid bone lengths found for scale standardization") + return 0 + + lengths.sort() + median_length: float = lengths[len(lengths) // 2] + logger.debug(f"Median bone length: {median_length}") + + # Calculate mean and standard deviation + mean: float = sum(lengths) / len(lengths) + variance: float = sum((l - mean) ** 2 for l in lengths) / len(lengths) + std_dev: float = math.sqrt(variance) + logger.debug(f"Mean bone length: {mean}, Standard deviation: {std_dev}") + + small_threshold: float = max(median_length * 0.05, mean - 3 * std_dev) + large_threshold: float = min(median_length * 15, mean + 5 * std_dev) + logger.debug(f"Scale thresholds - small: {small_threshold}, large: {large_threshold}") + + for bone in edit_bones: + is_finger: bool = any(finger in bone.name.lower() for finger in ['thumb', 'index', 'middle', 'ring', 'pinky', 'finger']) + + if bone.length < small_threshold and not is_finger: + old_length: float = bone.length + bone.length = small_threshold + logger.debug(f"Fixed small bone {bone.name}: {old_length} -> {bone.length}") + fixed_count += 1 + elif bone.length > large_threshold: + old_length: float = bone.length + bone.length = large_threshold + logger.debug(f"Fixed large bone {bone.name}: {old_length} -> {bone.length}") + fixed_count += 1 + + logger.info(f"Fixed {fixed_count} bone scale issues") + return fixed_count + +class AvatarToolkit_OT_StandardizeIssuesPopup(Operator): + """Display information about remaining issues after standardization""" + bl_idname: str = "avatar_toolkit.standardize_issues_popup" + bl_label: str = t("Tools.standardize_issues_title") + bl_options: Set[str] = {'INTERNAL'} + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + + def invoke(self, context: Context, event: Any) -> Set[str]: + logger.debug("Showing standardization issues popup") + return context.window_manager.invoke_props_dialog(self, width=400) + + def draw(self, context: Context) -> None: + layout = self.layout + col = layout.column(align=True) + + col.label(text=t("Tools.standardize_issues_header"), icon='INFO') + col.separator() + + col.label(text=t("Tools.standardize_issues_line1")) + col.label(text=t("Tools.standardize_issues_line2")) + col.label(text=t("Tools.standardize_issues_line3")) + col.separator() + col.label(text=t("Tools.standardize_issues_line4")) + col.label(text=t("Tools.standardize_issues_line5")) + col.separator() + col.label(text=t("Tools.standardize_issues_line6")) + diff --git a/functions/tools/uv_tools.py b/functions/tools/uv_tools.py new file mode 100644 index 0000000..e7dcc5d --- /dev/null +++ b/functions/tools/uv_tools.py @@ -0,0 +1,253 @@ +from typing import TypedDict, Set, Dict, List, Optional, Any, Tuple +import bpy +from bpy.types import Operator, Object, Context, Mesh, MeshUVLoopLayer +import bmesh +import numpy as np +import math +from ...core.translations import t +from ...core.logging_setup import logger + +class GenerateLoopTreeResult(TypedDict): + tree: Dict[str, Set[str]] + selected_loops: Dict[str, List[int]] + selected_verts: Dict[str, int] + +class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): + """Operator to align selected UV edges to target edge""" + bl_idname = "avatar_toolkit.align_uv_edges_to_target" + bl_label = t("UVTools.align_edges") + bl_description = t("UVTools.align_edges_desc") + bl_options = {'REGISTER', 'UNDO'} + + #all selected objects need to be meshes for this to work - @989onan + @classmethod + def poll(cls, context: Context) -> bool: + if not ((context.view_layer.objects.active is not None) and (len(context.view_layer.objects.selected) > 0)): + return False + if context.mode != "EDIT_MESH": + return False + for obj in context.view_layer.objects.selected: + if obj.type != "MESH": + return False + if not context.space_data: + return False + if not context.space_data.show_uvedit: + return False + if context.scene.tool_settings.use_uv_select_sync: + return False + return True + + def execute(self, context: Context) -> Set[str]: + target: str = context.view_layer.objects.active.name #The object which we want to align every other selected object's selected UV vertex line to + sources: List[str] = [i.name for i in context.view_layer.objects.selected] #The objects which we want to align their selected UV lines to the target's UV line + + prev_mode: str = bpy.context.object.mode + bpy.ops.object.mode_set(mode='OBJECT') + + def generate_loop_tree(obj_name: str) -> GenerateLoopTreeResult: + logger.debug(f"Finding selected line for: {obj_name}") + + vert_target_loops: Dict[str, List[int]] = {} + vert_target_verts: Dict[str, int] = {} + + me: Mesh = bpy.data.objects[obj_name].data + uv_lay: MeshUVLoopLayer = me.uv_layers.active + bm: bmesh.types.BMesh = bmesh.new() + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + + # To explain: + # So loops in UV maps are X polygons that make up a face (So a MeshLoop represent a face and each vertex on that face is in order) + # + # For some preknowledge: + # When a mesh is UV unwrapped, if a vertice is shared by two different faces on the model in the viewport and the vertice of both faces are in + # the same position on the UV map, then it considers it one point and the user can move it + # (is why the uv map doesn't split apart when you try to move a vertex because that would be annoying) + # + # The problem: + # The problem is that the data for whether the uv corners of two faces that share a vertex physically being connected and selected as one vertex on the uv map does not exist + # Though thankfully, blender forcibly (whether you like it or not) merges vertices of a uv map if the vertex of two different faces are actually shared in the UI, + # allowing for the moving of vertices of 4 faces connected by a single vertex. Behavior every normal blender user is familiar with. + # + # The solution + # We can use this to our advantage, by finding vertices on the uv map that share the same coridinate as another vertex that is also selected. + # that way we can group each pair shared in a line as the same vertex, and identify the line using these pairs and using the data that says for certain + # that two vertices share the same face loop, and therefore are connected. + + #hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate + for k,i in enumerate(uv_lay.vertex_selection): + if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False): + key = np.array(uv_lay.uv[k].vector[:]) + key = key.round(decimals=5) + + if str(key) not in vert_target_loops: + vert_target_loops[str(key)] = [] + vert_target_loops[str(key)].append(k) + vert_target_verts[str(key)] = me.loops[k].vertex_index + + if len(vert_target_loops) > 4000: + self.report({'WARNING'}, t("UVTools.too_many_vertices")) + return {"tree": {}, "selected_loops": {}, "selected_verts": {}} + + logger.debug(f"Finding connections on line for {obj_name}") + me.validate() + + bm = bmesh.new() + bm.from_mesh(me) + + tree: Dict[str, Set[str]] = {} + selected_verts = np.hstack(list(vert_target_loops.values())) + bm.verts.ensure_lookup_table() + + for uvcoordsstr in vert_target_loops: + uv_lay = me.uv_layers.active + + #before this section, each vert_target_loops is just groupings of vertices that share coordinates. + # Using the data that determines UV face corners (uvloops) that are associated with the real vertex, + # and the uv face corners (loops) that are on the same faces as the vertices that share coordinates in + # vert_target_loops, we can now identify them + #TL;DR: pairs of vertices that share cooridinates (chain links) find their buddies (make chain connected) + + # Someone explain this better than me if you can please - @989onan + extension_loops = [] + loops = bm.verts[vert_target_verts[uvcoordsstr]].link_loops + loops_indexes = [i.index for i in loops] + for loop in vert_target_loops[uvcoordsstr]: + if loop in loops_indexes: + loop_obj = loops[loops_indexes.index(loop)] + extension_loops.append(loop_obj.link_loop_next.index) + extension_loops.append(loop_obj.link_loop_prev.index) + + #make a tree out of the vertices we identified as sharing faces with the vertices in vert_target_loops, and then link them together in a dictionary. + #the order of this dictionary is unknown. + # Someone explain this better than me if you can please - @989onan + tree[uvcoordsstr] = set() + + for i in extension_loops: + if i in selected_verts: + key = np.array(uv_lay.uv[i].vector[:]) + key = key.round(decimals=5) + tree[uvcoordsstr].add(str(key)) + + if uvcoordsstr in tree: + if len(tree[uvcoordsstr]) > 2: + self.report({'WARNING'}, t("UVTools.need_line", obj=obj_name)) + return {"tree": {}, "selected_loops": {}, "selected_verts": {}} + + uv_lay = me.uv_layers.active + for uvcoordstr in vert_target_loops: + for loop in vert_target_loops[uvcoordstr]: + uv_lay.vertex_selection[loop].value = True + + bm.free() + me.validate() + logger.debug(f"Found UV line connections for {obj_name}") + + return {"tree": tree, "selected_loops": vert_target_loops, "selected_verts": vert_target_verts} + + def sort_uv_tree(originaltree: Dict[str, Set[str]], obj_name: str) -> List[str]: + sortedtree: Dict[str, Set[str]] = originaltree.copy() + startpoints: List[str] = [] + for i in sortedtree: + if len(sortedtree[i]) < 2: + startpoints.append(i) + + if len(startpoints) != 2: + self.report({'WARNING'}, t("UVTools.need_line", obj=obj_name)) + return [] + + uvcoords1 = [float(x) for x in startpoints[0].replace("[","").replace("]","").split()] + uvcoords2 = [float(x) for x in startpoints[1].replace("[","").replace("]","").split()] + + cursor = context.space_data.cursor_location + + startpoint = startpoints[0] if math.sqrt((uvcoords1[0] - cursor[0])**2 + (uvcoords1[1] - cursor[1])**2) > math.sqrt((uvcoords2[0] - cursor[0])**2 + (uvcoords2[1] - cursor[1])**2) else startpoints[1] + + #Wew my first actual recursive sort! - @989onan + def recursive_sort_uv_tree(point: str, sortedfinal: List[str]) -> List[str]: + #print("appending "+point) + sortedfinal.append(point) + + new_point: str = "" + for i in sortedtree: + if point in sortedtree[i]: + new_point = i + removed_value = sortedtree.pop(i) + #print(removed_value) + break + + if new_point == "": + logger.debug("Sorting complete, remaining tree:") + logger.debug(sortedtree) + return sortedfinal + + return recursive_sort_uv_tree(new_point, sortedfinal) + + sortedtree.pop(startpoint) + return recursive_sort_uv_tree(startpoint, []) + + def lerp(v0: float, v1: float, t: float) -> float: + return v0 + t * (v1 - v0) + + target_data = generate_loop_tree(target) + sorted_target_tree = sort_uv_tree(target_data["tree"], target) + logger.debug("Sorted target tree") + + for source in sources: + if source == target: + continue + + try: + source_data = generate_loop_tree(source) + sorted_source_tree = sort_uv_tree(source_data["tree"], source) + logger.debug(f"Sorted source {source}") + + vertex_factor = float(len(sorted_target_tree)-1) / float(len(sorted_source_tree)-1) + logger.debug(f"Vertex factor: {vertex_factor}") + + for k, i in enumerate(sorted_source_tree): + try: + #find where we are on the target edges, to interpolate the current point we're placing along the target point's line. + progress_along_edge = float(k) * vertex_factor + previous_vertex_index = math.floor(progress_along_edge) + next_vertex_index = math.ceil(progress_along_edge) + + #find the uv coordinates of the previous and next points on the target uv line. + previous_point = [float(x) for x in sorted_target_tree[previous_vertex_index].replace("[","").replace("]","").split()] + next_point = [float(x) for x in sorted_target_tree[next_vertex_index].replace("[","").replace("]","").split()] + + #create a point between these two values that represents a decimal 0-1 going where we are to where we are going between the two current points on the edge we are targeting this whole shebang with. + progress_between_points = progress_along_edge - int(progress_along_edge) + lerped_point = [ + lerp(previous_point[0], next_point[0], progress_between_points), + lerp(previous_point[1], next_point[1], progress_between_points) + ] + + #grab our uv face corners for each uv coord that we saved. + #Since each face is considered separate internally, we have to treat each connected face to a vertex in a uv map as separate entities/vertexes. + #basically pretend they are split apart. + uv_face_corners = source_data["selected_loops"][i] + + me = bpy.data.objects[source].data + me.validate() + bm = bmesh.new() + bm.from_mesh(me) + uv_lay = me.uv_layers.active + bm.verts.ensure_lookup_table() + + for corner in uv_face_corners: + uv_lay.uv[corner].vector = lerped_point + + except: + #This is probably fine? - @989onan + #TODO: What happened here? The magic of making code so complex you forget if this is even an issue. - @989onan + pass + + logger.info(f"Finished mesh {source} for UV's") + + except Exception as e: + logger.error(f"Error processing source {source}: {str(e)}") + return {'CANCELLED'} + + bpy.ops.object.mode_set(mode=prev_mode) + return {'FINISHED'} diff --git a/functions/visemes.py b/functions/visemes.py index 5052559..da332bc 100644 --- a/functions/visemes.py +++ b/functions/visemes.py @@ -9,10 +9,10 @@ from ..core.logging_setup import logger from ..core.translations import t from ..core.common import ( get_active_armature, - validate_armature, get_all_meshes, validate_mesh_for_pose ) +from ..core.armature_validation import validate_armature class VisemeCache: """Manages caching of generated viseme shape data for performance optimization""" @@ -35,6 +35,7 @@ class VisemePreview: _preview_data: Dict[str, float] = {} _active: bool = False _preview_shapes: Optional[OrderedDict] = None + _mesh_name: str = "" @classmethod def start_preview(cls, context: Context, mesh: Object, shapes: List[str]) -> bool: @@ -43,6 +44,7 @@ class VisemePreview: cls._active = True cls._preview_data = {} + cls._mesh_name = mesh.name # Store original values for shape_key in mesh.data.shape_keys.key_blocks: @@ -79,7 +81,11 @@ class VisemePreview: if not cls._active or not cls._preview_shapes: return - mesh = context.active_object + # Get the mesh by name instead of using active object + mesh = bpy.data.objects.get(cls._mesh_name) + if not mesh: + return + props = context.scene.avatar_toolkit viseme_data = cls._preview_shapes.get(props.viseme_preview_selection) if viseme_data: @@ -116,6 +122,7 @@ class VisemePreview: cls._active = False cls._preview_data.clear() cls._preview_shapes = None + cls._mesh_name = "" class ATOOLKIT_OT_preview_visemes(Operator): """Operator for previewing viseme shapes in real-time""" @@ -126,7 +133,6 @@ class ATOOLKIT_OT_preview_visemes(Operator): @classmethod def poll(cls, context: Context) -> bool: - # Check if we're in object mode if context.mode != 'OBJECT': return False @@ -138,19 +144,18 @@ class ATOOLKIT_OT_preview_visemes(Operator): armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid and mesh_obj and mesh_obj.type == 'MESH' - def execute(self, context: Context) -> Set[str]: props = context.scene.avatar_toolkit - mesh = context.active_object + mesh = bpy.data.objects.get(props.viseme_mesh) if props.viseme_preview_mode: VisemePreview.end_preview(mesh) props.viseme_preview_mode = False else: - if not mesh.data.shape_keys: + if not mesh or not mesh.data.shape_keys: self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) return {'CANCELLED'} @@ -197,15 +202,14 @@ class ATOOLKIT_OT_create_visemes(Operator): armature = get_active_armature(context) if not armature: return False - valid, _ = validate_armature(armature) + valid, _, _ = validate_armature(armature) return valid and mesh_obj and mesh_obj.type == 'MESH' - def execute(self, context: Context) -> Set[str]: props = context.scene.avatar_toolkit - mesh = context.active_object + mesh = bpy.data.objects.get(props.viseme_mesh) # Changed from context.active_object - if not mesh.data.shape_keys: + if not mesh or not mesh.data.shape_keys: self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) return {'CANCELLED'} @@ -280,7 +284,7 @@ class ATOOLKIT_OT_create_visemes(Operator): continue # Create new shape key - self.mix_shapekey(context, renamed_shapes, data['mix'], key) + self.mix_shapekey(context, renamed_shapes, data['mix'], key, mesh) # Added mesh parameter # Cache the new shape key data shape_data = [v.co.copy() for v in mesh.data.shape_keys.key_blocks[key].data] @@ -293,14 +297,16 @@ class ATOOLKIT_OT_create_visemes(Operator): mesh.active_shape_key_index = 0 wm.progress_end() - def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str) -> None: + def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str, mesh: Object) -> None: # Added mesh parameter """Creates a new shape key by mixing existing ones""" - mesh = context.active_object # Remove existing shape key if it exists if new_name in mesh.data.shape_keys.key_blocks: mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(new_name) + old_active = context.view_layer.objects.active + context.view_layer.objects.active = mesh bpy.ops.object.shape_key_remove() + context.view_layer.objects.active = old_active # Reset all shape keys for shapekey in mesh.data.shape_keys.key_blocks: @@ -313,7 +319,10 @@ class ATOOLKIT_OT_create_visemes(Operator): shapekey.value = value # Create mixed shape key + old_active = context.view_layer.objects.active + context.view_layer.objects.active = mesh mesh.shape_key_add(name=new_name, from_mix=True) + context.view_layer.objects.active = old_active # Reset values and restore shape key settings for shapekey in mesh.data.shape_keys.key_blocks: @@ -356,3 +365,4 @@ class ATOOLKIT_OT_create_visemes(Operator): props.mouth_a = current_names[0] props.mouth_o = current_names[1] props.mouth_ch = current_names[2] + diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index dc60653..6721c80 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.1.1)", + "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.2.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.", @@ -50,6 +50,7 @@ "QuickAccess.validation_basic_details": "Only essential bone structure is being validated", "QuickAccess.validation_none_warning": "Validation Disabled", "QuickAccess.validation_none_details": "No armature validation checks are being performed", + "Quick_Access.import_success": "Import successful", "PoseMode.error.start": "Failed to start pose mode: {error}", "PoseMode.error.stop": "Failed to stop pose mode: {error}", @@ -69,6 +70,46 @@ "Armature.validation.invalid_hierarchy": "Invalid bone hierarchy between {parent} and {child}", "Armature.validation.asymmetric_bones": "Missing symmetric bones for {bone}", "Armature.validation.asymmetric_hand_wrist": "Missing symmetric bones for hands/wrists", + "Armature.validation.found_bones": "Found bones in armature:\n- {bones}", + "Armature.validation.non_standard_bones": "Non-standard bones found:\n- {bones}", + "Armature.validation.accessory_bones_note.line1": "If you have hair bones, skirt bones, or other", + "Armature.validation.accessory_bones_note.line2": "accessorybones named similarly to main armature", + "Armature.validation.accessory_bones_note.line3": "bones (e.g., Head1, Head2), please rename them to", + "Armature.validation.accessory_bones_note.line4": "more descriptive names like Hair_1, Skirt_1.", + "Armature.validation.standardize_note.line1": "You can standardize your armature", + "Armature.validation.standardize_note.line2": "automatically by using the 'Standardize Armature'", + "Armature.validation.standardize_note.line3": "button in the Tools section.", + "Validation.section.found_bones": "Found Bones", + "Validation.section.non_standard": "Non-Standard Bones", + "Validation.section.hierarchy": "Hierarchy Issues", + "Validation.status.failed": "Validation has failed", + "Validation.message.failed.line1": "Armature validation has failed", + "Validation.message.failed.line2": "Please check below what the", + "Validation.message.failed.line3": "issues are", + "Validation.highlight_problem_bones_desc": "Visually highlight bones that have validation issues in the viewport", + "Validation.no_armature": "No armature selected", + "Validation.no_issues": "No validation issues found to highlight", + "Validation.highlighting_complete": "Problem bones highlighted successfully", + "Validation.tpose.no_armature": "No armature found for T-pose validation", + "Validation.tpose.left_arm_not_horizontal": "Left arm is not in a horizontal T-pose position", + "Validation.tpose.right_arm_not_horizontal": "Right arm is not in a horizontal T-pose position", + "Validation.tpose.spine_not_vertical": "Spine is not in a vertical position", + "Validation.tpose.warning": "T-Pose Validation Warning", + "Validation.tpose.recommendation": "We recommend fixing the T-pose before importing into Unity or other platforms", + "Validation.scale_issues": "Bones with abnormal scale detected:", + "Validation.scale_issue.too_small": "Bone is extremely small", + "Validation.scale_issue.too_large": "Bone is extremely large", + "Validation.section.scale_issues": "Scale Issues", + "Validation.tpose.label": "Validate T-Pose", + "Validation.no_scale_issues": "No scale issues detected", + "Validation.no_hierarchy_issues": "No hierarchy issues detected", + "Validation.no_non_standard_issues": "No non-standard bone issues detected", + "Validation.tpose.valid": "T-Pose validation passed successfully", + "Validation.tpose.desc": "Check if armature is in a proper T-pose", + "Validation.highlight_problem_bones": "Highlight Problem Bones", + "Validation.clear_bone_highlighting": "Clear Bone Highlighting", + "Validation.clear_bone_highlighting_desc": "Remove bone highlighting and reset bone colors to default", + "Validation.highlighting_cleared": "Bone highlighting cleared successfully", "Mesh.validation.no_data": "No mesh data", "Mesh.validation.no_vertex_groups": "No vertex groups found", @@ -149,6 +190,19 @@ "Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight", "Tools.clean_weights": "Remove Zero Weight Bones", "Tools.clean_weights_desc": "Remove bones with no vertex weights", + "Tools.preserve_parent_bones": "Preserve Parent Bones", + "Tools.preserve_parent_bones_desc": "Keep bones that have children even if they have no weights", + "Tools.target_bone_type": "Target Bone Type", + "Tools.target_bone_type_desc": "Filter which types of bones to process", + "Tools.target_all_bones": "All Bones", + "Tools.target_deform_bones": "Deform Bones Only", + "Tools.target_non_deform_bones": "Non-Deform Bones Only", + "Tools.list_only_mode": "List Mode Only", + "Tools.list_only_mode_desc": "List zero weight bones instead of removing them", + "Tools.zero_weight_bones_found": "Zero weight bones found: {bones}", + "Tools.remove_selected_bones": "Remove Selected Bones", + "Tools.remove_selected_bones_desc": "Remove selected zero weight bones from armature", + "Tools.bones_removed": "Removed {count} bones", "Tools.clean_constraints": "Delete Bone Constraints", "Tools.clean_constraints_desc": "Remove all bone constraints from armature", "Tools.clean_constraints_success": "Removed {count} bone constraints", @@ -187,6 +241,37 @@ "Tools.shapekey_tolerance": "Shape Key Tolerance", "Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used", "Tools.shapekeys_removed": "Removed {count} unused shape keys", + "Tools.rigify_title": "Rigify Tools", + "Tools.convert_rigify_to_unity": "Convert Rigify to Unity", + "Tools.convert_rigify_to_unity_desc": "Convert Rigify armature to Unity-compatible format", + "Tools.rigify_converted": "Rigify armature converted successfully", + "Tools.no_armature": "No armature selected", + "Tools.standardize_title": "Standardization", + "Tools.standardize_armature": "Standardize Armature", + "Tools.standardize_armature_desc": "Convert non-standard armature to Avatar Toolkit standards", + "Tools.standardize_fix_names": "Fix Bone Names", + "Tools.standardize_fix_names_desc": "Rename bones to match standard naming conventions", + "Tools.standardize_fix_hierarchy": "Fix Bone Hierarchy", + "Tools.standardize_fix_hierarchy_desc": "Correct parent-child relationships between bones", + "Tools.standardize_fix_scale": "Fix Bone Scale", + "Tools.standardize_fix_scale_desc": "Normalize bone lengths to fix scale issues", + "Tools.standardize_warning": "This operation will modify your armature. Make a backup first!", + "Tools.standardize_success": "Armature successfully standardized", + "Tools.standardize_partial": "Armature partially standardized. Some issues remain.", + "Tools.standardize_already_valid": "Armature already meets standards. No changes needed.", + "Tools.standardize_issues_title": "Standardization Issues", + "Tools.standardize_issues_header": "Some issues still remain after standardization", + "Tools.standardize_issues_line1": "This could be because some bones on your avatar have unique names", + "Tools.standardize_issues_line2": "that aren't in our list of recognized non-standard bones.", + "Tools.standardize_issues_line3": "For example, if your hips bone is named 'THISISMYHIPS', we can't detect it.", + "Tools.standardize_issues_line4": "If your main skeleton bones aren't being recognized, please report this", + "Tools.standardize_issues_line5": "on our GitHub so we can add them to our database.", + "Tools.standardize_issues_line6": "Accessory bones (hair, clothing, etc.) must be renamed manually.", + + "UVTools.too_many_vertices": "Error! You have too much stuff selected. Are you sure you're selecting two edges?", + "UVTools.need_line": "You need one line of selected UV points per selected object. Object \"{obj}\" does not meet this requirement!", + "UVTools.align_edges": "Align UV Edges to Target", + "UVTools.align_edges_desc": "Aligns a selected line of UV points on each selected mesh to the line of selected UV points on the active mesh. Useful for kitbashing textures of one model onto another. Uses distance from the 2D cursor to identify the start of the line of UV points on each mesh.", "MMD.label": "MMD Tools", "MMD.bone_standardization": "Bone Standardization", @@ -314,10 +399,15 @@ "EyeTracking.sdk_version": "SDK Version", "EyeTracking.type.av3": "Avatar 3.0", "EyeTracking.type.av3_desc": "VRChat Avatar 3.0 eye tracking setup", - "EyeTracking.type.sdk2": "SDK2 (Legacy)", - "EyeTracking.type.sdk2_desc": "VRChat SDK2 eye tracking setup", + "EyeTracking.type.sdk2": "Legacy (ChilloutVR", + "EyeTracking.type.sdk2_desc": "Legacy (SDK2) eye tracking setup", "EyeTracking.adjust.label": "Adjust Eye Position", "EyeTracking.adjust.desc": "Adjust the position of eye bones based on vertex groups", + "EyeTracking.sdk2_warning": "Legacy (SDK2) Eye Tracking Notice", + "EyeTracking.sdk2_warning_detail1": "This system SHOULD NOT BE USED FOR VRChat,", + "EyeTracking.sdk2_warning_detail2": "as eye tracking is now configured directly", + "EyeTracking.sdk2_warning_detail3": "in Unity. It remains for other platforms.", + "EyeTracking.sdk2_warning_detail4": "like ChilloutVR.", "CustomPanel.label": "Custom Avatar Tools", "CustomPanel.merge_mode": "Merge Mode", @@ -380,6 +470,26 @@ "MergeArmature.cleanup_shape_keys": "Clean Shape Keys", "MergeArmature.cleanup_shape_keys_desc": "Remove unused shape keys", + "TextureAtlas.atlas_completed": "Texture atlas creation completed", + "TextureAtlas.atlas_error": "An error occurred during texture atlas creation", + "TextureAtlas.atlas_materials": "Atlas Materials", + "TextureAtlas.atlas_materials_desc": "Atlas materials to optimize the model", + "TextureAtlas.label": "Texture Atlasing", + "TextureAtlas.loaded_list": "Loaded Texture Atlas Material List", + "TextureAtlas.material_list_label": "Texture Atlas Material List Material", + "TextureAtlas.reload_list": "Reload Texture Atlas Material List", + "TextureAtlas.error.label": "ERROR", + "TextureAtlas.none.label": "None", + "TextureAtlas.no_nodes_error.desc": "THIS MATERIAL DOES NOT USE NODES!", + "TextureAtlas.no_images_error.desc": "THIS MATERIAL HAS NO IMAGES!", + "TextureAtlas.texture_use_atlas.desc": "The texture that will be used for the {name} map atlas", + "TextureAtlas.albedo": "Albedo", + "TextureAtlas.normal": "Normal", + "TextureAtlas.emission": "Emission", + "TextureAtlas.ambient_occlusion": "Ambient Occlusion", + "TextureAtlas.height": "Height", + "TextureAtlas.roughness": "Roughness", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", @@ -397,6 +507,9 @@ "Settings.enable_logging_desc": "Enable detailed debug logging for troubleshooting", "Settings.logging_enabled": "Debug logging enabled", "Settings.logging_disabled": "Debug logging disabled", + "Settings.highlight_problem_bones": "Highlight Problem Bones", + "Settings.highlight_problem_bones_desc": "Highlight bones with validation issues in the viewport", + "Settings.bone_highlighting": "Bone Highlighting", "Language.auto": "Automatic", "Language.en_US": "English", "Language.ja_JP": "Japanese", diff --git a/resources/translations/ja_JP.json b/resources/translations/ja_JP.json index 2a7f445..99a9b33 100644 --- a/resources/translations/ja_JP.json +++ b/resources/translations/ja_JP.json @@ -149,6 +149,19 @@ "Tools.merge_twist_bones_desc": "チェックすると、重みが0でもツイストボーンを保持します", "Tools.clean_weights": "重みなしボーンを削除", "Tools.clean_weights_desc": "頂点の重みがないボーンを削除", + "Tools.preserve_parent_bones": "親ボーンを保持", + "Tools.preserve_parent_bones_desc": "ウェイトがなくても子ボーンを持つボーンを保持", + "Tools.target_bone_type": "対象ボーンタイプ", + "Tools.target_bone_type_desc": "処理するボーンタイプを選択", + "Tools.target_all_bones": "全てのボーン", + "Tools.target_deform_bones": "変形ボーンのみ", + "Tools.target_non_deform_bones": "非変形ボーンのみ", + "Tools.list_only_mode": "リストモードのみ", + "Tools.list_only_mode_desc": "ゼロウェイトボーンを削除せずにリスト表示", + "Tools.zero_weight_bones_found": "ゼロウェイトボーンが見つかりました: {bones}", + "Tools.remove_selected_bones": "選択したボーンを削除", + "Tools.remove_selected_bones_desc": "選択したゼロウェイトボーンをアーマチュアから削除", + "Tools.bones_removed": "{count}個のボーンを削除しました", "Tools.clean_constraints": "ボーンのコンストレイントを削除", "Tools.clean_constraints_desc": "アーマチュアからすべてのボーンコンストレイントを削除", "Tools.clean_constraints_success": "{count}個のボーンコンストレイントを削除しました", @@ -187,6 +200,10 @@ "Tools.shapekey_tolerance": "シェイプキーの許容値", "Tools.shapekey_tolerance_desc": "シェイプキーを使用済みと判断する最小差分", "Tools.shapekeys_removed": "{count}個の未使用シェイプキーを削除しました", + "Tools.convert_rigify_to_unity": "RigifyをUnityに変換", + "Tools.convert_rigify_to_unity_desc": "RigifyアーマチュアをUnity互換フォーマットに変換", + "Tools.rigify_converted": "Rigifyアーマチュアの変換が完了しました", + "Tools.no_armature": "アーマチュアが選択されていません", "MMD.label": "MMDツール", "MMD.bone_standardization": "ボーン標準化", @@ -380,6 +397,26 @@ "MergeArmature.cleanup_shape_keys": "シェイプキーをクリーン", "MergeArmature.cleanup_shape_keys_desc": "未使用のシェイプキーを削除", + "TextureAtlas.atlas_completed": "テクスチャアトラスの作成が完了しました", + "TextureAtlas.atlas_error": "テクスチャアトラスの作成中にエラーが発生しました", + "TextureAtlas.atlas_materials": "マテリアルをアトラス化", + "TextureAtlas.atlas_materials_desc": "モデルを最適化するためにマテリアルをアトラス化", + "TextureAtlas.label": "テクスチャアトラス化", + "TextureAtlas.loaded_list": "テクスチャアトラスマテリアルリストを読み込み済み", + "TextureAtlas.material_list_label": "テクスチャアトラスマテリアルリスト", + "TextureAtlas.reload_list": "テクスチャアトラスマテリアルリストを再読み込み", + "TextureAtlas.error.label": "エラー", + "TextureAtlas.none.label": "なし", + "TextureAtlas.no_nodes_error.desc": "このマテリアルはノードを使用していません!", + "TextureAtlas.no_images_error.desc": "このマテリアルには画像がありません!", + "TextureAtlas.texture_use_atlas.desc": "{name}マップアトラスに使用されるテクスチャ", + "TextureAtlas.albedo": "アルベド", + "TextureAtlas.normal": "法線", + "TextureAtlas.emission": "発光", + "TextureAtlas.ambient_occlusion": "アンビエントオクルージョン", + "TextureAtlas.height": "高さ", + "TextureAtlas.roughness": "ラフネス", + "Settings.label": "設定", "Settings.language": "言語", "Settings.language_desc": "インターフェース言語を選択", diff --git a/resources/translations/ko_KR.json b/resources/translations/ko_KR.json index 28ce1ed..66cc2a6 100644 --- a/resources/translations/ko_KR.json +++ b/resources/translations/ko_KR.json @@ -149,6 +149,19 @@ "Tools.merge_twist_bones_desc": "체크하면 가중치가 0이어도 트위스트 본 유지", "Tools.clean_weights": "0 가중치 본 제거", "Tools.clean_weights_desc": "버텍스 가중치가 없는 본 제거", + "Tools.preserve_parent_bones": "부모 본 보존", + "Tools.preserve_parent_bones_desc": "가중치가 없어도 자식 본이 있는 본 유지", + "Tools.target_bone_type": "대상 본 유형", + "Tools.target_bone_type_desc": "처리할 본 유형 필터링", + "Tools.target_all_bones": "모든 본", + "Tools.target_deform_bones": "변형 본만", + "Tools.target_non_deform_bones": "비변형 본만", + "Tools.list_only_mode": "목록 모드만", + "Tools.list_only_mode_desc": "제로 가중치 본을 제거하지 않고 목록으로 표시", + "Tools.zero_weight_bones_found": "제로 가중치 본 발견: {bones}", + "Tools.remove_selected_bones": "선택한 본 제거", + "Tools.remove_selected_bones_desc": "선택한 제로 가중치 본을 아마추어에서 제거", + "Tools.bones_removed": "{count}개의 본이 제거되었습니다", "Tools.clean_constraints": "본 제약 조건 삭제", "Tools.clean_constraints_desc": "아마추어에서 모든 본 제약 조건 제거", "Tools.clean_constraints_success": "{count}개의 본 제약 조건 제거됨", @@ -187,6 +200,10 @@ "Tools.shapekey_tolerance": "쉐이프 키 허용 오차", "Tools.shapekey_tolerance_desc": "쉐이프 키를 사용된 것으로 간주할 최소 차이", "Tools.shapekeys_removed": "{count}개의 미사용 쉐이프 키 제거됨", + "Tools.convert_rigify_to_unity": "Rigify를 Unity로 변환", + "Tools.convert_rigify_to_unity_desc": "Rigify 아마추어를 Unity 호환 형식으로 변환", + "Tools.rigify_converted": "Rigify 아마추어 변환 완료", + "Tools.no_armature": "아마추어가 선택되지 않았습니다", "MMD.label": "MMD 도구", "MMD.bone_standardization": "본 표준화", @@ -379,6 +396,26 @@ "MergeArmature.remove_zero_weights_desc": "가중치가 없는 버텍스 그룹 제거", "MergeArmature.cleanup_shape_keys": "쉐이프 키 정리", "MergeArmature.cleanup_shape_keys_desc": "미사용 쉐이프 키 제거", + + "TextureAtlas.atlas_completed": "텍스처 아틀라스 생성이 완료되었습니다", + "TextureAtlas.atlas_error": "텍스처 아틀라스 생성 중 오류가 발생했습니다", + "TextureAtlas.atlas_materials": "재질 아틀라스화", + "TextureAtlas.atlas_materials_desc": "모델을 최적화하기 위해 재질을 아틀라스화", + "TextureAtlas.label": "텍스처 아틀라스화", + "TextureAtlas.loaded_list": "텍스처 아틀라스 재질 목록 로드됨", + "TextureAtlas.material_list_label": "텍스처 아틀라스 재질 목록", + "TextureAtlas.reload_list": "텍스처 아틀라스 재질 목록 새로고침", + "TextureAtlas.error.label": "오류", + "TextureAtlas.none.label": "없음", + "TextureAtlas.no_nodes_error.desc": "이 재질은 노드를 사용하지 않습니다!", + "TextureAtlas.no_images_error.desc": "이 재질에는 이미지가 없습니다!", + "TextureAtlas.texture_use_atlas.desc": "{name} 맵 아틀라스에 사용될 텍스처", + "TextureAtlas.albedo": "알베도", + "TextureAtlas.normal": "노말", + "TextureAtlas.emission": "이미션", + "TextureAtlas.ambient_occlusion": "앰비언트 오클루전", + "TextureAtlas.height": "높이", + "TextureAtlas.roughness": "거칠기", "Settings.label": "설정", "Settings.language": "언어", diff --git a/ui/atlas_materials_panel.py b/ui/atlas_materials_panel.py new file mode 100644 index 0000000..2d9b70c --- /dev/null +++ b/ui/atlas_materials_panel.py @@ -0,0 +1,188 @@ +from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operator +import bpy +from math import sqrt +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.common import SceneMatClass, MaterialListBool, get_active_armature +from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials +from ..core.translations import t + +class AvatarToolKit_OT_SelectAllMaterials(Operator): + bl_idname = 'avatar_toolkit.select_all_materials' + bl_label = "Select All" + bl_description = "Select all materials for atlas" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.include_in_atlas = True + return {'FINISHED'} + +class AvatarToolKit_OT_SelectNoneMaterials(Operator): + bl_idname = 'avatar_toolkit.select_none_materials' + bl_label = "Select None" + bl_description = "Deselect all materials" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.include_in_atlas = False + return {'FINISHED'} + +class AvatarToolKit_OT_ExpandAllMaterials(Operator): + bl_idname = 'avatar_toolkit.expand_all_materials' + bl_label = "Expand All" + bl_description = "Expand all material settings" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.material_expanded = True + return {'FINISHED'} + +class AvatarToolKit_OT_CollapseAllMaterials(Operator): + bl_idname = 'avatar_toolkit.collapse_all_materials' + bl_label = "Collapse All" + bl_description = "Collapse all material settings" + + def execute(self, context): + for item in context.scene.avatar_toolkit.materials: + item.mat.material_expanded = False + return {'FINISHED'} + +class AvatarToolKit_OT_ExpandSectionMaterials(Operator): + bl_idname = 'avatar_toolkit.expand_section_materials' + bl_label = "" + bl_description = "" + + @classmethod + def poll(cls, context: Context) -> bool: + return True + + def execute(self, context: Context) -> set: + if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + context.scene.avatar_toolkit.materials.clear() + newlist: list[Material] = [] + for obj in context.scene.objects: + if len(obj.material_slots) > 0: + for mat_slot in obj.material_slots: + if mat_slot.material: + if mat_slot.material not in newlist: + newlist.append(mat_slot.material) + newitem: SceneMatClass = context.scene.avatar_toolkit.materials.add() + newitem.mat = mat_slot.material + MaterialListBool.old_list[context.scene.name] = newlist + context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = True + else: + context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False + return {'FINISHED'} + +class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): + bl_label = t("TextureAtlas.material_list_label") + bl_idname = "Material_UL_avatar_toolkit_texture_atlas_mat_list_mat" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + + def draw_header(self, context): + layout = self.layout + row = layout.row(align=True) + + row.operator("avatar_toolkit.select_all_materials", text="", icon='CHECKBOX_HLT') + row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT') + row.operator("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN') + row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT') + row.prop(context.scene.avatar_toolkit, "material_search_filter", text="", icon='VIEWZOOM') + + box = layout.box() + row = box.row() + row.label(text=f"Estimated Atlas Size: {self.calculate_atlas_size(context)}px") + + def draw_item(self, context: Context, layout: UILayout, data: Object, item: SceneMatClass, icon, active_data, active_propname, index): + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + if (context.scene.avatar_toolkit.material_search_filter and + context.scene.avatar_toolkit.material_search_filter.lower() not in item.mat.name.lower()): + return + + row = layout.row() + + row.prop(item.mat, "include_in_atlas", text="", icon='CHECKBOX_HLT' if item.mat.include_in_atlas else 'CHECKBOX_DEHLT') + + row.prop(item.mat, "material_expanded", + text=item.mat.name, + icon='DOWNARROW_HLT' if item.mat.material_expanded else 'RIGHTARROW', + emboss=False) + + if item.mat.material_expanded and item.mat.include_in_atlas: + box = layout.box() + col = box.column(align=True) + self.draw_texture_row(col, item.mat, "texture_atlas_albedo", "IMAGE_RGB") + self.draw_texture_row(col, item.mat, "texture_atlas_normal", "NORMALS_FACE") + self.draw_texture_row(col, item.mat, "texture_atlas_emission", "LIGHT") + self.draw_texture_row(col, item.mat, "texture_atlas_ambient_occlusion", "SHADING_SOLID") + self.draw_texture_row(col, item.mat, "texture_atlas_height", "IMAGE_ZDEPTH") + self.draw_texture_row(col, item.mat, "texture_atlas_roughness", "MATERIAL") + + col.separator(factor=0.5) + + def draw_texture_row(self, layout, material, prop_name, icon): + row = layout.row() + row.prop(material, prop_name, icon=icon) + if getattr(material, prop_name): + row.label(text="", icon='CHECKMARK') + else: + row.label(text="", icon='X') + + def is_material_ready(self, material): + return bool(material.texture_atlas_albedo or + material.texture_atlas_normal or + material.texture_atlas_emission) + + def calculate_atlas_size(self, context): + total_size = 0 + for mat in context.scene.avatar_toolkit.materials: + if mat.mat.include_in_atlas: + if mat.mat.texture_atlas_albedo: + img = bpy.data.images[mat.mat.texture_atlas_albedo] + total_size += img.size[0] * img.size[1] + return f"{int(sqrt(total_size))}x{int(sqrt(total_size))}" + +class AvatarToolKit_PT_TextureAtlasPanel(Panel): + bl_label = t("TextureAtlas.label") + bl_idname = "OBJECT_PT_avatar_toolkit_texture_atlas" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 6 + + def draw(self, context: Context): + layout = self.layout + armature = get_active_armature(context) + + if armature: + layout.label(text=t("TextureAtlas.label"), icon='TEXTURE') + layout.separator(factor=0.5) + + box = layout.box() + row = box.row() + direction_icon = 'RIGHTARROW' if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT' + row.operator(AvatarToolKit_OT_ExpandSectionMaterials.bl_idname, + text=(t("TextureAtlas.reload_list") if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")), + icon=direction_icon) + + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + row = box.row() + row.template_list(AvatarToolKit_UL_MaterialTextureAtlasProperties.bl_idname, + 'material_list', + context.scene.avatar_toolkit, + 'materials', + context.scene.avatar_toolkit, + 'texture_atlas_material_index', + rows=12, + type='DEFAULT') + + layout.separator(factor=1.0) + + row = layout.row() + row.scale_y = 1.5 + row.operator(AvatarToolKit_OT_AtlasMaterials.bl_idname, + text=t("TextureAtlas.atlas_materials"), + icon='NODE_TEXTURE') + else: + layout.label(text=t("Tools.select_armature"), icon='ERROR') diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py index 1db821f..4e3ff62 100644 --- a/ui/custom_avatar_panel.py +++ b/ui/custom_avatar_panel.py @@ -6,9 +6,9 @@ from ..core.translations import t from ..core.common import ( get_active_armature, get_all_meshes, - validate_armature, get_armature_list ) +from ..core.armature_validation import validate_armature class AvatarToolkit_OT_SearchMergeArmatureInto(Operator): """Search operator for selecting target armature to merge into""" @@ -155,7 +155,6 @@ class AvatarToolKit_PT_CustomPanel(Panel): # Group related options together transform_col: UILayout = col.column(align=True) - transform_col.prop(toolkit, "merge_all_bones") transform_col.prop(toolkit, "apply_transforms") col.separator(factor=0.5) diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py index 1178bc9..cf7b6c4 100644 --- a/ui/eye_tracking_panel.py +++ b/ui/eye_tracking_panel.py @@ -43,6 +43,16 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): row.prop(toolkit, "eye_tracking_type", expand=True) if toolkit.eye_tracking_type == 'SDK2': + # SDK2 Warning Box + warning_box: UILayout = layout.box() + col: UILayout = warning_box.column(align=True) + col.label(text=t("EyeTracking.sdk2_warning"), icon='INFO') + col.separator(factor=0.5) + col.label(text=t("EyeTracking.sdk2_warning_detail1")) + col.label(text=t("EyeTracking.sdk2_warning_detail2")) + col.label(text=t("EyeTracking.sdk2_warning_detail3")) + col.label(text=t("EyeTracking.sdk2_warning_detail4")) + # Mode Selection Box mode_box: UILayout = layout.box() col: UILayout = mode_box.column(align=True) diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py deleted file mode 100644 index d7333dc..0000000 --- a/ui/mmd_panel.py +++ /dev/null @@ -1,49 +0,0 @@ -# MMD Tools disabled for the time being unto it can be fixed. - -# import bpy -# from typing import Set -# from bpy.types import Panel, Context, UILayout -# from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -# from ..core.translations import t - -# class AvatarToolKit_PT_MMDPanel(Panel): -# """Panel containing MMD bone standardization and cleanup tools""" -# bl_label = t("MMD.label") -# bl_idname = "OBJECT_PT_avatar_toolkit_mmd" -# 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: -# layout: UILayout = self.layout -# toolkit = context.scene.avatar_toolkit - - # Bone Settings Box -# bone_box: UILayout = layout.box() -# col: UILayout = bone_box.column(align=True) -# col.label(text=t("MMD.bone_settings"), icon='BONE_DATA') -# col.separator(factor=0.5) -# col.prop(toolkit, "keep_twist_bones") -# col.prop(toolkit, "keep_upper_chest") -# col.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') - - # Mesh Tools Box -# mesh_box: UILayout = layout.box() -# col = mesh_box.column(align=True) -# col.label(text=t("MMD.mesh_tools"), icon='MESH_DATA') -# col.separator(factor=0.5) -# row: UILayout = col.row(align=True) -# row.operator("avatar_toolkit.fix_meshes", icon='MODIFIER') -# row.operator("avatar_toolkit.validate_meshes", icon='CHECKMARK') - - # Cleanup Box -# cleanup_box: UILayout = layout.box() -# col = cleanup_box.column(align=True) -# col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') -# col.separator(factor=0.5) -# col.operator("avatar_toolkit.cleanup_mmd", icon='SHADERFX') -# col.operator("avatar_toolkit.convert_mmd_morphs", icon='SHAPEKEY_DATA') -# col.operator("avatar_toolkit.reparent_meshes", icon='OUTLINER_OB_ARMATURE') diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 534d40d..59fbd6d 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -14,7 +14,6 @@ from ..core.translations import t from ..core.common import ( get_active_armature, clear_default_objects, - validate_armature, get_armature_list, get_armature_stats ) @@ -24,6 +23,7 @@ from ..functions.pose_mode import ( AvatarToolkit_OT_ApplyPoseAsShapekey, AvatarToolkit_OT_ApplyPoseAsRest ) +from ..core.armature_validation import validate_armature class AvatarToolKit_OT_ExportFBX(Operator): """Export selected objects as FBX""" @@ -70,6 +70,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): def draw(self, context: Context) -> None: """Draw the panel layout""" layout: UILayout = self.layout + props = context.scene.avatar_toolkit # Armature Selection Box armature_box: UILayout = layout.box() @@ -83,28 +84,134 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Armature Validation active_armature: Optional[Object] = get_active_armature(context) if active_armature: - is_valid: bool - messages: List[str] - is_valid, messages = validate_armature(active_armature) + is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True) - # Create info box for all validation information - info_box: UILayout = col.box() + info_box = col.box() - if is_valid: - row: UILayout = info_box.row() - split: UILayout = row.split(factor=0.6) + if not is_valid: + # Display non-standard bones and hierarchy issues + if len(messages) > 1: + # Found Bones section + validation_box = info_box.box() + row = validation_box.row() + row.prop(props, "show_found_bones", text=t("Validation.section.found_bones"), icon='TRIA_DOWN' if props.show_found_bones else 'TRIA_RIGHT', emboss=False) + if props.show_found_bones: + for line in messages[0].split('\n'): + validation_box.label(text=line) + + # Main validation status + validation_box = info_box.box() + row = validation_box.row() + row.alert = True + row.label(text=t("Validation.status.failed")) + + # Detailed validation message + validation_box = info_box.box() + row = validation_box.row() + row.alert = True + row.label(text=t("Validation.message.failed.line1")) + row = validation_box.row() + row.alert = True + row.label(text=t("Validation.message.failed.line2")) + row = validation_box.row() + row.alert = True + row.label(text=t("Validation.message.failed.line3")) + + # Non-Standard Bones section + validation_box = info_box.box() + row = validation_box.row() + row.alert = True + row.prop(props, "show_non_standard", text=t("Validation.section.non_standard"), + icon='TRIA_DOWN' if props.show_non_standard else 'TRIA_RIGHT', emboss=False) + if props.show_non_standard: + if non_standard_messages: + for message in non_standard_messages: + for line in message.split('\n'): + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=line) + else: + sub_row = validation_box.row() + sub_row.label(text=t("Validation.no_non_standard_issues")) + + # Hierarchy Issues section + validation_box = info_box.box() + row = validation_box.row() + row.alert = True + row.prop(props, "show_hierarchy", text=t("Validation.section.hierarchy"), + icon='TRIA_DOWN' if props.show_hierarchy else 'TRIA_RIGHT', emboss=False) + if props.show_hierarchy: + if hierarchy_messages: + for message in hierarchy_messages: + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=message) + else: + sub_row = validation_box.row() + sub_row.label(text=t("Validation.no_hierarchy_issues")) + + # Scale Issues section + validation_box = info_box.box() + row = validation_box.row() + row.alert = True + row.prop(props, "show_scale_issues", text=t("Validation.section.scale_issues"), + icon='TRIA_DOWN' if props.show_scale_issues else 'TRIA_RIGHT', emboss=False) + if props.show_scale_issues: + if scale_messages: + for scale_msg in scale_messages: + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=scale_msg) + else: + sub_row = validation_box.row() + sub_row.label(text=t("Validation.no_scale_issues")) + + pose_box = layout.box() + col = pose_box.column(align=True) + col.label(text=t("Validation.tpose.label"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.validate_tpose", icon='CHECKMARK') + + if props.show_tpose_validation: + validation_box = col.box() + if props.tpose_validation_result: + validation_box.label(text=t("Validation.tpose.valid"), icon='CHECKMARK') + else: + row = validation_box.row() + row.alert = True + row.label(text=t("Validation.tpose.warning"), icon='ERROR') + + for msg in props.tpose_validation_messages: + row = validation_box.row() + row.alert = True + row.label(text=msg.name) + else: + # If no specific issues, show acceptable message + info_box.label(text=messages[0], icon='INFO') + info_box.label(text=messages[1]) + info_box.label(text=messages[2]) + elif is_valid and not is_acceptable: + row = info_box.row() + split = row.split(factor=0.6) split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK') - stats: Dict[str, int] = get_armature_stats(active_armature) + stats = get_armature_stats(active_armature) split.label(text=t("QuickAccess.bones_count", count=stats['bone_count'])) if stats['has_pose']: info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') - else: - # Display validation failure messages - for message in messages: - info_box.label(text=message, icon='ERROR') + elif is_valid and is_acceptable: + # Show acceptable standard message + info_box.label(text=messages[0], icon='INFO') + info_box.label(text=messages[1]) + info_box.label(text=messages[2]) + + # Add standardize button + standardize_box = info_box.box() + standardize_box.operator("avatar_toolkit.standardize_armature", + text=t("QuickAccess.standardize_armature"), + icon='MODIFIER') - # Validation Mode Warnings - always show in info box + # Validation Mode Warnings validation_mode = context.scene.avatar_toolkit.validation_mode if validation_mode == 'BASIC': warning_row = info_box.box() diff --git a/ui/settings_panel.py b/ui/settings_panel.py index ed32263..d0bd2f3 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -42,6 +42,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection""" layout: UILayout = self.layout + props = context.scene.avatar_toolkit # Language Settings lang_box: UILayout = layout.box() @@ -50,7 +51,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): row.scale_y = 1.2 row.label(text=t("Settings.language"), icon='WORLD') col.separator() - col.prop(context.scene.avatar_toolkit, "language", text="") + col.prop(props, "language", text="") # Validation Settings val_box: UILayout = layout.box() @@ -59,18 +60,31 @@ class AvatarToolKit_PT_SettingsPanel(Panel): row.scale_y = 1.2 row.label(text=t("Settings.validation_mode"), icon='CHECKMARK') col.separator() - col.prop(context.scene.avatar_toolkit, "validation_mode", text="") + col.prop(props, "validation_mode", text="") + + # Bone Highlighting Settings + bone_box: UILayout = layout.box() + col = bone_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=t("Settings.bone_highlighting"), icon='BONE_DATA') + col.separator() + col.prop(props, "highlight_problem_bones") + if props.highlight_problem_bones: + col.operator("avatar_toolkit.highlight_problem_bones", icon='COLOR') + else: + col.operator("avatar_toolkit.clear_bone_highlighting", icon='X') # Debug Settings debug_box = layout.box() col = debug_box.column() row = col.row(align=True) - row.prop(context.scene.avatar_toolkit, "debug_expand", - icon="TRIA_DOWN" if context.scene.avatar_toolkit.debug_expand + row.prop(props, "debug_expand", + icon="TRIA_DOWN" if props.debug_expand else "TRIA_RIGHT", icon_only=True, emboss=False) row.label(text=t("Settings.debug"), icon='CONSOLE') - if context.scene.avatar_toolkit.debug_expand: + if props.debug_expand: col = debug_box.column(align=True) - col.prop(context.scene.avatar_toolkit, "enable_logging") + col.prop(props, "enable_logging") diff --git a/ui/tools_panel.py b/ui/tools_panel.py index ce0614a..7fbfe71 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -1,9 +1,21 @@ import bpy from typing import Set -from bpy.types import Panel, Context, UILayout, Operator +from bpy.types import Panel, Context, UILayout, Operator, UIList from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t +class AVATAR_TOOLKIT_UL_ZeroWeightBones(UIList): + """UI List for displaying zero weight bones with selection options""" + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if self.layout_type in {'DEFAULT', 'COMPACT'}: + row = layout.row(align=True) + row.prop(item, "selected", text="") + row.label(text=item.name) + if item.has_children: + row.label(text="", icon='OUTLINER_OB_ARMATURE') + if item.is_deform: + row.label(text="", icon='MOD_ARMATURE') + class AvatarToolKit_PT_ToolsPanel(Panel): """Panel containing various tools for avatar customization and optimization""" bl_label: str = t("Tools.label") @@ -18,6 +30,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): def draw(self, context: Context) -> None: """Draw the tools panel interface""" layout: UILayout = self.layout + toolkit = context.scene.avatar_toolkit # General Tools tools_box: UILayout = layout.box() @@ -42,10 +55,32 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.separator(factor=0.5) col.operator("avatar_toolkit.create_digitigrade", text=t("Tools.create_digitigrade"), icon='BONE_DATA') + # Standardization Tools + standardize_box: UILayout = bone_box.box() + col = standardize_box.column(align=True) + col.label(text=t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE') + col.separator(factor=0.5) + col.operator("avatar_toolkit.standardize_armature", icon='CHECKMARK') + # Weight Tools weight_box: UILayout = bone_box.box() col = weight_box.column(align=True) - col.prop(context.scene.avatar_toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones")) + col.prop(toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones")) + col.prop(toolkit, "preserve_parent_bones") + col.prop(toolkit, "target_bone_type") + col.prop(toolkit, "list_only_mode") + + if toolkit.list_only_mode and len(toolkit.zero_weight_bones) > 0: + box = weight_box.box() + row = box.row() + row.template_list("AVATAR_TOOLKIT_UL_ZeroWeightBones", "", + toolkit, "zero_weight_bones", + toolkit, "zero_weight_bones_index") + + col = box.column(align=True) + col.operator("avatar_toolkit.remove_selected_bones", + text=t("Tools.remove_selected_bones")) + row = col.row(align=True) row.operator("avatar_toolkit.clean_weights", text=t("Tools.clean_weights"), icon='GROUP_BONE') row.operator("avatar_toolkit.clean_constraints", text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') @@ -67,3 +102,11 @@ class AvatarToolKit_PT_ToolsPanel(Panel): col.separator(factor=0.5) col.operator("avatar_toolkit.apply_transforms", text=t("Tools.apply_transforms"), icon='OBJECT_DATA') col.operator("avatar_toolkit.clean_shapekeys", text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA') + + # Rigify Tools + rigify_box: UILayout = layout.box() + col = rigify_box.column(align=True) + col.label(text=t("Tools.rigify_title"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.convert_rigify_to_unity", icon='ARMATURE_DATA') + col.prop(context.scene.avatar_toolkit, "merge_twist_bones") diff --git a/ui/uv_panel.py b/ui/uv_panel.py new file mode 100644 index 0000000..d5499b6 --- /dev/null +++ b/ui/uv_panel.py @@ -0,0 +1,21 @@ +import bpy +from bpy.types import Panel, Context, UILayout +from ..core.translations import t + +class AvatarToolKit_PT_UVPanel(Panel): + """Main UV Tools panel for Avatar Toolkit""" + bl_label = t("AvatarToolkit.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv_main" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + + # Add title section + box: UILayout = layout.box() + col: UILayout = box.column(align=True) + row: UILayout = col.row() + row.scale_y = 1.2 + row.label(text=t("AvatarToolkit.label"), icon='UV') diff --git a/ui/uv_tools.py b/ui/uv_tools.py new file mode 100644 index 0000000..ecb258e --- /dev/null +++ b/ui/uv_tools.py @@ -0,0 +1,27 @@ +import bpy +from bpy.types import Panel, Context, UILayout +from ..core.translations import t + +class AvatarToolKit_PT_UVTools(Panel): + """UV Tools panel containing UV manipulation operators""" + bl_label = t("Tools.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + bl_parent_id = "OBJECT_PT_avatar_toolkit_uv_main" + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + + tools_box: UILayout = layout.box() + col: UILayout = tools_box.column(align=True) + col.label(text=t("Tools.uv_title"), icon='UV') + col.separator(factor=0.5) + + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.align_uv_edges_to_target", + text=t("UVTools.align_edges"), + icon='GP_MULTIFRAME_EDITING') diff --git a/ui/visemes_panel.py b/ui/visemes_panel.py index 5de266f..0ebb472 100644 --- a/ui/visemes_panel.py +++ b/ui/visemes_panel.py @@ -28,13 +28,13 @@ class AvatarToolKit_PT_VisemesPanel(Panel): armature = get_active_armature(context) if armature: - col.prop_search(props, "viseme_mesh", bpy.data, "objects", text="") + col.prop(props, "viseme_mesh", text="") else: col.label(text=t("Visemes.no_armature"), icon='ERROR') # Get selected mesh mesh_obj = bpy.data.objects.get(props.viseme_mesh) - if not mesh_obj or not mesh_obj.data.shape_keys: + if not mesh_obj or not mesh_obj.data or not mesh_obj.data.shape_keys: layout.label(text=t("Visemes.no_shapekeys")) return diff --git a/wheels/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl b/wheels/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl new file mode 100644 index 0000000..992aee7 Binary files /dev/null and b/wheels/lz4-4.3.3-cp311-cp311-macosx_10_9_x86_64.whl differ