diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py index cf7b6c4..85b534a 100644 --- a/ui/eye_tracking_panel.py +++ b/ui/eye_tracking_panel.py @@ -2,6 +2,7 @@ import bpy from typing import Set from bpy.types import Panel, Context, UILayout, Operator, Event, WindowManager from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .ui_utils import UIStyle, draw_section_header, wrap_text_label from ..core.translations import t from ..core.common import get_active_armature, get_all_meshes from ..functions.eye_tracking import ( @@ -34,30 +35,29 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): layout: UILayout = self.layout toolkit = context.scene.avatar_toolkit - # SDK Version Selection Box - sdk_box: UILayout = layout.box() - col: UILayout = sdk_box.column(align=True) - col.label(text=t("EyeTracking.sdk_version"), icon='PRESET') - col.separator(factor=0.5) + # SDK Version Selection + col = draw_section_header(layout, t("EyeTracking.sdk_version"), icon='PRESET') row: UILayout = col.row(align=True) row.prop(toolkit, "eye_tracking_type", expand=True) if toolkit.eye_tracking_type == 'SDK2': - # SDK2 Warning Box + # SDK2 Warning 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")) + col.alert = True + col.label(text=t("EyeTracking.sdk2_warning"), icon='ERROR') + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) - # Mode Selection Box - mode_box: UILayout = layout.box() - col: UILayout = mode_box.column(align=True) - col.label(text=t("EyeTracking.setup"), icon='TOOL_SETTINGS') - col.separator(factor=0.5) + warning_text = "\n".join([ + t("EyeTracking.sdk2_warning_detail1"), + t("EyeTracking.sdk2_warning_detail2"), + t("EyeTracking.sdk2_warning_detail3"), + t("EyeTracking.sdk2_warning_detail4") + ]) + wrap_text_label(col, warning_text, max_length=45) + + # Mode Selection + col = draw_section_header(layout, t("EyeTracking.setup"), icon='TOOL_SETTINGS') col.prop(toolkit, "eye_mode", expand=True) if toolkit.eye_mode == 'CREATION': @@ -72,11 +72,9 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): """Draw the AV3 eye tracking setup interface""" toolkit = context.scene.avatar_toolkit - # Bone Setup Box - bone_box: UILayout = layout.box() - col: UILayout = bone_box.column(align=True) - col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') - col.separator(factor=0.5) + # Bone Setup + col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA') + armature = get_active_armature(context) if armature: @@ -86,21 +84,16 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): else: col.label(text=t("EyeTracking.no_armature"), icon='ERROR') - # Create Button row: UILayout = layout.row(align=True) - row.scale_y = 1.5 + row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE row.operator(CreateEyesAV3Button.bl_idname, icon='PLAY') def draw_creation_mode(self, context: Context, layout: UILayout) -> None: """Draw the eye tracking creation mode interface""" toolkit = context.scene.avatar_toolkit - # Bone Setup Box - bone_box: UILayout = layout.box() - col: UILayout = bone_box.column(align=True) - col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') - col.separator(factor=0.5) - + # Bone Setup + col = draw_section_header(layout, t("EyeTracking.bone_setup"), icon='BONE_DATA') armature = get_active_armature(context) if armature: col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) @@ -109,19 +102,12 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): else: col.label(text=t("EyeTracking.no_armature"), icon='ERROR') - # Mesh Setup Box - mesh_box: UILayout = layout.box() - col: UILayout = mesh_box.column(align=True) - col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA') - col.separator(factor=0.5) + # Mesh Setup + col = draw_section_header(layout, t("EyeTracking.mesh_setup"), icon='MESH_DATA') col.prop_search(toolkit, "mesh_name_eye", bpy.data, "objects", text="") - # Shape Key Setup Box - shape_box: UILayout = layout.box() - col: UILayout = shape_box.column(align=True) - col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') - col.separator(factor=0.5) - + # Shape Key Setup + col = draw_section_header(layout, t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') mesh = bpy.data.objects.get(toolkit.mesh_name_eye) if mesh and mesh.data.shape_keys: col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left")) @@ -131,19 +117,15 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): else: col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR') - # Options Box - options_box: UILayout = layout.box() - col: UILayout = options_box.column(align=True) - col.label(text=t("EyeTracking.options"), icon='SETTINGS') - col.separator(factor=0.5) + # Options + col = draw_section_header(layout, t("EyeTracking.options"), icon='SETTINGS') col.prop(toolkit, "disable_eye_blinking") col.prop(toolkit, "disable_eye_movement") if not toolkit.disable_eye_movement: col.prop(toolkit, "eye_distance") - # Create Button row: UILayout = layout.row(align=True) - row.scale_y = 1.5 + row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE row.operator(CreateEyesSDK2Button.bl_idname, icon='PLAY') def draw_testing_mode(self, context: Context, layout: UILayout) -> None: @@ -151,37 +133,25 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): toolkit = context.scene.avatar_toolkit if context.mode != 'POSE': - # Testing Start Box - test_box: UILayout = layout.box() - col: UILayout = test_box.column(align=True) - col.label(text=t("EyeTracking.testing"), icon='PLAY') - col.separator(factor=0.5) + # Testing Start + col = draw_section_header(layout, t("EyeTracking.testing"), icon='PLAY') row: UILayout = col.row(align=True) - row.scale_y = 1.5 + row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE row.operator(StartTestingButton.bl_idname, icon='PLAY') else: - # Eye Rotation Box - rotation_box: UILayout = layout.box() - col: UILayout = rotation_box.column(align=True) - col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') - col.separator(factor=0.5) + # Eye Rotation + col = draw_section_header(layout, t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x")) col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y")) col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK') - # Eye Adjustment Box - adjust_box: UILayout = layout.box() - col: UILayout = adjust_box.column(align=True) - col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER') - col.separator(factor=0.5) + # Eye Adjustment + col = draw_section_header(layout, t("EyeTracking.adjustments"), icon='MODIFIER') col.prop(toolkit, "eye_distance") col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO') - # Blinking Test Box - blink_box: UILayout = layout.box() - col: UILayout = blink_box.column(align=True) - col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF') - col.separator(factor=0.5) + # Blinking Test + col = draw_section_header(layout, t("EyeTracking.blink_testing"), icon='HIDE_OFF') row: UILayout = col.row(align=True) row.prop(toolkit, "eye_blink_shape") row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF') @@ -192,7 +162,7 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): # Stop Testing Button row: UILayout = layout.row(align=True) - row.scale_y = 1.5 + row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE row.operator(StopTestingButton.bl_idname, icon='PAUSE') # Reset Button diff --git a/ui/main_panel.py b/ui/main_panel.py index 16bf637..b07b11d 100644 --- a/ui/main_panel.py +++ b/ui/main_panel.py @@ -1,6 +1,7 @@ import bpy from typing import Optional, Set from bpy.types import Panel, Context, UILayout +from .ui_utils import UIStyle, wrap_text_label from ..core.translations import t CATEGORY_NAME: str = "Avatar Toolkit" @@ -16,13 +17,14 @@ def draw_title(self: Panel) -> None: row.scale_y: float = 1.2 row.label(text=t("AvatarToolkit.label"), icon='ARMATURE_DATA') - # Description as a flowing paragraph - desc_col: UILayout = col.column() - desc_col.scale_y: float = 0.6 - desc_col.label(text=t("AvatarToolkit.desc1")) - desc_col.label(text=t("AvatarToolkit.desc2")) - desc_col.label(text=t("AvatarToolkit.desc3")) - col.separator() + # Description + col.separator(factor=UIStyle.SECTION_SEPARATOR_FACTOR) + description = " ".join([ + t("AvatarToolkit.desc1"), + t("AvatarToolkit.desc2"), + t("AvatarToolkit.desc3") + ]) + wrap_text_label(col, description, max_length=50) class AvatarToolKit_PT_AvatarToolkitPanel(Panel): """Main panel for Avatar Toolkit containing general information and settings""" diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py index cfa1559..25255c0 100644 --- a/ui/optimization_panel.py +++ b/ui/optimization_panel.py @@ -2,6 +2,7 @@ import bpy from typing import Set from bpy.types import Panel, Context, UILayout, Operator from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .ui_utils import UIStyle, draw_section_header, draw_operator_row from ..core.translations import t from ..functions.optimization.materials_tools import AvatarToolkit_OT_CombineMaterials from ..functions.optimization.remove_doubles import AvatarToolkit_OT_RemoveDoubles @@ -22,32 +23,17 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): """Draws the optimization panel interface with material, mesh cleanup and join mesh tools""" layout: UILayout = self.layout - # Materials Box - materials_box: UILayout = layout.box() - col: UILayout = materials_box.column(align=True) - col.label(text=t("Optimization.materials_title"), icon='MATERIAL') - col.separator(factor=0.5) - - # Material Operations + # Materials section + col = draw_section_header(layout, t("Optimization.materials_title"), icon='MATERIAL') col.operator(AvatarToolkit_OT_CombineMaterials.bl_idname, icon='MATERIAL') - # Mesh Cleanup Box - cleanup_box: UILayout = layout.box() - col: UILayout = cleanup_box.column(align=True) - col.label(text=t("Optimization.cleanup_title"), icon='MESH_DATA') - col.separator(factor=0.5) + # Mesh Cleanup section + col = draw_section_header(layout, t("Optimization.cleanup_title"), icon='MESH_DATA') + col.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA') - # Remove Doubles Row - row: UILayout = col.row(align=True) - row.operator(AvatarToolkit_OT_RemoveDoubles.bl_idname, icon='MESH_DATA') - - # Join Meshes Box - join_box: UILayout = layout.box() - col: UILayout = join_box.column(align=True) - col.label(text=t("Optimization.join_meshes_title"), icon='OBJECT_DATA') - col.separator(factor=0.5) - - # Join Meshes Row - row: UILayout = col.row(align=True) - row.operator(AvatarToolkit_OT_JoinAllMeshes.bl_idname, icon='OBJECT_DATA') - row.operator(AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, icon='RESTRICT_SELECT_OFF') + # Join Meshes section + col = draw_section_header(layout, t("Optimization.join_meshes_title"), icon='OBJECT_DATA') + draw_operator_row(col, [ + (AvatarToolkit_OT_JoinAllMeshes.bl_idname, t("Optimization.join_all"), 'OBJECT_DATA'), + (AvatarToolkit_OT_JoinSelectedMeshes.bl_idname, t("Optimization.join_selected"), 'RESTRICT_SELECT_OFF') + ]) diff --git a/ui/panel_layout.py b/ui/panel_layout.py new file mode 100644 index 0000000..90ccc32 --- /dev/null +++ b/ui/panel_layout.py @@ -0,0 +1,53 @@ +"""Panel ordering and organization guide for Avatar Toolkit UI +This module defines the standard panel order and grouping for the Avatar Toolkit. +""" + +# Main Panel +MAIN_PANEL_ORDER = -1 # Always first (parent panel) +QUICK_ACCESS_ORDER = 0 +OPTIMIZATION_ORDER = 1 +TOOLS_ORDER = 2 +CUSTOM_TOOLS_ORDER = 3 +CUSTOM_AVATAR_ORDER = 4 +TRANSLATION_ORDER = 5 +VISEMES_ORDER = 6 +EYE_TRACKING_ORDER = 7 +TEXTURE_ATLAS_ORDER = 8 +VRM_UNITY_ORDER = 9 +SETTINGS_ORDER = 10 + +# Panel open/closed by default +PANELS_OPEN_BY_DEFAULT = { + 'QUICK_ACCESS': False, + 'OPTIMIZATION': True, + 'TOOLS': True, + 'CUSTOM_TOOLS': True, + 'CUSTOM_AVATAR': True, + 'VISEMES': True, + 'EYE_TRACKING': True, + 'TEXTURE_ATLAS': True, + 'VRM_UNITY': True, + 'SETTINGS': True, + 'TRANSLATION': True, +} + +def get_panel_order(panel_name: str) -> int: + """Get the recommended bl_order value for a panel""" + order_map = { + 'quick_access': QUICK_ACCESS_ORDER, + 'optimization': OPTIMIZATION_ORDER, + 'tools': TOOLS_ORDER, + 'custom_tools': CUSTOM_TOOLS_ORDER, + 'custom_avatar': CUSTOM_AVATAR_ORDER, + 'translation': TRANSLATION_ORDER, + 'visemes': VISEMES_ORDER, + 'eye_tracking': EYE_TRACKING_ORDER, + 'texture_atlas': TEXTURE_ATLAS_ORDER, + 'vrm_unity': VRM_UNITY_ORDER, + 'settings': SETTINGS_ORDER, + } + return order_map.get(panel_name.lower(), 99) + +def should_open_by_default(panel_name: str) -> bool: + """Check if a panel should be open by default""" + return PANELS_OPEN_BY_DEFAULT.get(panel_name.upper(), True) diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index f41242a..8fdc1db 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -10,6 +10,7 @@ from bpy.types import ( Object ) from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .ui_utils import UIStyle, draw_section_header, draw_operator_row from ..core.translations import t from ..core.common import ( get_active_armature, @@ -86,27 +87,19 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): layout: UILayout = self.layout props = context.scene.avatar_toolkit - # Armature Selection Box - armature_box: UILayout = layout.box() - col: UILayout = armature_box.column(align=True) - col.label(text=t("QuickAccess.select_armature"), icon='ARMATURE_DATA') - col.separator(factor=0.5) - # Armature Selection + col = draw_section_header(layout, t("QuickAccess.select_armature"), icon='ARMATURE_DATA') col.prop(context.scene.avatar_toolkit, "active_armature", text="") # Get active armature active_armature: Optional[Object] = get_active_armature(context) if active_armature: - # Validation Button Box - Always visible - validation_box: UILayout = layout.box() - col = validation_box.column(align=True) - col.label(text=t("Validation.label", "Armature Validation"), icon='CHECKMARK') - col.separator(factor=0.5) + # Validation Section + col = draw_section_header(layout, t("Validation.label", "Armature Validation"), icon='CHECKMARK') # Main validate button with prominent styling validate_row = col.row(align=True) - validate_row.scale_y = 1.3 + validate_row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE validate_row.operator("avatar_toolkit.validate_armature_manual", text=t("Validation.validate_now", "Validate Armature Now"), icon='CHECKMARK') @@ -127,7 +120,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Check if this is a PMX model pmx_detected = is_pmx_model(active_armature) - results_box = validation_box.box() + results_box = col.box() row = results_box.row() row.prop(props, "show_validation_results", text=t("Validation.results", "Validation Results"), icon='TRIA_DOWN' if props.show_validation_results else 'TRIA_RIGHT', emboss=False) @@ -248,50 +241,40 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): text=t("QuickAccess.standardize_armature"), icon='MODIFIER') - # T-Pose Validation Box - tpose_box: UILayout = layout.box() - col = tpose_box.column(align=True) - col.label(text=t("Validation.tpose.label"), icon='ARMATURE_DATA') - col.separator(factor=0.5) + # T-Pose Validation + col = draw_section_header(layout, t("Validation.tpose.label"), icon='ARMATURE_DATA') col.operator(AvatarToolkit_OT_ValidateTPose.bl_idname, text=t("Validation.tpose.validate_now"), icon='CHECKMARK') if props.show_tpose_validation: - validation_box = col.box() + validation_result_col = col.column(align=True) if props.tpose_validation_result: - validation_box.label(text=t("Validation.tpose.valid"), icon='CHECKMARK') + validation_result_col.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') + validation_result_col.alert = True + validation_result_col.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) + validation_result_col.label(text=msg.name) # Pose Mode Controls - pose_box: UILayout = layout.box() - col = pose_box.column(align=True) - col.label(text=t("QuickAccess.pose_controls"), icon='ARMATURE_DATA') - col.separator(factor=0.5) + col = draw_section_header(layout, t("QuickAccess.pose_controls"), icon='ARMATURE_DATA') if context.mode == "POSE": col.operator(AvatarToolkit_OT_StopPoseMode.bl_idname, icon='POSE_HLT') - col.separator(factor=0.5) - col.operator(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, icon='MOD_ARMATURE') - col.operator(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, icon='MOD_ARMATURE') + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) + draw_operator_row(col, [ + (AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, t("QuickAccess.pose_as_rest"), 'MOD_ARMATURE'), + (AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, t("QuickAccess.pose_as_shapekey"), 'MOD_ARMATURE') + ]) else: col.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, icon='POSE_HLT') - # Import/Export Box - import_box: UILayout = layout.box() - col = import_box.column(align=True) - col.label(text=t("QuickAccess.import_export"), icon='IMPORT') - col.separator(factor=0.5) + # Import/Export Section + col = draw_section_header(layout, t("QuickAccess.import_export"), icon='IMPORT') # Import/Export Buttons - button_row: UILayout = col.row(align=True) - button_row.scale_y = 1.5 - button_row.operator(AvatarToolKit_OT_Import.bl_idname, text=t("QuickAccess.import"), icon='IMPORT') - button_row.operator(AvatarToolKit_OT_ExportMenu.bl_idname, text=t("QuickAccess.export"), icon='EXPORT') + draw_operator_row(col, [ + (AvatarToolKit_OT_Import.bl_idname, t("QuickAccess.import"), 'IMPORT'), + (AvatarToolKit_OT_ExportMenu.bl_idname, t("QuickAccess.export"), 'EXPORT') + ], scale_y=UIStyle.PRIMARY_BUTTON_SCALE) diff --git a/ui/search_operators.py b/ui/search_operators.py new file mode 100644 index 0000000..da9eb35 --- /dev/null +++ b/ui/search_operators.py @@ -0,0 +1,121 @@ +"""Base classes for reusable search operators""" + +from typing import Set, Callable, Optional +from bpy.types import Operator, Context, Event, WindowManager + + +class SearchOperatorBase(Operator): + """ + Reusable base class for search/selection operators. + + This is an abstract base class - do not use directly. + Subclass and implement your specific search operator instead. + + Subclasses should: + 1. Define bl_idname, bl_label, bl_description + 2. Define search_property_name (name of EnumProperty) + 3. Define target_property_name (name of property to set on scene) + 4. Define get_items_func (function to get enum items) + 5. Optionally override get_enum_property() to customize the enum + + This was created because search in ATK was all over the place and inconsistent, this way we have a standard way to do it. + """ + + # Mark this as abstract by setting a non-Blender-compatible idname + bl_idname = "wm.search_operator_base" # Will be overridden in subclasses + bl_label = "Search and Select" + bl_options = {'REGISTER', 'INTERNAL'} + + # These should be overridden in subclasses + search_property_name: str = "search_enum" + target_property_name: str = "target_property" + + @staticmethod + def get_items_func(scene, context) -> list: + """Override this to provide enum items. Return list of (id, name, description) tuples""" + return [] + + def get_enum_property(self) -> None: + """ + Create the enum property dynamically. Override if you need custom behavior. + This is called during class creation. + """ + import bpy + setattr( + type(self), + self.search_property_name, + bpy.props.EnumProperty( + name="Search", + description="Select item", + items=self.get_items_func + ) + ) + + def execute(self, context: Context) -> Set[str]: + """Set the target property from the search selection""" + search_value = getattr(self, self.search_property_name, None) + if search_value: + setattr(context.scene.avatar_toolkit, self.target_property_name, search_value) + return {'FINISHED'} + + def invoke(self, context: Context, event: Event) -> Set[str]: + """Open search popup""" + wm: WindowManager = context.window_manager + wm.invoke_search_popup(self) + return {'FINISHED'} + + +class ArmatureSearchOperator(SearchOperatorBase): + """Specialized search operator for selecting armatures""" + + bl_label = "Search Armatures" + search_property_name: str = "search_armature_enum" + + @staticmethod + def get_items_func(scene, context) -> list: + """Get list of all armature objects in scene""" + import bpy + return [ + (obj.name, obj.name, "") + for obj in bpy.data.objects + if obj.type == 'ARMATURE' + ] + + +class MeshSearchOperator(SearchOperatorBase): + """Specialized search operator for selecting meshes""" + + bl_label = "Search Meshes" + search_property_name: str = "search_mesh_enum" + + @staticmethod + def get_items_func(scene, context) -> list: + """Get list of all mesh objects without armature modifiers""" + import bpy + return [ + (obj.name, obj.name, "") + for obj in bpy.data.objects + if obj.type == 'MESH' + and not any(mod.type == 'ARMATURE' for mod in obj.modifiers) + ] + + +class BoneSearchOperator(SearchOperatorBase): + """Specialized search operator for selecting bones from active armature""" + + bl_label = "Search Bones" + search_property_name: str = "search_bone_enum" + + @staticmethod + def get_items_func(scene, context) -> list: + """Get list of all bones from active armature""" + from ..core.common import get_active_armature + + armature = get_active_armature(context) + if not armature: + return [] + + return [ + (bone.name, bone.name, "") + for bone in armature.data.bones + ] diff --git a/ui/settings_panel.py b/ui/settings_panel.py index bdaa783..7b676d1 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -9,6 +9,7 @@ from bpy.types import ( Event ) from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .ui_utils import UIStyle, draw_section_header, wrap_text_label from ..core.translations import t, get_languages_list from ..core.armature_validation import AvatarToolkit_OT_HighlightProblemBones, AvatarToolkit_OT_ClearBoneHighlighting @@ -26,8 +27,10 @@ class AvatarToolkit_OT_TranslationRestartPopup(Operator): def draw(self, context: Context) -> None: layout: UILayout = self.layout - layout.label(text=t("Language.changed.success")) - layout.label(text=t("Language.changed.restart")) + col = layout.column(align=True) + col.label(text=t("Language.changed.success")) + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) + wrap_text_label(col, t("Language.changed.restart"), max_length=50) class AvatarToolKit_PT_SettingsPanel(Panel): """Settings panel for Avatar Toolkit containing language preferences""" @@ -46,30 +49,18 @@ class AvatarToolKit_PT_SettingsPanel(Panel): props = context.scene.avatar_toolkit # Language Settings - lang_box: UILayout = layout.box() - col: UILayout = lang_box.column(align=True) - row: UILayout = col.row() - row.scale_y = 1.2 - row.label(text=t("Settings.language"), icon='WORLD') - col.separator() + col = draw_section_header(layout, t("Settings.language"), icon='WORLD') col.prop(props, "language", text="") - # Validation Settings - val_box: UILayout = layout.box() - col = val_box.column(align=True) - row = col.row() - row.scale_y = 1.2 - row.label(text=t("Settings.validation_mode"), icon='CHECKMARK') - col.separator() + # Validation Settings with help text + col = draw_section_header(layout, t("Settings.validation_mode"), icon='CHECKMARK') col.prop(props, "validation_mode", text="") + # Help text for validation mode + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) + wrap_text_label(col, "Select how strictly to validate armature bone structure and naming conventions.", max_length=40) # 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 = draw_section_header(layout, t("Settings.bone_highlighting"), icon='BONE_DATA') col.prop(props, "highlight_problem_bones") if props.highlight_problem_bones: col.operator(AvatarToolkit_OT_HighlightProblemBones.bl_idname, icon='COLOR') diff --git a/ui/tools_panel.py b/ui/tools_panel.py index fd4f25c..4312707 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -2,6 +2,7 @@ import bpy from typing import Set from bpy.types import Panel, Context, UILayout, Operator, UIList from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .ui_utils import UIStyle, draw_section_header, draw_operator_row from ..core.translations import t from ..core.resonite_utils import AvatarToolkit_OT_ConvertResonite @@ -38,94 +39,70 @@ class AvatarToolKit_PT_ToolsPanel(Panel): toolkit = context.scene.avatar_toolkit # General Tools - tools_box: UILayout = layout.box() - col: UILayout = tools_box.column(align=True) - col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS') - col.separator(factor=0.5) + col = draw_section_header(layout, t("Tools.general_title"), icon='TOOL_SETTINGS') col.operator(AvatarToolkit_OT_ConvertResonite.bl_idname, text=t("Tools.convert_resonite"), icon='EXPORT') # Separation Tools - sep_box: UILayout = layout.box() - col = sep_box.column(align=True) - col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE') - col.separator(factor=0.5) - row: UILayout = col.row(align=True) - row.operator(AvatarToolKit_OT_SeparateByMaterials.bl_idname, text=t("Tools.separate_materials"), icon='MATERIAL') - row.operator(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, text=t("Tools.separate_loose"), icon='MESH_DATA') + col = draw_section_header(layout, t("Tools.separate_title"), icon='MOD_EXPLODE') + draw_operator_row(col, [ + (AvatarToolKit_OT_SeparateByMaterials.bl_idname, t("Tools.separate_materials"), 'MATERIAL'), + (AvatarToolKit_OT_SeparateByLooseParts.bl_idname, t("Tools.separate_loose"), 'MESH_DATA') + ]) # Bone Tools - bone_box: UILayout = layout.box() - col = bone_box.column(align=True) - col.label(text=t("Tools.bone_title"), icon='BONE_DATA') - col.separator(factor=0.5) + col = draw_section_header(layout, t("Tools.bone_title"), icon='BONE_DATA') col.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade"), icon='BONE_DATA') - col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname,text=t("Tools.flip_pose_frames"),icon="ACTION") + col.operator(AvatarToolKit_OT_FlipCurrentKeyFrames.bl_idname, text=t("Tools.flip_pose_frames"), icon="ACTION") # Mesh Tools - mesh_box: UILayout = layout.box() - col = mesh_box.column(align=True) - col.label(text=t("Tools.mesh_title"), icon='MESH_DATA') - col.separator(factor=0.5) - col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname,text=t("Tools.find_shortest_seam_path"),icon="MESH_DATA") - col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname,text=t("Tools.apply_modifier_on_shapekey_obj"),icon="SHAPEKEY_DATA") - col.operator(AvatarToolkit_OT_ExplodeMesh.bl_idname,text=t("Tools.explode_mesh"),icon="MOD_EXPLODE") - + col = draw_section_header(layout, t("Tools.mesh_title"), icon='MESH_DATA') + col.operator(AvatarToolkit_OT_SelectShortestSeamPath.bl_idname, text=t("Tools.find_shortest_seam_path"), icon="MESH_DATA") + col.operator(AvatarToolkit_OT_ApplyModifierForShapkeyObj.bl_idname, text=t("Tools.apply_modifier_on_shapekey_obj"), icon="SHAPEKEY_DATA") + col.operator(AvatarToolkit_OT_ExplodeMesh.bl_idname, text=t("Tools.explode_mesh"), icon="MOD_EXPLODE") # 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 = draw_section_header(layout, t("Tools.standardize_title"), icon='OUTLINER_OB_ARMATURE') col.operator(AvatarToolkit_OT_StandardizeArmature.bl_idname, icon='CHECKMARK') # Weight Tools - weight_box: UILayout = bone_box.box() - col = weight_box.column(align=True) + col = draw_section_header(layout, t("Tools.weight_title"), icon='GROUP_BONE') 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() + sub_col = col.box() + row = sub_col.row() row.template_list("AVATAR_TOOLKIT_UL_ZeroWeightBones", "", toolkit, "zero_weight_bones", toolkit, "zero_weight_bones_index") - col = box.column(align=True) - col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname, - text=t("Tools.remove_selected_bones")) + sub_col.operator(AvatarToolKit_OT_RemoveSelectedBones.bl_idname, + text=t("Tools.remove_selected_bones")) - row = col.row(align=True) - row.operator(AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.clean_weights"), icon='GROUP_BONE') - row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') - row = col.row(align=True) - row.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE') + # Combine weight + draw_operator_row(col, [ + (AvatarToolKit_OT_RemoveZeroWeightBones.bl_idname, t("Tools.clean_weights"), 'GROUP_BONE'), + (AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, t("Tools.clean_constraints"), 'CONSTRAINT_BONE') + ]) + col.operator(AvatarToolKit_OT_RemoveZeroWeightVertexGroups.bl_idname, text=t("Tools.clean_vertex_groups"), icon='CONSTRAINT_BONE') # Merge Tools - merge_box: UILayout = layout.box() - col = merge_box.column(align=True) - col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON') - col.separator(factor=0.5) - row = col.row(align=True) - row.operator(AvatarToolkit_OT_MergeToActive.bl_idname, text=t("Tools.merge_to_active"), icon='BONE_DATA') - row.operator(AvatarToolkit_OT_MergeToParent.bl_idname, text=t("Tools.merge_to_parent"), icon='BONE_DATA') + col = draw_section_header(layout, t("Tools.merge_title"), icon='AUTOMERGE_ON') + draw_operator_row(col, [ + (AvatarToolkit_OT_MergeToActive.bl_idname, t("Tools.merge_to_active"), 'BONE_DATA'), + (AvatarToolkit_OT_MergeToParent.bl_idname, t("Tools.merge_to_parent"), 'BONE_DATA') + ]) col.operator(AvatarToolkit_OT_ConnectBones.bl_idname, text=t("Tools.connect_bones"), icon='BONE_DATA') # Additional Tools - extra_box: UILayout = layout.box() - col = extra_box.column(align=True) - col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS') - col.separator(factor=0.5) + col = draw_section_header(layout, t("Tools.additional_title"), icon='TOOL_SETTINGS') col.operator(AvatarToolkit_OT_ApplyTransforms.bl_idname, text=t("Tools.apply_transforms"), icon='OBJECT_DATA') col.operator(AvatarToolkit_OT_CleanShapekeys.bl_idname, 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 = draw_section_header(layout, t("Tools.rigify_title"), icon='ARMATURE_DATA') col.operator(AvatarToolkit_OT_ConvertRigifyToUnity.bl_idname, icon='ARMATURE_DATA') col.prop(context.scene.avatar_toolkit, "merge_twist_bones") diff --git a/ui/ui_utils.py b/ui/ui_utils.py new file mode 100644 index 0000000..6e19d1c --- /dev/null +++ b/ui/ui_utils.py @@ -0,0 +1,137 @@ +"""UI utilities and styling helpers for consistent Avatar Toolkit panel design""" + +from typing import Callable, Optional +from bpy.types import UILayout, Context, Operator + + +class UIStyle: + """Centralized UI styling constants for consistent appearance""" + g + SECTION_SEPARATOR_FACTOR: float = 0.5 + SUBSECTION_SEPARATOR_FACTOR: float = 0.3 + PRIMARY_BUTTON_SCALE: float = 1.5 + STANDARD_BUTTON_SCALE: float = 1.0 + COMPACT_BUTTON_SCALE: float = 0.9 + DEFAULT_PADDING: float = 1.0 + COMPACT_PADDING: float = 0.5 + + CATEGORY_ICONS = { + 'optimization': 'MOD_SMOOTH', + 'tools': 'TOOL_SETTINGS', + 'custom': 'TOOL_OPTIONS', + 'eye_tracking': 'OBJECT_CAMERA', + 'settings': 'PREFERENCES', + 'import_export': 'EXPORT', + 'pose': 'POSE_HLT', + 'materials': 'MATERIAL', + 'mesh': 'MESH_DATA', + 'bones': 'BONE_DATA', + 'vfx': 'MOD_DISPLACE' + } + + +def draw_section_header(layout: UILayout, title: str, icon: str = 'NONE', separator: bool = True) -> UILayout: + """Draw a consistent section header with optional icon and separator""" + header_box = layout.box() + col = header_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=title, icon=icon) + + if separator: + col.separator(factor=UIStyle.SECTION_SEPARATOR_FACTOR) + + return col + + +def draw_subsection(layout: UILayout, title: str, icon: str = 'NONE') -> UILayout: + """Draw a subsection with reduced visual weight (no box)""" + col = layout.column(align=True) + row = col.row() + row.label(text=title, icon=icon) + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) + return col + + +def draw_info_text(layout: UILayout, text: str, icon: str = 'INFO') -> None: + """Draw informational text that can wrap (replaces multiple labels)""" + col = layout.column() + col.alert = False + # Split long text for wrapping + row = col.row() + row.label(text=text, icon=icon) + + +def draw_warning_text(layout: UILayout, text: str) -> None: + """Draw warning-styled text""" + col = layout.column() + col.alert = True + row = col.row() + row.label(text=text, icon='ERROR') + + +def draw_primary_button(layout: UILayout, operator_idname: str, text: str = "", + icon: str = 'NONE', **kwargs) -> None: + """Draw a primary action button with standard scaling""" + row = layout.row(align=True) + row.scale_y = UIStyle.PRIMARY_BUTTON_SCALE + row.operator(operator_idname, text=text, icon=icon, **kwargs) + + +def draw_operator_row(layout: UILayout, operators: list[tuple[str, str, str]], + scale_y: float = 1.0, equal_width: bool = True) -> None: + """Draw multiple operators in a single row with consistent sizing""" + if not operators: + return + + row = layout.row(align=equal_width) + row.scale_y = scale_y + + for op_id, text, icon in operators: + row.operator(op_id, text=text, icon=icon) + + +def draw_collapsible_section(layout: UILayout, title: str, icon: str, + draw_func: Callable[[UILayout], None], + context: Context, storage_attr: str) -> None: + """Draw a collapsible section (using context scene properties for state)""" + col = layout.column(align=True) + row = col.row() + + scene = context.scene + attr_name = f"_ui_expand_{storage_attr}" + is_expanded = getattr(scene, attr_name, False) + icon_name = 'DISCLOSURE_TRI_DOWN' if is_expanded else 'DISCLOSURE_TRI_RIGHT' + row.prop(scene, attr_name, text="", icon=icon_name, emboss=False) + row.label(text=title, icon=icon) + + if is_expanded: + col.separator(factor=UIStyle.SUBSECTION_SEPARATOR_FACTOR) + draw_func(col) + + +def apply_operator_disable_feedback(operator: Operator, layout: UILayout, + is_disabled: bool, reason: str = "") -> UILayout: + """Prepare layout for disabled operator with visual feedback""" + if is_disabled: + layout.enabled = False + return layout + + +def wrap_text_label(layout: UILayout, text: str, max_length: int = 50) -> None: + """Draw a label that wraps long text across multiple lines""" + words = text.split() + current_line = "" + + col = layout.column() + + for word in words: + test_line = (current_line + " " + word).strip() + if len(test_line) > max_length and current_line: + col.label(text=current_line) + current_line = word + else: + current_line = test_line + + if current_line: + col.label(text=current_line)