import os import bpy import copy import math import bmesh import mathutils import json from bpy.types import Operator, Object, Context, UILayout, WindowManager, Event, ShapeKey, EditBone, PoseBone from typing import Optional, Dict, Tuple, Set, List, Any, Union, ClassVar from collections import OrderedDict from random import random from itertools import chain import traceback 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_mesh_for_pose, cache_vertex_positions, apply_vertex_positions ) from ..core.armature_validation import validate_armature VALID_EYE_NAMES: Dict[str, List[str]] = { 'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'], 'right': ['RightEye', 'Eye_R', 'eye_R', 'eye.R', 'EyeRight', 'right_eye', 'r_eye'] } class CreateEyesAV3Button(bpy.types.Operator): """Creates eye tracking setup compatible with VRChat Avatar 3.0 system""" bl_idname: str = 'avatar_toolkit.create_eye_tracking_av3' bl_label: str = t('EyeTracking.create.av3.label') bl_description: str = t('EyeTracking.create.av3.desc') bl_options: Set[str] = {'REGISTER', 'UNDO'} mesh: Optional[Object] = None @classmethod def poll(cls, context): toolkit = context.scene.avatar_toolkit if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: return False return True def execute(self, context): toolkit = context.scene.avatar_toolkit armature = get_active_armature(context) with ProgressTracker(context, 100, "Creating AV3 Eye Tracking") as progress: try: context.view_layer.objects.active = armature bpy.ops.object.mode_set(mode='EDIT') progress.step("Setting up bones") # 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) # Store original names and transformations left_name = old_eye_left.name right_name = old_eye_right.name left_matrix = old_eye_left.matrix.copy() right_matrix = old_eye_right.matrix.copy() left_length = old_eye_left.length right_length = old_eye_right.length # Unparent and remove original bones old_eye_left.parent = None old_eye_right.parent = None armature.data.edit_bones.remove(old_eye_left) armature.data.edit_bones.remove(old_eye_right) # Create new eye bones with original names new_left_eye = armature.data.edit_bones.new(left_name) new_right_eye = armature.data.edit_bones.new(right_name) # Parent them new_left_eye.parent = head new_right_eye.parent = head # Calculate straight up orientation matrix straight_up_matrix = mathutils.Matrix.Rotation(math.pi/2, 3, 'X') # Apply rotation while preserving position for eye_data in [(new_left_eye, left_matrix, left_length), (new_right_eye, right_matrix, right_length)]: new_eye, orig_matrix, length = eye_data new_matrix = straight_up_matrix.to_4x4() new_matrix.translation = orig_matrix.translation new_eye.matrix = new_matrix new_eye.length = length # Disable mirroring to prevent unwanted behavior armature.data.use_mirror_x = False progress.step("Finalizing setup") bpy.ops.object.mode_set(mode='OBJECT') self.report({'INFO'}, t('EyeTracking.success')) return {'FINISHED'} except Exception: logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class CreateEyesSDK2Button(bpy.types.Operator): """Creates eye tracking setup compatible with VRChat SDK2 system""" bl_idname: str = 'avatar_toolkit.create_eye_tracking_sdk2' bl_label: str = t('EyeTracking.create.sdk2.label') bl_description: str = t('EyeTracking.create.sdk2.desc') bl_options: Set[str] = {'REGISTER', 'UNDO'} mesh: Optional[Object] = 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 SDK2 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'} try: 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) # 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 for SDK2 style 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 vertex groups") if not toolkit.disable_eye_movement: # Switch to object mode for vertex group operations bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') self.mesh.select_set(True) context.view_layer.objects.active = self.mesh copy_vertex_group(self, old_eye_left.name, 'LeftEye') copy_vertex_group(self, old_eye_right.name, 'RightEye') # Return to armature edit mode context.view_layer.objects.active = armature bpy.ops.object.mode_set(mode='EDIT') progress.step("Processing shape keys") if not toolkit.disable_eye_blinking: 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") bpy.ops.object.mode_set(mode='OBJECT') toolkit.eye_mode = 'TESTING' self.report({'INFO'}, t('EyeTracking.success')) return {'FINISHED'} except Exception as e: logger.error(f"Eye tracking setup failed: {traceback.format_exc()}") return {'CANCELLED'} class EyeTrackingBackup: """Manages backup and restoration of eye bone positions""" def __init__(self) -> None: self.backup_path: str = 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: logger.error(f"Backup failed: {traceback.format_exc()}") 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: logger.error(f"Restore failed: {traceback.format_exc()}") return False class EyeTrackingValidator: """Validates eye tracking setup requirements and configurations""" @staticmethod def find_eye_vertex_groups(mesh_name: str) -> Tuple[Optional[str], Optional[str]]: """Locates left and right eye vertex groups in mesh""" 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: Context, mesh_name: str) -> Tuple[bool, str]: """Validates complete eye tracking setup configuration""" 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 StartTestingButton(bpy.types.Operator): """Initiates eye tracking testing mode""" bl_idname: str = 'avatar_toolkit.start_eye_testing' bl_label: str = t('EyeTracking.testing.start.label') bl_description: str = t('EyeTracking.testing.start.desc') bl_options: Set[str] = {'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): """Terminates eye tracking testing mode""" bl_idname: str = 'avatar_toolkit.stop_eye_testing' bl_label: str = t('EyeTracking.testing.stop.label') bl_description: str = t('EyeTracking.testing.stop.desc') bl_options: Set[str] = {'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 = [] bpy.ops.object.mode_set(mode='OBJECT') return {'FINISHED'} def set_rotation(self, context): """Updates eye bone rotations based on current settings""" 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 # Check if rotation data is available if not eye_left_rot or len(eye_left_rot) < 3 or not eye_right_rot or len(eye_right_rot) < 3: # Initialize rotation data if missing eye_left.rotation_mode = 'XYZ' eye_left_rot = list(eye_left.rotation_euler) eye_right.rotation_mode = 'XYZ' eye_right_rot = list(eye_right.rotation_euler) eye_left.rotation_mode = 'XYZ' eye_right.rotation_mode = 'XYZ' 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): """Resets eye bone rotations to default values""" bl_idname: str = 'avatar_toolkit.reset_eye_rotation' bl_label: str = t('EyeTracking.reset.label') bl_description: str = t('EyeTracking.reset.desc') bl_options: Set[str] = {'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): """Adjusts eye bone positions and orientations""" bl_idname: str = 'avatar_toolkit.adjust_eyes' bl_label: str = t('EyeTracking.adjust.label') bl_description: str = t('EyeTracking.adjust.desc') bl_options: Set[str] = {'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): """Adjusts iris height for eye meshes""" bl_idname: str = 'avatar_toolkit.adjust_iris_height' bl_label: str = t('EyeTracking.iris.label') bl_description: str = t('EyeTracking.iris.desc') bl_options: Set[str] = {'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): """Tests eye blinking animations""" bl_idname: str = 'avatar_toolkit.test_blinking' bl_label: str = t('EyeTracking.blink.test.label') bl_description: str = t('EyeTracking.blink.test.desc') bl_options: Set[str] = {'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): """Tests lower eyelid movements""" bl_idname: str = 'avatar_toolkit.test_lowerlid' bl_label: str = t('EyeTracking.lowerlid.test.label') bl_description: str = t('EyeTracking.lowerlid.test.desc') bl_options: Set[str] = {'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): """Resets all eye blinking test values""" bl_idname: str = 'avatar_toolkit.reset_blink_test' bl_label: str = t('EyeTracking.blink.reset.label') bl_description: str = t('EyeTracking.blink.reset.desc') bl_options: Set[str] = {'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: Context, old_eye: Union[EditBone, PoseBone], new_eye: EditBone, head: Optional[EditBone], right_side: bool) -> None: """Adjusts eye bone positions and orientations for proper tracking""" 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: str, vertex_group: str) -> None: """Repairs VRChat shape keys by 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() -> int: """Generates random boolean value as integer""" return -1 if random() < 0.5 else 1 def repair_shapekeys_mouth(mesh_name: str) -> None: """Repairs mouth-related shape keys using fallback method""" 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() -> Tuple[int, int, int]: """Returns standardized bone orientation axes""" return (0, 1, 2) # x, y, z coordinates def find_center_vector_of_vertex_group(mesh: Object, group_name: str) -> Union[mathutils.Vector, bool]: """Calculates 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: Object, group_name: str) -> bool: """Verifies existence and validity of vertex group""" 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: Any, vertex_group: str, rename_to: str) -> None: """Creates copy of vertex group with new name""" vertex_group_index = 0 # Select and make mesh active bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') self.mesh.select_set(True) bpy.context.view_layer.objects.active = self.mesh 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: Any, context: Context, from_shape: str, new_names: List[str], new_index: int) -> str: """Creates copy of 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): """Reorients eye bones for VRChat Avatar 3.0 compatibility""" bl_idname: str = "avatar_toolkit.rotate_eye_bones" bl_label: str = t("EyeTracking.rotate.label") bl_description: str = t("EyeTracking.rotate.desc") bl_options: Set[str] = {'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): """Resets all eye tracking settings to default values""" bl_idname: str = 'avatar_toolkit.reset_eye_tracking' bl_label: str = t('EyeTracking.reset.label') bl_description: str = t('EyeTracking.reset.desc') bl_options: Set[str] = {'REGISTER', 'UNDO'} def execute(self, context): global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot context.scene.avatar_toolkit.eye_mode = 'CREATION' context.scene.avatar_toolkit.eye_rotation_x = 0 context.scene.avatar_toolkit.eye_rotation_y = 0 eye_left = None eye_right = None eye_left_data = None eye_right_data = None eye_left_rot = [] eye_right_rot = [] mesh_name = context.scene.avatar_toolkit.mesh_name_eye mesh = bpy.data.objects.get(mesh_name) if mesh and mesh.data.shape_keys: for shape_key in mesh.data.shape_keys.key_blocks: shape_key.value = 0 return {'FINISHED'} def validate_weights(mesh_obj: Object, vertex_group: str) -> bool: """Validates vertex group weight assignments""" 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, Optional[str]]: """Retrieves standardized eye bone names from armature""" 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: """Stops eye tracking testing mode and resets all values""" 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 = []