Merge branch 'main' into pr/82
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
from ..core.register import register_wrap
|
||||
|
||||
#to reload all things in this directory and import them properly - @989onan
|
||||
if "bpy" not in locals():
|
||||
import bpy
|
||||
import glob
|
||||
import os
|
||||
from os.path import dirname, basename, isfile, join
|
||||
modules = glob.glob(join(dirname(__file__), "*.py"))
|
||||
for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]:
|
||||
exec("from . import "+module_name)
|
||||
print("importing " +module_name)
|
||||
else:
|
||||
import importlib
|
||||
modules = glob.glob(join(dirname(__file__), "*.py"))
|
||||
for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]:
|
||||
exec("importlib.reload("+module_name+")")
|
||||
print("reloading " +module_name)
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import bpy
|
||||
import math
|
||||
from bpy.types import Context, Operator
|
||||
from ..core.register import register_wrap
|
||||
from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes
|
||||
from ..functions.translations import t
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_ApplyTransforms(Operator):
|
||||
bl_idname = "avatar_toolkit.apply_transforms"
|
||||
bl_label = t("Tools.apply_transforms.label")
|
||||
bl_description = t("Tools.apply_transforms.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return get_selected_armature(context) is not None
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not is_valid_armature(armature):
|
||||
self.report({'ERROR'}, t("Tools.apply_transforms.invalid_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
meshes = get_all_meshes(context)
|
||||
for mesh in meshes:
|
||||
mesh.select_set(True)
|
||||
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
self.report({'INFO'}, t("Tools.apply_transforms.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_ConnectBones(Operator):
|
||||
bl_idname = "avatar_toolkit.connect_bones"
|
||||
bl_label = t("Tools.connect_bones.label")
|
||||
bl_description = t("Tools.connect_bones.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
min_distance: bpy.props.FloatProperty(
|
||||
name=t("Tools.connect_bones.min_distance.label"),
|
||||
description=t("Tools.connect_bones.min_distance.desc"),
|
||||
default=0.005,
|
||||
min=0.001,
|
||||
max=0.1
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return get_selected_armature(context) is not None
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not is_valid_armature(armature):
|
||||
self.report({'ERROR'}, t("Tools.connect_bones.invalid_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
edit_bones = armature.data.edit_bones
|
||||
bones_connected = 0
|
||||
|
||||
for bone in edit_bones:
|
||||
if len(bone.children) == 1 and bone.name not in ['LeftEye', 'RightEye', 'Head', 'Hips']:
|
||||
child = bone.children[0]
|
||||
distance = math.dist(bone.head, child.head)
|
||||
|
||||
if distance > self.min_distance:
|
||||
bone.tail = child.head
|
||||
if bone.parent and len(bone.parent.children) == 1:
|
||||
bone.use_connect = True
|
||||
bones_connected += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
self.report({'INFO'}, t("Tools.connect_bones.success").format(bones_connected=bones_connected))
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "min_distance")
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_DeleteBoneConstraints(Operator):
|
||||
bl_idname = "avatar_toolkit.delete_bone_constraints"
|
||||
bl_label = t("Tools.delete_bone_constraints.label")
|
||||
bl_description = t("Tools.delete_bone_constraints.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return get_selected_armature(context) is not None
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not is_valid_armature(armature):
|
||||
self.report({'ERROR'}, t("Tools.delete_bone_constraints.invalid_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
constraints_removed = 0
|
||||
for bone in armature.pose.bones:
|
||||
while bone.constraints:
|
||||
bone.constraints.remove(bone.constraints[0])
|
||||
constraints_removed += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
self.report({'INFO'}, t("Tools.delete_bone_constraints.success").format(constraints_removed=constraints_removed))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_SeparateByMaterials(Operator):
|
||||
bl_idname = "avatar_toolkit.separate_by_materials"
|
||||
bl_label = t("Tools.separate_by_materials.label")
|
||||
bl_description = t("Tools.separate_by_materials.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return context.active_object and context.active_object.type == 'MESH'
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
obj = context.active_object
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='MATERIAL')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.separate_by_materials.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_SeparateByLooseParts(Operator):
|
||||
bl_idname = "avatar_toolkit.separate_by_loose_parts"
|
||||
bl_label = t("Tools.separate_by_loose_parts.label")
|
||||
bl_description = t("Tools.separate_by_loose_parts.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return context.active_object and context.active_object.type == 'MESH'
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
obj = context.active_object
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.separate_by_loose_parts.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import bpy
|
||||
from ..core.register import register_wrap
|
||||
from bpy.types import Context, Mesh, Panel, Operator, Armature, EditBone
|
||||
from ..functions.translations import t
|
||||
from ..core.common import get_selected_armature, get_all_meshes
|
||||
from ..core import common
|
||||
from ..core.dictionaries import bone_names
|
||||
from mathutils import Matrix
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_StartPoseMode(Operator):
|
||||
bl_idname = 'avatar_toolkit.start_pose_mode'
|
||||
bl_label = t("Quick_Access.start_pose_mode.label")
|
||||
bl_description = t("Quick_Access.start_pose_mode.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_selected_armature(context) != None and context.mode != "POSE"
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
|
||||
#give an active object so the next line doesn't throw an error.
|
||||
context.view_layer.objects.active = get_selected_armature(context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
#deselect everything and select just our armature, then go into pose on just our selected armature. - @989onan
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = get_selected_armature(context)
|
||||
context.view_layer.objects.active.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_StopPoseMode(Operator):
|
||||
bl_idname = 'avatar_toolkit.stop_pose_mode'
|
||||
bl_label = t("Quick_Access.stop_pose_mode.label")
|
||||
bl_description = t("Quick_Access.stop_pose_mode.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_selected_armature(context) != None and context.mode == "POSE"
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
#this is done so that transforms are cleared but user selection is respected. - @989onan
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.pose.select_all(action="INVERT")
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.pose.select_all(action="INVERT")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_shapekey'
|
||||
bl_label = t("Quick_Access.apply_pose_as_shapekey.label")
|
||||
bl_description = t("Quick_Access.apply_pose_as_shapekey.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = common.get_selected_armature(context)
|
||||
return armature and context.mode == 'POSE'
|
||||
|
||||
def execute(self, context):
|
||||
armature_obj = common.get_selected_armature(context)
|
||||
mesh_objects = common.get_all_meshes(context)
|
||||
|
||||
for mesh_obj in mesh_objects:
|
||||
if not mesh_obj.data:
|
||||
continue
|
||||
|
||||
# Ensure basis exists
|
||||
if not mesh_obj.data.shape_keys:
|
||||
mesh_obj.shape_key_add(name='Basis')
|
||||
|
||||
# Store current pose as new shapekey
|
||||
new_shape = mesh_obj.shape_key_add(name='Pose_Shapekey', from_mix=False)
|
||||
|
||||
# Evaluate mesh in current pose
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_mesh = mesh_obj.evaluated_get(depsgraph)
|
||||
|
||||
# Apply evaluated vertices to new shapekey
|
||||
for i, v in enumerate(eval_mesh.data.vertices):
|
||||
new_shape.data[i].co = v.co.copy()
|
||||
|
||||
# Reset pose
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
self.report({'INFO'}, t('Tools.apply_pose_as_rest.success'))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_ApplyPoseAsRest(Operator):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_rest'
|
||||
bl_label = t("Quick_Access.apply_pose_as_rest.label")
|
||||
bl_description = t("Quick_Access.apply_pose_as_rest.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return get_selected_armature(context) != None and context.mode == "POSE"
|
||||
|
||||
def execute(self, context: Context):
|
||||
if not common.apply_pose_as_rest(armature_obj=get_selected_armature(context),
|
||||
meshes=get_all_meshes(context),
|
||||
context=context):
|
||||
self.report({'ERROR'}, t("Quick_Access.apply_armature_failed"))
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_RemoveZeroWeightBones(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_zero_weight_bones"
|
||||
bl_label = t("Tools.remove_zero_weight_bones.label")
|
||||
bl_description = t("Tools.remove_zero_weight_bones.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
threshold: bpy.props.FloatProperty(
|
||||
default=0.01,
|
||||
name=t("Tools.remove_zero_weight_bones.threshold.label"),
|
||||
description=t("Tools.remove_zero_weight_bones.threshold.desc"),
|
||||
min=0.0000001,
|
||||
max=0.9999999)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return common.get_selected_armature(context) is not None
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = common.get_selected_armature(context)
|
||||
if not common.is_valid_armature(armature):
|
||||
self.report({'ERROR'}, t("Tools.apply_transforms.invalid_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
weighted_bones: list[str] = []
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Store initial transforms
|
||||
initial_transforms = {}
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone in armature.data.edit_bones:
|
||||
initial_transforms[bone.name] = {
|
||||
'head': bone.head.copy(),
|
||||
'tail': bone.tail.copy(),
|
||||
'roll': bone.roll,
|
||||
'matrix': bone.matrix.copy(),
|
||||
'parent': bone.parent.name if bone.parent else None
|
||||
}
|
||||
|
||||
# Get weighted bones
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
meshes = common.get_all_meshes(context)
|
||||
for mesh in meshes:
|
||||
mesh_data: Mesh = mesh.data
|
||||
for vertex in mesh_data.vertices:
|
||||
for group in vertex.groups:
|
||||
if group.weight > self.threshold:
|
||||
weighted_bones.append(mesh.vertex_groups[group.group].name)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
amature_data: Armature = armature.data
|
||||
unweighted_bones: list[str] = []
|
||||
|
||||
# Identify unweighted bones
|
||||
for bone in amature_data.edit_bones:
|
||||
if bone.name not in weighted_bones:
|
||||
unweighted_bones.append(bone.name)
|
||||
|
||||
# Process bone removal while preserving positions
|
||||
for bone_name in unweighted_bones:
|
||||
bone = amature_data.edit_bones[bone_name]
|
||||
|
||||
# Store children data
|
||||
children = bone.children
|
||||
children_data = {}
|
||||
for child in children:
|
||||
children_data[child.name] = initial_transforms[child.name]
|
||||
|
||||
# Reparent children
|
||||
for child in children:
|
||||
child.use_connect = False
|
||||
if bone.parent:
|
||||
child.parent = bone.parent
|
||||
|
||||
# Remove bone
|
||||
amature_data.edit_bones.remove(bone)
|
||||
|
||||
# Restore children positions
|
||||
for child_name, data in children_data.items():
|
||||
if child_name in amature_data.edit_bones:
|
||||
child = amature_data.edit_bones[child_name]
|
||||
child.head = data['head']
|
||||
child.tail = data['tail']
|
||||
child.roll = data['roll']
|
||||
child.matrix = data['matrix']
|
||||
|
||||
# Final position verification
|
||||
for bone_name, transform in initial_transforms.items():
|
||||
if bone_name in amature_data.edit_bones:
|
||||
bone = amature_data.edit_bones[bone_name]
|
||||
bone.matrix = transform['matrix']
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.remove_zero_weight_bones.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_MergeBonesToActive(Operator):
|
||||
bl_idname = "avatar_toolkit.merge_bones_to_active"
|
||||
bl_label = t("Tools.merge_bones_to_active.label")
|
||||
bl_description = t("Tools.merge_bones_to_active.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
delete_old: bpy.props.BoolProperty(name=t("Tools.merge_bones_to_active.delete_old.label"), description=t("Tools.merge_bones_to_active.delete_old.desc"), default=False)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
if common.get_selected_armature(context) is not None:
|
||||
if common.get_selected_armature(context) == context.view_layer.objects.active:
|
||||
if context.mode == "POSE":
|
||||
return len(context.selected_pose_bones) > 1
|
||||
elif context.mode == "EDIT_ARMATURE":
|
||||
return len(context.selected_bones) > 1
|
||||
return False
|
||||
|
||||
def execute(cls, context: Context) -> set[str]:
|
||||
|
||||
prev_mode: str = "EDIT"
|
||||
if context.mode == "POSE":
|
||||
prev_mode = "POSE"
|
||||
|
||||
#get active bone and a list of all other selected bones
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
target_bone: str = context.active_bone.name
|
||||
|
||||
armature_data: Armature = context.view_layer.objects.active.data
|
||||
|
||||
|
||||
bones: list[str] = [i.name for i in context.selected_bones]
|
||||
bones.remove(target_bone)
|
||||
|
||||
for obj in common.get_all_meshes(context):
|
||||
for bone in bones:
|
||||
bone_name: str = armature_data.edit_bones[bone].name
|
||||
common.transfer_vertex_weights(context=context,obj=obj,source_group=bone_name,target_group=armature_data.edit_bones[target_bone].name)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone in bones:
|
||||
if cls.delete_old:
|
||||
for bone_child in armature_data.edit_bones[bone].children:
|
||||
bone_child.parent = armature_data.edit_bones[bone].parent
|
||||
armature_data.edit_bones.remove(armature_data.edit_bones[bone])
|
||||
|
||||
bpy.ops.object.mode_set(mode=prev_mode)
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_MergeBonesToParents(Operator):
|
||||
bl_idname = "avatar_toolkit.merge_bones_to_parents"
|
||||
bl_label = t("Tools.merge_bones_to_parents.label")
|
||||
bl_description = t("Tools.merge_bones_to_parents.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
delete_old: bpy.props.BoolProperty(
|
||||
name=t("Tools.merge_bones_to_parents.delete_old.label"),
|
||||
description=t("Tools.merge_bones_to_parents.delete_old.desc"),
|
||||
default=False
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = common.get_selected_armature(context)
|
||||
if armature and armature == context.view_layer.objects.active:
|
||||
if context.mode == "POSE":
|
||||
return len(context.selected_pose_bones) > 0
|
||||
elif context.mode == "EDIT_ARMATURE":
|
||||
return len(context.selected_editable_bones) > 0
|
||||
return False
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
prev_mode = context.mode
|
||||
|
||||
# Map 'EDIT_ARMATURE' to 'EDIT' for bpy.ops.object.mode_set
|
||||
if prev_mode == 'EDIT_ARMATURE':
|
||||
prev_mode = 'EDIT'
|
||||
|
||||
# Switch to Edit Mode
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
armature_data: Armature = context.view_layer.objects.active.data
|
||||
|
||||
# Get selected bones in Edit Mode
|
||||
selected_bones = context.selected_editable_bones
|
||||
selected_bone_names = [bone.name for bone in selected_bones]
|
||||
|
||||
if not selected_bone_names:
|
||||
self.report({'ERROR'}, t("No bones selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
for obj in common.get_all_meshes(context):
|
||||
for bone_name in selected_bone_names:
|
||||
bone = armature_data.edit_bones.get(bone_name)
|
||||
if bone and bone.parent:
|
||||
# Transfer weights from bone to its parent
|
||||
common.transfer_vertex_weights(
|
||||
context=context,
|
||||
obj=obj,
|
||||
source_group=bone_name,
|
||||
target_group=bone.parent.name
|
||||
)
|
||||
# Ensure we're in Edit Mode after transfer
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
else:
|
||||
self.report({'WARNING'}, f"Bone '{bone_name}' has no parent or not found; skipping")
|
||||
|
||||
# Optionally delete old bones
|
||||
if self.delete_old:
|
||||
for bone_name in selected_bone_names:
|
||||
bone = armature_data.edit_bones.get(bone_name)
|
||||
if bone:
|
||||
# Reassign children to the parent of the bone being deleted
|
||||
for child in bone.children:
|
||||
child.parent = bone.parent
|
||||
# Remove the bone
|
||||
armature_data.edit_bones.remove(bone)
|
||||
else:
|
||||
self.report({'WARNING'}, f"Bone '{bone_name}' not found in armature; cannot delete")
|
||||
|
||||
# Return to previous mode
|
||||
bpy.ops.object.mode_set(mode=prev_mode)
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_MergeArmatures(Operator):
|
||||
bl_idname = "avatar_toolkit.merge_armatures"
|
||||
bl_label = t("MergeArmature.merge_armatures.label")
|
||||
bl_description = t("MergeArmature.merge_armatures.desc").format(selected_armature_label=t("MergeArmatures.selected_armature.label"))
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return (common.get_selected_armature(context) is not None) and (common.get_merge_armature_source(context) is not None)
|
||||
|
||||
def make_active(self, obj: bpy.types.Object, context: Context):
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
|
||||
def execute(cls, context: Context) -> set[str]:
|
||||
source_armature: bpy.types.Object = bpy.data.objects[context.scene.merge_armature_source]
|
||||
source_armature_data: Armature = source_armature.data
|
||||
target_armature: bpy.types.Object = common.get_selected_armature(context)
|
||||
target_armature_data: Armature = target_armature.data
|
||||
parent_dictionary: dict[str, list[str]] = {}
|
||||
|
||||
cls.make_active(obj=source_armature, context=context)
|
||||
|
||||
|
||||
|
||||
if context.scene.merge_armature_apply_transforms:
|
||||
target_armature.select_set(True)
|
||||
for obj in target_armature.children:
|
||||
obj.select_set(True)
|
||||
for obj in source_armature.children:
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.transform_apply()
|
||||
|
||||
|
||||
if context.scene.merge_armature_align_bones:
|
||||
if not context.scene.merge_armature_apply_transforms:
|
||||
source_armature.matrix_world = target_armature.matrix_world
|
||||
|
||||
def children_bone_recursive(parent_bone) -> list[bpy.types.PoseBone]:
|
||||
child_bones = []
|
||||
child_bones.append(parent_bone)
|
||||
for child in parent_bone.children:
|
||||
child_bones.extend(children_bone_recursive(child))
|
||||
return child_bones
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
source_armature_bone_names = [j.name for j in children_bone_recursive(
|
||||
source_armature.pose.bones[
|
||||
next(bone.name for bone in source_armature.pose.bones if common.simplify_bonename(bone.name) in bone_names['hips']) #Find bone that matches dictionary for hips before continuing.
|
||||
]
|
||||
)] #bones are default in order of parent child.
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = target_armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for source_bone_name in source_armature_bone_names:
|
||||
|
||||
if source_bone_name in target_armature_data.edit_bones:
|
||||
obj = source_armature
|
||||
editbone = target_armature_data.edit_bones[source_bone_name]
|
||||
bone = obj.pose.bones[source_bone_name]
|
||||
bone.matrix = editbone.matrix
|
||||
else:
|
||||
continue
|
||||
if not common.apply_pose_as_rest(armature_obj=source_armature,meshes=[i for i in source_armature.children if i.type == 'MESH'], context=context):
|
||||
cls.report({'ERROR'}, t("Quick_Access.apply_armature_failed"))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
cls.make_active(obj=source_armature, context=context)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
source_armature_data: Armature = source_armature.data
|
||||
for bone_name in [i.name for i in source_armature_data.edit_bones]:
|
||||
if bone_name in target_armature_data.bones:
|
||||
parent_dictionary[bone_name] = [i.name for i in source_armature_data.edit_bones[bone_name].children]
|
||||
source_armature_data.edit_bones.remove(source_armature_data.edit_bones[bone_name])
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
cls.make_active(obj=target_armature, context=context)
|
||||
source_armature.select_set(True)
|
||||
|
||||
bpy.ops.object.join()
|
||||
target_armature: bpy.types.Object = common.get_selected_armature(context)
|
||||
cls.make_active(obj=target_armature, context=context)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name, bone_name_list in parent_dictionary.items():
|
||||
if bone_name in target_armature_data.edit_bones:
|
||||
for bone_child in bone_name_list:
|
||||
target_armature_data.edit_bones[bone_child].parent = target_armature_data.edit_bones[bone_name]
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -1,298 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy
|
||||
import bpy
|
||||
import os
|
||||
from typing import List, Tuple, Optional
|
||||
from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap
|
||||
from ..core.register import register_wrap
|
||||
from ..core.common import SceneMatClass, MaterialListBool
|
||||
from ..core.packer.rectangle_packer import MaterialImageList, BinPacker
|
||||
from ..functions.translations import t
|
||||
|
||||
class MaterialImageList:
|
||||
def __init__(self):
|
||||
self.albedo: Image = None
|
||||
self.normal: Image = None
|
||||
self.emission: Image = None
|
||||
self.ambient_occlusion: Image = None
|
||||
self.height: Image = None
|
||||
self.roughness: Image = None
|
||||
self.material: Material = None
|
||||
self.parent_mesh: Object = None
|
||||
self.w: int = 0
|
||||
self.h: int = 0
|
||||
self.fit = None
|
||||
|
||||
def scale_images_to_largest(images: list[Image]) -> set:
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
|
||||
# Filter out None or invalid images
|
||||
valid_images = [img for img in images if img and img.has_data]
|
||||
|
||||
if not valid_images:
|
||||
return 0, 0
|
||||
|
||||
for image in valid_images:
|
||||
x = max(x, image.size[0])
|
||||
y = max(y, image.size[1])
|
||||
|
||||
for image in valid_images:
|
||||
image.scale(width=int(x), height=int(y))
|
||||
|
||||
return x, y
|
||||
|
||||
def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> list[Image]:
|
||||
list_of_images: list[Image] = []
|
||||
|
||||
list_of_images.append(classitem.albedo)
|
||||
list_of_images.append(classitem.normal)
|
||||
list_of_images.append(classitem.emission)
|
||||
list_of_images.append(classitem.ambient_occlusion)
|
||||
list_of_images.append(classitem.height)
|
||||
list_of_images.append(classitem.roughness)
|
||||
|
||||
return list_of_images
|
||||
|
||||
|
||||
def get_material_images_from_scene(context: Context) -> list[MaterialImageList]:
|
||||
material_image_list: list[MaterialImageList] = []
|
||||
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'MESH':
|
||||
for mat_slot in obj.material_slots:
|
||||
# Only process materials that are selected for atlas
|
||||
if mat_slot.material and mat_slot.material.include_in_atlas is True:
|
||||
new_mat_image_item = MaterialImageList()
|
||||
try:
|
||||
new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_albedo_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
|
||||
try:
|
||||
new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_normal_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32)
|
||||
try:
|
||||
new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_emission_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32)
|
||||
try:
|
||||
new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_ambient_occlusion_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32)
|
||||
try:
|
||||
new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_height_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32)
|
||||
try:
|
||||
new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness]
|
||||
except Exception:
|
||||
name = mat_slot.material.name + "_roughness_replacement"
|
||||
if name in bpy.data.images:
|
||||
bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True)
|
||||
new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True)
|
||||
new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32)
|
||||
|
||||
new_mat_image_item.material = mat_slot.material
|
||||
new_mat_image_item.parent_mesh = obj
|
||||
material_image_list.append(new_mat_image_item)
|
||||
|
||||
return material_image_list
|
||||
|
||||
|
||||
def prep_images_in_scene(context: Context) -> list[MaterialImageList]:
|
||||
preped_images: list[MaterialImageList] = get_material_images_from_scene(context)
|
||||
for MaterialImageClass in preped_images:
|
||||
ImageList: list[Image] = MaterialImageList_to_Image_list(MaterialImageClass)
|
||||
|
||||
MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList)
|
||||
|
||||
|
||||
|
||||
return preped_images
|
||||
|
||||
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_AtlasMaterials(Operator):
|
||||
|
||||
bl_idname = "avatar_toolkit.atlas_materials"
|
||||
bl_label = t("TextureAtlas.atlas_materials")
|
||||
bl_description = t("TextureAtlas.atlas_materials_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return context.scene.texture_atlas_Has_Mat_List_Shown
|
||||
|
||||
def execute(self, context: Context) -> set:
|
||||
try:
|
||||
# Get only materials that are explicitly marked for inclusion
|
||||
selected_materials = [m for m in prep_images_in_scene(context) if m.material and m.material.include_in_atlas is True]
|
||||
|
||||
if not selected_materials:
|
||||
self.report({'WARNING'}, t("TextureAtlas.no_materials_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
packer: BinPacker = BinPacker(selected_materials)
|
||||
mat_images = packer.fit()
|
||||
|
||||
size: list[int] = [max([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images]),
|
||||
max([matimg.fit.h + matimg.albedo.size[1] for matimg in mat_images])]
|
||||
print([matimg.fit.w + matimg.albedo.size[1] for matimg in mat_images])
|
||||
|
||||
atlased_mat: MaterialImageList = MaterialImageList()
|
||||
|
||||
for mat in mat_images:
|
||||
x: int = int(mat.fit.x)
|
||||
y: int = int(mat.fit.y)
|
||||
w: int = int(mat.albedo.size[0])
|
||||
h: int = int(mat.albedo.size[1])
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH':
|
||||
mesh: Mesh = obj.data
|
||||
for layer in mesh.polygons:
|
||||
if obj.material_slots[layer.material_index].material:
|
||||
if obj.material_slots[layer.material_index].material == mat.material:
|
||||
for loop_idx in layer.loop_indices:
|
||||
layer_loops: MeshUVLoopLayer
|
||||
for layer_loops in mesh.uv_layers:
|
||||
uv_item: Float2AttributeValue = layer_loops.uv[loop_idx]
|
||||
uv_item.vector.x = (uv_item.vector.x*(w/size[0]))+(x/size[0])
|
||||
uv_item.vector.y = (uv_item.vector.y*(h/size[1]))+(y/size[1])
|
||||
|
||||
for type in ["albedo","normal", "emission","ambient_occlusion","height", "roughness"]:
|
||||
new_image_name: str= "Atlas_"+type+"_"+context.scene.name+"_"+Path(bpy.data.filepath).stem
|
||||
|
||||
print("Processing "+type+" atlas image")
|
||||
|
||||
if new_image_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[new_image_name])
|
||||
|
||||
canvas: Image = bpy.data.images.new(name=new_image_name, width=int(size[0]),height=int(size[1]), alpha=True)
|
||||
c_w = canvas.size[0]
|
||||
canvas_pixels: list[float] = list(canvas.pixels[:])
|
||||
for mat in mat_images:
|
||||
x: int = int(mat.fit.x)
|
||||
y: int = int(mat.fit.y)
|
||||
w: int = int(mat.albedo.size[0])
|
||||
h: int = int(mat.albedo.size[1])
|
||||
|
||||
image_var: Image = eval("mat."+type)
|
||||
|
||||
image_pixels: list[float] = list(image_var.pixels[:])
|
||||
|
||||
print("writing image \""+image_var.name+"\" to canvas.")
|
||||
print("x: \""+str(x)+"\" "+"y: \""+str(y)+"\" "+"w: \""+str(w)+"\" "+"h: \""+str(h)+"\" ")
|
||||
for k in range(0,h):
|
||||
for i in range(0, w):
|
||||
for channel in range(0,4):
|
||||
canvas_pixels[
|
||||
int((((k+y)*c_w)
|
||||
+
|
||||
(i+x))*4)
|
||||
+int(channel)
|
||||
] = image_pixels[
|
||||
int((
|
||||
(k*w)
|
||||
+i)*4)
|
||||
+int(channel)]
|
||||
|
||||
canvas.pixels[:] = canvas_pixels[:]
|
||||
canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath),new_image_name+".png"))
|
||||
exec("atlased_mat."+type+" = canvas")
|
||||
|
||||
#I am sorry for the amount of nodes I'm instanciating here and their values.
|
||||
#This is so that the nodes look pretty in the UI, which I think looks kinda nice. - @989onan
|
||||
atlased_mat.material = bpy.data.materials.new(name="Atlas_Final_"+bpy.context.scene.name+"_"+Path(bpy.data.filepath).stem)
|
||||
atlased_mat.material.use_nodes = True
|
||||
atlased_mat.material.node_tree.nodes.clear()
|
||||
|
||||
principled_node: ShaderNodeBsdfPrincipled = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled")
|
||||
principled_node.location.x = 7.29706335067749
|
||||
principled_node.location.y = 298.918212890625
|
||||
|
||||
output_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeOutputMaterial")
|
||||
output_node.location.x = 297.29705810546875
|
||||
output_node.location.y = 298.918212890625
|
||||
|
||||
albedo_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
albedo_node.location.x = -588.6177978515625
|
||||
albedo_node.location.y = 414.1948547363281
|
||||
albedo_node.image = atlased_mat.albedo
|
||||
|
||||
emission_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
emission_node.location.x = -588.6177978515625
|
||||
emission_node.location.y = -173.9259033203125
|
||||
emission_node.image = atlased_mat.emission
|
||||
|
||||
normal_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
normal_node.location.x = -941.4189453125
|
||||
normal_node.location.y = -20.8391780853271
|
||||
normal_node.image = atlased_mat.normal
|
||||
|
||||
normal_map_node: ShaderNodeNormalMap = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeNormalMap")
|
||||
normal_map_node.location.x = -545.550537109375
|
||||
normal_map_node.location.y = -0.7543716430664062
|
||||
|
||||
roughness_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
roughness_node.location.x = -592.1703491210938
|
||||
roughness_node.location.y = 206.74075317382812
|
||||
roughness_node.image = atlased_mat.roughness
|
||||
|
||||
ambient_occlusion_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
ambient_occlusion_node.location.x = -906.4371337890625
|
||||
ambient_occlusion_node.location.y = -389.9602355957031
|
||||
ambient_occlusion_node.image = atlased_mat.ambient_occlusion
|
||||
|
||||
height_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
height_node.location.x = -1222.383056640625
|
||||
height_node.location.y = -375.48406982421875
|
||||
height_node.image = atlased_mat.height
|
||||
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Metallic"], roughness_node.outputs["Alpha"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Roughness"], roughness_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Normal"], normal_map_node.outputs["Normal"])
|
||||
atlased_mat.material.node_tree.links.new(principled_node.inputs["Emission Color"], emission_node.outputs["Color"])
|
||||
atlased_mat.material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"])
|
||||
atlased_mat.material.node_tree.links.new(normal_map_node.inputs["Color"], normal_node.outputs["Color"])
|
||||
|
||||
# Only update selected materials for meshes
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'MESH':
|
||||
mesh: Mesh = obj.data
|
||||
for i, mat_slot in enumerate(obj.material_slots):
|
||||
if mat_slot.material and mat_slot.material.include_in_atlas is True:
|
||||
mesh.materials[i] = atlased_mat.material
|
||||
|
||||
self.report({'INFO'}, t("TextureAtlas.atlas_completed"))
|
||||
return {"FINISHED"}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, t("TextureAtlas.atlas_error"))
|
||||
raise e
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import bpy
|
||||
import re
|
||||
from typing import List, Tuple, Optional, Set, Dict
|
||||
from bpy.types import Material, Operator, Context, Object, NodeTree
|
||||
from ..core.common import clean_material_names, get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress
|
||||
from ..core.register import register_wrap
|
||||
from ..functions.translations import t
|
||||
|
||||
def textures_match(tex1: bpy.types.ImageTexture, tex2: bpy.types.ImageTexture) -> bool:
|
||||
return tex1.image == tex2.image and tex1.extension == tex2.extension
|
||||
|
||||
def consolidate_nodes(node1: bpy.types.ShaderNodeTexImage, node2: bpy.types.ShaderNodeTexImage) -> None:
|
||||
node2.color_space = node1.color_space
|
||||
node2.coordinates = node1.coordinates
|
||||
|
||||
def copy_tex_nodes(mat1: Material, mat2: Material) -> None:
|
||||
for node1 in mat1.node_tree.nodes:
|
||||
if node1.type == 'TEX_IMAGE':
|
||||
node2 = mat2.node_tree.nodes.get(node1.name)
|
||||
if node2:
|
||||
node2.mapping = node1.mapping
|
||||
node2.projection = node1.projection
|
||||
|
||||
def consolidate_textures(node_tree1: NodeTree, node_tree2: NodeTree) -> None:
|
||||
for node1 in node_tree1.nodes:
|
||||
if node1.type == 'TEX_IMAGE':
|
||||
for node2 in node_tree2.nodes:
|
||||
if (node2.type == 'TEX_IMAGE' and
|
||||
node1.image == node2.image):
|
||||
consolidate_nodes(node1, node2)
|
||||
node2.image = node1.image
|
||||
elif node1.type == 'GROUP':
|
||||
if node1.node_tree and node2.node_tree:
|
||||
consolidate_textures(node1.node_tree, node2.node_tree)
|
||||
|
||||
def color_match(col1: Tuple[float, float, float, float], col2: Tuple[float, float, float, float], tolerance: float = 0.01) -> bool:
|
||||
return all(abs(c1 - c2) < tolerance for c1, c2 in zip(col1, col2))
|
||||
|
||||
def materials_match(mat1: Material, mat2: Material, tolerance: float = 0.01) -> bool:
|
||||
if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance):
|
||||
return False
|
||||
|
||||
if abs(mat1.roughness - mat2.roughness) > tolerance:
|
||||
return False
|
||||
|
||||
if mat1.node_tree and mat2.node_tree:
|
||||
consolidate_textures(mat1.node_tree, mat2.node_tree)
|
||||
|
||||
return True
|
||||
|
||||
def get_base_name(name: str) -> str:
|
||||
mat_match = re.match(r"^(.*)\.\d{3}$", name)
|
||||
return mat_match.group(1) if mat_match else name
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_CombineMaterials(Operator):
|
||||
bl_idname = "avatar_toolkit.combine_materials"
|
||||
bl_label = t("Optimization.combine_materials.label")
|
||||
bl_description = t("Optimization.combine_materials.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not armature:
|
||||
self.report({'WARNING'}, t("Optimization.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
meshes = get_all_meshes(context)
|
||||
if not meshes:
|
||||
self.report({'WARNING'}, t("Optimization.no_meshes_found"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
init_progress(context, 5) # 5 steps in total
|
||||
|
||||
update_progress(self, context, t("Optimization.consolidating_materials"))
|
||||
num_combined = self.consolidate_materials(meshes)
|
||||
|
||||
update_progress(self, context, t("Optimization.cleaning_material_slots"))
|
||||
cleaned_slots = self.clean_material_slots(meshes)
|
||||
|
||||
update_progress(self, context, t("Optimization.cleaning_material_names"))
|
||||
cleaned_names = self.clean_material_names()
|
||||
|
||||
update_progress(self, context, t("Optimization.clearing_unused_data"))
|
||||
removed_data_blocks = self.clear_unused_data_blocks()
|
||||
|
||||
update_progress(self, context, t("Optimization.finalizing"))
|
||||
finish_progress(context)
|
||||
|
||||
self.report({'INFO'}, t("Optimization.materials_optimization_report").format(
|
||||
num_combined=num_combined,
|
||||
num_cleaned_slots=cleaned_slots,
|
||||
num_cleaned_names=cleaned_names,
|
||||
num_removed_data_blocks=removed_data_blocks
|
||||
))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def consolidate_materials(self, meshes: List[Object]) -> int:
|
||||
mat_mapping: Dict[str, Material] = {}
|
||||
num_combined: int = 0
|
||||
for mesh in meshes:
|
||||
for slot in mesh.material_slots:
|
||||
mat: Optional[Material] = slot.material
|
||||
if mat:
|
||||
base_name: str = get_base_name(mat.name)
|
||||
|
||||
if base_name in mat_mapping:
|
||||
base_mat: Material = mat_mapping[base_name]
|
||||
try:
|
||||
if materials_match(base_mat, mat):
|
||||
consolidate_textures(base_mat.node_tree, mat.node_tree)
|
||||
num_combined += 1
|
||||
slot.material = base_mat
|
||||
except AttributeError:
|
||||
self.report({'WARNING'}, t("Optimization.material_attribute_mismatch").format(material_name=mat.name))
|
||||
continue
|
||||
else:
|
||||
mat_mapping[base_name] = mat
|
||||
return num_combined
|
||||
|
||||
def clean_material_slots(self, meshes: List[Object]) -> int:
|
||||
cleaned_slots = 0
|
||||
for obj in meshes:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
initial_slots = len(obj.material_slots)
|
||||
bpy.ops.object.material_slot_remove_unused()
|
||||
cleaned_slots += initial_slots - len(obj.material_slots)
|
||||
obj.select_set(False)
|
||||
return cleaned_slots
|
||||
|
||||
def clean_material_names(self) -> int:
|
||||
cleaned_names = 0
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH':
|
||||
result = clean_material_names(obj)
|
||||
if result is not None:
|
||||
cleaned_names += result
|
||||
return cleaned_names
|
||||
|
||||
def clear_unused_data_blocks(self) -> int:
|
||||
initial_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection))
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
final_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection))
|
||||
return initial_count - final_count
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
from typing import List, Optional, Dict, Set
|
||||
from bpy.types import Context, Object, Operator
|
||||
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_all_meshes,
|
||||
fix_zero_length_bones,
|
||||
clear_unused_data_blocks,
|
||||
join_mesh_objects,
|
||||
remove_unused_shapekeys
|
||||
)
|
||||
|
||||
class AvatarToolkit_OT_MergeArmature(Operator):
|
||||
bl_idname = 'avatar_toolkit.merge_armatures'
|
||||
bl_label = t('MergeArmature.label')
|
||||
bl_description = t('MergeArmature.desc')
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return len(get_all_meshes(context)) > 1
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
wm = context.window_manager
|
||||
wm.progress_begin(0, 100)
|
||||
|
||||
# Get both armatures
|
||||
base_armature_name = context.scene.merge_armature_into
|
||||
merge_armature_name = context.scene.merge_armature
|
||||
base_armature = bpy.data.objects.get(base_armature_name)
|
||||
merge_armature = bpy.data.objects.get(merge_armature_name)
|
||||
|
||||
if not base_armature or not merge_armature:
|
||||
logger.error(f"Armature not found: {merge_armature_name}")
|
||||
self.report({'ERROR'}, t('MergeArmature.error.not_found', name=merge_armature_name))
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Remove Rigid Bodies and Joints
|
||||
delete_rigidbodies_and_joints(base_armature)
|
||||
delete_rigidbodies_and_joints(merge_armature)
|
||||
wm.progress_update(40)
|
||||
|
||||
# Check parents and transformations
|
||||
if not validate_parents_and_transforms(merge_armature, base_armature, context):
|
||||
wm.progress_end()
|
||||
return {'CANCELLED'}
|
||||
wm.progress_update(80)
|
||||
|
||||
# Get settings from scene properties
|
||||
merge_all_bones = context.scene.avatar_toolkit.merge_all_bones
|
||||
join_meshes = context.scene.avatar_toolkit.join_meshes
|
||||
|
||||
# Merge armatures
|
||||
merge_armatures(
|
||||
base_armature_name,
|
||||
merge_armature_name,
|
||||
mesh_only=False,
|
||||
merge_all_bones=context.scene.avatar_toolkit.merge_all_bones,
|
||||
join_meshes=join_meshes,
|
||||
operator=self
|
||||
)
|
||||
wm.progress_update(90)
|
||||
|
||||
wm.progress_update(100)
|
||||
wm.progress_end()
|
||||
|
||||
self.report({'INFO'}, t('MergeArmature.success'))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error merging armatures: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def delete_rigidbodies_and_joints(armature: Object):
|
||||
"""Delete rigid bodies and joints associated with the armature."""
|
||||
to_delete = []
|
||||
parent = armature
|
||||
while parent.parent:
|
||||
parent = parent.parent
|
||||
|
||||
for child in parent.children:
|
||||
if 'rigidbodies' in child.name.lower() or 'joints' in child.name.lower():
|
||||
to_delete.append(child)
|
||||
for grandchild in child.children:
|
||||
if 'rigidbodies' in grandchild.name.lower() or 'joints' in grandchild.name.lower():
|
||||
to_delete.append(grandchild)
|
||||
|
||||
for obj in to_delete:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
def validate_parents_and_transforms(merge_armature: Object, base_armature: Object, context: Context) -> bool:
|
||||
"""Validate parents and transformations of armatures before merging."""
|
||||
merge_parent = merge_armature.parent
|
||||
base_parent = base_armature.parent
|
||||
|
||||
if merge_parent or base_parent:
|
||||
if context.scene.merge_all_bones:
|
||||
for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]:
|
||||
if parent:
|
||||
if not is_transform_clean(parent):
|
||||
logger.error("Parent transforms are not clean")
|
||||
return False
|
||||
bpy.data.objects.remove(parent, do_unlink=True)
|
||||
else:
|
||||
logger.error("Parent relationships need fixing")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_transform_clean(obj: Object) -> bool:
|
||||
"""Check if an object's transforms are at default values."""
|
||||
for i in range(3):
|
||||
if obj.scale[i] != 1 or obj.location[i] != 0 or obj.rotation_euler[i] != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def prepare_mesh_vertex_groups(mesh: Object):
|
||||
"""Prepare mesh by assigning all vertices to a new vertex group."""
|
||||
if mesh.vertex_groups:
|
||||
for vg in mesh.vertex_groups:
|
||||
mesh.vertex_groups.remove(vg)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
vg = mesh.vertex_groups.new(name=mesh.name)
|
||||
bpy.ops.object.vertex_group_assign()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def merge_armatures(
|
||||
base_armature_name: str,
|
||||
merge_armature_name: str,
|
||||
mesh_only: bool,
|
||||
merge_all_bones: bool = False,
|
||||
join_meshes: bool = False,
|
||||
operator=None
|
||||
):
|
||||
"""Main function to merge two armatures."""
|
||||
logger.info(f"Merging armatures: {merge_armature_name} into {base_armature_name}")
|
||||
tolerance = 0.00008726647 # around 0.005 degrees
|
||||
|
||||
base_armature = bpy.data.objects.get(base_armature_name)
|
||||
merge_armature = bpy.data.objects.get(merge_armature_name)
|
||||
|
||||
if not base_armature or not merge_armature:
|
||||
logger.error(f"Armature not found: {merge_armature_name}")
|
||||
if operator:
|
||||
operator.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name))
|
||||
return
|
||||
|
||||
# Check transforms early
|
||||
if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance):
|
||||
if not bpy.context.scene.avatar_toolkit.apply_transforms:
|
||||
logger.error("Transforms not aligned - user notification sent")
|
||||
if operator:
|
||||
operator.report({'ERROR'}, t('MergeArmature.error.transforms_not_aligned'))
|
||||
return
|
||||
|
||||
# Apply transforms if enabled
|
||||
if bpy.context.scene.avatar_toolkit.apply_transforms:
|
||||
for obj in [base_armature, merge_armature]:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
obj.select_set(False)
|
||||
|
||||
# Validate and fix armatures
|
||||
fix_zero_length_bones(base_armature)
|
||||
fix_zero_length_bones(merge_armature)
|
||||
|
||||
# Store original parent relationships
|
||||
original_parents = {}
|
||||
for bone in merge_armature.data.bones:
|
||||
original_parents[bone.name] = bone.parent.name if bone.parent else None
|
||||
|
||||
# Get base bone names
|
||||
base_bone_names = set(bone.name for bone in base_armature.data.bones)
|
||||
|
||||
# Switch to edit mode on merge armature and rename bones
|
||||
bpy.context.view_layer.objects.active = merge_armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
# Handle bone renaming based on merge_all_bones setting
|
||||
for bone in merge_armature.data.edit_bones:
|
||||
if not merge_all_bones:
|
||||
# Only rename bones that don't exist in base armature
|
||||
if bone.name not in base_bone_names:
|
||||
bone.name += '.merge'
|
||||
else:
|
||||
# Rename all bones from merge armature
|
||||
bone.name += '.merge'
|
||||
|
||||
# Return to object mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Select and join armatures
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
base_armature.select_set(True)
|
||||
merge_armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
bpy.ops.object.join()
|
||||
|
||||
# Restore parent relationships
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone in base_armature.data.edit_bones:
|
||||
base_name = bone.name.replace('.merge', '')
|
||||
if base_name in original_parents:
|
||||
parent_name = original_parents[base_name]
|
||||
if parent_name:
|
||||
parent_bone = base_armature.data.edit_bones.get(parent_name)
|
||||
if parent_bone:
|
||||
bone.parent = parent_bone
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Update mesh parenting
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.parent == merge_armature:
|
||||
obj.parent = base_armature
|
||||
|
||||
# Process vertex groups if not mesh_only
|
||||
if not mesh_only:
|
||||
meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature]
|
||||
process_vertex_groups(meshes)
|
||||
|
||||
# Remove zero weight vertex groups if enabled
|
||||
if bpy.context.scene.avatar_toolkit.remove_zero_weights:
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
for mesh in meshes:
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
bpy.ops.avatar_toolkit.clean_weights()
|
||||
|
||||
# Join meshes if requested
|
||||
if join_meshes:
|
||||
meshes_to_join = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature]
|
||||
if meshes_to_join:
|
||||
joined_mesh = join_mesh_objects(bpy.context, meshes_to_join)
|
||||
if joined_mesh:
|
||||
logger.info(f"Joined meshes into {joined_mesh.name}")
|
||||
|
||||
# Clean up shape keys if enabled
|
||||
if bpy.context.scene.avatar_toolkit.cleanup_shape_keys:
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.parent == base_armature:
|
||||
remove_unused_shapekeys(obj)
|
||||
|
||||
# Remove any remaining .merge bones
|
||||
bpy.context.view_layer.objects.active = base_armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = base_armature.data.edit_bones
|
||||
bones_to_remove = [bone for bone in edit_bones if bone.name.endswith('.merge')]
|
||||
for bone in bones_to_remove:
|
||||
edit_bones.remove(bone)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Final cleanup
|
||||
clear_unused_data_blocks()
|
||||
|
||||
|
||||
def validate_merge_armature_transforms(
|
||||
base_armature: Object,
|
||||
merge_armature: Object,
|
||||
mesh_merge: Optional[Object],
|
||||
tolerance: float
|
||||
) -> bool:
|
||||
"""Validate transforms of both armatures and mesh."""
|
||||
for i in [0, 1, 2]:
|
||||
if abs(base_armature.scale[i] - merge_armature.scale[i]) > tolerance:
|
||||
return False
|
||||
|
||||
if abs(merge_armature.rotation_euler[i]) > tolerance or \
|
||||
(mesh_merge and abs(mesh_merge.rotation_euler[i]) > tolerance):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def adjust_merge_armature_transforms(
|
||||
merge_armature: Object,
|
||||
mesh_merge: Object
|
||||
):
|
||||
"""Adjust transforms of the merge armature."""
|
||||
old_loc = list(merge_armature.location)
|
||||
old_scale = list(merge_armature.scale)
|
||||
|
||||
for i in [0, 1, 2]:
|
||||
merge_armature.location[i] = (mesh_merge.location[i] * old_scale[i]) + old_loc[i]
|
||||
merge_armature.rotation_euler[i] = mesh_merge.rotation_euler[i]
|
||||
merge_armature.scale[i] = mesh_merge.scale[i] * old_scale[i]
|
||||
|
||||
for i in [0, 1, 2]:
|
||||
mesh_merge.location[i] = 0
|
||||
mesh_merge.rotation_euler[i] = 0
|
||||
mesh_merge.scale[i] = 1
|
||||
|
||||
|
||||
def detect_bones_to_merge(
|
||||
base_edit_bones: bpy.types.ArmatureEditBones,
|
||||
merge_edit_bones: bpy.types.ArmatureEditBones,
|
||||
tolerance: float,
|
||||
merge_all_bones: bool
|
||||
) -> List[str]:
|
||||
"""Detect corresponding bones between base and merge armatures using smart detection and position tolerance."""
|
||||
bones_to_merge = []
|
||||
|
||||
# Cache base bone positions
|
||||
base_bones_positions = {
|
||||
bone.name: np.array(bone.head) for bone in base_edit_bones
|
||||
}
|
||||
|
||||
# Smart bone detection
|
||||
for merge_bone in merge_edit_bones:
|
||||
merge_bone_position = np.array(merge_bone.head)
|
||||
found_match = False
|
||||
|
||||
if merge_all_bones and merge_bone.name in base_bones_positions:
|
||||
# If merging same bones by name
|
||||
bones_to_merge.append(merge_bone.name)
|
||||
found_match = True
|
||||
else:
|
||||
# Find bones with close positions
|
||||
for base_bone_name, base_bone_position in base_bones_positions.items():
|
||||
if np.linalg.norm(merge_bone_position - base_bone_position) <= tolerance:
|
||||
bones_to_merge.append(base_bone_name)
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
# Handle unmatched bones if needed
|
||||
pass
|
||||
|
||||
return bones_to_merge
|
||||
|
||||
|
||||
def process_vertex_groups(meshes: List[Object]):
|
||||
"""Process vertex groups in meshes."""
|
||||
for mesh in meshes:
|
||||
vg_names = {vg.name for vg in mesh.vertex_groups}
|
||||
merge_vg_names = [vg_name for vg_name in vg_names if vg_name.endswith('.merge')]
|
||||
|
||||
for vg_merge_name in merge_vg_names:
|
||||
base_name = vg_merge_name[:-6]
|
||||
vg_merge = mesh.vertex_groups.get(vg_merge_name)
|
||||
vg_base = mesh.vertex_groups.get(base_name)
|
||||
|
||||
if vg_merge is None:
|
||||
continue
|
||||
|
||||
if vg_base:
|
||||
mix_vertex_groups(mesh, vg_merge_name, base_name)
|
||||
else:
|
||||
vg_merge.name = base_name
|
||||
|
||||
def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str):
|
||||
"""Mix vertex group weights."""
|
||||
vg_from = mesh.vertex_groups.get(vg_from_name)
|
||||
vg_to = mesh.vertex_groups.get(vg_to_name)
|
||||
|
||||
if not vg_from or not vg_to:
|
||||
return
|
||||
|
||||
num_vertices = len(mesh.data.vertices)
|
||||
weights_from = np.zeros(num_vertices)
|
||||
weights_to = np.zeros(num_vertices)
|
||||
|
||||
idx_from = vg_from.index
|
||||
idx_to = vg_to.index
|
||||
|
||||
for v in mesh.data.vertices:
|
||||
for g in v.groups:
|
||||
if g.group == idx_from:
|
||||
weights_from[v.index] = g.weight
|
||||
elif g.group == idx_to:
|
||||
weights_to[v.index] = g.weight
|
||||
|
||||
weights_combined = np.clip(weights_from + weights_to, 0.0, 1.0)
|
||||
vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE')
|
||||
mesh.vertex_groups.remove(vg_from)
|
||||
|
||||
def remove_unused_vertex_groups(mesh: Object):
|
||||
"""Remove vertex groups with no weights."""
|
||||
for vg in mesh.vertex_groups:
|
||||
has_weights = False
|
||||
for vert in mesh.data.vertices:
|
||||
for group in vert.groups:
|
||||
if group.group == vg.index and group.weight > 0.001:
|
||||
has_weights = True
|
||||
break
|
||||
if has_weights:
|
||||
break
|
||||
if not has_weights:
|
||||
mesh.vertex_groups.remove(vg)
|
||||
|
||||
def apply_armature_to_mesh(armature: Object, mesh: Object):
|
||||
"""Apply armature deformation to mesh."""
|
||||
armature_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE')
|
||||
armature_mod.object = armature
|
||||
|
||||
if bpy.app.version >= (3, 5):
|
||||
mesh.modifiers.move(mesh.modifiers.find(armature_mod.name), 0)
|
||||
else:
|
||||
for _ in range(len(mesh.modifiers) - 1):
|
||||
bpy.ops.object.modifier_move_up(modifier=armature_mod.name)
|
||||
|
||||
with bpy.context.temp_override(object=mesh):
|
||||
bpy.ops.object.modifier_apply(modifier=armature_mod.name)
|
||||
|
||||
def apply_armature_to_mesh_with_shapekeys(armature: Object, mesh: Object, context: Context):
|
||||
"""Apply armature deformation to mesh with shape keys."""
|
||||
old_active_index = mesh.active_shape_key_index
|
||||
old_show_only = mesh.show_only_shape_key
|
||||
mesh.show_only_shape_key = True
|
||||
|
||||
shape_keys = mesh.data.shape_keys.key_blocks
|
||||
vertex_groups = []
|
||||
mutes = []
|
||||
|
||||
for sk in shape_keys:
|
||||
vertex_groups.append(sk.vertex_group)
|
||||
sk.vertex_group = ''
|
||||
mutes.append(sk.mute)
|
||||
sk.mute = False
|
||||
|
||||
disabled_mods = []
|
||||
for mod in mesh.modifiers:
|
||||
if mod.show_viewport:
|
||||
mod.show_viewport = False
|
||||
disabled_mods.append(mod)
|
||||
|
||||
arm_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE')
|
||||
arm_mod.object = armature
|
||||
|
||||
co_length = len(mesh.data.vertices) * 3
|
||||
eval_cos = np.empty(co_length, dtype=np.single)
|
||||
|
||||
for i, shape_key in enumerate(shape_keys):
|
||||
mesh.active_shape_key_index = i
|
||||
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_mesh = mesh.evaluated_get(depsgraph)
|
||||
eval_mesh.data.vertices.foreach_get('co', eval_cos)
|
||||
|
||||
shape_key.data.foreach_set('co', eval_cos)
|
||||
if i == 0:
|
||||
mesh.data.vertices.foreach_set('co', eval_cos)
|
||||
|
||||
for mod in disabled_mods:
|
||||
mod.show_viewport = True
|
||||
|
||||
mesh.modifiers.remove(arm_mod)
|
||||
|
||||
for sk, vg, mute in zip(shape_keys, vertex_groups, mutes):
|
||||
sk.vertex_group = vg
|
||||
sk.mute = mute
|
||||
|
||||
mesh.active_shape_key_index = old_active_index
|
||||
mesh.show_only_shape_key = old_show_only
|
||||
@@ -0,0 +1,130 @@
|
||||
import bpy
|
||||
from bpy.types import Operator, Context, Object
|
||||
from mathutils import Vector
|
||||
from typing import Set, Optional
|
||||
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_all_meshes,
|
||||
ProgressTracker,
|
||||
calculate_bone_orientation,
|
||||
add_armature_modifier
|
||||
)
|
||||
|
||||
class AvatarToolkit_OT_AttachMesh(Operator):
|
||||
"""Attach a mesh to an armature bone with automatic weight setup"""
|
||||
bl_idname = "avatar_toolkit.attach_mesh"
|
||||
bl_label = t("AttachMesh.label")
|
||||
bl_description = t("AttachMesh.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
logger.info("Starting mesh attachment process")
|
||||
|
||||
mesh_name = context.scene.avatar_toolkit.attach_mesh
|
||||
armature = get_active_armature(context)
|
||||
attach_bone_name = context.scene.avatar_toolkit.attach_bone
|
||||
mesh = bpy.data.objects.get(mesh_name)
|
||||
|
||||
with ProgressTracker(context, 10, "Attaching Mesh") as progress:
|
||||
# Validation steps
|
||||
is_valid, error_msg = validate_mesh_transforms(mesh)
|
||||
if not is_valid:
|
||||
raise ValueError(error_msg)
|
||||
progress.step(t("AttachMesh.validate_transforms"))
|
||||
|
||||
is_valid, error_msg = validate_mesh_name(armature, mesh_name)
|
||||
if not is_valid:
|
||||
raise ValueError(error_msg)
|
||||
progress.step(t("AttachMesh.validate_name"))
|
||||
|
||||
# Parent mesh to armature
|
||||
mesh.parent = armature
|
||||
mesh.parent_type = 'OBJECT'
|
||||
progress.step(t("AttachMesh.parent_mesh"))
|
||||
|
||||
# Setup vertex groups
|
||||
if mesh.vertex_groups:
|
||||
for vg in mesh.vertex_groups:
|
||||
mesh.vertex_groups.remove(vg)
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh.select_set(True)
|
||||
context.view_layer.objects.active = mesh
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
vg = mesh.vertex_groups.new(name=mesh_name)
|
||||
bpy.ops.object.vertex_group_assign()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
progress.step(t("AttachMesh.setup_weights"))
|
||||
|
||||
# Create and setup bone
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
attach_to_bone = armature.data.edit_bones.get(attach_bone_name)
|
||||
if not attach_to_bone:
|
||||
raise ValueError(t("AttachMesh.error.bone_not_found", bone=attach_bone_name))
|
||||
|
||||
mesh_bone = armature.data.edit_bones.new(mesh_name)
|
||||
mesh_bone.parent = attach_to_bone
|
||||
progress.step(t("AttachMesh.create_bone"))
|
||||
|
||||
# Calculate bone placement
|
||||
verts_in_group = [v for v in mesh.data.vertices
|
||||
for g in v.groups if g.group == vg.index]
|
||||
dimensions, roll_angle = calculate_bone_orientation(mesh, verts_in_group)
|
||||
|
||||
# Set bone position and orientation
|
||||
center = Vector((0, 0, 0))
|
||||
for v in verts_in_group:
|
||||
center += mesh.data.vertices[v.index].co
|
||||
center /= len(verts_in_group)
|
||||
|
||||
mesh_bone.head = center
|
||||
mesh_bone.tail = center + Vector((0, 0, max(0.1, dimensions.z)))
|
||||
mesh_bone.roll = roll_angle
|
||||
progress.step(t("AttachMesh.position_bone"))
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
add_armature_modifier(mesh, armature)
|
||||
progress.step(t("AttachMesh.add_modifier"))
|
||||
|
||||
logger.info(f"Successfully attached mesh {mesh_name} to bone {attach_bone_name}")
|
||||
self.report({'INFO'}, t("AttachMesh.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to attach mesh: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def validate_mesh_transforms(mesh):
|
||||
"""Validate mesh transforms are suitable for attaching."""
|
||||
if not mesh:
|
||||
return False, "Mesh not found"
|
||||
|
||||
# Check for non-uniform scale
|
||||
scale = mesh.scale
|
||||
if abs(scale[0] - scale[1]) > 0.001 or abs(scale[1] - scale[2]) > 0.001:
|
||||
return False, "Mesh has non-uniform scale. Please apply scale (Ctrl+A)"
|
||||
|
||||
return True, ""
|
||||
|
||||
def validate_mesh_name(armature, mesh_name):
|
||||
"""Validate mesh name doesn't conflict with existing bones."""
|
||||
if mesh_name in armature.data.bones:
|
||||
return False, f"Bone named '{mesh_name}' already exists in armature"
|
||||
return True, ""
|
||||
@@ -1,119 +0,0 @@
|
||||
import bpy
|
||||
from ..core import common
|
||||
from ..core import register_wrap
|
||||
from .translations import t
|
||||
import re
|
||||
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_CreateDigitigradeLegs(bpy.types.Operator):
|
||||
bl_idname = "avatar_toolkit.create_digitigrade_legs"
|
||||
bl_label = t('Tools.create_digitigrade_legs.label')
|
||||
bl_description = t('Tools.create_digitigrade_legs.desc')
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if(context.active_object is None):
|
||||
return False
|
||||
if(context.selected_editable_bones is not None):
|
||||
if(len(context.selected_editable_bones) == 2):
|
||||
return True
|
||||
return False
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
for digi0 in context.selected_editable_bones:
|
||||
digi1: bpy.types.EditBone = None
|
||||
digi2: bpy.types.EditBone = None
|
||||
digi3: bpy.types.EditBone = None
|
||||
|
||||
try:
|
||||
digi1 = digi0.children[0]
|
||||
digi2 = digi1.children[0]
|
||||
digi3 = digi2.children[0]
|
||||
except:
|
||||
self.report({'ERROR'}, t('Tools.digitigrade_legs.error.bone_format'))
|
||||
return {'CANCELLED'}
|
||||
digi4 = None
|
||||
try:
|
||||
digi4 = digi3.children[0]
|
||||
|
||||
except:
|
||||
print("no toe bone. Continuing.")
|
||||
digi0.select = True
|
||||
digi1.select = True
|
||||
digi2.select = True
|
||||
digi3.select = True
|
||||
if(digi4):
|
||||
digi4.select = True
|
||||
bpy.ops.armature.roll_clear()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
#creating transform for upper leg
|
||||
digi0.select = True
|
||||
bpy.ops.transform.create_orientation(name="Toolkit_digi0", overwrite=True)
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
|
||||
#duplicate digi0 and assign it to thigh
|
||||
thigh = common.duplicatebone(digi0)
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
#make digi2 parrallel to digi1
|
||||
digi2.align_orientation(digi0)
|
||||
|
||||
#extrude thigh
|
||||
thigh.select_tail = True
|
||||
bpy.ops.armature.extrude_move(ARMATURE_OT_extrude={"forked":False},TRANSFORM_OT_translate=None)
|
||||
#set new bone to calf varible
|
||||
bpy.ops.armature.select_more()
|
||||
calf = context.selected_bones[0]
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
#set calf end to digi2 end
|
||||
calf.tail = digi2.tail
|
||||
|
||||
#make copy of calf, flip it, and then align bone so that it's head is moved to match in align phase
|
||||
flipedcalf = common.duplicatebone(calf)
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
flipedcalf.select = True
|
||||
bpy.ops.armature.switch_direction()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
flippeddigi1 = common.duplicatebone(digi1)
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
flippeddigi1.select = True
|
||||
bpy.ops.armature.switch_direction()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
|
||||
|
||||
#align flipped calf to flipped middle leg to move the head
|
||||
flipedcalf.align_orientation(flippeddigi1)
|
||||
|
||||
flipedcalf.length = flippeddigi1.length
|
||||
|
||||
#assign calf tail to flipped calf head so it moves calf's tail to be out at the perfect parallelagram
|
||||
calf.head = flipedcalf.tail
|
||||
|
||||
#delete helper bones
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
flippeddigi1.select = True
|
||||
bpy.ops.armature.delete()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
flipedcalf.select = True
|
||||
bpy.ops.armature.delete()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
|
||||
|
||||
#reparent the foot to the new calf so it will be part of the new foot IK chain
|
||||
digi3.parent = calf
|
||||
#Tada! It's done! now to rename the old 3 segments that make up the old part to noik so resonite doesn't try to select them
|
||||
|
||||
digi0.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("",digi0.name)+"<noik>"
|
||||
digi1.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("",digi1.name)+"<noik>"
|
||||
digi2.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("",digi2.name)+"<noik>"
|
||||
#finally fully done!
|
||||
|
||||
self.report({'INFO'}, t('Tools.digitigrade_legs.success'))
|
||||
return {'FINISHED'}
|
||||
@@ -0,0 +1,945 @@
|
||||
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 CreateEyesAV3Button(bpy.types.Operator):
|
||||
"""Create eye tracking setup for VRChat Avatar 3.0"""
|
||||
bl_idname = 'avatar_toolkit.create_eye_tracking_av3'
|
||||
bl_label = t('EyeTracking.create.av3.label')
|
||||
bl_description = t('EyeTracking.create.av3.desc')
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
mesh = 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 as e:
|
||||
logger.error(f"Eye tracking setup failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
class CreateEyesSDK2Button(bpy.types.Operator):
|
||||
"""Create eye tracking setup for VRChat SDK2"""
|
||||
bl_idname = 'avatar_toolkit.create_eye_tracking_sdk2'
|
||||
bl_label = t('EyeTracking.create.sdk2.label')
|
||||
bl_description = t('EyeTracking.create.sdk2.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 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: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
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 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 = []
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
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
|
||||
# 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, 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 = []
|
||||
@@ -1,189 +0,0 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from ..core.register import register_wrap
|
||||
from ..core.importer import imports, import_types
|
||||
from ..core.common import remove_default_objects
|
||||
from ..functions.translations import t
|
||||
import pathlib
|
||||
import os
|
||||
|
||||
VRM_IMPORTER_URL = "https://github.com/saturday06/VRM_Addon_for_Blender"
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_ImportAnyModel(Operator, ImportHelper):
|
||||
bl_idname = 'avatar_toolkit.import_any_model'
|
||||
bl_label = t('Tools.import_any_model.label')
|
||||
bl_description = t('Tools.import_any_model.desc')
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
|
||||
|
||||
filter_glob: bpy.props.StringProperty(default=imports, options={'HIDDEN', 'SKIP_SAVE'})
|
||||
directory: bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
|
||||
|
||||
# since I wrote this myself, a bit more efficient than cats. mostly - @989onan
|
||||
def execute(self, context: bpy.types.Context):
|
||||
file_grouping_dict: dict[str, list[dict[str, str]]] = dict() # group our files so our importers can import them together. in the case of OBJ+MTL and others that need grouped files, this is extremely important.
|
||||
remove_default_objects()
|
||||
# check if we are importing multiple files
|
||||
is_multi = len(self.files) > 0
|
||||
|
||||
if is_multi:
|
||||
for file in self.files:
|
||||
fullpath = os.path.join(self.directory, os.path.basename(file.name))
|
||||
name = pathlib.Path(fullpath).suffix.replace(".", "")
|
||||
# this makes sure our imports that should be grouped stay together.
|
||||
# basically the method checks for if the first value has a lambda with the same bytecode as another lambda, then it will use that value's key (ex:"obj"<->"mtl" or "fbx"), keeping same importers together
|
||||
if name not in file_grouping_dict:
|
||||
file_grouping_dict[name] = []
|
||||
file_grouping_dict[name].append({"name": os.path.basename(file.name)}) # emulate passing a list of files.
|
||||
else:
|
||||
fullpath: str = os.path.join(os.path.dirname(self.filepath), os.path.basename(self.filepath))
|
||||
name = pathlib.Path(fullpath).suffix.replace(".", "")
|
||||
if name not in file_grouping_dict:
|
||||
file_grouping_dict[name] = []
|
||||
file_grouping_dict[name].append({"name": fullpath}) # emulate passing a list of files.
|
||||
|
||||
# import the files together to make sure things like obj import together. This is important
|
||||
for file_group_name, files in file_grouping_dict.items():
|
||||
try:
|
||||
# Check for VRM importer availability
|
||||
if file_group_name == "vrm" and not hasattr(bpy.ops.import_scene, "vrm"):
|
||||
bpy.ops.wm.vrm_importer_popup('INVOKE_DEFAULT')
|
||||
return {'CANCELLED'}
|
||||
|
||||
if self.directory:
|
||||
import_types[file_group_name](self.directory, files, self.filepath)
|
||||
else:
|
||||
import_types[file_group_name]("", files, self.filepath) # give an empty directory, works just fine for 90%
|
||||
except AttributeError as e:
|
||||
if file_group_name == "vrm":
|
||||
bpy.ops.wm.vrm_importer_popup('INVOKE_DEFAULT')
|
||||
else:
|
||||
self.report({'ERROR'}, t('Importing.need_importer').format(extension=file_group_name))
|
||||
print("Importer error:", e)
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.report({'INFO'}, t('Quick_Access.import_success'))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class VRMImporterPopup(Operator):
|
||||
bl_idname = "wm.vrm_importer_popup"
|
||||
bl_label = "VRM Importer Not Installed"
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self, width=300)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="VRM importer plugin is not installed.")
|
||||
layout.label(text="Please install it to import VRM files.")
|
||||
layout.operator("wm.url_open", text="Get VRM Importer").url = VRM_IMPORTER_URL
|
||||
|
||||
#TODO: This needs to be done with our own MMD importer.
|
||||
"""
|
||||
#stolen from cats. Oh wait I made this code riiiiiiight - @989onan
|
||||
@register_wrap
|
||||
class ImportMMDAnimation(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = 'avatar_toolkit.import_mmd_animation'
|
||||
bl_label = t('Importer.mmd_anim_importer.label')
|
||||
bl_description = t('Importer.mmd_anim_importer.desc')
|
||||
bl_options = {'INTERNAL', 'UNDO'}
|
||||
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.vmd",
|
||||
options={'HIDDEN'}
|
||||
)
|
||||
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
|
||||
directory: bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
|
||||
filepath: bpy.props.StringProperty()
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if common.get_armature(context) is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
# Make sure that the first layer is visible
|
||||
if hasattr(context.scene, 'layers'):
|
||||
context.scene.layers[0] = True
|
||||
|
||||
filename, extension = os.path.splitext(self.filepath)
|
||||
|
||||
if(extension == ".vmd"):
|
||||
|
||||
#A dictionary to change the current model to MMD importer compatable temporarily
|
||||
bonedict = {
|
||||
"chest":"UpperBody",
|
||||
"neck":"Neck",
|
||||
"head":"Head",
|
||||
"hips":"Center",
|
||||
"spine":"LowerBody",
|
||||
|
||||
"right_wrist":"Wrist_R",
|
||||
"right_elbow":"Elbow_R",
|
||||
"right_arm":"Arm_R",
|
||||
"right_shoulder":"Shoulder_R",
|
||||
"right_leg":"Leg_R",
|
||||
"right_knee":"Knee_R",
|
||||
"right_ankle":"Ankle_R",
|
||||
"right_toe":"Toe_R",
|
||||
|
||||
|
||||
"left_wrist":"Wrist_L",
|
||||
"left_elbow":"Elbow_L",
|
||||
"left_arm":"Arm_L",
|
||||
"left_shoulder":"Shoulder_L",
|
||||
"left_leg":"Leg_L",
|
||||
"left_knee":"Knee_L",
|
||||
"left_ankle":"Ankle_L",
|
||||
"left_toe":"Toe_L"
|
||||
|
||||
}
|
||||
|
||||
armature = common.get_armature(context)
|
||||
common.unselect_all()
|
||||
common.Set_Mode(context, 'OBJECT')
|
||||
common.unselect_all()
|
||||
common.set_active(armature)
|
||||
|
||||
orig_names = dict()
|
||||
reverse_bone_lookup = dict()
|
||||
for (preferred_name, name_list) in bone_names.items():
|
||||
for name in name_list:
|
||||
reverse_bone_lookup[name] = preferred_name
|
||||
|
||||
|
||||
for bone in armature.data.bones:
|
||||
if common.simplify_bonename(bone.name) in reverse_bone_lookup and reverse_bone_lookup[common.simplify_bonename(bone.name)] in bonedict:
|
||||
orig_names[bonedict[reverse_bone_lookup[common.simplify_bonename(bone.name)]]] = bone.name
|
||||
bone.name = bonedict[reverse_bone_lookup[common.simplify_bonename(bone.name)]]
|
||||
try:
|
||||
bpy.ops.mmd_tools.import_vmd(filepath=self.filepath,bone_mapper='RENAMED_BONES',use_underscore=True, dictionary='INTERNAL')
|
||||
except AttributeError as e:
|
||||
print("importer error was:")
|
||||
print(e)
|
||||
print(t('Importing.importer_search_term'))
|
||||
common.open_web_after_delay_multi_threaded(delay=12, url=t('Importing.importer_search_term').format(extension = "MMD"))
|
||||
self.report({'ERROR'},t('Importing.need_importer').format(extension = "MMD"))
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
#iterate through bones and put them back, therefore blender API will change the animation to be correct.
|
||||
#this is because renaming bones fixes the animation targets in the data model.
|
||||
for bone in armature.data.bones:
|
||||
if common.simplify_bonename(bone.name) in orig_names:
|
||||
bone.name = orig_names[common.simplify_bonename(bone.name)]
|
||||
|
||||
common.unselect_all()
|
||||
common.Set_Mode(context, 'OBJECT')
|
||||
common.unselect_all()
|
||||
common.set_active(armature)
|
||||
|
||||
return {'FINISHED'} """
|
||||
@@ -1,211 +0,0 @@
|
||||
import numpy as np
|
||||
import bpy
|
||||
from typing import List, Optional, Set
|
||||
from bpy.types import Operator, Context, Object
|
||||
from ..core.common import fix_uv_coordinates, get_selected_armature, get_all_meshes, is_valid_armature, apply_shapekey_to_basis, has_shapekeys, select_current_armature, init_progress, update_progress, finish_progress
|
||||
from ..functions.translations import t
|
||||
from ..core.register import register_wrap
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operator):
|
||||
tolerance: bpy.props.FloatProperty(name=t("Tools.remove_unused_shapekeys.tolerance.label"), default=0.001, description=t("Tools.remove_unused_shapekeys.tolerance.desc"))
|
||||
bl_idname = "avatar_toolkit.remove_unused_shapekeys"
|
||||
bl_label = t("Tools.remove_unused_shapekeys.label")
|
||||
bl_description = t("Tools.remove_unused_shapekeys.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature) and (len(get_all_meshes(context)) > 0) and (context.mode == "OBJECT")
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
#Shamefully taken from: https://blender.stackexchange.com/a/237611
|
||||
#at least I am crediting them - @989onan
|
||||
for ob in get_all_meshes(context):
|
||||
if not ob.data.shape_keys: continue
|
||||
if not ob.data.shape_keys.use_relative: continue
|
||||
|
||||
kbs = ob.data.shape_keys.key_blocks
|
||||
nverts = len(ob.data.vertices)
|
||||
to_delete = []
|
||||
|
||||
# Cache locs for rel keys since many keys have the same rel key
|
||||
cache = {}
|
||||
|
||||
locs = np.empty(3*nverts, dtype=np.float32)
|
||||
|
||||
for kb in kbs:
|
||||
if kb == kb.relative_key: continue
|
||||
|
||||
kb.data.foreach_get("co", locs)
|
||||
|
||||
if kb.relative_key.name not in cache:
|
||||
rel_locs = np.empty(3*nverts, dtype=np.float32)
|
||||
kb.relative_key.data.foreach_get("co", rel_locs)
|
||||
cache[kb.relative_key.name] = rel_locs
|
||||
rel_locs = cache[kb.relative_key.name]
|
||||
|
||||
locs -= rel_locs
|
||||
if (np.abs(locs) < self.tolerance).all():
|
||||
to_delete.append(kb.name)
|
||||
|
||||
for kb_name in to_delete:
|
||||
if ("-" in kb_name) or ("=" in kb_name) or ("~" in kb_name): #don't delete category names. - @989onan
|
||||
continue
|
||||
ob.shape_key_remove(ob.data.shape_keys.key_blocks[kb_name])
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolkit_OT_ApplyShapeKey(bpy.types.Operator):
|
||||
bl_idname = "avatar_toolkit.apply_shape_key"
|
||||
bl_label = t("Tools.apply_shape_key.label")
|
||||
bl_description = t("Tools.apply_shape_key.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature) and (len(get_all_meshes(context)) > 0) and (context.mode == "OBJECT") and context.view_layer.objects.active is not None and has_shapekeys(context.view_layer.objects.active)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
obj: bpy.types.Object = context.view_layer.objects.active
|
||||
|
||||
|
||||
if (apply_shapekey_to_basis(context,obj,obj.active_shape_key.name,False)):
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, t("Tools.apply_shape_key.error"))
|
||||
return {'FINISHED'}
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_JoinAllMeshes(Operator):
|
||||
bl_idname = "avatar_toolkit.join_all_meshes"
|
||||
bl_label = t("Optimization.join_all_meshes.label")
|
||||
bl_description = t("Optimization.join_all_meshes.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
self.join_all_meshes(context)
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"{t('Optimization.join_error')}: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def join_all_meshes(self, context: Context) -> None:
|
||||
if not select_current_armature(context):
|
||||
raise ValueError(t("Optimization.no_armature_selected"))
|
||||
|
||||
armature = get_selected_armature(context)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
meshes: List[Object] = get_all_meshes(context)
|
||||
|
||||
if not meshes:
|
||||
raise ValueError(t("Optimization.no_meshes_found"))
|
||||
|
||||
init_progress(context, 5) # 5 steps in total
|
||||
|
||||
update_progress(self, context, t("Optimization.selecting_meshes"))
|
||||
for mesh in meshes:
|
||||
mesh.select_set(True)
|
||||
|
||||
if bpy.context.selected_objects:
|
||||
bpy.context.view_layer.objects.active = bpy.context.selected_objects[0]
|
||||
|
||||
update_progress(self, context, t("Optimization.joining_meshes"))
|
||||
try:
|
||||
bpy.ops.object.join()
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"{t('Optimization.join_operation_failed')}: {str(e)}")
|
||||
|
||||
update_progress(self, context, t("Optimization.applying_transforms"))
|
||||
try:
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"{t('Optimization.transform_apply_failed')}: {str(e)}")
|
||||
|
||||
update_progress(self, context, t("Optimization.fixing_uv_coordinates"))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
fix_uv_coordinates(context)
|
||||
|
||||
update_progress(self, context, t("Optimization.finalizing"))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
self.report({'INFO'}, t("Optimization.meshes_joined"))
|
||||
else:
|
||||
raise ValueError(t("Optimization.no_mesh_selected"))
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
finish_progress(context)
|
||||
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_JoinSelectedMeshes(Operator):
|
||||
bl_idname = "avatar_toolkit.join_selected_meshes"
|
||||
bl_label = t("Optimization.join_selected_meshes.label")
|
||||
bl_description = t("Optimization.join_selected_meshes.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return context.mode == 'OBJECT' and len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
self.join_selected_meshes(context)
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"{t('Optimization.join_error')}: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def join_selected_meshes(self, context: Context) -> None:
|
||||
selected_objects: List[Object] = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH']
|
||||
|
||||
if len(selected_objects) < 2:
|
||||
raise ValueError(t("Optimization.select_at_least_two_meshes"))
|
||||
|
||||
init_progress(context, 5) # 5 steps in total
|
||||
|
||||
update_progress(self, context, t("Optimization.preparing_meshes"))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
update_progress(self, context, t("Optimization.selecting_meshes"))
|
||||
for obj in selected_objects:
|
||||
obj.select_set(True)
|
||||
|
||||
if bpy.context.selected_objects:
|
||||
bpy.context.view_layer.objects.active = bpy.context.selected_objects[0]
|
||||
|
||||
update_progress(self, context, t("Optimization.joining_meshes"))
|
||||
try:
|
||||
bpy.ops.object.join()
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"{t('Optimization.join_operation_failed')}: {str(e)}")
|
||||
|
||||
update_progress(self, context, t("Optimization.applying_transforms"))
|
||||
try:
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"{t('Optimization.transform_apply_failed')}: {str(e)}")
|
||||
|
||||
update_progress(self, context, t("Optimization.fixing_uv_coordinates"))
|
||||
fix_uv_coordinates(context)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
self.report({'INFO'}, t("Optimization.selected_meshes_joined"))
|
||||
else:
|
||||
raise ValueError(t("Optimization.no_mesh_selected"))
|
||||
|
||||
finish_progress(context)
|
||||
@@ -1,398 +0,0 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
import re
|
||||
from bpy.types import Operator, Context, Material, ShaderNodeTexImage, ShaderNodeGroup, Object
|
||||
from ..core.register import register_wrap
|
||||
from ..functions.translations import t
|
||||
from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress
|
||||
from ..functions.additional_tools import AvatarToolKit_OT_ConnectBones, AvatarToolKit_OT_DeleteBoneConstraints
|
||||
from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToParents
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_CleanupMesh(Operator):
|
||||
bl_idname = "avatar_toolkit.cleanup_mesh"
|
||||
bl_label = t("MMDOptions.cleanup_mesh.label")
|
||||
bl_description = t("MMDOptions.cleanup_mesh.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
init_progress(context, 4)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.removing_empty_objects"))
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in context.scene.objects:
|
||||
if obj.type == 'EMPTY':
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
update_progress(self, context, t("MMDOptions.removing_unused_vertex_groups"))
|
||||
for obj in get_all_meshes(context):
|
||||
self.remove_unused_vertex_groups(obj)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.removing_unused_vertices"))
|
||||
for obj in get_all_meshes(context):
|
||||
self.remove_unused_vertices(obj)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.removing_empty_shape_keys"))
|
||||
for obj in get_all_meshes(context):
|
||||
self.remove_empty_shape_keys(obj)
|
||||
|
||||
finish_progress(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
def remove_unused_vertex_groups(self, obj):
|
||||
vgroups = obj.vertex_groups
|
||||
for vgroup in vgroups:
|
||||
if not any(vgroup.index in [g.group for g in v.groups] for v in obj.data.vertices):
|
||||
vgroups.remove(vgroup)
|
||||
|
||||
def remove_unused_vertices(self, obj):
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.delete_loose()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def remove_empty_shape_keys(self, obj):
|
||||
if obj.data.shape_keys:
|
||||
for key in obj.data.shape_keys.key_blocks:
|
||||
if key.name != 'Basis' and all(abs(key.data[i].co[j] - obj.data.shape_keys.reference_key.data[i].co[j]) < 0.0001 for i in range(len(key.data)) for j in range(3)):
|
||||
obj.shape_key_remove(key)
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_OptimizeWeights(Operator):
|
||||
bl_idname = "avatar_toolkit.optimize_weights"
|
||||
bl_label = t("MMDOptions.optimize_weights.label")
|
||||
bl_description = t("MMDOptions.optimize_weights.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
max_weights: bpy.props.IntProperty(
|
||||
name=t("MMDOptions.max_weights.label"),
|
||||
description=t("MMDOptions.max_weights.desc"),
|
||||
default=4,
|
||||
min=1,
|
||||
max=8
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMDOptions.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
init_progress(context, 4)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.merging_weights"))
|
||||
for obj in get_all_meshes(context):
|
||||
for modifier in obj.modifiers:
|
||||
if modifier.type == 'ARMATURE' and modifier.object != armature:
|
||||
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.removing_zero_weight_bones"))
|
||||
bpy.ops.avatar_toolkit.remove_zero_weight_bones('EXEC_DEFAULT')
|
||||
|
||||
update_progress(self, context, t("MMDOptions.limiting_vertex_weights"))
|
||||
for obj in get_all_meshes(context):
|
||||
self.limit_vertex_weights(obj)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.weight_optimization_complete"))
|
||||
finish_progress(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
def limit_vertex_weights(self, obj):
|
||||
for v in obj.data.vertices:
|
||||
if len(v.groups) > self.max_weights:
|
||||
sorted_groups = sorted(v.groups, key=lambda g: g.weight, reverse=True)
|
||||
for g in sorted_groups[self.max_weights:]:
|
||||
obj.vertex_groups[g.group].remove([v.index])
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_OptimizeArmature(Operator):
|
||||
bl_idname = "avatar_toolkit.optimize_armature"
|
||||
bl_label = t("MMDOptions.optimize_armature.label")
|
||||
bl_description = t("MMDOptions.optimize_armature.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMDOptions.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
init_progress(context, 9)
|
||||
|
||||
# Ensure proper object selection and mode
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
# Store initial transforms
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
initial_transforms = {}
|
||||
for bone in armature.data.edit_bones:
|
||||
initial_transforms[bone.name] = {
|
||||
'head': bone.head.copy(),
|
||||
'tail': bone.tail.copy(),
|
||||
'roll': bone.roll,
|
||||
'matrix': bone.matrix.copy(),
|
||||
'parent': bone.parent.name if bone.parent else None
|
||||
}
|
||||
|
||||
update_progress(self, context, t("MMDOptions.deleting_bone_constraints"))
|
||||
bpy.ops.avatar_toolkit.delete_bone_constraints('EXEC_DEFAULT')
|
||||
|
||||
update_progress(self, context, t("MMDOptions.merging_bones_to_parents"))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
try:
|
||||
bpy.ops.avatar_toolkit.merge_bones_to_parents('EXEC_DEFAULT')
|
||||
except RuntimeError as e:
|
||||
self.report({'WARNING'}, f"Failed to merge bones to parents: {str(e)}")
|
||||
|
||||
update_progress(self, context, t("MMDOptions.reordering_bones"))
|
||||
self.reorder_bones(context, armature)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.fixing_armature_names"))
|
||||
self.fix_armature_names(armature)
|
||||
|
||||
update_progress(self, context, t("MMDOptions.renaming_bones"))
|
||||
self.rename_bones(armature)
|
||||
|
||||
# Restore original bone transforms
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name, transform in initial_transforms.items():
|
||||
if bone_name in armature.data.edit_bones:
|
||||
bone = armature.data.edit_bones[bone_name]
|
||||
bone.head = transform['head']
|
||||
bone.tail = transform['tail']
|
||||
bone.roll = transform['roll']
|
||||
bone.matrix = transform['matrix']
|
||||
|
||||
update_progress(self, context, t("MMDOptions.armature_optimization_complete"))
|
||||
|
||||
# Ensure we end in object mode with proper selection
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
finish_progress(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
def reorder_bones(self, context: Context, armature: bpy.types.Object):
|
||||
def sort_bones(bone):
|
||||
children = sorted(bone.children, key=lambda b: b.name)
|
||||
for child in children:
|
||||
sort_bones(child)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
root_bones = [bone for bone in armature.data.edit_bones if not bone.parent]
|
||||
for root_bone in sorted(root_bones, key=lambda b: b.name):
|
||||
sort_bones(root_bone)
|
||||
|
||||
def fix_armature_names(self, armature):
|
||||
for bone in armature.data.bones:
|
||||
fixed_name = self.get_fixed_bone_name(bone.name)
|
||||
if fixed_name != bone.name:
|
||||
bone.name = fixed_name
|
||||
|
||||
def get_fixed_bone_name(self, name):
|
||||
name = name.replace(' ', '_')
|
||||
name = re.sub(r'[^\w]', '', name)
|
||||
return name
|
||||
|
||||
def rename_bones(self, armature):
|
||||
for bone in armature.data.bones:
|
||||
new_name = self.get_standardized_bone_name(bone.name)
|
||||
if new_name != bone.name:
|
||||
bone.name = new_name
|
||||
|
||||
def get_standardized_bone_name(self, name):
|
||||
if 'left' in name.lower():
|
||||
return f"Left_{name}"
|
||||
elif 'right' in name.lower():
|
||||
return f"Right_{name}"
|
||||
return name
|
||||
|
||||
def bake_mmd_colors(node_base_tex: ShaderNodeTexImage, node_mmd_shader: ShaderNodeGroup):
|
||||
ambient_color_input = node_mmd_shader.inputs.get("Ambient Color")
|
||||
diffuse_color_input = node_mmd_shader.inputs.get("Diffuse Color")
|
||||
|
||||
if not ambient_color_input or not diffuse_color_input:
|
||||
return node_base_tex, None
|
||||
|
||||
ambient_color = np.array(ambient_color_input.default_value[:3])
|
||||
diffuse_color = np.array(diffuse_color_input.default_value[:3])
|
||||
mmd_color = np.clip(ambient_color + diffuse_color * 0.6, 0, 1)
|
||||
|
||||
if not node_base_tex or not node_base_tex.image:
|
||||
principled_base_color = np.append(mmd_color, 1)
|
||||
return None, principled_base_color
|
||||
|
||||
base_tex_image = node_base_tex.image
|
||||
if not base_tex_image.pixels:
|
||||
return node_base_tex, None
|
||||
|
||||
if base_tex_image.colorspace_settings.name == 'sRGB':
|
||||
is_small_mask = mmd_color < 0.0031308
|
||||
mmd_color[is_small_mask] = np.where(mmd_color[is_small_mask] < 0.0, 0, mmd_color[is_small_mask] * 12.92)
|
||||
is_large_mask = np.invert(is_small_mask)
|
||||
mmd_color[is_large_mask] = (mmd_color[is_large_mask] ** (1.0 / 2.4)) * 1.055 - 0.055
|
||||
|
||||
pixels = np.array(base_tex_image.pixels).reshape((-1, 4))
|
||||
pixels[:, :3] *= mmd_color
|
||||
|
||||
baked_image = bpy.data.images.new(base_tex_image.name + "MMDCatsBaked",
|
||||
width=base_tex_image.size[0],
|
||||
height=base_tex_image.size[1],
|
||||
alpha=True)
|
||||
baked_image.filepath = bpy.path.abspath("//" + base_tex_image.name + ".png")
|
||||
baked_image.file_format = 'PNG'
|
||||
baked_image.colorspace_settings.name = base_tex_image.colorspace_settings.name
|
||||
|
||||
baked_image.pixels = pixels.flatten()
|
||||
node_base_tex.image = baked_image
|
||||
|
||||
if bpy.data.is_saved:
|
||||
baked_image.save()
|
||||
|
||||
return node_base_tex, None
|
||||
|
||||
def add_principled_shader(material: Material, bake_mmd=True):
|
||||
node_tree = material.node_tree
|
||||
nodes = node_tree.nodes
|
||||
links = node_tree.links
|
||||
|
||||
principled_shader = nodes.new(type="ShaderNodeBsdfPrincipled")
|
||||
principled_shader.label = "Cats Export Shader"
|
||||
principled_shader.location = (501, -500)
|
||||
|
||||
output_shader = nodes.new(type="ShaderNodeOutputMaterial")
|
||||
output_shader.label = "Cats Export"
|
||||
output_shader.location = (801, -500)
|
||||
|
||||
links.new(principled_shader.outputs["BSDF"], output_shader.inputs["Surface"])
|
||||
|
||||
node_base_tex = nodes.get("mmd_base_tex") or next((n for n in nodes if n.type == 'TEX_IMAGE'), None)
|
||||
node_mmd_shader = nodes.get("mmd_shader")
|
||||
|
||||
if node_mmd_shader and bake_mmd:
|
||||
node_base_tex, principled_base_color = bake_mmd_colors(node_base_tex, node_mmd_shader)
|
||||
else:
|
||||
principled_base_color = None
|
||||
|
||||
if node_base_tex and node_base_tex.image:
|
||||
links.new(node_base_tex.outputs["Color"], principled_shader.inputs["Base Color"])
|
||||
links.new(node_base_tex.outputs["Alpha"], principled_shader.inputs["Alpha"])
|
||||
elif principled_base_color is not None:
|
||||
principled_shader.inputs["Base Color"].default_value = principled_base_color
|
||||
|
||||
principled_shader.inputs["Specular IOR Level"].default_value = 0
|
||||
principled_shader.inputs["Roughness"].default_value = 0.9
|
||||
principled_shader.inputs["Sheen Tint"].default_value = (1.0, 1.0, 1.0, 1.0)
|
||||
principled_shader.inputs["Coat Roughness"].default_value = 0
|
||||
principled_shader.inputs["IOR"].default_value = 1.45
|
||||
|
||||
# Handle transparency
|
||||
if material.blend_method != 'OPAQUE':
|
||||
principled_shader.inputs["Alpha"].default_value = material.alpha_threshold
|
||||
material.blend_method = 'CLIP'
|
||||
material.shadow_method = 'CLIP'
|
||||
|
||||
def fix_mmd_shader(material: Material):
|
||||
mmd_shader_node = material.node_tree.nodes.get("mmd_shader")
|
||||
if mmd_shader_node:
|
||||
reflect_input = mmd_shader_node.inputs.get("Reflect")
|
||||
if reflect_input:
|
||||
reflect_input.default_value = 1
|
||||
|
||||
def fix_vrm_shader(material: Material):
|
||||
nodes = material.node_tree.nodes
|
||||
is_vrm_mat = False
|
||||
for node in nodes:
|
||||
if hasattr(node, 'node_tree') and 'MToon_unversioned' in node.node_tree.name:
|
||||
node.location[0] = 200
|
||||
node.inputs['ReceiveShadow_Texture_alpha'].default_value = -10000
|
||||
node.inputs['ShadeTexture'].default_value = (1.0, 1.0, 1.0, 1.0)
|
||||
node.inputs['Emission_Texture'].default_value = (0.0, 0.0, 0.0, 0.0)
|
||||
node.inputs['SphereAddTexture'].default_value = (0.0, 0.0, 0.0, 0.0)
|
||||
node_input = node.inputs.get('NomalmapTexture') or node.inputs.get('NormalmapTexture')
|
||||
node_input.default_value = (1.0, 1.0, 1.0, 1.0)
|
||||
is_vrm_mat = True
|
||||
break
|
||||
|
||||
if is_vrm_mat:
|
||||
nodes_to_keep = ['DiffuseColor', 'MainTexture', 'Emission_Texture']
|
||||
if 'HAIR' in material.name:
|
||||
nodes_to_keep.append('SphereAddTexture')
|
||||
|
||||
for node in nodes:
|
||||
if ('RGB' in node.name or 'Value' in node.name or 'Image Texture' in node.name or
|
||||
'UV Map' in node.name or 'Mapping' in node.name):
|
||||
if node.label not in nodes_to_keep:
|
||||
material.node_tree.links = [link for link in material.node_tree.links
|
||||
if not (link.from_node == node or link.to_node == node)]
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_ConvertMaterials(Operator):
|
||||
bl_idname = "avatar_toolkit.convert_materials"
|
||||
bl_label = t("MMDOptions.convert_materials.label")
|
||||
bl_description = t("MMDOptions.convert_materials.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
meshes = get_all_meshes(context)
|
||||
init_progress(context, len(meshes))
|
||||
|
||||
for obj in meshes:
|
||||
update_progress(self, context, t("MMDOptions.converting_materials").format(name=obj.name))
|
||||
self.convert_materials_for_mesh(obj)
|
||||
|
||||
finish_progress(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
def convert_materials_for_mesh(self, mesh: Object):
|
||||
for mat_slot in mesh.material_slots:
|
||||
if mat_slot.material:
|
||||
mat = mat_slot.material
|
||||
mat.use_nodes = True
|
||||
|
||||
# Add Principled BSDF shader
|
||||
add_principled_shader(mat)
|
||||
|
||||
# Fix MMD shader if present
|
||||
fix_mmd_shader(mat)
|
||||
|
||||
# Fix VRM shader if present
|
||||
fix_vrm_shader(mat)
|
||||
|
||||
# Clean up unused nodes
|
||||
self.clean_unused_nodes(mat)
|
||||
|
||||
def clean_unused_nodes(self, material: Material):
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
|
||||
used_nodes = set()
|
||||
output_node = next((n for n in nodes if n.type == 'OUTPUT_MATERIAL'), None)
|
||||
|
||||
if output_node:
|
||||
self.traverse_node_tree(output_node, used_nodes)
|
||||
|
||||
for node in nodes:
|
||||
if node not in used_nodes:
|
||||
nodes.remove(node)
|
||||
|
||||
def traverse_node_tree(self, node, used_nodes):
|
||||
used_nodes.add(node)
|
||||
for input in node.inputs:
|
||||
for link in input.links:
|
||||
if link.from_node not in used_nodes:
|
||||
self.traverse_node_tree(link.from_node, used_nodes)
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
from typing import Dict, List, Tuple, Set, Optional
|
||||
from bpy.types import Object, Armature, EditBone, Bone, Operator, Context
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.common import (
|
||||
ProgressTracker,
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_vertex_weights,
|
||||
transfer_vertex_weights,
|
||||
get_all_meshes
|
||||
)
|
||||
from ..core.translations import t
|
||||
from ..core.dictionaries import bone_names, dont_delete_these_main_bones
|
||||
|
||||
class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator):
|
||||
"""MMD Bone standardization system"""
|
||||
bl_idname = "avatar_toolkit.standardize_mmd"
|
||||
bl_label = t("MMD.standardize")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def __init__(self):
|
||||
self.bone_mapping: Dict[str, str] = {}
|
||||
self.processed_bones: Set[str] = set()
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
self.armature = get_active_armature(context)
|
||||
|
||||
if not self.armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 5, "MMD Standardization") as progress:
|
||||
# Step 1: Process bone names
|
||||
self.process_bone_names(context)
|
||||
progress.step("Processed bone names")
|
||||
|
||||
# Step 2: Fix bone structure
|
||||
self.fix_bone_structure(context)
|
||||
progress.step("Fixed bone structure")
|
||||
|
||||
# Step 3: Process weights
|
||||
self.process_weights(context)
|
||||
progress.step("Processed weights")
|
||||
|
||||
# Step 4: Clean up
|
||||
self.cleanup_armature(context)
|
||||
progress.step("Cleaned up armature")
|
||||
|
||||
# Step 5: Final validation
|
||||
self.validate_results(context)
|
||||
progress.step("Validated results")
|
||||
|
||||
self.report({'INFO'}, t("MMD.standardization_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MMD Standardization failed: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_bone_names(self, context: Context) -> None:
|
||||
"""Process and standardize bone names"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# First pass - handle IK bones
|
||||
ik_bones = [bone for bone in edit_bones if 'IK' in bone.name or 'IK' in bone.name]
|
||||
for bone in ik_bones:
|
||||
new_name = f"ik_{self.standardize_bone_name(bone.name.replace('IK', '').replace('IK', ''))}"
|
||||
self.bone_mapping[bone.name] = new_name
|
||||
bone.name = new_name
|
||||
|
||||
# Second pass - standard bones
|
||||
for bone in edit_bones:
|
||||
if bone not in ik_bones:
|
||||
new_name = self.standardize_bone_name(bone.name)
|
||||
if new_name != bone.name:
|
||||
self.bone_mapping[bone.name] = new_name
|
||||
bone.name = new_name
|
||||
|
||||
def translate_japanese_bone_name(self, name: str) -> str:
|
||||
"""Translate Japanese bone names to English standardized names"""
|
||||
name_lower = name.lower()
|
||||
|
||||
for bone_category, variations in bone_names.items():
|
||||
for variation in variations:
|
||||
if variation in name_lower:
|
||||
return bone_category
|
||||
|
||||
return name
|
||||
|
||||
def standardize_bone_name(self, name: str) -> str:
|
||||
"""Standardize individual bone names"""
|
||||
result = self.translate_japanese_bone_name(name)
|
||||
|
||||
prefixes = ['ValveBiped_', 'Bip01_', 'MMD_', 'Armature|']
|
||||
for prefix in prefixes:
|
||||
if result.lower().startswith(prefix.lower()):
|
||||
result = result[len(prefix):]
|
||||
|
||||
if result.endswith('_L') or result.endswith('.L'):
|
||||
result = f"{result[:-2]}.L"
|
||||
elif result.endswith('_R') or result.endswith('.R'):
|
||||
result = f"{result[:-2]}.R"
|
||||
|
||||
return result
|
||||
return result
|
||||
|
||||
def fix_bone_structure(self, context: Context) -> None:
|
||||
"""Fix bone hierarchy and orientations"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
self.process_spine_chain(context)
|
||||
self.fix_bone_orientations(context)
|
||||
self.connect_bones(context)
|
||||
|
||||
def process_weights(self, context: Context) -> None:
|
||||
"""Process and clean up vertex weights"""
|
||||
for mesh in self.get_associated_meshes(context):
|
||||
# Transfer weights based on bone mapping
|
||||
for old_name, new_name in self.bone_mapping.items():
|
||||
if old_name != new_name:
|
||||
transfer_vertex_weights(mesh, old_name, new_name)
|
||||
|
||||
# Clean up zero weights
|
||||
self.cleanup_vertex_groups(mesh, context)
|
||||
|
||||
def cleanup_armature(self, context: Context) -> None:
|
||||
"""Perform final cleanup operations"""
|
||||
self.remove_unused_bones(context)
|
||||
self.cleanup_constraints(context)
|
||||
self.fix_zero_length_bones(context)
|
||||
|
||||
def get_associated_meshes(self, context: Context) -> List[Object]:
|
||||
"""Get all mesh objects associated with the armature"""
|
||||
return [obj for obj in bpy.data.objects
|
||||
if obj.type == 'MESH'
|
||||
and obj.parent == self.armature]
|
||||
|
||||
def process_spine_chain(self, context: Context) -> None:
|
||||
"""Process and fix spine bone chain hierarchy"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
spine_bones = {
|
||||
'hips': None,
|
||||
'spine': None,
|
||||
'chest': None,
|
||||
'upper_chest': None,
|
||||
'neck': None,
|
||||
'head': None
|
||||
}
|
||||
|
||||
# Find spine bones using bone_names dictionary
|
||||
for bone in edit_bones:
|
||||
for spine_part, _ in spine_bones.items():
|
||||
if any(alt_name in bone.name.lower() for alt_name in bone_names[spine_part]):
|
||||
spine_bones[spine_part] = bone
|
||||
break
|
||||
|
||||
# Set up spine hierarchy
|
||||
hierarchy = [
|
||||
('hips', 'spine'),
|
||||
('spine', 'chest'),
|
||||
('chest', 'neck'),
|
||||
('neck', 'head')
|
||||
]
|
||||
|
||||
for parent_name, child_name in hierarchy:
|
||||
parent = spine_bones.get(parent_name)
|
||||
child = spine_bones.get(child_name)
|
||||
if parent and child:
|
||||
child.parent = parent
|
||||
child.use_connect = True
|
||||
|
||||
def fix_bone_orientations(self, context: Context) -> None:
|
||||
"""Fix bone orientations for standard pose compatibility"""
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# Define standardized roll values for key bones
|
||||
roll_values = {
|
||||
'upper_arm.L': -0.1,
|
||||
'upper_arm.R': 0.1,
|
||||
'forearm.L': -0.1,
|
||||
'forearm.R': 0.1,
|
||||
'thigh.L': 0.0,
|
||||
'thigh.R': 0.0,
|
||||
'shin.L': 0.0,
|
||||
'shin.R': 0.0,
|
||||
'foot.L': 0.0,
|
||||
'foot.R': 0.0,
|
||||
'spine': 0.0,
|
||||
'chest': 0.0,
|
||||
'neck': 0.0
|
||||
}
|
||||
|
||||
# Apply roll corrections
|
||||
for bone in edit_bones:
|
||||
if bone.name.lower() in roll_values:
|
||||
bone.roll = roll_values[bone.name.lower()]
|
||||
|
||||
# Process arm chains
|
||||
arm_pairs = [
|
||||
('upper_arm', 'forearm'),
|
||||
('forearm', 'hand')
|
||||
]
|
||||
|
||||
for side in ['.L', '.R']:
|
||||
for parent, child in arm_pairs:
|
||||
parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None)
|
||||
child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None)
|
||||
|
||||
if parent_bone and child_bone:
|
||||
child_bone.use_connect = True
|
||||
child_bone.use_inherit_rotation = True
|
||||
|
||||
# Process leg chains
|
||||
leg_pairs = [
|
||||
('thigh', 'shin'),
|
||||
('shin', 'foot')
|
||||
]
|
||||
|
||||
for side in ['.L', '.R']:
|
||||
for parent, child in leg_pairs:
|
||||
parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None)
|
||||
child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None)
|
||||
|
||||
if parent_bone and child_bone:
|
||||
child_bone.use_connect = True
|
||||
child_bone.use_inherit_rotation = True
|
||||
|
||||
# Align twist bones if present
|
||||
twist_bones = [b for b in edit_bones if 'twist' in b.name.lower()]
|
||||
for twist_bone in twist_bones:
|
||||
if twist_bone.parent:
|
||||
twist_bone.roll = twist_bone.parent.roll
|
||||
|
||||
def remove_unused_bones(self, context: Context) -> None:
|
||||
"""Remove unused and unnecessary bones from the armature"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
# Get list of bones that have vertex weights
|
||||
used_bones = set()
|
||||
for mesh in self.get_associated_meshes(context):
|
||||
for group in mesh.vertex_groups:
|
||||
used_bones.add(group.name)
|
||||
|
||||
# Get list of essential bones to always keep
|
||||
essential_bones = {
|
||||
'hips', 'spine', 'chest', 'upper_chest', 'neck', 'head',
|
||||
'left_leg', 'right_leg', 'left_knee', 'right_knee',
|
||||
'left_ankle', 'right_ankle', 'left_toe', 'right_toe'
|
||||
}
|
||||
|
||||
# Add any additional bones you want to preserve
|
||||
essential_bones.update(dont_delete_these_main_bones)
|
||||
|
||||
# Remove unused bones
|
||||
for bone in edit_bones:
|
||||
# Skip if bone is essential
|
||||
if bone.name.lower() in essential_bones:
|
||||
continue
|
||||
|
||||
# Skip if bone has weights
|
||||
if bone.name in used_bones:
|
||||
continue
|
||||
|
||||
# Remove the bone
|
||||
edit_bones.remove(bone)
|
||||
|
||||
|
||||
def connect_bones(self, context: Context) -> None:
|
||||
"""Connect bones that should be connected in the hierarchy"""
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
connect_chains = [
|
||||
['hips', 'spine', 'chest', 'neck', 'head'],
|
||||
['shoulder.L', 'upper_arm.L', 'forearm.L', 'hand.L'],
|
||||
['shoulder.R', 'upper_arm.R', 'forearm.R', 'hand.R'],
|
||||
['thigh.L', 'shin.L', 'foot.L', 'toe.L'],
|
||||
['thigh.R', 'shin.R', 'foot.R', 'toe.R']
|
||||
]
|
||||
|
||||
for chain in connect_chains:
|
||||
prev_bone = None
|
||||
for bone_name in chain:
|
||||
bone = next((b for b in edit_bones if b.name.lower().endswith(bone_name.lower())), None)
|
||||
if bone and prev_bone:
|
||||
bone.parent = prev_bone
|
||||
bone.use_connect = True
|
||||
prev_bone = bone
|
||||
|
||||
def cleanup_vertex_groups(self, mesh_obj: Object, context: Context) -> None:
|
||||
"""Clean up vertex groups by removing zero weights and merging similar groups"""
|
||||
threshold = context.scene.avatar_toolkit.merge_weights_threshold
|
||||
|
||||
vertex_groups = mesh_obj.vertex_groups
|
||||
|
||||
groups_to_remove = set()
|
||||
|
||||
for group in vertex_groups:
|
||||
weights = get_vertex_weights(mesh_obj, group.name)
|
||||
|
||||
if not any(weight > threshold for weight in weights.values()):
|
||||
groups_to_remove.add(group.name)
|
||||
|
||||
for group_name in groups_to_remove:
|
||||
group = vertex_groups.get(group_name)
|
||||
if group:
|
||||
vertex_groups.remove(group)
|
||||
|
||||
def validate_results(self, context: Context) -> None:
|
||||
"""Validate the results of standardization"""
|
||||
valid, messages = validate_armature(self.armature)
|
||||
if not valid:
|
||||
raise ValueError("\n".join(messages))
|
||||
|
||||
def cleanup_constraints(self, context: Context) -> None:
|
||||
"""Remove all constraints from the armature."""
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
for pose_bone in self.armature.pose.bones:
|
||||
constraints_to_remove = [constraint for constraint in pose_bone.constraints]
|
||||
for constraint in constraints_to_remove:
|
||||
pose_bone.constraints.remove(constraint)
|
||||
|
||||
def fix_zero_length_bones(self, context: Context) -> None:
|
||||
"""Fix zero-length bones by setting minimal length"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = self.armature.data.edit_bones
|
||||
|
||||
min_length = 0.01 # Minimum bone length in Blender units
|
||||
|
||||
for bone in edit_bones:
|
||||
bone_length = (bone.tail - bone.head).length
|
||||
|
||||
if bone_length < min_length:
|
||||
if bone.parent:
|
||||
direction = bone.parent.tail - bone.parent.head
|
||||
direction.normalize()
|
||||
else:
|
||||
direction = Vector((0, 0, 1))
|
||||
|
||||
bone.tail = bone.head + (direction * min_length)
|
||||
|
||||
|
||||
class ReparentMeshesOperator(bpy.types.Operator):
|
||||
bl_idname = "avatar_toolkit.reparent_meshes"
|
||||
bl_label = t("MMD.reparent_meshes")
|
||||
bl_description = t("MMD.reparent_meshes_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
meshes = get_all_meshes(context)
|
||||
if not meshes:
|
||||
self.report({'ERROR'}, t("MMD.no_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, len(meshes) + 1, "Reparenting Meshes") as progress:
|
||||
# Get or create main collection
|
||||
main_collection = self._get_main_collection(context)
|
||||
progress.step("Setting up collections")
|
||||
|
||||
# Process each mesh
|
||||
for mesh in meshes:
|
||||
progress.step(f"Processing {mesh.name}")
|
||||
self._process_mesh(mesh, armature, main_collection)
|
||||
|
||||
self.report({'INFO'}, t("MMD.reparenting_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reparenting meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def _get_main_collection(self, context) -> bpy.types.Collection:
|
||||
"""Get or create the main collection for the armature"""
|
||||
if hasattr(context.scene, 'collection'):
|
||||
return context.scene.collection
|
||||
return context.scene.collection
|
||||
|
||||
def _process_mesh(self, mesh: bpy.types.Object,
|
||||
armature: bpy.types.Object,
|
||||
main_collection: bpy.types.Collection) -> None:
|
||||
"""Process individual mesh parenting and collection management"""
|
||||
# Unlink from other collections
|
||||
for col in mesh.users_collection:
|
||||
if col != main_collection:
|
||||
col.objects.unlink(mesh)
|
||||
|
||||
# Ensure mesh is in main collection
|
||||
if mesh.name not in main_collection.objects:
|
||||
main_collection.objects.link(mesh)
|
||||
|
||||
# Set parent to armature
|
||||
mesh.parent = armature
|
||||
if not mesh.parent_type == 'ARMATURE':
|
||||
mesh.parent_type = 'ARMATURE'
|
||||
|
||||
class AVATAR_TOOLKIT_OT_ConvertMmdMorphs(Operator):
|
||||
"""Convert MMD morph data to shape keys"""
|
||||
bl_idname = "avatar_toolkit.convert_mmd_morphs"
|
||||
bl_label = t("MMD.convert_morphs")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 3, "Converting MMD Morphs") as progress:
|
||||
# Convert bone morphs to shape keys
|
||||
if hasattr(armature, 'mmd_root') and armature.mmd_root.bone_morphs:
|
||||
self.process_bone_morphs(context, armature, progress)
|
||||
|
||||
progress.step("Processed bone morphs")
|
||||
|
||||
# Clean up unused data
|
||||
self.cleanup_unused_data(context)
|
||||
progress.step("Cleaned up data")
|
||||
|
||||
# Validate results
|
||||
self.validate_results(context)
|
||||
progress.step("Validated results")
|
||||
|
||||
self.report({'INFO'}, t("MMD.conversion_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting MMD morphs: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_bone_morphs(self, context, armature, progress):
|
||||
"""Process bone morphs into shape keys"""
|
||||
for morph in armature.mmd_root.bone_morphs:
|
||||
for mesh in get_all_meshes(context):
|
||||
# Create armature modifier
|
||||
mod = mesh.modifiers.new(morph.name, 'ARMATURE')
|
||||
mod.object = armature
|
||||
|
||||
# Apply as shape key
|
||||
with context.temp_override(object=mesh):
|
||||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||||
|
||||
class AVATAR_TOOLKIT_OT_CleanupMmdModel(Operator):
|
||||
"""Clean up MMD model by removing unused data and fixing display settings"""
|
||||
bl_idname = "avatar_toolkit.cleanup_mmd"
|
||||
bl_label = t("MMD.cleanup")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("MMD.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 4, "Cleaning MMD Model") as progress:
|
||||
# Remove rigid bodies and joints
|
||||
self.remove_physics_objects(armature)
|
||||
progress.step("Removed physics objects")
|
||||
|
||||
# Clean up collections and hierarchy
|
||||
self.cleanup_hierarchy(context, armature)
|
||||
progress.step("Cleaned hierarchy")
|
||||
|
||||
# Fix viewport settings
|
||||
self.fix_viewport_settings(context)
|
||||
progress.step("Fixed viewport")
|
||||
|
||||
# Final cleanup
|
||||
clear_unused_data_blocks()
|
||||
progress.step("Cleared unused data")
|
||||
|
||||
self.report({'INFO'}, t("MMD.cleanup_complete"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning MMD model: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def remove_physics_objects(self, armature):
|
||||
"""Remove physics-related objects"""
|
||||
to_delete = []
|
||||
for child in armature.children:
|
||||
if any(x in child.name.lower() for x in ['rigidbodies', 'joints', 'physics']):
|
||||
to_delete.append(child)
|
||||
|
||||
for obj in to_delete:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
def cleanup_hierarchy(self, context, armature):
|
||||
"""Clean up object hierarchy and collections"""
|
||||
meshes = get_all_meshes(context)
|
||||
for mesh in meshes:
|
||||
# Ensure proper parenting
|
||||
mesh.parent = armature
|
||||
mesh.parent_type = 'ARMATURE'
|
||||
|
||||
# Clean up collections
|
||||
for col in mesh.users_collection:
|
||||
if col != context.scene.collection:
|
||||
col.objects.unlink(mesh)
|
||||
|
||||
if mesh.name not in context.scene.collection.objects:
|
||||
context.scene.collection.objects.link(mesh)
|
||||
|
||||
def fix_viewport_settings(self, context):
|
||||
"""Fix viewport display settings"""
|
||||
# Set armature display
|
||||
armature = get_active_armature(context)
|
||||
armature.data.display_type = 'OCTAHEDRAL'
|
||||
armature.show_in_front = True
|
||||
|
||||
# Set viewport shading
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
space = area.spaces[0]
|
||||
space.shading.type = 'MATERIAL'
|
||||
space.clip_start = 0.01
|
||||
space.clip_end = 300
|
||||
|
||||
class AVATAR_TOOLKIT_OT_FixMeshes(Operator):
|
||||
"""Clean up and optimize mesh materials, shading, and shape keys"""
|
||||
bl_idname = "avatar_toolkit.fix_meshes"
|
||||
bl_label = t("Optimization.fix_meshes")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
armature = get_active_armature(context)
|
||||
return armature is not None and get_all_meshes(context)
|
||||
|
||||
def execute(self, context):
|
||||
try:
|
||||
meshes = get_all_meshes(context)
|
||||
if not meshes:
|
||||
self.report({'ERROR'}, t("Optimization.no_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
with ProgressTracker(context, len(meshes), "Fixing Meshes") as progress:
|
||||
for mesh in meshes:
|
||||
self.process_mesh(context, mesh)
|
||||
progress.step(f"Processed {mesh.name}")
|
||||
|
||||
self.report({'INFO'}, t("Optimization.meshes_fixed"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fixing meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def process_mesh(self, context: Context, mesh: Object) -> None:
|
||||
"""Process and fix individual mesh"""
|
||||
# Unlock transforms
|
||||
for i in range(3):
|
||||
mesh.lock_location[i] = False
|
||||
mesh.lock_rotation[i] = False
|
||||
mesh.lock_scale[i] = False
|
||||
|
||||
# Process shape keys
|
||||
if mesh.data.shape_keys:
|
||||
self.fix_shape_keys(mesh)
|
||||
|
||||
# Process materials
|
||||
self.fix_materials(context, mesh)
|
||||
|
||||
def fix_shape_keys(self, mesh: Object) -> None:
|
||||
"""Fix and clean up shape keys"""
|
||||
if not mesh.data.shape_keys:
|
||||
return
|
||||
|
||||
shape_keys = mesh.data.shape_keys.key_blocks
|
||||
|
||||
# Rename basis
|
||||
if shape_keys[0].name != "Basis":
|
||||
shape_keys[0].name = "Basis"
|
||||
|
||||
# Clean up names
|
||||
for key in shape_keys:
|
||||
# Remove common prefixes/suffixes
|
||||
clean_name = key.name
|
||||
for prefix in ['Face.M F00 000 Fcl ', 'Face.M F00 000 00 Fcl ']:
|
||||
clean_name = clean_name.replace(prefix, '')
|
||||
|
||||
# Replace underscores with spaces
|
||||
clean_name = clean_name.replace('_', ' ')
|
||||
key.name = clean_name
|
||||
|
||||
# Sort shape keys by category
|
||||
categories = ['MTH', 'EYE', 'BRW', 'ALL']
|
||||
|
||||
# Create sorted list of shape key names
|
||||
ordered_names = []
|
||||
|
||||
# Add categorized keys first
|
||||
for category in categories:
|
||||
category_keys = [key.name for key in shape_keys if key.name.startswith(category)]
|
||||
ordered_names.extend(sorted(category_keys))
|
||||
|
||||
# Add remaining keys
|
||||
remaining = [key.name for key in shape_keys if not any(key.name.startswith(c) for c in categories)]
|
||||
ordered_names.extend(sorted(remaining))
|
||||
|
||||
# Reorder using context override
|
||||
with bpy.context.temp_override(active_object=mesh, selected_objects=[mesh]):
|
||||
for idx, name in enumerate(ordered_names):
|
||||
mesh.active_shape_key_index = shape_keys.find(name)
|
||||
while mesh.active_shape_key_index > idx:
|
||||
bpy.ops.object.shape_key_move(type='UP')
|
||||
|
||||
|
||||
def fix_materials(self, context: Context, mesh: Object) -> None:
|
||||
"""Fix and optimize materials"""
|
||||
for slot in mesh.material_slots:
|
||||
if not slot.material:
|
||||
continue
|
||||
|
||||
material = slot.material
|
||||
|
||||
# Set up basic material properties
|
||||
material.use_backface_culling = True
|
||||
material.blend_method = 'HASHED'
|
||||
material.shadow_method = 'HASHED'
|
||||
|
||||
# Clean up material name
|
||||
material.name = self.clean_material_name(material.name)
|
||||
|
||||
# Consolidate similar materials
|
||||
for other_slot in mesh.material_slots:
|
||||
if other_slot.material and other_slot.material != material:
|
||||
if materials_match(material, other_slot.material):
|
||||
other_slot.material = material
|
||||
|
||||
def clean_material_name(self, name: str) -> str:
|
||||
"""Clean up material name"""
|
||||
# Remove common prefixes/suffixes
|
||||
prefixes = ['material', 'mat', 'mtl', 'material.']
|
||||
for prefix in prefixes:
|
||||
if name.lower().startswith(prefix):
|
||||
name = name[len(prefix):]
|
||||
|
||||
# Remove numbers at end
|
||||
while name and name[-1].isdigit():
|
||||
name = name[:-1]
|
||||
|
||||
return name.strip()
|
||||
|
||||
class AVATAR_TOOLKIT_OT_ValidateMeshes(Operator):
|
||||
"""Validate meshes and UV maps for common issues"""
|
||||
bl_idname = "avatar_toolkit.validate_meshes"
|
||||
bl_label = t("Validation.check_meshes")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("Validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
with ProgressTracker(context, 3, "Validating Meshes") as progress:
|
||||
# Check bone hierarchy
|
||||
hierarchy_issues = self.validate_bone_hierarchy(armature)
|
||||
progress.step("Checked bone hierarchy")
|
||||
|
||||
# Check UV coordinates
|
||||
uv_issues = self.validate_uv_maps(context)
|
||||
progress.step("Checked UV maps")
|
||||
|
||||
# Generate report
|
||||
self.generate_validation_report(context, hierarchy_issues, uv_issues)
|
||||
progress.step("Generated report")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating meshes: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def validate_bone_hierarchy(self, armature: Object) -> List[str]:
|
||||
"""Validate bone hierarchy against standard structure"""
|
||||
issues = []
|
||||
|
||||
# Define expected hierarchy
|
||||
hierarchy = [
|
||||
['hips', 'spine', 'chest', 'neck', 'head'],
|
||||
['hips', 'left_leg', 'left_knee', 'left_ankle'],
|
||||
['hips', 'right_leg', 'right_knee', 'right_ankle'],
|
||||
['chest', 'left_shoulder', 'left_arm', 'left_elbow', 'left_wrist'],
|
||||
['chest', 'right_shoulder', 'right_arm', 'right_elbow', 'right_wrist']
|
||||
]
|
||||
|
||||
for chain in hierarchy:
|
||||
previous = None
|
||||
for bone_name in chain:
|
||||
# Check if bone exists
|
||||
bone = None
|
||||
for alt_name in bone_names[bone_name]:
|
||||
if alt_name in armature.data.bones:
|
||||
bone = armature.data.bones[alt_name]
|
||||
break
|
||||
|
||||
if not bone:
|
||||
issues.append(t("Validation.missing_bone", bone=bone_name))
|
||||
continue
|
||||
|
||||
# Check parent relationship
|
||||
if previous:
|
||||
if not bone.parent:
|
||||
issues.append(t("Validation.no_parent", bone=bone.name))
|
||||
elif bone.parent.name != previous.name:
|
||||
issues.append(t("Validation.wrong_parent",
|
||||
bone=bone.name,
|
||||
expected=previous.name,
|
||||
actual=bone.parent.name))
|
||||
previous = bone
|
||||
|
||||
return issues
|
||||
|
||||
def validate_uv_maps(self, context: Context) -> Dict[str, int]:
|
||||
"""Check UV maps for issues"""
|
||||
issues = {'nan_coords': 0, 'missing_uvs': 0}
|
||||
|
||||
for mesh in get_all_meshes(context):
|
||||
if not mesh.data.uv_layers:
|
||||
issues['missing_uvs'] += 1
|
||||
continue
|
||||
|
||||
for uv_layer in mesh.data.uv_layers:
|
||||
for uv in uv_layer.data:
|
||||
if math.isnan(uv.uv.x):
|
||||
uv.uv.x = 0
|
||||
issues['nan_coords'] += 1
|
||||
if math.isnan(uv.uv.y):
|
||||
uv.uv.y = 0
|
||||
issues['nan_coords'] += 1
|
||||
|
||||
return issues
|
||||
|
||||
def generate_validation_report(self, context: Context,
|
||||
hierarchy_issues: List[str],
|
||||
uv_issues: Dict[str, int]) -> None:
|
||||
"""Generate and display validation report"""
|
||||
report_lines = []
|
||||
|
||||
# Add hierarchy issues
|
||||
if hierarchy_issues:
|
||||
report_lines.append(t("Validation.hierarchy_issues"))
|
||||
report_lines.extend(hierarchy_issues)
|
||||
|
||||
# Add UV issues
|
||||
if uv_issues['nan_coords'] > 0:
|
||||
report_lines.append(t("Validation.uv_nan_coords",
|
||||
count=uv_issues['nan_coords']))
|
||||
|
||||
if uv_issues['missing_uvs'] > 0:
|
||||
report_lines.append(t("Validation.missing_uvs",
|
||||
count=uv_issues['missing_uvs']))
|
||||
|
||||
# Show report
|
||||
if report_lines:
|
||||
self.report({'WARNING'}, "\n".join(report_lines))
|
||||
else:
|
||||
self.report({'INFO'}, t("Validation.no_issues"))
|
||||
@@ -0,0 +1,175 @@
|
||||
import bpy
|
||||
import re
|
||||
from typing import Set, Dict, List, Optional, Tuple
|
||||
from bpy.types import (
|
||||
Operator,
|
||||
Context,
|
||||
Object,
|
||||
Material,
|
||||
NodeTree,
|
||||
ShaderNodeTexImage
|
||||
)
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature,
|
||||
clear_unused_data_blocks,
|
||||
ProgressTracker
|
||||
)
|
||||
|
||||
def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool:
|
||||
"""Compare two texture nodes for matching properties and image data"""
|
||||
return tex1.image == tex2.image and tex1.extension == tex2.extension
|
||||
|
||||
def consolidate_nodes(node1: ShaderNodeTexImage, node2: ShaderNodeTexImage) -> None:
|
||||
"""Transfer properties from one texture node to another to ensure consistency"""
|
||||
node2.color_space = node1.color_space
|
||||
node2.coordinates = node1.coordinates
|
||||
|
||||
def consolidate_textures(node_tree1: NodeTree, node_tree2: NodeTree) -> None:
|
||||
"""Synchronize texture nodes between two material node trees"""
|
||||
for node1 in node_tree1.nodes:
|
||||
if node1.type == 'TEX_IMAGE':
|
||||
for node2 in node_tree2.nodes:
|
||||
if (node2.type == 'TEX_IMAGE' and node1.image == node2.image):
|
||||
consolidate_nodes(node1, node2)
|
||||
node2.image = node1.image
|
||||
elif node1.type == 'GROUP':
|
||||
if node1.node_tree and node2.node_tree:
|
||||
consolidate_textures(node1.node_tree, node2.node_tree)
|
||||
|
||||
def color_match(col1: Tuple[float, ...], col2: Tuple[float, ...], tolerance: float = 0.01) -> bool:
|
||||
"""Compare two color values within a specified tolerance"""
|
||||
return all(abs(c1 - c2) < tolerance for c1, c2 in zip(col1, col2))
|
||||
|
||||
def materials_match(mat1: Material, mat2: Material, tolerance: float = 0.01) -> bool:
|
||||
"""Compare two materials for matching properties within tolerance"""
|
||||
if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance):
|
||||
return False
|
||||
|
||||
if abs(mat1.roughness - mat2.roughness) > tolerance:
|
||||
return False
|
||||
|
||||
if abs(mat1.metallic - mat2.metallic) > tolerance:
|
||||
return False
|
||||
|
||||
if abs(mat1.alpha_threshold - mat2.alpha_threshold) > tolerance:
|
||||
return False
|
||||
|
||||
if not color_match(mat1.emission_color, mat2.emission_color, tolerance):
|
||||
return False
|
||||
|
||||
if mat1.node_tree and mat2.node_tree:
|
||||
consolidate_textures(mat1.node_tree, mat2.node_tree)
|
||||
|
||||
return True
|
||||
|
||||
def get_base_name(name: str) -> str:
|
||||
"""Extract the base material name by removing numeric suffixes"""
|
||||
mat_match = re.match(r"^(.*)\.\d{3}$", name)
|
||||
return mat_match.group(1) if mat_match else name
|
||||
|
||||
class AvatarToolkit_OT_CombineMaterials(Operator):
|
||||
"""Operator for combining similar materials to reduce duplicate materials"""
|
||||
bl_idname: str = "avatar_toolkit.combine_materials"
|
||||
bl_label: str = t("Optimization.combine_materials")
|
||||
bl_description: str = t("Optimization.combine_materials_desc")
|
||||
bl_options: Set[str] = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if the operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
"""Execute the material combination operation"""
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
meshes = get_all_meshes(context)
|
||||
|
||||
if not meshes:
|
||||
self.report({'WARNING'}, t("Optimization.no_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
if not any(mesh.material_slots for mesh in meshes):
|
||||
self.report({'WARNING'}, t("Optimization.no_materials"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
with ProgressTracker(context, 4, "Combining Materials") as progress:
|
||||
try:
|
||||
num_combined = self.consolidate_materials(meshes)
|
||||
except Exception as e:
|
||||
logger.error(f"Material consolidation failed: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.consolidation"))
|
||||
return {'CANCELLED'}
|
||||
progress.step("Consolidated materials")
|
||||
|
||||
try:
|
||||
num_cleaned = self.clean_material_slots(meshes)
|
||||
except Exception as e:
|
||||
logger.error(f"Material slot cleanup failed: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.slot_cleanup"))
|
||||
return {'CANCELLED'}
|
||||
progress.step("Cleaned material slots")
|
||||
|
||||
try:
|
||||
num_removed = clear_unused_data_blocks()
|
||||
except Exception as e:
|
||||
logger.error(f"Data block cleanup failed: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.data_cleanup"))
|
||||
return {'CANCELLED'}
|
||||
progress.step("Removed unused data blocks")
|
||||
|
||||
self.report({'INFO'}, t("Optimization.materials_combined",
|
||||
combined=num_combined,
|
||||
cleaned=num_cleaned,
|
||||
removed=num_removed))
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to combine materials: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.combine_materials", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def consolidate_materials(self, meshes: List[Object]) -> int:
|
||||
"""Consolidate similar materials across all meshes"""
|
||||
mat_mapping: Dict[str, Material] = {}
|
||||
num_combined: int = 0
|
||||
|
||||
for mesh in meshes:
|
||||
for slot in mesh.material_slots:
|
||||
mat: Optional[Material] = slot.material
|
||||
if mat:
|
||||
base_name: str = get_base_name(mat.name)
|
||||
|
||||
if base_name in mat_mapping:
|
||||
base_mat: Material = mat_mapping[base_name]
|
||||
try:
|
||||
if materials_match(base_mat, mat):
|
||||
consolidate_textures(base_mat.node_tree, mat.node_tree)
|
||||
num_combined += 1
|
||||
slot.material = base_mat
|
||||
except AttributeError:
|
||||
logger.warning(f"Material attribute mismatch: {mat.name}")
|
||||
continue
|
||||
else:
|
||||
mat_mapping[base_name] = mat
|
||||
|
||||
return num_combined
|
||||
|
||||
def clean_material_slots(self, meshes: List[Object]) -> int:
|
||||
"""Remove unused material slots from meshes"""
|
||||
cleaned_slots = 0
|
||||
for obj in meshes:
|
||||
initial_slots = len(obj.material_slots)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.material_slot_remove_unused()
|
||||
cleaned_slots += initial_slots - len(obj.material_slots)
|
||||
return cleaned_slots
|
||||
@@ -0,0 +1,101 @@
|
||||
import bpy
|
||||
from typing import Set, List, Tuple, ClassVar
|
||||
from bpy.types import Operator, Context, Object
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature,
|
||||
validate_meshes,
|
||||
join_mesh_objects,
|
||||
ProgressTracker
|
||||
)
|
||||
|
||||
class AvatarToolkit_OT_JoinAllMeshes(Operator):
|
||||
"""Operator to join all meshes in the scene"""
|
||||
bl_idname: ClassVar[str] = "avatar_toolkit.join_all_meshes"
|
||||
bl_label: ClassVar[str] = t("Optimization.join_all_meshes")
|
||||
bl_description: ClassVar[str] = t("Optimization.join_all_meshes_desc")
|
||||
bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature: Object | None = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid: bool
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature: Object = get_active_armature(context)
|
||||
meshes: List[Object] = get_all_meshes(context)
|
||||
|
||||
valid: bool
|
||||
message: str
|
||||
valid, message = validate_meshes(meshes)
|
||||
if not valid:
|
||||
self.report({'WARNING'}, message)
|
||||
return {'CANCELLED'}
|
||||
|
||||
with ProgressTracker(context, 5, "Joining All Meshes") as progress:
|
||||
joined_mesh = join_mesh_objects(context, meshes, progress)
|
||||
|
||||
if joined_mesh:
|
||||
context.view_layer.objects.active = armature
|
||||
self.report({'INFO'}, t("Optimization.meshes_joined"))
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, t("Optimization.error.join_meshes"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join meshes: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.join_meshes", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_JoinSelectedMeshes(Operator):
|
||||
"""Operator to join selected meshes"""
|
||||
bl_idname: ClassVar[str] = "avatar_toolkit.join_selected_meshes"
|
||||
bl_label: ClassVar[str] = t("Optimization.join_selected_meshes")
|
||||
bl_description: ClassVar[str] = t("Optimization.join_selected_meshes_desc")
|
||||
bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature: Object | None = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid: bool
|
||||
valid, _ = validate_armature(armature)
|
||||
return (valid and
|
||||
context.mode == 'OBJECT' and
|
||||
len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
selected_meshes: List[Object] = [obj for obj in context.selected_objects if obj.type == 'MESH']
|
||||
|
||||
valid: bool
|
||||
message: str
|
||||
valid, message = validate_meshes(selected_meshes)
|
||||
if not valid:
|
||||
self.report({'WARNING'}, message)
|
||||
return {'CANCELLED'}
|
||||
|
||||
with ProgressTracker(context, 5, "Joining Selected Meshes") as progress:
|
||||
joined_mesh = join_mesh_objects(context, selected_meshes, progress)
|
||||
|
||||
if joined_mesh:
|
||||
self.report({'INFO'}, t("Optimization.selected_meshes_joined"))
|
||||
return {'FINISHED'}
|
||||
else:
|
||||
self.report({'ERROR'}, t("Optimization.error.join_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join selected meshes: {str(e)}")
|
||||
self.report({'ERROR'}, t("Optimization.error.join_selected", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
@@ -0,0 +1,281 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
from typing import List, TypedDict, Any, Literal, TypeAlias, cast
|
||||
from bpy.types import Operator, Context, Object, Event
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
validate_armature
|
||||
)
|
||||
|
||||
# Constants
|
||||
MERGE_ITERATION_COUNT = 20
|
||||
MERGE_DISTANCE_DEFAULT = 0.0001
|
||||
|
||||
# Type definitions
|
||||
ModalReturnType: TypeAlias = Literal['RUNNING_MODAL', 'FINISHED', 'CANCELLED']
|
||||
|
||||
class MeshEntry(TypedDict):
|
||||
mesh: Object
|
||||
shapekeys: list[str]
|
||||
vertices: int
|
||||
cur_vertex_pass: int
|
||||
|
||||
def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str) -> Object:
|
||||
"""Creates a duplicate mesh object for merge testing"""
|
||||
context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh.select_set(True)
|
||||
bpy.ops.object.duplicate()
|
||||
bpy.ops.object.shape_key_move(type='TOP')
|
||||
|
||||
duplicate = context.view_layer.objects.active
|
||||
duplicate.name = f"{shapekey_name}_object_is_{mesh.name}"
|
||||
return duplicate
|
||||
|
||||
def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[int, Any], current_vertex: int) -> list[int]:
|
||||
"""Process vertex merging and return merged vertex indices"""
|
||||
merged_vertices = []
|
||||
i, j = 0, 0
|
||||
|
||||
while i < len(vertices_original):
|
||||
if j + 1 > len(mesh_data.vertices):
|
||||
merged_vertices.append(i)
|
||||
j = j - 1
|
||||
elif mesh_data.vertices[j].co.xyz != vertices_original[i]:
|
||||
merged_vertices.append(i)
|
||||
j = j - 1
|
||||
elif vertices_original[i] == vertices_original[current_vertex]:
|
||||
merged_vertices.append(i)
|
||||
i, j = i + 1, j + 1
|
||||
|
||||
return merged_vertices
|
||||
|
||||
class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles_advanced"
|
||||
bl_label = t("Optimization.remove_doubles_advanced")
|
||||
bl_description = t("Optimization.remove_doubles_advanced_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if the operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the advanced remove doubles operator"""
|
||||
context.scene.avatar_toolkit.remove_doubles_advanced = True
|
||||
bpy.ops.avatar_toolkit.remove_doubles('INVOKE_DEFAULT')
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
class AvatarToolkit_OT_RemoveDoubles(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles"
|
||||
bl_label = t("Optimization.remove_doubles")
|
||||
bl_description = t("Optimization.remove_doubles_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
objects_to_do: list[MeshEntry] = []
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if the operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
"""Draw the operator's UI"""
|
||||
layout = self.layout
|
||||
layout.prop(context.scene.avatar_toolkit, "remove_doubles_merge_distance")
|
||||
layout.label(text=t("Optimization.remove_doubles_warning"))
|
||||
layout.label(text=t("Optimization.remove_doubles_wait"))
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
"""Initialize the operator"""
|
||||
logger.info("Starting modal execution of merge doubles safely")
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def setup_mesh_entry(self, mesh: Object) -> MeshEntry:
|
||||
"""Set up mesh entry data structure"""
|
||||
mesh_entry: MeshEntry = {
|
||||
"mesh": mesh,
|
||||
"shapekeys": [],
|
||||
"vertices": len(mesh.data.vertices),
|
||||
"cur_vertex_pass": 0
|
||||
}
|
||||
|
||||
if mesh.data.shape_keys:
|
||||
mesh_entry["shapekeys"] = [shape.name for shape in mesh.data.shape_keys.key_blocks]
|
||||
|
||||
return mesh_entry
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the remove doubles operator"""
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
self.report({'WARNING'}, t("Optimization.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
objects = get_all_meshes(context)
|
||||
self.objects_to_do = []
|
||||
|
||||
for mesh in objects:
|
||||
if mesh.data.name not in [obj["mesh"].data.name for obj in self.objects_to_do]:
|
||||
logger.debug(f"Setting up data for object {mesh.name}")
|
||||
mesh_entry = self.setup_mesh_entry(mesh)
|
||||
self.objects_to_do.append(mesh_entry)
|
||||
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in execute: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
def modify_mesh(self, context: Context, mesh: MeshEntry) -> None:
|
||||
"""Basic mesh modification for simple cases"""
|
||||
try:
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
mesh_data = mesh["mesh"].data
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Select vertices with different positions in shape keys
|
||||
for index, point in enumerate(mesh["mesh"].active_shape_key.points):
|
||||
if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz:
|
||||
mesh_data.vertices[index].select = True
|
||||
logger.debug(f"Shapekey has moved vertex at index {index}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modify_mesh: {str(e)}")
|
||||
|
||||
def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> bool:
|
||||
"""Advanced mesh modification with shape key handling"""
|
||||
try:
|
||||
final_merged_vertex_group = []
|
||||
initialized_final = False
|
||||
merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance
|
||||
|
||||
for shapekey_name in mesh_entry["shapekeys"]:
|
||||
duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name)
|
||||
vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)}
|
||||
|
||||
# Process merging
|
||||
merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"])
|
||||
|
||||
if not initialized_final:
|
||||
final_merged_vertex_group = merged_vertices.copy()
|
||||
initialized_final = True
|
||||
else:
|
||||
final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices]
|
||||
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Apply final merging
|
||||
if final_merged_vertex_group:
|
||||
self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance)
|
||||
|
||||
return not (len(final_merged_vertex_group) > 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modify_mesh_advanced: {str(e)}")
|
||||
return True
|
||||
|
||||
def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None:
|
||||
"""Apply final vertex merging operations"""
|
||||
mesh = mesh_entry["mesh"]
|
||||
context.view_layer.objects.active = mesh
|
||||
mesh.select_set(True)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
select_target_group = [False] * len(mesh.data.vertices)
|
||||
for vertex_index in vertex_group:
|
||||
select_target_group[vertex_index] = True
|
||||
|
||||
mesh.data.vertices.foreach_set("select", select_target_group)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def process_simple_mesh(self, context: Context, mesh: MeshEntry, merge_distance: float) -> None:
|
||||
"""Process mesh without shapekeys using simple merge operation"""
|
||||
logger.debug(f"Processing mesh without shapekeys: {mesh['mesh'].name}")
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
mesh["mesh"].data.vertices.foreach_set("select", [False] * len(mesh["mesh"].data.vertices))
|
||||
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None:
|
||||
"""Complete the mesh processing by performing final merge operations"""
|
||||
logger.debug("Finishing mesh processing")
|
||||
|
||||
if not advanced:
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
def modal(self, context: Context, event: Event) -> set[ModalReturnType]:
|
||||
"""Modal operator execution"""
|
||||
try:
|
||||
if not self.objects_to_do:
|
||||
self.report({'INFO'}, t("Optimization.remove_doubles_completed"))
|
||||
logger.info("Finishing modal execution of merge doubles safely")
|
||||
return {'FINISHED'}
|
||||
|
||||
mesh = self.objects_to_do[0]
|
||||
mesh_data = mesh["mesh"].data
|
||||
advanced = context.scene.avatar_toolkit.remove_doubles_advanced
|
||||
merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance
|
||||
|
||||
if len(mesh['shapekeys']) > 0 and not advanced:
|
||||
shapekeyname = mesh['shapekeys'].pop(0)
|
||||
mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname)
|
||||
logger.debug(f"Processing shapekey {shapekeyname}")
|
||||
self.modify_mesh(context, mesh)
|
||||
|
||||
elif not mesh_data.shape_keys:
|
||||
self.process_simple_mesh(context, mesh, merge_distance)
|
||||
self.objects_to_do.pop(0)
|
||||
|
||||
elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced:
|
||||
if self.modify_mesh_advanced(context, mesh):
|
||||
mesh["cur_vertex_pass"] += 1
|
||||
|
||||
else:
|
||||
self.finish_mesh_processing(context, mesh, advanced, merge_distance)
|
||||
self.objects_to_do.pop(0)
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in modal: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
@@ -0,0 +1,166 @@
|
||||
import bpy
|
||||
from typing import Set, Dict, List, Tuple, Optional, Any
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator, Context, Object, Event, Modifier
|
||||
from ..core.logging_setup import logger
|
||||
from ..core.translations import t
|
||||
from ..core.common import (
|
||||
get_active_armature,
|
||||
get_all_meshes,
|
||||
apply_pose_as_rest,
|
||||
validate_armature,
|
||||
cache_vertex_positions,
|
||||
apply_vertex_positions,
|
||||
validate_mesh_for_pose,
|
||||
process_armature_modifiers,
|
||||
ProgressTracker
|
||||
)
|
||||
|
||||
class BatchPoseOperationMixin:
|
||||
"""Base class for batch pose operations"""
|
||||
@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.mode == 'POSE'
|
||||
|
||||
def validate_meshes(self, meshes: List[Object]) -> List[Tuple[Object, str]]:
|
||||
"""Validate meshes for pose operations"""
|
||||
invalid_meshes = []
|
||||
for mesh in meshes:
|
||||
valid, message = validate_mesh_for_pose(mesh)
|
||||
if not valid:
|
||||
invalid_meshes.append((mesh, message))
|
||||
return invalid_meshes
|
||||
|
||||
class AvatarToolkit_OT_StartPoseMode(Operator):
|
||||
bl_idname = 'avatar_toolkit.start_pose_mode'
|
||||
bl_label = t("QuickAccess.start_pose_mode.label")
|
||||
bl_description = t("QuickAccess.start_pose_mode.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature or context.mode == "POSE":
|
||||
return False
|
||||
valid, _ = validate_armature(armature)
|
||||
return valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
logger.info(f"Starting pose mode for armature: {armature.name}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
context.view_layer.objects.active = armature
|
||||
armature.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start pose mode: {str(e)}")
|
||||
self.report({'ERROR'}, t("PoseMode.error.start", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_StopPoseMode(Operator):
|
||||
bl_idname = 'avatar_toolkit.stop_pose_mode'
|
||||
bl_label = t("QuickAccess.stop_pose_mode.label")
|
||||
bl_description = t("QuickAccess.stop_pose_mode.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
return get_active_armature(context) and context.mode == "POSE"
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.pose.select_all(action="INVERT")
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.pose.select_all(action="INVERT")
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop pose mode: {str(e)}")
|
||||
self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_shapekey'
|
||||
bl_label = t("QuickAccess.apply_pose_as_shapekey.label")
|
||||
bl_description = t("QuickAccess.apply_pose_as_shapekey.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
shapekey_name: StringProperty(
|
||||
name=t("PoseMode.shapekey.name"),
|
||||
description=t("PoseMode.shapekey.description"),
|
||||
default=t("PoseMode.shapekey.default")
|
||||
)
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
meshes = get_all_meshes(context)
|
||||
invalid_meshes = self.validate_meshes(meshes)
|
||||
|
||||
if invalid_meshes:
|
||||
message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes)
|
||||
self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message))
|
||||
|
||||
valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]]
|
||||
|
||||
with ProgressTracker(context, len(valid_meshes), "Applying Pose as Shape Key") as progress:
|
||||
for mesh_obj in valid_meshes:
|
||||
if not mesh_obj.data.shape_keys:
|
||||
mesh_obj.shape_key_add(name=t("PoseMode.basis"))
|
||||
|
||||
new_shape = mesh_obj.shape_key_add(name=self.shapekey_name, from_mix=False)
|
||||
cached_positions = cache_vertex_positions(
|
||||
mesh_obj.evaluated_get(context.evaluated_depsgraph_get())
|
||||
)
|
||||
apply_vertex_positions(new_shape.data, cached_positions)
|
||||
progress.step(f"Processed {mesh_obj.name}")
|
||||
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply pose as shape key: {str(e)}")
|
||||
self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin):
|
||||
bl_idname = 'avatar_toolkit.apply_pose_as_rest'
|
||||
bl_label = t("QuickAccess.apply_pose_as_rest.label")
|
||||
bl_description = t("QuickAccess.apply_pose_as_rest.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature_obj = get_active_armature(context)
|
||||
meshes = get_all_meshes(context)
|
||||
|
||||
invalid_meshes = self.validate_meshes(meshes)
|
||||
if invalid_meshes:
|
||||
message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes)
|
||||
self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message))
|
||||
|
||||
valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]]
|
||||
|
||||
with ProgressTracker(context, len(valid_meshes) + 2, "Applying Pose as Rest") as progress:
|
||||
success, message = apply_pose_as_rest(context, armature_obj, valid_meshes)
|
||||
if not success:
|
||||
raise ValueError(message)
|
||||
progress.step("Applied pose to armature")
|
||||
|
||||
logger.info("Successfully applied pose as rest")
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply pose as rest: {str(e)}")
|
||||
self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=str(e)))
|
||||
return {'CANCELLED'}
|
||||
@@ -1,309 +0,0 @@
|
||||
import bpy
|
||||
from typing import List, TypedDict, Any
|
||||
from bpy.types import Operator, Context, Object
|
||||
from ..core.register import register_wrap
|
||||
from ..core.common import get_selected_armature, is_valid_armature, select_current_armature, get_all_meshes
|
||||
from ..functions.translations import t
|
||||
|
||||
class meshEntry(TypedDict):
|
||||
mesh: Object
|
||||
shapekeys: list[str]
|
||||
vertices: int
|
||||
cur_vertex_pass: int
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_RemoveDoublesSafelyAdvanced(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles_safely_advanced"
|
||||
bl_label = t("Optimization.remove_doubles_safely_advanced.label")
|
||||
bl_description = t("Optimization.remove_doubles_safely_advanced.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
merge_distance: bpy.props.FloatProperty(default=0.0001)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="This process may take a long time.")
|
||||
layout.label(text="Blender may seem unresponsive during this operation.")
|
||||
layout.label(text="Please be patient and wait for it to complete.")
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: Context):
|
||||
bpy.ops.avatar_toolkit.remove_doubles_safely('INVOKE_DEFAULT', advanced=True, merge_distance=self.merge_distance)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_RemoveDoublesSafely(Operator):
|
||||
bl_idname = "avatar_toolkit.remove_doubles_safely"
|
||||
bl_label = t("Optimization.remove_doubles_safely.label")
|
||||
bl_description = t("Optimization.remove_doubles_safely.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
objects_to_do: list[meshEntry] = []
|
||||
merge_distance: bpy.props.FloatProperty(default=0.0001)
|
||||
advanced: bpy.props.BoolProperty(default=False)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature)
|
||||
|
||||
def execute(self, context: Context) -> set:
|
||||
if not select_current_armature(context):
|
||||
self.report({'WARNING'}, t("Optimization.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
armature = get_selected_armature(context)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
objects: List[Object] = get_all_meshes(context)
|
||||
self.objects_to_do = []
|
||||
|
||||
for mesh in objects:
|
||||
if mesh.data.name not in [stored_object["mesh"].data.name for stored_object in self.objects_to_do]:
|
||||
print("setting up data for object" + mesh.name)
|
||||
mesh_shapekeys = {"mesh":mesh,"shapekeys":[],"vertices":0,"cur_vertex_pass":0}
|
||||
mesh_data: bpy.types.Mesh = mesh.data
|
||||
shape: bpy.types.ShapeKey = None
|
||||
mesh_shapekeys["vertices"] = len(mesh_data.vertices)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices))
|
||||
|
||||
if mesh_data.shape_keys:
|
||||
for shape in mesh_data.shape_keys.key_blocks:
|
||||
mesh_shapekeys["shapekeys"].append(shape.name)
|
||||
if self.advanced:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
print("queued data for "+mesh.name+" is: ")
|
||||
print(mesh_shapekeys)
|
||||
self.objects_to_do.append(mesh_shapekeys)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context: Context, event: bpy.types.Event) -> set:
|
||||
print("==================")
|
||||
print("==================")
|
||||
print("==================")
|
||||
print("==================")
|
||||
print("starting modal execution of merge doubles safely.")
|
||||
print("==================")
|
||||
print("==================")
|
||||
print("==================")
|
||||
print("==================")
|
||||
self.execute(context)
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def modify_mesh(self, context: Context, mesh: meshEntry):
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
mesh_data: bpy.types.Mesh = mesh["mesh"].data
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for index, point in enumerate(mesh["mesh"].active_shape_key.points):
|
||||
if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz:
|
||||
mesh_data.vertices[index].select = True
|
||||
print("shapekey has a moved vertex at index \""+str(index)+"\", excluding from simple double merging!")
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
print("finished shapekey basic.")
|
||||
|
||||
def modify_mesh_advanced(self, context: Context, mesh_entry: meshEntry):
|
||||
|
||||
final_merged_vertex_group: list[int] = []
|
||||
initialized_final: bool = False
|
||||
|
||||
for shapekey_name in mesh_entry["shapekeys"]:
|
||||
mesh = mesh_entry["mesh"]
|
||||
|
||||
|
||||
|
||||
#make a copy to do double merge testing on for the current vertex
|
||||
context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = mesh
|
||||
mesh_data: bpy.types.Mesh = mesh.data
|
||||
vertices_original: dict[int,Any] = {}
|
||||
original_count: int = len(mesh_data.vertices)
|
||||
mesh.select_set(True)
|
||||
mesh.active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekey_name)
|
||||
bpy.ops.object.duplicate()
|
||||
bpy.ops.object.shape_key_move(type='TOP')
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
bpy.ops.object.shape_key_remove(all=True, apply_mix=False)
|
||||
|
||||
mesh = context.view_layer.objects.active
|
||||
mesh.name = shapekey_name+"_object_is_"+mesh.name
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
mesh.select_set(True)
|
||||
context.view_layer.objects.active = mesh
|
||||
mesh_data: bpy.types.Mesh = mesh.data
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for index, merged_point in enumerate(mesh_data.vertices):
|
||||
vertices_original[index] = merged_point.co.xyz
|
||||
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices))
|
||||
|
||||
select_target_vertex = [False]*len(mesh_data.vertices)
|
||||
try:
|
||||
select_target_vertex[mesh_entry["cur_vertex_pass"]] = True
|
||||
except:
|
||||
bpy.ops.object.delete() #remove our double merge testing object for this shapekey, since we merged doubles on it, it will be useless.
|
||||
return True
|
||||
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh_data.vertices.foreach_set("select",select_target_vertex)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for i in range(0,20): #for some reason, if using merge to unselected on a vertex, the vertex will only merge to 1 other vertex. so we gotta spam it to fix it.
|
||||
bpy.ops.mesh.remove_doubles(threshold=self.merge_distance, use_unselected=True, use_sharp_edge_from_normals=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
merged_vertices: list[int] = []
|
||||
mesh_data_vertices: dict[int,Any] = {}
|
||||
for idx,vertex in enumerate(mesh_data.vertices):
|
||||
mesh_data_vertices[idx] = vertex.co.xyz
|
||||
|
||||
#I'm loosing my mind with indices because I cannot keep so many numbers in my head. I will have to use 2 pointers
|
||||
# yes this can be simplified more, but the mountains of errors with using a normal for statement are making me
|
||||
# loose my mind. This is hard. - @989onan
|
||||
#Below is the magic that determines whether or not vertices were merged and then puts the vertices
|
||||
#that were merged into a list. - @989onan
|
||||
|
||||
i = 0
|
||||
j = 0
|
||||
while(i<len(vertices_original)):
|
||||
if j+1 > len(mesh_data.vertices):
|
||||
merged_vertices.append(i)
|
||||
j = j-1
|
||||
elif mesh_data.vertices[j].co.xyz != vertices_original[i]:
|
||||
merged_vertices.append(i)
|
||||
j = j-1
|
||||
elif vertices_original[i] == vertices_original[mesh_entry["cur_vertex_pass"]]:
|
||||
merged_vertices.append(i)
|
||||
|
||||
i = i+1
|
||||
j = j+1
|
||||
|
||||
|
||||
|
||||
#give our final set of points some inital data. we're looking for points that are merged on every shape key (and therefore appear in every version of merged_vertices).
|
||||
# If we initialize the array with points from the first version of merged_vertices, then we can remove the vertices from final that don't get merged from
|
||||
#every future version of merged_vertices with the "if merged_point not in merged_vertices:" code.
|
||||
if initialized_final == False:
|
||||
for point in merged_vertices:
|
||||
final_merged_vertex_group.append(point)
|
||||
initialized_final = True
|
||||
#iterate through a copy of final vertex groups to prevent crash. If a vertex was merged before, but didn't merge in this vertex,
|
||||
# then the vertex shouldn't be merged because it moves away from the vertex we are double merging now (ex: bottom of mouth moving away from top when opening on a shapekey) - @989onan
|
||||
for merged_point in final_merged_vertex_group[:]:
|
||||
if merged_point not in merged_vertices:
|
||||
final_merged_vertex_group.remove(merged_point)
|
||||
|
||||
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.delete() #remove our double merge testing object for this shapekey, since we merged doubles on it, it will be useless.
|
||||
context.view_layer.objects.active = mesh_entry["mesh"]
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
context.view_layer.objects.active = mesh_entry["mesh"]
|
||||
mesh_entry["mesh"].select_set(True)
|
||||
|
||||
original_mesh_data: bpy.types.Mesh = mesh_entry["mesh"].data
|
||||
select_target_group = [False]*len(original_mesh_data.vertices)
|
||||
|
||||
|
||||
for vertex_index in final_merged_vertex_group:
|
||||
select_target_group[vertex_index] = True
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
original_mesh_data.vertices.foreach_set("select",select_target_group)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.remove_doubles(threshold=self.merge_distance, use_unselected=False, use_sharp_edge_from_normals=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
original_mesh_data.vertices.foreach_set("select",[False]*len(original_mesh_data.vertices))
|
||||
print("finished advanced merge doubles for single vertex at index: "+str(mesh_entry["cur_vertex_pass"]))
|
||||
return not (len(final_merged_vertex_group) > 1)
|
||||
|
||||
def modal(self, context: Context, event: bpy.types.Event) -> set:
|
||||
if len(self.objects_to_do) > 0:
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
mesh: meshEntry = self.objects_to_do[0]
|
||||
mesh_data: bpy.types.Mesh = mesh["mesh"].data
|
||||
if (len(mesh['shapekeys']) > 0) and (not self.advanced):
|
||||
shapekeyname: str = mesh['shapekeys'].pop(0)
|
||||
|
||||
target_shapekey: int = mesh_data.shape_keys.key_blocks.find(shapekeyname)
|
||||
mesh["mesh"].active_shape_key_index = target_shapekey
|
||||
print("doing shapekey \""+shapekeyname+"\" on mesh \""+mesh['mesh'].name+"\".")
|
||||
self.modify_mesh(context, mesh)
|
||||
elif not (mesh_data.shape_keys):
|
||||
print("doing mesh with no shapekeys named \""+mesh['mesh'].name+"\".")
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices))
|
||||
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=self.merge_distance,use_unselected=False)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
self.objects_to_do.pop(0)
|
||||
elif (not (mesh["cur_vertex_pass"] > mesh["vertices"])) and self.advanced:
|
||||
|
||||
print("doing a merge by single vertex index at index "+str(mesh["cur_vertex_pass"]))
|
||||
|
||||
if self.modify_mesh_advanced(context, mesh):
|
||||
mesh["cur_vertex_pass"] = mesh["cur_vertex_pass"]+1
|
||||
else:
|
||||
print("finishing double merge object.")
|
||||
if not self.advanced:
|
||||
mesh["mesh"].select_set(True)
|
||||
context.view_layer.objects.active = mesh["mesh"]
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.ops.mesh.select_all(action="INVERT")
|
||||
bpy.ops.mesh.remove_doubles(threshold=self.merge_distance,use_unselected=False)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
mesh["mesh"].select_set(False)
|
||||
|
||||
self.objects_to_do.pop(0)
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
self.report({'INFO'}, t("Optimization.remove_doubles_completed"))
|
||||
print("finishing modal execution of merge doubles safely.")
|
||||
return {'FINISHED'}
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
@@ -1,184 +0,0 @@
|
||||
# This code is heavily based on the Rigify-Move-DEF by NyankoNyan (https://github.com/NyankoNyan/Rigify-Move-DEF), which is licensed under the MIT License. We just heavily improve the code and add some new features.
|
||||
|
||||
import bpy
|
||||
from ..core.register import register_wrap
|
||||
from ..core.common import get_selected_armature, is_valid_armature
|
||||
from ..functions.translations import t
|
||||
from bpy.types import Operator, Context
|
||||
|
||||
import bpy
|
||||
from ..core.register import register_wrap
|
||||
from ..core.common import get_selected_armature, is_valid_armature
|
||||
from ..functions.translations import t
|
||||
from bpy.types import Operator, Context
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_ConvertRigifyToUnity(Operator):
|
||||
bl_idname = "avatar_toolkit.convert_rigify_to_unity"
|
||||
bl_label = t("Tools.convert_rigify_to_unity.label")
|
||||
bl_description = t("Tools.convert_rigify_to_unity.desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature) and "DEF-spine" in armature.data.bones
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
armature = get_selected_armature(context)
|
||||
if not armature:
|
||||
self.report({'ERROR'}, t("Tools.no_armature_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.move_def_bones(armature)
|
||||
self.rename_bones_for_unity(armature)
|
||||
if context.scene.merge_twist_bones:
|
||||
self.handle_twist_bones(armature)
|
||||
self.report({'INFO'}, t("Tools.convert_rigify_to_unity.success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
def move_def_bones(self, armature):
|
||||
remap = self.get_org_remap(armature)
|
||||
remap.update(self.get_special_remap())
|
||||
|
||||
remove_bones_in_chain = [
|
||||
'DEF-upper_arm.L.001', 'DEF-forearm.L.001',
|
||||
'DEF-upper_arm.R.001', 'DEF-forearm.R.001',
|
||||
'DEF-thigh.L.001', 'DEF-shin.L.001',
|
||||
'DEF-thigh.R.001', 'DEF-shin.R.001'
|
||||
]
|
||||
|
||||
transform_copies = self.get_transform_copies(armature)
|
||||
|
||||
# Add missing constraints
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
for bone_name in transform_copies:
|
||||
bone = armature.pose.bones[bone_name]
|
||||
org_name = 'ORG-' + self.get_proto_name(bone_name)
|
||||
if org_name in armature.pose.bones:
|
||||
constraint = bone.constraints.new('COPY_TRANSFORMS')
|
||||
constraint.target = armature
|
||||
constraint.subtarget = org_name
|
||||
constr_count = len(bone.constraints)
|
||||
if constr_count > 1:
|
||||
bone.constraints.move(constr_count-1, 0)
|
||||
|
||||
# Apply new parents
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for remap_key in remap:
|
||||
if remap_key in armature.data.edit_bones and remap[remap_key] in armature.data.edit_bones:
|
||||
armature.data.edit_bones[remap_key].parent = armature.data.edit_bones[remap[remap_key]]
|
||||
|
||||
# Remove extra bones in chains
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
armature.data.bones[bone_name].use_deform = False
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for bone_name in remove_bones_in_chain:
|
||||
if bone_name in armature.data.bones:
|
||||
remove_bone = armature.data.edit_bones[bone_name]
|
||||
parent_bone = remove_bone.parent
|
||||
parent_bone.tail = remove_bone.tail
|
||||
retarget_bones = list(remove_bone.children)
|
||||
for bone in retarget_bones:
|
||||
bone.parent = parent_bone
|
||||
armature.data.edit_bones.remove(remove_bone)
|
||||
|
||||
def rename_bones_for_unity(self, armature):
|
||||
unity_bone_names = {
|
||||
"DEF-spine": "Hips",
|
||||
"DEF-spine.001": "Spine",
|
||||
"DEF-spine.002": "Chest",
|
||||
"DEF-spine.003": "UpperChest",
|
||||
"DEF-neck": "Neck",
|
||||
"DEF-head": "Head",
|
||||
"DEF-shoulder.L": "LeftShoulder",
|
||||
"DEF-upper_arm.L": "LeftUpperArm",
|
||||
"DEF-forearm.L": "LeftLowerArm",
|
||||
"DEF-hand.L": "LeftHand",
|
||||
"DEF-shoulder.R": "RightShoulder",
|
||||
"DEF-upper_arm.R": "RightUpperArm",
|
||||
"DEF-forearm.R": "RightLowerArm",
|
||||
"DEF-hand.R": "RightHand",
|
||||
"DEF-thigh.L": "LeftUpperLeg",
|
||||
"DEF-shin.L": "LeftLowerLeg",
|
||||
"DEF-foot.L": "LeftFoot",
|
||||
"DEF-toe.L": "LeftToes",
|
||||
"DEF-thigh.R": "RightUpperLeg",
|
||||
"DEF-shin.R": "RightLowerLeg",
|
||||
"DEF-foot.R": "RightFoot",
|
||||
"DEF-toe.R": "RightToes"
|
||||
}
|
||||
|
||||
for old_name, new_name in unity_bone_names.items():
|
||||
bone = armature.pose.bones.get(old_name)
|
||||
if bone:
|
||||
bone.name = new_name
|
||||
|
||||
def handle_twist_bones(self, armature):
|
||||
twist_bones = [
|
||||
("DEF-upper_arm_twist.L", "DEF-upper_arm.L"),
|
||||
("DEF-upper_arm_twist.R", "DEF-upper_arm.R"),
|
||||
("DEF-forearm_twist.L", "DEF-forearm.L"),
|
||||
("DEF-forearm_twist.R", "DEF-forearm.R"),
|
||||
("DEF-thigh_twist.L", "DEF-thigh.L"),
|
||||
("DEF-thigh_twist.R", "DEF-thigh.R")
|
||||
]
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for twist_bone, parent_bone in twist_bones:
|
||||
if twist_bone in armature.data.edit_bones and parent_bone in armature.data.edit_bones:
|
||||
twist = armature.data.edit_bones[twist_bone]
|
||||
parent = armature.data.edit_bones[parent_bone]
|
||||
parent.tail = twist.tail
|
||||
for child in twist.children:
|
||||
child.parent = parent
|
||||
armature.data.edit_bones.remove(twist)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
def get_org_remap(self, armature):
|
||||
remap = {}
|
||||
for bone in armature.data.bones:
|
||||
if self.is_def_bone(bone.name):
|
||||
name = self.get_proto_name(bone.name)
|
||||
parent = bone.parent
|
||||
while parent:
|
||||
parent_name = self.get_proto_name(parent.name)
|
||||
if parent_name != name:
|
||||
if ('DEF-' + parent_name) in armature.data.bones:
|
||||
remap[bone.name] = 'DEF-' + parent_name
|
||||
break
|
||||
parent = parent.parent
|
||||
return remap
|
||||
|
||||
def get_special_remap(self):
|
||||
return {
|
||||
'DEF-thigh.L': 'DEF-pelvis.L',
|
||||
'DEF-thigh.R': 'DEF-pelvis.R',
|
||||
'DEF-upper_arm.L': 'DEF-shoulder.L',
|
||||
'DEF-upper_arm.R': 'DEF-shoulder.R',
|
||||
}
|
||||
|
||||
def get_transform_copies(self, armature):
|
||||
result = []
|
||||
for bone in armature.pose.bones:
|
||||
if self.is_def_bone(bone.name) and not self.has_transform_copies(bone):
|
||||
result.append(bone.name)
|
||||
return result
|
||||
|
||||
def has_transform_copies(self, bone):
|
||||
return any(constraint.type == 'COPY_TRANSFORMS' for constraint in bone.constraints)
|
||||
|
||||
def is_def_bone(self, bone_name):
|
||||
return bone_name.startswith('DEF-')
|
||||
|
||||
def is_org_bone(self, bone_name):
|
||||
return bone_name.startswith('ORG-')
|
||||
|
||||
def get_proto_name(self, bone_name):
|
||||
if self.is_def_bone(bone_name) or self.is_org_bone(bone_name):
|
||||
return bone_name[4:]
|
||||
return bone_name
|
||||
@@ -0,0 +1,91 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
from bpy.types import Operator, Context
|
||||
from typing import Set
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, get_all_meshes, validate_armature, remove_unused_shapekeys
|
||||
|
||||
class AvatarToolkit_OT_ApplyTransforms(Operator):
|
||||
"""Apply all transformations to armature and associated meshes"""
|
||||
bl_idname = "avatar_toolkit.apply_transforms"
|
||||
bl_label = t("Tools.apply_transforms")
|
||||
bl_description = t("Tools.apply_transforms_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid and context.mode == 'OBJECT'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
logger.info(f"Applying transforms to {armature.name} and associated meshes")
|
||||
|
||||
# Select armature and meshes
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
meshes = get_all_meshes(context)
|
||||
for mesh in meshes:
|
||||
mesh.select_set(True)
|
||||
|
||||
# Apply transforms
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
self.report({'INFO'}, t("Tools.transforms_applied"))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply transforms: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_CleanShapekeys(Operator):
|
||||
"""Remove unused shape keys from meshes"""
|
||||
bl_idname = "avatar_toolkit.clean_shapekeys"
|
||||
bl_label = t("Tools.clean_shapekeys")
|
||||
bl_description = t("Tools.clean_shapekeys_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
tolerance: bpy.props.FloatProperty(
|
||||
name=t("Tools.shapekey_tolerance"),
|
||||
description=t("Tools.shapekey_tolerance_desc"),
|
||||
default=0.001,
|
||||
min=0.0001,
|
||||
max=0.1
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
logger.info("Starting shape key cleanup")
|
||||
removed_count = 0
|
||||
|
||||
for mesh in get_all_meshes(context):
|
||||
if not mesh.data.shape_keys or not mesh.data.shape_keys.use_relative:
|
||||
continue
|
||||
|
||||
removed = remove_unused_shapekeys(mesh, self.tolerance)
|
||||
removed_count += removed
|
||||
logger.debug(f"Removed {removed} shape keys from {mesh.name}")
|
||||
|
||||
self.report({'INFO'}, t("Tools.shapekeys_removed", count=removed_count))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean shape keys: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
@@ -0,0 +1,233 @@
|
||||
import bpy
|
||||
import re
|
||||
from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from ...core.translations import t
|
||||
from ...core.common import (
|
||||
get_active_armature,
|
||||
validate_armature,
|
||||
get_all_meshes,
|
||||
ProgressTracker,
|
||||
validate_bone_hierarchy,
|
||||
restore_bone_transforms
|
||||
)
|
||||
|
||||
def duplicate_bone(bone: EditBone) -> EditBone:
|
||||
"""Create a duplicate of the given bone"""
|
||||
arm = bone.id_data
|
||||
new_bone = arm.edit_bones.new(bone.name + "_copy")
|
||||
new_bone.head = bone.head
|
||||
new_bone.tail = bone.tail
|
||||
new_bone.roll = bone.roll
|
||||
new_bone.parent = bone.parent
|
||||
return new_bone
|
||||
|
||||
class AvatarToolKit_OT_CreateDigitigradeLegs(Operator):
|
||||
"""Operator to convert standard legs to digitigrade setup"""
|
||||
bl_idname = "avatar_toolkit.create_digitigrade"
|
||||
bl_label = t("Tools.create_digitigrade")
|
||||
bl_description = t("Tools.create_digitigrade_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return (is_valid and
|
||||
context.mode == 'EDIT_ARMATURE' and
|
||||
context.selected_editable_bones is not None and
|
||||
len(context.selected_editable_bones) == 2)
|
||||
|
||||
def store_bone_chain_data(self, digi0: EditBone) -> Dict[str, Any]:
|
||||
"""Store initial bone chain data"""
|
||||
chain_data = {}
|
||||
current = digi0
|
||||
while current:
|
||||
chain_data[current.name] = {
|
||||
'head': current.head.copy(),
|
||||
'tail': current.tail.copy(),
|
||||
'roll': current.roll,
|
||||
'matrix': current.matrix.copy(),
|
||||
'parent': current.parent.name if current.parent else None
|
||||
}
|
||||
if current.children:
|
||||
current = current.children[0]
|
||||
else:
|
||||
break
|
||||
return chain_data
|
||||
|
||||
def process_leg_chain(self, digi0: EditBone) -> bool:
|
||||
"""Process a single leg bone chain"""
|
||||
try:
|
||||
# Get bone chain
|
||||
digi1: EditBone = digi0.children[0]
|
||||
digi2: EditBone = digi1.children[0]
|
||||
digi3: EditBone = digi2.children[0]
|
||||
digi4: Optional[EditBone] = digi3.children[0] if digi3.children else None
|
||||
|
||||
# Clear roll for all bones
|
||||
for bone in [digi0, digi1, digi2, digi3] + ([digi4] if digi4 else []):
|
||||
bone.select = True
|
||||
bpy.ops.armature.roll_clear()
|
||||
bpy.ops.armature.select_all(action='DESELECT')
|
||||
|
||||
# Create thigh bone
|
||||
thigh = duplicate_bone(digi0)
|
||||
base_name = digi0.name.split('.')[0]
|
||||
thigh.name = base_name
|
||||
|
||||
# Create and position calf bone
|
||||
calf = duplicate_bone(digi1)
|
||||
calf.name = digi1.name.split('.')[0]
|
||||
calf.parent = thigh
|
||||
|
||||
# Calculate new positions
|
||||
midpoint = (digi1.tail + digi2.tail) * 0.5
|
||||
calf.head = thigh.tail
|
||||
calf.tail = midpoint
|
||||
|
||||
# Reparent foot to new calf
|
||||
digi3.parent = calf
|
||||
|
||||
# Mark original bones as non-IK
|
||||
for bone in [digi0, digi1, digi2]:
|
||||
if "<noik>" not in bone.name:
|
||||
bone.name = bone.name.split('.')[0] + "<noik>"
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, t("Tools.digitigrade_error", error=str(e)))
|
||||
return False
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the digitigrade conversion"""
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress:
|
||||
for digi0 in context.selected_editable_bones:
|
||||
progress.step(t("Tools.processing_leg", bone=digi0.name))
|
||||
if not self.process_leg_chain(digi0):
|
||||
return {'CANCELLED'}
|
||||
|
||||
self.report({'INFO'}, t("Tools.digitigrade_success"))
|
||||
return {'FINISHED'}
|
||||
|
||||
class AvatarToolKit_OT_DeleteBoneConstraints(Operator):
|
||||
"""Operator to remove all bone constraints from armature"""
|
||||
bl_idname = "avatar_toolkit.clean_constraints"
|
||||
bl_label = t("Tools.clean_constraints")
|
||||
bl_description = t("Tools.clean_constraints_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the constraint removal operation"""
|
||||
armature = get_active_armature(context)
|
||||
|
||||
# Select armature and make it active before changing mode
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
context.view_layer.objects.active = armature
|
||||
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
|
||||
constraints_removed = 0
|
||||
for bone in armature.pose.bones:
|
||||
while bone.constraints:
|
||||
bone.constraints.remove(bone.constraints[0])
|
||||
constraints_removed += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class AvatarToolKit_OT_RemoveZeroWeightBones(Operator):
|
||||
"""Operator to remove bones with no vertex weights"""
|
||||
bl_idname = "avatar_toolkit.clean_weights"
|
||||
bl_label = t("Tools.clean_weights")
|
||||
bl_description = t("Tools.clean_weights_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def should_preserve_bone(self, bone_name: str, context: Context) -> bool:
|
||||
"""Check if bone should be preserved based on settings"""
|
||||
if context.scene.avatar_toolkit.merge_twist_bones:
|
||||
return "twist" in bone_name.lower()
|
||||
return False
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the zero weight bone removal operation"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Store initial transforms
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
initial_transforms: Dict[str, Dict[str, Any]] = {}
|
||||
for bone in armature.data.edit_bones:
|
||||
initial_transforms[bone.name] = {
|
||||
'head': bone.head.copy(),
|
||||
'tail': bone.tail.copy(),
|
||||
'roll': bone.roll,
|
||||
'matrix': bone.matrix.copy(),
|
||||
'parent': bone.parent.name if bone.parent else None
|
||||
}
|
||||
|
||||
# Get weighted bones
|
||||
weighted_bones: List[str] = []
|
||||
meshes = get_all_meshes(context)
|
||||
|
||||
for mesh in meshes:
|
||||
mesh_data: Mesh = mesh.data
|
||||
for vertex in mesh_data.vertices:
|
||||
for group in vertex.groups:
|
||||
if group.weight > context.scene.avatar_toolkit.merge_weights_threshold:
|
||||
weighted_bones.append(mesh.vertex_groups[group.group].name)
|
||||
|
||||
# Process bone removal
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
armature_data: Armature = armature.data
|
||||
removed_count = 0
|
||||
|
||||
for bone in armature_data.edit_bones[:]: # Create a copy of the list
|
||||
if (bone.name not in weighted_bones and
|
||||
not self.should_preserve_bone(bone.name, context)):
|
||||
|
||||
# Store children data
|
||||
children = bone.children
|
||||
children_data = {child.name: initial_transforms[child.name] for child in children}
|
||||
|
||||
# Reparent children
|
||||
for child in children:
|
||||
child.use_connect = False
|
||||
if bone.parent:
|
||||
child.parent = bone.parent
|
||||
|
||||
# Remove bone
|
||||
armature_data.edit_bones.remove(bone)
|
||||
removed_count += 1
|
||||
|
||||
# Restore children positions
|
||||
for child_name, data in children_data.items():
|
||||
if child_name in armature_data.edit_bones:
|
||||
child = armature_data.edit_bones[child_name]
|
||||
child.head = data['head']
|
||||
child.tail = data['tail']
|
||||
child.roll = data['roll']
|
||||
child.matrix = data['matrix']
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count))
|
||||
return {'FINISHED'}
|
||||
@@ -0,0 +1,89 @@
|
||||
import bpy
|
||||
import re
|
||||
from typing import Set, Dict, Optional
|
||||
from bpy.types import Operator, Context
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker
|
||||
from ...core.dictionaries import bone_names, resonite_translations
|
||||
|
||||
class AvatarToolkit_OT_ConvertResonite(Operator):
|
||||
"""Convert armature bone names to Resonite format with progress tracking and validation"""
|
||||
bl_idname = "avatar_toolkit.convert_resonite"
|
||||
bl_label = t("Tools.convert_resonite")
|
||||
bl_description = t("Tools.convert_resonite_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
logger.warning("No armature selected for Resonite conversion")
|
||||
self.report({'WARNING'}, t("Armature.validation.no_armature"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
translate_bone_fails: int = 0
|
||||
untranslated_bones: Set[str] = set()
|
||||
simplified_names: Dict[str, str] = {}
|
||||
|
||||
# Create reverse lookup dictionary
|
||||
reverse_bone_lookup = {}
|
||||
for preferred_name, name_list in bone_names.items():
|
||||
for name in name_list:
|
||||
reverse_bone_lookup[name] = preferred_name
|
||||
|
||||
try:
|
||||
context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Cache simplified bone names
|
||||
for bone in armature.data.bones:
|
||||
simplified_names[bone.name] = simplify_bonename(bone.name)
|
||||
|
||||
total_bones = len(armature.data.bones)
|
||||
with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress:
|
||||
for bone in armature.data.bones:
|
||||
# Remove any existing "<noik>" tags
|
||||
bone.name = re.compile(re.escape("<noik>"), re.IGNORECASE).sub("", bone.name)
|
||||
simplified_name = simplified_names[bone.name]
|
||||
|
||||
if simplified_name in reverse_bone_lookup and reverse_bone_lookup[simplified_name] in resonite_translations:
|
||||
new_name = resonite_translations[reverse_bone_lookup[simplified_name]]
|
||||
logger.debug(f"Translating bone: {bone.name} -> {new_name}")
|
||||
bone.name = new_name
|
||||
else:
|
||||
untranslated_bones.add(bone.name)
|
||||
bone.name = bone.name + "<noik>"
|
||||
translate_bone_fails += 1
|
||||
logger.debug(f"Failed to translate bone: {bone.name}")
|
||||
|
||||
progress.step(t("Tools.convert_resonite.processing", name=bone.name))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Resonite conversion: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
finally:
|
||||
try:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
except Exception as e:
|
||||
logger.warning(f"Error returning to object mode: {str(e)}")
|
||||
|
||||
if translate_bone_fails > 0:
|
||||
logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones")
|
||||
logger.debug(f"Untranslated bones: {untranslated_bones}")
|
||||
self.report({'INFO'}, t("Tools.bones_translated_with_fails", translate_bone_fails=translate_bone_fails))
|
||||
else:
|
||||
logger.info("All bones translated successfully")
|
||||
self.report({'INFO'}, t("Tools.bones_translated_success"))
|
||||
|
||||
return {'FINISHED'}
|
||||
@@ -0,0 +1,161 @@
|
||||
import bpy
|
||||
import math
|
||||
from typing import Set, List
|
||||
from bpy.types import Operator, Context, Armature, EditBone
|
||||
from ...core.translations import t
|
||||
from ...core.logging_setup import logger
|
||||
from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, validate_armature
|
||||
|
||||
class AvatarToolkit_OT_ConnectBones(Operator):
|
||||
"""Connect disconnected bones in chain"""
|
||||
bl_idname = "avatar_toolkit.connect_bones"
|
||||
bl_label = t("Tools.connect_bones")
|
||||
bl_description = t("Tools.connect_bones_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return is_valid
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
logger.info("Starting bone connection operation")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
edit_bones = armature.data.edit_bones
|
||||
bones_connected = 0
|
||||
min_distance = context.scene.avatar_toolkit.connect_bones_min_distance
|
||||
|
||||
excluded_bones = {'LeftEye', 'RightEye', 'Head', 'Hips'}
|
||||
|
||||
for bone in edit_bones:
|
||||
if len(bone.children) == 1 and bone.name not in excluded_bones:
|
||||
child = bone.children[0]
|
||||
distance = math.dist(bone.tail, child.head)
|
||||
|
||||
if distance > min_distance:
|
||||
logger.debug(f"Connecting bone {bone.name} to {child.name}")
|
||||
bone.tail = child.head
|
||||
if bone.parent and len(bone.parent.children) == 1:
|
||||
bone.use_connect = True
|
||||
bones_connected += 1
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.connect_bones_success", count=bones_connected))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect bones: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_MergeToActive(Operator):
|
||||
"""Merge selected bones into active bone and transfer weights"""
|
||||
bl_idname = "avatar_toolkit.merge_to_active"
|
||||
bl_label = t("Tools.merge_to_active")
|
||||
bl_description = t("Tools.merge_to_active_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
return context.mode == 'EDIT_ARMATURE' and context.active_bone
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
active_bone = context.active_bone
|
||||
selected_bones = [b for b in context.selected_editable_bones if b != active_bone]
|
||||
|
||||
if not selected_bones:
|
||||
self.report({'WARNING'}, t("Tools.no_bones_selected"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Merging {len(selected_bones)} bones into {active_bone.name}")
|
||||
|
||||
# Store weights before merging
|
||||
meshes = get_all_meshes(context)
|
||||
weight_data = {}
|
||||
for bone in selected_bones:
|
||||
for mesh in meshes:
|
||||
if bone.name in mesh.vertex_groups:
|
||||
weights = get_vertex_weights(mesh, bone.name)
|
||||
weight_data.setdefault(mesh.name, {})[bone.name] = weights
|
||||
|
||||
# Transfer weights to active bone
|
||||
threshold = context.scene.avatar_toolkit.merge_weights_threshold
|
||||
for mesh_name, bone_weights in weight_data.items():
|
||||
mesh = bpy.data.objects[mesh_name]
|
||||
for bone_name, weights in bone_weights.items():
|
||||
transfer_vertex_weights(mesh, bone_name, active_bone.name, threshold)
|
||||
|
||||
# Delete merged bones
|
||||
for bone in selected_bones:
|
||||
armature.data.edit_bones.remove(bone)
|
||||
|
||||
self.report({'INFO'}, t("Tools.merge_to_active_success", count=len(selected_bones)))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to merge bones: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolkit_OT_MergeToParent(Operator):
|
||||
"""Merge selected bones into their respective parents and transfer weights"""
|
||||
bl_idname = "avatar_toolkit.merge_to_parent"
|
||||
bl_label = t("Tools.merge_to_parent")
|
||||
bl_description = t("Tools.merge_to_parent_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
return context.mode == 'EDIT_ARMATURE'
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
try:
|
||||
armature = get_active_armature(context)
|
||||
selected_bones = [b for b in context.selected_editable_bones if b.parent]
|
||||
|
||||
if not selected_bones:
|
||||
self.report({'WARNING'}, t("Tools.no_bones_with_parent"))
|
||||
return {'CANCELLED'}
|
||||
|
||||
logger.info(f"Merging {len(selected_bones)} bones to their parents")
|
||||
|
||||
# Store weights before merging
|
||||
meshes = get_all_meshes(context)
|
||||
merged_count = 0
|
||||
threshold = context.scene.avatar_toolkit.merge_weights_threshold
|
||||
|
||||
for bone in selected_bones:
|
||||
parent = bone.parent
|
||||
if not parent:
|
||||
continue
|
||||
|
||||
# Transfer weights to parent
|
||||
for mesh in meshes:
|
||||
if bone.name in mesh.vertex_groups:
|
||||
transfer_vertex_weights(mesh, bone.name, parent.name, threshold)
|
||||
|
||||
# Delete merged bone
|
||||
armature.data.edit_bones.remove(bone)
|
||||
merged_count += 1
|
||||
|
||||
self.report({'INFO'}, t("Tools.merge_to_parent_success", count=merged_count))
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to merge bones: {str(e)}")
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
@@ -0,0 +1,68 @@
|
||||
import bpy
|
||||
from bpy.types import Operator, Context
|
||||
from ...core.translations import t
|
||||
from ...core.common import get_active_armature, validate_armature
|
||||
|
||||
class AvatarToolKit_OT_SeparateByMaterials(Operator):
|
||||
"""Operator to separate mesh by materials"""
|
||||
bl_idname = "avatar_toolkit.separate_materials"
|
||||
bl_label = t("Tools.separate_materials")
|
||||
bl_description = t("Tools.separate_materials_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return (context.active_object and
|
||||
context.active_object.type == 'MESH' and
|
||||
is_valid)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the separation operation"""
|
||||
try:
|
||||
obj = context.active_object
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='MATERIAL')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.separate_materials_success"))
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
class AvatarToolKit_OT_SeparateByLooseParts(Operator):
|
||||
"""Operator to separate mesh by loose parts"""
|
||||
bl_idname = "avatar_toolkit.separate_loose"
|
||||
bl_label = t("Tools.separate_loose")
|
||||
bl_description = t("Tools.separate_loose_desc")
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
"""Check if operator can be executed"""
|
||||
armature = get_active_armature(context)
|
||||
if not armature:
|
||||
return False
|
||||
is_valid, _ = validate_armature(armature)
|
||||
return (context.active_object and
|
||||
context.active_object.type == 'MESH' and
|
||||
is_valid)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
"""Execute the separation operation"""
|
||||
try:
|
||||
obj = context.active_object
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.report({'INFO'}, t("Tools.separate_loose_success"))
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
@@ -1,97 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import bpy
|
||||
from bpy.app.translations import locale
|
||||
from typing import Dict, List, Tuple
|
||||
from ..core.addon_preferences import save_preference, get_preference
|
||||
|
||||
# Use __file__ to get the current file's directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
main_dir = os.path.dirname(current_dir)
|
||||
resources_dir = os.path.join(main_dir, "resources")
|
||||
translations_dir = os.path.join(resources_dir, "translations")
|
||||
|
||||
dictionary: Dict[str, str] = dict()
|
||||
languages: List[str] = []
|
||||
verbose: bool = True
|
||||
|
||||
def load_translations() -> bool:
|
||||
global dictionary, languages
|
||||
|
||||
old_dictionary = dictionary.copy()
|
||||
|
||||
dictionary = dict()
|
||||
languages = ["auto"]
|
||||
|
||||
# Populate languages list
|
||||
for i in os.listdir(translations_dir):
|
||||
lang = i.split(".")[0]
|
||||
if lang != "auto":
|
||||
languages.append(lang)
|
||||
|
||||
language_index = get_preference("language", 0)
|
||||
# print(f"Loading translations for language index: {language_index}") # Debug print
|
||||
|
||||
if language_index == 0: # "auto"
|
||||
language = bpy.context.preferences.view.language
|
||||
else:
|
||||
try:
|
||||
language = languages[language_index]
|
||||
except IndexError:
|
||||
language = bpy.context.preferences.view.language
|
||||
|
||||
# print(f"Selected language: {language}") # Debug print
|
||||
|
||||
translation_file: str = os.path.join(translations_dir, language + ".json")
|
||||
if os.path.exists(translation_file):
|
||||
with open(translation_file, 'r', encoding='utf-8') as file:
|
||||
dictionary = json.load(file)["messages"]
|
||||
# print(f"Loaded translations: {dictionary}") # Debug print
|
||||
else:
|
||||
custom_language: str = language.split("_")[0]
|
||||
custom_translation_file: str = os.path.join(translations_dir, custom_language + ".json")
|
||||
if os.path.exists(custom_translation_file):
|
||||
with open(custom_translation_file, 'r', encoding='utf-8') as file:
|
||||
dictionary = json.load(file)["messages"]
|
||||
# print(f"Loaded custom translations: {dictionary}") # Debug print
|
||||
else:
|
||||
print(f"Translation file not found for language: {language}")
|
||||
default_file: str = os.path.join(translations_dir, "en_US.json")
|
||||
if os.path.exists(default_file):
|
||||
with open(default_file, 'r', encoding='utf-8') as file:
|
||||
dictionary = json.load(file)["messages"]
|
||||
# print(f"Loaded default translations: {dictionary}") # Debug print
|
||||
else:
|
||||
print("Default translation file 'en_US.json' not found.")
|
||||
|
||||
return dictionary != old_dictionary
|
||||
|
||||
def t(phrase: str, default: str = None, **kwargs) -> str:
|
||||
output: str = dictionary.get(phrase)
|
||||
if output is None:
|
||||
if verbose:
|
||||
print(f'Warning: Unknown phrase: {phrase}')
|
||||
return default if default is not None else phrase
|
||||
# print(f"Translating '{phrase}' to '{output}'") # Debug print
|
||||
return output.format(**kwargs) if kwargs else output
|
||||
|
||||
def get_language_display_name(lang: str) -> str:
|
||||
if lang == "auto":
|
||||
return t("Language.auto", "Automatic")
|
||||
return t(f"Language.{lang}", lang)
|
||||
|
||||
def get_languages_list(self, context) -> List[Tuple[str, str, str]]:
|
||||
return [(str(i), get_language_display_name(lang), f"Use {lang} language") for i, lang in enumerate(languages)]
|
||||
|
||||
def update_language(self, context):
|
||||
print(f"Updating language to: {self.avatar_toolkit_language}") # Debug print
|
||||
save_preference("language", int(self.avatar_toolkit_language))
|
||||
load_translations()
|
||||
# Set a flag to indicate that a language change has occurred
|
||||
context.scene.avatar_toolkit_language_changed = True
|
||||
# Show popup after language change
|
||||
bpy.ops.avatar_toolkit.translation_restart_popup('INVOKE_DEFAULT')
|
||||
|
||||
# Initial load of translations
|
||||
# print("Performing initial load of translations") # Debug print
|
||||
load_translations()
|
||||
@@ -1,94 +0,0 @@
|
||||
import bpy
|
||||
from ..core import common
|
||||
from ..core.register import register_wrap
|
||||
from ..functions.translations import t
|
||||
from typing import List, Tuple
|
||||
from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress
|
||||
|
||||
@register_wrap
|
||||
class AvatarToolKit_OT_AutoVisemeButton(bpy.types.Operator):
|
||||
bl_idname = 'avatar_toolkit.create_visemes'
|
||||
bl_label = t('AutoVisemeButton.label')
|
||||
bl_description = t('AutoVisemeButton.desc')
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
armature = get_selected_armature(context)
|
||||
return armature is not None and is_valid_armature(armature) and get_all_meshes(context)
|
||||
|
||||
def execute(self, context: bpy.types.Context) -> set:
|
||||
try:
|
||||
self.create_visemes(context)
|
||||
return {'FINISHED'}
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, str(e))
|
||||
return {'CANCELLED'}
|
||||
|
||||
def create_visemes(self, context: bpy.types.Context) -> None:
|
||||
init_progress(context, 5) # 5 main steps
|
||||
|
||||
update_progress(self, context, t("VisemePanel.start_viseme_creation"))
|
||||
mesh = bpy.data.objects.get(context.scene.selected_mesh)
|
||||
if not mesh or not common.has_shapekeys(mesh):
|
||||
raise ValueError(t('AutoVisemeButton.error.noShapekeys'))
|
||||
|
||||
update_progress(self, context, t("VisemePanel.removing_existing_visemes"))
|
||||
self.remove_existing_vrc_shapekeys(mesh)
|
||||
|
||||
shape_a = context.scene.avatar_toolkit_mouth_a
|
||||
shape_o = context.scene.avatar_toolkit_mouth_o
|
||||
shape_ch = context.scene.avatar_toolkit_mouth_ch
|
||||
|
||||
if shape_a == "Basis" or shape_o == "Basis" or shape_ch == "Basis":
|
||||
raise ValueError(t('AutoVisemeButton.error.selectShapekeys'))
|
||||
|
||||
update_progress(self, context, t("VisemePanel.creating_visemes"))
|
||||
visemes: List[Tuple[str, List[Tuple[str, float]]]] = [
|
||||
('vrc.v_aa', [(shape_a, 0.9998)]),
|
||||
('vrc.v_ch', [(shape_ch, 0.9996)]),
|
||||
('vrc.v_dd', [(shape_a, 0.3), (shape_ch, 0.7)]),
|
||||
('vrc.v_e', [(shape_a, 0.5), (shape_ch, 0.2)]),
|
||||
('vrc.v_ff', [(shape_a, 0.2), (shape_ch, 0.4)]),
|
||||
('vrc.v_ih', [(shape_ch, 0.7), (shape_o, 0.3)]),
|
||||
('vrc.v_kk', [(shape_a, 0.7), (shape_ch, 0.4)]),
|
||||
('vrc.v_nn', [(shape_a, 0.2), (shape_ch, 0.7)]),
|
||||
('vrc.v_oh', [(shape_a, 0.2), (shape_o, 0.8)]),
|
||||
('vrc.v_ou', [(shape_o, 0.9994)]),
|
||||
('vrc.v_pp', [(shape_a, 0.0004), (shape_o, 0.0004)]),
|
||||
('vrc.v_rr', [(shape_ch, 0.5), (shape_o, 0.3)]),
|
||||
('vrc.v_sil', [(shape_a, 0.0002), (shape_ch, 0.0002)]),
|
||||
('vrc.v_ss', [(shape_ch, 0.8)]),
|
||||
('vrc.v_th', [(shape_a, 0.4), (shape_o, 0.15)])
|
||||
]
|
||||
|
||||
for viseme_name, shape_mix in visemes:
|
||||
self.create_viseme(mesh, viseme_name, shape_mix, context.scene.avatar_toolkit_shape_intensity)
|
||||
|
||||
update_progress(self, context, t("VisemePanel.sorting_shapekeys"))
|
||||
common.sort_shape_keys(mesh)
|
||||
|
||||
update_progress(self, context, t("VisemePanel.viseme_creation_completed"))
|
||||
finish_progress(context)
|
||||
|
||||
def create_viseme(self, mesh: bpy.types.Object, viseme_name: str, shape_mix: List[Tuple[str, float]], intensity: float) -> None:
|
||||
shape_keys = mesh.data.shape_keys.key_blocks
|
||||
|
||||
if viseme_name in shape_keys:
|
||||
mesh.shape_key_remove(shape_keys[viseme_name])
|
||||
|
||||
new_key = mesh.shape_key_add(name=viseme_name, from_mix=False)
|
||||
new_key.value = 0.0
|
||||
|
||||
for shape_name, value in shape_mix:
|
||||
if shape_name in shape_keys:
|
||||
source_shape = shape_keys[shape_name]
|
||||
for i, vert in enumerate(new_key.data):
|
||||
vert.co += (source_shape.data[i].co - shape_keys['Basis'].data[i].co) * value * intensity
|
||||
|
||||
def remove_existing_vrc_shapekeys(self, mesh: bpy.types.Object) -> None:
|
||||
vrc_prefixes = ['vrc.v_', 'vrc.blink_', 'vrc.lowerlid_']
|
||||
shape_keys = mesh.data.shape_keys.key_blocks
|
||||
for key in reversed(shape_keys):
|
||||
if any(key.name.startswith(prefix) for prefix in vrc_prefixes):
|
||||
mesh.shape_key_remove(key)
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user