Files
Avatar-Toolkit/functions/eye_tracking.py
T
989onan 6d9f751a16 Housekeeping (bug fixes)
NEW FEATURES:
- added apply shapekey to basis from Cats
  - now that pesky thing I keep going back to cats for is in Avatar Toolkit.

BUG FIXES:
- now we push armature santizers into functions where they are needed
  - this prevents the methods from mirroring changes while working, causing them to blow up when mirror mode is on
  - more changes to come for armature setting santitizers
- fixed error reporting
  - now methods when catching errors will return full error tracebacks
  - this will help make debugging and finding user issues easier.
2025-07-10 18:44:42 -04:00

986 lines
36 KiB
Python

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 = []