Added Eye tracking and Visemes

This commit is contained in:
Yusarina
2024-12-15 20:14:26 +00:00
parent f4dc74d091
commit 87a351cea4
13 changed files with 1807 additions and 39 deletions
+91
View File
@@ -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
+169
View File
@@ -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")
+1
View File
@@ -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
+867
View File
@@ -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 = []
+335
View File
@@ -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]
+97
View File
@@ -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",
+143
View File
@@ -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')
-1
View File
@@ -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"""
+41 -38
View File
@@ -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')
+1
View File
@@ -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"""
+1
View File
@@ -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"""
+1
View File
@@ -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"""
+60
View File
@@ -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')