From 87a351cea41b66c73e144da0e88579dd380ca657 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sun, 15 Dec 2024 20:14:26 +0000 Subject: [PATCH] Added Eye tracking and Visemes --- core/common.py | 91 ++++ core/properties.py | 169 ++++++ core/updater.py | 1 + functions/eye_tracking.py | 867 ++++++++++++++++++++++++++++++ functions/visemes.py | 335 ++++++++++++ resources/translations/en_US.json | 97 ++++ ui/eye_tracking_panel.py | 143 +++++ ui/main_panel.py | 1 - ui/mmd_panel.py | 79 +-- ui/optimization_panel.py | 1 + ui/settings_panel.py | 1 + ui/tools_panel.py | 1 + ui/visemes_panel.py | 60 +++ 13 files changed, 1807 insertions(+), 39 deletions(-) create mode 100644 functions/eye_tracking.py create mode 100644 functions/visemes.py create mode 100644 ui/eye_tracking_panel.py create mode 100644 ui/visemes_panel.py diff --git a/core/common.py b/core/common.py index 916e6ef..253acc6 100644 --- a/core/common.py +++ b/core/common.py @@ -485,3 +485,94 @@ def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: removed_count += 1 return removed_count + +def has_shapekeys(mesh_obj: Object) -> bool: + return mesh_obj.data.shape_keys is not None + +# Identifier to indicate that an EnumProperty is empty +# This is the default identifier used when a wrapped items function returns an empty list +# This identifier needs to be something that should never normally be used, so as to avoid the possibility of +# conflicting with an enum value that exists. +_empty_enum_identifier = 'Cats_empty_enum_identifier' + +# names - The first object will be the first one in the list. So the first one has to be the one that exists in the most models +# no_basis - If this is true the Basis will not be available in the list +def get_shapekeys(context, names, is_mouth, no_basis, return_list): + choices = [] + choices_simple = [] + meshes_list = get_meshes_objects(check=False) + + if meshes_list: + if is_mouth: + meshes = [get_objects().get(context.scene.mesh_name_viseme)] + else: + meshes = [get_objects().get(context.scene.mesh_name_eye)] + else: + return choices + + for mesh in meshes: + if not mesh or not has_shapekeys(mesh): + return choices + + for shapekey in mesh.data.shape_keys.key_blocks: + name = shapekey.name + if name in choices_simple: + continue + if no_basis and name == 'Basis': + continue + # 1. Will be returned by context.scene + # 2. Will be shown in lists + # 3. will be shown in the hover description (below description) + choices.append((name, name, name)) + choices_simple.append(name) + + _sort_enum_choices_by_identifier_lower(choices) + + choices2 = [] + for name in names: + if name in choices_simple and len(choices) > 1 and choices[0][0] != name: + continue + choices2.append((name, name, name)) + + choices2.extend(choices) + + if return_list: + shape_list = [] + for choice in choices2: + shape_list.append(choice[0]) + return shape_list + + return choices2 + +# Default sorting for dynamic EnumProperty items +def _sort_enum_choices_by_identifier_lower(choices, in_place=True): + """Sort a list of enum choices (items) by the lowercase of their identifier. + + Sorting is performed in-place by default, but can be changed by setting in_place=False. + + Returns the sorted list of enum choices.""" + + def identifier_lower(choice): + return choice[0].lower() + + if in_place: + choices.sort(key=identifier_lower) + else: + choices = sorted(choices, key=identifier_lower) + return choices + +def is_enum_empty(string): + """Returns True only if the tested string is the string that signifies that an EnumProperty is empty. + + Returns False in all other cases.""" + return _empty_enum_identifier == string + + +# This function isn't needed since you can 'not is_enum_empty(string)', but is included for code clarity and readability +def is_enum_non_empty(string): + """Returns False only if the tested string is not the string that signifies that an EnumProperty is empty. + + Returns True in all other cases.""" + return _empty_enum_identifier != string + + diff --git a/core/properties.py b/core/properties.py index 6725b26..a294f2b 100644 --- a/core/properties.py +++ b/core/properties.py @@ -15,6 +15,8 @@ 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 ..functions.visemes import VisemePreview +from ..functions.eye_tracking import set_rotation def update_validation_mode(self, context): logger.info(f"Updating validation mode to: {self.validation_mode}") @@ -26,6 +28,11 @@ def update_logging_state(self, context): from .logging_setup import configure_logging configure_logging(self.enable_logging) +def update_shape_intensity(self, context): + if self.viseme_preview_mode: + from ..functions.visemes import VisemePreview + VisemePreview.update_preview(context) + class AvatarToolkitSceneProperties(PropertyGroup): """Property group containing Avatar Toolkit scene-level settings and properties""" @@ -112,6 +119,168 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=1.0 ) + viseme_preview_mode: BoolProperty( + name=t("Visemes.preview_mode"), + description=t("Visemes.preview_mode_desc"), + default=False + ) + + viseme_preview_selection: StringProperty( + name=t("Visemes.preview_selection"), + description=t("Visemes.preview_selection_desc"), + default="vrc.v_aa" + ) + + mouth_a: StringProperty( + name=t("Visemes.mouth_a"), + description=t("Visemes.mouth_a_desc") + ) + + mouth_o: StringProperty( + name=t("Visemes.mouth_o"), + description=t("Visemes.mouth_o_desc") + ) + + mouth_ch: StringProperty( + name=t("Visemes.mouth_ch"), + description=t("Visemes.mouth_ch_desc") + ) + + shape_intensity: FloatProperty( + name=t("Visemes.shape_intensity"), + description=t("Visemes.shape_intensity_desc"), + default=1.0, + min=0.0, + max=2.0, + precision=3, + update=update_shape_intensity + ) + + 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) +) + eye_mode: EnumProperty( + name=t("EyeTracking.mode"), + items=[ + ('CREATION', t("EyeTracking.mode.creation"), ""), + ('TESTING', t("EyeTracking.mode.testing"), "") + ], + default='CREATION' + ) + + eye_rotation_x: FloatProperty( + name=t("EyeTracking.rotation.x"), + update=set_rotation + ) + + eye_rotation_y: FloatProperty( + name=t("EyeTracking.rotation.y"), + update=set_rotation + ) + + mesh_name_eye: StringProperty( + name=t("EyeTracking.mesh_name"), + description=t("EyeTracking.mesh_name_desc") + ) + + head: StringProperty( + name=t("EyeTracking.head_bone"), + description=t("EyeTracking.head_bone_desc") + ) + + eye_left: StringProperty( + name=t("EyeTracking.eye_left"), + description=t("EyeTracking.eye_left_desc") + ) + + eye_right: StringProperty( + name=t("EyeTracking.eye_right"), + description=t("EyeTracking.eye_right_desc") + ) + + disable_eye_movement: BoolProperty( + name=t("EyeTracking.disable_movement"), + description=t("EyeTracking.disable_movement_desc"), + default=False + ) + + disable_eye_blinking: BoolProperty( + name=t("EyeTracking.disable_blinking"), + description=t("EyeTracking.disable_blinking_desc"), + default=False + ) + + eye_distance: FloatProperty( + name=t("EyeTracking.distance"), + description=t("EyeTracking.distance_desc"), + default=0.0, + min=-1.0, + max=1.0 + ) + + iris_height: FloatProperty( + name=t("EyeTracking.iris_height"), + description=t("EyeTracking.iris_height_desc"), + default=0.0, + min=-1.0, + max=1.0 + ) + + eye_blink_shape: FloatProperty( + name=t("EyeTracking.blink_shape"), + description=t("EyeTracking.blink_shape_desc"), + default=1.0, + min=0.0, + max=1.0 + ) + + eye_lowerlid_shape: FloatProperty( + name=t("EyeTracking.lowerlid_shape"), + description=t("EyeTracking.lowerlid_shape_desc"), + default=1.0, + min=0.0, + max=1.0 + ) + + wink_left: StringProperty( + name=t("EyeTracking.wink_left"), + description=t("EyeTracking.wink_left_desc") + ) + + wink_right: StringProperty( + name=t("EyeTracking.wink_right"), + description=t("EyeTracking.wink_right_desc") + ) + + lowerlid_left: StringProperty( + name=t("EyeTracking.lowerlid_left"), + description=t("EyeTracking.lowerlid_left_desc") + ) + + lowerlid_right: StringProperty( + name=t("EyeTracking.lowerlid_right"), + description=t("EyeTracking.lowerlid_right_desc") + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/core/updater.py b/core/updater.py index d490504..ffab4b5 100644 --- a/core/updater.py +++ b/core/updater.py @@ -77,6 +77,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order = 4 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py new file mode 100644 index 0000000..f8fe3d5 --- /dev/null +++ b/functions/eye_tracking.py @@ -0,0 +1,867 @@ +import os +import bpy +import copy +import math +import bmesh +import mathutils +import json +from bpy.types import Operator, Object, Context +from typing import Optional, Dict, Tuple, Set +from collections import OrderedDict +from random import random +from itertools import chain + +from ..core.logging_setup import logger +from ..core.translations import t +from ..core.common import ( + ProgressTracker, + get_active_armature, + get_all_meshes, + get_armature_list, + validate_armature, + validate_mesh_for_pose, + cache_vertex_positions, + apply_vertex_positions +) + +VALID_EYE_NAMES = { + 'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'], + 'right': ['RightEye', 'Eye_R', 'eye_R', 'eye.R', 'EyeRight', 'right_eye', 'r_eye'] +} + +class EyeTrackingBackup: + def __init__(self): + self.backup_path = os.path.join(bpy.app.tempdir, "eye_tracking_backup.json") + self.bone_positions: Dict[str, Dict[str, Tuple[float, float, float]]] = {} + + def store_bone_positions(self, armature) -> bool: + try: + self.bone_positions = { + 'LeftEye': { + 'head': tuple(armature.data.bones['LeftEye'].head_local), + 'tail': tuple(armature.data.bones['LeftEye'].tail_local) + }, + 'RightEye': { + 'head': tuple(armature.data.bones['RightEye'].head_local), + 'tail': tuple(armature.data.bones['RightEye'].tail_local) + } + } + + with open(self.backup_path, 'w') as f: + json.dump(self.bone_positions, f) + return True + except Exception as e: + logger.error(f"Backup failed: {str(e)}") + return False + + def restore_bone_positions(self, armature) -> bool: + try: + if not os.path.exists(self.backup_path): + return False + + with open(self.backup_path, 'r') as f: + backup_data = json.load(f) + + bpy.ops.object.mode_set(mode='EDIT') + + for bone_name, positions in backup_data.items(): + if bone_name in armature.data.edit_bones: + bone = armature.data.edit_bones[bone_name] + bone.head = positions['head'] + bone.tail = positions['tail'] + + return True + except Exception as e: + logger.error(f"Restore failed: {str(e)}") + return False + +class EyeTrackingValidator: + @staticmethod + def find_eye_vertex_groups(mesh_name: str) -> Tuple[str, str]: + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return None, None + + left_group = None + right_group = None + + for group in mesh.vertex_groups: + if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['left']): + left_group = group.name + if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['right']): + right_group = group.name + + return left_group, right_group + + @staticmethod + def validate_setup(context, mesh_name: str) -> Tuple[bool, str]: + armature = get_active_armature(context) + if not armature: + return False, t('EyeTracking.validation.noArmature') + + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return False, t('EyeTracking.validation.noMesh', mesh=mesh_name) + + if not mesh.data.shape_keys: + return False, t('EyeTracking.validation.noShapekeys') + + left_group, right_group = EyeTrackingValidator.find_eye_vertex_groups(mesh_name) + missing_groups = [] + + if not left_group: + missing_groups.append(t('EyeTracking.validation.leftEye')) + if not right_group: + missing_groups.append(t('EyeTracking.validation.rightEye')) + + if missing_groups: + return False, t('EyeTracking.validation.missingGroups', groups=', '.join(missing_groups)) + + required_bones = [context.scene.avatar_toolkit.head, + context.scene.avatar_toolkit.eye_left, + context.scene.avatar_toolkit.eye_right] + missing_bones = [bone for bone in required_bones if bone not in armature.data.bones] + + if missing_bones: + return False, t('EyeTracking.validation.missingBones', bones=', '.join(missing_bones)) + + return True, t('EyeTracking.validation.success') + +class CreateEyesButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.create_eye_tracking' + bl_label = t('EyeTracking.create.label') + bl_description = t('EyeTracking.create.desc') + bl_options = {'REGISTER', 'UNDO'} + + mesh = None + + @classmethod + def poll(cls, context): + if not get_all_meshes(context): + return False + + toolkit = context.scene.avatar_toolkit + if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: + return False + + if toolkit.disable_eye_blinking and toolkit.disable_eye_movement: + return False + + return True + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + with ProgressTracker(context, 100, "Creating Eye Tracking") as progress: + # Validate setup + validator = EyeTrackingValidator() + is_valid, message = validator.validate_setup(context, toolkit.mesh_name_eye) + if not is_valid: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + # Create backup + backup = EyeTrackingBackup() + if not backup.store_bone_positions(armature): + logger.warning("Failed to create backup") + + try: + # Set active object and mode + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + progress.step("Setting up bones") + + self.mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + + # Set up bones + head = armature.data.edit_bones.get(toolkit.head) + old_eye_left = armature.data.edit_bones.get(toolkit.eye_left) + old_eye_right = armature.data.edit_bones.get(toolkit.eye_right) + + if not toolkit.disable_eye_blinking: + if not all([toolkit.wink_left, toolkit.wink_right, + toolkit.lowerlid_left, toolkit.lowerlid_right]): + self.report({'ERROR'}, t('EyeTracking.error.noShapeSelected')) + return {'CANCELLED'} + + progress.step("Processing vertex groups") + + # Create new eye bones + new_left_eye = armature.data.edit_bones.new('LeftEye') + new_right_eye = armature.data.edit_bones.new('RightEye') + + # Parent them + new_left_eye.parent = head + new_right_eye.parent = head + + # Calculate positions + fix_eye_position(context, old_eye_left, new_left_eye, head, False) + fix_eye_position(context, old_eye_right, new_right_eye, head, True) + + progress.step("Processing shape keys") + + # Process shape keys + if not toolkit.disable_eye_movement: + self.copy_vertex_group(old_eye_left.name, 'LeftEye') + self.copy_vertex_group(old_eye_right.name, 'RightEye') + + # Handle shape keys + shapes = [toolkit.wink_left, toolkit.wink_right, + toolkit.lowerlid_left, toolkit.lowerlid_right] + new_shapes = ['vrc.blink_left', 'vrc.blink_right', + 'vrc.lowerlid_left', 'vrc.lowerlid_right'] + + progress.step("Finalizing setup") + + # Reset modes and cleanup + bpy.ops.object.mode_set(mode='OBJECT') + + # Update scene properties + toolkit.eye_mode = 'TESTING' + + self.report({'INFO'}, t('EyeTracking.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Eye tracking setup failed: {str(e)}") + if backup.restore_bone_positions(get_active_armature(context)): + logger.info("Restored from backup") + return {'CANCELLED'} + +class StartTestingButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.start_eye_testing' + bl_label = t('EyeTracking.testing.start.label') + bl_description = t('EyeTracking.testing.start.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones + + def execute(self, context): + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + armature.data.pose_position = 'POSE' + + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + # Save initial rotations + eye_left.rotation_mode = 'XYZ' + eye_left_rot = copy.deepcopy(eye_left.rotation_euler) + eye_right.rotation_mode = 'XYZ' + eye_right_rot = copy.deepcopy(eye_right.rotation_euler) + + if not all([eye_left, eye_right, eye_left_data, eye_right_data]): + return {'FINISHED'} + + # Reset shape keys + mesh = bpy.data.objects[context.scene.avatar_toolkit.mesh_name_eye] + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + # Clear transforms + for pb in armature.data.bones: + pb.select = True + bpy.ops.pose.transforms_clear() + for pb in armature.data.bones: + pb.select = False + pb.hide = True + + eye_left_data.hide = False + eye_right_data.hide = False + + context.scene.avatar_toolkit.eye_rotation_x = 0 + context.scene.avatar_toolkit.eye_rotation_y = 0 + + return {'FINISHED'} + +class StopTestingButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.stop_eye_testing' + bl_label = t('EyeTracking.testing.stop.label') + bl_description = t('EyeTracking.testing.stop.desc') + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + toolkit = context.scene.avatar_toolkit + + if eye_left: + toolkit.eye_rotation_x = 0 + toolkit.eye_rotation_y = 0 + + if not context.object or context.object.mode != 'POSE': + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + + armature = get_active_armature(context) + for pb in armature.data.bones: + pb.hide = False + pb.select = True + bpy.ops.pose.transforms_clear() + for pb in armature.data.bones: + pb.select = False + + mesh = bpy.data.objects[toolkit.mesh_name_eye] + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + eye_left = None + eye_right = None + eye_left_data = None + eye_right_data = None + eye_left_rot = [] + eye_right_rot = [] + + return {'FINISHED'} + +def set_rotation(self, context): + global eye_left, eye_right, eye_left_rot, eye_right_rot + toolkit = context.scene.avatar_toolkit + + if not eye_left or not eye_right: + StartTestingButton.execute(StartTestingButton, context) + return None + + eye_left.rotation_mode = 'XYZ' + eye_right.rotation_mode = 'XYZ' + + x_rotation = math.radians(toolkit.eye_rotation_x) + y_rotation = math.radians(toolkit.eye_rotation_y) + + eye_left.rotation_euler[0] = eye_left_rot[0] + x_rotation + eye_left.rotation_euler[1] = eye_left_rot[1] + y_rotation + + eye_right.rotation_euler[0] = eye_right_rot[0] + x_rotation + eye_right.rotation_euler[1] = eye_right_rot[1] + y_rotation + + return None + +class ResetRotationButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.reset_eye_rotation' + bl_label = t('EyeTracking.reset.label') + bl_description = t('EyeTracking.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + toolkit.eye_rotation_x = 0 + toolkit.eye_rotation_y = 0 + + global eye_left, eye_right, eye_left_data, eye_right_data + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + for eye in [eye_left, eye_right]: + eye.rotation_mode = 'XYZ' + for i in range(3): + eye.rotation_euler[i] = 0 + + return {'FINISHED'} + +class AdjustEyesButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.adjust_eyes' + bl_label = t('EyeTracking.adjust.label') + bl_description = t('EyeTracking.adjust.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + if toolkit.disable_eye_movement: + return {'FINISHED'} + + mesh_name = toolkit.mesh_name_eye + mesh = bpy.data.objects.get(mesh_name) + + if not mesh: + self.report({'ERROR'}, t('EyeTracking.error.noMesh')) + return {'CANCELLED'} + + for eye in ['LeftEye', 'RightEye']: + if not any(g.group == mesh.vertex_groups[eye].index for v in mesh.data.vertices for g in v.groups): + self.report({'ERROR'}, t('EyeTracking.error.noVertexGroup', bone=eye)) + return {'CANCELLED'} + + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') + + new_eye_left = armature.data.edit_bones.get('LeftEye') + new_eye_right = armature.data.edit_bones.get('RightEye') + old_eye_left = armature.pose.bones.get(toolkit.eye_left) + old_eye_right = armature.pose.bones.get(toolkit.eye_right) + + fix_eye_position(context, old_eye_left, new_eye_left, None, False) + fix_eye_position(context, old_eye_right, new_eye_right, None, True) + + bpy.ops.object.mode_set(mode='POSE') + + global eye_left, eye_right, eye_left_data, eye_right_data + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + return {'FINISHED'} + +class StartIrisHeightButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.adjust_iris_height' + bl_label = t('EyeTracking.iris.label') + bl_description = t('EyeTracking.iris.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + if toolkit.disable_eye_movement: + return {'FINISHED'} + + armature = get_active_armature(context) + armature.hide_viewport = True + + mesh = bpy.data.objects[toolkit.mesh_name_eye] + mesh.select_set(True) + context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + + if len(mesh.vertex_groups) > 0: + bpy.ops.mesh.select_mode(type='VERT') + + for vg_name in ['LeftEye', 'RightEye']: + vg = mesh.vertex_groups.get(vg_name) + if vg: + bpy.ops.object.vertex_group_set_active(group=vg.name) + bpy.ops.object.vertex_group_select() + + bm = bmesh.from_edit_mesh(mesh.data) + for v in bm.verts: + if v.select: + v.co.y += toolkit.iris_height * 0.01 + logger.debug(f"Adjusted vertex position: {v.co}") + bmesh.update_edit_mesh(mesh.data) + + return {'FINISHED'} + +class TestBlinking(bpy.types.Operator): + bl_idname = 'avatar_toolkit.test_blinking' + bl_label = t('EyeTracking.blink.test.label') + bl_description = t('EyeTracking.blink.test.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + return (mesh and mesh.data.shape_keys and + all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.blink_left', 'vrc.blink_right'])) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + shapes = ['vrc.blink_left', 'vrc.blink_right'] + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = toolkit.eye_blink_shape if shape_key.name in shapes else 0 + + return {'FINISHED'} + +class TestLowerlid(bpy.types.Operator): + bl_idname = 'avatar_toolkit.test_lowerlid' + bl_label = t('EyeTracking.lowerlid.test.label') + bl_description = t('EyeTracking.lowerlid.test.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + return (mesh and mesh.data.shape_keys and + all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.lowerlid_left', 'vrc.lowerlid_right'])) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + shapes = OrderedDict() + shapes['vrc.lowerlid_left'] = toolkit.eye_lowerlid_shape + shapes['vrc.lowerlid_right'] = toolkit.eye_lowerlid_shape + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = toolkit.eye_lowerlid_shape if shape_key.name in shapes else 0 + + return {'FINISHED'} + +class ResetBlinkTest(bpy.types.Operator): + bl_idname = 'avatar_toolkit.reset_blink_test' + bl_label = t('EyeTracking.blink.reset.label') + bl_description = t('EyeTracking.blink.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + toolkit.eye_blink_shape = 1 + toolkit.eye_lowerlid_shape = 1 + + return {'FINISHED'} + +def fix_eye_position(context, old_eye, new_eye, head, right_side): + toolkit = context.scene.avatar_toolkit + scale = -toolkit.eye_distance + 1 + mesh = bpy.data.objects[toolkit.mesh_name_eye] + + if not toolkit.disable_eye_movement: + if head: + coords_eye = find_center_vector_of_vertex_group(mesh, old_eye.name) + else: + coords_eye = find_center_vector_of_vertex_group(mesh, new_eye.name) + + if coords_eye is False: + return + + if head: + p1 = mesh.matrix_world @ head.head + p2 = mesh.matrix_world @ coords_eye + length = (p1 - p2).length + logger.debug(f"Eye distance: {length}") + + x_cord, y_cord, z_cord = get_bone_orientations() + + if toolkit.disable_eye_movement: + if head is not None: + new_eye.head[x_cord] = head.head[x_cord] + (0.05 if right_side else -0.05) + new_eye.head[y_cord] = head.head[y_cord] + new_eye.head[z_cord] = head.head[z_cord] + else: + new_eye.head[x_cord] = old_eye.head[x_cord] + scale * (coords_eye[0] - old_eye.head[x_cord]) + new_eye.head[y_cord] = old_eye.head[y_cord] + scale * (coords_eye[1] - old_eye.head[y_cord]) + new_eye.head[z_cord] = old_eye.head[z_cord] + scale * (coords_eye[2] - old_eye.head[z_cord]) + + new_eye.tail[x_cord] = new_eye.head[x_cord] + new_eye.tail[y_cord] = new_eye.head[y_cord] + new_eye.tail[z_cord] = new_eye.head[z_cord] + 0.1 + +def repair_shapekeys(mesh_name, vertex_group): + """Fix VRC shape keys by slightly adjusting vertex positions""" + armature = get_active_armature(bpy.context) + mesh = bpy.data.objects[mesh_name] + mesh.select_set(True) + bpy.context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + bm = bmesh.new() + bm.from_mesh(mesh.data) + bm.verts.ensure_lookup_table() + + logger.debug(f'Processing vertex group: {vertex_group}') + group = mesh.vertex_groups.get(vertex_group) + if group is None: + logger.warning(f'Group {vertex_group} not found, using fallback method') + repair_shapekeys_mouth(mesh_name) + return + + vcoords = None + gi = group.index + for v in mesh.data.vertices: + for g in v.groups: + if g.group == gi: + vcoords = v.co.xyz + + if not vcoords: + return + + logger.info('Repairing shape keys') + moved = False + i = 0 + for key in bm.verts.layers.shape.keys(): + if not key.startswith('vrc.'): + continue + logger.debug(f'Repairing shape: {key}') + value = bm.verts.layers.shape.get(key) + for index, vert in enumerate(bm.verts): + if vert.co.xyz == vcoords: + if index < i: + continue + shapekey = vert + shapekey_coords = mesh.matrix_world @ shapekey[value] + shapekey_coords[0] -= 0.00007 * randBoolNumber() + shapekey_coords[1] -= 0.00007 * randBoolNumber() + shapekey_coords[2] -= 0.00007 * randBoolNumber() + shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords + logger.debug(f'Repaired shape: {key}') + i += 1 + moved = True + break + + bm.to_mesh(mesh.data) + + if not moved: + logger.warning('Shape key repair failed, using random method') + repair_shapekeys_mouth(mesh_name) + +def randBoolNumber(): + return -1 if random() < 0.5 else 1 + +def repair_shapekeys_mouth(mesh_name): + mesh = bpy.data.objects[mesh_name] + mesh.select_set(True) + bpy.context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + bm = bmesh.new() + bm.from_mesh(mesh.data) + bm.verts.ensure_lookup_table() + + moved = False + for key in bm.verts.layers.shape.keys(): + if not key.startswith('vrc'): + continue + value = bm.verts.layers.shape.get(key) + for vert in bm.verts: + shapekey = vert + shapekey_coords = mesh.matrix_world @ shapekey[value] + shapekey_coords[0] -= 0.00007 + shapekey_coords[1] -= 0.00007 + shapekey_coords[2] -= 0.00007 + shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords + moved = True + break + + bm.to_mesh(mesh.data) + + if not moved: + logger.error('Random shape key repair failed') + +def get_bone_orientations(): + """Get bone orientation axes""" + return (0, 1, 2) # x, y, z coordinates + +def find_center_vector_of_vertex_group(mesh, group_name): + """Calculate center position of vertex group""" + group = mesh.vertex_groups.get(group_name) + if not group: + return False + + vertices = [] + for vert in mesh.data.vertices: + for g in vert.groups: + if g.group == group.index: + vertices.append(vert.co) + + if not vertices: + return False + + return sum((v for v in vertices), mathutils.Vector()) / len(vertices) + +def vertex_group_exists(mesh_obj, group_name): + """Check if vertex group exists and has weights""" + if not mesh_obj or group_name not in mesh_obj.vertex_groups: + return False + + group = mesh_obj.vertex_groups[group_name] + for vert in mesh_obj.data.vertices: + for g in vert.groups: + if g.group == group.index and g.weight > 0: + return True + return False + +def copy_vertex_group(self, vertex_group, rename_to): + """Copy vertex group with new name""" + vertex_group_index = 0 + for group in self.mesh.vertex_groups: + if group.name == vertex_group: + self.mesh.vertex_groups.active_index = vertex_group_index + bpy.ops.object.vertex_group_copy() + self.mesh.vertex_groups[vertex_group + '_copy'].name = rename_to + break + vertex_group_index += 1 + +def copy_shape_key(self, context, from_shape, new_names, new_index): + """Copy shape key with new name""" + blinking = not context.scene.avatar_toolkit.disable_eye_blinking + new_name = new_names[new_index - 1] + + # Rename existing shapekey if it exists + for shapekey in self.mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + if shapekey.name == new_name: + shapekey.name = shapekey.name + '_old' + if from_shape == new_name: + from_shape = shapekey.name + + # Create new shape key + for index, shapekey in enumerate(self.mesh.data.shape_keys.key_blocks): + if from_shape == shapekey.name: + self.mesh.active_shape_key_index = index + shapekey.value = 1 + self.mesh.shape_key_add(name=new_name, from_mix=blinking) + break + + # Reset shape keys + for shapekey in self.mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + self.mesh.active_shape_key_index = 0 + + return from_shape + +# Global state for eye tracking +eye_left = None +eye_right = None +eye_left_data = None +eye_right_data = None +eye_left_rot = [] +eye_right_rot = [] + +class VertexGroupCache: + """Cache for vertex group operations""" + _cache = {} + + @classmethod + def get_vertex_indices(cls, mesh_name: str, group_name: str) -> Optional[set]: + cache_key = f"{mesh_name}_{group_name}" + + if cache_key in cls._cache: + return cls._cache[cache_key] + + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return None + + group = mesh.vertex_groups.get(group_name) + if not group: + return None + + indices = {v.index for v in mesh.data.vertices + if any(g.group == group.index for g in v.groups)} + + cls._cache[cache_key] = indices + return indices + + @classmethod + def clear_cache(cls): + cls._cache.clear() + +class RotateEyeBonesForAv3Button(Operator): + """Reorient eye bones for proper VRChat eye tracking""" + bl_idname = "avatar_toolkit.rotate_eye_bones" + bl_label = t("EyeTracking.rotate.label") + bl_description = t("EyeTracking.rotate.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + armature = get_active_armature(context) + straight_up_matrix = mathutils.Matrix.Rotation(math.pi/2, 3, 'X') + + bpy.ops.object.mode_set(mode='EDIT') + + for eye_name in ['LeftEye', 'RightEye']: + eye_bone = armature.data.edit_bones[eye_name] + new_matrix = straight_up_matrix.to_4x4() + new_matrix.translation = eye_bone.matrix.translation + eye_bone.matrix = new_matrix + + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} + +class ResetEyeTrackingButton(Operator): + """Reset all eye tracking settings and state""" + bl_idname = 'avatar_toolkit.reset_eye_tracking' + bl_label = t('EyeTracking.reset.label') + bl_description = t('EyeTracking.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + 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' + return {'FINISHED'} + +def validate_weights(mesh_obj: Object, vertex_group: str) -> bool: + """Validate vertex group weights""" + group = mesh_obj.vertex_groups.get(vertex_group) + if not group: + return False + + for vertex in mesh_obj.data.vertices: + for group_element in vertex.groups: + if group_element.group == group.index and group_element.weight > 0: + return True + return False + +def get_eye_bone_names(armature: Object) -> Dict[str, str]: + """Get standardized eye bone names""" + eye_bones = {'left': None, 'right': None} + + for bone in armature.data.bones: + if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['left']): + eye_bones['left'] = bone.name + if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['right']): + eye_bones['right'] = bone.name + + return eye_bones + +def stop_testing(context: Context) -> None: + """Stop eye tracking testing mode""" + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + + if not all([eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot]): + return + + armature = get_active_armature(context) + if not armature: + return + + bpy.ops.object.mode_set(mode='POSE') + + # Reset rotations + context.scene.avatar_toolkit.eye_rotation_x = 0 + context.scene.avatar_toolkit.eye_rotation_y = 0 + + # Clear transforms + for bone in armature.data.bones: + bone.hide = False + bone.select = True + bpy.ops.pose.transforms_clear() + + # Reset shape keys + mesh = bpy.data.objects.get(context.scene.avatar_toolkit.mesh_name_eye) + if mesh and mesh.data.shape_keys: + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + # Clear globals + eye_left = eye_right = eye_left_data = eye_right_data = None + eye_left_rot = eye_right_rot = [] diff --git a/functions/visemes.py b/functions/visemes.py new file mode 100644 index 0000000..f6ac3b5 --- /dev/null +++ b/functions/visemes.py @@ -0,0 +1,335 @@ +# MIT License +# This code was taken from Cats Blender Plugin Unoffical, some of this code is by the original developers, however was improved by myself. +# Didn't think it was necessary to re-make something that works well. + +import bpy +from typing import Dict, List, Optional, Tuple, Any, Set +from bpy.types import Operator, Context, Object, ShapeKey +from collections import OrderedDict +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 +) + +class VisemeCache: + """Caches generated viseme shape data""" + _cache: Dict = {} + + @classmethod + def get_cached_shape(cls, key: str, mix_data: List) -> Optional[List]: + cache_key = (key, tuple(tuple(x) for x in mix_data)) + return cls._cache.get(cache_key) + + @classmethod + def cache_shape(cls, key: str, mix_data: List, shape_data: List) -> None: + cache_key = (key, tuple(tuple(x) for x in mix_data)) + cls._cache[cache_key] = shape_data + +class VisemePreview: + """Handles viseme preview functionality""" + _preview_data: Dict = {} + _active: bool = False + _preview_shapes: Optional[OrderedDict] = None + + @classmethod + def start_preview(cls, context: Context, mesh: Object, shapes: List[str]) -> bool: + if not mesh or not mesh.data or not mesh.data.shape_keys: + return False + + cls._active = True + cls._preview_data = {} + + # Store original values + for shape_key in mesh.data.shape_keys.key_blocks: + cls._preview_data[shape_key.name] = shape_key.value + + # Get properties from avatar_toolkit + props = context.scene.avatar_toolkit + shape_a = props.mouth_a + shape_o = props.mouth_o + shape_ch = props.mouth_ch + + + cls._preview_shapes = OrderedDict() + cls._preview_shapes['vrc.v_aa'] = {'mix': [[(shape_a), (0.9998)]]} + cls._preview_shapes['vrc.v_ch'] = {'mix': [[(shape_ch), (0.9996)]]} + cls._preview_shapes['vrc.v_dd'] = {'mix': [[(shape_a), (0.3)], [(shape_ch), (0.7)]]} + cls._preview_shapes['vrc.v_ih'] = {'mix': [[(shape_ch), (0.7)], [(shape_o), (0.3)]]} + cls._preview_shapes['vrc.v_ff'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.4)]]} + cls._preview_shapes['vrc.v_e'] = {'mix': [[(shape_a), (0.5)], [(shape_ch), (0.2)]]} + cls._preview_shapes['vrc.v_kk'] = {'mix': [[(shape_a), (0.7)], [(shape_ch), (0.4)]]} + cls._preview_shapes['vrc.v_nn'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.7)]]} + cls._preview_shapes['vrc.v_oh'] = {'mix': [[(shape_a), (0.2)], [(shape_o), (0.8)]]} + cls._preview_shapes['vrc.v_ou'] = {'mix': [[(shape_o), (0.9994)]]} + cls._preview_shapes['vrc.v_pp'] = {'mix': [[(shape_a), (0.0004)], [(shape_o), (0.0004)]]} + cls._preview_shapes['vrc.v_rr'] = {'mix': [[(shape_ch), (0.5)], [(shape_o), (0.3)]]} + cls._preview_shapes['vrc.v_sil'] = {'mix': [[(shape_a), (0.0002)], [(shape_ch), (0.0002)]]} + cls._preview_shapes['vrc.v_ss'] = {'mix': [[(shape_ch), (0.8)]]} + cls._preview_shapes['vrc.v_th'] = {'mix': [[(shape_a), (0.4)], [(shape_o), (0.15)]]} + + return True + + @classmethod + def update_preview(cls, context: Context) -> None: + if not cls._active or not cls._preview_shapes: + return + + mesh = context.active_object + props = context.scene.avatar_toolkit + viseme_data = cls._preview_shapes.get(props.viseme_preview_selection) + if viseme_data: + cls.show_viseme(context, mesh, props.viseme_preview_selection, viseme_data['mix']) + + @classmethod + def show_viseme(cls, context: Context, mesh: Object, viseme_name: str, mix_data: List) -> None: + if not cls._active: + return + + # Get shape intensity from properties + intensity = context.scene.avatar_toolkit.shape_intensity + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + for shape_name, value in mix_data: + if shape_name in mesh.data.shape_keys.key_blocks: + # Apply intensity to the preview value + mesh.data.shape_keys.key_blocks[shape_name].value = value * intensity + + context.view_layer.update() + + + @classmethod + def end_preview(cls, mesh: Object) -> None: + if not cls._active: + return + + for shape_name, value in cls._preview_data.items(): + if shape_name in mesh.data.shape_keys.key_blocks: + mesh.data.shape_keys.key_blocks[shape_name].value = value + + cls._active = False + cls._preview_data.clear() + cls._preview_shapes = None + +class ATOOLKIT_OT_preview_visemes(Operator): + bl_idname = "avatar_toolkit.preview_visemes" + bl_label = t("Visemes.preview_label") + bl_description = t("Visemes.preview_desc") + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + @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.active_object and context.active_object.type == 'MESH' + + def execute(self, context: Context) -> Set[str]: + props = context.scene.avatar_toolkit + mesh = context.active_object + + if props.viseme_preview_mode: + VisemePreview.end_preview(mesh) + props.viseme_preview_mode = False + else: + if not mesh.data.shape_keys: + self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) + return {'CANCELLED'} + + if VisemePreview.start_preview(context, mesh, [props.mouth_a, props.mouth_o, props.mouth_ch]): + props.viseme_preview_mode = True + props.viseme_preview_selection = 'vrc.v_aa' + + return {'FINISHED'} + +def validate_deformation(mesh, mix_data): + """Validates if shape key deformations are within reasonable ranges""" + base_coords = [v.co.copy() for v in mesh.data.shape_keys.key_blocks['Basis'].data] + max_deform = 0 + + for shape_data in mix_data: + shape_name, value = shape_data + if shape_name in mesh.data.shape_keys.key_blocks: + shape_key = mesh.data.shape_keys.key_blocks[shape_name] + for i, v in enumerate(shape_key.data): + deform = (v.co - base_coords[i]).length * value + max_deform = max(max_deform, deform) + + mesh_size = max(mesh.dimensions) + return max_deform < (mesh_size * 0.4) + +class ATOOLKIT_OT_create_visemes(Operator): + bl_idname = "avatar_toolkit.create_visemes" + bl_label = t("Visemes.create_label") + bl_description = t("Visemes.create_desc") + bl_options = {'REGISTER', 'UNDO'} + + @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.active_object and context.active_object.type == 'MESH' + + def execute(self, context: Context) -> Set[str]: + props = context.scene.avatar_toolkit + mesh = context.active_object + + if not mesh.data.shape_keys: + self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) + return {'CANCELLED'} + + if props.mouth_a == "Basis" or props.mouth_o == "Basis" or props.mouth_ch == "Basis": + self.report({'ERROR'}, t("Visemes.error.select_shapekeys")) + return {'CANCELLED'} + + try: + self.create_visemes(context, mesh) + self.report({'INFO'}, t("Visemes.success")) + return {'FINISHED'} + except Exception as e: + logger.error(f"Error creating visemes: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def create_visemes(self, context: Context, mesh: Object) -> None: + """Creates viseme shape keys by mixing existing shape keys""" + props = context.scene.avatar_toolkit + wm = context.window_manager + + # Store original shape key names + shapes = [props.mouth_a, props.mouth_o, props.mouth_ch] + renamed_shapes = shapes.copy() + + # Temporarily rename selected shapes to avoid conflicts + for shapekey in mesh.data.shape_keys.key_blocks: + if shapekey.name == props.mouth_a: + shapekey.name = f"{shapekey.name}_old" + props.mouth_a = shapekey.name + renamed_shapes[0] = shapekey.name + elif shapekey.name == props.mouth_o: + if props.mouth_a != props.mouth_o: + shapekey.name = f"{shapekey.name}_old" + props.mouth_o = shapekey.name + renamed_shapes[1] = shapekey.name + elif shapekey.name == props.mouth_ch: + if props.mouth_a != props.mouth_ch and props.mouth_o != props.mouth_ch: + shapekey.name = f"{shapekey.name}_old" + props.mouth_ch = shapekey.name + renamed_shapes[2] = shapekey.name + + # Define viseme shape key data + shapekey_data = OrderedDict() + shapekey_data['vrc.v_aa'] = {'mix': [[(props.mouth_a), (0.9998)]]} + shapekey_data['vrc.v_ch'] = {'mix': [[(props.mouth_ch), (0.9996)]]} + shapekey_data['vrc.v_dd'] = {'mix': [[(props.mouth_a), (0.3)], [(props.mouth_ch), (0.7)]]} + shapekey_data['vrc.v_ih'] = {'mix': [[(props.mouth_ch), (0.7)], [(props.mouth_o), (0.3)]]} + shapekey_data['vrc.v_ff'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.4)]]} + shapekey_data['vrc.v_e'] = {'mix': [[(props.mouth_a), (0.5)], [(props.mouth_ch), (0.2)]]} + shapekey_data['vrc.v_kk'] = {'mix': [[(props.mouth_a), (0.7)], [(props.mouth_ch), (0.4)]]} + shapekey_data['vrc.v_nn'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.7)]]} + shapekey_data['vrc.v_oh'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_o), (0.8)]]} + shapekey_data['vrc.v_ou'] = {'mix': [[(props.mouth_o), (0.9994)]]} + shapekey_data['vrc.v_pp'] = {'mix': [[(props.mouth_a), (0.0004)], [(props.mouth_o), (0.0004)]]} + shapekey_data['vrc.v_rr'] = {'mix': [[(props.mouth_ch), (0.5)], [(props.mouth_o), (0.3)]]} + shapekey_data['vrc.v_sil'] = {'mix': [[(props.mouth_a), (0.0002)], [(props.mouth_ch), (0.0002)]]} + shapekey_data['vrc.v_ss'] = {'mix': [[(props.mouth_ch), (0.8)]]} + shapekey_data['vrc.v_th'] = {'mix': [[(props.mouth_a), (0.4)], [(props.mouth_o), (0.15)]]} + + # Create progress tracker + total_steps = len(shapekey_data) + wm.progress_begin(0, total_steps) + + # Create viseme shape keys + for index, (key, data) in enumerate(shapekey_data.items()): + wm.progress_update(index) + + # Check cache first + cached_data = VisemeCache.get_cached_shape(key, data['mix']) + if cached_data: + continue + + # Create new shape key + self.mix_shapekey(context, renamed_shapes, data['mix'], key) + + # Cache the new shape key data + shape_data = [v.co.copy() for v in mesh.data.shape_keys.key_blocks[key].data] + VisemeCache.cache_shape(key, data['mix'], shape_data) + + # Restore original shape key names + self.restore_shape_names(context, mesh, shapes, renamed_shapes) + + # Cleanup and finalize + 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: + """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) + bpy.ops.object.shape_key_remove() + + # Reset all shape keys + for shapekey in mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + + # Set mix values + for shape_name, value in mix_data: + if shape_name in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks[shape_name] + shapekey.value = value + + # Create mixed shape key + mesh.shape_key_add(name=new_name, from_mix=True) + + # Reset values and restore shape key settings + for shapekey in mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + if shapekey.name in shapes: + shapekey.slider_max = 1 + + def restore_shape_names(self, context: Context, mesh: Object, original_names: List[str], current_names: List[str]) -> None: + """Restores original shape key names""" + props = context.scene.avatar_toolkit + + # Restore mouth_a + if original_names[0] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[0]) + if shapekey: + shapekey.name = original_names[0] + if current_names[2] == current_names[0]: + current_names[2] = original_names[0] + if current_names[1] == current_names[0]: + current_names[1] = original_names[0] + current_names[0] = original_names[0] + + # Restore mouth_o + if original_names[1] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[1]) + if shapekey: + shapekey.name = original_names[1] + if current_names[2] == current_names[1]: + current_names[2] = original_names[1] + current_names[1] = original_names[1] + + # Restore mouth_ch + if original_names[2] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[2]) + if shapekey: + shapekey.name = original_names[2] + current_names[2] = original_names[2] + + # Update properties + 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 7067f0b..635bc20 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -207,6 +207,103 @@ "MMD.connect_bones": "Connect Bones", "MMD.connect_bones_desc": "Connect bones in chain where appropriate", + "Visemes.panel_label": "Visemes", + "Visemes.shape_selection": "Shape Key Selection", + "Visemes.controls": "Viseme Controls", + "Visemes.no_shapekeys": "Select a mesh with shape keys", + "Visemes.mouth_a": "A Shape", + "Visemes.mouth_a_desc": "Shape key for 'A' sound", + "Visemes.mouth_o": "O Shape", + "Visemes.mouth_o_desc": "Shape key for 'O' sound", + "Visemes.mouth_ch": "CH Shape", + "Visemes.mouth_ch_desc": "Shape key for 'CH' sound", + "Visemes.shape_intensity": "Shape Intensity", + "Visemes.shape_intensity_desc": "Intensity multiplier for viseme shapes", + "Visemes.start_preview": "Start Preview", + "Visemes.stop_preview": "Stop Preview", + "Visemes.preview_mode_desc": "Toggle viseme preview mode", + "Visemes.preview_selection": "Preview Selection", + "Visemes.preview_selection_desc": "Select viseme to preview", + "Visemes.preview_label": "Preview Visemes", + "Visemes.preview_desc": "Preview viseme shapes in viewport", + "Visemes.create_label": "Create Visemes", + "Visemes.create_desc": "Create VRC viseme shape keys", + "Visemes.error.no_shapekeys": "Mesh has no shape keys", + "Visemes.error.select_shapekeys": "Please select shape keys for A, O and CH", + "Visemes.success": "Visemes created successfully", + + "EyeTracking.label": "Eye Tracking", + "EyeTracking.setup": "Eye Tracking Setup", + "EyeTracking.mesh_select": "Mesh Selection", + "EyeTracking.bones": "Bone Selection", + "EyeTracking.head_bone": "Head Bone", + "EyeTracking.eye_left": "Left Eye Bone", + "EyeTracking.eye_right": "Right Eye Bone", + "EyeTracking.shapekeys": "Shape Key Selection", + "EyeTracking.options": "Options", + "EyeTracking.rotation": "Eye Rotation", + "EyeTracking.rotation.x": "Vertical Rotation", + "EyeTracking.rotation.y": "Horizontal Rotation", + "EyeTracking.adjust": "Eye Adjustments", + "EyeTracking.blinking": "Blinking Controls", + "EyeTracking.no_shapekeys": "No shape keys found on selected mesh", + "EyeTracking.no_armature": "No armature selected", + "EyeTracking.no_mesh": "No mesh found", + "EyeTracking.create.label": "Create Eye Tracking", + "EyeTracking.create.desc": "Set up eye tracking bones and shape keys", + "EyeTracking.testing.start.label": "Start Testing", + "EyeTracking.testing.start.desc": "Enter eye tracking test mode", + "EyeTracking.testing.stop.label": "Stop Testing", + "EyeTracking.testing.stop.desc": "Exit eye tracking test mode", + "EyeTracking.reset.label": "Reset Eye Tracking", + "EyeTracking.reset.desc": "Reset all eye tracking settings", + "EyeTracking.rotate.label": "Rotate Eye Bones", + "EyeTracking.rotate.desc": "Rotate eye bones for VRChat compatibility", + "EyeTracking.iris.label": "Adjust Iris Height", + "EyeTracking.iris.desc": "Adjust the height of iris vertices", + "EyeTracking.blink.test.label": "Test Blink", + "EyeTracking.blink.test.desc": "Test eye blinking shape keys", + "EyeTracking.lowerlid.test.label": "Test Lower Lid", + "EyeTracking.lowerlid.test.desc": "Test lower lid shape keys", + "EyeTracking.blink.reset.label": "Reset Blink Test", + "EyeTracking.blink.reset.desc": "Reset blink testing values", + "EyeTracking.validation.noArmature": "No armature found in scene", + "EyeTracking.validation.noMesh": "Mesh '{mesh}' not found", + "EyeTracking.validation.noShapekeys": "Selected mesh has no shape keys", + "EyeTracking.validation.leftEye": "Left Eye", + "EyeTracking.validation.rightEye": "Right Eye", + "EyeTracking.validation.missingGroups": "Missing vertex groups: {groups}", + "EyeTracking.validation.missingBones": "Missing required bones: {bones}", + "EyeTracking.validation.success": "Eye tracking setup validated successfully", + "EyeTracking.error.noMesh": "No mesh selected for eye tracking", + "EyeTracking.error.noVertexGroup": "No vertex group found for bone: {bone}", + "EyeTracking.error.noShapeSelected": "Please select all required shape keys", + "EyeTracking.success": "Eye tracking setup completed successfully", + "EyeTracking.mode_select": "Mode Selection", + "EyeTracking.mesh_setup": "Mesh Setup", + "EyeTracking.bone_setup": "Bone Setup", + "EyeTracking.shapekey_setup": "Shape Key Setup", + "EyeTracking.testing": "Testing Mode", + "EyeTracking.rotation_controls": "Eye Rotation Controls", + "EyeTracking.adjustments": "Eye Adjustments", + "EyeTracking.blink_testing": "Blink Testing", + "EyeTracking.wink_left": "Left Wink", + "EyeTracking.wink_right": "Right Wink", + "EyeTracking.lowerlid_left": "Left Lower Lid", + "EyeTracking.lowerlid_right": "Right Lower Lid", + "EyeTracking.mode.creation": "Creation Mode", + "EyeTracking.mode.testing": "Testing Mode", + "EyeTracking.disable_blinking": "Disable Eye Blinking", + "EyeTracking.disable_movement": "Disable Eye Movement", + "EyeTracking.distance": "Eye Distance", + "EyeTracking.distance_desc": "Adjust the distance between eyes", + "EyeTracking.mode": "Eye Tracking Mode", + "EyeTracking.mesh_name": "Mesh", + "EyeTracking.mesh_name_desc": "Select mesh for eye tracking", + "EyeTracking.head_bone_desc": "Select head bone", + "EyeTracking.eye_left_desc": "Select left eye bone", + "EyeTracking.eye_right_desc": "Select right eye bone", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py new file mode 100644 index 0000000..4ce4017 --- /dev/null +++ b/ui/eye_tracking_panel.py @@ -0,0 +1,143 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.common import get_active_armature, get_all_meshes +from ..functions.eye_tracking import ( + CreateEyesButton, + StartTestingButton, + StopTestingButton, + ResetRotationButton, + AdjustEyesButton, + TestBlinking, + TestLowerlid, + ResetBlinkTest, + ResetEyeTrackingButton, + RotateEyeBonesForAv3Button +) + +class AvatarToolKit_PT_EyeTrackingPanel(Panel): + """Panel containing eye tracking setup and testing tools""" + bl_label = t("EyeTracking.label") + bl_idname = "VIEW3D_PT_avatar_toolkit_eye_tracking" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the eye tracking panel interface""" + layout = self.layout + toolkit = context.scene.avatar_toolkit + + # Mode Selection Box + mode_box = layout.box() + col = mode_box.column(align=True) + col.label(text=t("EyeTracking.mode_select"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "eye_mode", expand=True) + + if toolkit.eye_mode == 'CREATION': + # Mesh Setup Box + mesh_box = layout.box() + col = mesh_box.column(align=True) + col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA') + col.separator(factor=0.5) + col.prop(toolkit, "mesh_name_eye", text="") + + # Bone Setup Box + bone_box = layout.box() + col = bone_box.column(align=True) + col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') + col.separator(factor=0.5) + + armature = get_active_armature(context) + if armature: + col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) + col.prop_search(toolkit, "eye_left", armature.data, "bones", text=t("EyeTracking.eye_left")) + col.prop_search(toolkit, "eye_right", armature.data, "bones", text=t("EyeTracking.eye_right")) + else: + col.label(text=t("EyeTracking.no_armature"), icon='ERROR') + + # Shapekey Setup Box + shape_box = layout.box() + col = shape_box.column(align=True) + col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') + col.separator(factor=0.5) + + 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")) + col.prop_search(toolkit, "wink_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_right")) + col.prop_search(toolkit, "lowerlid_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_left")) + col.prop_search(toolkit, "lowerlid_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_right")) + else: + col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR') + + # Options Box + options_box = layout.box() + col = options_box.column(align=True) + col.label(text=t("EyeTracking.options"), icon='SETTINGS') + col.separator(factor=0.5) + 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 = layout.row(align=True) + row.scale_y = 1.5 + row.operator(CreateEyesButton.bl_idname, icon='PLAY') + + else: + if context.mode != 'POSE': + # Testing Start Box + test_box = layout.box() + col = test_box.column(align=True) + col.label(text=t("EyeTracking.testing"), icon='PLAY') + col.separator(factor=0.5) + row = col.row(align=True) + row.scale_y = 1.5 + row.operator(StartTestingButton.bl_idname, icon='PLAY') + else: + # Eye Rotation Box + rotation_box = layout.box() + col = rotation_box.column(align=True) + col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') + col.separator(factor=0.5) + 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 = layout.box() + col = adjust_box.column(align=True) + col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER') + col.separator(factor=0.5) + col.prop(toolkit, "eye_distance") + col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO') + + # Blinking Test Box + blink_box = layout.box() + col = blink_box.column(align=True) + col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF') + col.separator(factor=0.5) + row = col.row(align=True) + row.prop(toolkit, "eye_blink_shape") + row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF') + row = col.row(align=True) + row.prop(toolkit, "eye_lowerlid_shape") + row.operator(TestLowerlid.bl_idname, icon='RESTRICT_VIEW_OFF') + col.operator(ResetBlinkTest.bl_idname, icon='LOOP_BACK') + + # Stop Testing Button + row = layout.row(align=True) + row.scale_y = 1.5 + row.operator(StopTestingButton.bl_idname, icon='PAUSE') + + # Reset Button + row = layout.row(align=True) + row.operator(ResetEyeTrackingButton.bl_idname, icon='FILE_REFRESH') diff --git a/ui/main_panel.py b/ui/main_panel.py index 6ae130d..16bf637 100644 --- a/ui/main_panel.py +++ b/ui/main_panel.py @@ -31,7 +31,6 @@ class AvatarToolKit_PT_AvatarToolkitPanel(Panel): bl_space_type: str = 'VIEW_3D' bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME - bl_options: Set[str] = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the main panel layout""" diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py index 96210b5..d7333dc 100644 --- a/ui/mmd_panel.py +++ b/ui/mmd_panel.py @@ -1,46 +1,49 @@ -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 +# MMD Tools disabled for the time being unto it can be fixed. -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 +# 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 - def draw(self, context: Context) -> None: - layout: UILayout = self.layout - toolkit = context.scene.avatar_toolkit +# 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') +# 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') +# 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') +# 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/optimization_panel.py b/ui/optimization_panel.py index 2e65ec1..57e9802 100644 --- a/ui/optimization_panel.py +++ b/ui/optimization_panel.py @@ -13,6 +13,7 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 1 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draws the optimization panel interface with material, mesh cleanup and join mesh tools""" diff --git a/ui/settings_panel.py b/ui/settings_panel.py index 67b782f..6650698 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -37,6 +37,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 5 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection""" diff --git a/ui/tools_panel.py b/ui/tools_panel.py index a55d734..ce0614a 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -13,6 +13,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 2 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the tools panel interface""" diff --git a/ui/visemes_panel.py b/ui/visemes_panel.py new file mode 100644 index 0000000..33f4d9f --- /dev/null +++ b/ui/visemes_panel.py @@ -0,0 +1,60 @@ +from bpy.types import Panel, Context, UILayout +from ..core.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME + +class AvatarToolKit_PT_VisemesPanel(Panel): + """Panel containing viseme creation and preview tools""" + bl_label: str = t("Visemes.panel_label") + bl_idname: str = "VIEW3D_PT_avatar_toolkit_visemes" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 4 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the visemes panel interface""" + layout: UILayout = self.layout + props = context.scene.avatar_toolkit + + # Check for valid mesh with shape keys + if not context.active_object or context.active_object.type != 'MESH' or not context.active_object.data.shape_keys: + layout.label(text=t("Visemes.no_shapekeys")) + return + + # Shape Key Selection Box + shape_box: UILayout = layout.box() + col: UILayout = shape_box.column(align=True) + col.label(text=t("Visemes.shape_selection"), icon='SHAPEKEY_DATA') + col.separator(factor=0.5) + + # Shape key selection with valid data + shape_keys = context.active_object.data.shape_keys + col.prop_search(props, "mouth_a", shape_keys, "key_blocks", text=t("Visemes.mouth_a")) + col.prop_search(props, "mouth_o", shape_keys, "key_blocks", text=t("Visemes.mouth_o")) + col.prop_search(props, "mouth_ch", shape_keys, "key_blocks", text=t("Visemes.mouth_ch")) + + # Shape intensity slider + col.separator() + col.prop(props, "shape_intensity", slider=True) + + # Preview Box + preview_box: UILayout = layout.box() + col = preview_box.column(align=True) + col.label(text=t("Visemes.preview_label"), icon='HIDE_OFF') + col.separator(factor=0.5) + + if props.viseme_preview_mode: + col.prop(props, "viseme_preview_selection", text="") + col.separator() + + preview_text = t("Visemes.stop_preview") if props.viseme_preview_mode else t("Visemes.start_preview") + col.operator("avatar_toolkit.preview_visemes", text=preview_text, icon='HIDE_OFF') + + # Create Box + create_box: UILayout = layout.box() + col = create_box.column(align=True) + col.label(text=t("Visemes.create_label"), icon='ADD') + col.separator(factor=0.5) + col.operator("avatar_toolkit.create_visemes", icon='ADD')