Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion blender_manifest.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
3 changes: 3 additions & 0 deletions custom_properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
ops_link,
ops_menu_settings,
ops_rebuild,
ops_set_section,
ops_smartcheck,
ui_list,
)
Expand All @@ -17,13 +18,15 @@ def register():
menus.register()
ui_list.register()
ops_menu_settings.register()
ops_set_section.register()
ops_rebuild.register()
ops_smartcheck.register()


def unregister():
ops_smartcheck.unregister()
ops_rebuild.unregister()
ops_set_section.unregister()
ops_menu_settings.unregister()
ui_list.unregister()
menus.unregister()
Expand Down
72 changes: 72 additions & 0 deletions custom_properties/ops_set_section.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 10 additions & 8 deletions custom_properties/ui_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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:
Expand Down
179 changes: 179 additions & 0 deletions misc/mesh_cleanup.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 1 addition & 4 deletions model_selection/active_object.py
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
3 changes: 2 additions & 1 deletion morphs/ops_defvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions morphs/settings_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading