From daba04536497ef31c47971df52a27bb2c4523d76 Mon Sep 17 00:00:00 2001 From: 989onan Date: Mon, 19 Aug 2024 13:59:31 -0400 Subject: [PATCH 1/8] Add zero weight bone removal --- functions/armature_modifying.py | 64 +++++++++++++++++++++++++++++++ resources/translations/en_US.json | 5 +++ ui/tools.py | 4 +- 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 functions/armature_modifying.py diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py new file mode 100644 index 0000000..ba82d05 --- /dev/null +++ b/functions/armature_modifying.py @@ -0,0 +1,64 @@ +import bpy +from ..core import common +from bpy.types import Operator, Context, Mesh, Armature, EditBone, PoseBone +from ..core.register import register_wrap +from .translations import t + +@register_wrap +class AvatarToolkit_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') + + 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) #add bone name to list of bones that are greater than the weight threshold + + bpy.ops.object.mode_set(mode='EDIT') + amature_data: Armature = armature.data + unweighted_bones: list[str] = [] + + #doing 2 loops to prevent modification of array during iteration + for bone in amature_data.edit_bones: + if bone.name not in weighted_bones: + unweighted_bones.append(bone.name) #add bones that arent in the list of bones that have weight into the list of bones that don't + + for bone_name in unweighted_bones: + for edit_bone in amature_data.edit_bones[bone_name].children: + edit_bone.use_connect = False #to fix randomly moving bones + edit_bone.parent = amature_data.edit_bones[bone_name].parent #to fix unparented bones. + amature_data.edit_bones.remove(amature_data.edit_bones[bone_name]) #delete list of unweighted bones from the armature + + self.report({'INFO'}, t("Tools.remove_zero_weight_bones.success")) + return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index a0ec7b9..e982db8 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -130,6 +130,11 @@ "Tools.apply_transforms.desc": "Apply position, rotation, and scale to the armature and its meshes", "Tools.apply_transforms.invalid_armature": "Invalid armature selected", "Tools.apply_transforms.success": "Transforms applied successfully to armature and meshes", + "Tools.remove_zero_weight_bones.success": "Zero weight bones removed successfully", + "Tools.remove_zero_weight_bones.label": "Remove Zero Weight Bones", + "Tools.remove_zero_weight_bones.desc": "Remove bones from the armature that have weights less than threshold.", + "Tools.remove_zero_weight_bones.threshold.label": "Weight Threshold", + "Tools.remove_zero_weight_bones.threshold.desc": "If a bone is not weighted to any part of any mesh under the armature with a threshold greater than this, it is removed", "VisemePanel.create_visemes": "Create Visemes", "VisemePanel.creating_viseme": "Creating viseme: {viseme_name}", "VisemePanel.creating_viseme_detail": "Creating viseme: {viseme_name}", diff --git a/ui/tools.py b/ui/tools.py index 7d8dc73..db6841a 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -7,6 +7,7 @@ from ..functions.translations import t from ..core.common import get_selected_armature from ..functions.seperate_by import SeparateByMaterials, SeparateByLooseParts from ..functions.additional_tools import ApplyTransforms +from ..functions.armature_modifying import AvatarToolkit_RemoveZeroWeightBones @register_wrap class AvatarToolkitToolsPanel(bpy.types.Panel): @@ -33,10 +34,11 @@ class AvatarToolkitToolsPanel(bpy.types.Panel): row.operator(CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade_legs.label"), icon='BONE_DATA') layout.separator() row = layout.row(align=True) - layout.label(text=t("Tools.separate_by.label"), icon='MESH') + layout.label(text=t("Tools.separate_by.label"), icon='MESH_DATA') row.operator(SeparateByMaterials.bl_idname, text=t("Tools.separate_by_materials.label"), icon='MATERIAL') row.operator(SeparateByLooseParts.bl_idname, text=t("Tools.separate_by_loose_parts.label"), icon='OUTLINER_OB_MESH') row = layout.row(align=True) row.operator(ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') + row.operator(AvatarToolkit_RemoveZeroWeightBones.bl_idname, text=t("Tools.remove_zero_weight_bones.label"), icon='BONE_DATA') else: layout.label(text=t("Tools.select_armature"), icon='ERROR') From dc2b6e46ce1d6b71c8a65afec43ebff7f10316f5 Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 6 Sep 2024 12:32:50 -0400 Subject: [PATCH 2/8] Add Bone Tools - Added merge to active to tools section for bones - Added merge to individual parents, which merges each selected bone to their parent bone --- core/common.py | 22 +++++++ functions/armature_modifying.py | 96 ++++++++++++++++++++++++++++++- resources/translations/en_US.json | 8 +++ ui/tools.py | 6 +- 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/core/common.py b/core/common.py index 0ea4ae0..2ce289c 100644 --- a/core/common.py +++ b/core/common.py @@ -262,3 +262,25 @@ def update_progress(self, context, message): def finish_progress(context): context.window_manager.progress_end() context.area.header_text_set(None) + +def transfer_vertex_weights(context: Context, obj: bpy.types.Object, source_group: str, target_group: str, delete_source_group: bool = True) -> bool: + + modifier: bpy.types.VertexWeightMixModifier = obj.modifiers.new(name="merge_weights",type="VERTEX_WEIGHT_MIX") + + modifier.mix_set = 'B' + modifier.vertex_group_a = target_group + modifier.vertex_group_b = source_group + modifier.mask_constant = 1.0 + + bpy.ops.object.mode_set(mode='OBJECT') + prev_obj: bpy.types.Object = context.view_layer.objects.active + context.view_layer.objects.active = obj + bpy.ops.object.modifier_apply(modifier=modifier.name) + if delete_source_group: + obj.vertex_groups.remove(obj.vertex_groups.get(source_group)) + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + context.view_layer.objects.active = prev_obj + + return True + diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py index ba82d05..8982909 100644 --- a/functions/armature_modifying.py +++ b/functions/armature_modifying.py @@ -1,6 +1,6 @@ import bpy from ..core import common -from bpy.types import Operator, Context, Mesh, Armature, EditBone, PoseBone +from bpy.types import Operator, Context, Mesh, Armature, EditBone from ..core.register import register_wrap from .translations import t @@ -62,3 +62,97 @@ class AvatarToolkit_RemoveZeroWeightBones(Operator): 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: + 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) > 0 + elif context.mode == "EDIT_ARMATURE": + return len(context.selected_bones) > 0 + 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') + armature_data: Armature = context.view_layer.objects.active.data + + for obj in common.get_all_meshes(context): + for bone in [i.name for i in context.selected_bones]: + if armature_data.edit_bones[bone].parent != None: + 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[bone].parent.name) + bpy.ops.object.mode_set(mode='EDIT') + + for bone in [i.name for i in context.selected_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'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 9ba83ec..be71d7a 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -135,6 +135,14 @@ "Tools.remove_zero_weight_bones.success": "Zero weight bones removed successfully", "Tools.remove_zero_weight_bones.label": "Remove Zero Weight Bones", "Tools.remove_zero_weight_bones.desc": "Remove bones from the armature that have weights less than threshold.", + "Tools.merge_bones_to_active.delete_old.desc": "Remove old bones when merging.", + "Tools.merge_bones_to_active.delete_old.label": "Remove Old Bones", + "Tools.merge_bones_to_active.desc": "Merge selected bones to active bone (selected in bright blue or orange).", + "Tools.merge_bones_to_active.label": "Merge Bones to Active", + "Tools.merge_bones_to_parents.delete_old.desc": "Remove old bones when merging.", + "Tools.merge_bones_to_parents.delete_old.label": "Remove Old Bones", + "Tools.merge_bones_to_parents.desc": "Merges every bone in the selection to each of their parents.", + "Tools.merge_bones_to_parents.label": "Merge Bones to Individual Parents", "Tools.remove_zero_weight_bones.threshold.label": "Weight Threshold", "Tools.remove_zero_weight_bones.threshold.desc": "If a bone is not weighted to any part of any mesh under the armature with a threshold greater than this, it is removed", "VisemePanel.create_visemes": "Create Visemes", diff --git a/ui/tools.py b/ui/tools.py index db6841a..8b1b189 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -7,7 +7,7 @@ from ..functions.translations import t from ..core.common import get_selected_armature from ..functions.seperate_by import SeparateByMaterials, SeparateByLooseParts from ..functions.additional_tools import ApplyTransforms -from ..functions.armature_modifying import AvatarToolkit_RemoveZeroWeightBones +from ..functions.armature_modifying import AvatarToolkit_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToActive, AvatarToolkit_OT_MergeBonesToParents @register_wrap class AvatarToolkitToolsPanel(bpy.types.Panel): @@ -39,6 +39,10 @@ class AvatarToolkitToolsPanel(bpy.types.Panel): row.operator(SeparateByLooseParts.bl_idname, text=t("Tools.separate_by_loose_parts.label"), icon='OUTLINER_OB_MESH') row = layout.row(align=True) row.operator(ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') + row = layout.row(align=True) row.operator(AvatarToolkit_RemoveZeroWeightBones.bl_idname, text=t("Tools.remove_zero_weight_bones.label"), icon='BONE_DATA') + row = layout.row(align=True) + row.operator(AvatarToolkit_OT_MergeBonesToActive.bl_idname, text=t("Tools.merge_bones_to_active.label"), icon='BONE_DATA') + row.operator(AvatarToolkit_OT_MergeBonesToParents.bl_idname, text=t("Tools.merge_bones_to_parents.label"), icon='BONE_DATA') else: layout.label(text=t("Tools.select_armature"), icon='ERROR') From 40a7f71f079ffabd717848d6e7191cf76dd133a9 Mon Sep 17 00:00:00 2001 From: 989onan Date: Fri, 6 Sep 2024 12:34:06 -0400 Subject: [PATCH 3/8] fix zero weight bones Class name --- functions/armature_modifying.py | 2 +- ui/tools.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py index 8982909..e48408b 100644 --- a/functions/armature_modifying.py +++ b/functions/armature_modifying.py @@ -5,7 +5,7 @@ from ..core.register import register_wrap from .translations import t @register_wrap -class AvatarToolkit_RemoveZeroWeightBones(Operator): +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") diff --git a/ui/tools.py b/ui/tools.py index 8b1b189..8ec1f7e 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -7,7 +7,7 @@ from ..functions.translations import t from ..core.common import get_selected_armature from ..functions.seperate_by import SeparateByMaterials, SeparateByLooseParts from ..functions.additional_tools import ApplyTransforms -from ..functions.armature_modifying import AvatarToolkit_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToActive, AvatarToolkit_OT_MergeBonesToParents +from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToActive, AvatarToolkit_OT_MergeBonesToParents @register_wrap class AvatarToolkitToolsPanel(bpy.types.Panel): @@ -40,7 +40,7 @@ class AvatarToolkitToolsPanel(bpy.types.Panel): row = layout.row(align=True) row.operator(ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') row = layout.row(align=True) - row.operator(AvatarToolkit_RemoveZeroWeightBones.bl_idname, text=t("Tools.remove_zero_weight_bones.label"), icon='BONE_DATA') + row.operator(AvatarToolkit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.remove_zero_weight_bones.label"), icon='BONE_DATA') row = layout.row(align=True) row.operator(AvatarToolkit_OT_MergeBonesToActive.bl_idname, text=t("Tools.merge_bones_to_active.label"), icon='BONE_DATA') row.operator(AvatarToolkit_OT_MergeBonesToParents.bl_idname, text=t("Tools.merge_bones_to_parents.label"), icon='BONE_DATA') From fba59faa50a2f1294ecb680bdf09bb98ce1e2b35 Mon Sep 17 00:00:00 2001 From: 989onan Date: Mon, 9 Sep 2024 21:18:56 -0400 Subject: [PATCH 4/8] UVAligner Tool - Adds a new Avatar Toolkit panel location for UV tools - adds Align UV Edges to target - Allows for aligning uv vertices in a line on one object to another's uvs pixel perfect. This is useful for making one model copy another model's UV's with some user work - Added translation keys for tool --- functions/uv_tools.py | 286 ++++++++++++++++++++++++++++++ resources/translations/en_US.json | 4 + ui/optimization.py | 2 +- ui/panel.py | 14 +- ui/uv_panel.py | 19 ++ ui/uv_tools.py | 24 +++ 6 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 functions/uv_tools.py create mode 100644 ui/uv_panel.py create mode 100644 ui/uv_tools.py diff --git a/functions/uv_tools.py b/functions/uv_tools.py new file mode 100644 index 0000000..a72216a --- /dev/null +++ b/functions/uv_tools.py @@ -0,0 +1,286 @@ +from typing import TypedDict +import bpy +from bpy.types import Operator, Object, Context, Mesh, MeshUVLoopLayer +import bmesh +import numpy as np +import math +from ..functions.translations import t +from ..core.register import register_wrap + +class GenerateLoopTreeResult(TypedDict): + tree: dict[str, set[str]] + selected_loops: dict[str,list[int]] + selected_verts: dict[str,int] + +@register_wrap +class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): + bl_idname = "avatar_toolkit.align_uv_edges_to_target" + bl_label = t("avatar_toolkit.align_uv_edges_to_target.label") + bl_description = t("avatar_toolkit.align_uv_edges_to_target.desc") + bl_options = {'REGISTER', 'UNDO'} + + + + #all selected objects need to be meshes for this to work - @989onan + @classmethod + def poll(cls, context: Context): + if not ((context.view_layer.objects.active is not None) and (len(context.view_layer.objects.selected) > 0)): + return False + if context.mode != "EDIT_MESH": + return False + for obj in context.view_layer.objects.selected: + if obj.type != "MESH": + return False + return True + + def execute(self, context: Context): + + + target: str = context.view_layer.objects.active.name #The object which we want to align every other selected object's selected UV vertex line to + + sources: list[str] = [i.name for i in context.view_layer.objects.selected] #The objects which we want to align their selected UV lines to the target's UV line + + prev_mode: str = bpy.context.object.mode + bpy.ops.object.mode_set(mode='OBJECT') + + + def generate_loop_tree(obj_name: str) -> GenerateLoopTreeResult: + print("Finding selected line for: \""+obj_name+"\"!") + + + vert_target_loops: dict[str,list[int]] = {} + vert_target_verts: dict[str,int] = {} + + me: Mesh = bpy.data.objects[obj_name].data + uv_lay: MeshUVLoopLayer = me.uv_layers.active + bm: bmesh.types.BMesh = bmesh.new() + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + + + + # To explain: + # So loops in UV maps are X polygons that make up a face (So a MeshLoop represent a face and each vertex on that face is in order) + # + # For some preknowledge: + # When a mesh is UV unwrapped, if a vertice is shared by two different faces on the model in the viewport and the vertice of both faces are in + # the same position on the UV map, then it considers it one point and the user can move it + # (is why the uv map doesn't split apart when you try to move a vertex because that would be annoying) + # + # The problem: + # The problem is that the data for whether the uv corners of two faces that share a vertex physically being connected and selected as one vertex on the uv map does not exist + # Though thankfully, blender forcibly (whether you like it or not) merges vertices of a uv map if the vertex of two different faces are actually shared in the UI, + # allowing for the moving of vertices of 4 faces connected by a single vertex. Behavior every normal blender user is familiar with. + # + # The solution + # We can use this to our advantage, by finding vertices on the uv map that share the same coridinate as another vertex that is also selected. + # that way we can group each pair shared in a line as the same vertex, and identify the line using these pairs and using the data that says for certain + # that two vertices share the same face loop, and therefore are connected. + + #hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate + + for k,i in enumerate(uv_lay.vertex_selection): #go through the selected vertices on object. + if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False): #find vertices that are not hidden and are are selected again TODO: Why does checking hidden not work? - @989onan + key = np.array(uv_lay.uv[k].vector[:]) + key = key.round(decimals=5) #make a key that is the position of a selected vertex + + if str(key) not in vert_target_loops: + vert_target_loops[str(key)] = [] #if the vertex's position is not a list yet, add it. + vert_target_loops[str(key)].append(k) #Basically, group vertices based on their position on a UV map as a list. + vert_target_verts[str(key)] = me.loops[k].vertex_index #associate the index of the physical vertex in real space with the coordinate of the uv vertices that share a position (Basically associate UV vert with real vert) + if len(vert_target_loops) > 4000: #This usually indicates that the user has a bunch of crap selected. + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.too_much")) + return {'FINISHED'} + print("Finding connections on line for \""+obj_name+"\"!") + me.validate() + + bm = bmesh.new() + bm.from_mesh(me) + + + #print(vert_target_loops) + #print(vert_target_verts) + tree: dict[str, set[str]] = {} + selected_verts = np.hstack(list(vert_target_loops.values())) + #print(selected_verts) + bm.verts.ensure_lookup_table() + for uvcoordsstr in vert_target_loops: + + uv_lay = me.uv_layers.active + + + #before this section, each vert_target_loops is just groupings of vertices that share coordinates. + # Using the data that determines UV face corners (uvloops) that are associated with the real vertex, + # and the uv face corners (loops) that are on the same faces as the vertices that share coordinates in + # vert_target_loops, we can now identify them + #TL;DR: pairs of vertices that share cooridinates (chain links) find their buddies (make chain connected) + + # Someone explain this better than me if you can please - @989onan + extension_loops = [] + loops = bm.verts[vert_target_verts[uvcoordsstr]].link_loops + loops_indexes = [i.index for i in loops] + for loop in vert_target_loops[uvcoordsstr]: + if loop in loops_indexes: + loop_obj = loops[loops_indexes.index(loop)] + extension_loops.append(loop_obj.link_loop_next.index) + extension_loops.append(loop_obj.link_loop_prev.index) + + + + + + #make a tree out of the vertices we identified as sharing faces with the vertices in vert_target_loops, and then link them together in a dictionary. + #the order of this dictionary is unknown. + # Someone explain this better than me if you can please - @989onan + tree[uvcoordsstr] = set() + + for i in extension_loops: + if i in selected_verts: + key = np.array(uv_lay.uv[i].vector[:]) + key = key.round(decimals=5) + tree[uvcoordsstr].add(str(key)) + + if uvcoordsstr in tree: + if len(tree[uvcoordsstr]) > 2: + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.need_a_line").format(obj=obj_name)) + return {'FINISHED'} + + uv_lay = me.uv_layers.active + for uvcoordstr in vert_target_loops: + for loop in vert_target_loops[uvcoordstr]: + uv_lay.vertex_selection[loop].value = True + + + bm.free() + me.validate() + print("found UV line connections for \""+obj_name+"\":") + #print(tree) + + return {"tree":tree,"selected_loops":vert_target_loops,"selected_verts":vert_target_verts} + + + + #This function uses the previous point to find the next point based on connected loops and faces. + def sort_uv_tree(originaltree: dict[str, set[str]], obj_name: str): + sortedtree: dict[str, set[str]] = originaltree.copy() + startpoints: list[str] = [] + for i in sortedtree: + if len(sortedtree[i]) < 2: + startpoints.append(i) + + if len(startpoints) != 2: + self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.need_a_line").format(obj=obj_name)) + return {'FINISHED'} + + a_list1 = startpoints[0].replace(", "," ").replace("[","").replace("]","").split() + map_object1 = map(float, a_list1) + uvcoords1 = list(map_object1) + a_list2 = startpoints[1].replace(", "," ").replace("[","").replace("]","").split() + map_object2 = map(float, a_list2) + uvcoords2 = list(map_object2) + + cursor = context.space_data.cursor_location + + startpoint = None + if math.sqrt( (((uvcoords1[0]) - (cursor[0])) **2) + (((uvcoords1[1]) - (cursor[1])) **2) ) > math.sqrt( (((uvcoords2[0]) - (cursor[0])) **2) + (((uvcoords2[1]) - (cursor[1])) **2) ): + startpoint = startpoints[0] + else: + startpoint = startpoints[1] + + #Wew my first actual recursive sort! - @989onan + def recursive_sort_uv_tree(point: str, sortedfinal: list[str]): + #print("appending "+point) + sortedfinal.append(point) + + new_point: str = "" + for i in sortedtree: + if point in sortedtree[i]: + new_point = i + removed_value = sortedtree.pop(i) + #print(removed_value) + break + + if new_point == "": + print("BROKE OUT OF SORTING, FINAL TREE (Should be empty, if not you errored here!):") + print(sortedtree) + + return sortedfinal + + return recursive_sort_uv_tree(new_point, sortedfinal) + + array = [] + + sortedtree.pop(startpoint) + return recursive_sort_uv_tree(startpoint, array) + + def lerp(v0, v1, t): + return v0 + t * (v1 - v0) + + + target_data: GenerateLoopTreeResult = generate_loop_tree(target) + sorted_target_tree = sort_uv_tree(target_data["tree"], target) + print("sorted target.") + #print(sorted_target_tree) + + for source in sources: + if source == target: + continue + + #create our list of points that is a chain. then sort the chain into the correct order based on connections of vertices and the faces that the vertices make up in the UV map. + source_data = generate_loop_tree(source) + sorted_source_tree = sort_uv_tree(source_data["tree"], source) + print("Sorted source "+source) + print(sorted_source_tree) + + vertex_factor = float(len(sorted_target_tree)-1) / (float(len(sorted_source_tree)-1)) + + print(str(vertex_factor)+" = "+str(float(len(sorted_target_tree)-1)) + " / " + str((float(len(sorted_source_tree)-1)))+")") + + for k,i in enumerate(sorted_source_tree): + + try: + #find where we are on the target edges, to interpolate the current point we're placing along the target point's line. + progress_along_edge = (float(k)*vertex_factor) + previous_vertex_index = math.floor(progress_along_edge) + next_vertex_index = math.ceil(progress_along_edge) + + + #find the uv coordinates of the previous and next points on the target uv line. + a_list1 = sorted_target_tree[previous_vertex_index].replace(", "," ").replace("[","").replace("]","").split() + map_object1 = map(float, a_list1) + previous_point = list(map_object1) + a_list2 = sorted_target_tree[next_vertex_index].replace(", "," ").replace("[","").replace("]","").split() + map_object2 = map(float, a_list2) + next_point = list(map_object2) + + + + #create a point between these two values that represents a decimal 0-1 going where we are to where we are going between the two current points on the edge we are targeting this whole shebang with. + progress_between_points = progress_along_edge - int(progress_along_edge) + lerped_point = [lerp(previous_point[0],next_point[0],progress_between_points),lerp(previous_point[1],next_point[1],progress_between_points)] + + #grab our uv face corners for each uv coord that we saved. + #Since each face is considered separate internally, we have to treat each connected face to a vertex in a uv map as separate entities/vertexes. + #basically pretend they are split apart. + uv_face_corners = source_data["selected_loops"][i] + #print("doing from vertex "+str(previous_vertex_index)+" to "+str(next_vertex_index)+" total progress: "+str(progress_along_edge)) + + + + me: Mesh = bpy.data.objects[source].data + me.validate() + bm: bmesh.types.BMesh = bmesh.new() + bm.from_mesh(me) + uv_lay: MeshUVLoopLayer = me.uv_layers.active + bm.verts.ensure_lookup_table() + for corner in uv_face_corners: + uv_lay.uv[corner].vector = lerped_point #put the vertcies at the point we calculated. + except: + print("This is probably fine? - @989onan") #TODO: What happened here? The magic of making code so complex you forget if this is even an issue. - @989onan + + print("Finished mesh \""+source+"\" for UV's") + + + + bpy.ops.object.mode_set(mode=prev_mode) + return {'FINISHED'} \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 0317753..de3a719 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -117,6 +117,10 @@ "Tools.digitigrade_legs.success": "Digitigrade legs created successfully", "Tools.import_any_model.desc": "Import any supported model, FBX, SMD, DMX, GLTF, PMD, PMX and more.", "Tools.import_any_model.label": "Import Model", + "UVTools.align_uv_to_target.warning.too_much": "Error! You have way to much stuff selected. Are you sure you're selecting two edges?", + "UVTools.align_uv_to_target.warning.need_a_line": "You need one line of selected uv points per selected object. Object \"{obj}\" does not meet this requirement!", + "avatar_toolkit.align_uv_edges_to_target.label":"Align UV Edges to Target", + "avatar_toolkit.align_uv_edges_to_target.desc":"Aligns a selected line of UV points on each selected mesh\nto the line of selected uv points on the active mesh.\nUseful for kitbashing textures of one model onto another.\nUses distance from the 2D cursor to identify the start of the line of uv points on each mesh.", "Tools.label": "Tools", "Tools.no_armature_selected": "No armature selected", "Tools.select_armature": "Please select an armature", diff --git a/ui/optimization.py b/ui/optimization.py index 6b1d1b3..e793b9e 100644 --- a/ui/optimization.py +++ b/ui/optimization.py @@ -15,7 +15,7 @@ class AvatarToolkitOptimizationPanel(bpy.types.Panel): bl_parent_id = "OBJECT_PT_avatar_toolkit" bl_order = 2 - def draw(self, context): + def draw(self: bpy.types.Panel, context: bpy.types.Context): layout = self.layout armature = get_selected_armature(context) diff --git a/ui/panel.py b/ui/panel.py index 629fee3..7bc3c6d 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -2,6 +2,13 @@ import bpy from ..core.register import register_wrap from ..functions.translations import t +def draw_title(self: bpy.types.Panel): + layout = self.layout + layout.label(text=t("AvatarToolkit.welcome")) + layout.label(text=t("AvatarToolkit.description")) + layout.label(text=t("AvatarToolkit.alpha_warning")) + + @register_wrap class AvatarToolkitPanel(bpy.types.Panel): bl_label = t("AvatarToolkit.label") @@ -10,10 +17,7 @@ class AvatarToolkitPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = "Avatar Toolkit" - def draw(self, context): - layout = self.layout - layout.label(text=t("AvatarToolkit.welcome")) - layout.label(text=t("AvatarToolkit.description")) - layout.label(text=t("AvatarToolkit.alpha_warning")) + def draw(self: bpy.types.Panel, context: bpy.types.Context): + draw_title(self) diff --git a/ui/uv_panel.py b/ui/uv_panel.py new file mode 100644 index 0000000..950d9de --- /dev/null +++ b/ui/uv_panel.py @@ -0,0 +1,19 @@ +import bpy +from ..core.register import register_wrap +from ..functions.translations import t +from .panel import draw_title + +@register_wrap +class UVTools_PT_MainPanel(bpy.types.Panel): + bl_label = t("AvatarToolkit.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + + def draw(self: bpy.types.Panel, context: bpy.types.Context): + layout = self.layout + + sima = context.space_data + if sima.show_uvedit: + draw_title(self) \ No newline at end of file diff --git a/ui/uv_tools.py b/ui/uv_tools.py new file mode 100644 index 0000000..89b24f4 --- /dev/null +++ b/ui/uv_tools.py @@ -0,0 +1,24 @@ + +import bpy +from ..core.register import register_wrap +from ..functions.translations import t +from ..functions.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget +from .panel import draw_title + +@register_wrap +class UVTools_PT_Tools(bpy.types.Panel): + bl_label = t("Tools.label") + bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools" + bl_space_type = 'IMAGE_EDITOR' + bl_region_type = 'UI' + bl_category = "Avatar Toolkit" + bl_parent_id = "OBJECT_PT_avatar_toolkit_uv" + bl_order = 3 + + def draw(self, context: bpy.types.Context): + layout = self.layout + + sima = context.space_data + if sima.show_uvedit: + row = layout.row(align=True) + row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("avatar_toolkit.align_uv_edges_to_target.label"), icon='GP_MULTIFRAME_EDITING') \ No newline at end of file From 94dcc3ed7a54935652b86e4a60334bb3414205ba Mon Sep 17 00:00:00 2001 From: 989onan Date: Mon, 9 Sep 2024 21:26:45 -0400 Subject: [PATCH 5/8] fix context issues and drawing --- functions/uv_tools.py | 4 ++++ ui/uv_panel.py | 4 +--- ui/uv_tools.py | 6 ++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/functions/uv_tools.py b/functions/uv_tools.py index a72216a..d93e5e7 100644 --- a/functions/uv_tools.py +++ b/functions/uv_tools.py @@ -31,6 +31,10 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): for obj in context.view_layer.objects.selected: if obj.type != "MESH": return False + if not context.space_data: + return False + if not context.space_data.show_uvedit: + return False return True def execute(self, context: Context): diff --git a/ui/uv_panel.py b/ui/uv_panel.py index 950d9de..8221e31 100644 --- a/ui/uv_panel.py +++ b/ui/uv_panel.py @@ -14,6 +14,4 @@ class UVTools_PT_MainPanel(bpy.types.Panel): def draw(self: bpy.types.Panel, context: bpy.types.Context): layout = self.layout - sima = context.space_data - if sima.show_uvedit: - draw_title(self) \ No newline at end of file + draw_title(self) \ No newline at end of file diff --git a/ui/uv_tools.py b/ui/uv_tools.py index 89b24f4..d5a6d83 100644 --- a/ui/uv_tools.py +++ b/ui/uv_tools.py @@ -18,7 +18,5 @@ class UVTools_PT_Tools(bpy.types.Panel): def draw(self, context: bpy.types.Context): layout = self.layout - sima = context.space_data - if sima.show_uvedit: - row = layout.row(align=True) - row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("avatar_toolkit.align_uv_edges_to_target.label"), icon='GP_MULTIFRAME_EDITING') \ No newline at end of file + row = layout.row(align=True) + row.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("avatar_toolkit.align_uv_edges_to_target.label"), icon='GP_MULTIFRAME_EDITING') \ No newline at end of file From 9bdaa1ef017d2a5371a899eabf886426509bc7fd Mon Sep 17 00:00:00 2001 From: 989onan Date: Mon, 9 Sep 2024 21:57:13 -0400 Subject: [PATCH 6/8] Add Remove unused shapekeys removes shapekeys that don't do anything --- functions/mesh_tools.py | 56 +++++++++++++++++++++++++++++++ resources/translations/en_US.json | 4 +++ ui/tools.py | 2 ++ 3 files changed, 62 insertions(+) create mode 100644 functions/mesh_tools.py diff --git a/functions/mesh_tools.py b/functions/mesh_tools.py new file mode 100644 index 0000000..c0e2a23 --- /dev/null +++ b/functions/mesh_tools.py @@ -0,0 +1,56 @@ +import numpy as np +import bpy +from bpy.types import Context +from ..core.common import get_selected_armature, get_all_meshes, is_valid_armature +from ..functions.translations import t +from ..core.register import register_wrap + +@register_wrap +class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operators): + 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]) \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 0317753..1af48d9 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -132,6 +132,10 @@ "Tools.apply_transforms.desc": "Apply position, rotation, and scale to the armature and its meshes", "Tools.apply_transforms.invalid_armature": "Invalid armature selected", "Tools.apply_transforms.success": "Transforms applied successfully to armature and meshes", + "Tools.remove_unused_shapekeys.label": "Remove Unused Shapekeys", + "Tools.remove_unused_shapekeys.tolerance.desc": "Min movement for position on any coordinate\n for any vertex for a shapekey to be kept.", + "Tools.remove_unused_shapekeys.desc": "Remove shapekeys that don't move anything.\nDoesn't get rid of category shapekeys.\n(ex: has \"~\", \"-\", or \"=\" in the name.)", + "Tools.remove_unused_shapekeys.tolerance.label": "Position Tolerance", "VisemePanel.create_visemes": "Create Visemes", "VisemePanel.creating_viseme": "Creating viseme: {viseme_name}", "VisemePanel.creating_viseme_detail": "Creating viseme: {viseme_name}", diff --git a/ui/tools.py b/ui/tools.py index 62dde6c..4016d06 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -7,6 +7,7 @@ from ..functions.translations import t from ..core.common import get_selected_armature from ..functions.seperate_by import SeparateByMaterials, SeparateByLooseParts from ..functions.additional_tools import ApplyTransforms +from ..functions.mesh_tools import AvatarToolkit_OT_RemoveUnusedShapekeys @register_wrap class AvatarToolkitToolsPanel(bpy.types.Panel): @@ -38,5 +39,6 @@ class AvatarToolkitToolsPanel(bpy.types.Panel): row.operator(SeparateByLooseParts.bl_idname, text=t("Tools.separate_by_loose_parts.label"), icon='OUTLINER_OB_MESH') row = layout.row(align=True) row.operator(ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') + row.operator(AvatarToolkit_OT_RemoveUnusedShapekeys.bl_idname, text=t("Tools.remove_unused_shapekeys.label"), icon='SHAPEKEY_DATA') else: layout.label(text=t("Tools.select_armature"), icon='ERROR') From 6954342d3764b96e353a5deb74157f59b1f61db6 Mon Sep 17 00:00:00 2001 From: 989onan Date: Tue, 10 Sep 2024 12:25:10 -0400 Subject: [PATCH 7/8] Fix error handling --- functions/uv_tools.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/functions/uv_tools.py b/functions/uv_tools.py index d93e5e7..504fc84 100644 --- a/functions/uv_tools.py +++ b/functions/uv_tools.py @@ -35,6 +35,8 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): return False if not context.space_data.show_uvedit: return False + if context.scene.tool_settings.use_uv_select_sync: + return False return True def execute(self, context: Context): @@ -84,7 +86,7 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): #hmmm real stupid grimlin hours with this one. Using a string as the index of a dictionary of loop corners that end up on the same coordinate for k,i in enumerate(uv_lay.vertex_selection): #go through the selected vertices on object. - if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False): #find vertices that are not hidden and are are selected again TODO: Why does checking hidden not work? - @989onan + if (i.value == True) and (bm.verts[me.loops[k].vertex_index].select == True) and (bm.verts[me.loops[k].vertex_index].hide == False): #filter out vertices that are hidden from UV port key = np.array(uv_lay.uv[k].vector[:]) key = key.round(decimals=5) #make a key that is the position of a selected vertex @@ -94,7 +96,7 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): vert_target_verts[str(key)] = me.loops[k].vertex_index #associate the index of the physical vertex in real space with the coordinate of the uv vertices that share a position (Basically associate UV vert with real vert) if len(vert_target_loops) > 4000: #This usually indicates that the user has a bunch of crap selected. self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.too_much")) - return {'FINISHED'} + return print("Finding connections on line for \""+obj_name+"\"!") me.validate() @@ -174,7 +176,7 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): if len(startpoints) != 2: self.report({'WARNING'}, t("UVTools.align_uv_to_target.warning.need_a_line").format(obj=obj_name)) - return {'FINISHED'} + return a_list1 = startpoints[0].replace(", "," ").replace("[","").replace("]","").split() map_object1 = map(float, a_list1) @@ -231,14 +233,18 @@ class AvatarToolkit_OT_AlignUVEdgesToTarget(Operator): continue #create our list of points that is a chain. then sort the chain into the correct order based on connections of vertices and the faces that the vertices make up in the UV map. - source_data = generate_loop_tree(source) - sorted_source_tree = sort_uv_tree(source_data["tree"], source) - print("Sorted source "+source) - print(sorted_source_tree) - - vertex_factor = float(len(sorted_target_tree)-1) / (float(len(sorted_source_tree)-1)) - - print(str(vertex_factor)+" = "+str(float(len(sorted_target_tree)-1)) + " / " + str((float(len(sorted_source_tree)-1)))+")") + try: + source_data = generate_loop_tree(source) + sorted_source_tree = sort_uv_tree(source_data["tree"], source) + print("Sorted source "+source) + print(sorted_source_tree) + + vertex_factor = float(len(sorted_target_tree)-1) / (float(len(sorted_source_tree)-1)) + + print(str(vertex_factor)+" = "+str(float(len(sorted_target_tree)-1)) + " / " + str((float(len(sorted_source_tree)-1)))+")") + except Exception as e: + print(e) + return {'FINISHED'} for k,i in enumerate(sorted_source_tree): From 337fb7c56119be9f6b05d207e0fec68f44f7f3fd Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 11 Sep 2024 01:39:35 +0100 Subject: [PATCH 8/8] Update tools.py --- ui/tools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/tools.py b/ui/tools.py index eaa6c36..caec2c2 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -6,8 +6,8 @@ from ..functions.digitigrade_legs import AvatarToolKit_OT_CreateDigitigradeLegs from ..functions.resonite_functions import AvatarToolKit_OT_ConvertToResonite from ..functions.translations import t from ..core.common import get_selected_armature -from ..functions.seperate_by import SeparateByMaterials, SeparateByLooseParts -from ..functions.additional_tools import ApplyTransforms +from ..functions.seperate_by import AvatarToolKit_OT_SeparateByMaterials, AvatarToolKit_OT_SeparateByLooseParts +from ..functions.additional_tools import AvatarToolKit_OT_ApplyTransforms from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToActive, AvatarToolkit_OT_MergeBonesToParents @register_wrap @@ -36,10 +36,10 @@ class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): layout.separator() row = layout.row(align=True) layout.label(text=t("Tools.separate_by.label"), icon='MESH_DATA') - row.operator(SeparateByMaterials.bl_idname, text=t("Tools.separate_by_materials.label"), icon='MATERIAL') - row.operator(SeparateByLooseParts.bl_idname, text=t("Tools.separate_by_loose_parts.label"), icon='OUTLINER_OB_MESH') + row.operator(AvatarToolKit_OT_SeparateByMaterials.bl_idname, text=t("Tools.separate_by_materials.label"), icon='MATERIAL') + row.operator(AvatarToolKit_OT_SeparateByLooseParts.bl_idname, text=t("Tools.separate_by_loose_parts.label"), icon='OUTLINER_OB_MESH') row = layout.row(align=True) - row.operator(ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') + row.operator(AvatarToolKit_OT_ApplyTransforms.bl_idname, text=t("Tools.apply_transforms.label"), icon='OBJECT_ORIGIN') row = layout.row(align=True) row.operator(AvatarToolkit_OT_RemoveZeroWeightBones.bl_idname, text=t("Tools.remove_zero_weight_bones.label"), icon='BONE_DATA') row = layout.row(align=True)