diff --git a/__init__.py b/__init__.py index 40947c92..1734af2e 100644 --- a/__init__.py +++ b/__init__.py @@ -6,7 +6,7 @@ "name": "MustardUI", "description": "Easy-to-use UI for human characters.", "author": "Mustard", - "version": (2026, 7, 0), + "version": (2026, 7, 1), "blender": (4, 2, 0), "warning": "", "doc_url": "https://github.com/Mustard2/MustardUI/wiki", diff --git a/blender_manifest.toml b/blender_manifest.toml index 86f289b1..62d7b12b 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "MustardUI" -version = "2026.7.0" +version = "2026.7.1" name = "MustardUI" tagline = "Easy-to-use UI for human characters" maintainer = "Mustard" diff --git a/custom_properties/__init__.py b/custom_properties/__init__.py index 19962b33..45fbc189 100644 --- a/custom_properties/__init__.py +++ b/custom_properties/__init__.py @@ -5,6 +5,7 @@ ops_link, ops_menu_settings, ops_rebuild, + ops_set_section, ops_smartcheck, ui_list, ) @@ -17,6 +18,7 @@ def register(): menus.register() ui_list.register() ops_menu_settings.register() + ops_set_section.register() ops_rebuild.register() ops_smartcheck.register() @@ -24,6 +26,7 @@ def register(): def unregister(): ops_smartcheck.unregister() ops_rebuild.unregister() + ops_set_section.unregister() ops_menu_settings.unregister() ui_list.unregister() menus.unregister() diff --git a/custom_properties/ops_set_section.py b/custom_properties/ops_set_section.py new file mode 100644 index 00000000..6f790bc7 --- /dev/null +++ b/custom_properties/ops_set_section.py @@ -0,0 +1,72 @@ +import bpy +from bpy.props import EnumProperty, IntProperty + +from ..model_selection.active_object import ( + active_object_operator_poll, + mustardui_active_object, +) + +# Identifier used for the entry that removes the property from every section +SECTION_NONE = "MUSTARDUI_SECTION_NONE" + +# Blender does not keep a reference to the strings returned by an EnumProperty items +# callback, therefore they are stored here to avoid them being garbage collected +sections_enum_items = [] + + +def sections_enum(self, context): + sections_enum_items.clear() + sections_enum_items.append( + (SECTION_NONE, "No Section", "Remove the property from any section", "RECORD_OFF", 0) + ) + + res, arm = mustardui_active_object(context, config=1) + if res: + rig_settings = arm.MustardUI_RigSettings + for i, section in enumerate(rig_settings.body_custom_properties_sections): + sections_enum_items.append( + ( + section.name, + section.name, + "Add the property to this section", + section.icon if section.icon not in {"NONE", ""} else "DOT", + i + 1, + ) + ) + + return sections_enum_items + + +class MustardUI_Property_SetSection(bpy.types.Operator): + """Change the section of the property""" + + bl_idname = "mustardui.property_set_section" + bl_label = "Section" + bl_options = {"UNDO", "INTERNAL"} + + index: IntProperty(default=-1, options={"HIDDEN"}) + section: EnumProperty(name="Section", items=sections_enum) + + @classmethod + def poll(cls, context): + return active_object_operator_poll(context, config=1) + + def execute(self, context): + res, arm = mustardui_active_object(context, config=1) + custom_props = arm.MustardUI_CustomProperties + + if not 0 <= self.index < len(custom_props): + self.report({"ERROR"}, "MustardUI - Can not find the property to modify") + return {"CANCELLED"} + + custom_props[self.index].section = "" if self.section == SECTION_NONE else self.section + + return {"FINISHED"} + + +def register(): + bpy.utils.register_class(MustardUI_Property_SetSection) + + +def unregister(): + bpy.utils.unregister_class(MustardUI_Property_SetSection) diff --git a/custom_properties/ui_list.py b/custom_properties/ui_list.py index 05df66a1..e11dabc1 100644 --- a/custom_properties/ui_list.py +++ b/custom_properties/ui_list.py @@ -4,6 +4,7 @@ from .. import __package__ as base_package from ..misc.prop_utils import evaluate_path from ..model_selection.active_object import mustardui_active_object +from .ops_set_section import MustardUI_Property_SetSection def draw_item_by_type( @@ -15,7 +16,7 @@ def draw_item_by_type( _icon, _active_data, _active_propname, - _index, + index, cptype=0, ): res, obj = mustardui_active_object(context, config=1) @@ -41,15 +42,16 @@ def draw_item_by_type( if cptype == 0: section = rig_settings.body_custom_properties_sections.get(item.section) - row.scale_x = 0.8 - row.prop_search( - item, + icon = "RECORD_OFF" + if section is not None: + icon = section.icon if section not in {"", "NONE"} else "DOT" + op = row.operator_menu_enum( + MustardUI_Property_SetSection.bl_idname, "section", - rig_settings, - "body_custom_properties_sections", - text="", - icon=section.icon if section else "LINENUMBERS_OFF", + text=item.section if item.section not in {"", "NONE"} else "No Section", + icon=icon, ) + op.index = index elif cptype == 1: if item.outfit is not None and item.outfit_piece is None: if rig_settings.model_MustardUI_naming_convention: diff --git a/misc/mesh_cleanup.py b/misc/mesh_cleanup.py new file mode 100644 index 00000000..626f54ed --- /dev/null +++ b/misc/mesh_cleanup.py @@ -0,0 +1,179 @@ +"""Clean up helpers for the meshes generated by the Creators Tools. + +A cage is generated by copying, or by tracing, a mesh of the model, and it +inherits all of its data: UV Maps, color attributes, shape keys, and one vertex +group per bone of the Armature. None of this is read by the simulation, and it +is only carried around in the file. +""" + +# Try NumPy +try: + import numpy as np + + USE_NUMPY = True +except ImportError: + USE_NUMPY = False + +# Weights below this do not deform anything: transferring the groups of a model +# on a cage leaves a trail of them on the bones which are far from it +WEIGHT_THRESHOLD = 1e-4 + +# Distance below which two shapes are considered to be the same one. A shape key +# is stored in single precision, so the coordinates of a key which was copied +# from another shape can differ from it by the last digits +SHAPE_KEY_THRESHOLD = 1e-6 + + +def clear_attributes(obj): + """Remove the UV Maps and the custom attributes of the mesh 'obj'.""" + + mesh = obj.data + + while mesh.uv_layers: + mesh.uv_layers.remove(mesh.uv_layers[0]) + + # Vertex groups can be exposed as attributes as well: together with the + # required and the internal ones (positions, selection, ...) they are the + # only attributes which are kept, as removing them would break the modifiers + # using them + vertex_groups = {x.name for x in obj.vertex_groups} + for name in [x.name for x in mesh.attributes]: + attribute = mesh.attributes.get(name) + if attribute is None or attribute.is_required or attribute.is_internal: + continue + if name in vertex_groups: + continue + mesh.attributes.remove(attribute) + + +def _coordinates(data, count): + """The 'co' of a shape key or of the vertices of a mesh, as a flat buffer.""" + + values = np.empty(count * 3, dtype=np.float32) if USE_NUMPY else [0.0] * (count * 3) + data.foreach_get("co", values) + return values + + +def _same_coordinates(first, second): + """True when no coordinate of the two buffers differs by more than the threshold.""" + + if USE_NUMPY: + return bool(np.all(np.abs(first - second) <= SHAPE_KEY_THRESHOLD)) + return all(abs(a - b) <= SHAPE_KEY_THRESHOLD for a, b in zip(first, second)) + + +def shape_key_is_void(key_block): + """True when 'key_block' does not move a single vertex of its mesh. + + A key is compared to the shape it is relative to: a key which is a copy of it + deforms nothing, while it still costs a full set of coordinates. + """ + + shape_keys = key_block.id_data + + # Absolute shape keys are interpolated one into the next, so a key identical + # to its neighbour is still a step of the sequence: only relative keys can be + # told to be void by comparing them to the shape they are relative to + if not shape_keys.use_relative: + return False + + reference = shape_keys.reference_key + if key_block == reference: + return False + + relative = key_block.relative_key or reference + count = len(key_block.data) + + return _same_coordinates( + _coordinates(key_block.data, count), _coordinates(relative.data, count) + ) + + +def remove_shape_key(obj, key_block): + """Remove 'key_block' from 'obj', together with the drivers pointing at it. + + Blender does not clean up after a Shape Key which is removed: every driver of + the key, on its Value as much as on its Mute, is left on the Key datablock as + an F-Curve pointing at a Shape Key which is not there any more. + """ + + shape_keys = key_block.id_data + animation_data = shape_keys.animation_data + + if animation_data is not None: + # The path of the key itself, 'key_blocks["Name"]', is the prefix of the + # path of each one of its properties. Taken from Blender and not built + # from the name, so that the quotes of a name are escaped the same way + prefix = key_block.path_from_id() + for fcurve in [x for x in animation_data.drivers if x.data_path.startswith(prefix)]: + animation_data.drivers.remove(fcurve) + + obj.shape_key_remove(key_block) + + +def clear_shape_keys(obj, void_only=False): + """Remove the shape keys of the mesh 'obj'. + + With 'void_only', only the keys which do not move a single vertex are removed: + a key which is a copy of the shape it is relative to deforms nothing, while it + still costs a full set of coordinates. Returns the number of removed keys. + """ + + shape_keys = obj.data.shape_keys + if shape_keys is None: + return 0 + + if not void_only: + removed = len(shape_keys.key_blocks) + obj.shape_key_clear() + return removed + + count = len(obj.data.vertices) + removed = 0 + + for key_block in list(shape_keys.key_blocks): + if shape_key_is_void(key_block): + remove_shape_key(obj, key_block) + removed += 1 + + # A lone Basis deforms nothing, and it holds a full copy of the coordinates. + # It is only dropped when it is the shape the mesh itself is in: removing the + # keys of a mesh whose Basis was edited away from its vertices moves it back + shape_keys = obj.data.shape_keys + if shape_keys is not None and len(shape_keys.key_blocks) == 1: + basis = shape_keys.key_blocks[0] + if _same_coordinates( + _coordinates(basis.data, count), _coordinates(obj.data.vertices, count) + ): + remove_shape_key(obj, basis) + removed += 1 + + return removed + + +def clear_unused_vertex_groups(obj, keep=()): + """Remove the vertex groups of 'obj' which do not weight a single vertex. + + 'keep' are the names of the groups to preserve even when they are empty: a + group a modifier points at has to exist, or the modifier silently changes + what it does (an empty Pin group is not the same as no Pin group at all). + """ + + used = set() + for vertex in obj.data.vertices: + for element in vertex.groups: + if element.weight > WEIGHT_THRESHOLD: + used.add(element.group) + + # On top of the requested ones, the groups the modifiers are pointing at + preserved = set(keep) + for modifier in obj.modifiers: + name = getattr(modifier, "vertex_group", "") + if name: + preserved.add(name) + + removed = [x for x in obj.vertex_groups if x.index not in used and x.name not in preserved] + for group in removed: + obj.vertex_groups.remove(group) + + return len(removed) diff --git a/model_selection/active_object.py b/model_selection/active_object.py index e8229dea..73c3eb17 100644 --- a/model_selection/active_object.py +++ b/model_selection/active_object.py @@ -1,9 +1,6 @@ -import bpy - - # Function to decide the active object for showing properties in the UI def mustardui_active_object(context, config=0): - settings = bpy.context.scene.MustardUI_Settings + settings = context.scene.MustardUI_Settings # Quick Setup mode: always use the viewport active object, returns True only if # the armature has never been configured with MustardUI (MustardUI_created=False). diff --git a/morphs/ops_defvalue.py b/morphs/ops_defvalue.py index 2f063f42..d16ae26d 100644 --- a/morphs/ops_defvalue.py +++ b/morphs/ops_defvalue.py @@ -37,7 +37,8 @@ def execute(self, context): elif isinstance(val, bool): cp_source[morph.path] = True elif morph.shape_key: - kb = rig_settings.data.shape_keys.key_blocks.get(morph.path) + shape_keys = rig_settings.model_body.data.shape_keys + kb = shape_keys.key_blocks.get(morph.path) if shape_keys is not None else None if kb is not None: kb.value = 0.0 diff --git a/morphs/settings_presets.py b/morphs/settings_presets.py index 3a5ab92d..8ddfbe44 100644 --- a/morphs/settings_presets.py +++ b/morphs/settings_presets.py @@ -17,7 +17,8 @@ def morphs_to_json(morph_settings, rig_settings): if cp is not None: val = cp elif morph.shape_key: - kb = rig_settings.data.shape_keys.key_blocks.get(morph.path) + shape_keys = rig_settings.model_body.data.shape_keys + kb = shape_keys.key_blocks.get(morph.path) if shape_keys is not None else None if kb: val = kb.value @@ -61,7 +62,7 @@ def apply_morphs_preset(context, arm, settings, data, force=False): else: cp_source[m["path"]] = val - elif m.get("shape_key") and rig_settings.data and rig_settings.data.shape_keys: + elif m.get("shape_key") and rig_settings.model_body.data.shape_keys: kb = rig_settings.model_body.data.shape_keys.key_blocks.get(m.get("path")) if kb is None: errors += 1 diff --git a/tools_creators/ops_collision_cage.py b/tools_creators/ops_collision_cage.py index 9d8f139b..ffdfb459 100644 --- a/tools_creators/ops_collision_cage.py +++ b/tools_creators/ops_collision_cage.py @@ -30,6 +30,7 @@ import bpy from rna_prop_ui import rna_idprop_ui_create +from ..misc import mesh_cleanup from ..model_selection.active_object import ( active_object_operator_poll, mustardui_active_object, @@ -49,6 +50,13 @@ class MustardUI_ToolsCreators_CreateCollisionCage(bpy.types.Operator): "performance, and it usually lead to similar results to un-decimated cages", default=True, ) + clear_attributes: bpy.props.BoolProperty( + name="Clear Attributes", + description="Remove the UV Maps and the attributes inherited from the mesh the " + "cage is generated from.\nThey are not used by the collisions, and they only " + "increase the size of the file", + default=True, + ) add_to_panel: bpy.props.BoolProperty( name="Add to Physics Panel", description="Add the Collision item to Physics Panel", @@ -284,6 +292,11 @@ def add_collision_modifier(obj): # Print a message to confirm completion print("All materials removed from selected objects.") + # Remove the UV Maps and the attributes copied over from the original mesh. + if self.clear_attributes: + for obj in [x for x in bpy.context.selected_objects if x.type == "MESH"]: + mesh_cleanup.clear_attributes(obj) + if self.decimate_proxy: # Set the decimation ratio as a variable decimation_ratio = 0.25 # You can change this value later @@ -322,11 +335,12 @@ def add_collision_modifier(obj): def draw(self, context): layout = self.layout - layout.prop(self, "decimate_proxy", icon_value=0, emboss=True) + layout.prop(self, "decimate_proxy") + layout.prop(self, "clear_attributes") layout.separator() - layout.prop(self, "add_to_panel", icon_value=0, emboss=True) + layout.prop(self, "add_to_panel") def invoke(self, context, event): self.decimate_proxy = True diff --git a/tools_creators/ops_jiggle.py b/tools_creators/ops_jiggle.py index 6a6c0836..2f76f65b 100644 --- a/tools_creators/ops_jiggle.py +++ b/tools_creators/ops_jiggle.py @@ -145,6 +145,11 @@ class MustardUI_ToolsCreators_CreateJiggle(bpy.types.Operator): description="Direction where to create the Pin group weights.\nThe direction " "in global coordinates is the direction in which the weights decreases", items=[ + ( + "AUTO", + "Automatic", + "Infer the direction from the border of the selection", + ), ("+X", "+X", "+X"), ("-X", "-X", "-X"), ("+Y", "+Y", "+Y"), @@ -152,7 +157,7 @@ class MustardUI_ToolsCreators_CreateJiggle(bpy.types.Operator): ("+Z", "+Z", "+Z"), ("-Z", "-Z", "-Z"), ], - default="+Y", + default="AUTO", ) parent_to_model: bpy.props.BoolProperty( name="Parent to Model", @@ -315,9 +320,33 @@ def add_corrective_smooth_modifier( stack.append(neighbor) islands.append(island) + def island_pin_direction(island): + """The direction the Pin weights of a region of the model decrease along.""" + border = [v for v in island if any(not e.other_vert(v).select for e in v.link_edges)] + if not border: + return None + + island_center = sum((v.co for v in island), Vector()) / len(island) + border_center = sum((v.co for v in border), Vector()) / len(border) + + # The direction is used against the world coordinates of the cage + direction = obj.matrix_world.to_3x3() @ (island_center - border_center) + + # A region centred on its own border (a belt, a ring) has no side to + # hang from, as much as one which is attached all around + if direction.length < 1e-6: + return None + + return direction.normalized() + # List to store created proxy objects created_proxies = [] + # Pin direction of each region, and where the region is, to match the cages + # back to the region they were built on when Automatic is used + island_directions = [] + island_centers = [] + # Create a combined vertex group for all regions combined_group_verts = set() @@ -353,6 +382,9 @@ def add_corrective_smooth_modifier( ) # Add the proxy object to the list of created proxies created_proxies.append(proxy) + # Store the direction the Pin group of this region hangs from + island_directions.append(island_pin_direction(island)) + island_centers.append(obj.matrix_world @ center) # Create a vertex group for this region group_name = f"Jiggle Region {idx + 1}" region_vertex_group = create_vertex_group(obj, group_name, island) @@ -482,8 +514,41 @@ def create_unique_vertex_group_name(obj, base_name): return new_name index += 1 + # Regions whose Pin direction could not be inferred, reported once at the end + auto_fallback = [] + + PIN_AXIS_FALLBACK = "+Y" + + def pin_direction(world_coords): + """The direction the weights of a single cage island decrease along.""" + + PIN_AXES = { + "+X": Vector((1.0, 0.0, 0.0)), + "-X": Vector((-1.0, 0.0, 0.0)), + "+Y": Vector((0.0, 1.0, 0.0)), + "-Y": Vector((0.0, -1.0, 0.0)), + "+Z": Vector((0.0, 0.0, 1.0)), + "-Z": Vector((0.0, 0.0, -1.0)), + } + + if self.object_direction != "AUTO": + return PIN_AXES[self.object_direction] + + center = sum((co for _, co in world_coords), Vector()) / len(world_coords) + nearest = min( + range(len(island_centers)), + key=lambda i: (island_centers[i] - center).length_squared, + ) + + direction = island_directions[nearest] + if direction is None: + auto_fallback.append(nearest) + return PIN_AXES[PIN_AXIS_FALLBACK] + + return direction + def create_gradient_vertex_group(obj, group_name, all_islands): - """Creates a vertex group with a gradient weight based on the Y position of vertices for all islands.""" # noqa: E501 + """Creates a vertex group with a gradient weight along the Pin direction for all islands.""" # noqa: E501 # Create a unique name for the vertex group if needed unique_group_name = create_unique_vertex_group_name(obj, group_name) # Create the vertex group @@ -493,54 +558,22 @@ def create_gradient_vertex_group(obj, group_name, all_islands): for island_verts in all_islands: # Collect world coordinates and vertex indices for the current island world_coords = [(v.index, obj.matrix_world @ v.co) for v in island_verts] - # Calculate min and max Y coordinates in world space for this island - if self.object_direction in ["+Y", "-Y"]: - bbox_min = min(world_coords, key=lambda vc: vc[1].y)[1].y - bbox_max = max(world_coords, key=lambda vc: vc[1].y)[1].y - elif self.object_direction in ["+X", "-X"]: - bbox_min = min(world_coords, key=lambda vc: vc[1].x)[1].x - bbox_max = max(world_coords, key=lambda vc: vc[1].x)[1].x - else: - bbox_min = min(world_coords, key=lambda vc: vc[1].z)[1].z - bbox_max = max(world_coords, key=lambda vc: vc[1].z)[1].z + # Where each vertex stands along the direction the weights decrease + # along: a signed axis is the direction itself, so the same + # projection covers the manual directions and the inferred ones + direction = pin_direction(world_coords) + projections = [(v_idx, world_co.dot(direction)) for v_idx, world_co in world_coords] + bbox_min = min(projection for _, projection in projections) + bbox_max = max(projection for _, projection in projections) bbox_depth = ( bbox_max - bbox_min if bbox_max != bbox_min else 1 ) # Avoid division by zero - # Collect vertex weights for this island - invert = self.object_direction in ["-X", "-Y", "-Z"] - if invert: - if self.object_direction in ["+Y", "-Y"]: - vertex_weights = [ - (v_idx, (world_co.y - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - elif self.object_direction in ["+X", "-X"]: - vertex_weights = [ - (v_idx, (world_co.x - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - else: - vertex_weights = [ - (v_idx, (world_co.z - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - else: - if self.object_direction in ["+Y", "-Y"]: - vertex_weights = [ - (v_idx, 1 - (world_co.y - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - elif self.object_direction in ["+X", "-X"]: - vertex_weights = [ - (v_idx, 1 - (world_co.x - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - else: - vertex_weights = [ - (v_idx, 1 - (world_co.z - bbox_min) / bbox_depth) - for v_idx, world_co in world_coords - ] - all_vertex_weights.extend(vertex_weights) + # Collect vertex weights for this island: 1 at the start of the + # direction, 0 at its end + all_vertex_weights.extend( + (v_idx, 1 - (projection - bbox_min) / bbox_depth) + for v_idx, projection in projections + ) # Apply weights to the vertex group in object mode bpy.ops.object.mode_set(mode="OBJECT") for index, weight in all_vertex_weights: @@ -603,6 +636,13 @@ def restore_selection_and_mode(mode, selected_objects, active_object): # Restore the original selection and mode restore_selection_and_mode(initial_mode, initial_selected_objects, initial_active_object) + if auto_fallback: + self.report( + {"WARNING"}, + f"MustardUI - The Pin direction of {len(set(auto_fallback))} regions could " + f"not be inferred: {PIN_AXIS_FALLBACK} was used for them.", + ) + # Restore Armature Pose States for obj in bpy.context.scene.objects: if obj.type == "ARMATURE" and obj.name in stored_pose_states: diff --git a/tools_creators/ops_jiggle_accurate.py b/tools_creators/ops_jiggle_accurate.py index 7a7257d4..4e5cd8c4 100644 --- a/tools_creators/ops_jiggle_accurate.py +++ b/tools_creators/ops_jiggle_accurate.py @@ -5,6 +5,7 @@ from rna_prop_ui import rna_idprop_ui_create from .. import __package__ as base_package +from ..misc import mesh_cleanup from ..model_selection.active_object import mustardui_active_object from . import physics_presets @@ -134,6 +135,13 @@ class MustardUI_ToolsCreators_CreateJiggleAccurate(bpy.types.Operator): min=0, max=20, ) + clear_data: bpy.props.BoolProperty( + name="Clear Unused Data", + description="Remove the UV Maps, the attributes and the shape keys and vertex " + "groups which are empty, inherited by the cage from the model.\nNone of them is " + "used by the cage, and they only increase the size of the file", + default=True, + ) add_to_panel: bpy.props.BoolProperty( name="Add to Physics Panel", description="Add the Cage item to Physics Panel", @@ -1095,6 +1103,20 @@ def create_cage(island, cage_name, border_positions): f"{item['pinned_loops']} of {item['border_loops']} borders pinned)." ) + # Drop what the cage copied from the model and does not use. + if self.clear_data: + for item in cages: + cage = item["object"] + mesh_cleanup.clear_shape_keys(cage, void_only=True) + mesh_cleanup.clear_attributes(cage) + # The Pin group can legitimately be empty + removed = mesh_cleanup.clear_unused_vertex_groups( + cage, + keep=[x for x in (item["pin_name"], item["structural_group_name"]) if x], + ) + if addon_prefs.debug: + print(f"MustardUI - {removed} empty vertex groups removed from '{cage.name}'.") + # Restore the Armature pose states. Every cage has been generated and bound # by now: what follows does not depend on the shape of the model any more for name, pose_position in stored_pose_states.items(): @@ -1170,6 +1192,9 @@ def draw(self, context): col.prop(self, "multiple_pin_boundaries") col.prop(self, "merge_cages") + col = box.column(align=True) + col.prop(self, "clear_data") + box = layout.box() box.label(text="Physics Settings", icon="PHYSICS") physics_presets.draw_physics_presets(box, self) diff --git a/tools_creators/ops_optimize_sk.py b/tools_creators/ops_optimize_sk.py index 3a3c3c71..b8831ddb 100644 --- a/tools_creators/ops_optimize_sk.py +++ b/tools_creators/ops_optimize_sk.py @@ -1,5 +1,6 @@ import bpy +from ..misc import mesh_cleanup from ..model_selection.active_object import ( active_object_operator_poll, mustardui_active_object, @@ -14,12 +15,22 @@ class MustardUI_ToolsCreators_OptimizeShapeKeys(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} add_shape_key_mute_driver: bpy.props.BoolProperty( - default=True, + default=False, name="Automatically Mute null Shape Keys", description="Add a driver on the Mute property of the Shape Keys, which are " "automatically disabled when their value is 0.\nNote: Freezable option for " "custom sections will be disabled as incompatible with drivers on the Mute " - "properties", + "properties.\nWarning: This option might affect performance, check before and " + "after using this tool.", + ) + + remove_void_shape_keys: bpy.props.BoolProperty( + default=True, + name="Remove Void Shape Keys", + description="Remove the Shape Keys which do not move a single vertex.\nThese " + "are copies of the shape they are relative to: they deform nothing, while " + "they take up as much space in the file as any other Shape Key.\nNote: the " + "Shape Keys used by the Morphs of the UI are never removed", ) revert: bpy.props.BoolProperty(default=False) @@ -45,27 +56,41 @@ def execute(self, context): obj = context.active_object sks = obj.data.shape_keys - kb = sks.key_blocks # Skip Shape Keys already managed by Morphs + morph_shape_keys = set() if obj == rig_settings.model_body: - morph_shape_keys = set() for section in sections: if not section.shape_keys: continue for morph in section.morphs: if not morph.custom_property: morph_shape_keys.add(morph.path) - kb = [x for x in kb if x.name not in morph_shape_keys] - if not self.add_shape_key_mute_driver: + def managed_key_blocks(): + return [x for x in sks.key_blocks if x.name not in morph_shape_keys] + + # Removing a Shape Key can not be reverted by the tool + remove_void = self.remove_void_shape_keys and not self.revert + + if not self.add_shape_key_mute_driver and not remove_void: self.report( {"WARNING"}, "MustardUI - No Option Selected.", ) return {"CANCELLED"} - if not self.revert: + removed = 0 + if remove_void: + for sk in managed_key_blocks(): + if not mesh_cleanup.shape_key_is_void(sk): + continue + mesh_cleanup.remove_shape_key(obj, sk) + removed += 1 + + kb = managed_key_blocks() + + if self.add_shape_key_mute_driver and not self.revert: for sk in kb: # Skip Basis if sk == sks.reference_key: @@ -93,7 +118,7 @@ def execute(self, context): driver.expression = "abs(var) < 0.001" # Otherwise remove the mute driver - else: + elif self.add_shape_key_mute_driver: for sk in kb: try: driver_path = f'key_blocks["{sk.name}"].mute' @@ -109,12 +134,18 @@ def execute(self, context): except Exception: pass - self.report( - {"INFO"}, - "MustardUI - Shape Key drivers removed." - if self.revert - else "MustardUI - Shape Keys Optimized.", - ) + if self.revert: + message = "MustardUI - Shape Key drivers removed." + else: + message = ( + "MustardUI - Shape Keys Optimized." + if self.add_shape_key_mute_driver + else "MustardUI - Shape Keys checked." + ) + if remove_void: + message += f" {removed} void Shape Keys removed." + + self.report({"INFO"}, message) return {"FINISHED"} @@ -129,6 +160,7 @@ def draw(self, context): if not self.revert: col.prop(self, "add_shape_key_mute_driver") + col.prop(self, "remove_void_shape_keys") else: col.prop( self,