# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later import re from collections import OrderedDict import bpy from bpy.types import Object, Operator from bpy.props import StringProperty, BoolProperty, EnumProperty from mathutils import Vector, Quaternion from math import sqrt from .symmetrize_shape_key import mirror_mesh from .prefs import get_addon_prefs from .ui_list import UILIST_OT_Entry_Add, UILIST_OT_Entry_Remove from .naming import side_is_left # When saving or pushing shapes, disable any modifier NOT in this list. DEFORM_MODIFIERS = [ 'ARMATURE', 'CAST', 'CURVE', 'DISPLACE', 'HOOK', 'LAPLACIANDEFORM', 'LATTICE', 'MESH_DEFORM', 'SHRINKWRAP', 'SIMPLE_DEFORM', 'SMOOTH', 'CORRECTIVE_SMOOTH', 'LAPLACIANSMOOTH', 'SURFACE_DEFORM', 'WARP', 'WAVE', ] GOOD_MODIFIERS = ['ARMATURE'] class OBJECT_OT_pose_key_add(UILIST_OT_Entry_Add, Operator): """Add Pose Shape Key""" bl_idname = "object.posekey_add" bl_label = "Add Pose Shape Key" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} bl_property = "pose_key_name" # Focus the text input box list_context_path: StringProperty() active_idx_context_path: StringProperty() pose_key_name: StringProperty(name="Name", default="Pose Key") def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout.column() layout.use_property_split = True layout.prop(self, 'pose_key_name') if self.pose_key_name.strip() == "": layout.alert = True layout.label(text="Name cannot be empty.", icon='ERROR') def execute(self, context): if self.pose_key_name.strip() == "": self.report({'ERROR'}, "Must specify a name.") return {'CANCELLED'} my_list = self.get_list(context) active_index = self.get_active_index(context) to_index = active_index + 1 if len(my_list) == 0: to_index = 0 psk = my_list.add() psk.name = self.pose_key_name my_list.move(len(my_list) - 1, to_index) self.set_active_index(context, to_index) return {'FINISHED'} class OBJECT_OT_pose_key_auto_init(Operator): """Assign the current Action and scene frame number to this pose key""" bl_idname = "object.posekey_auto_init" bl_label = "Initialize From Context" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): obj = context.object arm_ob = get_deforming_armature(obj) if not arm_ob: cls.poll_message_set("No deforming armature.") return False if not (arm_ob.animation_data and arm_ob.animation_data.action): cls.poll_message_set("Armature has no Action assigned.") return False obj = context.object pose_key = get_active_pose_key(obj) if ( pose_key.action == arm_ob.animation_data.action and pose_key.frame == context.scene.frame_current ): cls.poll_message_set("Action and frame number are already set.") return False return True def execute(self, context): # Set action and frame number to the current ones. obj = context.object pose_key = get_active_pose_key(obj) arm_ob = get_deforming_armature(obj) pose_key.action = arm_ob.animation_data.action pose_key.frame = context.scene.frame_current self.report({'INFO'}, "Initialized Pose Key data.") return {'FINISHED'} class OBJECT_OT_pose_key_set_pose(Operator): """Reset the rig, then set the above Action and frame number""" bl_idname = "object.posekey_set_pose" bl_label = "Set Pose" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): return poll_correct_pose_key_pose(cls, context, demand_pose=False) def execute(self, context): set_pose_of_active_pose_key(context) return {'FINISHED'} class SaveAndRestoreState: def disable_non_deform_modifiers(self, storage_ob: Object, rigged_ob: Object): # Disable non-deforming modifiers self.disabled_mods_storage = [] self.disabled_mods_rigged = [] self.disabled_fcurves = [] for obj, lst in zip( [storage_ob, rigged_ob], [self.disabled_mods_storage, self.disabled_mods_rigged] ): if not obj: continue for mod in obj.modifiers: if mod.type not in GOOD_MODIFIERS and mod.show_viewport: lst.append(mod.name) mod.show_viewport = False if mod.show_viewport: data_path = f'modifiers["{mod.name}"].show_viewport' fc = obj.animation_data.drivers.find(data_path) if fc: fc.mute = True self.disabled_fcurves.append(data_path) mod.show_viewport = False def restore_non_deform_modifiers(self, storage_ob: Object, rigged_ob: Object): # Re-enable non-deforming modifiers for obj, mod_list in zip( [storage_ob, rigged_ob], [self.disabled_mods_storage, self.disabled_mods_rigged] ): if not obj: continue for mod_namee in mod_list: obj.modifiers[mod_namee].show_viewport = True for data_path in self.disabled_fcurves: fc = obj.animation_data.drivers.find(data_path) if fc: fc.mute = False def save_state(self, context): rigged_ob = context.object pose_key = get_active_pose_key(rigged_ob) storage_ob = pose_key.storage_object # Non-Deforming modifiers self.disable_non_deform_modifiers(storage_ob, rigged_ob) # Active Shape Key Index self.orig_sk_index = rigged_ob.active_shape_key_index rigged_ob.active_shape_key_index = 0 # Shape Keys self.org_sk_toggles = {} for target_shape in pose_key.target_shapes: key_block = target_shape.key_block if not key_block: self.report({'ERROR'}, f"Shape key not found: {key_block.name}") return {'CANCELLED'} self.org_sk_toggles[key_block.name] = key_block.mute key_block.mute = True def restore_state(self, context): rigged_ob = context.object pose_key = get_active_pose_key(rigged_ob) storage_ob = pose_key.storage_object self.restore_non_deform_modifiers(storage_ob, rigged_ob) rigged_ob.active_shape_key_index = self.orig_sk_index for kb_name, kb_value in self.org_sk_toggles.items(): rigged_ob.data.shape_keys.key_blocks[kb_name].mute = kb_value class OperatorWithWarning: def invoke(self, context, event): addon_prefs = get_addon_prefs(context) if addon_prefs.no_warning: return self.execute(context) return context.window_manager.invoke_props_dialog(self, width=400) def draw(self, context): layout = self.layout.column(align=True) warning = self.get_warning_text(context) for line in warning.split("\n"): row = layout.row() row.alert = True row.label(text=line) addon_prefs = get_addon_prefs(context) col = layout.column(align=True) col.prop(addon_prefs, 'no_warning', text="Disable Warnings (Can be reset in Preferences)") def get_warning_text(self, context): raise NotImplemented class OBJECT_OT_pose_key_save(Operator, OperatorWithWarning, SaveAndRestoreState): """Save the deformed mesh vertex positions of the current pose into the Storage Object""" bl_idname = "object.posekey_save" bl_label = "Overwrite Storage Object" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): return poll_correct_pose_key_pose(cls, context) def invoke(self, context, event): obj = context.object pose_key = get_active_pose_key(obj) if pose_key.storage_object: return super().invoke(context, event) return self.execute(context) def get_warning_text(self, context): obj = context.object pose_key = get_active_pose_key(obj) return f'Overwrite storage object "{pose_key.storage_object.name}"?' def execute(self, context): rigged_ob = context.object pose_key = get_active_pose_key(rigged_ob) storage_ob = pose_key.storage_object already_existed = storage_ob != None self.disable_non_deform_modifiers(storage_ob, rigged_ob) depsgraph = context.evaluated_depsgraph_get() rigged_ob_eval = rigged_ob.evaluated_get(depsgraph) rigged_ob_eval_mesh = rigged_ob_eval.data storage_ob_name = rigged_ob.name + "-" + pose_key.name storage_ob_mesh = bpy.data.meshes.new_from_object(rigged_ob) storage_ob_mesh.name = storage_ob_name if not already_existed: storage_ob = bpy.data.objects.new(storage_ob_name, storage_ob_mesh) context.scene.collection.objects.link(storage_ob) pose_key.storage_object = storage_ob storage_ob.location = rigged_ob.location storage_ob.location.x -= rigged_ob.dimensions.x * 1.1 else: old_mesh = storage_ob.data storage_ob.data = storage_ob_mesh bpy.data.meshes.remove(old_mesh) if len(storage_ob.data.vertices) != len(rigged_ob.data.vertices): self.report( {'WARNING'}, f'Vertex Count did not match between storage object {storage_ob.name}({len(storage_ob.data.vertices)}) and current ({len(rigged_ob.data.vertices)})!', ) storage_ob_mesh = bpy.data.meshes.new_from_object(rigged_ob_eval) storage_ob.data = storage_ob_mesh storage_ob.data.name = storage_ob_name storage_ob.use_shape_key_edit_mode = True storage_ob.shape_key_add(name="Basis") target = storage_ob.shape_key_add(name="Morph Target") adjust = storage_ob.shape_key_add(name="New Changes", from_mix=True) target.value = 1 adjust.value = 1 storage_ob.active_shape_key_index = 2 # Fix material assignments in case any material slots are linked to the # object instead of the mesh. for i, ms in enumerate(rigged_ob.material_slots): if ms.link == 'OBJECT': storage_ob.material_slots[i].link = 'OBJECT' storage_ob.material_slots[i].material = ms.material # Set the target shape to be the evaluated mesh. for target_v, eval_v in zip(target.data, rigged_ob_eval_mesh.vertices): target_v.co = eval_v.co # Copy some symmetry settings from the original storage_ob.data.use_mirror_x = rigged_ob.data.use_mirror_x # Nuke vertex groups, since we don't need them. storage_ob.vertex_groups.clear() self.restore_non_deform_modifiers(storage_ob, rigged_ob) # If new shape is visible and it already existed, set it as active. if already_existed and storage_ob.visible_get(): bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') context.view_layer.objects.active = storage_ob storage_ob.select_set(True) self.report({'INFO'}, f'The deformed mesh has been stored in "{storage_ob.name}".') return {'FINISHED'} class OBJECT_OT_pose_key_push(Operator, OperatorWithWarning, SaveAndRestoreState): """Let the below shape keys blend the current deformed shape into the shape of the Storage Object""" bl_idname = "object.posekey_push" bl_label = "Load Vertex Position Data into Shape Keys" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): if not poll_correct_pose_key_pose(cls, context): return False # No shape keys to push into. obj = context.object pose_key = get_active_pose_key(obj) for target_shape in pose_key.target_shapes: if target_shape.key_block: return True cls.poll_message_set( "This Pose Key doesn't have any target shape keys to push into. Add some in the Shape Key Slots list below." ) return False def get_warning_text(self, context): obj = context.object pose_key = get_active_pose_key(obj) target_shape_names = [target.name for target in pose_key.target_shapes if target] return ( "This will overwrite the following Shape Keys: \n " + "\n ".join(target_shape_names) + "\n Are you sure?" ) def execute(self, context): """ Load the active PoseShapeKey's mesh data into its corresponding shape key, such that the shape key will blend from whatever state the mesh is currently in, into the shape stored in the PoseShapeKey. """ self.save_state(context) try: self.push_active_pose_key(context, set_pose=False) except: return {'CANCELLED'} self.restore_state(context) return {'FINISHED'} def push_active_pose_key(self, context, set_pose=False): depsgraph = context.evaluated_depsgraph_get() scene = context.scene rigged_ob = context.object pose_key = get_active_pose_key(rigged_ob) storage_object = pose_key.storage_object if storage_object.name not in context.view_layer.objects: self.report({'ERROR'}, f'Storage object "{storage_object.name}" must be in view layer!') raise Exception if set_pose: set_pose_of_active_pose_key(context) # The Pose Key stores the vertex positions of a previous evaluated mesh. # This, and the current vertex positions of the mesh are subtracted # from each other to get the difference in their shape. storage_eval_verts = pose_key.storage_object.evaluated_get(depsgraph).data.vertices rigged_eval_verts = rigged_ob.evaluated_get(depsgraph).data.vertices # Shape keys are relative to the base shape of the mesh, so that delta # will be added to the base mesh to get the final shape key vertex positions. rigged_base_verts = rigged_ob.data.vertices # The CrazySpace provides us the matrix by which each vertex has been # deformed by modifiers and shape keys. This matrix is necessary to # calculate the correct delta. rigged_ob.crazyspace_eval(depsgraph, scene) for i, v in enumerate(storage_eval_verts): if i > len(rigged_base_verts) - 1: break storage_eval_co = Vector(v.co) rigged_eval_co = rigged_eval_verts[i].co delta = storage_eval_co - rigged_eval_co delta = rigged_ob.crazyspace_displacement_to_original( vertex_index=i, displacement=delta ) base_v = rigged_base_verts[i].co for target_shape in pose_key.target_shapes: key_block = target_shape.key_block if not key_block: continue key_block.data[i].co = base_v + delta # Mirror shapes if needed for target_shape in pose_key.target_shapes: if target_shape.mirror_x: key_block = target_shape.key_block if not key_block: continue mirror_mesh( reference_verts=rigged_ob.data.vertices, vertices=key_block.data, axis='X', symmetrize=False, ) rigged_ob.crazyspace_eval_clear() if len(storage_eval_verts) != len(rigged_eval_verts): self.report( {'WARNING'}, f'Mismatching topology: Stored shape "{pose_key.storage_object.name}" had {len(storage_eval_verts)} vertices instead of {len(rigged_eval_verts)}', ) class OBJECT_OT_pose_key_push_all(Operator, OperatorWithWarning, SaveAndRestoreState): """Go through all Pose Keys, set their pose and overwrite the shape keys to match the storage object shapes""" bl_idname = "object.posekey_push_all" bl_label = "Push ALL Pose Keys into Shape Keys" bl_options = {'UNDO', 'REGISTER'} @classmethod def poll(cls, context): obj = context.object if not obj or obj.type != 'MESH': cls.poll_message_set("No active mesh object.") return False if len(obj.data.pose_keys) == 0: cls.poll_message_set("No Pose Shape Keys to push.") return False return True def get_warning_text(self, context): obj = context.object target_shape_names = [] for pk in obj.data.pose_keys: target_shape_names.extend([t.name for t in pk.target_shapes if t]) return ( "This will overwrite the following Shape Keys: \n " + "\n ".join(target_shape_names) + "\n Are you sure?" ) def execute(self, context): rigged_ob = context.object for i, pk in enumerate(rigged_ob.data.pose_keys): rigged_ob.data.active_pose_key_index = i bpy.ops.object.posekey_set_pose() bpy.ops.object.posekey_push() return {'FINISHED'} class OBJECT_OT_pose_key_clamp_influence(Operator): """Clamp the influence of this pose key's shape keys to 1.0 for each vertex, by normalizing the vertex weight mask values of vertices where the total influence is greater than 1""" bl_idname = "object.posekey_clamp_influence" bl_label = "Clamp Vertex Influences" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @staticmethod def get_affected_vertex_group_names(object: Object) -> list[str]: pose_key = get_active_pose_key(object) vg_names = [] for target_shape in pose_key.target_shapes: kb = target_shape.key_block if not kb: continue if kb.vertex_group and kb.vertex_group in object.vertex_groups: vg_names.append(kb.vertex_group) return vg_names @classmethod def poll(cls, context): if not cls.get_affected_vertex_group_names(context.object): cls.poll_message_set( "No shape keys of this pose shape key use vertex masks. There is nothing to clamp." ) return False return True def normalize_vgroups(self, obj, vgroups): """Normalize a set of vertex groups in isolation""" """ Used for creating mask vertex groups for splitting shape keys """ for vert in obj.data.vertices: # Find sum of weights in specified vgroups # set weight to original/sum sum_weights = 0 for vgroup in vgroups: try: sum_weights += vgroup.weight(vert.index) except: pass for vgroup in vgroups: if sum_weights > 1.0: try: vgroup.add([vert.index], vgroup.weight(vert.index) / sum_weights, 'REPLACE') except: pass def execute(self, context): obj = context.object vg_names = self.get_affected_vertex_group_names(obj) self.normalize_vgroups(obj, [obj.vertex_groups[vg_name] for vg_name in vg_names]) return {'FINISHED'} class OBJECT_OT_pose_key_place_objects_in_grid(Operator): """Place the storage objects in a grid above this object""" bl_idname = "object.posekey_object_grid" bl_label = "Place ALL Storage Objects in a Grid" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @staticmethod def get_storage_objects(context) -> list[Object]: obj = context.object pose_keys = obj.data.pose_keys return [pk.storage_object for pk in pose_keys if pk.storage_object] @classmethod def poll(cls, context): """Only available if there are any storage objects in any of the pose keys.""" if not cls.get_storage_objects(context): cls.poll_message_set( "This pose key has no storage objects, so there is nothing to sort into a grid." ) return False return True @staticmethod def place_objects_in_grid(context, objs: list[Object]): if not objs: return x = max([obj.dimensions.x for obj in objs]) y = max([obj.dimensions.y for obj in objs]) z = max([obj.dimensions.z for obj in objs]) scalar = 1.2 dimensions = Vector((x * scalar, y * scalar, z * scalar)) grid_rows = round(sqrt(len(objs))) for i, obj in enumerate(objs): col_i = (i % grid_rows) - int(grid_rows / 2) row_i = int(i / grid_rows) + scalar offset = Vector((col_i * dimensions.x, 0, row_i * dimensions.z)) obj.location = context.object.location + offset def execute(self, context): storage_objects = self.get_storage_objects(context) self.place_objects_in_grid(context, storage_objects) return {'FINISHED'} class OBJECT_OT_pose_key_jump_to_storage(Operator): """Place the storage object next to this object and select it""" bl_idname = "object.posekey_jump_to_storage" bl_label = "Jump To Storage Object" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @staticmethod def get_storage_object(context): obj = context.object pose_key = get_active_pose_key(obj) return pose_key.storage_object @classmethod def poll(cls, context): """Only available if there is a storage object in the pose key.""" if not cls.get_storage_object(context): cls.poll_message_set("This pose key doesn't have a storage object to jump to.") return False return True def execute(self, context): storage_object = self.get_storage_object(context) storage_object.location = context.object.location storage_object.location.x -= context.object.dimensions.x * 1.1 if storage_object.name not in context.view_layer.objects: self.report({'ERROR'}, "Storage object must be in view layer.") return {'CANCELLED'} bpy.ops.object.select_all(action='DESELECT') storage_object.select_set(True) storage_object.hide_set(False) context.view_layer.objects.active = storage_object # Put the other storage objects in a grid prefs = get_addon_prefs(context) if prefs.grid_objects_on_jump: storage_objects = OBJECT_OT_pose_key_place_objects_in_grid.get_storage_objects(context) storage_objects.remove(storage_object) OBJECT_OT_pose_key_place_objects_in_grid.place_objects_in_grid(context, storage_objects) return {'FINISHED'} class OBJECT_OT_pose_key_copy_data(Operator): """Copy Pose Key data from active object to selected ones""" bl_idname = "object.posekey_copy_data" bl_label = "Copy Pose Key Data" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} @classmethod def poll(cls, context): """Only available if there is a selected mesh and the active mesh has pose key data.""" selected_meshes = [obj for obj in context.selected_objects if obj.type == 'MESH'] if len(selected_meshes) < 2: cls.poll_message_set("No other meshes are selected to copy pose key data to.") return False if context.object.type != 'MESH' or not context.object.data.pose_keys: cls.poll_message_set("No active mesh object with pose keys to copy.") return False return True def execute(self, context): source_ob = context.object targets = [ obj for obj in context.selected_objects if obj.type == 'MESH' and obj != source_ob ] for target_ob in targets: target_ob.data.pose_keys.clear() for src_pk in source_ob.data.pose_keys: new_pk = target_ob.data.pose_keys.add() new_pk.name = src_pk.name new_pk.action = src_pk.action new_pk.frame = src_pk.frame new_pk.storage_object = src_pk.storage_object for src_sk_slot in src_pk.target_shapes: new_sk_slot = new_pk.target_shapes.add() new_sk_slot.name = src_sk_slot.name new_sk_slot.mirror_x = src_sk_slot.mirror_x return {'FINISHED'} class OBJECT_OT_pose_key_shape_add(UILIST_OT_Entry_Add, Operator): """Add Target Shape Key""" bl_idname = "object.posekey_shape_add" bl_label = "Add Target Shape Key" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} list_context_path: StringProperty() active_idx_context_path: StringProperty() def update_sk_name(self, context): def set_vg(vg_name): obj = context.object vg = obj.vertex_groups.get(vg_name) if vg: self.vg_name = vg.name return vg vg = set_vg(self.sk_name) if not vg and self.sk_name.endswith(".L"): vg = set_vg("Side.L") if not vg and self.sk_name.endswith(".R"): vg = set_vg("Side.R") sk_name: StringProperty( name="Name", description="Name to set for the new shape key", default="Key", update=update_sk_name, ) def update_create_sk(self, context): obj = context.object if not self.create_sk and not obj.data.shape_keys: # If there are no shape keys, force enable creation of new shape key. self.create_sk = True return pose_key = get_active_pose_key(obj) if self.create_sk: self.sk_name = pose_key.name elif self.sk_name not in context.object.data.shape_keys.key_blocks: self.sk_name = "" create_sk: BoolProperty( name="Create New Shape Key", description="Create a new blank Shape Key to push this pose into, instead of using an existing one", default=True, update=update_create_sk ) vg_name: StringProperty( name="Vertex Group", description="Vertex Group to assign as the masking group of this shape key", default="", ) def update_create_vg(self, context): if self.create_vg: self.vg_name = self.sk_name elif self.vg_name not in context.object.vertex_groups: self.vg_name = "" create_vg: BoolProperty( name="Create New Vertex Group", description="Create a new blank Vertex Group as a mask for this shape key, as opposed to using an existing vertex group for masking", default=False, update=update_create_vg, ) create_slot: BoolProperty( name="Create New Slot", description="Internal. Whether to assign the chosen (or created) shape key to the current slot, or to create a new one", default=True, ) def invoke(self, context, event): obj = context.object if obj.data.shape_keys: self.sk_name = f"Key {len(obj.data.shape_keys.key_blocks)}" else: self.sk_name = "Key" self.create_sk = True pose_key = get_active_pose_key(obj) if pose_key.name: self.sk_name = pose_key.name if not self.create_slot and pose_key.active_target: self.sk_name = pose_key.active_target.name return context.window_manager.invoke_props_dialog(self, width=350) def draw(self, context): layout = self.layout.column() layout.use_property_split = True obj = context.object row = layout.row(align=True) if self.create_sk: row.prop(self, 'sk_name', icon='SHAPEKEY_DATA') else: row.prop_search( self, 'sk_name', obj.data.shape_keys, 'key_blocks', icon='SHAPEKEY_DATA' ) if obj.data.shape_keys: # We don't want to draw this option if there are no shape keys. (Since it would have to be True.) row.prop(self, 'create_sk', text="", icon='ADD') if (self.create_sk and self.sk_name) or obj.data.shape_keys.key_blocks.get(self.sk_name): row = layout.row(align=True) if self.create_vg: if obj.vertex_groups.get(self.vg_name): row.alert = True layout.label( text="Cannot create that vertex group because it already exists!", icon='ERROR' ) row.prop(self, 'vg_name', icon='GROUP_VERTEX') else: row.prop_search(self, 'vg_name', obj, "vertex_groups") row.prop(self, 'create_vg', text="", icon='ADD') def execute(self, context): if self.sk_name.strip() == "": self.report({'ERROR'}, "Must provide shape key name.") return {'CANCELLED'} obj = context.object if self.create_vg and obj.vertex_groups.get(self.vg_name): self.report({'ERROR'}, f"Vertex group '{self.vg_name}' already exists!") return {'CANCELLED'} # Ensure Basis shape key if not obj.data.shape_keys: basis = obj.shape_key_add() basis.name = "Basis" obj.data.update() if self.create_sk: # Add new shape key. key_block = obj.shape_key_add() key_block.name = self.sk_name key_block.value = 1 else: key_block = obj.data.shape_keys.key_blocks.get(self.sk_name) if key_block: if self.create_vg: obj.vertex_groups.new(name=self.vg_name) if self.vg_name: key_block.vertex_group = self.vg_name pose_key = get_active_pose_key(obj) if self.create_slot: super().execute(context) if key_block: target_slot = pose_key.active_target target_slot.name = key_block.name self.report({'INFO'}, f"Added shape key {key_block.name}.") else: self.report({'ERROR'}, "Failed to add shape key.") obj.data.active_pose_key_index = obj.data.active_pose_key_index return {'FINISHED'} class OBJECT_OT_pose_key_shape_remove(UILIST_OT_Entry_Remove, OperatorWithWarning, Operator): """Remove Target Shape Key. Hold Shift to only remove the slot and keep the actual shape key""" bl_idname = "object.posekey_shape_remove" bl_label = "Remove Target Shape Key" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} list_context_path: StringProperty() active_idx_context_path: StringProperty() delete_sk: BoolProperty( name="Delete Shape Key", description="Delete the underlying Shape Key", default=True, ) def get_key_block(self, context): obj = context.object pose_key = get_active_pose_key(obj) target_slot = pose_key.active_target return target_slot.key_block def invoke(self, context, event): if self.get_key_block(context): # If this actually targets a shape key, prompt for removal. self.delete_sk = not event.shift if self.delete_sk: return super().invoke(context, event) return self.execute(context) def get_warning_text(self, context): return "Delete this Shape Key?" @staticmethod def delete_shapekey_with_drivers(obj, key_block): shape_key = key_block.id_data if shape_key.animation_data: for fcurve in shape_key.animation_data.drivers: if fcurve.data_path.startswith(f'key_blocks["{key_block.name}"]'): shape_key.animation_data.drivers.remove(fcurve) obj.shape_key_remove(key_block) def execute(self, context): obj = context.object key_block = self.get_key_block(context) if key_block and self.delete_sk: self.delete_shapekey_with_drivers(obj, key_block) super().execute(context) self.report({'INFO'}, f"Removed shape key slot.") return {'FINISHED'} class OBJECT_OT_pose_key_magic_driver(Operator): """Automatically drive this shape key based on current pose""" bl_idname = "object.posekey_magic_driver" bl_label = "Auto-initialize Driver" bl_options = {'UNDO', 'REGISTER', 'INTERNAL'} shapekey_name: StringProperty() side: EnumProperty( name="Side", items=[ ('LEFT', "Left", "Exclude bones whose names indicate right-side."), ('BOTH', "Both", "Consider the transformations of all bones, regardless of which side they're on."), ('RIGHT', "Right", "Exclude bones whose names indicate left-side."), ], default='BOTH', ) only_selected: BoolProperty( name="Only Selected Bones", description="Only consider selected bones for the driver set-up", default=False ) @classmethod def poll(cls, context): return poll_correct_pose_key_pose(cls, context) def get_driving_channels(self, context) -> OrderedDict[str, tuple[str, float]]: obj = context.object arm_ob = get_deforming_armature(obj) channels = OrderedDict() EPSILON = 0.0001 for pb in arm_ob.pose.bones: bone_is_left = side_is_left(pb.name) if self.side != 'BOTH': if (bone_is_left and self.side=='RIGHT') or (bone_is_left==False and self.side=='LEFT'): continue if self.only_selected and not pb.bone.select: continue bone_channels = OrderedDict({'loc' : [], 'rot': [], 'scale': []}) for axis in "xyz": value = getattr(pb.location, axis) if abs(value) > EPSILON: bone_channels['loc'].append((axis.upper(), value)) channels[pb.name] = bone_channels if len(pb.rotation_mode) == 3: # Euler rotation: Check each axis. value = getattr(pb.rotation_euler, axis) if abs(value) > EPSILON: bone_channels['rot'].append((axis.upper(), value)) channels[pb.name] = bone_channels else: if pb.rotation_mode == 'QUATERNION': euler_rot = pb.rotation_quaternion.to_euler() elif pb.rotation_mode == 'AXIS_ANGLE': quat = Quaternion(Vector(pb.rotation_axis_angle).yzw, pb.rotation_axis_angle[0]) euler_rot = quat.to_euler() value = getattr(euler_rot, axis) if abs(value) > EPSILON: bone_channels['rot'].append((axis.upper(), value)) channels[pb.name] = bone_channels value = getattr(pb.scale, axis) if abs(value-1) > EPSILON: bone_channels['scale'].append((axis.upper(), value)) channels[pb.name] = bone_channels return channels @staticmethod def sanitize_variable_name(name): # Replace all non-word characters with underscores sanitized = re.sub(r'\W', '_', name) # Prepend underscore if the first character is a digit if sanitized and sanitized[0].isdigit(): sanitized = '_' + sanitized return sanitized def invoke(self, context, event): key_is_left = side_is_left(self.shapekey_name) if key_is_left: self.side = 'LEFT' elif key_is_left == False: self.side = 'RIGHT' else: self.side = 'BOTH' return context.window_manager.invoke_props_dialog(self, width=300) def draw(self, context): layout = self.layout layout.label(text="Driver will be created based on the current pose.") layout.use_property_split = True layout.prop(self, 'only_selected') layout.prop(self, 'side', expand=True, text="Side") driving_channels = self.get_driving_channels(context) if not driving_channels: layout.label(text="No driving channels found.") return obj = context.object arm_ob = get_deforming_armature(obj) col = layout.column(align=True) for bone_name, transforms in driving_channels.items(): pb = arm_ob.pose.bones.get(bone_name) bone_box = col.box() bone_box.prop(pb, 'name', icon='BONE_DATA', text="", emboss=False) for transform, trans_inf in transforms.items(): axes = [f"{inf[0]} ({val:.2f})" for inf, val in trans_inf] if not axes: continue if transform == 'rot': icon = 'CON_ROTLIKE' elif transform == 'scale': icon = 'CON_SIZELIKE' else: icon = 'CON_LOCLIKE' bone_box.row().label(text=", ".join(axes), icon=icon) def execute(self, context): driving_channels = self.get_driving_channels(context) if not driving_channels: self.report({'ERROR'}, "No transform channels found to drive the shape key.") return {'CANCELLED'} count = 0 for bone_name, transforms in driving_channels.items(): for transform, trans_inf in transforms.items(): for axis, value in trans_inf: count += 1 if count > 16: self.report({'ERROR'}, f"Too many transform channels ({count}>16). Can't fit all in the driver expression.") return {'CANCELLED'} obj = context.object arm_ob = get_deforming_armature(obj) key_block = obj.data.shape_keys.key_blocks.get(self.shapekey_name) key_block.driver_remove('value') fc = key_block.driver_add('value') if not self.setup_driver(fc, arm_ob, driving_channels): return {'CANCELLED'} self.report({'INFO'}, "Created shape key driver.") return {'FINISHED'} def setup_driver(self, fcurve, target, driving_channels, short=False) -> str: expressions = [] for m in fcurve.modifiers: # Drivers created via Python are initialized with a modifier that does nothing, # except it prevents the insertion of keyframes. Weird as hell. fcurve.modifiers.remove(m) fcurve.keyframe_points.insert(0, 0) fcurve.keyframe_points.insert(1, 1) fcurve.extrapolation = 'LINEAR' driver = fcurve.driver for var in reversed(driver.variables): driver.variables.remove(var) for bone_name, transforms in driving_channels.items(): for transform, trans_inf in transforms.items(): for axis, value in trans_inf: transf_type = transform.upper()+"_"+axis var = driver.variables.new() if short: # Earlier code should early-exit before this gets to q. var.name = "abcdefghijklmnopqrstuvwxyz"[len(driver.variables)-1] else: var.name = self.sanitize_variable_name(bone_name) + "_" + transf_type.lower() var.type = 'TRANSFORMS' var.targets[0].id = target var.targets[0].bone_target = bone_name var.targets[0].transform_type = transf_type var.targets[0].rotation_mode = 'SWING_TWIST_Y' var.targets[0].transform_space = 'LOCAL_SPACE' if value < 0: clamped_var = f"min(0, {var.name})" else: clamped_var = f"max(0, {var.name})" if short: value = f"{value:.2f}" else: value = f"{value:.4f}" base = f"{clamped_var}/{value}" if transf_type.startswith("SCALE"): expressions.append(f"((1-{base})") else: expressions.append(f"({base})") final_exp = " * ".join(expressions) if short: final_exp = final_exp.replace(" ", "") if len(final_exp) > 255: # I can't believe the expression length is this limited. Just to save 4 bytes in memory... Was it worth it? if not short: final_exp = self.setup_driver(driver, target, driving_channels, short=True) else: self.report({'ERROR'}, f"Too many driving channels ({len(driving_channels)}) to fit into expression. Blame Blender. Try to reduce number of channels.") return final_exp driver.expression = final_exp return final_exp def get_deforming_armature(mesh_ob: Object) -> Object | None: for mod in mesh_ob.modifiers: if mod.type == 'ARMATURE': return mod.object def reset_rig(rig, *, reset_transforms=True, reset_props=True, pbones=[]): if not pbones: pbones = rig.pose.bones for pb in pbones: if reset_transforms: pb.location = (0, 0, 0) pb.rotation_euler = (0, 0, 0) pb.rotation_quaternion = (1, 0, 0, 0) pb.scale = (1, 1, 1) if not reset_props or len(pb.keys()) == 0: continue rna_properties = [prop.identifier for prop in pb.bl_rna.properties if prop.is_runtime] # Reset custom property values to their default value for key in pb.keys(): if key.startswith("$"): continue if key in rna_properties: continue # Addon defined property. property_settings = None try: property_settings = pb.id_properties_ui(key) if not property_settings: continue property_settings = property_settings.as_dict() if not 'default' in property_settings: continue except TypeError: # Some properties don't support UI data, and so don't have a default value. (like addon PropertyGroups) pass if not property_settings: continue if type(pb[key]) not in (float, int, bool): continue pb[key] = property_settings['default'] def set_pose_of_active_pose_key(context): rigged_ob = context.object pose_key = get_active_pose_key(rigged_ob) arm_ob = get_deforming_armature(rigged_ob) reset_rig(arm_ob) if pose_key.action: # Set Action and Frame to get the right pose arm_ob.animation_data.action = pose_key.action context.scene.frame_current = pose_key.frame def poll_correct_pose_key_pose(operator, context, demand_pose=True): """To make these operators foolproof, there are a lot of checks to make sure that the user gets to see the effect of the operator. The "Set Pose" operator can be used first to set the correct state and pass all the checks here. """ obj = context.object if not obj: operator.poll_message_set("There must be an active mesh object.") return False pose_key = get_active_pose_key(obj) if not pose_key: operator.poll_message_set("A Pose Shape Key must be selected.") return False if not pose_key.name: operator.poll_message_set("The Pose Shape Key must be named.") arm_ob = get_deforming_armature(obj) if not arm_ob: operator.poll_message_set("This mesh object is not deformed by any Armature modifier.") return False if not pose_key.action: operator.poll_message_set("An Action must be associated with the Pose Shape Key.") return False if demand_pose: # Action must exist and match. if not ( arm_ob.animation_data and arm_ob.animation_data.action and arm_ob.animation_data.action == pose_key.action ): operator.poll_message_set( "The armature must have the Pose Shape Key's action assigned. Use the Set Pose button." ) return False if pose_key.frame != context.scene.frame_current: operator.poll_message_set( "The Pose Shape Key's frame must be the same as the current scene frame. Use the Set Pose button." ) return False return True def get_active_pose_key(obj): if obj.type != 'MESH': return if len(obj.data.pose_keys) == 0 or obj.data.active_pose_key_index >= len(obj.data.pose_keys): return return obj.data.pose_keys[obj.data.active_pose_key_index] registry = [ OBJECT_OT_pose_key_auto_init, OBJECT_OT_pose_key_add, OBJECT_OT_pose_key_save, OBJECT_OT_pose_key_set_pose, OBJECT_OT_pose_key_push, OBJECT_OT_pose_key_push_all, OBJECT_OT_pose_key_clamp_influence, OBJECT_OT_pose_key_place_objects_in_grid, OBJECT_OT_pose_key_jump_to_storage, OBJECT_OT_pose_key_copy_data, OBJECT_OT_pose_key_shape_add, OBJECT_OT_pose_key_shape_remove, OBJECT_OT_pose_key_magic_driver, ]