diff --git a/core/common.py b/core/common.py index 14121fe..ee3b694 100644 --- a/core/common.py +++ b/core/common.py @@ -1,10 +1,48 @@ import bpy import numpy as np -from bpy.types import Context, Object -from typing import Optional, Tuple, List, Set +import logging +from bpy.types import Context, Object, Modifier +from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.translations import t from ..core.dictionaries import bone_names +logger = logging.getLogger('avatar_toolkit') +logger.setLevel(logging.DEBUG) + +def setup_logging() -> None: + """Configure logging for Avatar Toolkit""" + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + +class ProgressTracker: + """Universal progress tracking for Avatar Toolkit operations""" + + def __init__(self, context: Context, total_steps: int, operation_name: str = "Operation"): + self.context = context + self.total = total_steps + self.current = 0 + self.operation_name = operation_name + self.wm = context.window_manager + + def step(self, message: str = "") -> None: + """Update progress by one step""" + self.current += 1 + progress = self.current / self.total + self.wm.progress_begin(0, 100) + self.wm.progress_update(progress * 100) + logger.debug(f"{self.operation_name} - {progress:.1%}: {message}") + + def __enter__(self): + logger.info(f"Starting {self.operation_name}") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.wm.progress_end() + logger.info(f"Completed {self.operation_name}") + def get_active_armature(context: bpy.types.Context) -> Optional[bpy.types.Object]: """Get the currently selected armature from Avatar Toolkit properties""" armature_name = context.scene.avatar_toolkit.active_armature @@ -25,27 +63,95 @@ def get_armature_list(self=None, context: bpy.types.Context = None) -> List[Tupl return [('NONE', t("Armature.validation.no_armature"), '')] return armatures -def validate_armature(armature: bpy.types.Object) -> Tuple[bool, str]: - """ - Validate if the selected object is a proper armature and has required bones - Returns tuple of (is_valid, message) - """ - if not armature: - return False, t("Armature.validation.no_armature") - if armature.type != 'ARMATURE': - return False, t("Armature.validation.not_armature") - if not armature.data.bones: - return False, t("Armature.validation.no_bones") - - essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'} - found_bones: Set[str] = {bone.name.lower() for bone in armature.data.bones} +def validate_armature(armature: bpy.types.Object, validation_level: str = 'standard') -> Tuple[bool, List[str]]: + """Enhanced armature validation with multiple checks and validation levels""" + messages = [] + # Basic checks + if not armature or armature.type != 'ARMATURE' or not armature.data.bones: + return False, [t("Armature.validation.basic_check_failed")] + + found_bones = {bone.name.lower(): bone for bone in armature.data.bones} + + # Essential bones check + essential_bones = {'hips', 'spine', 'chest', 'neck', 'head'} + missing_bones = [] for bone in essential_bones: if not any(alt_name in found_bones for alt_name in bone_names[bone]): - return False, t("Armature.validation.missing_bone", bone=bone) - - return True, t("QuickAccess.valid_armature") + missing_bones.append(bone) + + if missing_bones: + messages.append(t("Armature.validation.missing_bones", bones=", ".join(missing_bones))) + + if validation_level in ['standard', 'strict']: + # Hierarchy validation + hierarchy = [('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 validation + symmetry_pairs = [('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)) + + # Special handling for hand/wrist symmetry + 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 = len(messages) == 0 + return is_valid, messages +def validate_bone_hierarchy(bones: Dict[str, bpy.types.Bone], parent_name: str, child_name: str) -> bool: + """Validate if there is a valid parent-child relationship between bones""" + # Find matching parent and child bones using bone_names dictionary + parent_bone = None + child_bone = None + + # Check for parent bone matches + for alt_name in bone_names[parent_name]: + if alt_name in bones: + parent_bone = bones[alt_name] + break + + # Check for child bone matches + 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 + + # Check if child's parent matches parent bone + return child_bone.parent == parent_bone + +def validate_symmetry(bones: Dict[str, bpy.types.Bone], base: str, left: str, right: str) -> bool: + """ + Validate if matching left and right bones exist for a given base bone name + """ + # Define common naming patterns + left_patterns = [ + f"{base}.{left}", + f"{base}_{left}", + f"{left}_{base}" + ] + + right_patterns = [ + f"{base}.{right}", + f"{base}_{right}", + f"{right}_{base}" + ] + + # Check if any of the patterns exist in the bones dictionary + left_exists = any(pattern in bones for pattern in left_patterns) + right_exists = any(pattern in bones for pattern in right_patterns) + + return left_exists and right_exists + + def auto_select_single_armature(context: bpy.types.Context) -> None: """Automatically select armature if only one exists in scene""" armatures = get_armature_list(context) @@ -69,33 +175,79 @@ def get_armature_stats(armature: bpy.types.Object) -> dict: } def get_all_meshes(context: Context) -> List[Object]: + """Get all mesh objects parented to the active armature""" armature = get_active_armature(context) if armature: return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] return [] -def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> bool: - for mesh_obj in meshes: - if not mesh_obj.data: - continue - if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks: - if len(mesh_obj.data.shape_keys.key_blocks) == 1: - basis = mesh_obj.data.shape_keys.key_blocks[0] - basis_name = basis.name - mesh_obj.shape_key_remove(basis) - apply_armature_to_mesh(armature_obj, mesh_obj) - mesh_obj.shape_key_add(name=basis_name) - else: - apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context) - else: - apply_armature_to_mesh(armature_obj, mesh_obj) +def validate_mesh_for_pose(mesh_obj: Object) -> Tuple[bool, str]: + """Validate mesh object for pose operations""" + if not mesh_obj.data: + return False, t("Mesh.validation.no_data") + + if not mesh_obj.vertex_groups: + return False, t("Mesh.validation.no_vertex_groups") + + armature_mods = [mod for mod in mesh_obj.modifiers if mod.type == 'ARMATURE'] + if not armature_mods: + return False, t("Mesh.validation.no_armature_modifier") + + return True, t("Mesh.validation.valid") + +def cache_vertex_positions(mesh_obj: Object) -> np.ndarray: + """Cache vertex positions for a mesh object""" + vertices = mesh_obj.data.vertices + positions = np.empty(len(vertices) * 3, dtype=np.float32) + vertices.foreach_get('co', positions) + return positions.reshape(-1, 3) + +def apply_vertex_positions(vertices: Object, positions: np.ndarray) -> None: + """Apply cached vertex positions to mesh in batch""" + vertices.foreach_set('co', positions.flatten()) + +def process_armature_modifiers(mesh_obj: Object) -> List[Dict[str, Any]]: + """Process and store armature modifier states""" + modifier_states = [] + for mod in mesh_obj.modifiers: + if mod.type == 'ARMATURE': + modifier_states.append({ + 'name': mod.name, + 'object': mod.object, + 'vertex_group': mod.vertex_group, + 'show_viewport': mod.show_viewport + }) + return modifier_states + +def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> Tuple[bool, str]: + """Apply current pose as rest pose for armature and update meshes""" + try: + logger.info(f"Starting pose application for {len(meshes)} meshes") + + with ProgressTracker(context, len(meshes), "Applying Pose") as progress: + for mesh_obj in meshes: + if not mesh_obj.data: + continue + + if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks: + apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context) + else: + apply_armature_to_mesh(armature_obj, mesh_obj) + + progress.step(f"Processed {mesh_obj.name}") - bpy.ops.object.mode_set(mode='POSE') - bpy.ops.pose.armature_apply(selected=False) - bpy.ops.object.mode_set(mode='OBJECT') - return True + bpy.ops.object.mode_set(mode='POSE') + bpy.ops.pose.armature_apply(selected=False) + bpy.ops.object.mode_set(mode='OBJECT') + + return True, t("Operation.pose_applied") + + except Exception as e: + logger.error(f"Error applying pose as rest: {str(e)}") + return False, str(e) def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: + """Apply armature deformation to mesh""" armature_mod = mesh_obj.modifiers.new('PoseToRest', 'ARMATURE') armature_mod.object = armature_obj @@ -109,6 +261,7 @@ def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: bpy.ops.object.modifier_apply(modifier=armature_mod.name) def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object, context: Context) -> None: + """Apply armature deformation to mesh with shape keys""" old_active_index = mesh_obj.active_shape_key_index old_show_only = mesh_obj.show_only_shape_key mesh_obj.show_only_shape_key = True @@ -156,4 +309,4 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object sk.mute = mute mesh_obj.active_shape_key_index = old_active_index - mesh_obj.show_only_shape_key = old_show_only + mesh_obj.show_only_shape_key = old_show_only \ No newline at end of file diff --git a/functions/pose_mode.py b/functions/pose_mode.py index 27aa135..5c3dc50 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -1,120 +1,168 @@ import bpy -import numpy as np -from bpy.types import Operator, Context, Object -from typing import List +import logging +from typing import Set, Dict, List, Tuple, Optional, Any +from bpy.props import StringProperty +from bpy.types import Operator, Context, Object, Event, Modifier from ..core.translations import t from ..core.common import ( get_active_armature, get_all_meshes, apply_pose_as_rest, - apply_armature_to_mesh, - apply_armature_to_mesh_with_shapekeys, - validate_armature + validate_armature, + cache_vertex_positions, + apply_vertex_positions, + validate_mesh_for_pose, + process_armature_modifiers, + ProgressTracker ) +logger = logging.getLogger('avatar_toolkit.pose') + +class BatchPoseOperationMixin: + """Base class for batch pose operations""" + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid and context.mode == 'POSE' + + def validate_meshes(self, meshes: List[Object]) -> List[Tuple[Object, str]]: + """Validate meshes for pose operations""" + invalid_meshes = [] + for mesh in meshes: + valid, message = validate_mesh_for_pose(mesh) + if not valid: + invalid_meshes.append((mesh, message)) + return invalid_meshes + class AvatarToolkit_OT_StartPoseMode(Operator): bl_idname = 'avatar_toolkit.start_pose_mode' - bl_label = t("Quick_Access.start_pose_mode.label") - bl_description = t("Quick_Access.start_pose_mode.desc") + bl_label = t("QuickAccess.start_pose_mode.label") + bl_description = t("QuickAccess.start_pose_mode.desc") bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: armature = get_active_armature(context) if not armature or context.mode == "POSE": return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context: Context) -> set[str]: - armature = get_active_armature(context) - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - bpy.ops.object.mode_set(mode='POSE') - return {'FINISHED'} + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + logger.info(f"Starting pose mode for armature: {armature.name}") + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + context.view_layer.objects.active = armature + armature.select_set(True) + bpy.ops.object.mode_set(mode='POSE') + + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to start pose mode: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.start", error=str(e))) + return {'CANCELLED'} class AvatarToolkit_OT_StopPoseMode(Operator): bl_idname = 'avatar_toolkit.stop_pose_mode' - bl_label = t("Quick_Access.stop_pose_mode.label") - bl_description = t("Quick_Access.stop_pose_mode.desc") + bl_label = t("QuickAccess.stop_pose_mode.label") + bl_description = t("QuickAccess.stop_pose_mode.desc") bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: return get_active_armature(context) and context.mode == "POSE" - - def execute(self, context: Context) -> set[str]: - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - bpy.ops.object.mode_set(mode='OBJECT') - return {'FINISHED'} -class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' - bl_label = t("Quick_Access.apply_pose_as_shapekey.label") - bl_description = t("Quick_Access.apply_pose_as_shapekey.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - if not armature or context.mode != 'POSE': - return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context): - armature_obj = get_active_armature(context) - mesh_objects = get_all_meshes(context) - - for mesh_obj in mesh_objects: - if not mesh_obj.data: - continue - - if not mesh_obj.data.shape_keys: - mesh_obj.shape_key_add(name='Basis') - - new_shape = mesh_obj.shape_key_add(name='Pose_Shapekey', from_mix=False) - - depsgraph = context.evaluated_depsgraph_get() - eval_mesh = mesh_obj.evaluated_get(depsgraph) - - for i, v in enumerate(eval_mesh.data.vertices): - new_shape.data[i].co = v.co.copy() - - bpy.ops.pose.select_all(action='SELECT') - bpy.ops.pose.transforms_clear() - bpy.ops.object.mode_set(mode='OBJECT') - - self.report({'INFO'}, t('Tools.apply_pose_as_rest.success')) - return {'FINISHED'} - -class AvatarToolkit_OT_ApplyPoseAsRest(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_rest' - bl_label = t("Quick_Access.apply_pose_as_rest.label") - bl_description = t("Quick_Access.apply_pose_as_rest.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - if not armature or context.mode != "POSE": - return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context): - if not apply_pose_as_rest( - context=context, - armature_obj=get_active_armature(context), - meshes=get_all_meshes(context) - ): - self.report({'ERROR'}, t("Quick_Access.apply_armature_failed")) + def execute(self, context: Context) -> Set[str]: + try: + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to stop pose mode: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): + bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' + bl_label = t("QuickAccess.apply_pose_as_shapekey.label") + bl_description = t("QuickAccess.apply_pose_as_shapekey.desc") + bl_options = {'REGISTER', 'UNDO'} + + shapekey_name: StringProperty( + name=t("PoseMode.shapekey.name"), + description=t("PoseMode.shapekey.description"), + default=t("PoseMode.shapekey.default") + ) + + def invoke(self, context: Context, event: Event) -> Set[str]: + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: Context) -> Set[str]: + try: + meshes = get_all_meshes(context) + invalid_meshes = self.validate_meshes(meshes) + + if invalid_meshes: + message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes) + self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message)) + + valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]] + + with ProgressTracker(context, len(valid_meshes), "Applying Pose as Shape Key") as progress: + for mesh_obj in valid_meshes: + if not mesh_obj.data.shape_keys: + mesh_obj.shape_key_add(name=t("PoseMode.basis")) + + new_shape = mesh_obj.shape_key_add(name=self.shapekey_name, from_mix=False) + cached_positions = cache_vertex_positions( + mesh_obj.evaluated_get(context.evaluated_depsgraph_get()) + ) + apply_vertex_positions(new_shape.data, cached_positions) + progress.step(f"Processed {mesh_obj.name}") + + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to apply pose as shape key: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): + bl_idname = 'avatar_toolkit.apply_pose_as_rest' + bl_label = t("QuickAccess.apply_pose_as_rest.label") + bl_description = t("QuickAccess.apply_pose_as_rest.desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> Set[str]: + try: + armature_obj = get_active_armature(context) + meshes = get_all_meshes(context) + + invalid_meshes = self.validate_meshes(meshes) + if invalid_meshes: + message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes) + self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message)) + + valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]] + + with ProgressTracker(context, len(valid_meshes) + 2, "Applying Pose as Rest") as progress: + success, message = apply_pose_as_rest(context, armature_obj, valid_meshes) + if not success: + raise ValueError(message) + progress.step("Applied pose to armature") + + logger.info("Successfully applied pose as rest") + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to apply pose as rest: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=str(e))) return {'CANCELLED'} - - self.report({'INFO'}, t("Tools.apply_pose_as_rest.success")) - return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index e8475c9..ba503a3 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -12,6 +12,7 @@ "Updater.UpdateToLatestButton.label": "Update to {name}", "Updater.UpdateToSelectedButton.label": "Update", "Updater.currentVersion": "Current Version: {name}", + "Updater.selectVersion": "Select Version", "Updater.CheckForUpdateButton.desc": "Check for available updates", "UpdateToLatestButton.desc": "Update to the latest version", "UpdateNotificationPopup.label": "Update Notification", @@ -36,23 +37,41 @@ "QuickAccess.export": "Export", "QuickAccess.export_fbx": "Export FBX", "QuickAccess.export_resonite": "Export to Resonite", + "QuickAccess.start_pose_mode.label": "Start Pose Mode", + "QuickAccess.start_pose_mode.desc": "Enter pose mode for the selected armature", + "QuickAccess.stop_pose_mode.label": "Stop Pose Mode", + "QuickAccess.stop_pose_mode.desc": "Exit pose mode and clear transforms", + "QuickAccess.apply_pose_as_shapekey.label": "Apply Pose as Shape Key", + "QuickAccess.apply_pose_as_shapekey.desc": "Create a new shape key from current pose", + "QuickAccess.apply_pose_as_rest.label": "Apply Pose as Rest", + "QuickAccess.apply_pose_as_rest.desc": "Apply current pose as rest pose", + "QuickAccess.apply_armature_failed": "Failed to apply armature modifications", - "Quick_Access.start_pose_mode.label": "Start Pose Mode", - "Quick_Access.start_pose_mode.desc": "Enter pose mode for the selected armature", - "Quick_Access.stop_pose_mode.label": "Stop Pose Mode", - "Quick_Access.stop_pose_mode.desc": "Exit pose mode and clear transforms", - "Quick_Access.apply_pose_as_shapekey.label": "Apply Pose as Shape Key", - "Quick_Access.apply_pose_as_shapekey.desc": "Create a new shape key from current pose", - "Quick_Access.apply_pose_as_rest.label": "Apply Pose as Rest", - "Quick_Access.apply_pose_as_rest.desc": "Apply current pose as rest pose", - "Quick_Access.apply_armature_failed": "Failed to apply armature modifications", - - "Tools.apply_pose_as_rest.success": "Successfully applied pose as rest position", + "PoseMode.error.start": "Failed to start pose mode: {error}", + "PoseMode.error.stop": "Failed to stop pose mode: {error}", + "PoseMode.error.shapekey": "Failed to apply pose as shape key: {error}", + "PoseMode.error.rest_pose": "Failed to apply pose as rest: {error}", + "PoseMode.shapekey.name": "Shape Key Name", + "PoseMode.shapekey.description": "Name for the new shape key", + "PoseMode.shapekey.default": "Pose_Shapekey", + "PoseMode.skipped_meshes": "Some meshes were skipped:\n{message}", + "PoseMode.basis": "Basis", "Armature.validation.no_armature": "No armature selected", "Armature.validation.not_armature": "Selected object is not an armature", "Armature.validation.no_bones": "Armature has no bones", - "Armature.validation.missing_bone": "Missing essential bone: {bone}", + "Armature.validation.basic_check_failed": "Basic armature validation failed", + "Armature.validation.missing_bones": "Missing essential bones: {bones}", + "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", + + "Mesh.validation.no_data": "No mesh data", + "Mesh.validation.no_vertex_groups": "No vertex groups found", + "Mesh.validation.no_armature_modifier": "No armature modifier", + "Mesh.validation.valid": "Valid mesh for pose operations", + + "Operation.pose_applied": "Pose applied successfully", "Scene.avatar_toolkit_updater_version_list.name": "Version List", "Scene.avatar_toolkit_updater_version_list.description": "List of available versions" diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 154fc09..66454c2 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -87,9 +87,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Armature Validation active_armature = get_active_armature(context) if active_armature: - is_valid: bool - message: str - is_valid, message = validate_armature(active_armature) + is_valid, messages = validate_armature(active_armature) if is_valid: info_box: UILayout = col.box() @@ -103,7 +101,9 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') else: col.separator(factor=0.5) - col.label(text=message, icon='ERROR') + # Display each validation message + for message in messages: + col.label(text=message, icon='ERROR') # Pose Mode Controls pose_box: UILayout = layout.box()