From fe8f5f69d5b10158d467e76191ca5dfff7e22608 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 2 Dec 2024 01:52:11 +0000 Subject: [PATCH 01/19] Plugin Registration Changes - Re-wrote how the plugin registers itself. - No longer need @register_wrapper classes get auto detected and added. - The new Auto loader is much better then the old way, no longer need "if "bpy" not in locals():" this was an old way of doing things and wasn't really efficient. using auto_load.py provides several advantages: - It automatically discovers and loads all modules in the addon. - It handles dependencies between classes correctly through topological sorting. - It manages registration order automatically. - It properly handles unregistration in the correct order. This approach is much less error prone and I not had any issues so far. However it still needs testing fully. I have also start to re-organise files into folders as well, this is going to be needed so we don't have a long list of files as Avatar Toolkit is getting larger then i originally planned. --- __init__.py | 60 +---- core/__init__.py | 20 -- core/auto_load.py | 172 ++++++++++++++ core/common.py | 1 - core/{ => exporters}/export_resonite.py | 8 +- core/{ => importers}/import_pmd.py | 0 core/{ => importers}/import_pmx.py | 0 core/{ => importers}/importer.py | 0 core/preferences.json | 3 - core/properties.py | 295 ++++++++++++------------ core/register.py | 105 --------- core/translations.py | 97 ++++++++ core/updater.py | 15 +- functions/__init__.py | 18 -- functions/additional_tools.py | 13 +- functions/armature_modifying.py | 19 +- functions/atlas_materials.py | 5 +- functions/combine_materials.py | 5 +- functions/digitigrade_legs.py | 5 +- functions/import_anything.py | 11 +- functions/mesh_tools.py | 11 +- functions/mmd_functions.py | 11 +- functions/remove_doubles_safely.py | 7 +- functions/resonite_functions.py | 5 +- functions/rigify_functions.py | 11 +- functions/uv_tools.py | 5 +- functions/viseme.py | 5 +- ui/__init__.py | 15 -- ui/atlas_materials.py | 93 ++++---- ui/credits_support.py | 6 +- ui/{panel.py => main_panel.py} | 9 +- ui/merge_armatures.py | 15 +- ui/mmd_options.py | 7 +- ui/optimization.py | 10 +- ui/quick_access.py | 23 +- ui/settings.py | 14 +- ui/tools.py | 24 +- ui/uv_panel.py | 9 +- ui/uv_tools.py | 10 +- ui/viseme.py | 19 +- 40 files changed, 581 insertions(+), 580 deletions(-) create mode 100644 core/auto_load.py rename core/{ => exporters}/export_resonite.py (88%) rename core/{ => importers}/import_pmd.py (100%) rename core/{ => importers}/import_pmx.py (100%) rename core/{ => importers}/importer.py (100%) delete mode 100644 core/preferences.json delete mode 100644 core/register.py create mode 100644 core/translations.py rename ui/{panel.py => main_panel.py} (84%) diff --git a/__init__.py b/__init__.py index c628dc6..4016289 100644 --- a/__init__.py +++ b/__init__.py @@ -1,55 +1,13 @@ -if "bpy" not in locals(): - import bpy - from . import ui - from . import core - from . import functions - from .core import register - from .core.register import __bl_ordered_classes - from .core import properties - from .core import addon_preferences - from .core.updater import check_for_update_on_start -else: - import importlib - importlib.reload(ui) - importlib.reload(core) - importlib.reload(functions) - importlib.reload(properties) - importlib.reload(addon_preferences) +modules = None +ordered_classes = None def register(): - print("Registering Avatar Toolkit") - # Register the addon properties - properties.register() - - # Load the translations - functions.translations.load_translations() - - # Order the classes before registration - core.register.order_classes() - # Register the UI classes - for cls in core.register.__bl_ordered_classes: - print("registering " + str(cls)) - bpy.utils.register_class(cls) - - #finally register properties that may use some classes. - core.register.register_properties() - - bpy.app.handlers.load_post.append(check_for_update_on_start) - - from .functions.mesh_tools import AvatarToolkit_OT_ApplyShapeKey - - bpy.types.MESH_MT_shape_key_context_menu.append((lambda self, context: self.layout.separator())) - bpy.types.MESH_MT_shape_key_context_menu.append((lambda self, context: self.layout.operator(AvatarToolkit_OT_ApplyShapeKey.bl_idname, icon="KEY_HLT"))) + from .core import auto_load + print("Starting registration") + auto_load.init() + auto_load.register() + print("Registration complete") def unregister(): - print("Unregistering Avatar Toolkit") - # Unregister the UI classes - if check_for_update_on_start in bpy.app.handlers.load_post: - bpy.app.handlers.load_post.remove(check_for_update_on_start) - - # Iterate over the classes to unregister in reverse order and unregister them - for cls in reversed(list(__bl_ordered_classes)): - bpy.utils.unregister_class(cls) - print("unregistering " + str(cls)) - core.register.unregister_properties() - properties.unregister() + from .core import auto_load + auto_load.unregister() diff --git a/core/__init__.py b/core/__init__.py index 50c92e7..e69de29 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,20 +0,0 @@ -# core/__init__.py - -from .register import register_wrap - -#to reload all things in this directory and import them properly - @989onan -if "bpy" not in locals(): - import bpy - import glob - import os - from os.path import dirname, basename, isfile, join - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - exec("from . import "+module_name) - print("importing " +module_name) -else: - import importlib - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - exec("importlib.reload("+module_name+")") - print("reloading " +module_name) diff --git a/core/auto_load.py b/core/auto_load.py new file mode 100644 index 0000000..7ca78ab --- /dev/null +++ b/core/auto_load.py @@ -0,0 +1,172 @@ +import os +import bpy +import sys +import typing +import inspect +import pkgutil +import tomllib +import importlib +from pathlib import Path + +__all__ = ( + "init", + "register", + "unregister", +) + +modules = None +ordered_classes = None + +def init(): + global modules + global ordered_classes + print("Auto-load init starting") + modules = get_all_submodules(Path(__file__).parent.parent) + ordered_classes = get_ordered_classes_to_register(modules) + print(f"Found modules: {modules}") + print(f"Found classes: {ordered_classes}") + +def register(): + print("Registering classes") + for cls in ordered_classes: + print(f"Registering: {cls}") + try: + bpy.utils.register_class(cls) + except ValueError: + continue + + for module in modules: + if module.__name__ == __name__: + continue + if hasattr(module, "register"): + module.register() + +def unregister(): + for cls in reversed(ordered_classes): + bpy.utils.unregister_class(cls) + + for module in modules: + if module.__name__ == __name__: + continue + if hasattr(module, "unregister"): + module.unregister() + +def get_manifest_id(): + manifest_path = Path(__file__).parent.parent / "blender_manifest.toml" + with open(manifest_path, "rb") as f: + manifest = tomllib.load(f) + return manifest["id"] + +def get_all_submodules(directory): + modules = [] + addon_id = get_manifest_id() + for root, dirs, files in os.walk(directory): + if "__pycache__" in root: + continue + path = Path(root) + if path == directory: + package_name = f"bl_ext.user_default.{addon_id}" + else: + relative_path = path.relative_to(directory).as_posix().replace('/', '.') + package_name = f"bl_ext.user_default.{addon_id}.{relative_path}" + for name in sorted(iter_module_names(path)): + modules.append(importlib.import_module(f".{name}", package_name)) + return modules + +def iter_submodules(path, package_name): + for name in sorted(iter_module_names(path)): + yield importlib.import_module("." + name, package_name) + +def iter_module_names(path): + print(f"Scanning path: {path}") # Debug path + modules_list = list(pkgutil.iter_modules([str(path)])) + print(f"Found these modules: {modules_list}") # Debug modules + for _, module_name, is_pkg in modules_list: + if not is_pkg: + print(f"Found module: {module_name}") + yield module_name + + + +def get_ordered_classes_to_register(modules): + return toposort(get_register_deps_dict(modules)) + +def get_register_deps_dict(modules): + deps_dict = {} + classes_to_register = set(iter_classes_to_register(modules)) + for cls in classes_to_register: + deps_dict[cls] = set(iter_own_register_deps(cls, classes_to_register)) + return deps_dict + +def iter_own_register_deps(cls, classes_to_register): + yield from (dep for dep in iter_register_deps(cls) if dep in classes_to_register) + +def iter_register_deps(cls): + for value in typing.get_type_hints(cls, {}, {}).values(): + dependency = get_dependency_from_annotation(value) + if dependency is not None: + yield dependency + +def get_dependency_from_annotation(value): + if isinstance(value, tuple) and len(value) == 2: + if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): + return value[1]["type"] + return None + +def iter_classes_to_register(modules): + base_types = get_register_base_types() + for cls in get_classes_in_modules(modules): + if any(base in base_types for base in cls.__bases__): + if not getattr(cls, "_is_registered", False): + yield cls + +def get_classes_in_modules(modules): + classes = set() + for module in modules: + for cls in iter_classes_in_module(module): + classes.add(cls) + return classes + +def iter_classes_in_module(module): + for value in module.__dict__.values(): + if inspect.isclass(value): + yield value + +def get_register_base_types(): + return set(getattr(bpy.types, name) for name in [ + "Panel", "Operator", "PropertyGroup", + "AddonPreferences", "Header", "Menu", + "Node", "NodeSocket", "NodeTree", + "UIList", "RenderEngine" + ]) + +def toposort(deps_dict): + sorted_list = [] + sorted_values = set() + + # First pass: Register panels without parents + panels_to_sort = [(value, deps) for value, deps in deps_dict.items() + if hasattr(value, 'bl_parent_id')] + + base_panels = [(value, deps) for value, deps in deps_dict.items() + if not hasattr(value, 'bl_parent_id')] + + # Add base panels first + for value, deps in base_panels: + if len(deps) == 0: + sorted_list.append(value) + sorted_values.add(value) + + # Then add child panels + while len(deps_dict) > len(sorted_values): + unsorted = [] + for value, deps in deps_dict.items(): + if value not in sorted_values: + if len(deps - sorted_values) == 0: + sorted_list.append(value) + sorted_values.add(value) + else: + unsorted.append(value) + + return sorted_list + diff --git a/core/common.py b/core/common.py index 30cd235..b1f3644 100644 --- a/core/common.py +++ b/core/common.py @@ -6,7 +6,6 @@ import time import webbrowser import typing -from ..core.register import register_wrap from typing import List, Optional, Tuple from bpy.types import Object, ShapeKey, Mesh, Context, Material, PropertyGroup from functools import lru_cache diff --git a/core/export_resonite.py b/core/exporters/export_resonite.py similarity index 88% rename from core/export_resonite.py rename to core/exporters/export_resonite.py index c5a668f..9de6448 100644 --- a/core/export_resonite.py +++ b/core/exporters/export_resonite.py @@ -1,14 +1,10 @@ import bpy - from typing import List, Optional -from .common import get_armature +from ...core.common import get_armature from bpy.types import Object, ShapeKey, Mesh, Context, Operator from functools import lru_cache -from ..core.register import register_wrap -from ..functions.translations import t +from ...core.translations import t - -@register_wrap class AvatarToolKit_OT_ExportResonite(Operator): bl_idname = 'avatar_toolkit.export_resonite' bl_label = t("Importer.export_resonite.label") diff --git a/core/import_pmd.py b/core/importers/import_pmd.py similarity index 100% rename from core/import_pmd.py rename to core/importers/import_pmd.py diff --git a/core/import_pmx.py b/core/importers/import_pmx.py similarity index 100% rename from core/import_pmx.py rename to core/importers/import_pmx.py diff --git a/core/importer.py b/core/importers/importer.py similarity index 100% rename from core/importer.py rename to core/importers/importer.py diff --git a/core/preferences.json b/core/preferences.json deleted file mode 100644 index b0ca7bf..0000000 --- a/core/preferences.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "language": 0 -} \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index 58cd396..5d5c755 100644 --- a/core/properties.py +++ b/core/properties.py @@ -1,169 +1,180 @@ import bpy -from ..functions.translations import t, get_languages_list, update_language -from ..core.register import register_property -from bpy.types import Scene, Object, Material, Context -from bpy.props import BoolProperty, EnumProperty, IntProperty, CollectionProperty, StringProperty, FloatVectorProperty, PointerProperty -from ..core.addon_preferences import get_preference -from ..core.common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected +from .translations import t, get_languages_list, update_language +from bpy.types import PropertyGroup, Material, Scene, Object, Context +from bpy.props import (StringProperty, BoolProperty, EnumProperty, + IntProperty, FloatProperty, CollectionProperty, + PointerProperty) +from .addon_preferences import get_preference +from .common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected from .updater import get_version_list -def register() -> None: - default_language = get_preference("language", 0) - register_property((bpy.types.Scene, "avatar_toolkit_language", bpy.props.EnumProperty( - name=t("Settings.language.label", "Language"), - description=t("Settings.language.desc", "Select the language for the addon"), +class AvatarToolkitSceneProperties(PropertyGroup): + language: EnumProperty( + name="Language", + description="Select the language for the addon", items=get_languages_list, - default=default_language, update=update_language - ))) - - register_property((bpy.types.Scene, "selected_mesh", bpy.props.EnumProperty( + ) + + selected_mesh: EnumProperty( items=get_mesh_items, - name=t("VisemePanel.selected_mesh.label"), - description=t("VisemePanel.selected_mesh.desc") - ))) + name="Selected Mesh", + description="Select mesh to modify" + ) - register_property((bpy.types.Object, "material_group_expanded", bpy.props.BoolProperty( - name="Expand Material Group", - description="Show/hide materials for this mesh", - default=False - ))) - - register_property((bpy.types.Material, "material_expanded", bpy.props.BoolProperty( - name="Expand Material", - description="Show/hide material properties", - default=False - ))) - - register_property((bpy.types.Scene, "material_search_filter", bpy.props.StringProperty( + material_search_filter: StringProperty( name="Search Materials", description="Filter materials by name", default="" - ))) + ) - register_property((bpy.types.Material, "include_in_atlas", bpy.props.BoolProperty( - name=t("TextureAtlas.include_in_atlas"), - description=t("TextureAtlas.include_in_atlas_desc"), - default=True - ))) - - register_property((bpy.types.Scene, "merge_armature_apply_transforms", bpy.props.BoolProperty( + merge_armature_apply_transforms: BoolProperty( default=False, - name=t("MergeArmature.merge_armatures.apply_transforms.label"), - description=t("MergeArmature.merge_armatures.apply_transforms.desc") - ))) - register_property((bpy.types.Scene, "merge_armature_align_bones", bpy.props.BoolProperty( + name="Apply Transforms", + description="Apply transforms when merging armatures" + ) + + merge_armature_align_bones: BoolProperty( default=False, - name=t("MergeArmature.merge_armatures.align_bones.label"), - description=t("MergeArmature.merge_armatures.align_bones.desc") - ))) - - register_property((bpy.types.Scene, "avatar_toolkit_language_changed", bpy.props.BoolProperty(default=False))) + name="Align Bones", + description="Align bones when merging armatures" + ) - register_property((bpy.types.Scene, "avatar_toolkit_progress_steps", bpy.props.IntProperty(default=0))) - register_property((bpy.types.Scene, "avatar_toolkit_progress_current", bpy.props.IntProperty(default=0))) + progress_steps: IntProperty(default=0) + progress_current: IntProperty(default=0) + language_changed: BoolProperty(default=False) - register_property((bpy.types.Scene, "avatar_toolkit_mouth_a", bpy.props.StringProperty( - name=t("VisemePanel.mouth_a.label"), - description=t("VisemePanel.mouth_a.desc") - ))) - register_property((bpy.types.Scene, "avatar_toolkit_mouth_o", bpy.props.StringProperty( - name=t("VisemePanel.mouth_o.label"), - description=t("VisemePanel.mouth_o.desc") - ))) - register_property((bpy.types.Scene, "avatar_toolkit_mouth_ch", bpy.props.StringProperty( - name=t("VisemePanel.mouth_ch.label"), - description=t("VisemePanel.mouth_ch.desc") - ))) - register_property((bpy.types.Scene, "avatar_toolkit_shape_intensity", bpy.props.FloatProperty( - name=t("VisemePanel.shape_intensity"), - description=t("VisemePanel.shape_intensity_desc"), + mouth_a: StringProperty( + name="Mouth A", + description="Shape key for A sound" + ) + + mouth_o: StringProperty( + name="Mouth O", + description="Shape key for O sound" + ) + + mouth_ch: StringProperty( + name="Mouth CH", + description="Shape key for CH sound" + ) + + shape_intensity: FloatProperty( + name="Shape Intensity", + description="Intensity of shape key modifications", default=1.0, min=0.0, max=2.0 - ))) - register_property((bpy.types.Scene, "merge_twist_bones", bpy.props.BoolProperty( - name=t("Tools.merge_twist_bones.label"), - description=t("Tools.merge_twist_bones.desc"), + ) + + merge_twist_bones: BoolProperty( + name="Merge Twist Bones", + description="Merge twist bones during processing", default=True - ))) + ) - register_property((bpy.types.Scene, "selected_armature", bpy.props.EnumProperty( + selected_armature: EnumProperty( items=get_armatures, - name=t("Quick_Access.selected_armature.label"), - description=t("Quick_Access.selected_armature.desc"), - default=0 - ))) + name="Selected Armature", + description="Select the armature to work with" + ) - register_property((bpy.types.Scene, "merge_armature_source", bpy.props.EnumProperty( + merge_armature_source: EnumProperty( items=get_armatures_that_are_not_selected, - name=t("MergeArmatures.selected_armature.label"), - description=t("MergeArmatures.selected_armature.label"), - default=0 - ))) + name="Source Armature", + description="Select the source armature for merging" + ) - register_property((bpy.types.Scene, "avatar_toolkit_updater_version_list", bpy.props.EnumProperty( - name=t('Scene.avatar_toolkit_updater_version_list.name'), - description=t('Scene.avatar_toolkit_updater_version_list.description'), - items=get_version_list - ))) - - #happy with how compressed this get_texture_node_list method is - @989onan - def get_texture_node_list(self: Material, context: Context) -> list[set[3]]: - - if self.use_nodes: - - Object.Enum = [((i.image.name if i.image else i.name+"_image"),(i.image.name if i.image else "node with no image..."),(i.image.name if i.image else i.name),index+1) for index,i in enumerate(self.node_tree.nodes) if i.bl_idname == "ShaderNodeTexImage"] - if not len(Object.Enum): - Object.Enum = [(t("TextureAtlas.error.label"), t("TextureAtlas.no_images_error.desc") , t("TextureAtlas.error.label"), 0)] - else: - Object.Enum = [(t("TextureAtlas.error.label"), t("TextureAtlas.no_nodes_error.desc"), t("TextureAtlas.error.label"), 0)] - Object.Enum.append((t("TextureAtlas.none.label"), t("TextureAtlas.none.label"), t("TextureAtlas.none.label"), 0)) - return Object.Enum - - register_property((Material, "texture_atlas_albedo", EnumProperty( - name=t("TextureAtlas.albedo"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.albedo").lower()), - default=0, - items=get_texture_node_list))) - register_property((Material, "texture_atlas_normal", EnumProperty( - name=t("TextureAtlas.normal"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.normal").lower()), - default=0, - items=get_texture_node_list))) - register_property((Material, "texture_atlas_emission", EnumProperty( - name=t("TextureAtlas.emission"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.emission").lower()), - default=0, - items=get_texture_node_list))) - register_property((Material, "texture_atlas_ambient_occlusion", EnumProperty( - name=t("TextureAtlas.ambient_occlusion"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.ambient_occlusion").lower()), - default=0, - items=get_texture_node_list))) - register_property((Material, "texture_atlas_height", EnumProperty( - name=t("TextureAtlas.height"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.height").lower()), - default=0, - items=get_texture_node_list))) - register_property((Material, "texture_atlas_roughness", EnumProperty( - name=t("TextureAtlas.roughness"), - description=t("TextureAtlas.texture_use_atlas.desc").format(name=t("TextureAtlas.roughness").lower()), - default=0, - items=get_texture_node_list))) - - register_property((Scene, "texture_atlas_material_index", IntProperty( - default=-1, - get=(lambda self : -1), - set=(lambda self,context : None)))) + texture_atlas_material_index: IntProperty( + default=-1, + get=lambda self: -1, + set=lambda self, context: None + ) - register_property((Scene, "materials", CollectionProperty(type=SceneMatClass))) - - register_property((Scene, "texture_atlas_Has_Mat_List_Shown", BoolProperty( + materials: CollectionProperty(type=SceneMatClass) + + texture_atlas_Has_Mat_List_Shown: BoolProperty( default=False, - get=MaterialListBool.get_bool, - set=MaterialListBool.set_bool))) + get=MaterialListBool.get_bool, + set=MaterialListBool.set_bool + ) -def unregister() -> None: - #if you register properties with register_property then you shouldn't need this function. - pass +class AvatarToolkitMaterialProperties(PropertyGroup): + material_expanded: BoolProperty( + name="Expand Material", + description="Show/hide material properties", + default=False + ) + + include_in_atlas: BoolProperty( + name="Include in Atlas", + description="Include this material in texture atlas", + default=True + ) + + def get_texture_node_list(self, context): + if self.use_nodes: + nodes = [(i.image.name if i.image else i.name+"_image", + i.image.name if i.image else "node with no image...", + i.image.name if i.image else i.name, index+1) + for index, i in enumerate(self.node_tree.nodes) + if i.bl_idname == "ShaderNodeTexImage"] + if not nodes: + nodes = [("Error", "No images found", "Error", 0)] + else: + nodes = [("Error", "No node tree found", "Error", 0)] + nodes.append(("None", "None", "None", 0)) + return nodes + + texture_atlas_albedo: EnumProperty( + name="Albedo", + description="Albedo texture for atlas", + items=get_texture_node_list + ) + + texture_atlas_normal: EnumProperty( + name="Normal", + description="Normal map for atlas", + items=get_texture_node_list + ) + + texture_atlas_emission: EnumProperty( + name="Emission", + description="Emission texture for atlas", + items=get_texture_node_list + ) + + texture_atlas_ambient_occlusion: EnumProperty( + name="Ambient Occlusion", + description="AO texture for atlas", + items=get_texture_node_list + ) + + texture_atlas_height: EnumProperty( + name="Height", + description="Height map for atlas", + items=get_texture_node_list + ) + + texture_atlas_roughness: EnumProperty( + name="Roughness", + description="Roughness map for atlas", + items=get_texture_node_list + ) + +class AvatarToolkitObjectProperties(PropertyGroup): + material_group_expanded: BoolProperty( + name="Expand Material Group", + description="Show/hide materials for this mesh", + default=False + ) + +def register(): + bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties) + bpy.types.Material.avatar_toolkit = PointerProperty(type=AvatarToolkitMaterialProperties) + bpy.types.Object.avatar_toolkit = PointerProperty(type=AvatarToolkitObjectProperties) + +def unregister(): + del bpy.types.Scene.avatar_toolkit + del bpy.types.Material.avatar_toolkit + del bpy.types.Object.avatar_toolkit diff --git a/core/register.py b/core/register.py deleted file mode 100644 index 915c372..0000000 --- a/core/register.py +++ /dev/null @@ -1,105 +0,0 @@ -import bpy -import typing -from typing import List, Type - -# List to store the classes to register -__bl_classes = [] -# List to store the ordered classes for registration -__bl_ordered_classes = [] -# List to store props to register -__bl_props = [] - -def register_wrap(cls): - # Check if the class has a 'bl_rna' attribute (indicating it's a Blender class) - if hasattr(cls, 'bl_rna'): - # Add the class to the list of classes to register - __bl_classes.append(cls) - return cls - -# Register all properties -def register_property(prop): - __bl_props.append(prop) - -def register_properties(): - for prop in __bl_props: - if isinstance(prop[2], bpy.props._PropertyDeferred): - setattr(prop[0], prop[1], prop[2]) - else: - prop() - -def clear_registration(): - __bl_classes.clear() - __bl_ordered_classes.clear() - __bl_props.clear() - -def unregister_properties(): - for prop in reversed(__bl_props): - try: - delattr(prop[0], prop[1]) - except AttributeError: - continue - clear_registration() - -#- @989onan had to add this from Cats. This is extremely important else you will be screamed at by register order issues! -# Find order to register to solve dependencies - -################################################# - -def toposort(deps_dict): - sorted_list = [] - sorted_values = set() - while len(deps_dict) > 0: - unsorted = [] - for value, deps in deps_dict.items(): - if len(deps) == 0: - sorted_list.append(value) - sorted_values.add(value) - else: - unsorted.append(value) - deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted} - - #sort_order(sorted_list) #to sort by 'bl_order' so we can choose how things may appear in the ui - return sorted_list - - - -def order_classes(): - deps_dict = {} - classes_to_register = set(iter_classes_to_register()) - for class_obj in classes_to_register: - deps_dict[class_obj] = set(iter_own_register_deps(class_obj, classes_to_register)) - - __bl_ordered_classes.clear() - # Then put everything else sorted into the list - for class_obj in toposort(deps_dict): - __bl_ordered_classes.append(class_obj) - - print(__bl_ordered_classes) - __bl_classes.clear() - - -def iter_classes_to_register(): - for class_obj in __bl_classes: - yield class_obj - - -def iter_own_register_deps(class_obj, own_classes): - yield from (dep for dep in iter_register_deps(class_obj) if dep in own_classes) - - -def iter_register_deps(class_obj): - for value in typing.get_type_hints(class_obj, {}, {}, True).values(): - dependency = get_dependency_from_annotation(value) - if dependency is not None: - yield dependency - if hasattr(class_obj, "bl_parent_id"): - if class_obj.bl_parent_id != "": - for dependency in __bl_classes: - if dependency.bl_idname == class_obj.bl_parent_id: - yield dependency - -def get_dependency_from_annotation(value): - if isinstance(value, tuple) and len(value) == 2: - if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): - return value[1]["type"] - return None diff --git a/core/translations.py b/core/translations.py new file mode 100644 index 0000000..c123ad7 --- /dev/null +++ b/core/translations.py @@ -0,0 +1,97 @@ +import os +import json +import bpy +from bpy.app.translations import locale +from typing import Dict, List, Tuple +from .addon_preferences import save_preference, get_preference + +# Use __file__ to get the current file's directory +current_dir = os.path.dirname(os.path.abspath(__file__)) +main_dir = os.path.dirname(current_dir) +resources_dir = os.path.join(main_dir, "resources") +translations_dir = os.path.join(resources_dir, "translations") + +dictionary: Dict[str, str] = dict() +languages: List[str] = [] +verbose: bool = True + +def load_translations() -> bool: + global dictionary, languages + + old_dictionary = dictionary.copy() + + dictionary = dict() + languages = ["auto"] + + # Populate languages list + for i in os.listdir(translations_dir): + lang = i.split(".")[0] + if lang != "auto": + languages.append(lang) + + language_index = get_preference("language", 0) + # print(f"Loading translations for language index: {language_index}") # Debug print + + if language_index == 0: # "auto" + language = bpy.context.preferences.view.language + else: + try: + language = languages[language_index] + except IndexError: + language = bpy.context.preferences.view.language + + # print(f"Selected language: {language}") # Debug print + + translation_file: str = os.path.join(translations_dir, language + ".json") + if os.path.exists(translation_file): + with open(translation_file, 'r', encoding='utf-8') as file: + dictionary = json.load(file)["messages"] + # print(f"Loaded translations: {dictionary}") # Debug print + else: + custom_language: str = language.split("_")[0] + custom_translation_file: str = os.path.join(translations_dir, custom_language + ".json") + if os.path.exists(custom_translation_file): + with open(custom_translation_file, 'r', encoding='utf-8') as file: + dictionary = json.load(file)["messages"] + # print(f"Loaded custom translations: {dictionary}") # Debug print + else: + print(f"Translation file not found for language: {language}") + default_file: str = os.path.join(translations_dir, "en_US.json") + if os.path.exists(default_file): + with open(default_file, 'r', encoding='utf-8') as file: + dictionary = json.load(file)["messages"] + # print(f"Loaded default translations: {dictionary}") # Debug print + else: + print("Default translation file 'en_US.json' not found.") + + return dictionary != old_dictionary + +def t(phrase: str, default: str = None, **kwargs) -> str: + output: str = dictionary.get(phrase) + if output is None: + if verbose: + print(f'Warning: Unknown phrase: {phrase}') + return default if default is not None else phrase + # print(f"Translating '{phrase}' to '{output}'") # Debug print + return output.format(**kwargs) if kwargs else output + +def get_language_display_name(lang: str) -> str: + if lang == "auto": + return t("Language.auto", "Automatic") + return t(f"Language.{lang}", lang) + +def get_languages_list(self, context) -> List[Tuple[str, str, str]]: + return [(str(i), get_language_display_name(lang), f"Use {lang} language") for i, lang in enumerate(languages)] + +def update_language(self, context): + print(f"Updating language to: {self.avatar_toolkit_language}") # Debug print + save_preference("language", int(self.avatar_toolkit_language)) + load_translations() + # Set a flag to indicate that a language change has occurred + context.scene.avatar_toolkit_language_changed = True + # Show popup after language change + bpy.ops.avatar_toolkit.translation_restart_popup('INVOKE_DEFAULT') + +# Initial load of translations +# print("Performing initial load of translations") # Debug print +load_translations() diff --git a/core/updater.py b/core/updater.py index 96bc017..53eb248 100644 --- a/core/updater.py +++ b/core/updater.py @@ -10,10 +10,9 @@ import time from urllib import request, error from threading import Thread from bpy.app.handlers import persistent -from ..functions.translations import t +from .translations import t from .addon_preferences import get_preference, get_current_version, save_preference -from .register import register_wrap -from ..ui.panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..ui.main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from typing import Dict, List, Tuple, Optional, Set, Any GITHUB_REPO = "teamneoneko/Avatar-Toolkit" @@ -27,7 +26,7 @@ version_list: Optional[Dict[str, List[str]]] = None main_dir: str = os.path.dirname(os.path.dirname(__file__)) downloads_dir: str = os.path.join(main_dir, "downloads") -@register_wrap + class AvatarToolkit_OT_CheckForUpdate(bpy.types.Operator): bl_idname = 'avatar_toolkit.check_for_update' bl_label = t('CheckForUpdateButton.label') @@ -38,7 +37,7 @@ class AvatarToolkit_OT_CheckForUpdate(bpy.types.Operator): check_for_update_background() return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_UpdateToLatest(bpy.types.Operator): bl_idname = 'avatar_toolkit.update_latest' bl_label = t('UpdateToLatestButton.label') @@ -49,7 +48,7 @@ class AvatarToolkit_OT_UpdateToLatest(bpy.types.Operator): update_now(latest=True) return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_UpdateNotificationPopup(bpy.types.Operator): bl_idname = "avatar_toolkit.update_notification_popup" bl_label = t('UpdateNotificationPopup.label') @@ -69,7 +68,7 @@ class AvatarToolkit_OT_UpdateNotificationPopup(bpy.types.Operator): col = layout.column(align=True) col.label(text=t('UpdateNotificationPopup.newUpdate', default="New update available: {version}").format(version=latest_version_str)) -@register_wrap + class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_label = t("Updater.label") bl_idname = "OBJECT_PT_avatar_toolkit_updater" @@ -83,7 +82,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): layout = self.layout draw_updater_panel(context, layout) -@register_wrap + class AvatarToolkit_OT_RestartBlenderPopup(bpy.types.Operator): bl_idname = "avatar_toolkit.restart_blender_popup" bl_label = t('RestartBlenderPopup.label', default="Restart Blender") diff --git a/functions/__init__.py b/functions/__init__.py index afece68..e69de29 100644 --- a/functions/__init__.py +++ b/functions/__init__.py @@ -1,18 +0,0 @@ -from ..core.register import register_wrap - -#to reload all things in this directory and import them properly - @989onan -if "bpy" not in locals(): - import bpy - import glob - import os - from os.path import dirname, basename, isfile, join - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - exec("from . import "+module_name) - print("importing " +module_name) -else: - import importlib - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - exec("importlib.reload("+module_name+")") - print("reloading " +module_name) diff --git a/functions/additional_tools.py b/functions/additional_tools.py index 0979207..57f8f3d 100644 --- a/functions/additional_tools.py +++ b/functions/additional_tools.py @@ -1,11 +1,10 @@ import bpy import math from bpy.types import Context, Operator -from ..core.register import register_wrap from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes -from ..functions.translations import t +from ..core.translations import t + -@register_wrap class AvatarToolKit_OT_ApplyTransforms(Operator): bl_idname = "avatar_toolkit.apply_transforms" bl_label = t("Tools.apply_transforms.label") @@ -37,7 +36,7 @@ class AvatarToolKit_OT_ApplyTransforms(Operator): self.report({'INFO'}, t("Tools.apply_transforms.success")) return {'FINISHED'} -@register_wrap + class AvatarToolKit_OT_ConnectBones(Operator): bl_idname = "avatar_toolkit.connect_bones" bl_label = t("Tools.connect_bones.label") @@ -90,7 +89,7 @@ class AvatarToolKit_OT_ConnectBones(Operator): layout = self.layout layout.prop(self, "min_distance") -@register_wrap + class AvatarToolKit_OT_DeleteBoneConstraints(Operator): bl_idname = "avatar_toolkit.delete_bone_constraints" bl_label = t("Tools.delete_bone_constraints.label") @@ -120,7 +119,7 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): self.report({'INFO'}, t("Tools.delete_bone_constraints.success").format(constraints_removed=constraints_removed)) return {'FINISHED'} -@register_wrap + class AvatarToolKit_OT_SeparateByMaterials(Operator): bl_idname = "avatar_toolkit.separate_by_materials" bl_label = t("Tools.separate_by_materials.label") @@ -140,7 +139,7 @@ class AvatarToolKit_OT_SeparateByMaterials(Operator): self.report({'INFO'}, t("Tools.separate_by_materials.success")) return {'FINISHED'} -@register_wrap + class AvatarToolKit_OT_SeparateByLooseParts(Operator): bl_idname = "avatar_toolkit.separate_by_loose_parts" bl_label = t("Tools.separate_by_loose_parts.label") diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py index efc109f..f8c1acd 100644 --- a/functions/armature_modifying.py +++ b/functions/armature_modifying.py @@ -1,13 +1,12 @@ import bpy -from ..core.register import register_wrap from bpy.types import Context, Mesh, Panel, Operator, Armature, EditBone -from ..functions.translations import t +from ..core.translations import t from ..core.common import get_selected_armature, get_all_meshes from ..core import common from ..core.dictionaries import bone_names from mathutils import Matrix -@register_wrap + class AvatarToolkit_OT_StartPoseMode(Operator): bl_idname = 'avatar_toolkit.start_pose_mode' bl_label = t("Quick_Access.start_pose_mode.label") @@ -33,7 +32,7 @@ class AvatarToolkit_OT_StartPoseMode(Operator): return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_StopPoseMode(Operator): bl_idname = 'avatar_toolkit.stop_pose_mode' bl_label = t("Quick_Access.stop_pose_mode.label") @@ -55,7 +54,7 @@ class AvatarToolkit_OT_StopPoseMode(Operator): return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' bl_label = t("Quick_Access.apply_pose_as_shapekey.label") @@ -98,7 +97,7 @@ class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): self.report({'INFO'}, t('Tools.apply_pose_as_rest.success')) return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_ApplyPoseAsRest(Operator): bl_idname = 'avatar_toolkit.apply_pose_as_rest' bl_label = t("Quick_Access.apply_pose_as_rest.label") @@ -117,7 +116,7 @@ class AvatarToolkit_OT_ApplyPoseAsRest(Operator): return {'CANCELLED'} return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): bl_idname = "avatar_toolkit.remove_zero_weight_bones" bl_label = t("Tools.remove_zero_weight_bones.label") @@ -218,7 +217,7 @@ class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): 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") @@ -267,7 +266,7 @@ class AvatarToolkit_OT_MergeBonesToActive(Operator): 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") @@ -342,7 +341,7 @@ class AvatarToolkit_OT_MergeBonesToParents(Operator): bpy.ops.object.mode_set(mode=prev_mode) return {'FINISHED'} -@register_wrap + class AvatarToolkit_OT_MergeArmatures(Operator): bl_idname = "avatar_toolkit.merge_armatures" bl_label = t("MergeArmature.merge_armatures.label") diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py index 4b925c1..48f5c40 100644 --- a/functions/atlas_materials.py +++ b/functions/atlas_materials.py @@ -5,10 +5,9 @@ import bpy import os from typing import List, Tuple, Optional from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap -from ..core.register import register_wrap from ..core.common import SceneMatClass, MaterialListBool from ..core.packer.rectangle_packer import MaterialImageList, BinPacker -from ..functions.translations import t +from ..core.translations import t class MaterialImageList: def __init__(self): @@ -134,7 +133,7 @@ def prep_images_in_scene(context: Context) -> list[MaterialImageList]: -@register_wrap + class AvatarToolKit_OT_AtlasMaterials(Operator): bl_idname = "avatar_toolkit.atlas_materials" diff --git a/functions/combine_materials.py b/functions/combine_materials.py index 48ec63f..8e0bcaf 100644 --- a/functions/combine_materials.py +++ b/functions/combine_materials.py @@ -3,8 +3,7 @@ import re from typing import List, Tuple, Optional, Set, Dict from bpy.types import Material, Operator, Context, Object, NodeTree from ..core.common import clean_material_names, get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress -from ..core.register import register_wrap -from ..functions.translations import t +from ..core.translations import t def textures_match(tex1: bpy.types.ImageTexture, tex2: bpy.types.ImageTexture) -> bool: return tex1.image == tex2.image and tex1.extension == tex2.extension @@ -52,7 +51,7 @@ def get_base_name(name: str) -> str: mat_match = re.match(r"^(.*)\.\d{3}$", name) return mat_match.group(1) if mat_match else name -@register_wrap + class AvatarToolKit_OT_CombineMaterials(Operator): bl_idname = "avatar_toolkit.combine_materials" bl_label = t("Optimization.combine_materials.label") diff --git a/functions/digitigrade_legs.py b/functions/digitigrade_legs.py index 3b5dc56..7553e01 100644 --- a/functions/digitigrade_legs.py +++ b/functions/digitigrade_legs.py @@ -1,11 +1,10 @@ import bpy from ..core import common -from ..core import register_wrap -from .translations import t +from ..core.translations import t import re -@register_wrap + class AvatarToolKit_OT_CreateDigitigradeLegs(bpy.types.Operator): bl_idname = "avatar_toolkit.create_digitigrade_legs" bl_label = t('Tools.create_digitigrade_legs.label') diff --git a/functions/import_anything.py b/functions/import_anything.py index 0e0fd49..86a5c3a 100644 --- a/functions/import_anything.py +++ b/functions/import_anything.py @@ -1,16 +1,15 @@ import bpy from bpy.types import Operator from bpy_extras.io_utils import ImportHelper -from ..core.register import register_wrap -from ..core.importer import imports, import_types +from ..core.importers.importer import imports, import_types from ..core.common import remove_default_objects -from ..functions.translations import t +from ..core.translations import t import pathlib import os VRM_IMPORTER_URL = "https://github.com/saturday06/VRM_Addon_for_Blender" -@register_wrap + class AvatarToolKit_OT_ImportAnyModel(Operator, ImportHelper): bl_idname = 'avatar_toolkit.import_any_model' bl_label = t('Tools.import_any_model.label') @@ -67,7 +66,7 @@ class AvatarToolKit_OT_ImportAnyModel(Operator, ImportHelper): self.report({'INFO'}, t('Quick_Access.import_success')) return {'FINISHED'} -@register_wrap + class VRMImporterPopup(Operator): bl_idname = "wm.vrm_importer_popup" bl_label = "VRM Importer Not Installed" @@ -87,7 +86,7 @@ class VRMImporterPopup(Operator): #TODO: This needs to be done with our own MMD importer. """ #stolen from cats. Oh wait I made this code riiiiiiight - @989onan -@register_wrap + class ImportMMDAnimation(bpy.types.Operator, ImportHelper): bl_idname = 'avatar_toolkit.import_mmd_animation' bl_label = t('Importer.mmd_anim_importer.label') diff --git a/functions/mesh_tools.py b/functions/mesh_tools.py index e814932..d3c917f 100644 --- a/functions/mesh_tools.py +++ b/functions/mesh_tools.py @@ -3,10 +3,9 @@ import bpy from typing import List, Optional, Set from bpy.types import Operator, Context, Object from ..core.common import fix_uv_coordinates, get_selected_armature, get_all_meshes, is_valid_armature, apply_shapekey_to_basis, has_shapekeys, select_current_armature, init_progress, update_progress, finish_progress -from ..functions.translations import t -from ..core.register import register_wrap +from ..core.translations import t + -@register_wrap class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operator): tolerance: bpy.props.FloatProperty(name=t("Tools.remove_unused_shapekeys.tolerance.label"), default=0.001, description=t("Tools.remove_unused_shapekeys.tolerance.desc")) bl_idname = "avatar_toolkit.remove_unused_shapekeys" @@ -56,7 +55,7 @@ class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operator): continue ob.shape_key_remove(ob.data.shape_keys.key_blocks[kb_name]) -@register_wrap + class AvatarToolkit_OT_ApplyShapeKey(bpy.types.Operator): bl_idname = "avatar_toolkit.apply_shape_key" bl_label = t("Tools.apply_shape_key.label") @@ -79,7 +78,7 @@ class AvatarToolkit_OT_ApplyShapeKey(bpy.types.Operator): self.report({'ERROR'}, t("Tools.apply_shape_key.error")) return {'FINISHED'} -@register_wrap + class AvatarToolKit_OT_JoinAllMeshes(Operator): bl_idname = "avatar_toolkit.join_all_meshes" bl_label = t("Optimization.join_all_meshes.label") @@ -150,7 +149,7 @@ class AvatarToolKit_OT_JoinAllMeshes(Operator): finish_progress(context) -@register_wrap + class AvatarToolKit_OT_JoinSelectedMeshes(Operator): bl_idname = "avatar_toolkit.join_selected_meshes" bl_label = t("Optimization.join_selected_meshes.label") diff --git a/functions/mmd_functions.py b/functions/mmd_functions.py index daaca13..76ba124 100644 --- a/functions/mmd_functions.py +++ b/functions/mmd_functions.py @@ -2,13 +2,12 @@ import bpy import numpy as np import re from bpy.types import Operator, Context, Material, ShaderNodeTexImage, ShaderNodeGroup, Object -from ..core.register import register_wrap -from ..functions.translations import t +from ..core.translations import t from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress from ..functions.additional_tools import AvatarToolKit_OT_ConnectBones, AvatarToolKit_OT_DeleteBoneConstraints from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToParents -@register_wrap + class AvatarToolKit_OT_CleanupMesh(Operator): bl_idname = "avatar_toolkit.cleanup_mesh" bl_label = t("MMDOptions.cleanup_mesh.label") @@ -61,7 +60,7 @@ class AvatarToolKit_OT_CleanupMesh(Operator): if key.name != 'Basis' and all(abs(key.data[i].co[j] - obj.data.shape_keys.reference_key.data[i].co[j]) < 0.0001 for i in range(len(key.data)) for j in range(3)): obj.shape_key_remove(key) -@register_wrap + class AvatarToolKit_OT_OptimizeWeights(Operator): bl_idname = "avatar_toolkit.optimize_weights" bl_label = t("MMDOptions.optimize_weights.label") @@ -108,7 +107,7 @@ class AvatarToolKit_OT_OptimizeWeights(Operator): for g in sorted_groups[self.max_weights:]: obj.vertex_groups[g.group].remove([v.index]) -@register_wrap + class AvatarToolKit_OT_OptimizeArmature(Operator): bl_idname = "avatar_toolkit.optimize_armature" bl_label = t("MMDOptions.optimize_armature.label") @@ -339,7 +338,7 @@ def fix_vrm_shader(material: Material): material.node_tree.links = [link for link in material.node_tree.links if not (link.from_node == node or link.to_node == node)] -@register_wrap + class AvatarToolKit_OT_ConvertMaterials(Operator): bl_idname = "avatar_toolkit.convert_materials" bl_label = t("MMDOptions.convert_materials.label") diff --git a/functions/remove_doubles_safely.py b/functions/remove_doubles_safely.py index ea635f7..3e3ed42 100644 --- a/functions/remove_doubles_safely.py +++ b/functions/remove_doubles_safely.py @@ -1,9 +1,8 @@ import bpy from typing import List, TypedDict, Any from bpy.types import Operator, Context, Object -from ..core.register import register_wrap from ..core.common import get_selected_armature, is_valid_armature, select_current_armature, get_all_meshes -from ..functions.translations import t +from ..core.translations import t class meshEntry(TypedDict): mesh: Object @@ -11,7 +10,7 @@ class meshEntry(TypedDict): vertices: int cur_vertex_pass: int -@register_wrap + class AvatarToolKit_OT_RemoveDoublesSafelyAdvanced(Operator): bl_idname = "avatar_toolkit.remove_doubles_safely_advanced" bl_label = t("Optimization.remove_doubles_safely_advanced.label") @@ -39,7 +38,7 @@ class AvatarToolKit_OT_RemoveDoublesSafelyAdvanced(Operator): return {'RUNNING_MODAL'} -@register_wrap + class AvatarToolKit_OT_RemoveDoublesSafely(Operator): bl_idname = "avatar_toolkit.remove_doubles_safely" bl_label = t("Optimization.remove_doubles_safely.label") diff --git a/functions/resonite_functions.py b/functions/resonite_functions.py index 1f32e2e..8033e7f 100644 --- a/functions/resonite_functions.py +++ b/functions/resonite_functions.py @@ -1,13 +1,12 @@ import bpy -from ..core.register import register_wrap from typing import List, Optional import re from bpy.types import Operator, Context, Object from ..core.dictionaries import bone_names from ..core.common import get_selected_armature, simplify_bonename, is_valid_armature -from ..functions.translations import t +from ..core.translations import t + -@register_wrap class AvatarToolKit_OT_ConvertToResonite(Operator): bl_idname = 'avatar_toolkit.convert_to_resonite' bl_label = t('Tools.convert_to_resonite.label') diff --git a/functions/rigify_functions.py b/functions/rigify_functions.py index 8ca932f..1a23b24 100644 --- a/functions/rigify_functions.py +++ b/functions/rigify_functions.py @@ -1,18 +1,9 @@ # This code is heavily based on the Rigify-Move-DEF by NyankoNyan (https://github.com/NyankoNyan/Rigify-Move-DEF), which is licensed under the MIT License. We just heavily improve the code and add some new features. - import bpy -from ..core.register import register_wrap from ..core.common import get_selected_armature, is_valid_armature -from ..functions.translations import t +from ..core.translations import t from bpy.types import Operator, Context -import bpy -from ..core.register import register_wrap -from ..core.common import get_selected_armature, is_valid_armature -from ..functions.translations import t -from bpy.types import Operator, Context - -@register_wrap class AvatarToolKit_OT_ConvertRigifyToUnity(Operator): bl_idname = "avatar_toolkit.convert_rigify_to_unity" bl_label = t("Tools.convert_rigify_to_unity.label") diff --git a/functions/uv_tools.py b/functions/uv_tools.py index 504fc84..9273d2f 100644 --- a/functions/uv_tools.py +++ b/functions/uv_tools.py @@ -4,15 +4,14 @@ 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 +from ..core.translations import t 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") diff --git a/functions/viseme.py b/functions/viseme.py index 7f20e73..6793997 100644 --- a/functions/viseme.py +++ b/functions/viseme.py @@ -1,11 +1,10 @@ import bpy from ..core import common -from ..core.register import register_wrap -from ..functions.translations import t +from ..core.translations import t from typing import List, Tuple from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress -@register_wrap + class AvatarToolKit_OT_AutoVisemeButton(bpy.types.Operator): bl_idname = 'avatar_toolkit.create_visemes' bl_label = t('AutoVisemeButton.label') diff --git a/ui/__init__.py b/ui/__init__.py index 7db3379..e69de29 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -1,15 +0,0 @@ -if "bpy" not in locals(): - import bpy - import glob - import os - from os.path import dirname, basename, isfile, join - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - exec("from . import "+module_name) - print("importing " +module_name) -else: - import importlib - modules = glob.glob(join(dirname(__file__), "*.py")) - for module_name in [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]: - print("reloading " +module_name) - exec("importlib.reload("+module_name+")") diff --git a/ui/atlas_materials.py b/ui/atlas_materials.py index 7d5a666..7ab0206 100644 --- a/ui/atlas_materials.py +++ b/ui/atlas_materials.py @@ -1,57 +1,51 @@ -from bpy.types import UIList, Panel, UILayout, Object, Context,Material, Operator +from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operator import bpy from math import sqrt -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.common import SceneMatClass, MaterialListBool, get_selected_armature from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials -from ..functions.translations import t +from ..core.translations import t -@register_wrap class AvatarToolKit_OT_SelectAllMaterials(Operator): bl_idname = 'avatar_toolkit.select_all_materials' bl_label = "Select All" bl_description = "Select all materials for atlas" def execute(self, context): - for item in context.scene.materials: - item.mat.include_in_atlas = True + for item in context.scene.avatar_toolkit.materials: + item.mat.avatar_toolkit.include_in_atlas = True return {'FINISHED'} -@register_wrap class AvatarToolKit_OT_SelectNoneMaterials(Operator): bl_idname = 'avatar_toolkit.select_none_materials' bl_label = "Select None" bl_description = "Deselect all materials" def execute(self, context): - for item in context.scene.materials: - item.mat.include_in_atlas = False + for item in context.scene.avatar_toolkit.materials: + item.mat.avatar_toolkit.include_in_atlas = False return {'FINISHED'} -@register_wrap class AvatarToolKit_OT_ExpandAllMaterials(Operator): bl_idname = 'avatar_toolkit.expand_all_materials' bl_label = "Expand All" bl_description = "Expand all material settings" def execute(self, context): - for item in context.scene.materials: - item.mat.material_expanded = True + for item in context.scene.avatar_toolkit.materials: + item.mat.avatar_toolkit.material_expanded = True return {'FINISHED'} -@register_wrap class AvatarToolKit_OT_CollapseAllMaterials(Operator): bl_idname = 'avatar_toolkit.collapse_all_materials' bl_label = "Collapse All" bl_description = "Collapse all material settings" def execute(self, context): - for item in context.scene.materials: - item.mat.material_expanded = False + for item in context.scene.avatar_toolkit.materials: + item.mat.avatar_toolkit.material_expanded = False return {'FINISHED'} -@register_wrap class AvatarToolKit_OT_ExpandSectionMaterials(Operator): bl_idname = 'avatar_toolkit.expand_section_materials' bl_label = "" @@ -62,23 +56,22 @@ class AvatarToolKit_OT_ExpandSectionMaterials(Operator): return True def execute(self, context: Context) -> set: - if not context.scene.texture_atlas_Has_Mat_List_Shown: - context.scene.materials.clear() + if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + context.scene.avatar_toolkit.materials.clear() newlist: list[Material] = [] - for obj in bpy.context.scene.objects: + for obj in context.scene.objects: if len(obj.material_slots)>0: for mat_slot in obj.material_slots: if mat_slot.material: if mat_slot.material not in newlist: newlist.append(mat_slot.material) - newitem: SceneMatClass = context.scene.materials.add() + newitem: SceneMatClass = context.scene.avatar_toolkit.materials.add() newitem.mat = mat_slot.material MaterialListBool.old_list[context.scene.name] = newlist else: - context.scene.texture_atlas_Has_Mat_List_Shown = False + context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False return {'FINISHED'} -@register_wrap class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): bl_label = t("TextureAtlas.material_list_label") bl_idname = "Material_UL_avatar_toolkit_texture_atlas_mat_list_mat" @@ -93,38 +86,35 @@ class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT') row.operator("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN') row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT') - row.prop(context.scene, "material_search_filter", text="", icon='VIEWZOOM') + row.prop(context.scene.avatar_toolkit, "material_search_filter", text="", icon='VIEWZOOM') box = layout.box() row = box.row() row.label(text=f"Estimated Atlas Size: {self.calculate_atlas_size(context)}px") def draw_item(self, context: Context, layout: UILayout, data: Object, item: SceneMatClass, icon, active_data, active_propname, index): - if context.scene.texture_atlas_Has_Mat_List_Shown: - if context.scene.material_search_filter and context.scene.material_search_filter.lower() not in item.mat.name.lower(): + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: + if context.scene.avatar_toolkit.material_search_filter and context.scene.avatar_toolkit.material_search_filter.lower() not in item.mat.name.lower(): return row = layout.row() - # Add a clear checkbox for material selection - row.prop(item.mat, "include_in_atlas", text="", icon='CHECKBOX_HLT' if item.mat.include_in_atlas else 'CHECKBOX_DEHLT') + row.prop(item.mat.avatar_toolkit, "include_in_atlas", text="", icon='CHECKBOX_HLT' if item.mat.avatar_toolkit.include_in_atlas else 'CHECKBOX_DEHLT') - # Material name and expansion toggle - row.prop(item.mat, "material_expanded", + row.prop(item.mat.avatar_toolkit, "material_expanded", text=item.mat.name, - icon='DOWNARROW_HLT' if item.mat.material_expanded else 'RIGHTARROW', + icon='DOWNARROW_HLT' if item.mat.avatar_toolkit.material_expanded else 'RIGHTARROW', emboss=False) - # Show texture settings if expanded - if item.mat.material_expanded and item.mat.include_in_atlas: + if item.mat.avatar_toolkit.material_expanded and item.mat.avatar_toolkit.include_in_atlas: box = layout.box() col = box.column(align=True) - self.draw_texture_row(col, item.mat, "texture_atlas_albedo", "IMAGE_RGB") - self.draw_texture_row(col, item.mat, "texture_atlas_normal", "NORMALS_FACE") - self.draw_texture_row(col, item.mat, "texture_atlas_emission", "LIGHT") - self.draw_texture_row(col, item.mat, "texture_atlas_ambient_occlusion", "SHADING_SOLID") - self.draw_texture_row(col, item.mat, "texture_atlas_height", "IMAGE_ZDEPTH") - self.draw_texture_row(col, item.mat, "texture_atlas_roughness", "MATERIAL") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_albedo", "IMAGE_RGB") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_normal", "NORMALS_FACE") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_emission", "LIGHT") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_ambient_occlusion", "SHADING_SOLID") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_height", "IMAGE_ZDEPTH") + self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_roughness", "MATERIAL") col.separator(factor=0.5) @@ -136,21 +126,15 @@ class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): else: row.label(text="", icon='X') - def is_material_ready(self, material): - return bool(material.texture_atlas_albedo or - material.texture_atlas_normal or - material.texture_atlas_emission) - def calculate_atlas_size(self, context): total_size = 0 - for mat in context.scene.materials: - if mat.mat.include_in_atlas: - if mat.mat.texture_atlas_albedo: - img = bpy.data.images[mat.mat.texture_atlas_albedo] + for mat in context.scene.avatar_toolkit.materials: + if mat.mat.avatar_toolkit.include_in_atlas: + if mat.mat.avatar_toolkit.texture_atlas_albedo: + img = bpy.data.images[mat.mat.avatar_toolkit.texture_atlas_albedo] total_size += img.size[0] * img.size[1] return f"{int(sqrt(total_size))}x{int(sqrt(total_size))}" -@register_wrap class AvatarToolKit_PT_TextureAtlasPanel(Panel): bl_label = t("TextureAtlas.label") bl_idname = "OBJECT_PT_avatar_toolkit_texture_atlas" @@ -170,18 +154,18 @@ class AvatarToolKit_PT_TextureAtlasPanel(Panel): box = layout.box() row = box.row() - direction_icon = 'RIGHTARROW' if not context.scene.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT' + direction_icon = 'RIGHTARROW' if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT' row.operator(AvatarToolKit_OT_ExpandSectionMaterials.bl_idname, - text=(t("TextureAtlas.reload_list") if not context.scene.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")), + text=(t("TextureAtlas.reload_list") if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")), icon=direction_icon) - if context.scene.texture_atlas_Has_Mat_List_Shown: + if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: row = box.row() row.template_list(AvatarToolKit_UL_MaterialTextureAtlasProperties.bl_idname, 'material_list', - context.scene, + context.scene.avatar_toolkit, 'materials', - context.scene, + context.scene.avatar_toolkit, 'texture_atlas_material_index', rows=12, type='DEFAULT') @@ -195,3 +179,4 @@ class AvatarToolKit_PT_TextureAtlasPanel(Panel): icon='NODE_TEXTURE') else: layout.label(text=t("Tools.select_armature"), icon='ERROR') + diff --git a/ui/credits_support.py b/ui/credits_support.py index 90c9f61..e650d9a 100644 --- a/ui/credits_support.py +++ b/ui/credits_support.py @@ -1,10 +1,8 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..functions.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t from ..core.common import open_web_after_delay_multi_threaded -@register_wrap class AvatarToolkit_PT_CreditsSupport(bpy.types.Panel): bl_label = t("CreditsSupport.label") bl_idname = "OBJECT_PT_avatar_toolkit_credits_support" diff --git a/ui/panel.py b/ui/main_panel.py similarity index 84% rename from ui/panel.py rename to ui/main_panel.py index 66e9496..6463bed 100644 --- a/ui/panel.py +++ b/ui/main_panel.py @@ -1,6 +1,7 @@ import bpy -from ..core.register import register_wrap -from ..functions.translations import t +from ..core.translations import t + +CATEGORY_NAME = "Avatar Toolkit" def draw_title(self: bpy.types.Panel): layout = self.layout @@ -8,9 +9,6 @@ def draw_title(self: bpy.types.Panel): layout.label(text=t("AvatarToolkit.desc2")) layout.label(text=t("AvatarToolkit.desc3")) -CATEGORY_NAME = "Avatar Toolkit" - -@register_wrap class AvatarToolKit_PT_AvatarToolkitPanel(bpy.types.Panel): bl_label = t("AvatarToolkit.label") bl_idname = "OBJECT_PT_avatar_toolkit" @@ -21,4 +19,3 @@ class AvatarToolKit_PT_AvatarToolkitPanel(bpy.types.Panel): def draw(self: bpy.types.Panel, context: bpy.types.Context): draw_title(self) - diff --git a/ui/merge_armatures.py b/ui/merge_armatures.py index 8f00458..494c5a9 100644 --- a/ui/merge_armatures.py +++ b/ui/merge_armatures.py @@ -1,13 +1,10 @@ - import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from bpy.types import Panel, Context from ..core.common import get_selected_armature -from ..functions.translations import t +from ..core.translations import t from ..functions.armature_modifying import AvatarToolkit_OT_MergeArmatures -@register_wrap class AvatarToolkit_PT_MergeArmaturesPanel(Panel): bl_label = t("MergeArmatures.label") bl_idname = "OBJECT_PT_avatar_toolkit_merge_armatures" @@ -29,14 +26,14 @@ class AvatarToolkit_PT_MergeArmaturesPanel(Panel): box = layout.box() col = box.column(align=True) - col.prop(context.scene, property="selected_armature", text=t("MergeArmatures.target_armature.label"), icon="ARMATURE_DATA") - col.prop(context.scene, property="merge_armature_source", icon="OUTLINER_OB_ARMATURE") + col.prop(context.scene.avatar_toolkit, "selected_armature", text=t("MergeArmatures.target_armature.label"), icon="ARMATURE_DATA") + col.prop(context.scene.avatar_toolkit, "merge_armature_source", icon="OUTLINER_OB_ARMATURE") layout.separator(factor=0.5) col = layout.column(align=True) - col.prop(context.scene, property="merge_armature_align_bones", icon="BONE_DATA") - col.prop(context.scene, property="merge_armature_apply_transforms", icon="OBJECT_ORIGIN") + col.prop(context.scene.avatar_toolkit, "merge_armature_align_bones", icon="BONE_DATA") + col.prop(context.scene.avatar_toolkit, "merge_armature_apply_transforms", icon="OBJECT_ORIGIN") layout.separator(factor=1.0) diff --git a/ui/mmd_options.py b/ui/mmd_options.py index fe2c04e..55b1839 100644 --- a/ui/mmd_options.py +++ b/ui/mmd_options.py @@ -1,13 +1,11 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..functions.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t from ..functions.mmd_functions import * from ..functions.mesh_tools import AvatarToolKit_OT_JoinAllMeshes from ..functions.combine_materials import AvatarToolKit_OT_CombineMaterials from ..functions.additional_tools import AvatarToolKit_OT_ApplyTransforms -@register_wrap class AvatarToolkit_PT_MMDOptionsPanel(bpy.types.Panel): bl_label = t("MMDOptions.label") bl_idname = "OBJECT_PT_avatar_toolkit_mmd_options" @@ -48,4 +46,3 @@ class AvatarToolkit_PT_MMDOptionsPanel(bpy.types.Panel): row = layout.row() row.scale_y = 1.2 row.operator(AvatarToolKit_OT_ConvertMaterials.bl_idname, icon='SHADING_TEXTURE') - diff --git a/ui/optimization.py b/ui/optimization.py index a84abdb..0c2877e 100644 --- a/ui/optimization.py +++ b/ui/optimization.py @@ -1,13 +1,11 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..functions.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t from ..functions.remove_doubles_safely import AvatarToolKit_OT_RemoveDoublesSafely, AvatarToolKit_OT_RemoveDoublesSafelyAdvanced from ..core.common import get_selected_armature from ..functions.mesh_tools import AvatarToolKit_OT_JoinAllMeshes, AvatarToolKit_OT_JoinSelectedMeshes from ..functions.combine_materials import AvatarToolKit_OT_CombineMaterials -@register_wrap class AvatarToolkit_PT_OptimizationPanel(bpy.types.Panel): bl_label = t("Optimization.label") bl_idname = "OBJECT_PT_avatar_toolkit_optimization" @@ -17,7 +15,7 @@ class AvatarToolkit_PT_OptimizationPanel(bpy.types.Panel): bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order = 2 - def draw(self: bpy.types.Panel, context: bpy.types.Context): + def draw(self, context: bpy.types.Context): layout = self.layout armature = get_selected_armature(context) @@ -46,5 +44,3 @@ class AvatarToolkit_PT_OptimizationPanel(bpy.types.Panel): else: layout.label(text=t("Optimization.select_armature"), icon='ERROR') - - diff --git a/ui/quick_access.py b/ui/quick_access.py index 26a33ef..cd87e32 100644 --- a/ui/quick_access.py +++ b/ui/quick_access.py @@ -1,17 +1,15 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.export_resonite import AvatarToolKit_OT_ExportResonite +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.exporters.export_resonite import AvatarToolKit_OT_ExportResonite from bpy.types import Context, Mesh, Panel, Operator -from ..functions.translations import t - -from ..core.import_pmx import import_pmx -from ..core.import_pmd import import_pmd +from ..core.translations import t +from ..core.common import get_selected_armature from ..functions.import_anything import AvatarToolKit_OT_ImportAnyModel -from ..functions.armature_modifying import AvatarToolkit_OT_StartPoseMode, AvatarToolkit_OT_StopPoseMode, AvatarToolkit_OT_ApplyPoseAsRest, AvatarToolkit_OT_ApplyPoseAsShapekey -from ..core.common import get_selected_armature, set_selected_armature, get_all_meshes +from ..functions.armature_modifying import (AvatarToolkit_OT_StartPoseMode, + AvatarToolkit_OT_StopPoseMode, + AvatarToolkit_OT_ApplyPoseAsRest, + AvatarToolkit_OT_ApplyPoseAsShapekey) -@register_wrap class AvatarToolkitQuickAccessPanel(Panel): bl_label = t("Quick_Access.label") bl_idname = "OBJECT_PT_avatar_toolkit_quick_access" @@ -28,7 +26,7 @@ class AvatarToolkitQuickAccessPanel(Panel): layout.separator(factor=1.0) layout.label(text=t("Quick_Access.select_armature"), icon='ARMATURE_DATA') - layout.prop(context.scene, "selected_armature", text="") + layout.prop(context.scene.avatar_toolkit, "selected_armature", text="") layout.separator(factor=1.0) @@ -58,8 +56,6 @@ class AvatarToolkitQuickAccessPanel(Panel): row.scale_y = 1.2 row.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, text=t("Quick_Access.start_pose_mode.label"), icon='POSE_HLT') - -@register_wrap class AVATAR_TOOLKIT_OT_ExportMenu(bpy.types.Operator): bl_idname = "avatar_toolkit.export_menu" bl_label = t("Quick_Access.export_menu.label") @@ -82,7 +78,6 @@ class AVATAR_TOOLKIT_OT_ExportMenu(bpy.types.Operator): layout.operator(AvatarToolKit_OT_ExportResonite.bl_idname, text=t("Quick_Access.select_export_resonite.label"), icon='SCENE_DATA') layout.operator(AVATAR_TOOLKIT_OT_ExportFbx.bl_idname, text=t("Quick_Access.export_fbx.label"), icon='OBJECT_DATA') -@register_wrap class AVATAR_TOOLKIT_OT_ExportFbx(bpy.types.Operator): bl_idname = 'avatar_toolkit.export_fbx' bl_label = t("Quick_Access.export_fbx.label") diff --git a/ui/settings.py b/ui/settings.py index 997d933..fbb6c6c 100644 --- a/ui/settings.py +++ b/ui/settings.py @@ -1,9 +1,7 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..functions.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t -@register_wrap class AvatarToolkitSettingsPanel(bpy.types.Panel): bl_label = t("Settings.label") bl_idname = "OBJECT_PT_avatar_toolkit_settings" @@ -17,9 +15,8 @@ class AvatarToolkitSettingsPanel(bpy.types.Panel): layout = self.layout layout.label(text=t("Settings.language.label")) - layout.prop(context.scene, "avatar_toolkit_language", text="", icon='WORLD') + layout.prop(context.scene.avatar_toolkit, "language", text="", icon='WORLD') -@register_wrap class AVATAR_TOOLKIT_OT_translation_restart_popup(bpy.types.Operator): bl_idname = "avatar_toolkit.translation_restart_popup" bl_label = t("Settings.translation_restart_popup.label") @@ -27,9 +24,9 @@ class AVATAR_TOOLKIT_OT_translation_restart_popup(bpy.types.Operator): bl_options = {'INTERNAL'} def execute(self, context): - if context.scene.avatar_toolkit_language_changed: + if context.scene.avatar_toolkit.language_changed: bpy.ops.script.reload() - context.scene.avatar_toolkit_language_changed = False + context.scene.avatar_toolkit.language_changed = False return {'FINISHED'} def invoke(self, context, event): @@ -39,4 +36,3 @@ class AVATAR_TOOLKIT_OT_translation_restart_popup(bpy.types.Operator): layout = self.layout layout.label(text=t("Settings.translation_restart_popup.message1"), icon='INFO') layout.label(text=t("Settings.translation_restart_popup.message2"), icon='FILE_REFRESH') - diff --git a/ui/tools.py b/ui/tools.py index df218c7..d07a380 100644 --- a/ui/tools.py +++ b/ui/tools.py @@ -1,17 +1,20 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from bpy.types import Context from ..functions.digitigrade_legs import AvatarToolKit_OT_CreateDigitigradeLegs from ..functions.resonite_functions import AvatarToolKit_OT_ConvertToResonite -from ..functions.translations import t +from ..core.translations import t from ..core.common import get_selected_armature from ..functions.mesh_tools import AvatarToolkit_OT_RemoveUnusedShapekeys -from ..functions.additional_tools import AvatarToolKit_OT_ApplyTransforms, AvatarToolKit_OT_ConnectBones, AvatarToolKit_OT_DeleteBoneConstraints, AvatarToolKit_OT_SeparateByMaterials, AvatarToolKit_OT_SeparateByLooseParts -from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToActive, AvatarToolkit_OT_MergeBonesToParents -from ..functions.rigify_functions import AvatarToolKit_OT_ConvertRigifyToUnity +from ..functions.additional_tools import (AvatarToolKit_OT_ApplyTransforms, + AvatarToolKit_OT_ConnectBones, + AvatarToolKit_OT_DeleteBoneConstraints, + AvatarToolKit_OT_SeparateByMaterials, + AvatarToolKit_OT_SeparateByLooseParts) +from ..functions.armature_modifying import (AvatarToolkit_OT_RemoveZeroWeightBones, + AvatarToolkit_OT_MergeBonesToActive, + AvatarToolkit_OT_MergeBonesToParents) -@register_wrap class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): bl_label = t("Tools.label") bl_idname = "OBJECT_PT_avatar_toolkit_tools" @@ -33,10 +36,6 @@ class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): row.scale_y = 1.5 row.operator(AvatarToolKit_OT_ConvertToResonite.bl_idname, text=t("Tools.convert_to_resonite.label"), icon='SCENE_DATA') - row = layout.row(align=True) - row.scale_y = 1.5 - row.operator(AvatarToolKit_OT_ConvertRigifyToUnity.bl_idname, text=t("Tools.convert_rigify_to_unity.label"), icon='ARMATURE_DATA') - layout.separator(factor=1.0) layout.label(text=t("Tools.separate_by.label"), icon='MESH_DATA') @@ -62,7 +61,7 @@ class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.delete_bone_constraints.label"), icon='CONSTRAINT_BONE') row = layout.row() - row.prop(context.scene, "merge_twist_bones") + row.prop(context.scene.avatar_toolkit, "merge_twist_bones") layout.separator(factor=1.0) @@ -74,3 +73,4 @@ class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): layout.separator(factor=1.0) else: layout.label(text=t("Tools.select_armature"), icon='ERROR') + diff --git a/ui/uv_panel.py b/ui/uv_panel.py index 8221e31..50e4c7b 100644 --- a/ui/uv_panel.py +++ b/ui/uv_panel.py @@ -1,9 +1,7 @@ import bpy -from ..core.register import register_wrap -from ..functions.translations import t -from .panel import draw_title +from ..core.translations import t +from .main_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" @@ -13,5 +11,4 @@ class UVTools_PT_MainPanel(bpy.types.Panel): def draw(self: bpy.types.Panel, context: bpy.types.Context): layout = self.layout - - draw_title(self) \ No newline at end of file + draw_title(self) diff --git a/ui/uv_tools.py b/ui/uv_tools.py index e4b5244..d05489e 100644 --- a/ui/uv_tools.py +++ b/ui/uv_tools.py @@ -1,12 +1,9 @@ - import bpy -from ..core.register import register_wrap -from ..functions.translations import t +from ..core.translations import t from ..functions.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget -from .panel import draw_title +from .main_panel import draw_title from .uv_panel import UVTools_PT_MainPanel -@register_wrap class UVTools_PT_Tools(bpy.types.Panel): bl_label = t("Tools.label") bl_idname = "OBJECT_PT_avatar_toolkit_uv_tools" @@ -18,6 +15,5 @@ class UVTools_PT_Tools(bpy.types.Panel): def draw(self, context: bpy.types.Context): layout = self.layout - 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.operator(AvatarToolkit_OT_AlignUVEdgesToTarget.bl_idname, text=t("avatar_toolkit.align_uv_edges_to_target.label"), icon='GP_MULTIFRAME_EDITING') diff --git a/ui/viseme.py b/ui/viseme.py index 8e0ef26..32e8e43 100644 --- a/ui/viseme.py +++ b/ui/viseme.py @@ -1,11 +1,9 @@ import bpy -from ..core.register import register_wrap -from .panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..functions.viseme import AvatarToolKit_OT_AutoVisemeButton -from ..functions.translations import t +from ..core.translations import t from ..core.common import get_selected_armature -@register_wrap class AvatarToolkitVisemePanel(bpy.types.Panel): bl_label = t("VisemePanel.label") bl_idname = "OBJECT_PT_avatar_toolkit_viseme" @@ -24,20 +22,20 @@ class AvatarToolkitVisemePanel(bpy.types.Panel): layout.separator(factor=0.5) - layout.prop(context.scene, "selected_mesh", text=t("VisemePanel.select_mesh"), icon='OUTLINER_OB_MESH') + layout.prop(context.scene.avatar_toolkit, "selected_mesh", text=t("VisemePanel.select_mesh"), icon='OUTLINER_OB_MESH') - mesh = bpy.data.objects.get(context.scene.selected_mesh) + mesh = bpy.data.objects.get(context.scene.avatar_toolkit.selected_mesh) if mesh and mesh.type == 'MESH': if mesh.data.shape_keys: box = layout.box() col = box.column(align=True) - col.prop_search(context.scene, "avatar_toolkit_mouth_a", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_a.label'), icon='SHAPEKEY_DATA') - col.prop_search(context.scene, "avatar_toolkit_mouth_o", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_o.label'), icon='SHAPEKEY_DATA') - col.prop_search(context.scene, "avatar_toolkit_mouth_ch", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_ch.label'), icon='SHAPEKEY_DATA') + col.prop_search(context.scene.avatar_toolkit, "mouth_a", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_a.label'), icon='SHAPEKEY_DATA') + col.prop_search(context.scene.avatar_toolkit, "mouth_o", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_o.label'), icon='SHAPEKEY_DATA') + col.prop_search(context.scene.avatar_toolkit, "mouth_ch", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_ch.label'), icon='SHAPEKEY_DATA') layout.separator(factor=0.5) - layout.prop(context.scene, 'avatar_toolkit_shape_intensity', text=t('VisemePanel.shape_intensity'), icon='FORCE_LENNARDJONES') + layout.prop(context.scene.avatar_toolkit, 'shape_intensity', text=t('VisemePanel.shape_intensity'), icon='FORCE_LENNARDJONES') layout.separator(factor=1.0) @@ -53,4 +51,3 @@ class AvatarToolkitVisemePanel(bpy.types.Panel): layout.separator(factor=1.0) layout.label(text=t('VisemePanel.info.selectMesh'), icon='HELP') - From 7e584e36488f96b07ef100fdaff8da788bd06641 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Tue, 3 Dec 2024 01:26:10 +0000 Subject: [PATCH 02/19] Fixes This fixes is to get everything working on the new auto load and properties system. Also some other small fixes. --- core/common.py | 17 +- core/properties.py | 17 +- core/translations.py | 6 +- core/updater.py | 2 +- functions/armature_modifying.py | 9 ++ functions/atlas_materials.py | 270 +++++++++++++++++--------------- functions/mesh_tools.py | 4 +- functions/mmd_functions.py | 1 - functions/translations.py | 97 ------------ functions/viseme.py | 10 +- 10 files changed, 189 insertions(+), 244 deletions(-) delete mode 100644 functions/translations.py diff --git a/core/common.py b/core/common.py index b1f3644..2b4c702 100644 --- a/core/common.py +++ b/core/common.py @@ -161,15 +161,15 @@ def get_armatures(self, context: Context) -> List[Tuple[str, str, str]]: return armatures def get_armatures_that_are_not_selected(self, context: Context) -> List[Tuple[str, str, str]]: - armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if ((obj.type == 'ARMATURE') and (obj.name != context.scene.selected_armature))] + armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if ((obj.type == 'ARMATURE') and (obj.name != context.scene.avatar_toolkit.selected_armature))] if not armatures: return [('NONE', 'No Other Armature', '')] return armatures def get_selected_armature(context: Context) -> Optional[Object]: try: - if hasattr(context.scene, 'selected_armature'): - armature_name = context.scene.selected_armature + if hasattr(context.scene, 'avatar_toolkit'): + armature_name = context.scene.avatar_toolkit.selected_armature if isinstance(armature_name, bytes): try: armature_name = armature_name.decode('utf-8') @@ -209,9 +209,8 @@ def get_merge_armature_source(context: Context) -> Optional[Object]: pass return None - def set_selected_armature(context: Context, armature: Optional[Object]) -> None: - context.scene.selected_armature = armature.name if armature else "" + context.scene.avatar_toolkit.selected_armature = armature.name if armature else "" def is_valid_armature(armature: Object) -> bool: if not armature or armature.type != 'ARMATURE': @@ -451,12 +450,12 @@ def remove_default_objects(): def init_progress(context, steps): context.window_manager.progress_begin(0, 100) - context.scene.avatar_toolkit_progress_steps = steps - context.scene.avatar_toolkit_progress_current = 0 + context.scene.avatar_toolkit.progress_steps = steps + context.scene.avatar_toolkit.progress_current = 0 def update_progress(self, context, message): - context.scene.avatar_toolkit_progress_current += 1 - progress = (context.scene.avatar_toolkit_progress_current / context.scene.avatar_toolkit_progress_steps) * 100 + context.scene.avatar_toolkit.progress_current += 1 + progress = (context.scene.avatar_toolkit.progress_current / context.scene.avatar_toolkit.progress_steps) * 100 context.window_manager.progress_update(progress) context.area.header_text_set(message) self.report({'INFO'}, message) diff --git a/core/properties.py b/core/properties.py index 5d5c755..d920160 100644 --- a/core/properties.py +++ b/core/properties.py @@ -99,6 +99,13 @@ class AvatarToolkitSceneProperties(PropertyGroup): set=MaterialListBool.set_bool ) + avatar_toolkit_updater_version_list: EnumProperty( + items=get_version_list, + name="Version List", + description="List of available versions" + ) + + class AvatarToolkitMaterialProperties(PropertyGroup): material_expanded: BoolProperty( name="Expand Material", @@ -113,11 +120,13 @@ class AvatarToolkitMaterialProperties(PropertyGroup): ) def get_texture_node_list(self, context): - if self.use_nodes: + # Access the material through the property group's id_data + material = self.id_data + if material and material.use_nodes: nodes = [(i.image.name if i.image else i.name+"_image", - i.image.name if i.image else "node with no image...", - i.image.name if i.image else i.name, index+1) - for index, i in enumerate(self.node_tree.nodes) + i.image.name if i.image else "node with no image...", + i.image.name if i.image else i.name, index+1) + for index, i in enumerate(material.node_tree.nodes) if i.bl_idname == "ShaderNodeTexImage"] if not nodes: nodes = [("Error", "No images found", "Error", 0)] diff --git a/core/translations.py b/core/translations.py index c123ad7..f047964 100644 --- a/core/translations.py +++ b/core/translations.py @@ -84,11 +84,11 @@ def get_languages_list(self, context) -> List[Tuple[str, str, str]]: return [(str(i), get_language_display_name(lang), f"Use {lang} language") for i, lang in enumerate(languages)] def update_language(self, context): - print(f"Updating language to: {self.avatar_toolkit_language}") # Debug print - save_preference("language", int(self.avatar_toolkit_language)) + print(f"Updating language to: {self.language}") # Debug print + save_preference("language", int(self.language)) load_translations() # Set a flag to indicate that a language change has occurred - context.scene.avatar_toolkit_language_changed = True + context.scene.avatar_toolkit.language_changed = True # Show popup after language change bpy.ops.avatar_toolkit.translation_restart_popup('INVOKE_DEFAULT') diff --git a/core/updater.py b/core/updater.py index 53eb248..4ccbd23 100644 --- a/core/updater.py +++ b/core/updater.py @@ -287,7 +287,7 @@ def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) - col.separator() row = col.row(align=True) - row.prop(context.scene, 'avatar_toolkit_updater_version_list', text='') + row.prop(context.scene.avatar_toolkit, 'avatar_toolkit_updater_version_list', text='') row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToSelectedButton.label')) col.separator() diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py index f8c1acd..7edaf97 100644 --- a/functions/armature_modifying.py +++ b/functions/armature_modifying.py @@ -156,6 +156,15 @@ class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): 'matrix': bone.matrix.copy(), 'parent': bone.parent.name if bone.parent else None } + # Add end bones to transforms + if bone.name.endswith('_end'): + initial_transforms[bone.name] = { + 'head': bone.head.copy(), + 'tail': bone.tail.copy(), + 'roll': bone.roll, + 'matrix': bone.matrix.copy(), + 'parent': bone.parent.name if bone.parent else None + } # Get weighted bones armature.select_set(True) diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py index 48f5c40..86e2c66 100644 --- a/functions/atlas_materials.py +++ b/functions/atlas_materials.py @@ -23,24 +23,24 @@ class MaterialImageList: self.h: int = 0 self.fit = None -def scale_images_to_largest(images: list[Image]) -> set: - x: int = 0 - y: int = 0 +def scale_images_to_largest(images: list[Image]) -> tuple[int, int]: + try: + valid_images = [] + for img in images: + if img and hasattr(img, 'name'): + image_data = bpy.data.images.get(img.name) + if image_data and image_data.has_data: + valid_images.append(image_data) - # Filter out None or invalid images - valid_images = [img for img in images if img and img.has_data] - - if not valid_images: - return 0, 0 + if not valid_images: + return 1, 1 + + max_width = max(img.size[0] for img in valid_images) + max_height = max(img.size[1] for img in valid_images) - for image in valid_images: - x = max(x, image.size[0]) - y = max(y, image.size[1]) - - for image in valid_images: - image.scale(width=int(x), height=int(y)) - - return x, y + return max_width, max_height + except: + return 1, 1 def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> list[Image]: list_of_images: list[Image] = [] @@ -62,56 +62,75 @@ def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: if obj.type == 'MESH': for mat_slot in obj.material_slots: # Only process materials that are selected for atlas - if mat_slot.material and mat_slot.material.include_in_atlas is True: + if mat_slot.material and mat_slot.material.avatar_toolkit.include_in_atlas: new_mat_image_item = MaterialImageList() - try: - new_mat_image_item.albedo = bpy.data.images[mat_slot.material.texture_atlas_albedo] - except Exception: - name = mat_slot.material.name + "_albedo_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.albedo = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.albedo.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) - try: - new_mat_image_item.normal = bpy.data.images[mat_slot.material.texture_atlas_normal] - except Exception: - name = mat_slot.material.name + "_normal_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.normal = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.normal.pixels[:] = numpy.tile(numpy.array([0.5,0.5,1.0,1.0]), 32*32) - try: - new_mat_image_item.emission = bpy.data.images[mat_slot.material.texture_atlas_emission] - except Exception: - name = mat_slot.material.name + "_emission_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.emission = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.emission.pixels[:] = numpy.tile(numpy.array([0.0,0.0,0.0,1.0]), 32*32) - try: - new_mat_image_item.ambient_occlusion = bpy.data.images[mat_slot.material.texture_atlas_ambient_occlusion] - except Exception: - name = mat_slot.material.name + "_ambient_occlusion_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.ambient_occlusion = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.ambient_occlusion.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,1.0]), 32*32) - try: - new_mat_image_item.height = bpy.data.images[mat_slot.material.texture_atlas_height] - except Exception: - name = mat_slot.material.name + "_height_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.height = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.height.pixels[:] = numpy.tile(numpy.array([0.5,0.5,0.5,1.0]), 32*32) - try: - new_mat_image_item.roughness = bpy.data.images[mat_slot.material.texture_atlas_roughness] - except Exception: - name = mat_slot.material.name + "_roughness_replacement" - if name in bpy.data.images: - bpy.data.images.remove(image=bpy.data.images[name], do_unlink=True) - new_mat_image_item.roughness = bpy.data.images.new(name=name, width=32, height=32, alpha=True) - new_mat_image_item.roughness.pixels[:] = numpy.tile(numpy.array([1.0,1.0,1.0,0.0]), 32*32) + + def get_or_create_image(image_name, replacement_name, default_color): + if image_name and image_name in bpy.data.images: + image = bpy.data.images[image_name] + else: + # Create a new image with the replacement name if it doesn't exist + if replacement_name in bpy.data.images: + image = bpy.data.images[replacement_name] + else: + image = bpy.data.images.new( + name=replacement_name, width=32, height=32, alpha=True + ) + # Set the pixel data to the default color + num_pixels = 32 * 32 + pixel_data = numpy.tile(numpy.array(default_color), num_pixels) + image.pixels[:] = pixel_data + # Set use_fake_user to True to prevent Blender from removing the image + image.use_fake_user = True + return image + + # Albedo + albedo_name = getattr(mat_slot.material, 'texture_atlas_albedo', '') + new_mat_image_item.albedo = get_or_create_image( + albedo_name, + mat_slot.material.name + "_albedo_replacement", + [0.0, 0.0, 0.0, 1.0] + ) + + # Normal + normal_name = getattr(mat_slot.material, 'texture_atlas_normal', '') + new_mat_image_item.normal = get_or_create_image( + normal_name, + mat_slot.material.name + "_normal_replacement", + [0.5, 0.5, 1.0, 1.0] + ) + + # Emission + emission_name = getattr(mat_slot.material, 'texture_atlas_emission', '') + new_mat_image_item.emission = get_or_create_image( + emission_name, + mat_slot.material.name + "_emission_replacement", + [0.0, 0.0, 0.0, 1.0] + ) + + # Ambient Occlusion + ao_name = getattr(mat_slot.material, 'texture_atlas_ambient_occlusion', '') + new_mat_image_item.ambient_occlusion = get_or_create_image( + ao_name, + mat_slot.material.name + "_ambient_occlusion_replacement", + [1.0, 1.0, 1.0, 1.0] + ) + + # Height + height_name = getattr(mat_slot.material, 'texture_atlas_height', '') + new_mat_image_item.height = get_or_create_image( + height_name, + mat_slot.material.name + "_height_replacement", + [0.5, 0.5, 0.5, 1.0] + ) + + # Roughness + roughness_name = getattr(mat_slot.material, 'texture_atlas_roughness', '') + new_mat_image_item.roughness = get_or_create_image( + roughness_name, + mat_slot.material.name + "_roughness_replacement", + [1.0, 1.0, 1.0, 0.0] + ) new_mat_image_item.material = mat_slot.material new_mat_image_item.parent_mesh = obj @@ -120,6 +139,7 @@ def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: return material_image_list + def prep_images_in_scene(context: Context) -> list[MaterialImageList]: preped_images: list[MaterialImageList] = get_material_images_from_scene(context) for MaterialImageClass in preped_images: @@ -135,7 +155,6 @@ def prep_images_in_scene(context: Context) -> list[MaterialImageList]: class AvatarToolKit_OT_AtlasMaterials(Operator): - bl_idname = "avatar_toolkit.atlas_materials" bl_label = t("TextureAtlas.atlas_materials") bl_description = t("TextureAtlas.atlas_materials_desc") @@ -143,12 +162,13 @@ class AvatarToolKit_OT_AtlasMaterials(Operator): @classmethod def poll(cls, context: Context) -> bool: - return context.scene.texture_atlas_Has_Mat_List_Shown + return context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown def execute(self, context: Context) -> set: try: # Get only materials that are explicitly marked for inclusion - selected_materials = [m for m in prep_images_in_scene(context) if m.material and m.material.include_in_atlas is True] + selected_materials = [m for m in prep_images_in_scene(context) + if m.material and m.material.avatar_toolkit.include_in_atlas is True] if not selected_materials: self.report({'WARNING'}, t("TextureAtlas.no_materials_selected")) @@ -157,71 +177,75 @@ class AvatarToolKit_OT_AtlasMaterials(Operator): packer: BinPacker = BinPacker(selected_materials) mat_images = packer.fit() - size: list[int] = [max([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images]), - max([matimg.fit.h + matimg.albedo.size[1] for matimg in mat_images])] - print([matimg.fit.w + matimg.albedo.size[1] for matimg in mat_images]) + size: list[int] = [ + max([ + matimg.fit.w + matimg.albedo.size[0] + for matimg in mat_images + if matimg.albedo and matimg.albedo.has_data + ] or [1]), + max([ + matimg.fit.h + matimg.albedo.size[1] + for matimg in mat_images + if matimg.albedo and matimg.albedo.has_data + ] or [1]) + ] + print([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images if matimg.albedo and matimg.albedo.has_data]) atlased_mat: MaterialImageList = MaterialImageList() for mat in mat_images: - x: int = int(mat.fit.x) - y: int = int(mat.fit.y) - w: int = int(mat.albedo.size[0]) - h: int = int(mat.albedo.size[1]) - - for obj in bpy.data.objects: - if obj.type == 'MESH': - mesh: Mesh = obj.data - for layer in mesh.polygons: - if obj.material_slots[layer.material_index].material: - if obj.material_slots[layer.material_index].material == mat.material: - for loop_idx in layer.loop_indices: - layer_loops: MeshUVLoopLayer - for layer_loops in mesh.uv_layers: - uv_item: Float2AttributeValue = layer_loops.uv[loop_idx] - uv_item.vector.x = (uv_item.vector.x*(w/size[0]))+(x/size[0]) - uv_item.vector.y = (uv_item.vector.y*(h/size[1]))+(y/size[1]) - - for type in ["albedo","normal", "emission","ambient_occlusion","height", "roughness"]: - new_image_name: str= "Atlas_"+type+"_"+context.scene.name+"_"+Path(bpy.data.filepath).stem - - print("Processing "+type+" atlas image") - - if new_image_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[new_image_name]) - - canvas: Image = bpy.data.images.new(name=new_image_name, width=int(size[0]),height=int(size[1]), alpha=True) - c_w = canvas.size[0] - canvas_pixels: list[float] = list(canvas.pixels[:]) - for mat in mat_images: + if mat.albedo and mat.albedo.has_data: x: int = int(mat.fit.x) y: int = int(mat.fit.y) w: int = int(mat.albedo.size[0]) h: int = int(mat.albedo.size[1]) - image_var: Image = eval("mat."+type) - - image_pixels: list[float] = list(image_var.pixels[:]) - - print("writing image \""+image_var.name+"\" to canvas.") - print("x: \""+str(x)+"\" "+"y: \""+str(y)+"\" "+"w: \""+str(w)+"\" "+"h: \""+str(h)+"\" ") - for k in range(0,h): - for i in range(0, w): - for channel in range(0,4): - canvas_pixels[ - int((((k+y)*c_w) - + - (i+x))*4) - +int(channel) - ] = image_pixels[ - int(( - (k*w) - +i)*4) - +int(channel)] + for obj in bpy.data.objects: + if obj.type == 'MESH': + mesh: Mesh = obj.data + for layer in mesh.polygons: + if obj.material_slots[layer.material_index].material: + if obj.material_slots[layer.material_index].material == mat.material: + for loop_idx in layer.loop_indices: + layer_loops: MeshUVLoopLayer + for layer_loops in mesh.uv_layers: + uv_item: Float2AttributeValue = layer_loops.uv[loop_idx] + uv_item.vector.x = (uv_item.vector.x * (w / size[0])) + (x / size[0]) + uv_item.vector.y = (uv_item.vector.y * (h / size[1])) + (y / size[1]) + + for texture_type in ["albedo", "normal", "emission", "ambient_occlusion", "height", "roughness"]: + new_image_name: str = f"Atlas_{texture_type}_{context.scene.name}_{Path(bpy.data.filepath).stem}" + + print(f"Processing {texture_type} atlas image") + + if new_image_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[new_image_name]) + + canvas: Image = bpy.data.images.new(name=new_image_name, width=int(size[0]), height=int(size[1]), alpha=True) + c_w = canvas.size[0] + canvas_pixels: list[float] = list(canvas.pixels[:]) + for mat in mat_images: + image_var: Image = getattr(mat, texture_type, None) + if image_var and image_var.has_data: + x: int = int(mat.fit.x) + y: int = int(mat.fit.y) + w: int = int(image_var.size[0]) + h: int = int(image_var.size[1]) + + image_pixels: list[float] = list(image_var.pixels[:]) + + print(f"Writing image \"{image_var.name}\" to canvas.") + print(f"x: \"{x}\" y: \"{y}\" w: \"{w}\" h: \"{h}\"") + for k in range(0, h): + for i in range(0, w): + for channel in range(0, 4): + canvas_index = (((k + y) * c_w) + (i + x)) * 4 + channel + image_index = ((k * w) + i) * 4 + channel + canvas_pixels[int(canvas_index)] = image_pixels[int(image_index)] canvas.pixels[:] = canvas_pixels[:] - canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath),new_image_name+".png")) - exec("atlased_mat."+type+" = canvas") + canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath), new_image_name + ".png")) + setattr(atlased_mat, texture_type, canvas) #I am sorry for the amount of nodes I'm instanciating here and their values. #This is so that the nodes look pretty in the UI, which I think looks kinda nice. - @989onan @@ -285,7 +309,7 @@ class AvatarToolKit_OT_AtlasMaterials(Operator): if obj.type == 'MESH': mesh: Mesh = obj.data for i, mat_slot in enumerate(obj.material_slots): - if mat_slot.material and mat_slot.material.include_in_atlas is True: + if mat_slot.material and mat_slot.material.avatar_toolkit.include_in_atlas is True: mesh.materials[i] = atlased_mat.material self.report({'INFO'}, t("TextureAtlas.atlas_completed")) diff --git a/functions/mesh_tools.py b/functions/mesh_tools.py index d3c917f..b9f4fa3 100644 --- a/functions/mesh_tools.py +++ b/functions/mesh_tools.py @@ -51,9 +51,11 @@ class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operator): 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 + if ("-" in kb_name) or ("=" in kb_name) or ("~" in kb_name): continue ob.shape_key_remove(ob.data.shape_keys.key_blocks[kb_name]) + + return {'FINISHED'} class AvatarToolkit_OT_ApplyShapeKey(bpy.types.Operator): diff --git a/functions/mmd_functions.py b/functions/mmd_functions.py index 76ba124..c5bff59 100644 --- a/functions/mmd_functions.py +++ b/functions/mmd_functions.py @@ -302,7 +302,6 @@ def add_principled_shader(material: Material, bake_mmd=True): if material.blend_method != 'OPAQUE': principled_shader.inputs["Alpha"].default_value = material.alpha_threshold material.blend_method = 'CLIP' - material.shadow_method = 'CLIP' def fix_mmd_shader(material: Material): mmd_shader_node = material.node_tree.nodes.get("mmd_shader") diff --git a/functions/translations.py b/functions/translations.py deleted file mode 100644 index 4e03f66..0000000 --- a/functions/translations.py +++ /dev/null @@ -1,97 +0,0 @@ -import os -import json -import bpy -from bpy.app.translations import locale -from typing import Dict, List, Tuple -from ..core.addon_preferences import save_preference, get_preference - -# Use __file__ to get the current file's directory -current_dir = os.path.dirname(os.path.abspath(__file__)) -main_dir = os.path.dirname(current_dir) -resources_dir = os.path.join(main_dir, "resources") -translations_dir = os.path.join(resources_dir, "translations") - -dictionary: Dict[str, str] = dict() -languages: List[str] = [] -verbose: bool = True - -def load_translations() -> bool: - global dictionary, languages - - old_dictionary = dictionary.copy() - - dictionary = dict() - languages = ["auto"] - - # Populate languages list - for i in os.listdir(translations_dir): - lang = i.split(".")[0] - if lang != "auto": - languages.append(lang) - - language_index = get_preference("language", 0) - # print(f"Loading translations for language index: {language_index}") # Debug print - - if language_index == 0: # "auto" - language = bpy.context.preferences.view.language - else: - try: - language = languages[language_index] - except IndexError: - language = bpy.context.preferences.view.language - - # print(f"Selected language: {language}") # Debug print - - translation_file: str = os.path.join(translations_dir, language + ".json") - if os.path.exists(translation_file): - with open(translation_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded translations: {dictionary}") # Debug print - else: - custom_language: str = language.split("_")[0] - custom_translation_file: str = os.path.join(translations_dir, custom_language + ".json") - if os.path.exists(custom_translation_file): - with open(custom_translation_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded custom translations: {dictionary}") # Debug print - else: - print(f"Translation file not found for language: {language}") - default_file: str = os.path.join(translations_dir, "en_US.json") - if os.path.exists(default_file): - with open(default_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded default translations: {dictionary}") # Debug print - else: - print("Default translation file 'en_US.json' not found.") - - return dictionary != old_dictionary - -def t(phrase: str, default: str = None, **kwargs) -> str: - output: str = dictionary.get(phrase) - if output is None: - if verbose: - print(f'Warning: Unknown phrase: {phrase}') - return default if default is not None else phrase - # print(f"Translating '{phrase}' to '{output}'") # Debug print - return output.format(**kwargs) if kwargs else output - -def get_language_display_name(lang: str) -> str: - if lang == "auto": - return t("Language.auto", "Automatic") - return t(f"Language.{lang}", lang) - -def get_languages_list(self, context) -> List[Tuple[str, str, str]]: - return [(str(i), get_language_display_name(lang), f"Use {lang} language") for i, lang in enumerate(languages)] - -def update_language(self, context): - print(f"Updating language to: {self.avatar_toolkit_language}") # Debug print - save_preference("language", int(self.avatar_toolkit_language)) - load_translations() - # Set a flag to indicate that a language change has occurred - context.scene.avatar_toolkit_language_changed = True - # Show popup after language change - bpy.ops.avatar_toolkit.translation_restart_popup('INVOKE_DEFAULT') - -# Initial load of translations -# print("Performing initial load of translations") # Debug print -load_translations() diff --git a/functions/viseme.py b/functions/viseme.py index 6793997..24c3bcb 100644 --- a/functions/viseme.py +++ b/functions/viseme.py @@ -28,16 +28,16 @@ class AvatarToolKit_OT_AutoVisemeButton(bpy.types.Operator): init_progress(context, 5) # 5 main steps update_progress(self, context, t("VisemePanel.start_viseme_creation")) - mesh = bpy.data.objects.get(context.scene.selected_mesh) + mesh = bpy.data.objects.get(context.scene.avatar_toolkit.selected_mesh) if not mesh or not common.has_shapekeys(mesh): raise ValueError(t('AutoVisemeButton.error.noShapekeys')) update_progress(self, context, t("VisemePanel.removing_existing_visemes")) self.remove_existing_vrc_shapekeys(mesh) - shape_a = context.scene.avatar_toolkit_mouth_a - shape_o = context.scene.avatar_toolkit_mouth_o - shape_ch = context.scene.avatar_toolkit_mouth_ch + shape_a = context.scene.avatar_toolkit.mouth_a + shape_o = context.scene.avatar_toolkit.mouth_o + shape_ch = context.scene.avatar_toolkit.mouth_ch if shape_a == "Basis" or shape_o == "Basis" or shape_ch == "Basis": raise ValueError(t('AutoVisemeButton.error.selectShapekeys')) @@ -62,7 +62,7 @@ class AvatarToolKit_OT_AutoVisemeButton(bpy.types.Operator): ] for viseme_name, shape_mix in visemes: - self.create_viseme(mesh, viseme_name, shape_mix, context.scene.avatar_toolkit_shape_intensity) + self.create_viseme(mesh, viseme_name, shape_mix, context.scene.avatar_toolkit.shape_intensity) update_progress(self, context, t("VisemePanel.sorting_shapekeys")) common.sort_shape_keys(mesh) From 7f9dc205640d05a857e98465425a77b8d9649f95 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Tue, 3 Dec 2024 17:40:31 +0000 Subject: [PATCH 03/19] Fixes --- core/common.py | 11 ++++++---- functions/armature_modifying.py | 38 ++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/core/common.py b/core/common.py index 2b4c702..719166b 100644 --- a/core/common.py +++ b/core/common.py @@ -469,7 +469,7 @@ def transfer_vertex_weights(context: Context, obj: bpy.types.Object, source_grou modifier = obj.modifiers.new(name="merge_weights", type="VERTEX_WEIGHT_MIX") modifier.show_viewport = True modifier.show_render = True - modifier.mix_set = 'B' # Replace weights in A with weights from B + modifier.mix_set = 'B' modifier.vertex_group_a = target_group modifier.vertex_group_b = source_group modifier.mask_constant = 1.0 @@ -482,12 +482,13 @@ def transfer_vertex_weights(context: Context, obj: bpy.types.Object, source_grou obj.select_set(True) context.view_layer.objects.active = obj - # Move modifier to the top of the stack if necessary + # Move modifier to the top of the stack if len(obj.modifiers) > 1: obj.modifiers.move(obj.modifiers.find(modifier.name), 0) - # Apply modifier - bpy.ops.object.modifier_apply(modifier=modifier.name) + # Apply modifier with correct syntax + with context.temp_override(active_object=obj): + bpy.ops.object.modifier_apply(modifier=modifier.name) # Clean up if delete_source_group and source_group in obj.vertex_groups: @@ -495,3 +496,5 @@ def transfer_vertex_weights(context: Context, obj: bpy.types.Object, source_grou return True + + diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py index 7edaf97..e43b35b 100644 --- a/functions/armature_modifying.py +++ b/functions/armature_modifying.py @@ -145,7 +145,7 @@ class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') - # Store initial transforms + # Modify the initial transforms collection section to include all bones: initial_transforms = {} bpy.ops.object.mode_set(mode='EDIT') for bone in armature.data.edit_bones: @@ -156,14 +156,14 @@ class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): 'matrix': bone.matrix.copy(), 'parent': bone.parent.name if bone.parent else None } - # Add end bones to transforms - if bone.name.endswith('_end'): - initial_transforms[bone.name] = { - 'head': bone.head.copy(), - 'tail': bone.tail.copy(), - 'roll': bone.roll, - 'matrix': bone.matrix.copy(), - 'parent': bone.parent.name if bone.parent else None + # Handle any child bones including _end bones + for child in bone.children: + initial_transforms[child.name] = { + 'head': child.head.copy(), + 'tail': child.tail.copy(), + 'roll': child.roll, + 'matrix': child.matrix.copy(), + 'parent': child.parent.name if child.parent else None } # Get weighted bones @@ -300,14 +300,19 @@ class AvatarToolkit_OT_MergeBonesToParents(Operator): def execute(self, context: Context) -> set[str]: prev_mode = context.mode - + armature = common.get_selected_armature(context) + # Map 'EDIT_ARMATURE' to 'EDIT' for bpy.ops.object.mode_set if prev_mode == 'EDIT_ARMATURE': prev_mode = 'EDIT' - # Switch to Edit Mode + # Set active object and mode + context.view_layer.objects.active = armature + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) bpy.ops.object.mode_set(mode='EDIT') - armature_data: Armature = context.view_layer.objects.active.data + + armature_data: Armature = armature.data # Get selected bones in Edit Mode selected_bones = context.selected_editable_bones @@ -322,13 +327,16 @@ class AvatarToolkit_OT_MergeBonesToParents(Operator): bone = armature_data.edit_bones.get(bone_name) if bone and bone.parent: # Transfer weights from bone to its parent + context.view_layer.objects.active = obj common.transfer_vertex_weights( context=context, obj=obj, source_group=bone_name, target_group=bone.parent.name ) - # Ensure we're in Edit Mode after transfer + # Return to armature edit mode + context.view_layer.objects.active = armature + armature.select_set(True) bpy.ops.object.mode_set(mode='EDIT') else: self.report({'WARNING'}, f"Bone '{bone_name}' has no parent or not found; skipping") @@ -347,10 +355,11 @@ class AvatarToolkit_OT_MergeBonesToParents(Operator): self.report({'WARNING'}, f"Bone '{bone_name}' not found in armature; cannot delete") # Return to previous mode + context.view_layer.objects.active = armature + armature.select_set(True) bpy.ops.object.mode_set(mode=prev_mode) return {'FINISHED'} - class AvatarToolkit_OT_MergeArmatures(Operator): bl_idname = "avatar_toolkit.merge_armatures" bl_label = t("MergeArmature.merge_armatures.label") @@ -362,7 +371,6 @@ class AvatarToolkit_OT_MergeArmatures(Operator): return (common.get_selected_armature(context) is not None) and (common.get_merge_armature_source(context) is not None) def make_active(self, obj: bpy.types.Object, context: Context): - context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') context.view_layer.objects.active = obj From ff23d23cfc68fa20bf42b1d6b199e4cf449d0618 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Tue, 3 Dec 2024 22:58:17 +0000 Subject: [PATCH 04/19] Start of the Major Overhaul I decided to go through each function and UI section one by one, improving and overhauling things. Each function and section is going to be fully tested and not rushed out. This is the best way to catch things, but also include the code base as much as possible. --- core/__init__.py | 0 core/auto_load.py | 67 ++-- core/common.py | 485 +++++------------------------ core/exporters/export_resonite.py | 5 +- core/importers/importer.py | 163 +++++++--- core/packer/rectangle_packer.py | 152 --------- core/properties.py | 196 ++---------- core/updater.py | 42 ++- functions/__init__.py | 0 functions/additional_tools.py | 161 ---------- functions/armature_modifying.py | 461 --------------------------- functions/atlas_materials.py | 321 ------------------- functions/combine_materials.py | 155 --------- functions/digitigrade_legs.py | 118 ------- functions/import_anything.py | 188 ----------- functions/mesh_tools.py | 212 ------------- functions/mmd_functions.py | 396 ----------------------- functions/pose_mode.py | 120 +++++++ functions/remove_doubles_safely.py | 308 ------------------ functions/resonite_functions.py | 114 ------- functions/rigify_functions.py | 175 ----------- functions/uv_tools.py | 295 ------------------ functions/viseme.py | 93 ------ resources/translations/en_US.json | 312 +++---------------- ui/__init__.py | 0 ui/atlas_materials.py | 182 ----------- ui/credits_support.py | 53 ---- ui/main_panel.py | 35 ++- ui/merge_armatures.py | 45 --- ui/mmd_options.py | 48 --- ui/optimization.py | 46 --- ui/quick_access.py | 89 ------ ui/quick_access_panel.py | 132 ++++++++ ui/settings.py | 38 --- ui/tools.py | 76 ----- ui/uv_panel.py | 14 - ui/uv_tools.py | 19 -- ui/viseme.py | 53 ---- 38 files changed, 604 insertions(+), 4765 deletions(-) delete mode 100644 core/__init__.py delete mode 100644 core/packer/rectangle_packer.py delete mode 100644 functions/__init__.py delete mode 100644 functions/additional_tools.py delete mode 100644 functions/armature_modifying.py delete mode 100644 functions/atlas_materials.py delete mode 100644 functions/combine_materials.py delete mode 100644 functions/digitigrade_legs.py delete mode 100644 functions/import_anything.py delete mode 100644 functions/mesh_tools.py delete mode 100644 functions/mmd_functions.py create mode 100644 functions/pose_mode.py delete mode 100644 functions/remove_doubles_safely.py delete mode 100644 functions/resonite_functions.py delete mode 100644 functions/rigify_functions.py delete mode 100644 functions/uv_tools.py delete mode 100644 functions/viseme.py delete mode 100644 ui/__init__.py delete mode 100644 ui/atlas_materials.py delete mode 100644 ui/credits_support.py delete mode 100644 ui/merge_armatures.py delete mode 100644 ui/mmd_options.py delete mode 100644 ui/optimization.py delete mode 100644 ui/quick_access.py create mode 100644 ui/quick_access_panel.py delete mode 100644 ui/settings.py delete mode 100644 ui/tools.py delete mode 100644 ui/uv_panel.py delete mode 100644 ui/uv_tools.py delete mode 100644 ui/viseme.py diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/auto_load.py b/core/auto_load.py index 7ca78ab..f0c50a5 100644 --- a/core/auto_load.py +++ b/core/auto_load.py @@ -7,6 +7,7 @@ import pkgutil import tomllib import importlib from pathlib import Path +from typing import List, Dict, Set, Optional, Any, Type, Tuple, Generator, TypeVar __all__ = ( "init", @@ -14,10 +15,12 @@ __all__ = ( "unregister", ) -modules = None -ordered_classes = None +T = TypeVar('T') +modules: Optional[List[Any]] = None +ordered_classes: Optional[List[Type]] = None -def init(): +def init() -> None: + """Initialize the auto-loader by discovering modules and classes""" global modules global ordered_classes print("Auto-load init starting") @@ -26,7 +29,8 @@ def init(): print(f"Found modules: {modules}") print(f"Found classes: {ordered_classes}") -def register(): +def register() -> None: + """Register all discovered classes and modules""" print("Registering classes") for cls in ordered_classes: print(f"Registering: {cls}") @@ -41,7 +45,8 @@ def register(): if hasattr(module, "register"): module.register() -def unregister(): +def unregister() -> None: + """Unregister all classes and modules in reverse order""" for cls in reversed(ordered_classes): bpy.utils.unregister_class(cls) @@ -51,13 +56,15 @@ def unregister(): if hasattr(module, "unregister"): module.unregister() -def get_manifest_id(): +def get_manifest_id() -> str: + """Get the addon ID from the manifest file""" manifest_path = Path(__file__).parent.parent / "blender_manifest.toml" with open(manifest_path, "rb") as f: manifest = tomllib.load(f) return manifest["id"] -def get_all_submodules(directory): +def get_all_submodules(directory: Path) -> List[Any]: + """Discover and import all submodules in the given directory""" modules = [] addon_id = get_manifest_id() for root, dirs, files in os.walk(directory): @@ -73,66 +80,75 @@ def get_all_submodules(directory): modules.append(importlib.import_module(f".{name}", package_name)) return modules -def iter_submodules(path, package_name): +def iter_submodules(path: Path, package_name: str) -> Generator[Any, None, None]: + """Iterate through submodules in a package""" for name in sorted(iter_module_names(path)): yield importlib.import_module("." + name, package_name) -def iter_module_names(path): - print(f"Scanning path: {path}") # Debug path +def iter_module_names(path: Path) -> Generator[str, None, None]: + """Iterate through module names in a directory""" + print(f"Scanning path: {path}") modules_list = list(pkgutil.iter_modules([str(path)])) - print(f"Found these modules: {modules_list}") # Debug modules + print(f"Found these modules: {modules_list}") for _, module_name, is_pkg in modules_list: if not is_pkg: print(f"Found module: {module_name}") yield module_name - - -def get_ordered_classes_to_register(modules): +def get_ordered_classes_to_register(modules: List[Any]) -> List[Type]: + """Get a topologically sorted list of classes to register""" return toposort(get_register_deps_dict(modules)) -def get_register_deps_dict(modules): +def get_register_deps_dict(modules: List[Any]) -> Dict[Type, Set[Type]]: + """Get dependencies dictionary for class registration""" deps_dict = {} classes_to_register = set(iter_classes_to_register(modules)) for cls in classes_to_register: deps_dict[cls] = set(iter_own_register_deps(cls, classes_to_register)) return deps_dict -def iter_own_register_deps(cls, classes_to_register): +def iter_own_register_deps(cls: Type, classes_to_register: Set[Type]) -> Generator[Type, None, None]: + """Iterate through a class's own registration dependencies""" yield from (dep for dep in iter_register_deps(cls) if dep in classes_to_register) -def iter_register_deps(cls): +def iter_register_deps(cls: Type) -> Generator[Type, None, None]: + """Iterate through all registration dependencies of a class""" for value in typing.get_type_hints(cls, {}, {}).values(): dependency = get_dependency_from_annotation(value) if dependency is not None: yield dependency -def get_dependency_from_annotation(value): +def get_dependency_from_annotation(value: Any) -> Optional[Type]: + """Get dependency type from a type annotation""" if isinstance(value, tuple) and len(value) == 2: if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): return value[1]["type"] return None -def iter_classes_to_register(modules): +def iter_classes_to_register(modules: List[Any]) -> Generator[Type, None, None]: + """Iterate through classes that need to be registered""" base_types = get_register_base_types() for cls in get_classes_in_modules(modules): if any(base in base_types for base in cls.__bases__): if not getattr(cls, "_is_registered", False): yield cls -def get_classes_in_modules(modules): +def get_classes_in_modules(modules: List[Any]) -> Set[Type]: + """Get all classes defined in the modules""" classes = set() for module in modules: for cls in iter_classes_in_module(module): classes.add(cls) return classes -def iter_classes_in_module(module): +def iter_classes_in_module(module: Any) -> Generator[Type, None, None]: + """Iterate through classes defined in a module""" for value in module.__dict__.values(): if inspect.isclass(value): yield value -def get_register_base_types(): +def get_register_base_types() -> Set[Type]: + """Get set of base types that need registration""" return set(getattr(bpy.types, name) for name in [ "Panel", "Operator", "PropertyGroup", "AddonPreferences", "Header", "Menu", @@ -140,24 +156,22 @@ def get_register_base_types(): "UIList", "RenderEngine" ]) -def toposort(deps_dict): +def toposort(deps_dict: Dict[Type, Set[Type]]) -> List[Type]: + """Topologically sort classes based on their dependencies""" sorted_list = [] sorted_values = set() - # First pass: Register panels without parents panels_to_sort = [(value, deps) for value, deps in deps_dict.items() if hasattr(value, 'bl_parent_id')] base_panels = [(value, deps) for value, deps in deps_dict.items() if not hasattr(value, 'bl_parent_id')] - # Add base panels first for value, deps in base_panels: if len(deps) == 0: sorted_list.append(value) sorted_values.add(value) - # Then add child panels while len(deps_dict) > len(sorted_values): unsorted = [] for value, deps in deps_dict.items(): @@ -169,4 +183,3 @@ def toposort(deps_dict): unsorted.append(value) return sorted_list - diff --git a/core/common.py b/core/common.py index 719166b..14121fe 100644 --- a/core/common.py +++ b/core/common.py @@ -1,268 +1,83 @@ import bpy import numpy as np -from .dictionaries import bone_names -import threading -import time -import webbrowser -import typing +from bpy.types import Context, Object +from typing import Optional, Tuple, List, Set +from ..core.translations import t +from ..core.dictionaries import bone_names -from typing import List, Optional, Tuple -from bpy.types import Object, ShapeKey, Mesh, Context, Material, PropertyGroup -from functools import lru_cache -from bpy.props import PointerProperty, IntProperty, StringProperty -from bpy.utils import register_class - - - - -class SceneMatClass(PropertyGroup): - mat: PointerProperty(type=Material) - -register_class(SceneMatClass) - -class MaterialListBool: - #For the love that is holy do not ever touch these. If this was java I would make these private - #They should only be accessed via context.scene.texture_atlas_Has_Mat_List_Shown - #This is so we know if the materials are up to date. messing with these variables directly will make the thing blow up. - - #The only exception to this is the ExpandSection_Materials operator which populates this with new data once the materials have changed and need reloading. - old_list: dict[str,list[Material]] = {} - bool_material_list_expand: dict[str,bool] = {} - - def set_bool(self, value: bool) -> None: - MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = value - if value == False: - MaterialListBool.old_list[bpy.context.scene.name] = [] - - def get_bool(self) -> bool: - newlist: list[Material] = [] - for obj in bpy.context.scene.objects: - if len(obj.material_slots)>0: - for mat_slot in obj.material_slots: - if mat_slot.material: - if mat_slot.material not in newlist: - newlist.append(mat_slot.material) - - still_the_same: bool = True - if bpy.context.scene.name in MaterialListBool.old_list: - for item in newlist: - if item not in MaterialListBool.old_list[bpy.context.scene.name]: - still_the_same = False - break - for item in MaterialListBool.old_list[bpy.context.scene.name]: - if item not in newlist: - still_the_same = False - break - else: - still_the_same = False - MaterialListBool.bool_material_list_expand[bpy.context.scene.name] = still_the_same - - return MaterialListBool.bool_material_list_expand[bpy.context.scene.name] - - -### Clean up material names in the given mesh by removing the '.001' suffix. -def clean_material_names(mesh: Mesh) -> None: - for j, mat in enumerate(mesh.material_slots): - if mat.name.endswith(('.0+', ' 0+')): - mesh.active_material_index = j - mesh.active_material.name = mat.name[:-len(mat.name.rstrip('0')) - 1] - -# This will fix faulty uv coordinates, cats did this a other way which can have unintended consequences, -# this is the best way i could of think of doing this for the time being, however may need improvements. - -def fix_uv_coordinates(context: Context) -> None: - obj = context.object - - # Store current mode and selection - current_mode = context.mode - current_active = context.view_layer.objects.active - current_selected = context.selected_objects.copy() - - # Ensure we're in object mode and select the object - bpy.ops.object.mode_set(mode='OBJECT') - obj.select_set(True) - context.view_layer.objects.active = obj - - # Check if the object has any mesh data - if obj.type == 'MESH' and obj.data: - - # Switch to Edit Mode - bpy.ops.object.mode_set(mode='EDIT') - - # Select all UVs - bpy.ops.mesh.select_all(action='SELECT') - - # Try to find UV Editor area, fall back to 3D View if not found - area = next((area for area in context.screen.areas if area.type == 'UV_EDITOR'), None) - if not area: - area = next((area for area in context.screen.areas if area.type == 'VIEW_3D'), None) - - # Get the region and space data - region = next((region for region in area.regions if region.type == 'WINDOW'), None) - space_data = area.spaces.active - - # Create a context override - override = { - 'area': area, - 'region': region, - 'space_data': space_data, - 'edit_object': obj, - 'active_object': obj, - 'selected_objects': [obj], - 'mode': 'EDIT_MESH', - } - - try: - # Ensure UVs are selected - bpy.ops.uv.select_all(override, action='SELECT') - # Average UV island scales - bpy.ops.uv.average_islands_scale(override) - except Exception as e: - print(f"UV Fix - Error during UV scaling: {str(e)}") - - # Switch back to Object Mode - bpy.ops.object.mode_set(mode='OBJECT') - print("UV Fix - Switched back to Object Mode") - - # Restore previous selection and active object - for sel_obj in current_selected: - sel_obj.select_set(True) - context.view_layer.objects.active = current_active - else: - print("UV Fix - Object is not a valid mesh with UV data") - -def has_shapekeys(mesh_obj: Object) -> bool: - return mesh_obj.data.shape_keys is not None - -@lru_cache(maxsize=None) -def _get_shape_key_co(shape_key: ShapeKey) -> np.ndarray: - return np.array([v.co for v in shape_key.data]) - -def simplify_bonename(n: str) -> str: - return n.lower().translate(dict.fromkeys(map(ord, u" _."))) - -def get_armature(context: Context, armature_name: Optional[str] = None) -> Optional[Object]: - if armature_name: - obj = bpy.data.objects[armature_name] - if obj.type == "ARMATURE": - return obj - else: - return None - if context.view_layer.objects.active: - obj = context.view_layer.objects.active - if obj.type == "ARMATURE": - return obj - return next((obj for obj in context.view_layer.objects if obj.type == 'ARMATURE'), None) - -def get_armatures(self, context: Context) -> List[Tuple[str, str, str]]: - armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if obj.type == 'ARMATURE'] - if not armatures: - return [('NONE', 'No Armature', '')] - return armatures - -def get_armatures_that_are_not_selected(self, context: Context) -> List[Tuple[str, str, str]]: - armatures = [(obj.name, obj.name, "") for obj in bpy.data.objects if ((obj.type == 'ARMATURE') and (obj.name != context.scene.avatar_toolkit.selected_armature))] - if not armatures: - return [('NONE', 'No Other Armature', '')] - return armatures - -def get_selected_armature(context: Context) -> Optional[Object]: - try: - if hasattr(context.scene, 'avatar_toolkit'): - armature_name = context.scene.avatar_toolkit.selected_armature - if isinstance(armature_name, bytes): - try: - armature_name = armature_name.decode('utf-8') - except UnicodeDecodeError: - try: - armature_name = armature_name.decode('gbk') # For Chinese characters - except UnicodeDecodeError: - try: - armature_name = armature_name.decode('shift-jis') - except UnicodeDecodeError: - armature_name = armature_name.decode('latin1') - - if armature_name: - armature = bpy.data.objects.get(str(armature_name)) - if is_valid_armature(armature): - return armature - except Exception: - pass +def get_active_armature(context: bpy.types.Context) -> Optional[bpy.types.Object]: + """Get the currently selected armature from Avatar Toolkit properties""" + armature_name = context.scene.avatar_toolkit.active_armature + if armature_name and armature_name != 'NONE': + return bpy.data.objects.get(armature_name) return None -def get_merge_armature_source(context: Context) -> Optional[Object]: - try: - if hasattr(context.scene, 'merge_armature_source'): - source_name = context.scene.merge_armature_source - if isinstance(source_name, bytes): - try: - source_name = source_name.decode('utf-8') - except UnicodeDecodeError: - try: - source_name = source_name.decode('shift-jis') - except UnicodeDecodeError: - source_name = source_name.decode('latin1', errors='ignore') +def set_active_armature(context: bpy.types.Context, armature: bpy.types.Object) -> None: + """Set the active armature for Avatar Toolkit operations""" + context.scene.avatar_toolkit.active_armature = armature + +def get_armature_list(self=None, context: bpy.types.Context = None) -> List[Tuple[str, str, str]]: + """Get list of all armature objects in the scene""" + if context is None: + context = bpy.context + armatures = [(obj.name, obj.name, "") for obj in context.scene.objects if obj.type == 'ARMATURE'] + if not armatures: + return [('NONE', t("Armature.validation.no_armature"), '')] + return armatures + +def validate_armature(armature: bpy.types.Object) -> Tuple[bool, str]: + """ + Validate if the selected object is a proper armature and has required bones + Returns tuple of (is_valid, message) + """ + if not armature: + return False, t("Armature.validation.no_armature") + if armature.type != 'ARMATURE': + return False, t("Armature.validation.not_armature") + if not armature.data.bones: + return False, t("Armature.validation.no_bones") + + essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'} + found_bones: Set[str] = {bone.name.lower() for bone in armature.data.bones} + + for bone in essential_bones: + if not any(alt_name in found_bones for alt_name in bone_names[bone]): + return False, t("Armature.validation.missing_bone", bone=bone) - if source_name: - return bpy.data.objects.get(str(source_name)) - except Exception: - pass - return None + return True, t("QuickAccess.valid_armature") -def set_selected_armature(context: Context, armature: Optional[Object]) -> None: - context.scene.avatar_toolkit.selected_armature = armature.name if armature else "" +def auto_select_single_armature(context: bpy.types.Context) -> None: + """Automatically select armature if only one exists in scene""" + armatures = get_armature_list(context) + if len(armatures) == 1: + set_active_armature(context, armatures[0]) -def is_valid_armature(armature: Object) -> bool: - if not armature or armature.type != 'ARMATURE': - return False - if not armature.data or not armature.data.bones: - return False - return True +def clear_default_objects() -> None: + """Removes default Blender objects (cube, light, camera)""" + default_names: Set[str] = {'Cube', 'Light', 'Camera'} + for obj in bpy.data.objects: + if obj.name.split('.')[0] in default_names: + bpy.data.objects.remove(obj, do_unlink=True) -def select_current_armature(context: Context) -> bool: - armature = get_selected_armature(context) +def get_armature_stats(armature: bpy.types.Object) -> dict: + """Get statistics about the armature""" + return { + 'bone_count': len(armature.data.bones), + 'has_pose': bool(armature.pose), + 'visible': not armature.hide_viewport, + 'name': armature.name + } + +def get_all_meshes(context: Context) -> List[Object]: + armature = get_active_armature(context) if armature: - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - context.view_layer.objects.active = armature - return True - return False + return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] + return [] -def apply_shapekey_to_basis(context: bpy.types.Context, obj: bpy.types.Object, shape_key_name: str, delete_old: bool = False) -> bool: - if shape_key_name not in obj.data.shape_keys.key_blocks: - return False - shapekeynum = obj.data.shape_keys.key_blocks.find(shape_key_name) - - bpy.ops.object.mode_set(mode="EDIT") - - bpy.ops.mesh.select_all(action='SELECT') - - - obj.active_shape_key_index = 0 - bpy.ops.mesh.blend_from_shape(shape = shape_key_name, add=True, blend=1) - obj.active_shape_key_index = shapekeynum - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.blend_from_shape(shape = shape_key_name, add=True, blend=-2) - - - bpy.ops.mesh.select_all(action='DESELECT') - - bpy.ops.object.mode_set(mode="OBJECT") - print("blended!") - - if delete_old: - obj.active_shape_key_index = shapekeynum - bpy.ops.object.shape_key_remove(all=False) - else: - mesh: bpy.types.Mesh = obj.data - mesh.shape_keys.key_blocks[shape_key_name].name = shape_key_name + "_reversed" - return True - -def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: list[Object]) -> bool: +def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> bool: for mesh_obj in meshes: if not mesh_obj.data: continue - if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks: if len(mesh_obj.data.shape_keys.key_blocks) == 1: basis = mesh_obj.data.shape_keys.key_blocks[0] @@ -274,11 +89,10 @@ def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: list[Obje apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context) else: apply_armature_to_mesh(armature_obj, mesh_obj) - + bpy.ops.object.mode_set(mode='POSE') bpy.ops.pose.armature_apply(selected=False) bpy.ops.object.mode_set(mode='OBJECT') - return True def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: @@ -290,7 +104,7 @@ def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: else: for _ in range(len(mesh_obj.modifiers) - 1): bpy.ops.object.modifier_move_up(modifier=armature_mod.name) - + with bpy.context.temp_override(object=mesh_obj): bpy.ops.object.modifier_apply(modifier=armature_mod.name) @@ -298,10 +112,11 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object old_active_index = mesh_obj.active_shape_key_index old_show_only = mesh_obj.show_only_shape_key mesh_obj.show_only_shape_key = True - + shape_keys = mesh_obj.data.shape_keys.key_blocks vertex_groups = [] mutes = [] + for sk in shape_keys: vertex_groups.append(sk.vertex_group) sk.vertex_group = '' @@ -316,7 +131,7 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object arm_mod = mesh_obj.modifiers.new('PoseToRest', 'ARMATURE') arm_mod.object = armature_obj - + co_length = len(mesh_obj.data.vertices) * 3 eval_cos = np.empty(co_length, dtype=np.single) @@ -333,6 +148,7 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object for mod in disabled_mods: mod.show_viewport = True + mesh_obj.modifiers.remove(arm_mod) for sk, vg, mute in zip(shape_keys, vertex_groups, mutes): @@ -341,160 +157,3 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object mesh_obj.active_shape_key_index = old_active_index mesh_obj.show_only_shape_key = old_show_only - -def get_all_meshes(context: Context) -> List[Object]: - armature = get_selected_armature(context) - if armature and is_valid_armature(armature): - return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] - return [] - -def get_mesh_items(self, context): - return [(obj.name, obj.name, "") for obj in get_all_meshes(context)] - -def open_web_after_delay_multi_threaded(delay: typing.Optional[float] = 1.0, url: typing.Union[str, typing.Any] = ""): - thread = threading.Thread(target=open_web_after_delay,args=[delay,url],name="open_browser_thread") - thread.start() - -def open_web_after_delay(delay, url): - print("opening browser in "+str(delay)+" seconds.") - time.sleep(delay) - - webbrowser.open_new_tab(url) - -def duplicatebone(b: bpy.types.EditBone) -> bpy.types.EditBone: - arm = bpy.context.object.data - cb = arm.edit_bones.new(b.name) - - cb.head = b.head - cb.tail = b.tail - cb.matrix = b.matrix - cb.parent = b.parent - return cb - -def has_shapekeys(mesh_obj: Object) -> bool: - return mesh_obj.data.shape_keys is not None - -def sort_shape_keys(mesh: Object) -> None: - print("Starting shape key sorting...") - if not has_shapekeys(mesh): - print("No shape keys found. Exiting sort function.") - return - - # Set the mesh as the active object - bpy.context.view_layer.objects.active = mesh - bpy.ops.object.mode_set(mode='OBJECT') - - order = [ - 'Basis', - 'vrc.blink_left', - 'vrc.blink_right', - 'vrc.lowerlid_left', - 'vrc.lowerlid_right', - 'vrc.v_aa', - 'vrc.v_ch', - 'vrc.v_dd', - 'vrc.v_e', - 'vrc.v_ff', - 'vrc.v_ih', - 'vrc.v_kk', - 'vrc.v_nn', - 'vrc.v_oh', - 'vrc.v_ou', - 'vrc.v_pp', - 'vrc.v_rr', - 'vrc.v_sil', - 'vrc.v_ss', - 'vrc.v_th', - ] - - shape_keys = mesh.data.shape_keys.key_blocks - print(f"Total shape keys: {len(shape_keys)}") - - # Create a list of shape key names in their current order - current_order = [key.name for key in shape_keys] - - # Create a new order list - new_order = [] - - # First, add all the keys that are in the predefined order - for name in order: - if name in current_order: - new_order.append(name) - current_order.remove(name) - - # Then add any remaining keys that weren't in the predefined order - new_order.extend(current_order) - - print("New order:", new_order) - - # Now, rearrange the shape keys based on the new order - for i, name in enumerate(new_order): - index = shape_keys.find(name) - if index != i: - print(f"Moving {name} from index {index} to {i}") - mesh.active_shape_key_index = index - while mesh.active_shape_key_index > i: - bpy.ops.object.shape_key_move(type='UP') - - print("Shape key sorting completed.") - -def get_shapekeys(mesh: Object, prefix: str = '') -> List[tuple]: - if not has_shapekeys(mesh): - return [] - return [(key.name, key.name, key.name) for key in mesh.data.shape_keys.key_blocks if key.name != 'Basis' and key.name.startswith(prefix)] - -def remove_default_objects(): - for obj in bpy.data.objects: - if obj.name in ["Camera", "Light", "Cube"]: - bpy.data.objects.remove(obj, do_unlink=True) - -def init_progress(context, steps): - context.window_manager.progress_begin(0, 100) - context.scene.avatar_toolkit.progress_steps = steps - context.scene.avatar_toolkit.progress_current = 0 - -def update_progress(self, context, message): - context.scene.avatar_toolkit.progress_current += 1 - progress = (context.scene.avatar_toolkit.progress_current / context.scene.avatar_toolkit.progress_steps) * 100 - context.window_manager.progress_update(progress) - context.area.header_text_set(message) - self.report({'INFO'}, 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: - # Create and configure the Vertex Weight Mix modifier - modifier = obj.modifiers.new(name="merge_weights", type="VERTEX_WEIGHT_MIX") - modifier.show_viewport = True - modifier.show_render = True - modifier.mix_set = 'B' - modifier.vertex_group_a = target_group - modifier.vertex_group_b = source_group - modifier.mask_constant = 1.0 - - # Ensure we're in Object Mode - bpy.ops.object.mode_set(mode='OBJECT') - - # Deselect all objects and select only our target object - bpy.ops.object.select_all(action='DESELECT') - obj.select_set(True) - context.view_layer.objects.active = obj - - # Move modifier to the top of the stack - if len(obj.modifiers) > 1: - obj.modifiers.move(obj.modifiers.find(modifier.name), 0) - - # Apply modifier with correct syntax - with context.temp_override(active_object=obj): - bpy.ops.object.modifier_apply(modifier=modifier.name) - - # Clean up - if delete_source_group and source_group in obj.vertex_groups: - obj.vertex_groups.remove(obj.vertex_groups[source_group]) - - return True - - - diff --git a/core/exporters/export_resonite.py b/core/exporters/export_resonite.py index 9de6448..51c6c6f 100644 --- a/core/exporters/export_resonite.py +++ b/core/exporters/export_resonite.py @@ -1,6 +1,6 @@ import bpy from typing import List, Optional -from ...core.common import get_armature +from ...core.common import get_active_armature from bpy.types import Object, ShapeKey, Mesh, Context, Operator from functools import lru_cache from ...core.translations import t @@ -12,10 +12,9 @@ class AvatarToolKit_OT_ExportResonite(Operator): bl_options = {'REGISTER', 'UNDO'} filepath: bpy.props.StringProperty() - @classmethod def poll(cls, context: Context): - if get_armature(context) is None: + if get_active_armature(context) is None: return False return True diff --git a/core/importers/importer.py b/core/importers/importer.py index 976c7dd..91eef2d 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -1,54 +1,129 @@ import bpy - -# Importers which don't need much code should be added here, however if a importer needs alot of code -# Like the PMX and PMD importers, they should be added to their own files and referenced in the import_types str->lambda dictionary. - -#See below comments on how the system works. - @989onan - -import importlib.util +import logging import os import typing +from typing import Optional, Callable, Dict, List, Union, Set +from ..common import clear_default_objects from .import_pmx import import_pmx from .import_pmd import import_pmd -if importlib.util.find_spec("io_scene_valvesource") is not None: - #from .....scripts.addons.io_scene_valvesource.import_smd import SmdImporter #<- use this to check if your IDE is working properly. idfk - from io_scene_valvesource.import_smd import SmdImporter #ignore IDE bitching this is fine, trust me, also above comment should be okay to an IDE usually if set up right. ^_^ - @989onan +# Configure logging +logging.basicConfig(level=logging.INFO) +logger: logging.Logger = logging.getLogger(__name__) -def import_multi_files(method = None, directory: typing.Optional[str] = None, files: list[dict[str,str]] = None, filepath: typing.Optional[str] = ""): - if not files: - method(directory, filepath) - else: - for file in files: - fullpath = os.path.join(directory,os.path.basename(file["name"])) - print("run method!") - method(directory, fullpath) -#each import should map to a type. even in the case that multiple methods should import together, or have the same import method. Make sure the lambdas match so they get grouped together -#In the case of a file importer that takes only one file argument and each one needs individual import, use above method. (example of it in use is ".dae" format) -import_types: dict[str, typing.Callable[[str, list[dict[str,str]], str], None]] = { - "fbx": (lambda directory, files, filepath : bpy.ops.import_scene.fbx(files=files, directory=directory, filepath=filepath,automatic_bone_orientation=False,use_prepost_rot=False,use_anim=False)), - "smd": (lambda directory, files, filepath : eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")), - "dmx": (lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")), - "gltf": (lambda directory, files, filepath : bpy.ops.import_scene.gltf(files=files, filepath=filepath)), - "glb": (lambda directory, files, filepath : bpy.ops.import_scene.gltf(files=files, filepath=filepath)), - "qc": (lambda directory, files, filepath : eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)")), - "obj": (lambda directory, files, filepath : bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath)), - "dae": (lambda directory, files, filepath : import_multi_files(directory=directory, files=files, filepath=filepath, method = (lambda directory, filepath: bpy.ops.wm.collada_import(filepath=filepath, auto_connect = True, find_chains = True, fix_orientation = True)))), - "3ds": (lambda directory, files, filepath : bpy.ops.import_scene.max3ds(files=files, directory=directory, filepath=filepath)), - "stl": (lambda directory, files, filepath : bpy.ops.import_mesh.stl(files=files, directory=directory, filepath=filepath)), - "mtl": (lambda directory, files, filepath : bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath)), - "x3d": (lambda directory, files, filepath : bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath)), - "wrl": (lambda directory, files, filepath : bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath)), - "vmd": (lambda directory, files, filepath : import_multi_files(directory=directory, files=files, filepath=filepath, method = (lambda directory, filepath: bpy.ops.tuxedo.import_mmd_animation(directory=directory, filepath=filepath)))), - "vrm": (lambda directory, files, filepath: bpy.ops.import_scene.vrm(filepath=filepath)), - "pmx": (lambda directory, files, filepath : import_pmx(filepath)), - "pmd": (lambda directory, files, filepath : import_pmd(filepath)), +import importlib.util + +if importlib.util.find_spec("io_scene_valvesource") is not None: + from io_scene_valvesource.import_smd import SmdImporter + +class ImportProgress: + """Tracks and logs the progress of multi-file imports""" + def __init__(self, total_files: int): + self.total: int = total_files + self.current: int = 0 + + def update(self, filename: str) -> None: + """Update import progress and log current file""" + self.current += 1 + logger.info(f"Importing {filename} ({self.current}/{self.total})") + +def validate_file(filepath: str) -> bool: + """ + Validate if a file exists and is accessible + Returns: True if file is valid, False otherwise + """ + if not os.path.exists(filepath): + logger.error(f"File not found: {filepath}") + return False + if not os.path.isfile(filepath): + logger.error(f"Not a file: {filepath}") + return False + return True + +def import_multi_files( + method: Optional[Callable] = None, + directory: Optional[str] = None, + files: Optional[List[Dict[str, str]]] = None, + filepath: str = "", + progress_callback: Optional[Callable[[str], None]] = None +) -> None: + """ + Import multiple files using the specified import method + + Args: + method: Import method to use + directory: Directory containing files + files: List of files to import + filepath: Single file path to import + progress_callback: Callback for progress updates + """ + try: + if not method: + raise ValueError("Import method not specified") + + if not files: + if not validate_file(filepath): + return + method(directory, filepath) + if progress_callback: + progress_callback(filepath) + else: + progress = ImportProgress(len(files)) + for file in files: + fullpath: str = os.path.join(directory, os.path.basename(file["name"])) + if not validate_file(fullpath): + continue + + logger.info(f"Importing file: {fullpath}") + method(directory, fullpath) + + if progress_callback: + progress_callback(fullpath) + progress.update(file["name"]) + + except Exception as e: + logger.error(f"Import failed: {str(e)}", exc_info=True) + raise + +ImportMethod = Callable[[str, List[Dict[str, str]], str], None] + +import_types: Dict[str, ImportMethod] = { + "fbx": lambda directory, files, filepath: bpy.ops.import_scene.fbx( + files=files, directory=directory, filepath=filepath, + automatic_bone_orientation=False, use_prepost_rot=False, use_anim=False + ), + "smd": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), + "dmx": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), + "gltf": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath), + "glb": lambda directory, files, filepath: bpy.ops.import_scene.gltf(files=files, filepath=filepath), + "qc": lambda directory, files, filepath: eval("bpy."+SmdImporter.bl_idname+".(files=files, directory=directory, filepath=filepath)"), + "obj": lambda directory, files, filepath: bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath), + "dae": lambda directory, files, filepath: import_multi_files( + directory=directory, + files=files, + filepath=filepath, + method=lambda directory, filepath: bpy.ops.wm.collada_import( + filepath=filepath, auto_connect=True, find_chains=True, fix_orientation=True + ) + ), + "3ds": lambda directory, files, filepath: bpy.ops.import_scene.max3ds(files=files, directory=directory, filepath=filepath), + "stl": lambda directory, files, filepath: bpy.ops.import_mesh.stl(files=files, directory=directory, filepath=filepath), + "mtl": lambda directory, files, filepath: bpy.ops.wm.obj_import(files=files, directory=directory, filepath=filepath), + "x3d": lambda directory, files, filepath: bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath), + "wrl": lambda directory, files, filepath: bpy.ops.import_scene.x3d(files=files, directory=directory, filepath=filepath), + "vmd": lambda directory, files, filepath: import_multi_files( + directory=directory, + files=files, + filepath=filepath, + method=lambda directory, filepath: bpy.ops.tuxedo.import_mmd_animation(directory=directory, filepath=filepath) + ), + "vrm": lambda directory, files, filepath: bpy.ops.import_scene.vrm(filepath=filepath), + "pmx": lambda directory, files, filepath: import_pmx(filepath), + "pmd": lambda directory, files, filepath: import_pmd(filepath), } -def concat_imports_filter(imports): - names = "" - for importer in imports.keys(): - names = names+"*."+importer+";" - return names +def concat_imports_filter(imports: Dict[str, ImportMethod]) -> str: + """Create a file filter string from import types""" + return "".join(f"*.{importer};" for importer in imports.keys()) -imports = concat_imports_filter(import_types) \ No newline at end of file +imports: str = concat_imports_filter(import_types) diff --git a/core/packer/rectangle_packer.py b/core/packer/rectangle_packer.py deleted file mode 100644 index f4fe5ad..0000000 --- a/core/packer/rectangle_packer.py +++ /dev/null @@ -1,152 +0,0 @@ -# thank you https://stackoverflow.com/a/71432759 -from __future__ import annotations - - -from typing import Optional -from bpy.types import Image, Material - - -# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016 Jake Gordon and contributors -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -class Rectangle_Obj: - x: int = 0 - y: int = 0 - w: int = 0 - h: int = 0 - down: Rectangle_Obj = None - used: bool = False - right: Rectangle_Obj = None - - def __init__(self, x:int, y:int, w:int, h:int, down=None, used =False, right=None): - self.x = x - self.y = y - self.w = w - self.h = h - self.down = down - self.used = used - self.right = right - - def split(self, w, h) -> Rectangle_Obj: - self.used = True - self.down = Rectangle_Obj(x=self.x, y=self.y + h, w=self.w, h=self.h - h) - self.right = Rectangle_Obj(x=self.x + w, y=self.y, w=self.w - w, h=h) - return self - - def find(self, w, h) -> Optional[Rectangle_Obj]: - if self.used: - return self.right.find(w, h) or self.down.find(w, h) - elif (w <= self.w) and (h <= self.h): - return self - return None - -class MaterialImageList: - albedo: Image - normal: Image - emission: Image - ambient_occlusion: Image - height: Image - roughness: Image - fit: Rectangle_Obj - material: Material - - def __init__(self): - pass - - x: int = 0 - y: int = 0 - w: int = 0 - h: int = 0 - - - - - -class BinPacker(object): - root: Rectangle_Obj - bin: list[MaterialImageList] = [] - def __init__(self, structure: list[MaterialImageList]): - self.root = None - self.bin = structure - - def fit(self): - structure = self.bin - structure_len = len(self.bin) - w: int = 0 - h: int = 0 - if structure_len > 0: - w = structure[0].w - h = structure[0].h - self.root = Rectangle_Obj(x=0, y=0, w=w, h=h) - for img in structure: - w = img.w - h = img.h - node = self.root.find(w, h) - if node: - img.fit = node.split(w, h) - else: - img.fit = self.grow_node(w, h) - return structure - - def grow_node(self, w, h) -> Optional[Rectangle_Obj]: - can_grow_right = (h <= self.root.h) - can_grow_down = (w <= self.root.w) - - should_grow_right = can_grow_right and (self.root.h >= (self.root.w + w)) - should_grow_down = can_grow_down and (self.root.w >= (self.root.h + h)) - - if should_grow_right: - return self.grow_right(w, h) - elif should_grow_down: - return self.grow_down(w, h) - elif can_grow_right: - return self.grow_right(w, h) - elif can_grow_down: - return self.grow_down(w, h) - return None - - def grow_right(self, w, h) -> Optional[Rectangle_Obj]: - self.root = Rectangle_Obj( - used=True, - x=0, - y=0, - w=self.root.w + w, - h=self.root.h, - down=self.root, - right=Rectangle_Obj(x=self.root.w, y=0, w=w, h=self.root.h)) - node = self.root.find(w, h) - if node: - return node.split(w, h) - return None - - def grow_down(self, w, h) -> Optional[Rectangle_Obj]: - self.root = Rectangle_Obj( - used=True, - x=0, - y=0, - w=self.root.w, - h=self.root.h + h, - down=Rectangle_Obj(x=0, y=self.root.h, w=self.root.w, h=h), - right=self.root - ) - node = self.root.find(w, h) - if node: - return node.split(w, h) - return None \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index d920160..d35eda0 100644 --- a/core/properties.py +++ b/core/properties.py @@ -1,189 +1,39 @@ import bpy -from .translations import t, get_languages_list, update_language +from typing import List, Tuple, Optional from bpy.types import PropertyGroup, Material, Scene, Object, Context -from bpy.props import (StringProperty, BoolProperty, EnumProperty, - IntProperty, FloatProperty, CollectionProperty, - PointerProperty) +from bpy.props import ( + StringProperty, + BoolProperty, + EnumProperty, + IntProperty, + FloatProperty, + CollectionProperty, + PointerProperty +) +from .translations import t, get_languages_list, update_language from .addon_preferences import get_preference -from .common import SceneMatClass, MaterialListBool, get_armatures, get_mesh_items, get_armatures_that_are_not_selected from .updater import get_version_list +from .common import get_armature_list class AvatarToolkitSceneProperties(PropertyGroup): - language: EnumProperty( - name="Language", - description="Select the language for the addon", - items=get_languages_list, - update=update_language - ) + """Property group containing Avatar Toolkit scene-level settings and properties""" - selected_mesh: EnumProperty( - items=get_mesh_items, - name="Selected Mesh", - description="Select mesh to modify" - ) - - material_search_filter: StringProperty( - name="Search Materials", - description="Filter materials by name", - default="" - ) - - merge_armature_apply_transforms: BoolProperty( - default=False, - name="Apply Transforms", - description="Apply transforms when merging armatures" - ) - - merge_armature_align_bones: BoolProperty( - default=False, - name="Align Bones", - description="Align bones when merging armatures" - ) - - progress_steps: IntProperty(default=0) - progress_current: IntProperty(default=0) - language_changed: BoolProperty(default=False) - - mouth_a: StringProperty( - name="Mouth A", - description="Shape key for A sound" - ) - - mouth_o: StringProperty( - name="Mouth O", - description="Shape key for O sound" - ) - - mouth_ch: StringProperty( - name="Mouth CH", - description="Shape key for CH sound" - ) - - shape_intensity: FloatProperty( - name="Shape Intensity", - description="Intensity of shape key modifications", - default=1.0, - min=0.0, - max=2.0 - ) - - merge_twist_bones: BoolProperty( - name="Merge Twist Bones", - description="Merge twist bones during processing", - default=True - ) - - selected_armature: EnumProperty( - items=get_armatures, - name="Selected Armature", - description="Select the armature to work with" - ) - - merge_armature_source: EnumProperty( - items=get_armatures_that_are_not_selected, - name="Source Armature", - description="Select the source armature for merging" - ) - - texture_atlas_material_index: IntProperty( - default=-1, - get=lambda self: -1, - set=lambda self, context: None - ) - - materials: CollectionProperty(type=SceneMatClass) - - texture_atlas_Has_Mat_List_Shown: BoolProperty( - default=False, - get=MaterialListBool.get_bool, - set=MaterialListBool.set_bool - ) - avatar_toolkit_updater_version_list: EnumProperty( items=get_version_list, - name="Version List", - description="List of available versions" + name=t("Scene.avatar_toolkit_updater_version_list.name"), + description=t("Scene.avatar_toolkit_updater_version_list.description") ) - -class AvatarToolkitMaterialProperties(PropertyGroup): - material_expanded: BoolProperty( - name="Expand Material", - description="Show/hide material properties", - default=False + active_armature: EnumProperty( + items=get_armature_list, + name=t("QuickAccess.select_armature"), + description=t("QuickAccess.select_armature") ) - include_in_atlas: BoolProperty( - name="Include in Atlas", - description="Include this material in texture atlas", - default=True - ) - - def get_texture_node_list(self, context): - # Access the material through the property group's id_data - material = self.id_data - if material and material.use_nodes: - nodes = [(i.image.name if i.image else i.name+"_image", - i.image.name if i.image else "node with no image...", - i.image.name if i.image else i.name, index+1) - for index, i in enumerate(material.node_tree.nodes) - if i.bl_idname == "ShaderNodeTexImage"] - if not nodes: - nodes = [("Error", "No images found", "Error", 0)] - else: - nodes = [("Error", "No node tree found", "Error", 0)] - nodes.append(("None", "None", "None", 0)) - return nodes - - texture_atlas_albedo: EnumProperty( - name="Albedo", - description="Albedo texture for atlas", - items=get_texture_node_list - ) - - texture_atlas_normal: EnumProperty( - name="Normal", - description="Normal map for atlas", - items=get_texture_node_list - ) - - texture_atlas_emission: EnumProperty( - name="Emission", - description="Emission texture for atlas", - items=get_texture_node_list - ) - - texture_atlas_ambient_occlusion: EnumProperty( - name="Ambient Occlusion", - description="AO texture for atlas", - items=get_texture_node_list - ) - - texture_atlas_height: EnumProperty( - name="Height", - description="Height map for atlas", - items=get_texture_node_list - ) - - texture_atlas_roughness: EnumProperty( - name="Roughness", - description="Roughness map for atlas", - items=get_texture_node_list - ) - -class AvatarToolkitObjectProperties(PropertyGroup): - material_group_expanded: BoolProperty( - name="Expand Material Group", - description="Show/hide materials for this mesh", - default=False - ) - -def register(): +def register() -> None: + """Register the Avatar Toolkit property group""" bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties) - bpy.types.Material.avatar_toolkit = PointerProperty(type=AvatarToolkitMaterialProperties) - bpy.types.Object.avatar_toolkit = PointerProperty(type=AvatarToolkitObjectProperties) -def unregister(): +def unregister() -> None: + """Unregister the Avatar Toolkit property group""" del bpy.types.Scene.avatar_toolkit - del bpy.types.Material.avatar_toolkit - del bpy.types.Object.avatar_toolkit diff --git a/core/updater.py b/core/updater.py index 4ccbd23..437bf15 100644 --- a/core/updater.py +++ b/core/updater.py @@ -276,22 +276,48 @@ def get_version_list(self, context: bpy.types.Context) -> List[Tuple[str, str, s return [(v, v, '') for v in version_list.keys()] if version_list else [] def draw_updater_panel(context: bpy.types.Context, layout: bpy.types.UILayout) -> None: - col = layout.column(align=True) - + box = layout.box() + col = box.column(align=True) + + # Header + row = col.row() + row.scale_y = 1.2 + row.label(text=t('Updater.label'), icon='DOWNARROW_HLT') + + col.separator() + + # Update check/status section if is_checking_for_update: - col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label')) + col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, + text=t('Updater.CheckForUpdateButton.label'), + icon='SORTTIME') elif update_needed: - col.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToLatestButton.label', name=latest_version_str)) + update_row = col.row(align=True) + update_row.scale_y = 1.5 + update_row.alert = True + update_row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, + text=t('Updater.UpdateToLatestButton.label', name=latest_version_str), + icon='IMPORT') else: - col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, text=t('Updater.CheckForUpdateButton.label_alt')) + col.operator(AvatarToolkit_OT_CheckForUpdate.bl_idname, + text=t('Updater.CheckForUpdateButton.label_alt'), + icon='FILE_REFRESH') + # Version selection section col.separator() - row = col.row(align=True) + box_inner = col.box() + box_inner.label(text=t('Updater.selectVersion'), icon='SETTINGS') + row = box_inner.row(align=True) row.prop(context.scene.avatar_toolkit, 'avatar_toolkit_updater_version_list', text='') - row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, text=t('Updater.UpdateToSelectedButton.label')) + row.operator(AvatarToolkit_OT_UpdateToLatest.bl_idname, + text=t('Updater.UpdateToSelectedButton.label'), + icon='IMPORT') + # Current version info col.separator() - col.label(text=t('Updater.currentVersion').format(name=get_current_version())) + curr_ver_row = col.row() + curr_ver_row.label(text=t('Updater.currentVersion').format(name=get_current_version()), + icon='CHECKMARK') def ui_refresh() -> None: for windowManager in bpy.data.window_managers: diff --git a/functions/__init__.py b/functions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/functions/additional_tools.py b/functions/additional_tools.py deleted file mode 100644 index 57f8f3d..0000000 --- a/functions/additional_tools.py +++ /dev/null @@ -1,161 +0,0 @@ -import bpy -import math -from bpy.types import Context, Operator -from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes -from ..core.translations import t - - -class AvatarToolKit_OT_ApplyTransforms(Operator): - bl_idname = "avatar_toolkit.apply_transforms" - bl_label = t("Tools.apply_transforms.label") - bl_description = t("Tools.apply_transforms.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return get_selected_armature(context) is not None - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not is_valid_armature(armature): - self.report({'ERROR'}, t("Tools.apply_transforms.invalid_armature")) - return {'CANCELLED'} - - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - - armature.select_set(True) - context.view_layer.objects.active = armature - - meshes = get_all_meshes(context) - for mesh in meshes: - mesh.select_set(True) - - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - - self.report({'INFO'}, t("Tools.apply_transforms.success")) - return {'FINISHED'} - - -class AvatarToolKit_OT_ConnectBones(Operator): - bl_idname = "avatar_toolkit.connect_bones" - bl_label = t("Tools.connect_bones.label") - bl_description = t("Tools.connect_bones.desc") - bl_options = {'REGISTER', 'UNDO'} - - min_distance: bpy.props.FloatProperty( - name=t("Tools.connect_bones.min_distance.label"), - description=t("Tools.connect_bones.min_distance.desc"), - default=0.005, - min=0.001, - max=0.1 - ) - - @classmethod - def poll(cls, context: Context) -> bool: - return get_selected_armature(context) is not None - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not is_valid_armature(armature): - self.report({'ERROR'}, t("Tools.connect_bones.invalid_armature")) - return {'CANCELLED'} - - bpy.ops.object.mode_set(mode='EDIT') - - edit_bones = armature.data.edit_bones - bones_connected = 0 - - for bone in edit_bones: - if len(bone.children) == 1 and bone.name not in ['LeftEye', 'RightEye', 'Head', 'Hips']: - child = bone.children[0] - distance = math.dist(bone.head, child.head) - - if distance > self.min_distance: - bone.tail = child.head - if bone.parent and len(bone.parent.children) == 1: - bone.use_connect = True - bones_connected += 1 - - bpy.ops.object.mode_set(mode='OBJECT') - - self.report({'INFO'}, t("Tools.connect_bones.success").format(bones_connected=bones_connected)) - return {'FINISHED'} - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - layout.prop(self, "min_distance") - - -class AvatarToolKit_OT_DeleteBoneConstraints(Operator): - bl_idname = "avatar_toolkit.delete_bone_constraints" - bl_label = t("Tools.delete_bone_constraints.label") - bl_description = t("Tools.delete_bone_constraints.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return get_selected_armature(context) is not None - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not is_valid_armature(armature): - self.report({'ERROR'}, t("Tools.delete_bone_constraints.invalid_armature")) - return {'CANCELLED'} - - bpy.ops.object.mode_set(mode='POSE') - - constraints_removed = 0 - for bone in armature.pose.bones: - while bone.constraints: - bone.constraints.remove(bone.constraints[0]) - constraints_removed += 1 - - bpy.ops.object.mode_set(mode='OBJECT') - - self.report({'INFO'}, t("Tools.delete_bone_constraints.success").format(constraints_removed=constraints_removed)) - return {'FINISHED'} - - -class AvatarToolKit_OT_SeparateByMaterials(Operator): - bl_idname = "avatar_toolkit.separate_by_materials" - bl_label = t("Tools.separate_by_materials.label") - bl_description = t("Tools.separate_by_materials.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return context.active_object and context.active_object.type == 'MESH' - - def execute(self, context: Context) -> set[str]: - obj = context.active_object - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.separate(type='MATERIAL') - bpy.ops.object.mode_set(mode='OBJECT') - self.report({'INFO'}, t("Tools.separate_by_materials.success")) - return {'FINISHED'} - - -class AvatarToolKit_OT_SeparateByLooseParts(Operator): - bl_idname = "avatar_toolkit.separate_by_loose_parts" - bl_label = t("Tools.separate_by_loose_parts.label") - bl_description = t("Tools.separate_by_loose_parts.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return context.active_object and context.active_object.type == 'MESH' - - def execute(self, context: Context) -> set[str]: - obj = context.active_object - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.separate(type='LOOSE') - bpy.ops.object.mode_set(mode='OBJECT') - self.report({'INFO'}, t("Tools.separate_by_loose_parts.success")) - return {'FINISHED'} - diff --git a/functions/armature_modifying.py b/functions/armature_modifying.py deleted file mode 100644 index e43b35b..0000000 --- a/functions/armature_modifying.py +++ /dev/null @@ -1,461 +0,0 @@ -import bpy -from bpy.types import Context, Mesh, Panel, Operator, Armature, EditBone -from ..core.translations import t -from ..core.common import get_selected_armature, get_all_meshes -from ..core import common -from ..core.dictionaries import bone_names -from mathutils import Matrix - - -class AvatarToolkit_OT_StartPoseMode(Operator): - bl_idname = 'avatar_toolkit.start_pose_mode' - bl_label = t("Quick_Access.start_pose_mode.label") - bl_description = t("Quick_Access.start_pose_mode.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - return get_selected_armature(context) != None and context.mode != "POSE" - - def execute(self, context: Context) -> set[str]: - - #give an active object so the next line doesn't throw an error. - context.view_layer.objects.active = get_selected_armature(context) - - bpy.ops.object.mode_set(mode='OBJECT') - - #deselect everything and select just our armature, then go into pose on just our selected armature. - @989onan - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = get_selected_armature(context) - context.view_layer.objects.active.select_set(True) - bpy.ops.object.mode_set(mode='POSE') - - return {'FINISHED'} - - -class AvatarToolkit_OT_StopPoseMode(Operator): - bl_idname = 'avatar_toolkit.stop_pose_mode' - bl_label = t("Quick_Access.stop_pose_mode.label") - bl_description = t("Quick_Access.stop_pose_mode.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - return get_selected_armature(context) != None and context.mode == "POSE" - - def execute(self, context: Context) -> set[str]: - #this is done so that transforms are cleared but user selection is respected. - @989onan - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - - bpy.ops.object.mode_set(mode='OBJECT') - - return {'FINISHED'} - - -class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' - bl_label = t("Quick_Access.apply_pose_as_shapekey.label") - bl_description = t("Quick_Access.apply_pose_as_shapekey.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = common.get_selected_armature(context) - return armature and context.mode == 'POSE' - - def execute(self, context): - armature_obj = common.get_selected_armature(context) - mesh_objects = common.get_all_meshes(context) - - for mesh_obj in mesh_objects: - if not mesh_obj.data: - continue - - # Ensure basis exists - if not mesh_obj.data.shape_keys: - mesh_obj.shape_key_add(name='Basis') - - # Store current pose as new shapekey - new_shape = mesh_obj.shape_key_add(name='Pose_Shapekey', from_mix=False) - - # Evaluate mesh in current pose - depsgraph = context.evaluated_depsgraph_get() - eval_mesh = mesh_obj.evaluated_get(depsgraph) - - # Apply evaluated vertices to new shapekey - for i, v in enumerate(eval_mesh.data.vertices): - new_shape.data[i].co = v.co.copy() - - # Reset pose - bpy.ops.pose.select_all(action='SELECT') - bpy.ops.pose.transforms_clear() - bpy.ops.object.mode_set(mode='OBJECT') - - self.report({'INFO'}, t('Tools.apply_pose_as_rest.success')) - return {'FINISHED'} - - -class AvatarToolkit_OT_ApplyPoseAsRest(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_rest' - bl_label = t("Quick_Access.apply_pose_as_rest.label") - bl_description = t("Quick_Access.apply_pose_as_rest.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - return get_selected_armature(context) != None and context.mode == "POSE" - - def execute(self, context: Context): - if not common.apply_pose_as_rest(armature_obj=get_selected_armature(context), - meshes=get_all_meshes(context), - context=context): - self.report({'ERROR'}, t("Quick_Access.apply_armature_failed")) - return {'CANCELLED'} - return {'FINISHED'} - - -class AvatarToolkit_OT_RemoveZeroWeightBones(Operator): - bl_idname = "avatar_toolkit.remove_zero_weight_bones" - bl_label = t("Tools.remove_zero_weight_bones.label") - bl_description = t("Tools.remove_zero_weight_bones.desc") - bl_options = {'REGISTER', 'UNDO'} - - threshold: bpy.props.FloatProperty( - default=0.01, - name=t("Tools.remove_zero_weight_bones.threshold.label"), - description=t("Tools.remove_zero_weight_bones.threshold.desc"), - min=0.0000001, - max=0.9999999) - - @classmethod - def poll(cls, context: Context) -> bool: - return common.get_selected_armature(context) is not None - - def execute(self, context: Context) -> set[str]: - armature = common.get_selected_armature(context) - if not common.is_valid_armature(armature): - self.report({'ERROR'}, t("Tools.apply_transforms.invalid_armature")) - return {'CANCELLED'} - - weighted_bones: list[str] = [] - - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - - # Modify the initial transforms collection section to include all bones: - initial_transforms = {} - bpy.ops.object.mode_set(mode='EDIT') - for bone in armature.data.edit_bones: - initial_transforms[bone.name] = { - 'head': bone.head.copy(), - 'tail': bone.tail.copy(), - 'roll': bone.roll, - 'matrix': bone.matrix.copy(), - 'parent': bone.parent.name if bone.parent else None - } - # Handle any child bones including _end bones - for child in bone.children: - initial_transforms[child.name] = { - 'head': child.head.copy(), - 'tail': child.tail.copy(), - 'roll': child.roll, - 'matrix': child.matrix.copy(), - 'parent': child.parent.name if child.parent else None - } - - # Get weighted bones - armature.select_set(True) - context.view_layer.objects.active = armature - - meshes = common.get_all_meshes(context) - for mesh in meshes: - mesh_data: Mesh = mesh.data - for vertex in mesh_data.vertices: - for group in vertex.groups: - if group.weight > self.threshold: - weighted_bones.append(mesh.vertex_groups[group.group].name) - - bpy.ops.object.mode_set(mode='EDIT') - amature_data: Armature = armature.data - unweighted_bones: list[str] = [] - - # Identify unweighted bones - for bone in amature_data.edit_bones: - if bone.name not in weighted_bones: - unweighted_bones.append(bone.name) - - # Process bone removal while preserving positions - for bone_name in unweighted_bones: - bone = amature_data.edit_bones[bone_name] - - # Store children data - children = bone.children - children_data = {} - for child in children: - children_data[child.name] = initial_transforms[child.name] - - # Reparent children - for child in children: - child.use_connect = False - if bone.parent: - child.parent = bone.parent - - # Remove bone - amature_data.edit_bones.remove(bone) - - # Restore children positions - for child_name, data in children_data.items(): - if child_name in amature_data.edit_bones: - child = amature_data.edit_bones[child_name] - child.head = data['head'] - child.tail = data['tail'] - child.roll = data['roll'] - child.matrix = data['matrix'] - - # Final position verification - for bone_name, transform in initial_transforms.items(): - if bone_name in amature_data.edit_bones: - bone = amature_data.edit_bones[bone_name] - bone.matrix = transform['matrix'] - - bpy.ops.object.mode_set(mode='OBJECT') - self.report({'INFO'}, t("Tools.remove_zero_weight_bones.success")) - return {'FINISHED'} - - - -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'} - - -class AvatarToolkit_OT_MergeBonesToParents(Operator): - bl_idname = "avatar_toolkit.merge_bones_to_parents" - bl_label = t("Tools.merge_bones_to_parents.label") - bl_description = t("Tools.merge_bones_to_parents.desc") - bl_options = {'REGISTER', 'UNDO'} - - delete_old: bpy.props.BoolProperty( - name=t("Tools.merge_bones_to_parents.delete_old.label"), - description=t("Tools.merge_bones_to_parents.delete_old.desc"), - default=False - ) - - @classmethod - def poll(cls, context: Context) -> bool: - armature = common.get_selected_armature(context) - if armature and armature == context.view_layer.objects.active: - if context.mode == "POSE": - return len(context.selected_pose_bones) > 0 - elif context.mode == "EDIT_ARMATURE": - return len(context.selected_editable_bones) > 0 - return False - - def execute(self, context: Context) -> set[str]: - prev_mode = context.mode - armature = common.get_selected_armature(context) - - # Map 'EDIT_ARMATURE' to 'EDIT' for bpy.ops.object.mode_set - if prev_mode == 'EDIT_ARMATURE': - prev_mode = 'EDIT' - - # Set active object and mode - context.view_layer.objects.active = armature - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - bpy.ops.object.mode_set(mode='EDIT') - - armature_data: Armature = armature.data - - # Get selected bones in Edit Mode - selected_bones = context.selected_editable_bones - selected_bone_names = [bone.name for bone in selected_bones] - - if not selected_bone_names: - self.report({'ERROR'}, t("No bones selected")) - return {'CANCELLED'} - - for obj in common.get_all_meshes(context): - for bone_name in selected_bone_names: - bone = armature_data.edit_bones.get(bone_name) - if bone and bone.parent: - # Transfer weights from bone to its parent - context.view_layer.objects.active = obj - common.transfer_vertex_weights( - context=context, - obj=obj, - source_group=bone_name, - target_group=bone.parent.name - ) - # Return to armature edit mode - context.view_layer.objects.active = armature - armature.select_set(True) - bpy.ops.object.mode_set(mode='EDIT') - else: - self.report({'WARNING'}, f"Bone '{bone_name}' has no parent or not found; skipping") - - # Optionally delete old bones - if self.delete_old: - for bone_name in selected_bone_names: - bone = armature_data.edit_bones.get(bone_name) - if bone: - # Reassign children to the parent of the bone being deleted - for child in bone.children: - child.parent = bone.parent - # Remove the bone - armature_data.edit_bones.remove(bone) - else: - self.report({'WARNING'}, f"Bone '{bone_name}' not found in armature; cannot delete") - - # Return to previous mode - context.view_layer.objects.active = armature - armature.select_set(True) - bpy.ops.object.mode_set(mode=prev_mode) - return {'FINISHED'} - -class AvatarToolkit_OT_MergeArmatures(Operator): - bl_idname = "avatar_toolkit.merge_armatures" - bl_label = t("MergeArmature.merge_armatures.label") - bl_description = t("MergeArmature.merge_armatures.desc").format(selected_armature_label=t("MergeArmatures.selected_armature.label")) - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - return (common.get_selected_armature(context) is not None) and (common.get_merge_armature_source(context) is not None) - - def make_active(self, obj: bpy.types.Object, context: Context): - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = obj - obj.select_set(True) - - def execute(cls, context: Context) -> set[str]: - source_armature: bpy.types.Object = bpy.data.objects[context.scene.merge_armature_source] - source_armature_data: Armature = source_armature.data - target_armature: bpy.types.Object = common.get_selected_armature(context) - target_armature_data: Armature = target_armature.data - parent_dictionary: dict[str, list[str]] = {} - - cls.make_active(obj=source_armature, context=context) - - - - if context.scene.merge_armature_apply_transforms: - target_armature.select_set(True) - for obj in target_armature.children: - obj.select_set(True) - for obj in source_armature.children: - obj.select_set(True) - bpy.ops.object.transform_apply() - - - if context.scene.merge_armature_align_bones: - if not context.scene.merge_armature_apply_transforms: - source_armature.matrix_world = target_armature.matrix_world - - def children_bone_recursive(parent_bone) -> list[bpy.types.PoseBone]: - child_bones = [] - child_bones.append(parent_bone) - for child in parent_bone.children: - child_bones.extend(children_bone_recursive(child)) - return child_bones - bpy.ops.object.mode_set(mode='POSE') - source_armature_bone_names = [j.name for j in children_bone_recursive( - source_armature.pose.bones[ - next(bone.name for bone in source_armature.pose.bones if common.simplify_bonename(bone.name) in bone_names['hips']) #Find bone that matches dictionary for hips before continuing. - ] - )] #bones are default in order of parent child. - - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = target_armature - bpy.ops.object.mode_set(mode='EDIT') - for source_bone_name in source_armature_bone_names: - - if source_bone_name in target_armature_data.edit_bones: - obj = source_armature - editbone = target_armature_data.edit_bones[source_bone_name] - bone = obj.pose.bones[source_bone_name] - bone.matrix = editbone.matrix - else: - continue - if not common.apply_pose_as_rest(armature_obj=source_armature,meshes=[i for i in source_armature.children if i.type == 'MESH'], context=context): - cls.report({'ERROR'}, t("Quick_Access.apply_armature_failed")) - return {'FINISHED'} - - - - - - cls.make_active(obj=source_armature, context=context) - bpy.ops.object.mode_set(mode='EDIT') - source_armature_data: Armature = source_armature.data - for bone_name in [i.name for i in source_armature_data.edit_bones]: - if bone_name in target_armature_data.bones: - parent_dictionary[bone_name] = [i.name for i in source_armature_data.edit_bones[bone_name].children] - source_armature_data.edit_bones.remove(source_armature_data.edit_bones[bone_name]) - bpy.ops.object.mode_set(mode='OBJECT') - - cls.make_active(obj=target_armature, context=context) - source_armature.select_set(True) - - bpy.ops.object.join() - target_armature: bpy.types.Object = common.get_selected_armature(context) - cls.make_active(obj=target_armature, context=context) - bpy.ops.object.mode_set(mode='EDIT') - for bone_name, bone_name_list in parent_dictionary.items(): - if bone_name in target_armature_data.edit_bones: - for bone_child in bone_name_list: - target_armature_data.edit_bones[bone_child].parent = target_armature_data.edit_bones[bone_name] - bpy.ops.object.mode_set(mode='OBJECT') - - - - return {'FINISHED'} diff --git a/functions/atlas_materials.py b/functions/atlas_materials.py deleted file mode 100644 index 86e2c66..0000000 --- a/functions/atlas_materials.py +++ /dev/null @@ -1,321 +0,0 @@ -from pathlib import Path - -import numpy -import bpy -import os -from typing import List, Tuple, Optional -from bpy.types import Material, Operator, Context, Object, Image, Mesh, MeshUVLoopLayer, Float2AttributeValue, ShaderNodeTexImage, ShaderNodeBsdfPrincipled, ShaderNodeNormalMap -from ..core.common import SceneMatClass, MaterialListBool -from ..core.packer.rectangle_packer import MaterialImageList, BinPacker -from ..core.translations import t - -class MaterialImageList: - def __init__(self): - self.albedo: Image = None - self.normal: Image = None - self.emission: Image = None - self.ambient_occlusion: Image = None - self.height: Image = None - self.roughness: Image = None - self.material: Material = None - self.parent_mesh: Object = None - self.w: int = 0 - self.h: int = 0 - self.fit = None - -def scale_images_to_largest(images: list[Image]) -> tuple[int, int]: - try: - valid_images = [] - for img in images: - if img and hasattr(img, 'name'): - image_data = bpy.data.images.get(img.name) - if image_data and image_data.has_data: - valid_images.append(image_data) - - if not valid_images: - return 1, 1 - - max_width = max(img.size[0] for img in valid_images) - max_height = max(img.size[1] for img in valid_images) - - return max_width, max_height - except: - return 1, 1 - -def MaterialImageList_to_Image_list(classitem: MaterialImageList) -> list[Image]: - list_of_images: list[Image] = [] - - list_of_images.append(classitem.albedo) - list_of_images.append(classitem.normal) - list_of_images.append(classitem.emission) - list_of_images.append(classitem.ambient_occlusion) - list_of_images.append(classitem.height) - list_of_images.append(classitem.roughness) - - return list_of_images - - -def get_material_images_from_scene(context: Context) -> list[MaterialImageList]: - material_image_list: list[MaterialImageList] = [] - - for obj in context.scene.objects: - if obj.type == 'MESH': - for mat_slot in obj.material_slots: - # Only process materials that are selected for atlas - if mat_slot.material and mat_slot.material.avatar_toolkit.include_in_atlas: - new_mat_image_item = MaterialImageList() - - def get_or_create_image(image_name, replacement_name, default_color): - if image_name and image_name in bpy.data.images: - image = bpy.data.images[image_name] - else: - # Create a new image with the replacement name if it doesn't exist - if replacement_name in bpy.data.images: - image = bpy.data.images[replacement_name] - else: - image = bpy.data.images.new( - name=replacement_name, width=32, height=32, alpha=True - ) - # Set the pixel data to the default color - num_pixels = 32 * 32 - pixel_data = numpy.tile(numpy.array(default_color), num_pixels) - image.pixels[:] = pixel_data - # Set use_fake_user to True to prevent Blender from removing the image - image.use_fake_user = True - return image - - # Albedo - albedo_name = getattr(mat_slot.material, 'texture_atlas_albedo', '') - new_mat_image_item.albedo = get_or_create_image( - albedo_name, - mat_slot.material.name + "_albedo_replacement", - [0.0, 0.0, 0.0, 1.0] - ) - - # Normal - normal_name = getattr(mat_slot.material, 'texture_atlas_normal', '') - new_mat_image_item.normal = get_or_create_image( - normal_name, - mat_slot.material.name + "_normal_replacement", - [0.5, 0.5, 1.0, 1.0] - ) - - # Emission - emission_name = getattr(mat_slot.material, 'texture_atlas_emission', '') - new_mat_image_item.emission = get_or_create_image( - emission_name, - mat_slot.material.name + "_emission_replacement", - [0.0, 0.0, 0.0, 1.0] - ) - - # Ambient Occlusion - ao_name = getattr(mat_slot.material, 'texture_atlas_ambient_occlusion', '') - new_mat_image_item.ambient_occlusion = get_or_create_image( - ao_name, - mat_slot.material.name + "_ambient_occlusion_replacement", - [1.0, 1.0, 1.0, 1.0] - ) - - # Height - height_name = getattr(mat_slot.material, 'texture_atlas_height', '') - new_mat_image_item.height = get_or_create_image( - height_name, - mat_slot.material.name + "_height_replacement", - [0.5, 0.5, 0.5, 1.0] - ) - - # Roughness - roughness_name = getattr(mat_slot.material, 'texture_atlas_roughness', '') - new_mat_image_item.roughness = get_or_create_image( - roughness_name, - mat_slot.material.name + "_roughness_replacement", - [1.0, 1.0, 1.0, 0.0] - ) - - new_mat_image_item.material = mat_slot.material - new_mat_image_item.parent_mesh = obj - material_image_list.append(new_mat_image_item) - - return material_image_list - - - -def prep_images_in_scene(context: Context) -> list[MaterialImageList]: - preped_images: list[MaterialImageList] = get_material_images_from_scene(context) - for MaterialImageClass in preped_images: - ImageList: list[Image] = MaterialImageList_to_Image_list(MaterialImageClass) - - MaterialImageClass.w, MaterialImageClass.h = scale_images_to_largest(ImageList) - - - - return preped_images - - - - -class AvatarToolKit_OT_AtlasMaterials(Operator): - bl_idname = "avatar_toolkit.atlas_materials" - bl_label = t("TextureAtlas.atlas_materials") - bl_description = t("TextureAtlas.atlas_materials_desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown - - def execute(self, context: Context) -> set: - try: - # Get only materials that are explicitly marked for inclusion - selected_materials = [m for m in prep_images_in_scene(context) - if m.material and m.material.avatar_toolkit.include_in_atlas is True] - - if not selected_materials: - self.report({'WARNING'}, t("TextureAtlas.no_materials_selected")) - return {'CANCELLED'} - - packer: BinPacker = BinPacker(selected_materials) - mat_images = packer.fit() - - size: list[int] = [ - max([ - matimg.fit.w + matimg.albedo.size[0] - for matimg in mat_images - if matimg.albedo and matimg.albedo.has_data - ] or [1]), - max([ - matimg.fit.h + matimg.albedo.size[1] - for matimg in mat_images - if matimg.albedo and matimg.albedo.has_data - ] or [1]) - ] - print([matimg.fit.w + matimg.albedo.size[0] for matimg in mat_images if matimg.albedo and matimg.albedo.has_data]) - - atlased_mat: MaterialImageList = MaterialImageList() - - for mat in mat_images: - if mat.albedo and mat.albedo.has_data: - x: int = int(mat.fit.x) - y: int = int(mat.fit.y) - w: int = int(mat.albedo.size[0]) - h: int = int(mat.albedo.size[1]) - - for obj in bpy.data.objects: - if obj.type == 'MESH': - mesh: Mesh = obj.data - for layer in mesh.polygons: - if obj.material_slots[layer.material_index].material: - if obj.material_slots[layer.material_index].material == mat.material: - for loop_idx in layer.loop_indices: - layer_loops: MeshUVLoopLayer - for layer_loops in mesh.uv_layers: - uv_item: Float2AttributeValue = layer_loops.uv[loop_idx] - uv_item.vector.x = (uv_item.vector.x * (w / size[0])) + (x / size[0]) - uv_item.vector.y = (uv_item.vector.y * (h / size[1])) + (y / size[1]) - - for texture_type in ["albedo", "normal", "emission", "ambient_occlusion", "height", "roughness"]: - new_image_name: str = f"Atlas_{texture_type}_{context.scene.name}_{Path(bpy.data.filepath).stem}" - - print(f"Processing {texture_type} atlas image") - - if new_image_name in bpy.data.images: - bpy.data.images.remove(bpy.data.images[new_image_name]) - - canvas: Image = bpy.data.images.new(name=new_image_name, width=int(size[0]), height=int(size[1]), alpha=True) - c_w = canvas.size[0] - canvas_pixels: list[float] = list(canvas.pixels[:]) - for mat in mat_images: - image_var: Image = getattr(mat, texture_type, None) - if image_var and image_var.has_data: - x: int = int(mat.fit.x) - y: int = int(mat.fit.y) - w: int = int(image_var.size[0]) - h: int = int(image_var.size[1]) - - image_pixels: list[float] = list(image_var.pixels[:]) - - print(f"Writing image \"{image_var.name}\" to canvas.") - print(f"x: \"{x}\" y: \"{y}\" w: \"{w}\" h: \"{h}\"") - for k in range(0, h): - for i in range(0, w): - for channel in range(0, 4): - canvas_index = (((k + y) * c_w) + (i + x)) * 4 + channel - image_index = ((k * w) + i) * 4 + channel - canvas_pixels[int(canvas_index)] = image_pixels[int(image_index)] - - canvas.pixels[:] = canvas_pixels[:] - canvas.save(filepath=os.path.join(os.path.dirname(bpy.data.filepath), new_image_name + ".png")) - setattr(atlased_mat, texture_type, canvas) - - #I am sorry for the amount of nodes I'm instanciating here and their values. - #This is so that the nodes look pretty in the UI, which I think looks kinda nice. - @989onan - atlased_mat.material = bpy.data.materials.new(name="Atlas_Final_"+bpy.context.scene.name+"_"+Path(bpy.data.filepath).stem) - atlased_mat.material.use_nodes = True - atlased_mat.material.node_tree.nodes.clear() - - principled_node: ShaderNodeBsdfPrincipled = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeBsdfPrincipled") - principled_node.location.x = 7.29706335067749 - principled_node.location.y = 298.918212890625 - - output_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeOutputMaterial") - output_node.location.x = 297.29705810546875 - output_node.location.y = 298.918212890625 - - albedo_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - albedo_node.location.x = -588.6177978515625 - albedo_node.location.y = 414.1948547363281 - albedo_node.image = atlased_mat.albedo - - emission_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - emission_node.location.x = -588.6177978515625 - emission_node.location.y = -173.9259033203125 - emission_node.image = atlased_mat.emission - - normal_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - normal_node.location.x = -941.4189453125 - normal_node.location.y = -20.8391780853271 - normal_node.image = atlased_mat.normal - - normal_map_node: ShaderNodeNormalMap = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeNormalMap") - normal_map_node.location.x = -545.550537109375 - normal_map_node.location.y = -0.7543716430664062 - - roughness_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - roughness_node.location.x = -592.1703491210938 - roughness_node.location.y = 206.74075317382812 - roughness_node.image = atlased_mat.roughness - - ambient_occlusion_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - ambient_occlusion_node.location.x = -906.4371337890625 - ambient_occlusion_node.location.y = -389.9602355957031 - ambient_occlusion_node.image = atlased_mat.ambient_occlusion - - height_node: ShaderNodeTexImage = atlased_mat.material.node_tree.nodes.new(type="ShaderNodeTexImage") - height_node.location.x = -1222.383056640625 - height_node.location.y = -375.48406982421875 - height_node.image = atlased_mat.height - - atlased_mat.material.node_tree.links.new(principled_node.inputs["Base Color"], albedo_node.outputs["Color"]) - atlased_mat.material.node_tree.links.new(principled_node.inputs["Metallic"], roughness_node.outputs["Alpha"]) - atlased_mat.material.node_tree.links.new(principled_node.inputs["Roughness"], roughness_node.outputs["Color"]) - atlased_mat.material.node_tree.links.new(principled_node.inputs["Alpha"], albedo_node.outputs["Alpha"]) - atlased_mat.material.node_tree.links.new(principled_node.inputs["Normal"], normal_map_node.outputs["Normal"]) - atlased_mat.material.node_tree.links.new(principled_node.inputs["Emission Color"], emission_node.outputs["Color"]) - atlased_mat.material.node_tree.links.new(output_node.inputs["Surface"], principled_node.outputs["BSDF"]) - atlased_mat.material.node_tree.links.new(normal_map_node.inputs["Color"], normal_node.outputs["Color"]) - - # Only update selected materials for meshes - for obj in context.scene.objects: - if obj.type == 'MESH': - mesh: Mesh = obj.data - for i, mat_slot in enumerate(obj.material_slots): - if mat_slot.material and mat_slot.material.avatar_toolkit.include_in_atlas is True: - mesh.materials[i] = atlased_mat.material - - self.report({'INFO'}, t("TextureAtlas.atlas_completed")) - return {"FINISHED"} - except Exception as e: - self.report({'ERROR'}, t("TextureAtlas.atlas_error")) - raise e - return {"FINISHED"} - \ No newline at end of file diff --git a/functions/combine_materials.py b/functions/combine_materials.py deleted file mode 100644 index 8e0bcaf..0000000 --- a/functions/combine_materials.py +++ /dev/null @@ -1,155 +0,0 @@ -import bpy -import re -from typing import List, Tuple, Optional, Set, Dict -from bpy.types import Material, Operator, Context, Object, NodeTree -from ..core.common import clean_material_names, get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress -from ..core.translations import t - -def textures_match(tex1: bpy.types.ImageTexture, tex2: bpy.types.ImageTexture) -> bool: - return tex1.image == tex2.image and tex1.extension == tex2.extension - -def consolidate_nodes(node1: bpy.types.ShaderNodeTexImage, node2: bpy.types.ShaderNodeTexImage) -> None: - node2.color_space = node1.color_space - node2.coordinates = node1.coordinates - -def copy_tex_nodes(mat1: Material, mat2: Material) -> None: - for node1 in mat1.node_tree.nodes: - if node1.type == 'TEX_IMAGE': - node2 = mat2.node_tree.nodes.get(node1.name) - if node2: - node2.mapping = node1.mapping - node2.projection = node1.projection - -def consolidate_textures(node_tree1: NodeTree, node_tree2: NodeTree) -> None: - for node1 in node_tree1.nodes: - if node1.type == 'TEX_IMAGE': - for node2 in node_tree2.nodes: - if (node2.type == 'TEX_IMAGE' and - node1.image == node2.image): - consolidate_nodes(node1, node2) - node2.image = node1.image - elif node1.type == 'GROUP': - if node1.node_tree and node2.node_tree: - consolidate_textures(node1.node_tree, node2.node_tree) - -def color_match(col1: Tuple[float, float, float, float], col2: Tuple[float, float, float, float], tolerance: float = 0.01) -> bool: - return all(abs(c1 - c2) < tolerance for c1, c2 in zip(col1, col2)) - -def materials_match(mat1: Material, mat2: Material, tolerance: float = 0.01) -> bool: - if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance): - return False - - if abs(mat1.roughness - mat2.roughness) > tolerance: - return False - - if mat1.node_tree and mat2.node_tree: - consolidate_textures(mat1.node_tree, mat2.node_tree) - - return True - -def get_base_name(name: str) -> str: - mat_match = re.match(r"^(.*)\.\d{3}$", name) - return mat_match.group(1) if mat_match else name - - -class AvatarToolKit_OT_CombineMaterials(Operator): - bl_idname = "avatar_toolkit.combine_materials" - bl_label = t("Optimization.combine_materials.label") - bl_description = t("Optimization.combine_materials.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) - - def execute(self, context: Context) -> Set[str]: - armature = get_selected_armature(context) - if not armature: - self.report({'WARNING'}, t("Optimization.no_armature_selected")) - return {'CANCELLED'} - - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='OBJECT') - - meshes = get_all_meshes(context) - if not meshes: - self.report({'WARNING'}, t("Optimization.no_meshes_found")) - return {'CANCELLED'} - - init_progress(context, 5) # 5 steps in total - - update_progress(self, context, t("Optimization.consolidating_materials")) - num_combined = self.consolidate_materials(meshes) - - update_progress(self, context, t("Optimization.cleaning_material_slots")) - cleaned_slots = self.clean_material_slots(meshes) - - update_progress(self, context, t("Optimization.cleaning_material_names")) - cleaned_names = self.clean_material_names() - - update_progress(self, context, t("Optimization.clearing_unused_data")) - removed_data_blocks = self.clear_unused_data_blocks() - - update_progress(self, context, t("Optimization.finalizing")) - finish_progress(context) - - self.report({'INFO'}, t("Optimization.materials_optimization_report").format( - num_combined=num_combined, - num_cleaned_slots=cleaned_slots, - num_cleaned_names=cleaned_names, - num_removed_data_blocks=removed_data_blocks - )) - - return {'FINISHED'} - - def consolidate_materials(self, meshes: List[Object]) -> int: - mat_mapping: Dict[str, Material] = {} - num_combined: int = 0 - for mesh in meshes: - for slot in mesh.material_slots: - mat: Optional[Material] = slot.material - if mat: - base_name: str = get_base_name(mat.name) - - if base_name in mat_mapping: - base_mat: Material = mat_mapping[base_name] - try: - if materials_match(base_mat, mat): - consolidate_textures(base_mat.node_tree, mat.node_tree) - num_combined += 1 - slot.material = base_mat - except AttributeError: - self.report({'WARNING'}, t("Optimization.material_attribute_mismatch").format(material_name=mat.name)) - continue - else: - mat_mapping[base_name] = mat - return num_combined - - def clean_material_slots(self, meshes: List[Object]) -> int: - cleaned_slots = 0 - for obj in meshes: - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - initial_slots = len(obj.material_slots) - bpy.ops.object.material_slot_remove_unused() - cleaned_slots += initial_slots - len(obj.material_slots) - obj.select_set(False) - return cleaned_slots - - def clean_material_names(self) -> int: - cleaned_names = 0 - for obj in bpy.data.objects: - if obj.type == 'MESH': - result = clean_material_names(obj) - if result is not None: - cleaned_names += result - return cleaned_names - - def clear_unused_data_blocks(self) -> int: - initial_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - final_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) - return initial_count - final_count - - diff --git a/functions/digitigrade_legs.py b/functions/digitigrade_legs.py deleted file mode 100644 index 7553e01..0000000 --- a/functions/digitigrade_legs.py +++ /dev/null @@ -1,118 +0,0 @@ -import bpy -from ..core import common -from ..core.translations import t -import re - - - -class AvatarToolKit_OT_CreateDigitigradeLegs(bpy.types.Operator): - bl_idname = "avatar_toolkit.create_digitigrade_legs" - bl_label = t('Tools.create_digitigrade_legs.label') - bl_description = t('Tools.create_digitigrade_legs.desc') - - @classmethod - def poll(cls, context): - if(context.active_object is None): - return False - if(context.selected_editable_bones is not None): - if(len(context.selected_editable_bones) == 2): - return True - return False - - def execute(self, context): - - for digi0 in context.selected_editable_bones: - digi1: bpy.types.EditBone = None - digi2: bpy.types.EditBone = None - digi3: bpy.types.EditBone = None - - try: - digi1 = digi0.children[0] - digi2 = digi1.children[0] - digi3 = digi2.children[0] - except: - self.report({'ERROR'}, t('Tools.digitigrade_legs.error.bone_format')) - return {'CANCELLED'} - digi4 = None - try: - digi4 = digi3.children[0] - - except: - print("no toe bone. Continuing.") - digi0.select = True - digi1.select = True - digi2.select = True - digi3.select = True - if(digi4): - digi4.select = True - bpy.ops.armature.roll_clear() - bpy.ops.armature.select_all(action='DESELECT') - - #creating transform for upper leg - digi0.select = True - bpy.ops.transform.create_orientation(name="Toolkit_digi0", overwrite=True) - bpy.ops.armature.select_all(action='DESELECT') - - - #duplicate digi0 and assign it to thigh - thigh = common.duplicatebone(digi0) - bpy.ops.armature.select_all(action='DESELECT') - - #make digi2 parrallel to digi1 - digi2.align_orientation(digi0) - - #extrude thigh - thigh.select_tail = True - bpy.ops.armature.extrude_move(ARMATURE_OT_extrude={"forked":False},TRANSFORM_OT_translate=None) - #set new bone to calf varible - bpy.ops.armature.select_more() - calf = context.selected_bones[0] - bpy.ops.armature.select_all(action='DESELECT') - - #set calf end to digi2 end - calf.tail = digi2.tail - - #make copy of calf, flip it, and then align bone so that it's head is moved to match in align phase - flipedcalf = common.duplicatebone(calf) - bpy.ops.armature.select_all(action='DESELECT') - flipedcalf.select = True - bpy.ops.armature.switch_direction() - bpy.ops.armature.select_all(action='DESELECT') - flippeddigi1 = common.duplicatebone(digi1) - bpy.ops.armature.select_all(action='DESELECT') - flippeddigi1.select = True - bpy.ops.armature.switch_direction() - bpy.ops.armature.select_all(action='DESELECT') - - - - #align flipped calf to flipped middle leg to move the head - flipedcalf.align_orientation(flippeddigi1) - - flipedcalf.length = flippeddigi1.length - - #assign calf tail to flipped calf head so it moves calf's tail to be out at the perfect parallelagram - calf.head = flipedcalf.tail - - #delete helper bones - bpy.ops.armature.select_all(action='DESELECT') - flippeddigi1.select = True - bpy.ops.armature.delete() - bpy.ops.armature.select_all(action='DESELECT') - flipedcalf.select = True - bpy.ops.armature.delete() - bpy.ops.armature.select_all(action='DESELECT') - - - - #reparent the foot to the new calf so it will be part of the new foot IK chain - digi3.parent = calf - #Tada! It's done! now to rename the old 3 segments that make up the old part to noik so resonite doesn't try to select them - - digi0.name = re.compile(re.escape(""), re.IGNORECASE).sub("",digi0.name)+"" - digi1.name = re.compile(re.escape(""), re.IGNORECASE).sub("",digi1.name)+"" - digi2.name = re.compile(re.escape(""), re.IGNORECASE).sub("",digi2.name)+"" - #finally fully done! - - self.report({'INFO'}, t('Tools.digitigrade_legs.success')) - return {'FINISHED'} \ No newline at end of file diff --git a/functions/import_anything.py b/functions/import_anything.py deleted file mode 100644 index 86a5c3a..0000000 --- a/functions/import_anything.py +++ /dev/null @@ -1,188 +0,0 @@ -import bpy -from bpy.types import Operator -from bpy_extras.io_utils import ImportHelper -from ..core.importers.importer import imports, import_types -from ..core.common import remove_default_objects -from ..core.translations import t -import pathlib -import os - -VRM_IMPORTER_URL = "https://github.com/saturday06/VRM_Addon_for_Blender" - - -class AvatarToolKit_OT_ImportAnyModel(Operator, ImportHelper): - bl_idname = 'avatar_toolkit.import_any_model' - bl_label = t('Tools.import_any_model.label') - bl_description = t('Tools.import_any_model.desc') - bl_options = {'REGISTER', 'UNDO'} - files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'}) - - filter_glob: bpy.props.StringProperty(default=imports, options={'HIDDEN', 'SKIP_SAVE'}) - directory: bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'}) - - # since I wrote this myself, a bit more efficient than cats. mostly - @989onan - def execute(self, context: bpy.types.Context): - file_grouping_dict: dict[str, list[dict[str, str]]] = dict() # group our files so our importers can import them together. in the case of OBJ+MTL and others that need grouped files, this is extremely important. - remove_default_objects() - # check if we are importing multiple files - is_multi = len(self.files) > 0 - - if is_multi: - for file in self.files: - fullpath = os.path.join(self.directory, os.path.basename(file.name)) - name = pathlib.Path(fullpath).suffix.replace(".", "") - # this makes sure our imports that should be grouped stay together. - # basically the method checks for if the first value has a lambda with the same bytecode as another lambda, then it will use that value's key (ex:"obj"<->"mtl" or "fbx"), keeping same importers together - if name not in file_grouping_dict: - file_grouping_dict[name] = [] - file_grouping_dict[name].append({"name": os.path.basename(file.name)}) # emulate passing a list of files. - else: - fullpath: str = os.path.join(os.path.dirname(self.filepath), os.path.basename(self.filepath)) - name = pathlib.Path(fullpath).suffix.replace(".", "") - if name not in file_grouping_dict: - file_grouping_dict[name] = [] - file_grouping_dict[name].append({"name": fullpath}) # emulate passing a list of files. - - # import the files together to make sure things like obj import together. This is important - for file_group_name, files in file_grouping_dict.items(): - try: - # Check for VRM importer availability - if file_group_name == "vrm" and not hasattr(bpy.ops.import_scene, "vrm"): - bpy.ops.wm.vrm_importer_popup('INVOKE_DEFAULT') - return {'CANCELLED'} - - if self.directory: - import_types[file_group_name](self.directory, files, self.filepath) - else: - import_types[file_group_name]("", files, self.filepath) # give an empty directory, works just fine for 90% - except AttributeError as e: - if file_group_name == "vrm": - bpy.ops.wm.vrm_importer_popup('INVOKE_DEFAULT') - else: - self.report({'ERROR'}, t('Importing.need_importer').format(extension=file_group_name)) - print("Importer error:", e) - return {'CANCELLED'} - - self.report({'INFO'}, t('Quick_Access.import_success')) - return {'FINISHED'} - - -class VRMImporterPopup(Operator): - bl_idname = "wm.vrm_importer_popup" - bl_label = "VRM Importer Not Installed" - - def execute(self, context): - return {'FINISHED'} - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self, width=300) - - def draw(self, context): - layout = self.layout - layout.label(text="VRM importer plugin is not installed.") - layout.label(text="Please install it to import VRM files.") - layout.operator("wm.url_open", text="Get VRM Importer").url = VRM_IMPORTER_URL - -#TODO: This needs to be done with our own MMD importer. -""" -#stolen from cats. Oh wait I made this code riiiiiiight - @989onan - -class ImportMMDAnimation(bpy.types.Operator, ImportHelper): - bl_idname = 'avatar_toolkit.import_mmd_animation' - bl_label = t('Importer.mmd_anim_importer.label') - bl_description = t('Importer.mmd_anim_importer.desc') - bl_options = {'INTERNAL', 'UNDO'} - - filter_glob: bpy.props.StringProperty( - default="*.vmd", - options={'HIDDEN'} - ) - files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'}) - directory: bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'}) - filepath: bpy.props.StringProperty() - - @classmethod - def poll(cls, context): - if common.get_armature(context) is None: - return False - return True - - def execute(self, context): - - # Make sure that the first layer is visible - if hasattr(context.scene, 'layers'): - context.scene.layers[0] = True - - filename, extension = os.path.splitext(self.filepath) - - if(extension == ".vmd"): - - #A dictionary to change the current model to MMD importer compatable temporarily - bonedict = { - "chest":"UpperBody", - "neck":"Neck", - "head":"Head", - "hips":"Center", - "spine":"LowerBody", - - "right_wrist":"Wrist_R", - "right_elbow":"Elbow_R", - "right_arm":"Arm_R", - "right_shoulder":"Shoulder_R", - "right_leg":"Leg_R", - "right_knee":"Knee_R", - "right_ankle":"Ankle_R", - "right_toe":"Toe_R", - - - "left_wrist":"Wrist_L", - "left_elbow":"Elbow_L", - "left_arm":"Arm_L", - "left_shoulder":"Shoulder_L", - "left_leg":"Leg_L", - "left_knee":"Knee_L", - "left_ankle":"Ankle_L", - "left_toe":"Toe_L" - - } - - armature = common.get_armature(context) - common.unselect_all() - common.Set_Mode(context, 'OBJECT') - common.unselect_all() - common.set_active(armature) - - orig_names = dict() - reverse_bone_lookup = dict() - for (preferred_name, name_list) in bone_names.items(): - for name in name_list: - reverse_bone_lookup[name] = preferred_name - - - for bone in armature.data.bones: - if common.simplify_bonename(bone.name) in reverse_bone_lookup and reverse_bone_lookup[common.simplify_bonename(bone.name)] in bonedict: - orig_names[bonedict[reverse_bone_lookup[common.simplify_bonename(bone.name)]]] = bone.name - bone.name = bonedict[reverse_bone_lookup[common.simplify_bonename(bone.name)]] - try: - bpy.ops.mmd_tools.import_vmd(filepath=self.filepath,bone_mapper='RENAMED_BONES',use_underscore=True, dictionary='INTERNAL') - except AttributeError as e: - print("importer error was:") - print(e) - print(t('Importing.importer_search_term')) - common.open_web_after_delay_multi_threaded(delay=12, url=t('Importing.importer_search_term').format(extension = "MMD")) - self.report({'ERROR'},t('Importing.need_importer').format(extension = "MMD")) - - return {'CANCELLED'} - - #iterate through bones and put them back, therefore blender API will change the animation to be correct. - #this is because renaming bones fixes the animation targets in the data model. - for bone in armature.data.bones: - if common.simplify_bonename(bone.name) in orig_names: - bone.name = orig_names[common.simplify_bonename(bone.name)] - - common.unselect_all() - common.Set_Mode(context, 'OBJECT') - common.unselect_all() - common.set_active(armature) - - return {'FINISHED'} """ \ No newline at end of file diff --git a/functions/mesh_tools.py b/functions/mesh_tools.py deleted file mode 100644 index b9f4fa3..0000000 --- a/functions/mesh_tools.py +++ /dev/null @@ -1,212 +0,0 @@ -import numpy as np -import bpy -from typing import List, Optional, Set -from bpy.types import Operator, Context, Object -from ..core.common import fix_uv_coordinates, get_selected_armature, get_all_meshes, is_valid_armature, apply_shapekey_to_basis, has_shapekeys, select_current_armature, init_progress, update_progress, finish_progress -from ..core.translations import t - - -class AvatarToolkit_OT_RemoveUnusedShapekeys(bpy.types.Operator): - tolerance: bpy.props.FloatProperty(name=t("Tools.remove_unused_shapekeys.tolerance.label"), default=0.001, description=t("Tools.remove_unused_shapekeys.tolerance.desc")) - bl_idname = "avatar_toolkit.remove_unused_shapekeys" - bl_label = t("Tools.remove_unused_shapekeys.label") - bl_description = t("Tools.remove_unused_shapekeys.desc") - bl_options = {'REGISTER', 'UNDO'} - - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) and (len(get_all_meshes(context)) > 0) and (context.mode == "OBJECT") - - def execute(self, context: Context) -> set[str]: - #Shamefully taken from: https://blender.stackexchange.com/a/237611 - #at least I am crediting them - @989onan - for ob in get_all_meshes(context): - if not ob.data.shape_keys: continue - if not ob.data.shape_keys.use_relative: continue - - kbs = ob.data.shape_keys.key_blocks - nverts = len(ob.data.vertices) - to_delete = [] - - # Cache locs for rel keys since many keys have the same rel key - cache = {} - - locs = np.empty(3*nverts, dtype=np.float32) - - for kb in kbs: - if kb == kb.relative_key: continue - - kb.data.foreach_get("co", locs) - - if kb.relative_key.name not in cache: - rel_locs = np.empty(3*nverts, dtype=np.float32) - kb.relative_key.data.foreach_get("co", rel_locs) - cache[kb.relative_key.name] = rel_locs - rel_locs = cache[kb.relative_key.name] - - locs -= rel_locs - if (np.abs(locs) < self.tolerance).all(): - to_delete.append(kb.name) - - for kb_name in to_delete: - if ("-" in kb_name) or ("=" in kb_name) or ("~" in kb_name): - continue - ob.shape_key_remove(ob.data.shape_keys.key_blocks[kb_name]) - - return {'FINISHED'} - - -class AvatarToolkit_OT_ApplyShapeKey(bpy.types.Operator): - bl_idname = "avatar_toolkit.apply_shape_key" - bl_label = t("Tools.apply_shape_key.label") - bl_description = t("Tools.apply_shape_key.desc") - bl_options = {'REGISTER', 'UNDO'} - - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) and (len(get_all_meshes(context)) > 0) and (context.mode == "OBJECT") and context.view_layer.objects.active is not None and has_shapekeys(context.view_layer.objects.active) - - def execute(self, context: Context) -> set[str]: - obj: bpy.types.Object = context.view_layer.objects.active - - - if (apply_shapekey_to_basis(context,obj,obj.active_shape_key.name,False)): - return {'FINISHED'} - else: - self.report({'ERROR'}, t("Tools.apply_shape_key.error")) - return {'FINISHED'} - - -class AvatarToolKit_OT_JoinAllMeshes(Operator): - bl_idname = "avatar_toolkit.join_all_meshes" - bl_label = t("Optimization.join_all_meshes.label") - bl_description = t("Optimization.join_all_meshes.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) - - def execute(self, context: Context) -> Set[str]: - try: - self.join_all_meshes(context) - return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, f"{t('Optimization.join_error')}: {str(e)}") - return {'CANCELLED'} - - def join_all_meshes(self, context: Context) -> None: - if not select_current_armature(context): - raise ValueError(t("Optimization.no_armature_selected")) - - armature = get_selected_armature(context) - - bpy.ops.object.mode_set(mode='OBJECT') - - bpy.ops.object.select_all(action='DESELECT') - - meshes: List[Object] = get_all_meshes(context) - - if not meshes: - raise ValueError(t("Optimization.no_meshes_found")) - - init_progress(context, 5) # 5 steps in total - - update_progress(self, context, t("Optimization.selecting_meshes")) - for mesh in meshes: - mesh.select_set(True) - - if bpy.context.selected_objects: - bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] - - update_progress(self, context, t("Optimization.joining_meshes")) - try: - bpy.ops.object.join() - except RuntimeError as e: - raise RuntimeError(f"{t('Optimization.join_operation_failed')}: {str(e)}") - - update_progress(self, context, t("Optimization.applying_transforms")) - try: - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - except RuntimeError as e: - raise RuntimeError(f"{t('Optimization.transform_apply_failed')}: {str(e)}") - - update_progress(self, context, t("Optimization.fixing_uv_coordinates")) - bpy.ops.object.mode_set(mode='OBJECT') - fix_uv_coordinates(context) - - update_progress(self, context, t("Optimization.finalizing")) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - self.report({'INFO'}, t("Optimization.meshes_joined")) - else: - raise ValueError(t("Optimization.no_mesh_selected")) - - context.view_layer.objects.active = armature - finish_progress(context) - - - -class AvatarToolKit_OT_JoinSelectedMeshes(Operator): - bl_idname = "avatar_toolkit.join_selected_meshes" - bl_label = t("Optimization.join_selected_meshes.label") - bl_description = t("Optimization.join_selected_meshes.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - return context.mode == 'OBJECT' and len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1 - - def execute(self, context: Context) -> Set[str]: - try: - self.join_selected_meshes(context) - return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, f"{t('Optimization.join_error')}: {str(e)}") - return {'CANCELLED'} - - def join_selected_meshes(self, context: Context) -> None: - selected_objects: List[Object] = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH'] - - if len(selected_objects) < 2: - raise ValueError(t("Optimization.select_at_least_two_meshes")) - - init_progress(context, 5) # 5 steps in total - - update_progress(self, context, t("Optimization.preparing_meshes")) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - - update_progress(self, context, t("Optimization.selecting_meshes")) - for obj in selected_objects: - obj.select_set(True) - - if bpy.context.selected_objects: - bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] - - update_progress(self, context, t("Optimization.joining_meshes")) - try: - bpy.ops.object.join() - except RuntimeError as e: - raise RuntimeError(f"{t('Optimization.join_operation_failed')}: {str(e)}") - - update_progress(self, context, t("Optimization.applying_transforms")) - try: - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - except RuntimeError as e: - raise RuntimeError(f"{t('Optimization.transform_apply_failed')}: {str(e)}") - - update_progress(self, context, t("Optimization.fixing_uv_coordinates")) - fix_uv_coordinates(context) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - self.report({'INFO'}, t("Optimization.selected_meshes_joined")) - else: - raise ValueError(t("Optimization.no_mesh_selected")) - - finish_progress(context) \ No newline at end of file diff --git a/functions/mmd_functions.py b/functions/mmd_functions.py deleted file mode 100644 index c5bff59..0000000 --- a/functions/mmd_functions.py +++ /dev/null @@ -1,396 +0,0 @@ -import bpy -import numpy as np -import re -from bpy.types import Operator, Context, Material, ShaderNodeTexImage, ShaderNodeGroup, Object -from ..core.translations import t -from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress -from ..functions.additional_tools import AvatarToolKit_OT_ConnectBones, AvatarToolKit_OT_DeleteBoneConstraints -from ..functions.armature_modifying import AvatarToolkit_OT_RemoveZeroWeightBones, AvatarToolkit_OT_MergeBonesToParents - - -class AvatarToolKit_OT_CleanupMesh(Operator): - bl_idname = "avatar_toolkit.cleanup_mesh" - bl_label = t("MMDOptions.cleanup_mesh.label") - bl_description = t("MMDOptions.cleanup_mesh.desc") - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context: Context) -> set[str]: - init_progress(context, 4) - - update_progress(self, context, t("MMDOptions.removing_empty_objects")) - bpy.ops.object.select_all(action='DESELECT') - for obj in context.scene.objects: - if obj.type == 'EMPTY': - obj.select_set(True) - bpy.ops.object.delete() - - update_progress(self, context, t("MMDOptions.removing_unused_vertex_groups")) - for obj in get_all_meshes(context): - self.remove_unused_vertex_groups(obj) - - update_progress(self, context, t("MMDOptions.removing_unused_vertices")) - for obj in get_all_meshes(context): - self.remove_unused_vertices(obj) - - update_progress(self, context, t("MMDOptions.removing_empty_shape_keys")) - for obj in get_all_meshes(context): - self.remove_empty_shape_keys(obj) - - finish_progress(context) - return {'FINISHED'} - - def remove_unused_vertex_groups(self, obj): - vgroups = obj.vertex_groups - for vgroup in vgroups: - if not any(vgroup.index in [g.group for g in v.groups] for v in obj.data.vertices): - vgroups.remove(vgroup) - - def remove_unused_vertices(self, obj): - bpy.ops.object.select_all(action='DESELECT') - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.delete_loose() - bpy.ops.object.mode_set(mode='OBJECT') - - def remove_empty_shape_keys(self, obj): - if obj.data.shape_keys: - for key in obj.data.shape_keys.key_blocks: - if key.name != 'Basis' and all(abs(key.data[i].co[j] - obj.data.shape_keys.reference_key.data[i].co[j]) < 0.0001 for i in range(len(key.data)) for j in range(3)): - obj.shape_key_remove(key) - - -class AvatarToolKit_OT_OptimizeWeights(Operator): - bl_idname = "avatar_toolkit.optimize_weights" - bl_label = t("MMDOptions.optimize_weights.label") - bl_description = t("MMDOptions.optimize_weights.desc") - bl_options = {'REGISTER', 'UNDO'} - - max_weights: bpy.props.IntProperty( - name=t("MMDOptions.max_weights.label"), - description=t("MMDOptions.max_weights.desc"), - default=4, - min=1, - max=8 - ) - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not armature: - self.report({'ERROR'}, t("MMDOptions.no_armature_selected")) - return {'CANCELLED'} - - init_progress(context, 4) - - update_progress(self, context, t("MMDOptions.merging_weights")) - for obj in get_all_meshes(context): - for modifier in obj.modifiers: - if modifier.type == 'ARMATURE' and modifier.object != armature: - bpy.ops.object.modifier_apply(modifier=modifier.name) - - update_progress(self, context, t("MMDOptions.removing_zero_weight_bones")) - bpy.ops.avatar_toolkit.remove_zero_weight_bones('EXEC_DEFAULT') - - update_progress(self, context, t("MMDOptions.limiting_vertex_weights")) - for obj in get_all_meshes(context): - self.limit_vertex_weights(obj) - - update_progress(self, context, t("MMDOptions.weight_optimization_complete")) - finish_progress(context) - return {'FINISHED'} - - def limit_vertex_weights(self, obj): - for v in obj.data.vertices: - if len(v.groups) > self.max_weights: - sorted_groups = sorted(v.groups, key=lambda g: g.weight, reverse=True) - for g in sorted_groups[self.max_weights:]: - obj.vertex_groups[g.group].remove([v.index]) - - -class AvatarToolKit_OT_OptimizeArmature(Operator): - bl_idname = "avatar_toolkit.optimize_armature" - bl_label = t("MMDOptions.optimize_armature.label") - bl_description = t("MMDOptions.optimize_armature.desc") - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not armature: - self.report({'ERROR'}, t("MMDOptions.no_armature_selected")) - return {'CANCELLED'} - - init_progress(context, 9) - - # Ensure proper object selection and mode - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - context.view_layer.objects.active = armature - - # Store initial transforms - bpy.ops.object.mode_set(mode='EDIT') - initial_transforms = {} - for bone in armature.data.edit_bones: - initial_transforms[bone.name] = { - 'head': bone.head.copy(), - 'tail': bone.tail.copy(), - 'roll': bone.roll, - 'matrix': bone.matrix.copy(), - 'parent': bone.parent.name if bone.parent else None - } - - update_progress(self, context, t("MMDOptions.deleting_bone_constraints")) - bpy.ops.avatar_toolkit.delete_bone_constraints('EXEC_DEFAULT') - - update_progress(self, context, t("MMDOptions.merging_bones_to_parents")) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='EDIT') - try: - bpy.ops.avatar_toolkit.merge_bones_to_parents('EXEC_DEFAULT') - except RuntimeError as e: - self.report({'WARNING'}, f"Failed to merge bones to parents: {str(e)}") - - update_progress(self, context, t("MMDOptions.reordering_bones")) - self.reorder_bones(context, armature) - - update_progress(self, context, t("MMDOptions.fixing_armature_names")) - self.fix_armature_names(armature) - - update_progress(self, context, t("MMDOptions.renaming_bones")) - self.rename_bones(armature) - - # Restore original bone transforms - bpy.ops.object.mode_set(mode='EDIT') - for bone_name, transform in initial_transforms.items(): - if bone_name in armature.data.edit_bones: - bone = armature.data.edit_bones[bone_name] - bone.head = transform['head'] - bone.tail = transform['tail'] - bone.roll = transform['roll'] - bone.matrix = transform['matrix'] - - update_progress(self, context, t("MMDOptions.armature_optimization_complete")) - - # Ensure we end in object mode with proper selection - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - context.view_layer.objects.active = armature - - finish_progress(context) - return {'FINISHED'} - - def reorder_bones(self, context: Context, armature: bpy.types.Object): - def sort_bones(bone): - children = sorted(bone.children, key=lambda b: b.name) - for child in children: - sort_bones(child) - - bpy.ops.object.mode_set(mode='EDIT') - root_bones = [bone for bone in armature.data.edit_bones if not bone.parent] - for root_bone in sorted(root_bones, key=lambda b: b.name): - sort_bones(root_bone) - - def fix_armature_names(self, armature): - for bone in armature.data.bones: - fixed_name = self.get_fixed_bone_name(bone.name) - if fixed_name != bone.name: - bone.name = fixed_name - - def get_fixed_bone_name(self, name): - name = name.replace(' ', '_') - name = re.sub(r'[^\w]', '', name) - return name - - def rename_bones(self, armature): - for bone in armature.data.bones: - new_name = self.get_standardized_bone_name(bone.name) - if new_name != bone.name: - bone.name = new_name - - def get_standardized_bone_name(self, name): - if 'left' in name.lower(): - return f"Left_{name}" - elif 'right' in name.lower(): - return f"Right_{name}" - return name - -def bake_mmd_colors(node_base_tex: ShaderNodeTexImage, node_mmd_shader: ShaderNodeGroup): - ambient_color_input = node_mmd_shader.inputs.get("Ambient Color") - diffuse_color_input = node_mmd_shader.inputs.get("Diffuse Color") - - if not ambient_color_input or not diffuse_color_input: - return node_base_tex, None - - ambient_color = np.array(ambient_color_input.default_value[:3]) - diffuse_color = np.array(diffuse_color_input.default_value[:3]) - mmd_color = np.clip(ambient_color + diffuse_color * 0.6, 0, 1) - - if not node_base_tex or not node_base_tex.image: - principled_base_color = np.append(mmd_color, 1) - return None, principled_base_color - - base_tex_image = node_base_tex.image - if not base_tex_image.pixels: - return node_base_tex, None - - if base_tex_image.colorspace_settings.name == 'sRGB': - is_small_mask = mmd_color < 0.0031308 - mmd_color[is_small_mask] = np.where(mmd_color[is_small_mask] < 0.0, 0, mmd_color[is_small_mask] * 12.92) - is_large_mask = np.invert(is_small_mask) - mmd_color[is_large_mask] = (mmd_color[is_large_mask] ** (1.0 / 2.4)) * 1.055 - 0.055 - - pixels = np.array(base_tex_image.pixels).reshape((-1, 4)) - pixels[:, :3] *= mmd_color - - baked_image = bpy.data.images.new(base_tex_image.name + "MMDCatsBaked", - width=base_tex_image.size[0], - height=base_tex_image.size[1], - alpha=True) - baked_image.filepath = bpy.path.abspath("//" + base_tex_image.name + ".png") - baked_image.file_format = 'PNG' - baked_image.colorspace_settings.name = base_tex_image.colorspace_settings.name - - baked_image.pixels = pixels.flatten() - node_base_tex.image = baked_image - - if bpy.data.is_saved: - baked_image.save() - - return node_base_tex, None - -def add_principled_shader(material: Material, bake_mmd=True): - node_tree = material.node_tree - nodes = node_tree.nodes - links = node_tree.links - - principled_shader = nodes.new(type="ShaderNodeBsdfPrincipled") - principled_shader.label = "Cats Export Shader" - principled_shader.location = (501, -500) - - output_shader = nodes.new(type="ShaderNodeOutputMaterial") - output_shader.label = "Cats Export" - output_shader.location = (801, -500) - - links.new(principled_shader.outputs["BSDF"], output_shader.inputs["Surface"]) - - node_base_tex = nodes.get("mmd_base_tex") or next((n for n in nodes if n.type == 'TEX_IMAGE'), None) - node_mmd_shader = nodes.get("mmd_shader") - - if node_mmd_shader and bake_mmd: - node_base_tex, principled_base_color = bake_mmd_colors(node_base_tex, node_mmd_shader) - else: - principled_base_color = None - - if node_base_tex and node_base_tex.image: - links.new(node_base_tex.outputs["Color"], principled_shader.inputs["Base Color"]) - links.new(node_base_tex.outputs["Alpha"], principled_shader.inputs["Alpha"]) - elif principled_base_color is not None: - principled_shader.inputs["Base Color"].default_value = principled_base_color - - principled_shader.inputs["Specular IOR Level"].default_value = 0 - principled_shader.inputs["Roughness"].default_value = 0.9 - principled_shader.inputs["Sheen Tint"].default_value = (1.0, 1.0, 1.0, 1.0) - principled_shader.inputs["Coat Roughness"].default_value = 0 - principled_shader.inputs["IOR"].default_value = 1.45 - - # Handle transparency - if material.blend_method != 'OPAQUE': - principled_shader.inputs["Alpha"].default_value = material.alpha_threshold - material.blend_method = 'CLIP' - -def fix_mmd_shader(material: Material): - mmd_shader_node = material.node_tree.nodes.get("mmd_shader") - if mmd_shader_node: - reflect_input = mmd_shader_node.inputs.get("Reflect") - if reflect_input: - reflect_input.default_value = 1 - -def fix_vrm_shader(material: Material): - nodes = material.node_tree.nodes - is_vrm_mat = False - for node in nodes: - if hasattr(node, 'node_tree') and 'MToon_unversioned' in node.node_tree.name: - node.location[0] = 200 - node.inputs['ReceiveShadow_Texture_alpha'].default_value = -10000 - node.inputs['ShadeTexture'].default_value = (1.0, 1.0, 1.0, 1.0) - node.inputs['Emission_Texture'].default_value = (0.0, 0.0, 0.0, 0.0) - node.inputs['SphereAddTexture'].default_value = (0.0, 0.0, 0.0, 0.0) - node_input = node.inputs.get('NomalmapTexture') or node.inputs.get('NormalmapTexture') - node_input.default_value = (1.0, 1.0, 1.0, 1.0) - is_vrm_mat = True - break - - if is_vrm_mat: - nodes_to_keep = ['DiffuseColor', 'MainTexture', 'Emission_Texture'] - if 'HAIR' in material.name: - nodes_to_keep.append('SphereAddTexture') - - for node in nodes: - if ('RGB' in node.name or 'Value' in node.name or 'Image Texture' in node.name or - 'UV Map' in node.name or 'Mapping' in node.name): - if node.label not in nodes_to_keep: - material.node_tree.links = [link for link in material.node_tree.links - if not (link.from_node == node or link.to_node == node)] - - -class AvatarToolKit_OT_ConvertMaterials(Operator): - bl_idname = "avatar_toolkit.convert_materials" - bl_label = t("MMDOptions.convert_materials.label") - bl_description = t("MMDOptions.convert_materials.desc") - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context: Context) -> set[str]: - meshes = get_all_meshes(context) - init_progress(context, len(meshes)) - - for obj in meshes: - update_progress(self, context, t("MMDOptions.converting_materials").format(name=obj.name)) - self.convert_materials_for_mesh(obj) - - finish_progress(context) - return {'FINISHED'} - - def convert_materials_for_mesh(self, mesh: Object): - for mat_slot in mesh.material_slots: - if mat_slot.material: - mat = mat_slot.material - mat.use_nodes = True - - # Add Principled BSDF shader - add_principled_shader(mat) - - # Fix MMD shader if present - fix_mmd_shader(mat) - - # Fix VRM shader if present - fix_vrm_shader(mat) - - # Clean up unused nodes - self.clean_unused_nodes(mat) - - def clean_unused_nodes(self, material: Material): - nodes = material.node_tree.nodes - links = material.node_tree.links - - used_nodes = set() - output_node = next((n for n in nodes if n.type == 'OUTPUT_MATERIAL'), None) - - if output_node: - self.traverse_node_tree(output_node, used_nodes) - - for node in nodes: - if node not in used_nodes: - nodes.remove(node) - - def traverse_node_tree(self, node, used_nodes): - used_nodes.add(node) - for input in node.inputs: - for link in input.links: - if link.from_node not in used_nodes: - self.traverse_node_tree(link.from_node, used_nodes) - diff --git a/functions/pose_mode.py b/functions/pose_mode.py new file mode 100644 index 0000000..27aa135 --- /dev/null +++ b/functions/pose_mode.py @@ -0,0 +1,120 @@ +import bpy +import numpy as np +from bpy.types import Operator, Context, Object +from typing import List +from ..core.translations import t +from ..core.common import ( + get_active_armature, + get_all_meshes, + apply_pose_as_rest, + apply_armature_to_mesh, + apply_armature_to_mesh_with_shapekeys, + validate_armature +) + +class AvatarToolkit_OT_StartPoseMode(Operator): + bl_idname = 'avatar_toolkit.start_pose_mode' + bl_label = t("Quick_Access.start_pose_mode.label") + bl_description = t("Quick_Access.start_pose_mode.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + if not armature or context.mode == "POSE": + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> set[str]: + armature = get_active_armature(context) + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) + bpy.ops.object.mode_set(mode='POSE') + return {'FINISHED'} + +class AvatarToolkit_OT_StopPoseMode(Operator): + bl_idname = 'avatar_toolkit.stop_pose_mode' + bl_label = t("Quick_Access.stop_pose_mode.label") + bl_description = t("Quick_Access.stop_pose_mode.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return get_active_armature(context) and context.mode == "POSE" + + def execute(self, context: Context) -> set[str]: + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} + +class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): + bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' + bl_label = t("Quick_Access.apply_pose_as_shapekey.label") + bl_description = t("Quick_Access.apply_pose_as_shapekey.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + if not armature or context.mode != 'POSE': + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context): + armature_obj = get_active_armature(context) + mesh_objects = get_all_meshes(context) + + for mesh_obj in mesh_objects: + if not mesh_obj.data: + continue + + if not mesh_obj.data.shape_keys: + mesh_obj.shape_key_add(name='Basis') + + new_shape = mesh_obj.shape_key_add(name='Pose_Shapekey', from_mix=False) + + depsgraph = context.evaluated_depsgraph_get() + eval_mesh = mesh_obj.evaluated_get(depsgraph) + + for i, v in enumerate(eval_mesh.data.vertices): + new_shape.data[i].co = v.co.copy() + + bpy.ops.pose.select_all(action='SELECT') + bpy.ops.pose.transforms_clear() + bpy.ops.object.mode_set(mode='OBJECT') + + self.report({'INFO'}, t('Tools.apply_pose_as_rest.success')) + return {'FINISHED'} + +class AvatarToolkit_OT_ApplyPoseAsRest(Operator): + bl_idname = 'avatar_toolkit.apply_pose_as_rest' + bl_label = t("Quick_Access.apply_pose_as_rest.label") + bl_description = t("Quick_Access.apply_pose_as_rest.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + if not armature or context.mode != "POSE": + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context): + if not apply_pose_as_rest( + context=context, + armature_obj=get_active_armature(context), + meshes=get_all_meshes(context) + ): + self.report({'ERROR'}, t("Quick_Access.apply_armature_failed")) + return {'CANCELLED'} + + self.report({'INFO'}, t("Tools.apply_pose_as_rest.success")) + return {'FINISHED'} diff --git a/functions/remove_doubles_safely.py b/functions/remove_doubles_safely.py deleted file mode 100644 index 3e3ed42..0000000 --- a/functions/remove_doubles_safely.py +++ /dev/null @@ -1,308 +0,0 @@ -import bpy -from typing import List, TypedDict, Any -from bpy.types import Operator, Context, Object -from ..core.common import get_selected_armature, is_valid_armature, select_current_armature, get_all_meshes -from ..core.translations import t - -class meshEntry(TypedDict): - mesh: Object - shapekeys: list[str] - vertices: int - cur_vertex_pass: int - - -class AvatarToolKit_OT_RemoveDoublesSafelyAdvanced(Operator): - bl_idname = "avatar_toolkit.remove_doubles_safely_advanced" - bl_label = t("Optimization.remove_doubles_safely_advanced.label") - bl_description = t("Optimization.remove_doubles_safely_advanced.desc") - bl_options = {'REGISTER', 'UNDO'} - - merge_distance: bpy.props.FloatProperty(default=0.0001) - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) - - def draw(self, context): - layout = self.layout - layout.label(text="This process may take a long time.") - layout.label(text="Blender may seem unresponsive during this operation.") - layout.label(text="Please be patient and wait for it to complete.") - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def execute(self, context: Context): - bpy.ops.avatar_toolkit.remove_doubles_safely('INVOKE_DEFAULT', advanced=True, merge_distance=self.merge_distance) - return {'RUNNING_MODAL'} - - - -class AvatarToolKit_OT_RemoveDoublesSafely(Operator): - bl_idname = "avatar_toolkit.remove_doubles_safely" - bl_label = t("Optimization.remove_doubles_safely.label") - bl_description = t("Optimization.remove_doubles_safely.desc") - bl_options = {'REGISTER', 'UNDO'} - objects_to_do: list[meshEntry] = [] - merge_distance: bpy.props.FloatProperty(default=0.0001) - advanced: bpy.props.BoolProperty(default=False) - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) - - def execute(self, context: Context) -> set: - if not select_current_armature(context): - self.report({'WARNING'}, t("Optimization.no_armature_selected")) - return {'CANCELLED'} - - armature = get_selected_armature(context) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - objects: List[Object] = get_all_meshes(context) - self.objects_to_do = [] - - for mesh in objects: - if mesh.data.name not in [stored_object["mesh"].data.name for stored_object in self.objects_to_do]: - print("setting up data for object" + mesh.name) - mesh_shapekeys = {"mesh":mesh,"shapekeys":[],"vertices":0,"cur_vertex_pass":0} - mesh_data: bpy.types.Mesh = mesh.data - shape: bpy.types.ShapeKey = None - mesh_shapekeys["vertices"] = len(mesh_data.vertices) - bpy.ops.object.mode_set(mode='OBJECT') - mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices)) - - if mesh_data.shape_keys: - for shape in mesh_data.shape_keys.key_blocks: - mesh_shapekeys["shapekeys"].append(shape.name) - if self.advanced: - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = mesh - bpy.ops.object.select_all(action='DESELECT') - print("queued data for "+mesh.name+" is: ") - print(mesh_shapekeys) - self.objects_to_do.append(mesh_shapekeys) - - return {'FINISHED'} - - def invoke(self, context: Context, event: bpy.types.Event) -> set: - print("==================") - print("==================") - print("==================") - print("==================") - print("starting modal execution of merge doubles safely.") - print("==================") - print("==================") - print("==================") - print("==================") - self.execute(context) - context.window_manager.modal_handler_add(self) - return {'RUNNING_MODAL'} - - def modify_mesh(self, context: Context, mesh: meshEntry): - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - mesh_data: bpy.types.Mesh = mesh["mesh"].data - bpy.ops.object.mode_set(mode='EDIT') - - bpy.ops.object.mode_set(mode='OBJECT') - for index, point in enumerate(mesh["mesh"].active_shape_key.points): - if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz: - mesh_data.vertices[index].select = True - print("shapekey has a moved vertex at index \""+str(index)+"\", excluding from simple double merging!") - bpy.ops.object.mode_set(mode='EDIT') - - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - print("finished shapekey basic.") - - def modify_mesh_advanced(self, context: Context, mesh_entry: meshEntry): - - final_merged_vertex_group: list[int] = [] - initialized_final: bool = False - - for shapekey_name in mesh_entry["shapekeys"]: - mesh = mesh_entry["mesh"] - - - - #make a copy to do double merge testing on for the current vertex - context.view_layer.objects.active = mesh - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = mesh - mesh_data: bpy.types.Mesh = mesh.data - vertices_original: dict[int,Any] = {} - original_count: int = len(mesh_data.vertices) - mesh.select_set(True) - mesh.active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekey_name) - bpy.ops.object.duplicate() - bpy.ops.object.shape_key_move(type='TOP') - - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.object.mode_set(mode='OBJECT') - - bpy.ops.object.shape_key_remove(all=True, apply_mix=False) - - mesh = context.view_layer.objects.active - mesh.name = shapekey_name+"_object_is_"+mesh.name - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - - mesh.select_set(True) - context.view_layer.objects.active = mesh - mesh_data: bpy.types.Mesh = mesh.data - bpy.ops.object.mode_set(mode='EDIT') - - - - bpy.ops.object.mode_set(mode='OBJECT') - for index, merged_point in enumerate(mesh_data.vertices): - vertices_original[index] = merged_point.co.xyz - - - bpy.ops.object.mode_set(mode='OBJECT') - mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices)) - - select_target_vertex = [False]*len(mesh_data.vertices) - try: - select_target_vertex[mesh_entry["cur_vertex_pass"]] = True - except: - bpy.ops.object.delete() #remove our double merge testing object for this shapekey, since we merged doubles on it, it will be useless. - return True - - - bpy.ops.object.mode_set(mode='OBJECT') - mesh_data.vertices.foreach_set("select",select_target_vertex) - bpy.ops.object.mode_set(mode='EDIT') - for i in range(0,20): #for some reason, if using merge to unselected on a vertex, the vertex will only merge to 1 other vertex. so we gotta spam it to fix it. - bpy.ops.mesh.remove_doubles(threshold=self.merge_distance, use_unselected=True, use_sharp_edge_from_normals=False) - bpy.ops.object.mode_set(mode='OBJECT') - - merged_vertices: list[int] = [] - mesh_data_vertices: dict[int,Any] = {} - for idx,vertex in enumerate(mesh_data.vertices): - mesh_data_vertices[idx] = vertex.co.xyz - - #I'm loosing my mind with indices because I cannot keep so many numbers in my head. I will have to use 2 pointers - # yes this can be simplified more, but the mountains of errors with using a normal for statement are making me - # loose my mind. This is hard. - @989onan - #Below is the magic that determines whether or not vertices were merged and then puts the vertices - #that were merged into a list. - @989onan - - i = 0 - j = 0 - while(i len(mesh_data.vertices): - merged_vertices.append(i) - j = j-1 - elif mesh_data.vertices[j].co.xyz != vertices_original[i]: - merged_vertices.append(i) - j = j-1 - elif vertices_original[i] == vertices_original[mesh_entry["cur_vertex_pass"]]: - merged_vertices.append(i) - - i = i+1 - j = j+1 - - - - #give our final set of points some inital data. we're looking for points that are merged on every shape key (and therefore appear in every version of merged_vertices). - # If we initialize the array with points from the first version of merged_vertices, then we can remove the vertices from final that don't get merged from - #every future version of merged_vertices with the "if merged_point not in merged_vertices:" code. - if initialized_final == False: - for point in merged_vertices: - final_merged_vertex_group.append(point) - initialized_final = True - #iterate through a copy of final vertex groups to prevent crash. If a vertex was merged before, but didn't merge in this vertex, - # then the vertex shouldn't be merged because it moves away from the vertex we are double merging now (ex: bottom of mouth moving away from top when opening on a shapekey) - @989onan - for merged_point in final_merged_vertex_group[:]: - if merged_point not in merged_vertices: - final_merged_vertex_group.remove(merged_point) - - - - bpy.ops.object.mode_set(mode='OBJECT') - mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices)) - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.delete() #remove our double merge testing object for this shapekey, since we merged doubles on it, it will be useless. - context.view_layer.objects.active = mesh_entry["mesh"] - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - context.view_layer.objects.active = mesh_entry["mesh"] - mesh_entry["mesh"].select_set(True) - - original_mesh_data: bpy.types.Mesh = mesh_entry["mesh"].data - select_target_group = [False]*len(original_mesh_data.vertices) - - - for vertex_index in final_merged_vertex_group: - select_target_group[vertex_index] = True - - bpy.ops.object.mode_set(mode='OBJECT') - original_mesh_data.vertices.foreach_set("select",select_target_group) - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.remove_doubles(threshold=self.merge_distance, use_unselected=False, use_sharp_edge_from_normals=False) - bpy.ops.object.mode_set(mode='OBJECT') - original_mesh_data.vertices.foreach_set("select",[False]*len(original_mesh_data.vertices)) - print("finished advanced merge doubles for single vertex at index: "+str(mesh_entry["cur_vertex_pass"])) - return not (len(final_merged_vertex_group) > 1) - - def modal(self, context: Context, event: bpy.types.Event) -> set: - if len(self.objects_to_do) > 0: - bpy.ops.object.select_all(action='DESELECT') - mesh: meshEntry = self.objects_to_do[0] - mesh_data: bpy.types.Mesh = mesh["mesh"].data - if (len(mesh['shapekeys']) > 0) and (not self.advanced): - shapekeyname: str = mesh['shapekeys'].pop(0) - - target_shapekey: int = mesh_data.shape_keys.key_blocks.find(shapekeyname) - mesh["mesh"].active_shape_key_index = target_shapekey - print("doing shapekey \""+shapekeyname+"\" on mesh \""+mesh['mesh'].name+"\".") - self.modify_mesh(context, mesh) - elif not (mesh_data.shape_keys): - print("doing mesh with no shapekeys named \""+mesh['mesh'].name+"\".") - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - bpy.ops.object.mode_set(mode='EDIT') - mesh_data.vertices.foreach_set("select",[False]*len(mesh_data.vertices)) - - bpy.ops.mesh.select_all(action="INVERT") - bpy.ops.mesh.remove_doubles(threshold=self.merge_distance,use_unselected=False) - - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - self.objects_to_do.pop(0) - elif (not (mesh["cur_vertex_pass"] > mesh["vertices"])) and self.advanced: - - print("doing a merge by single vertex index at index "+str(mesh["cur_vertex_pass"])) - - if self.modify_mesh_advanced(context, mesh): - mesh["cur_vertex_pass"] = mesh["cur_vertex_pass"]+1 - else: - print("finishing double merge object.") - if not self.advanced: - mesh["mesh"].select_set(True) - context.view_layer.objects.active = mesh["mesh"] - bpy.ops.object.mode_set(mode='EDIT') - - bpy.ops.mesh.select_all(action="INVERT") - bpy.ops.mesh.remove_doubles(threshold=self.merge_distance,use_unselected=False) - - bpy.ops.object.mode_set(mode='OBJECT') - mesh["mesh"].select_set(False) - - self.objects_to_do.pop(0) - - - - - else: - self.report({'INFO'}, t("Optimization.remove_doubles_completed")) - print("finishing modal execution of merge doubles safely.") - return {'FINISHED'} - - return {'RUNNING_MODAL'} diff --git a/functions/resonite_functions.py b/functions/resonite_functions.py deleted file mode 100644 index 8033e7f..0000000 --- a/functions/resonite_functions.py +++ /dev/null @@ -1,114 +0,0 @@ -import bpy -from typing import List, Optional -import re -from bpy.types import Operator, Context, Object -from ..core.dictionaries import bone_names -from ..core.common import get_selected_armature, simplify_bonename, is_valid_armature -from ..core.translations import t - - -class AvatarToolKit_OT_ConvertToResonite(Operator): - bl_idname = 'avatar_toolkit.convert_to_resonite' - bl_label = t('Tools.convert_to_resonite.label') - bl_description = t('Tools.convert_to_resonite.desc') - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) - - def execute(self, context: Context) -> set: - armature = get_selected_armature(context) - if not armature: - self.report({'WARNING'}, t("Tools.no_armature_selected")) - return {'CANCELLED'} - - translate_bone_fails = 0 - untranslated_bones = set() - - reverse_bone_lookup = dict() - for (preferred_name, name_list) in bone_names.items(): - for name in name_list: - reverse_bone_lookup[name] = preferred_name - - resonite_translations = { - 'hips': "Hips", - 'spine': "Spine", - 'chest': "Chest", - 'neck': "Neck", - 'head': "Head", - 'left_eye': "Eye.L", - 'right_eye': "Eye.R", - 'right_leg': "UpperLeg.R", - 'right_knee': "Calf.R", - 'right_ankle': "Foot.R", - 'right_toe': 'Toes.R', - 'right_shoulder': "Shoulder.R", - 'right_arm': "UpperArm.R", - 'right_elbow': "ForeArm.R", - 'right_wrist': "Hand.R", - 'left_leg': "UpperLeg.L", - 'left_knee': "Calf.L", - 'left_ankle': "Foot.L", - 'left_toe': "Toes.L", - 'left_shoulder': "Shoulder.L", - 'left_arm': "UpperArm.L", - 'left_elbow': "ForeArm.L", - 'left_wrist': "Hand.R", - - 'pinkie_1_l': "pinkie1.L", - 'pinkie_2_l': "pinkie2.L", - 'pinkie_3_l': "pinkie3.L", - 'ring_1_l': "ring1.L", - 'ring_2_l': "ring2.L", - 'ring_3_l': "ring3.L", - 'middle_1_l': "middle1.L", - 'middle_2_l': "middle2.L", - 'middle_3_l': "middle3.L", - 'index_1_l': "index1.L", - 'index_2_l': "index2.L", - 'index_3_l': "index3.L", - 'thumb_1_l': "thumb1.L", - 'thumb_2_l': "thumb2.L", - 'thumb_3_l': "thumb3.L", - - 'pinkie_1_r': "pinkie1.R", - 'pinkie_2_r': "pinkie2.R", - 'pinkie_3_r': "pinkie3.R", - 'ring_1_r': "ring1.R", - 'ring_2_r': "ring2.R", - 'ring_3_r': "ring3.R", - 'middle_1_r': "middle1.R", - 'middle_2_r': "middle2.R", - 'middle_3_r': "middle3.R", - 'index_1_r': "index1.R", - 'index_2_r': "index2.R", - 'index_3_r': "index3.R", - 'thumb_1_r': "thumb1.R", - 'thumb_2_r': "thumb2.R", - 'thumb_3_r': "thumb3.R" - } - - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='EDIT') - - bpy.ops.object.mode_set(mode='OBJECT') - bone.name = re.compile(re.escape(""), re.IGNORECASE).sub("",bone.name) #remove "NOIK" from bones before translating again, in case an update was done that fixes a translation. - for bone in armature.data.bones: - if simplify_bonename(bone.name) in reverse_bone_lookup and reverse_bone_lookup[simplify_bonename(bone.name)] in resonite_translations: - bone.name = resonite_translations[reverse_bone_lookup[simplify_bonename(bone.name)]] - else: - untranslated_bones.add(bone.name) - - bone.name = bone.name+"" - translate_bone_fails += 1 - - bpy.ops.object.mode_set(mode='OBJECT') - - if translate_bone_fails > 0: - self.report({'INFO'}, t("Tools.bones_translated_with_fails").format(translate_bone_fails=translate_bone_fails)) - else: - self.report({'INFO'}, t("Tools.bones_translated_success")) - - return {'FINISHED'} diff --git a/functions/rigify_functions.py b/functions/rigify_functions.py deleted file mode 100644 index 1a23b24..0000000 --- a/functions/rigify_functions.py +++ /dev/null @@ -1,175 +0,0 @@ -# This code is heavily based on the Rigify-Move-DEF by NyankoNyan (https://github.com/NyankoNyan/Rigify-Move-DEF), which is licensed under the MIT License. We just heavily improve the code and add some new features. -import bpy -from ..core.common import get_selected_armature, is_valid_armature -from ..core.translations import t -from bpy.types import Operator, Context - -class AvatarToolKit_OT_ConvertRigifyToUnity(Operator): - bl_idname = "avatar_toolkit.convert_rigify_to_unity" - bl_label = t("Tools.convert_rigify_to_unity.label") - bl_description = t("Tools.convert_rigify_to_unity.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) and "DEF-spine" in armature.data.bones - - def execute(self, context: Context) -> set[str]: - armature = get_selected_armature(context) - if not armature: - self.report({'ERROR'}, t("Tools.no_armature_selected")) - return {'CANCELLED'} - - self.move_def_bones(armature) - self.rename_bones_for_unity(armature) - if context.scene.merge_twist_bones: - self.handle_twist_bones(armature) - self.report({'INFO'}, t("Tools.convert_rigify_to_unity.success")) - return {'FINISHED'} - - def move_def_bones(self, armature): - remap = self.get_org_remap(armature) - remap.update(self.get_special_remap()) - - remove_bones_in_chain = [ - 'DEF-upper_arm.L.001', 'DEF-forearm.L.001', - 'DEF-upper_arm.R.001', 'DEF-forearm.R.001', - 'DEF-thigh.L.001', 'DEF-shin.L.001', - 'DEF-thigh.R.001', 'DEF-shin.R.001' - ] - - transform_copies = self.get_transform_copies(armature) - - # Add missing constraints - bpy.ops.object.mode_set(mode='POSE') - for bone_name in transform_copies: - bone = armature.pose.bones[bone_name] - org_name = 'ORG-' + self.get_proto_name(bone_name) - if org_name in armature.pose.bones: - constraint = bone.constraints.new('COPY_TRANSFORMS') - constraint.target = armature - constraint.subtarget = org_name - constr_count = len(bone.constraints) - if constr_count > 1: - bone.constraints.move(constr_count-1, 0) - - # Apply new parents - bpy.ops.object.mode_set(mode='EDIT') - for remap_key in remap: - if remap_key in armature.data.edit_bones and remap[remap_key] in armature.data.edit_bones: - armature.data.edit_bones[remap_key].parent = armature.data.edit_bones[remap[remap_key]] - - # Remove extra bones in chains - bpy.ops.object.mode_set(mode='OBJECT') - for bone_name in remove_bones_in_chain: - if bone_name in armature.data.bones: - armature.data.bones[bone_name].use_deform = False - - bpy.ops.object.mode_set(mode='EDIT') - for bone_name in remove_bones_in_chain: - if bone_name in armature.data.bones: - remove_bone = armature.data.edit_bones[bone_name] - parent_bone = remove_bone.parent - parent_bone.tail = remove_bone.tail - retarget_bones = list(remove_bone.children) - for bone in retarget_bones: - bone.parent = parent_bone - armature.data.edit_bones.remove(remove_bone) - - def rename_bones_for_unity(self, armature): - unity_bone_names = { - "DEF-spine": "Hips", - "DEF-spine.001": "Spine", - "DEF-spine.002": "Chest", - "DEF-spine.003": "UpperChest", - "DEF-neck": "Neck", - "DEF-head": "Head", - "DEF-shoulder.L": "LeftShoulder", - "DEF-upper_arm.L": "LeftUpperArm", - "DEF-forearm.L": "LeftLowerArm", - "DEF-hand.L": "LeftHand", - "DEF-shoulder.R": "RightShoulder", - "DEF-upper_arm.R": "RightUpperArm", - "DEF-forearm.R": "RightLowerArm", - "DEF-hand.R": "RightHand", - "DEF-thigh.L": "LeftUpperLeg", - "DEF-shin.L": "LeftLowerLeg", - "DEF-foot.L": "LeftFoot", - "DEF-toe.L": "LeftToes", - "DEF-thigh.R": "RightUpperLeg", - "DEF-shin.R": "RightLowerLeg", - "DEF-foot.R": "RightFoot", - "DEF-toe.R": "RightToes" - } - - for old_name, new_name in unity_bone_names.items(): - bone = armature.pose.bones.get(old_name) - if bone: - bone.name = new_name - - def handle_twist_bones(self, armature): - twist_bones = [ - ("DEF-upper_arm_twist.L", "DEF-upper_arm.L"), - ("DEF-upper_arm_twist.R", "DEF-upper_arm.R"), - ("DEF-forearm_twist.L", "DEF-forearm.L"), - ("DEF-forearm_twist.R", "DEF-forearm.R"), - ("DEF-thigh_twist.L", "DEF-thigh.L"), - ("DEF-thigh_twist.R", "DEF-thigh.R") - ] - - bpy.ops.object.mode_set(mode='EDIT') - for twist_bone, parent_bone in twist_bones: - if twist_bone in armature.data.edit_bones and parent_bone in armature.data.edit_bones: - twist = armature.data.edit_bones[twist_bone] - parent = armature.data.edit_bones[parent_bone] - parent.tail = twist.tail - for child in twist.children: - child.parent = parent - armature.data.edit_bones.remove(twist) - - bpy.ops.object.mode_set(mode='OBJECT') - - def get_org_remap(self, armature): - remap = {} - for bone in armature.data.bones: - if self.is_def_bone(bone.name): - name = self.get_proto_name(bone.name) - parent = bone.parent - while parent: - parent_name = self.get_proto_name(parent.name) - if parent_name != name: - if ('DEF-' + parent_name) in armature.data.bones: - remap[bone.name] = 'DEF-' + parent_name - break - parent = parent.parent - return remap - - def get_special_remap(self): - return { - 'DEF-thigh.L': 'DEF-pelvis.L', - 'DEF-thigh.R': 'DEF-pelvis.R', - 'DEF-upper_arm.L': 'DEF-shoulder.L', - 'DEF-upper_arm.R': 'DEF-shoulder.R', - } - - def get_transform_copies(self, armature): - result = [] - for bone in armature.pose.bones: - if self.is_def_bone(bone.name) and not self.has_transform_copies(bone): - result.append(bone.name) - return result - - def has_transform_copies(self, bone): - return any(constraint.type == 'COPY_TRANSFORMS' for constraint in bone.constraints) - - def is_def_bone(self, bone_name): - return bone_name.startswith('DEF-') - - def is_org_bone(self, bone_name): - return bone_name.startswith('ORG-') - - def get_proto_name(self, bone_name): - if self.is_def_bone(bone_name) or self.is_org_bone(bone_name): - return bone_name[4:] - return bone_name diff --git a/functions/uv_tools.py b/functions/uv_tools.py deleted file mode 100644 index 9273d2f..0000000 --- a/functions/uv_tools.py +++ /dev/null @@ -1,295 +0,0 @@ -from typing import TypedDict -import bpy -from bpy.types import Operator, Object, Context, Mesh, MeshUVLoopLayer -import bmesh -import numpy as np -import math -from ..core.translations import t - -class GenerateLoopTreeResult(TypedDict): - tree: dict[str, set[str]] - selected_loops: dict[str,list[int]] - selected_verts: dict[str,int] - - -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 - if not context.space_data: - 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): - - - 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): #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 - - 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 - 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 - - 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. - 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): - - 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/functions/viseme.py b/functions/viseme.py deleted file mode 100644 index 24c3bcb..0000000 --- a/functions/viseme.py +++ /dev/null @@ -1,93 +0,0 @@ -import bpy -from ..core import common -from ..core.translations import t -from typing import List, Tuple -from ..core.common import get_selected_armature, is_valid_armature, get_all_meshes, init_progress, update_progress, finish_progress - - -class AvatarToolKit_OT_AutoVisemeButton(bpy.types.Operator): - bl_idname = 'avatar_toolkit.create_visemes' - bl_label = t('AutoVisemeButton.label') - bl_description = t('AutoVisemeButton.desc') - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - armature = get_selected_armature(context) - return armature is not None and is_valid_armature(armature) and get_all_meshes(context) - - def execute(self, context: bpy.types.Context) -> set: - try: - self.create_visemes(context) - return {'FINISHED'} - except Exception as e: - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - - def create_visemes(self, context: bpy.types.Context) -> None: - init_progress(context, 5) # 5 main steps - - update_progress(self, context, t("VisemePanel.start_viseme_creation")) - mesh = bpy.data.objects.get(context.scene.avatar_toolkit.selected_mesh) - if not mesh or not common.has_shapekeys(mesh): - raise ValueError(t('AutoVisemeButton.error.noShapekeys')) - - update_progress(self, context, t("VisemePanel.removing_existing_visemes")) - self.remove_existing_vrc_shapekeys(mesh) - - shape_a = context.scene.avatar_toolkit.mouth_a - shape_o = context.scene.avatar_toolkit.mouth_o - shape_ch = context.scene.avatar_toolkit.mouth_ch - - if shape_a == "Basis" or shape_o == "Basis" or shape_ch == "Basis": - raise ValueError(t('AutoVisemeButton.error.selectShapekeys')) - - update_progress(self, context, t("VisemePanel.creating_visemes")) - visemes: List[Tuple[str, List[Tuple[str, float]]]] = [ - ('vrc.v_aa', [(shape_a, 0.9998)]), - ('vrc.v_ch', [(shape_ch, 0.9996)]), - ('vrc.v_dd', [(shape_a, 0.3), (shape_ch, 0.7)]), - ('vrc.v_e', [(shape_a, 0.5), (shape_ch, 0.2)]), - ('vrc.v_ff', [(shape_a, 0.2), (shape_ch, 0.4)]), - ('vrc.v_ih', [(shape_ch, 0.7), (shape_o, 0.3)]), - ('vrc.v_kk', [(shape_a, 0.7), (shape_ch, 0.4)]), - ('vrc.v_nn', [(shape_a, 0.2), (shape_ch, 0.7)]), - ('vrc.v_oh', [(shape_a, 0.2), (shape_o, 0.8)]), - ('vrc.v_ou', [(shape_o, 0.9994)]), - ('vrc.v_pp', [(shape_a, 0.0004), (shape_o, 0.0004)]), - ('vrc.v_rr', [(shape_ch, 0.5), (shape_o, 0.3)]), - ('vrc.v_sil', [(shape_a, 0.0002), (shape_ch, 0.0002)]), - ('vrc.v_ss', [(shape_ch, 0.8)]), - ('vrc.v_th', [(shape_a, 0.4), (shape_o, 0.15)]) - ] - - for viseme_name, shape_mix in visemes: - self.create_viseme(mesh, viseme_name, shape_mix, context.scene.avatar_toolkit.shape_intensity) - - update_progress(self, context, t("VisemePanel.sorting_shapekeys")) - common.sort_shape_keys(mesh) - - update_progress(self, context, t("VisemePanel.viseme_creation_completed")) - finish_progress(context) - - def create_viseme(self, mesh: bpy.types.Object, viseme_name: str, shape_mix: List[Tuple[str, float]], intensity: float) -> None: - shape_keys = mesh.data.shape_keys.key_blocks - - if viseme_name in shape_keys: - mesh.shape_key_remove(shape_keys[viseme_name]) - - new_key = mesh.shape_key_add(name=viseme_name, from_mix=False) - new_key.value = 0.0 - - for shape_name, value in shape_mix: - if shape_name in shape_keys: - source_shape = shape_keys[shape_name] - for i, vert in enumerate(new_key.data): - vert.co += (source_shape.data[i].co - shape_keys['Basis'].data[i].co) * value * intensity - - def remove_existing_vrc_shapekeys(self, mesh: bpy.types.Object) -> None: - vrc_prefixes = ['vrc.v_', 'vrc.blink_', 'vrc.lowerlid_'] - shape_keys = mesh.data.shape_keys.key_blocks - for key in reversed(shape_keys): - if any(key.name.startswith(prefix) for prefix in vrc_prefixes): - mesh.shape_key_remove(key) diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 5d045e2..e8475c9 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -1,263 +1,11 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AutoVisemeButton.desc": "Create visemes automatically, based on shape keys", - "AutoVisemeButton.error.noShapekeys": "No shape keys found", - "AutoVisemeButton.error.selectShapekeys": "Please Select shape keys", - "AutoVisemeButton.label": "Create Visemes", - "AutoVisemeButton.success": "Visemes created successfully", - "AvatarToolkit.label": "Avatar Toolkit (Alpha)", - "AvatarToolkit.desc1": "Avatar Toolkit is in Early Access", - "AvatarToolkit.desc2": "There will be issues, if you find", - "AvatarToolkit.desc3": "an issue, please report it on Github.", - "Export.resonite.desc": "Export a GLB with all animations and materials. For animation data see:", - "Export.resonite.label": "Export to Resonite", - "Importer.export_resonite.desc": "Export to Resonite as a GLTF. Make sure your model is to scale in blender, and import as meters in Resonite.", - "Importer.export_resonite.label": "Export to Resonite", - "Importer.export_vrchat.desc": "Export to VRChat, may also work for ChilloutVR. Is similar to Cats export.", - "Importer.export_vrchat.label": "Export to VRChat", - "Importer.mmd_anim_importer.desc": "Import a MMD Animation (.vmd)", - "Importer.mmd_anim_importer.label": "MMD Animation", - "Importing.importer_search_term": "https://search.brave.com/search?q=blender+{extension}+importer+addon&source=web", - "Importing.need_importer": "You do not have the required importer for the {extension} type! Opening web browser for importer search term...", - "Language.auto": "Automatic", - "Language.en_US": "English", - "Language.ja_JP": "日本語", - "Optimization.applying_transforms": "Applying transforms...", - "Optimization.cleaning_material_names": "Cleaning material names...", - "Optimization.cleaning_material_slots": "Cleaning material slots...", - "Optimization.clearing_unused_data": "Clearing unused data...", - "Optimization.materials_optimization_report": "Materials optimization completed: Combined {num_combined} materials, cleaned {num_cleaned_slots} material slots, cleaned {num_cleaned_names} material names, and removed {num_removed_data_blocks} unused data blocks", - "Optimization.combine_materials.desc": "Combine similar materials to reduce draw calls and improve performance", - "Optimization.combine_materials.label": "Combine Materials", - "Optimization.consolidating_materials": "Consolidating materials...", - "Optimization.finalizing": "Finalizing...", - "Optimization.fixing_uv_coordinates": "Fixing UV coordinates...", - "Optimization.join_all_meshes.desc": "Merge all meshes into a single object to reduce draw calls", - "Optimization.join_all_meshes.label": "Join All Meshes", - "Optimization.join_error": "Error during mesh joining", - "Optimization.join_operation_failed": "Join operation failed", - "Optimization.join_selected_meshes.desc": "Merge only the selected meshes into a single object", - "Optimization.join_selected_meshes.label": "Join Selected Meshes", - "Optimization.joinmeshes.label": "Join Meshes:", - "Optimization.joining_meshes": "Joining meshes...", - "Optimization.label": "Optimization", - "Optimization.material_attribute_mismatch": "Attribute mismatch in material {material_name}, skipping", - "Optimization.materials_combined": "Combined {num_combined} materials", - "Optimization.meshes_joined": "Meshes joined successfully", - "Optimization.no_armature_selected": "No armature selected", - "Optimization.no_mesh_selected": "No mesh objects selected", - "Optimization.no_meshes_found": "No meshes found for the selected armature", - "Optimization.options.label": "Optimization:", - "Optimization.preparing_meshes": "Preparing meshes...", - "Optimization.processing_mesh_no_shapekeys": "Processing mesh with no shapekeys named \"{mesh_name}\"", - "Optimization.processing_shapekey": "Processing shapekey \"{shapekeyname}\" on mesh \"{mesh_name}\"", - "Optimization.remove_doubles_completed": "Remove doubles operation completed", - "Optimization.remove_doubles_safely.desc": "Remove duplicate vertices while preserving important features like mouth shapes.\nIs a quick solution but does not merge vertices that move at all.", - "Optimization.remove_doubles_safely.label": "Remove Doubles Safely", - "Optimization.remove_doubles_safely_advanced.label": "Advanced Remove Doubles Safely", - "Optimization.remove_doubles_safely_advanced.desc": "Remove duplicate vertices while preserving important features like mouth shapes.\nUnlike basic, Advanced will merge vertices together that move, but still preserve shapekeys.\nEx: It will not seal the lips of the mouth closed, but will fix split polygons that make up the lips.", - "Optimization.select_armature": "Please select an armature", - "Optimization.select_at_least_two_meshes": "Please select at least two mesh objects", - "Optimization.selected_meshes_joined": "Selected meshes joined successfully", - "Optimization.selecting_meshes": "Selecting meshes...", - "Optimization.transform_apply_failed": "Transform apply failed", - "Optimization.vertex_excluded": "Shapekey has a moved vertex at index \"{index}\", excluding from double merging!", - "Quick_Access.selected_armature.label": "Selected Armature", - "Quick_Access.selected_armature.desc": "The currently \"targeted\" armature for Avatar Toolkit operations", - "Quick_Access.export": "Export", - "Quick_Access.export_fbx.desc": "Export the model as FBX", - "Quick_Access.export_fbx.label": "Export FBX", - "Quick_Access.export_menu.desc": "Export to a supported format", - "Quick_Access.export_menu.label": "Export Menu", - "Quick_Access.import": "Import", - "Quick_Access.import_export.label": "Import/Export:", - "Quick_Access.import_menu.desc": "Import a Model", - "Quick_Access.import_menu.label": "Import Menu", - "Quick_Access.import_pmd": "Import PMD", - "Quick_Access.import_pmd.desc": "Import MMD PMD Model", - "Quick_Access.import_pmx": "Import PMX", - "Quick_Access.import_pmx.desc": "Import MMD PMX Model", - "Quick_Access.import_success": "Model imported successfully", - "Quick_Access.label": "Quick Access", - "Quick_Access.options": "Quick Access:", - "Quick_Access.select_armature": "Select Armature:", - "Quick_Access.apply_armature_failed": "Applying armature as pose failed at the joining shapekeys back together stage!", - "Quick_Access.apply_pose_as_rest.desc": "Makes current pose the default rest pose.", - "Quick_Access.stop_pose_mode.desc": "Exits pose mode and clears all posing on all visible bones in pose mode.", - "Quick_Access.apply_pose_as_rest.label": "Apply Pose as Rest Pose", - "Quick_Access.apply_pose_as_shapekey.desc": "Makes the current pose a shapekey that can be activated later.\nThis is good for applying a jaw open position as a shapekey for facial movements.", - "Quick_Access.apply_pose_as_shapekey.label": "Apply Pose as Shapekey", - "Quick_Access.stop_pose_mode.label": "Exit Pose Mode", - "Quick_Access.start_pose_mode.desc": "Starts pose mode for the armature targeted by Avatar Toolkit.", - "Quick_Access.start_pose_mode.label": "Start Pose Mode", - "Quick_Access.select_export.label": "Select Export Method", - "Quick_Access.select_export_resonite.label": "Resonite", - "Settings.label": "Settings", - "Settings.language.desc": "Select the language for the addon's UI", - "Settings.language.label": "Language:", - "Settings.translation_restart_popup.description": "Information about translation updates", - "Settings.translation_restart_popup.label": "Translation Update", - "Settings.translation_restart_popup.message1": "Some translations may not apply", - "Settings.translation_restart_popup.message2": "until you restart Blender.", - "TextureAtlas.atlas_completed": "Texture atlas creation completed", - "TextureAtlas.atlas_error": "An error occurred during texture atlas creation", - "TextureAtlas.atlas_materials": "Atlas Materials", - "TextureAtlas.atlas_materials_desc": "Atlas materials to optimize the model", - "TextureAtlas.label": "Texture Atlasing", - "TextureAtlas.loaded_list": "Loaded Texture Atlas Material List", - "TextureAtlas.material_list_label": "Texture Atlas Material List Material", - "TextureAtlas.reload_list": "Reload Texture Atlas Material List", - "TextureAtlas.error.label": "ERROR", - "TextureAtlas.none.label": "None", - "TextureAtlas.no_nodes_error.desc": "THIS MATERIAL DOES NOT USE NODES!", - "TextureAtlas.no_images_error.desc": "THIS MATERIAL HAS NO IMAGES!", - "TextureAtlas.texture_use_atlas.desc": "The texture that will be used for the {name} map atlas", - "TextureAtlas.albedo": "Albedo", - "TextureAtlas.normal": "Normal", - "TextureAtlas.emission": "Emission", - "TextureAtlas.ambient_occlusion": "Ambient Occlusion", - "TextureAtlas.height": "Height", - "TextureAtlas.roughness": "Roughness", - "Tools.bones_translated_success": "Successfully translated all bones to humanoid names", - "Tools.bones_translated_with_fails": "Failed to translate {translate_bone_fails} bones to humanoid names. Adding \"\" to their names.", - "Tools.convert_to_resonite.desc": "Converts bone names on a model to names compatible with Resonite", - "Tools.convert_to_resonite.label": "Convert to Resonite", - "Tools.create_digitigrade_legs.desc": "Create digitigrade legs from a selected bone chain", - "Tools.create_digitigrade_legs.label": "Create Digitigrade Legs", - "Tools.digitigrade_legs.error.bone_format": "Bone format incorrect! Please select a chain of 4 continuous bones!", - "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", - "Tools.tools_title.label": "Tools:", - "Tools.separate_by.label": "Separate By:", - "Tools.separate_by_materials.label": "Separate by Materials", - "Tools.separate_by_materials.desc": "Separate the selected mesh by materials", - "Tools.separate_by_materials.success": "Mesh separated by materials successfully", - "Tools.separate_by_loose_parts.label": "Separate by Loose Parts", - "Tools.separate_by_loose_parts.desc": "Separate the selected mesh by loose parts", - "Tools.separate_by_loose_parts.success": "Mesh separated by loose parts successfully", - "Tools.apply_transforms.label": "Apply Transforms", - "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", - "Tools.apply_shape_key.label": "Apply Shapekey to Basis", - "Tools.apply_shape_key.desc": "Apply the selected shapekey to the basis, making it default on.", - "Tools.apply_shape_key.error": "The shape keys were not merged for some reason!", - "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", - "Tools.connect_bones.label": "Connect Bones", - "Tools.bone_tools.label": "Bone Tools", - "Tools.additional_tools.label": "Additional Tools", - "Tools.merge_twist_bones.label": "Merge Twist Bones", - "Tools.merge_twist_bones.desc": "Merge twist bones into their parent bones", - "Tools.connect_bones.desc": "Connect bones with their respective children", - "Tools.connect_bones.invalid_armature": "Invalid armature selected", - "Tools.connect_bones.min_distance.label": "Minimum Distance", - "Tools.connect_bones.min_distance.desc": "Minimum distance between bones to connect them", - "Tools.connect_bones.success": "Connected {bones_connected} bones successfully", - "Tools.delete_bone_constraints.label": "Delete Bone Constraints", - "Tools.delete_bone_constraints.desc": "Remove all constraints from bones in the armature", - "Tools.delete_bone_constraints.invalid_armature": "Invalid armature selected", - "Tools.delete_bone_constraints.success": "Removed {constraints_removed} constraints from bones", - "Tools.convert_rigify_to_unity.label": "Convert Rigify to Unity", - "Tools.convert_rigify_to_unity.desc": "Prepare Rigify armature for use in Unity", - "Tools.convert_rigify_to_unity.success": "Rigify armature successfully converted for Unity", - "MergeArmatures.select_armature": "Please select an armature", - "MergeArmatures.title.label": "Merge Armatures:", - "MergeArmatures.label": "Merge Armatures", - "MergeArmatures.selected_armature.label": "Armature to Merge From", - "MergeArmatures.selected_armature.desc": "The armature that should be merged into the targeted armature for Avatar Toolkit.", - "MergeArmatures.target_armature.label": "Armature to Merge To", - "MergeArmatures.target_armature.desc": "The armature that should be the target for merging armatures.", - "MergeArmature.merge_armatures.label": "Merge Armatures Together", - "MergeArmature.merge_armatures.desc": "Merge {selected_armature_label} to the targeted armature for Avatar Toolkit.", - "MergeArmature.merge_armatures.align_bones.label": "Align Bones", - "MergeArmature.merge_armatures.align_bones.desc": "Align bones from source armature to target armature,\nstretching bones to match before merging.", - "MergeArmature.merge_armatures.apply_transforms.label": "Apply Transforms", - "MergeArmature.merge_armatures.apply_transforms.desc": "Apply transforms on armature and it's meshes before merging.", - "VisemePanel.create_visemes": "Create Visemes", - "VisemePanel.creating_viseme": "Creating viseme: {viseme_name}", - "VisemePanel.creating_viseme_detail": "Creating viseme: {viseme_name}", - "VisemePanel.creating_visemes": "Creating visemes...", - "VisemePanel.error.noArmature": "No armature selected", - "VisemePanel.error.noMesh": "No mesh selected", - "VisemePanel.error.noShapekeys": "Selected mesh has no shape keys", - "VisemePanel.error.selectMesh": "Select a mesh to create visemes", - "VisemePanel.info.selectMesh": "Select a mesh to create visemes", - "VisemePanel.label": "Visemes", - "VisemePanel.mixing_shape": "Mixing shape: {shape_name} with value: {value}", - "VisemePanel.mouth_a.desc": "The shapekey for the 'A' mouth shape", - "VisemePanel.mouth_a.label": "Mouth A", - "VisemePanel.mouth_ch.desc": "The shapekey for the 'CH' mouth shape", - "VisemePanel.mouth_ch.label": "Mouth CH", - "VisemePanel.mouth_o.desc": "The shapekey for the 'O' mouth shape", - "VisemePanel.mouth_o.label": "Mouth O", - "VisemePanel.removing_existing_viseme": "Removing existing viseme: {viseme_name}", - "VisemePanel.removing_existing_visemes": "Removing existing visemes...", - "VisemePanel.select_mesh": "Select Mesh", - "VisemePanel.selected_mesh.label": "Selected Mesh", - "VisemePanel.selected_mesh.desc": "The currently selected mesh for viseme operations", - "VisemePanel.selected_shapes": "Selected shapes: A={shape_a}, O={shape_o}, CH={shape_ch}", - "VisemePanel.shape_intensity": "Shape Intensity", - "VisemePanel.shape_intensity_desc": "The intensity of the viseme shapekeys", - "VisemePanel.sorting_shapekeys": "Sorting shape keys...", - "VisemePanel.start_viseme_creation": "Starting viseme creation...", - "VisemePanel.viseme_created_successfully": "Viseme {viseme_name} created successfully", - "VisemePanel.viseme_creation_completed": "Viseme creation completed.", - "MMDOptions.title": "MMD Options", - "MMDOptions.no_armature_selected": "No armature selected", - "MMDOptions.label": "MMD Options", - "MMDOptions.cleanup_mesh.label": "Cleanup Mesh", - "MMDOptions.cleanup_mesh.desc": "Clean up the mesh by removing empty objects, unused vertex groups, unused vertices, and empty shape keys", - "MMDOptions.removing_empty_objects": "Removing empty objects", - "MMDOptions.removing_unused_vertex_groups": "Removing unused vertex groups", - "MMDOptions.removing_unused_vertices": "Removing unused vertices", - "MMDOptions.removing_empty_shape_keys": "Removing empty shape keys", - "MMDOptions.optimize_weights.label": "Optimize Weights", - "MMDOptions.optimize_weights.desc": "Optimize vertex weights by limiting the number of weights per vertex", - "MMDOptions.max_weights.label": "Max Weights", - "MMDOptions.max_weights.desc": "Maximum number of weights per vertex", - "MMDOptions.merging_weights": "Merging weights", - "MMDOptions.removing_zero_weight_bones": "Removing zero weight bones", - "MMDOptions.limiting_vertex_weights": "Limiting vertex weights", - "MMDOptions.weight_optimization_complete": "Weight optimization complete", - "MMDOptions.optimize_armature.label": "Optimize Armature", - "MMDOptions.optimize_armature.desc": "Optimize the armature by fixing bone rolls, aligning bones, connecting bones, and more", - "MMDOptions.fixing_bone_rolls": "Fixing bone rolls", - "MMDOptions.aligning_bones": "Aligning bones", - "MMDOptions.connecting_bones": "Connecting bones", - "MMDOptions.deleting_bone_constraints": "Deleting bone constraints", - "MMDOptions.merging_bones_to_parents": "Merging bones to parents", - "MMDOptions.reordering_bones": "Reordering bones", - "MMDOptions.fixing_armature_names": "Fixing armature names", - "MMDOptions.renaming_bones": "Renaming bones", - "MMDOptions.armature_optimization_complete": "Armature optimization complete", - "MMDOptions.convert_materials.label": "Convert Materials", - "MMDOptions.convert_materials.desc": "Convert materials to use Principled BSDF shader and fix MMD and VRM shaders", - "MMDOptions.converting_materials": "Converting materials for {name}", + "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.1.0)", + "AvatarToolkit.desc1": "Avatar Toolkit is in Early Access there", + "AvatarToolkit.desc2": "will be issues, if you find any issues,", + "AvatarToolkit.desc3": "please report it on our Github.", + "Updater.label": "Updater", "Updater.CheckForUpdateButton.label": "Check for Updates", "Updater.CheckForUpdateButton.label_alt": "No Updates Available", @@ -276,25 +24,37 @@ "download_file.cantConnect": "Cannot connect to update server", "download_file.cantFindZip": "Update file not found", "download_file.cantFindAvatarToolkit": "Avatar Toolkit files not found in update package", - "CreditsSupport.label": "Credits & Support", - "CreditsSupport.credits_title": "Credits", - "CreditsSupport.credits_text1": "Avatar Toolkit has been created by the Neoneko team:", - "CreditsSupport.credits_text2": "Yusarina and 989Onan", - "CreditsSupport.credits_text3": "Some code has been inspired by Cats Blender Plugin,", - "CreditsSupport.credits_text4": "thanks to the original contributors to that plugin.", - "CreditsSupport.support_text1": "If you like what we do, you can donate/ tip to us", - "CreditsSupport.support_text2": "through our pally.gg page.", - "CreditsSupport.support_title": "Support Us", - "CreditsSupport.support_button": "Support Us", - "CreditsSupport.help_title": "Need Help?", - "CreditsSupport.help_text1": "Check out our wiki first, we HIGHLY encourage", - "CreditsSupport.help_text2": "that you read it before seeking further support.", - "CreditsSupport.wiki_button": "Wiki", - "CreditsSupport.discord_button": "Join Discord", - "TextureAtlas.include_in_atlas": "Include in Atlas", - "TextureAtlas.include_in_atlas_desc": "Include this material in the texture atlas", + + "QuickAccess.label": "Quick Access", + "QuickAccess.select_armature": "Select Armature", + "QuickAccess.valid_armature": "Valid Armature", + "QuickAccess.bones_count": "Bones: {count}", + "QuickAccess.pose_bones_available": "Pose bones: Available", + "QuickAccess.pose_controls": "Pose Controls", + "QuickAccess.import_export": "Import/Export", + "QuickAccess.import": "Import", + "QuickAccess.export": "Export", + "QuickAccess.export_fbx": "Export FBX", + "QuickAccess.export_resonite": "Export to Resonite", + + "Quick_Access.start_pose_mode.label": "Start Pose Mode", + "Quick_Access.start_pose_mode.desc": "Enter pose mode for the selected armature", + "Quick_Access.stop_pose_mode.label": "Stop Pose Mode", + "Quick_Access.stop_pose_mode.desc": "Exit pose mode and clear transforms", + "Quick_Access.apply_pose_as_shapekey.label": "Apply Pose as Shape Key", + "Quick_Access.apply_pose_as_shapekey.desc": "Create a new shape key from current pose", + "Quick_Access.apply_pose_as_rest.label": "Apply Pose as Rest", + "Quick_Access.apply_pose_as_rest.desc": "Apply current pose as rest pose", + "Quick_Access.apply_armature_failed": "Failed to apply armature modifications", + + "Tools.apply_pose_as_rest.success": "Successfully applied pose as rest position", + + "Armature.validation.no_armature": "No armature selected", + "Armature.validation.not_armature": "Selected object is not an armature", + "Armature.validation.no_bones": "Armature has no bones", + "Armature.validation.missing_bone": "Missing essential bone: {bone}", + "Scene.avatar_toolkit_updater_version_list.name": "Version List", - "Scene.avatar_toolkit_updater_version_list.description": "List of available versions to update to", - "TextureAtlas.no_materials_selected": "No materials selected for atlas" + "Scene.avatar_toolkit_updater_version_list.description": "List of available versions" } } diff --git a/ui/__init__.py b/ui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ui/atlas_materials.py b/ui/atlas_materials.py deleted file mode 100644 index 7ab0206..0000000 --- a/ui/atlas_materials.py +++ /dev/null @@ -1,182 +0,0 @@ -from bpy.types import UIList, Panel, UILayout, Object, Context, Material, Operator -import bpy -from math import sqrt -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.common import SceneMatClass, MaterialListBool, get_selected_armature -from ..functions.atlas_materials import AvatarToolKit_OT_AtlasMaterials -from ..core.translations import t - -class AvatarToolKit_OT_SelectAllMaterials(Operator): - bl_idname = 'avatar_toolkit.select_all_materials' - bl_label = "Select All" - bl_description = "Select all materials for atlas" - - def execute(self, context): - for item in context.scene.avatar_toolkit.materials: - item.mat.avatar_toolkit.include_in_atlas = True - return {'FINISHED'} - -class AvatarToolKit_OT_SelectNoneMaterials(Operator): - bl_idname = 'avatar_toolkit.select_none_materials' - bl_label = "Select None" - bl_description = "Deselect all materials" - - def execute(self, context): - for item in context.scene.avatar_toolkit.materials: - item.mat.avatar_toolkit.include_in_atlas = False - return {'FINISHED'} - -class AvatarToolKit_OT_ExpandAllMaterials(Operator): - bl_idname = 'avatar_toolkit.expand_all_materials' - bl_label = "Expand All" - bl_description = "Expand all material settings" - - def execute(self, context): - for item in context.scene.avatar_toolkit.materials: - item.mat.avatar_toolkit.material_expanded = True - return {'FINISHED'} - -class AvatarToolKit_OT_CollapseAllMaterials(Operator): - bl_idname = 'avatar_toolkit.collapse_all_materials' - bl_label = "Collapse All" - bl_description = "Collapse all material settings" - - def execute(self, context): - for item in context.scene.avatar_toolkit.materials: - item.mat.avatar_toolkit.material_expanded = False - return {'FINISHED'} - -class AvatarToolKit_OT_ExpandSectionMaterials(Operator): - bl_idname = 'avatar_toolkit.expand_section_materials' - bl_label = "" - bl_description = "" - - @classmethod - def poll(cls, context: Context) -> bool: - return True - - def execute(self, context: Context) -> set: - if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: - context.scene.avatar_toolkit.materials.clear() - newlist: list[Material] = [] - for obj in context.scene.objects: - if len(obj.material_slots)>0: - for mat_slot in obj.material_slots: - if mat_slot.material: - if mat_slot.material not in newlist: - newlist.append(mat_slot.material) - newitem: SceneMatClass = context.scene.avatar_toolkit.materials.add() - newitem.mat = mat_slot.material - MaterialListBool.old_list[context.scene.name] = newlist - else: - context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown = False - return {'FINISHED'} - -class AvatarToolKit_UL_MaterialTextureAtlasProperties(UIList): - bl_label = t("TextureAtlas.material_list_label") - bl_idname = "Material_UL_avatar_toolkit_texture_atlas_mat_list_mat" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - - def draw_header(self, context): - layout = self.layout - row = layout.row(align=True) - - row.operator("avatar_toolkit.select_all_materials", text="", icon='CHECKBOX_HLT') - row.operator("avatar_toolkit.select_none_materials", text="", icon='CHECKBOX_DEHLT') - row.operator("avatar_toolkit.expand_all_materials", text="", icon='DISCLOSURE_TRI_DOWN') - row.operator("avatar_toolkit.collapse_all_materials", text="", icon='DISCLOSURE_TRI_RIGHT') - row.prop(context.scene.avatar_toolkit, "material_search_filter", text="", icon='VIEWZOOM') - - box = layout.box() - row = box.row() - row.label(text=f"Estimated Atlas Size: {self.calculate_atlas_size(context)}px") - - def draw_item(self, context: Context, layout: UILayout, data: Object, item: SceneMatClass, icon, active_data, active_propname, index): - if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: - if context.scene.avatar_toolkit.material_search_filter and context.scene.avatar_toolkit.material_search_filter.lower() not in item.mat.name.lower(): - return - - row = layout.row() - - row.prop(item.mat.avatar_toolkit, "include_in_atlas", text="", icon='CHECKBOX_HLT' if item.mat.avatar_toolkit.include_in_atlas else 'CHECKBOX_DEHLT') - - row.prop(item.mat.avatar_toolkit, "material_expanded", - text=item.mat.name, - icon='DOWNARROW_HLT' if item.mat.avatar_toolkit.material_expanded else 'RIGHTARROW', - emboss=False) - - if item.mat.avatar_toolkit.material_expanded and item.mat.avatar_toolkit.include_in_atlas: - box = layout.box() - col = box.column(align=True) - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_albedo", "IMAGE_RGB") - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_normal", "NORMALS_FACE") - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_emission", "LIGHT") - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_ambient_occlusion", "SHADING_SOLID") - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_height", "IMAGE_ZDEPTH") - self.draw_texture_row(col, item.mat.avatar_toolkit, "texture_atlas_roughness", "MATERIAL") - - col.separator(factor=0.5) - - def draw_texture_row(self, layout, material, prop_name, icon): - row = layout.row() - row.prop(material, prop_name, icon=icon) - if getattr(material, prop_name): - row.label(text="", icon='CHECKMARK') - else: - row.label(text="", icon='X') - - def calculate_atlas_size(self, context): - total_size = 0 - for mat in context.scene.avatar_toolkit.materials: - if mat.mat.avatar_toolkit.include_in_atlas: - if mat.mat.avatar_toolkit.texture_atlas_albedo: - img = bpy.data.images[mat.mat.avatar_toolkit.texture_atlas_albedo] - total_size += img.size[0] * img.size[1] - return f"{int(sqrt(total_size))}x{int(sqrt(total_size))}" - -class AvatarToolKit_PT_TextureAtlasPanel(Panel): - bl_label = t("TextureAtlas.label") - bl_idname = "OBJECT_PT_avatar_toolkit_texture_atlas" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 6 - - def draw(self, context: Context): - layout = self.layout - armature = get_selected_armature(context) - - if armature: - layout.label(text=t("TextureAtlas.label"), icon='TEXTURE') - layout.separator(factor=0.5) - - box = layout.box() - row = box.row() - direction_icon = 'RIGHTARROW' if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else 'DOWNARROW_HLT' - row.operator(AvatarToolKit_OT_ExpandSectionMaterials.bl_idname, - text=(t("TextureAtlas.reload_list") if not context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown else t("TextureAtlas.loaded_list")), - icon=direction_icon) - - if context.scene.avatar_toolkit.texture_atlas_Has_Mat_List_Shown: - row = box.row() - row.template_list(AvatarToolKit_UL_MaterialTextureAtlasProperties.bl_idname, - 'material_list', - context.scene.avatar_toolkit, - 'materials', - context.scene.avatar_toolkit, - 'texture_atlas_material_index', - rows=12, - type='DEFAULT') - - layout.separator(factor=1.0) - - row = layout.row() - row.scale_y = 1.5 - row.operator(AvatarToolKit_OT_AtlasMaterials.bl_idname, - text=t("TextureAtlas.atlas_materials"), - icon='NODE_TEXTURE') - else: - layout.label(text=t("Tools.select_armature"), icon='ERROR') - diff --git a/ui/credits_support.py b/ui/credits_support.py deleted file mode 100644 index e650d9a..0000000 --- a/ui/credits_support.py +++ /dev/null @@ -1,53 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.translations import t -from ..core.common import open_web_after_delay_multi_threaded - -class AvatarToolkit_PT_CreditsSupport(bpy.types.Panel): - bl_label = t("CreditsSupport.label") - bl_idname = "OBJECT_PT_avatar_toolkit_credits_support" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 10 - - def draw(self, context): - layout = self.layout - - layout.label(text=t("CreditsSupport.credits_title")) - box = layout.box() - column = box.column(align=True) - column.scale_y = 0.7 - column.label(text=t("CreditsSupport.credits_text1")) - column.label(text=t("CreditsSupport.credits_text2")) - column.label(text=t("CreditsSupport.credits_text3")) - column.label(text=t("CreditsSupport.credits_text4")) - - layout.separator() - - layout.label(text=t("CreditsSupport.support_title")) - box = layout.box() - column = box.column(align=True) - column.scale_y = 0.7 - column.label(text=t("CreditsSupport.support_text1")) - column.label(text=t("CreditsSupport.support_text2")) - row = column.row() - row.scale_y = 1.5 - row.operator("wm.url_open", text=t("CreditsSupport.support_button")).url = "https://neoneko.xyz/supportus.html" - - layout.separator() - - layout.label(text=t("CreditsSupport.help_title")) - box = layout.box() - column = box.column(align=True) - column.scale_y = 0.7 - column.label(text=t("CreditsSupport.help_text1")) - column.label(text=t("CreditsSupport.help_text2")) - row = column.row() - row.scale_y = 1.5 - row.operator("wm.url_open", text=t("CreditsSupport.wiki_button")).url = "https://github.com/teamneoneko/Avatar-Toolkit" - row = column.row() - row.scale_y = 1.5 - row.operator("wm.url_open", text=t("CreditsSupport.discord_button")).url = "https://discord.catsblenderplugin.xyz" - diff --git a/ui/main_panel.py b/ui/main_panel.py index 6463bed..7a802c8 100644 --- a/ui/main_panel.py +++ b/ui/main_panel.py @@ -1,21 +1,38 @@ import bpy +from typing import Optional +from bpy.types import Panel, Context, UILayout from ..core.translations import t -CATEGORY_NAME = "Avatar Toolkit" +CATEGORY_NAME: str = "Avatar Toolkit" -def draw_title(self: bpy.types.Panel): - layout = self.layout - layout.label(text=t("AvatarToolkit.desc1")) - layout.label(text=t("AvatarToolkit.desc2")) - layout.label(text=t("AvatarToolkit.desc3")) +def draw_title(self: Panel) -> None: + """Draw the main panel title and description""" + layout: UILayout = self.layout + box: UILayout = layout.box() + col: UILayout = box.column(align=True) + + # Add a nice header + row: UILayout = col.row() + row.scale_y = 1.2 + row.label(text=t("AvatarToolkit.label"), icon='ARMATURE_DATA') + + # Description as a flowing paragraph + desc_col: UILayout = col.column() + desc_col.scale_y = 0.6 + desc_col.label(text=t("AvatarToolkit.desc1")) + desc_col.label(text=t("AvatarToolkit.desc2")) + desc_col.label(text=t("AvatarToolkit.desc3")) + col.separator() -class AvatarToolKit_PT_AvatarToolkitPanel(bpy.types.Panel): +class AvatarToolKit_PT_AvatarToolkitPanel(Panel): + """Main panel for Avatar Toolkit containing general information and settings""" bl_label = t("AvatarToolkit.label") bl_idname = "OBJECT_PT_avatar_toolkit" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = CATEGORY_NAME + bl_options = {'DEFAULT_CLOSED'} - def draw(self: bpy.types.Panel, context: bpy.types.Context): + def draw(self, context: Context) -> None: + """Draw the main panel layout""" draw_title(self) - diff --git a/ui/merge_armatures.py b/ui/merge_armatures.py deleted file mode 100644 index 494c5a9..0000000 --- a/ui/merge_armatures.py +++ /dev/null @@ -1,45 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from bpy.types import Panel, Context -from ..core.common import get_selected_armature -from ..core.translations import t -from ..functions.armature_modifying import AvatarToolkit_OT_MergeArmatures - -class AvatarToolkit_PT_MergeArmaturesPanel(Panel): - bl_label = t("MergeArmatures.label") - bl_idname = "OBJECT_PT_avatar_toolkit_merge_armatures" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 5 - - def draw(self, context: Context): - layout = self.layout - armature = get_selected_armature(context) - - if armature: - layout.label(text=t("MergeArmatures.title.label"), icon='ARMATURE_DATA') - - layout.separator(factor=0.5) - - box = layout.box() - col = box.column(align=True) - - col.prop(context.scene.avatar_toolkit, "selected_armature", text=t("MergeArmatures.target_armature.label"), icon="ARMATURE_DATA") - col.prop(context.scene.avatar_toolkit, "merge_armature_source", icon="OUTLINER_OB_ARMATURE") - - layout.separator(factor=0.5) - - col = layout.column(align=True) - col.prop(context.scene.avatar_toolkit, "merge_armature_align_bones", icon="BONE_DATA") - col.prop(context.scene.avatar_toolkit, "merge_armature_apply_transforms", icon="OBJECT_ORIGIN") - - layout.separator(factor=1.0) - - row = layout.row() - row.scale_y = 1.5 - row.operator(operator=AvatarToolkit_OT_MergeArmatures.bl_idname, icon="ARMATURE_DATA") - - else: - layout.label(text=t("MergeArmatures.select_armature"), icon='ERROR') diff --git a/ui/mmd_options.py b/ui/mmd_options.py deleted file mode 100644 index 55b1839..0000000 --- a/ui/mmd_options.py +++ /dev/null @@ -1,48 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.translations import t -from ..functions.mmd_functions import * -from ..functions.mesh_tools import AvatarToolKit_OT_JoinAllMeshes -from ..functions.combine_materials import AvatarToolKit_OT_CombineMaterials -from ..functions.additional_tools import AvatarToolKit_OT_ApplyTransforms - -class AvatarToolkit_PT_MMDOptionsPanel(bpy.types.Panel): - bl_label = t("MMDOptions.label") - bl_idname = "OBJECT_PT_avatar_toolkit_mmd_options" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 4 - - def draw(self, context: bpy.types.Context) -> None: - layout = self.layout - - layout.label(text=t("MMDOptions.title"), icon='OUTLINER_OB_ARMATURE') - - layout.separator(factor=0.5) - - col = layout.column(align=True) - col.scale_y = 1.2 - col.operator(AvatarToolKit_OT_CleanupMesh.bl_idname, icon='BRUSH_DATA') - col.operator(AvatarToolKit_OT_JoinAllMeshes.bl_idname, icon='OBJECT_DATAMODE') - - layout.separator(factor=0.5) - - col = layout.column(align=True) - col.scale_y = 1.2 - col.operator(AvatarToolKit_OT_OptimizeWeights.bl_idname, icon='MOD_VERTEX_WEIGHT') - col.operator(AvatarToolKit_OT_OptimizeArmature.bl_idname, icon='ARMATURE_DATA') - - layout.separator(factor=0.5) - - row = layout.row(align=True) - row.scale_y = 1.2 - row.operator(AvatarToolKit_OT_ApplyTransforms.bl_idname, icon='OBJECT_ORIGIN') - row.operator(AvatarToolKit_OT_CombineMaterials.bl_idname, icon='MATERIAL') - - layout.separator(factor=0.5) - - row = layout.row() - row.scale_y = 1.2 - row.operator(AvatarToolKit_OT_ConvertMaterials.bl_idname, icon='SHADING_TEXTURE') diff --git a/ui/optimization.py b/ui/optimization.py deleted file mode 100644 index 0c2877e..0000000 --- a/ui/optimization.py +++ /dev/null @@ -1,46 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.translations import t -from ..functions.remove_doubles_safely import AvatarToolKit_OT_RemoveDoublesSafely, AvatarToolKit_OT_RemoveDoublesSafelyAdvanced -from ..core.common import get_selected_armature -from ..functions.mesh_tools import AvatarToolKit_OT_JoinAllMeshes, AvatarToolKit_OT_JoinSelectedMeshes -from ..functions.combine_materials import AvatarToolKit_OT_CombineMaterials - -class AvatarToolkit_PT_OptimizationPanel(bpy.types.Panel): - bl_label = t("Optimization.label") - bl_idname = "OBJECT_PT_avatar_toolkit_optimization" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 2 - - def draw(self, context: bpy.types.Context): - layout = self.layout - armature = get_selected_armature(context) - - if armature: - layout.label(text=t("Optimization.options.label"), icon='SETTINGS') - - layout.separator(factor=0.5) - - row = layout.row(align=True) - row.scale_y = 1.2 - row.operator(AvatarToolKit_OT_CombineMaterials.bl_idname, text=t("Optimization.combine_materials.label"), icon='MATERIAL') - - layout.separator(factor=0.5) - - row = layout.row(align=True) - row.scale_y = 1.2 - row.operator(AvatarToolKit_OT_RemoveDoublesSafely.bl_idname, text=t("Optimization.remove_doubles_safely.label"), icon='SNAP_VERTEX') - row.operator(AvatarToolKit_OT_RemoveDoublesSafelyAdvanced.bl_idname, text=t("Optimization.remove_doubles_safely_advanced.label"), icon="ACTION") - layout.separator(factor=1.0) - - layout.label(text=t("Optimization.joinmeshes.label"), icon='OBJECT_DATA') - row = layout.row(align=True) - row.scale_y = 1.2 - row.operator(AvatarToolKit_OT_JoinAllMeshes.bl_idname, text=t("Optimization.join_all_meshes.label"), icon='OUTLINER_OB_MESH') - row.operator(AvatarToolKit_OT_JoinSelectedMeshes.bl_idname, text=t("Optimization.join_selected_meshes.label"), icon='STICKY_UVS_LOC') - - else: - layout.label(text=t("Optimization.select_armature"), icon='ERROR') diff --git a/ui/quick_access.py b/ui/quick_access.py deleted file mode 100644 index cd87e32..0000000 --- a/ui/quick_access.py +++ /dev/null @@ -1,89 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.exporters.export_resonite import AvatarToolKit_OT_ExportResonite -from bpy.types import Context, Mesh, Panel, Operator -from ..core.translations import t -from ..core.common import get_selected_armature -from ..functions.import_anything import AvatarToolKit_OT_ImportAnyModel -from ..functions.armature_modifying import (AvatarToolkit_OT_StartPoseMode, - AvatarToolkit_OT_StopPoseMode, - AvatarToolkit_OT_ApplyPoseAsRest, - AvatarToolkit_OT_ApplyPoseAsShapekey) - -class AvatarToolkitQuickAccessPanel(Panel): - bl_label = t("Quick_Access.label") - bl_idname = "OBJECT_PT_avatar_toolkit_quick_access" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 1 - - def draw(self, context: Context): - layout = self.layout - layout.label(text=t("Quick_Access.options"), icon='TOOL_SETTINGS') - - layout.separator(factor=1.0) - - layout.label(text=t("Quick_Access.select_armature"), icon='ARMATURE_DATA') - layout.prop(context.scene.avatar_toolkit, "selected_armature", text="") - - layout.separator(factor=1.0) - - layout.label(text=t("Quick_Access.import_export.label"), icon='IMPORT') - - row = layout.row(align=True) - row.scale_y = 1.5 - row.operator(AvatarToolKit_OT_ImportAnyModel.bl_idname, text=t("Quick_Access.import"), icon='IMPORT') - row.operator(AVATAR_TOOLKIT_OT_ExportMenu.bl_idname, text=t("Quick_Access.export"), icon='EXPORT') - - layout.separator(factor=1.0) - - if get_selected_armature(context) != None: - if(context.mode == "POSE"): - col = layout.column(align=True) - col.scale_y = 1.2 - col.operator(AvatarToolkit_OT_StopPoseMode.bl_idname, text=t("Quick_Access.stop_pose_mode.label"), icon='POSE_HLT') - - layout.separator(factor=0.5) - - col = layout.column(align=True) - col.scale_y = 1.2 - col.operator(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, text=t("Quick_Access.apply_pose_as_rest.label"), icon='MOD_ARMATURE') - col.operator(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, text=t("Quick_Access.apply_pose_as_shapekey.label"), icon='MOD_ARMATURE') - else: - row = layout.row() - row.scale_y = 1.2 - row.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, text=t("Quick_Access.start_pose_mode.label"), icon='POSE_HLT') - -class AVATAR_TOOLKIT_OT_ExportMenu(bpy.types.Operator): - bl_idname = "avatar_toolkit.export_menu" - bl_label = t("Quick_Access.export_menu.label") - bl_description = t("Quick_Access.export_menu.desc") - - @classmethod - def poll(cls, context): - return any(obj.type == 'MESH' for obj in context.scene.objects) - - def execute(self, context: Context) -> set[str]: - return {'FINISHED'} - - def invoke(self, context: Context, event): - wm = context.window_manager - return wm.invoke_popup(self, width=200) - - def draw(self, context: Context): - layout = self.layout - layout.label(text=t("Quick_Access.select_export.label"), icon='EXPORT') - layout.operator(AvatarToolKit_OT_ExportResonite.bl_idname, text=t("Quick_Access.select_export_resonite.label"), icon='SCENE_DATA') - layout.operator(AVATAR_TOOLKIT_OT_ExportFbx.bl_idname, text=t("Quick_Access.export_fbx.label"), icon='OBJECT_DATA') - -class AVATAR_TOOLKIT_OT_ExportFbx(bpy.types.Operator): - bl_idname = 'avatar_toolkit.export_fbx' - bl_label = t("Quick_Access.export_fbx.label") - bl_description = t("Quick_Access.export_fbx.desc") - bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} - - def execute(self, context) -> set[str]: - bpy.ops.export_scene.fbx('INVOKE_DEFAULT') - return {'FINISHED'} diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py new file mode 100644 index 0000000..154fc09 --- /dev/null +++ b/ui/quick_access_panel.py @@ -0,0 +1,132 @@ +import bpy +from typing import Set, Optional, List, Tuple +from bpy.types import Operator, Panel, Menu, Context, UILayout +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.common import ( + get_active_armature, + clear_default_objects, + validate_armature, + get_armature_list, + get_armature_stats +) +from ..core.importers.importer import import_types, imports +from ..functions.pose_mode import ( + AvatarToolkit_OT_StartPoseMode, + AvatarToolkit_OT_StopPoseMode, + AvatarToolkit_OT_ApplyPoseAsShapekey, + AvatarToolkit_OT_ApplyPoseAsRest +) + +class AvatarToolKit_OT_Import(Operator): + """Import FBX files into Blender with Avatar Toolkit settings""" + bl_idname = "avatar_toolkit.import" + bl_label = t("QuickAccess.import") + + def execute(self, context: Context) -> Set[str]: + clear_default_objects() + bpy.ops.import_scene.fbx('INVOKE_DEFAULT', filter_glob=imports) + return {'FINISHED'} + +class AvatarToolKit_OT_ExportFBX(Operator): + """Export selected objects as FBX""" + bl_idname = "avatar_toolkit.export_fbx" + bl_label = t("QuickAccess.export_fbx") + + def execute(self, context: Context) -> Set[str]: + bpy.ops.export_scene.fbx('INVOKE_DEFAULT') + return {'FINISHED'} + +class AvatarToolKit_MT_ExportMenu(Menu): + """Export menu containing various export options""" + bl_idname = "AVATAR_TOOLKIT_MT_export_menu" + bl_label = t("QuickAccess.export") + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + layout.operator("avatar_toolkit.export_fbx", text=t("QuickAccess.export_fbx")) + layout.operator("avatar_toolkit.export_resonite", text=t("QuickAccess.export_resonite")) + +class AvatarToolKit_OT_ExportMenu(Operator): + """Open the export menu""" + bl_idname = "avatar_toolkit.export" + bl_label = t("QuickAccess.export") + + def execute(self, context: Context) -> Set[str]: + bpy.ops.wm.call_menu(name=AvatarToolKit_MT_ExportMenu.bl_idname) + return {'FINISHED'} + +class AvatarToolKit_PT_QuickAccessPanel(Panel): + """Quick access panel for common Avatar Toolkit operations""" + bl_label = t("QuickAccess.label") + bl_idname = "OBJECT_PT_avatar_toolkit_quick_access" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 0 + + @classmethod + def poll(cls, context: Context) -> bool: + """Only show panel in Object or Pose mode""" + return context.mode in {'OBJECT', 'POSE'} + + def draw(self, context: Context) -> None: + """Draw the panel layout""" + layout: UILayout = self.layout + + # Armature Selection Box + armature_box: UILayout = layout.box() + col: UILayout = armature_box.column(align=True) + col.label(text=t("QuickAccess.select_armature"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + + # Armature Selection + col.prop(context.scene.avatar_toolkit, "active_armature", text="") + + # Armature Validation + active_armature = get_active_armature(context) + if active_armature: + is_valid: bool + message: str + is_valid, message = validate_armature(active_armature) + + if is_valid: + info_box: UILayout = col.box() + row: UILayout = info_box.row() + split: UILayout = row.split(factor=0.6) + split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK') + stats: dict = get_armature_stats(active_armature) + split.label(text=t("QuickAccess.bones_count", count=stats['bone_count'])) + + if stats['has_pose']: + info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') + else: + col.separator(factor=0.5) + col.label(text=message, icon='ERROR') + + # Pose Mode Controls + pose_box: UILayout = layout.box() + col = pose_box.column(align=True) + col.label(text=t("QuickAccess.pose_controls"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + + if context.mode == "POSE": + col.operator(AvatarToolkit_OT_StopPoseMode.bl_idname, icon='POSE_HLT') + col.separator(factor=0.5) + col.operator(AvatarToolkit_OT_ApplyPoseAsRest.bl_idname, icon='MOD_ARMATURE') + col.operator(AvatarToolkit_OT_ApplyPoseAsShapekey.bl_idname, icon='MOD_ARMATURE') + else: + col.operator(AvatarToolkit_OT_StartPoseMode.bl_idname, icon='POSE_HLT') + + # Import/Export Box + import_box: UILayout = layout.box() + col = import_box.column(align=True) + col.label(text=t("QuickAccess.import_export"), icon='IMPORT') + col.separator(factor=0.5) + + # Import/Export Buttons + button_row: UILayout = col.row(align=True) + button_row.scale_y = 1.5 + button_row.operator("avatar_toolkit.import", text=t("QuickAccess.import"), icon='IMPORT') + button_row.operator("avatar_toolkit.export", text=t("QuickAccess.export"), icon='EXPORT') diff --git a/ui/settings.py b/ui/settings.py deleted file mode 100644 index fbb6c6c..0000000 --- a/ui/settings.py +++ /dev/null @@ -1,38 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.translations import t - -class AvatarToolkitSettingsPanel(bpy.types.Panel): - bl_label = t("Settings.label") - bl_idname = "OBJECT_PT_avatar_toolkit_settings" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 8 - - def draw(self, context): - layout = self.layout - - layout.label(text=t("Settings.language.label")) - layout.prop(context.scene.avatar_toolkit, "language", text="", icon='WORLD') - -class AVATAR_TOOLKIT_OT_translation_restart_popup(bpy.types.Operator): - bl_idname = "avatar_toolkit.translation_restart_popup" - bl_label = t("Settings.translation_restart_popup.label") - bl_description = t("Settings.translation_restart_popup.description") - bl_options = {'INTERNAL'} - - def execute(self, context): - if context.scene.avatar_toolkit.language_changed: - bpy.ops.script.reload() - context.scene.avatar_toolkit.language_changed = False - return {'FINISHED'} - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self, width=300) - - def draw(self, context): - layout = self.layout - layout.label(text=t("Settings.translation_restart_popup.message1"), icon='INFO') - layout.label(text=t("Settings.translation_restart_popup.message2"), icon='FILE_REFRESH') diff --git a/ui/tools.py b/ui/tools.py deleted file mode 100644 index d07a380..0000000 --- a/ui/tools.py +++ /dev/null @@ -1,76 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from bpy.types import Context -from ..functions.digitigrade_legs import AvatarToolKit_OT_CreateDigitigradeLegs -from ..functions.resonite_functions import AvatarToolKit_OT_ConvertToResonite -from ..core.translations import t -from ..core.common import get_selected_armature -from ..functions.mesh_tools import AvatarToolkit_OT_RemoveUnusedShapekeys -from ..functions.additional_tools import (AvatarToolKit_OT_ApplyTransforms, - AvatarToolKit_OT_ConnectBones, - AvatarToolKit_OT_DeleteBoneConstraints, - AvatarToolKit_OT_SeparateByMaterials, - AvatarToolKit_OT_SeparateByLooseParts) -from ..functions.armature_modifying import (AvatarToolkit_OT_RemoveZeroWeightBones, - AvatarToolkit_OT_MergeBonesToActive, - AvatarToolkit_OT_MergeBonesToParents) - -class AvatarToolkit_PT_ToolsPanel(bpy.types.Panel): - bl_label = t("Tools.label") - bl_idname = "OBJECT_PT_avatar_toolkit_tools" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 - - def draw(self, context: Context): - layout = self.layout - armature = get_selected_armature(context) - - if armature: - layout.label(text=t("Tools.tools_title.label"), icon='TOOL_SETTINGS') - layout.separator(factor=0.5) - - row = layout.row(align=True) - row.scale_y = 1.5 - row.operator(AvatarToolKit_OT_ConvertToResonite.bl_idname, text=t("Tools.convert_to_resonite.label"), icon='SCENE_DATA') - - layout.separator(factor=1.0) - - layout.label(text=t("Tools.separate_by.label"), icon='MESH_DATA') - row = layout.row(align=True) - 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') - - layout.separator(factor=1.0) - - layout.label(text=t("Tools.bone_tools.label"), icon='BONE_DATA') - row = layout.row(align=True) - row.operator(AvatarToolKit_OT_CreateDigitigradeLegs.bl_idname, text=t("Tools.create_digitigrade_legs.label"), icon='BONE_DATA') - - 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) - 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') - - row = layout.row(align=True) - row.operator(AvatarToolKit_OT_ConnectBones.bl_idname, text=t("Tools.connect_bones.label"), icon='BONE_DATA') - row.operator(AvatarToolKit_OT_DeleteBoneConstraints.bl_idname, text=t("Tools.delete_bone_constraints.label"), icon='CONSTRAINT_BONE') - - row = layout.row() - row.prop(context.scene.avatar_toolkit, "merge_twist_bones") - - layout.separator(factor=1.0) - - layout.label(text=t("Tools.additional_tools.label"), icon='TOOL_SETTINGS') - row = layout.row(align=True) - row.operator(AvatarToolKit_OT_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') - - layout.separator(factor=1.0) - else: - layout.label(text=t("Tools.select_armature"), icon='ERROR') - diff --git a/ui/uv_panel.py b/ui/uv_panel.py deleted file mode 100644 index 50e4c7b..0000000 --- a/ui/uv_panel.py +++ /dev/null @@ -1,14 +0,0 @@ -import bpy -from ..core.translations import t -from .main_panel import draw_title - -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 - draw_title(self) diff --git a/ui/uv_tools.py b/ui/uv_tools.py deleted file mode 100644 index d05489e..0000000 --- a/ui/uv_tools.py +++ /dev/null @@ -1,19 +0,0 @@ -import bpy -from ..core.translations import t -from ..functions.uv_tools import AvatarToolkit_OT_AlignUVEdgesToTarget -from .main_panel import draw_title -from .uv_panel import UVTools_PT_MainPanel - -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 = UVTools_PT_MainPanel.bl_idname - bl_order = 3 - - def draw(self, context: bpy.types.Context): - layout = self.layout - 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') diff --git a/ui/viseme.py b/ui/viseme.py deleted file mode 100644 index 32e8e43..0000000 --- a/ui/viseme.py +++ /dev/null @@ -1,53 +0,0 @@ -import bpy -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..functions.viseme import AvatarToolKit_OT_AutoVisemeButton -from ..core.translations import t -from ..core.common import get_selected_armature - -class AvatarToolkitVisemePanel(bpy.types.Panel): - bl_label = t("VisemePanel.label") - bl_idname = "OBJECT_PT_avatar_toolkit_viseme" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 7 - - def draw(self, context: bpy.types.Context) -> None: - layout = self.layout - - armature = get_selected_armature(context) - if armature: - layout.label(text=t("VisemePanel.label"), icon='SOUND') - - layout.separator(factor=0.5) - - layout.prop(context.scene.avatar_toolkit, "selected_mesh", text=t("VisemePanel.select_mesh"), icon='OUTLINER_OB_MESH') - - mesh = bpy.data.objects.get(context.scene.avatar_toolkit.selected_mesh) - if mesh and mesh.type == 'MESH': - if mesh.data.shape_keys: - box = layout.box() - col = box.column(align=True) - col.prop_search(context.scene.avatar_toolkit, "mouth_a", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_a.label'), icon='SHAPEKEY_DATA') - col.prop_search(context.scene.avatar_toolkit, "mouth_o", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_o.label'), icon='SHAPEKEY_DATA') - col.prop_search(context.scene.avatar_toolkit, "mouth_ch", mesh.data.shape_keys, "key_blocks", text=t('VisemePanel.mouth_ch.label'), icon='SHAPEKEY_DATA') - - layout.separator(factor=0.5) - - layout.prop(context.scene.avatar_toolkit, 'shape_intensity', text=t('VisemePanel.shape_intensity'), icon='FORCE_LENNARDJONES') - - layout.separator(factor=1.0) - - row = layout.row() - row.scale_y = 1.5 - row.operator(AvatarToolKit_OT_AutoVisemeButton.bl_idname, text=t('VisemePanel.create_visemes'), icon='TRIA_RIGHT') - else: - layout.label(text=t('VisemePanel.error.noShapekeys'), icon='ERROR') - else: - layout.label(text=t('VisemePanel.error.selectMesh'), icon='INFO') - else: - layout.label(text=t('VisemePanel.error.noArmature'), icon='ERROR') - - layout.separator(factor=1.0) - layout.label(text=t('VisemePanel.info.selectMesh'), icon='HELP') From 5dcaba381d862908c05c07b0abc19a167d6b07fe Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 4 Dec 2024 00:54:21 +0000 Subject: [PATCH 05/19] Pose mode improvements, armature validation improvements. Pose mode Improvements: Batch processing for all mesh operations Numpy-powered vertex array handling Optimized modifier stack management Smart shape key processing Enhanced progress tracking The armature validation system improvements: Essential bones (hips, spine, chest, neck, head) Proper bone hierarchy validation Symmetry pair verification (e.g., arm.l/arm.r) --- core/common.py | 231 ++++++++++++++++++++++++----- functions/pose_mode.py | 238 ++++++++++++++++++------------ resources/translations/en_US.json | 43 ++++-- ui/quick_access_panel.py | 8 +- 4 files changed, 370 insertions(+), 150 deletions(-) diff --git a/core/common.py b/core/common.py index 14121fe..ee3b694 100644 --- a/core/common.py +++ b/core/common.py @@ -1,10 +1,48 @@ import bpy import numpy as np -from bpy.types import Context, Object -from typing import Optional, Tuple, List, Set +import logging +from bpy.types import Context, Object, Modifier +from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.translations import t from ..core.dictionaries import bone_names +logger = logging.getLogger('avatar_toolkit') +logger.setLevel(logging.DEBUG) + +def setup_logging() -> None: + """Configure logging for Avatar Toolkit""" + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + +class ProgressTracker: + """Universal progress tracking for Avatar Toolkit operations""" + + def __init__(self, context: Context, total_steps: int, operation_name: str = "Operation"): + self.context = context + self.total = total_steps + self.current = 0 + self.operation_name = operation_name + self.wm = context.window_manager + + def step(self, message: str = "") -> None: + """Update progress by one step""" + self.current += 1 + progress = self.current / self.total + self.wm.progress_begin(0, 100) + self.wm.progress_update(progress * 100) + logger.debug(f"{self.operation_name} - {progress:.1%}: {message}") + + def __enter__(self): + logger.info(f"Starting {self.operation_name}") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.wm.progress_end() + logger.info(f"Completed {self.operation_name}") + def get_active_armature(context: bpy.types.Context) -> Optional[bpy.types.Object]: """Get the currently selected armature from Avatar Toolkit properties""" armature_name = context.scene.avatar_toolkit.active_armature @@ -25,27 +63,95 @@ def get_armature_list(self=None, context: bpy.types.Context = None) -> List[Tupl return [('NONE', t("Armature.validation.no_armature"), '')] return armatures -def validate_armature(armature: bpy.types.Object) -> Tuple[bool, str]: - """ - Validate if the selected object is a proper armature and has required bones - Returns tuple of (is_valid, message) - """ - if not armature: - return False, t("Armature.validation.no_armature") - if armature.type != 'ARMATURE': - return False, t("Armature.validation.not_armature") - if not armature.data.bones: - return False, t("Armature.validation.no_bones") - - essential_bones: Set[str] = {'hips', 'spine', 'chest', 'neck', 'head'} - found_bones: Set[str] = {bone.name.lower() for bone in armature.data.bones} +def validate_armature(armature: bpy.types.Object, validation_level: str = 'standard') -> Tuple[bool, List[str]]: + """Enhanced armature validation with multiple checks and validation levels""" + messages = [] + # Basic checks + if not armature or armature.type != 'ARMATURE' or not armature.data.bones: + return False, [t("Armature.validation.basic_check_failed")] + + found_bones = {bone.name.lower(): bone for bone in armature.data.bones} + + # Essential bones check + essential_bones = {'hips', 'spine', 'chest', 'neck', 'head'} + missing_bones = [] for bone in essential_bones: if not any(alt_name in found_bones for alt_name in bone_names[bone]): - return False, t("Armature.validation.missing_bone", bone=bone) - - return True, t("QuickAccess.valid_armature") + missing_bones.append(bone) + + if missing_bones: + messages.append(t("Armature.validation.missing_bones", bones=", ".join(missing_bones))) + + if validation_level in ['standard', 'strict']: + # Hierarchy validation + hierarchy = [('hips', 'spine'), ('spine', 'chest'), ('chest', 'neck'), ('neck', 'head')] + for parent, child in hierarchy: + if not validate_bone_hierarchy(found_bones, parent, child): + messages.append(t("Armature.validation.invalid_hierarchy", parent=parent, child=child)) + + # Symmetry validation + symmetry_pairs = [('arm', 'l', 'r'), ('leg', 'l', 'r')] + for base, left, right in symmetry_pairs: + if not validate_symmetry(found_bones, base, left, right): + messages.append(t("Armature.validation.asymmetric_bones", bone=base)) + + # Special handling for hand/wrist symmetry + if (not validate_symmetry(found_bones, 'hand', 'l', 'r') and + not validate_symmetry(found_bones, 'wrist', 'l', 'r')): + messages.append(t("Armature.validation.asymmetric_hand_wrist")) + + is_valid = len(messages) == 0 + return is_valid, messages +def validate_bone_hierarchy(bones: Dict[str, bpy.types.Bone], parent_name: str, child_name: str) -> bool: + """Validate if there is a valid parent-child relationship between bones""" + # Find matching parent and child bones using bone_names dictionary + parent_bone = None + child_bone = None + + # Check for parent bone matches + for alt_name in bone_names[parent_name]: + if alt_name in bones: + parent_bone = bones[alt_name] + break + + # Check for child bone matches + for alt_name in bone_names[child_name]: + if alt_name in bones: + child_bone = bones[alt_name] + break + + if not parent_bone or not child_bone: + return False + + # Check if child's parent matches parent bone + return child_bone.parent == parent_bone + +def validate_symmetry(bones: Dict[str, bpy.types.Bone], base: str, left: str, right: str) -> bool: + """ + Validate if matching left and right bones exist for a given base bone name + """ + # Define common naming patterns + left_patterns = [ + f"{base}.{left}", + f"{base}_{left}", + f"{left}_{base}" + ] + + right_patterns = [ + f"{base}.{right}", + f"{base}_{right}", + f"{right}_{base}" + ] + + # Check if any of the patterns exist in the bones dictionary + left_exists = any(pattern in bones for pattern in left_patterns) + right_exists = any(pattern in bones for pattern in right_patterns) + + return left_exists and right_exists + + def auto_select_single_armature(context: bpy.types.Context) -> None: """Automatically select armature if only one exists in scene""" armatures = get_armature_list(context) @@ -69,33 +175,79 @@ def get_armature_stats(armature: bpy.types.Object) -> dict: } def get_all_meshes(context: Context) -> List[Object]: + """Get all mesh objects parented to the active armature""" armature = get_active_armature(context) if armature: return [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == armature] return [] -def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> bool: - for mesh_obj in meshes: - if not mesh_obj.data: - continue - if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks: - if len(mesh_obj.data.shape_keys.key_blocks) == 1: - basis = mesh_obj.data.shape_keys.key_blocks[0] - basis_name = basis.name - mesh_obj.shape_key_remove(basis) - apply_armature_to_mesh(armature_obj, mesh_obj) - mesh_obj.shape_key_add(name=basis_name) - else: - apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context) - else: - apply_armature_to_mesh(armature_obj, mesh_obj) +def validate_mesh_for_pose(mesh_obj: Object) -> Tuple[bool, str]: + """Validate mesh object for pose operations""" + if not mesh_obj.data: + return False, t("Mesh.validation.no_data") + + if not mesh_obj.vertex_groups: + return False, t("Mesh.validation.no_vertex_groups") + + armature_mods = [mod for mod in mesh_obj.modifiers if mod.type == 'ARMATURE'] + if not armature_mods: + return False, t("Mesh.validation.no_armature_modifier") + + return True, t("Mesh.validation.valid") + +def cache_vertex_positions(mesh_obj: Object) -> np.ndarray: + """Cache vertex positions for a mesh object""" + vertices = mesh_obj.data.vertices + positions = np.empty(len(vertices) * 3, dtype=np.float32) + vertices.foreach_get('co', positions) + return positions.reshape(-1, 3) + +def apply_vertex_positions(vertices: Object, positions: np.ndarray) -> None: + """Apply cached vertex positions to mesh in batch""" + vertices.foreach_set('co', positions.flatten()) + +def process_armature_modifiers(mesh_obj: Object) -> List[Dict[str, Any]]: + """Process and store armature modifier states""" + modifier_states = [] + for mod in mesh_obj.modifiers: + if mod.type == 'ARMATURE': + modifier_states.append({ + 'name': mod.name, + 'object': mod.object, + 'vertex_group': mod.vertex_group, + 'show_viewport': mod.show_viewport + }) + return modifier_states + +def apply_pose_as_rest(context: Context, armature_obj: Object, meshes: List[Object]) -> Tuple[bool, str]: + """Apply current pose as rest pose for armature and update meshes""" + try: + logger.info(f"Starting pose application for {len(meshes)} meshes") + + with ProgressTracker(context, len(meshes), "Applying Pose") as progress: + for mesh_obj in meshes: + if not mesh_obj.data: + continue + + if mesh_obj.data.shape_keys and mesh_obj.data.shape_keys.key_blocks: + apply_armature_to_mesh_with_shapekeys(armature_obj, mesh_obj, context) + else: + apply_armature_to_mesh(armature_obj, mesh_obj) + + progress.step(f"Processed {mesh_obj.name}") - bpy.ops.object.mode_set(mode='POSE') - bpy.ops.pose.armature_apply(selected=False) - bpy.ops.object.mode_set(mode='OBJECT') - return True + bpy.ops.object.mode_set(mode='POSE') + bpy.ops.pose.armature_apply(selected=False) + bpy.ops.object.mode_set(mode='OBJECT') + + return True, t("Operation.pose_applied") + + except Exception as e: + logger.error(f"Error applying pose as rest: {str(e)}") + return False, str(e) def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: + """Apply armature deformation to mesh""" armature_mod = mesh_obj.modifiers.new('PoseToRest', 'ARMATURE') armature_mod.object = armature_obj @@ -109,6 +261,7 @@ def apply_armature_to_mesh(armature_obj: Object, mesh_obj: Object) -> None: bpy.ops.object.modifier_apply(modifier=armature_mod.name) def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object, context: Context) -> None: + """Apply armature deformation to mesh with shape keys""" old_active_index = mesh_obj.active_shape_key_index old_show_only = mesh_obj.show_only_shape_key mesh_obj.show_only_shape_key = True @@ -156,4 +309,4 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object sk.mute = mute mesh_obj.active_shape_key_index = old_active_index - mesh_obj.show_only_shape_key = old_show_only + mesh_obj.show_only_shape_key = old_show_only \ No newline at end of file diff --git a/functions/pose_mode.py b/functions/pose_mode.py index 27aa135..5c3dc50 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -1,120 +1,168 @@ import bpy -import numpy as np -from bpy.types import Operator, Context, Object -from typing import List +import logging +from typing import Set, Dict, List, Tuple, Optional, Any +from bpy.props import StringProperty +from bpy.types import Operator, Context, Object, Event, Modifier from ..core.translations import t from ..core.common import ( get_active_armature, get_all_meshes, apply_pose_as_rest, - apply_armature_to_mesh, - apply_armature_to_mesh_with_shapekeys, - validate_armature + validate_armature, + cache_vertex_positions, + apply_vertex_positions, + validate_mesh_for_pose, + process_armature_modifiers, + ProgressTracker ) +logger = logging.getLogger('avatar_toolkit.pose') + +class BatchPoseOperationMixin: + """Base class for batch pose operations""" + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid and context.mode == 'POSE' + + def validate_meshes(self, meshes: List[Object]) -> List[Tuple[Object, str]]: + """Validate meshes for pose operations""" + invalid_meshes = [] + for mesh in meshes: + valid, message = validate_mesh_for_pose(mesh) + if not valid: + invalid_meshes.append((mesh, message)) + return invalid_meshes + class AvatarToolkit_OT_StartPoseMode(Operator): bl_idname = 'avatar_toolkit.start_pose_mode' - bl_label = t("Quick_Access.start_pose_mode.label") - bl_description = t("Quick_Access.start_pose_mode.desc") + bl_label = t("QuickAccess.start_pose_mode.label") + bl_description = t("QuickAccess.start_pose_mode.desc") bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: armature = get_active_armature(context) if not armature or context.mode == "POSE": return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context: Context) -> set[str]: - armature = get_active_armature(context) - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='OBJECT') - bpy.ops.object.select_all(action='DESELECT') - armature.select_set(True) - bpy.ops.object.mode_set(mode='POSE') - return {'FINISHED'} + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + logger.info(f"Starting pose mode for armature: {armature.name}") + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + context.view_layer.objects.active = armature + armature.select_set(True) + bpy.ops.object.mode_set(mode='POSE') + + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to start pose mode: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.start", error=str(e))) + return {'CANCELLED'} class AvatarToolkit_OT_StopPoseMode(Operator): bl_idname = 'avatar_toolkit.stop_pose_mode' - bl_label = t("Quick_Access.stop_pose_mode.label") - bl_description = t("Quick_Access.stop_pose_mode.desc") + bl_label = t("QuickAccess.stop_pose_mode.label") + bl_description = t("QuickAccess.stop_pose_mode.desc") bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: return get_active_armature(context) and context.mode == "POSE" - - def execute(self, context: Context) -> set[str]: - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - bpy.ops.pose.transforms_clear() - bpy.ops.pose.select_all(action="INVERT") - bpy.ops.object.mode_set(mode='OBJECT') - return {'FINISHED'} -class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' - bl_label = t("Quick_Access.apply_pose_as_shapekey.label") - bl_description = t("Quick_Access.apply_pose_as_shapekey.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - if not armature or context.mode != 'POSE': - return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context): - armature_obj = get_active_armature(context) - mesh_objects = get_all_meshes(context) - - for mesh_obj in mesh_objects: - if not mesh_obj.data: - continue - - if not mesh_obj.data.shape_keys: - mesh_obj.shape_key_add(name='Basis') - - new_shape = mesh_obj.shape_key_add(name='Pose_Shapekey', from_mix=False) - - depsgraph = context.evaluated_depsgraph_get() - eval_mesh = mesh_obj.evaluated_get(depsgraph) - - for i, v in enumerate(eval_mesh.data.vertices): - new_shape.data[i].co = v.co.copy() - - bpy.ops.pose.select_all(action='SELECT') - bpy.ops.pose.transforms_clear() - bpy.ops.object.mode_set(mode='OBJECT') - - self.report({'INFO'}, t('Tools.apply_pose_as_rest.success')) - return {'FINISHED'} - -class AvatarToolkit_OT_ApplyPoseAsRest(Operator): - bl_idname = 'avatar_toolkit.apply_pose_as_rest' - bl_label = t("Quick_Access.apply_pose_as_rest.label") - bl_description = t("Quick_Access.apply_pose_as_rest.desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - if not armature or context.mode != "POSE": - return False - is_valid, _ = validate_armature(armature) - return is_valid - - def execute(self, context): - if not apply_pose_as_rest( - context=context, - armature_obj=get_active_armature(context), - meshes=get_all_meshes(context) - ): - self.report({'ERROR'}, t("Quick_Access.apply_armature_failed")) + def execute(self, context: Context) -> Set[str]: + try: + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.pose.transforms_clear() + bpy.ops.pose.select_all(action="INVERT") + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to stop pose mode: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.stop", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_ApplyPoseAsRest(Operator, BatchPoseOperationMixin): + bl_idname = 'avatar_toolkit.apply_pose_as_shapekey' + bl_label = t("QuickAccess.apply_pose_as_shapekey.label") + bl_description = t("QuickAccess.apply_pose_as_shapekey.desc") + bl_options = {'REGISTER', 'UNDO'} + + shapekey_name: StringProperty( + name=t("PoseMode.shapekey.name"), + description=t("PoseMode.shapekey.description"), + default=t("PoseMode.shapekey.default") + ) + + def invoke(self, context: Context, event: Event) -> Set[str]: + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context: Context) -> Set[str]: + try: + meshes = get_all_meshes(context) + invalid_meshes = self.validate_meshes(meshes) + + if invalid_meshes: + message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes) + self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message)) + + valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]] + + with ProgressTracker(context, len(valid_meshes), "Applying Pose as Shape Key") as progress: + for mesh_obj in valid_meshes: + if not mesh_obj.data.shape_keys: + mesh_obj.shape_key_add(name=t("PoseMode.basis")) + + new_shape = mesh_obj.shape_key_add(name=self.shapekey_name, from_mix=False) + cached_positions = cache_vertex_positions( + mesh_obj.evaluated_get(context.evaluated_depsgraph_get()) + ) + apply_vertex_positions(new_shape.data, cached_positions) + progress.step(f"Processed {mesh_obj.name}") + + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to apply pose as shape key: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.shapekey", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_ApplyPoseAsShapekey(Operator, BatchPoseOperationMixin): + bl_idname = 'avatar_toolkit.apply_pose_as_rest' + bl_label = t("QuickAccess.apply_pose_as_rest.label") + bl_description = t("QuickAccess.apply_pose_as_rest.desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> Set[str]: + try: + armature_obj = get_active_armature(context) + meshes = get_all_meshes(context) + + invalid_meshes = self.validate_meshes(meshes) + if invalid_meshes: + message = "\n".join(f"{mesh.name}: {reason}" for mesh, reason in invalid_meshes) + self.report({'WARNING'}, t("PoseMode.skipped_meshes", message=message)) + + valid_meshes = [mesh for mesh in meshes if mesh not in [m for m, _ in invalid_meshes]] + + with ProgressTracker(context, len(valid_meshes) + 2, "Applying Pose as Rest") as progress: + success, message = apply_pose_as_rest(context, armature_obj, valid_meshes) + if not success: + raise ValueError(message) + progress.step("Applied pose to armature") + + logger.info("Successfully applied pose as rest") + return {'FINISHED'} + except Exception as e: + logger.error(f"Failed to apply pose as rest: {str(e)}") + self.report({'ERROR'}, t("PoseMode.error.rest_pose", error=str(e))) return {'CANCELLED'} - - self.report({'INFO'}, t("Tools.apply_pose_as_rest.success")) - return {'FINISHED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index e8475c9..ba503a3 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -12,6 +12,7 @@ "Updater.UpdateToLatestButton.label": "Update to {name}", "Updater.UpdateToSelectedButton.label": "Update", "Updater.currentVersion": "Current Version: {name}", + "Updater.selectVersion": "Select Version", "Updater.CheckForUpdateButton.desc": "Check for available updates", "UpdateToLatestButton.desc": "Update to the latest version", "UpdateNotificationPopup.label": "Update Notification", @@ -36,23 +37,41 @@ "QuickAccess.export": "Export", "QuickAccess.export_fbx": "Export FBX", "QuickAccess.export_resonite": "Export to Resonite", + "QuickAccess.start_pose_mode.label": "Start Pose Mode", + "QuickAccess.start_pose_mode.desc": "Enter pose mode for the selected armature", + "QuickAccess.stop_pose_mode.label": "Stop Pose Mode", + "QuickAccess.stop_pose_mode.desc": "Exit pose mode and clear transforms", + "QuickAccess.apply_pose_as_shapekey.label": "Apply Pose as Shape Key", + "QuickAccess.apply_pose_as_shapekey.desc": "Create a new shape key from current pose", + "QuickAccess.apply_pose_as_rest.label": "Apply Pose as Rest", + "QuickAccess.apply_pose_as_rest.desc": "Apply current pose as rest pose", + "QuickAccess.apply_armature_failed": "Failed to apply armature modifications", - "Quick_Access.start_pose_mode.label": "Start Pose Mode", - "Quick_Access.start_pose_mode.desc": "Enter pose mode for the selected armature", - "Quick_Access.stop_pose_mode.label": "Stop Pose Mode", - "Quick_Access.stop_pose_mode.desc": "Exit pose mode and clear transforms", - "Quick_Access.apply_pose_as_shapekey.label": "Apply Pose as Shape Key", - "Quick_Access.apply_pose_as_shapekey.desc": "Create a new shape key from current pose", - "Quick_Access.apply_pose_as_rest.label": "Apply Pose as Rest", - "Quick_Access.apply_pose_as_rest.desc": "Apply current pose as rest pose", - "Quick_Access.apply_armature_failed": "Failed to apply armature modifications", - - "Tools.apply_pose_as_rest.success": "Successfully applied pose as rest position", + "PoseMode.error.start": "Failed to start pose mode: {error}", + "PoseMode.error.stop": "Failed to stop pose mode: {error}", + "PoseMode.error.shapekey": "Failed to apply pose as shape key: {error}", + "PoseMode.error.rest_pose": "Failed to apply pose as rest: {error}", + "PoseMode.shapekey.name": "Shape Key Name", + "PoseMode.shapekey.description": "Name for the new shape key", + "PoseMode.shapekey.default": "Pose_Shapekey", + "PoseMode.skipped_meshes": "Some meshes were skipped:\n{message}", + "PoseMode.basis": "Basis", "Armature.validation.no_armature": "No armature selected", "Armature.validation.not_armature": "Selected object is not an armature", "Armature.validation.no_bones": "Armature has no bones", - "Armature.validation.missing_bone": "Missing essential bone: {bone}", + "Armature.validation.basic_check_failed": "Basic armature validation failed", + "Armature.validation.missing_bones": "Missing essential bones: {bones}", + "Armature.validation.invalid_hierarchy": "Invalid bone hierarchy between {parent} and {child}", + "Armature.validation.asymmetric_bones": "Missing symmetric bones for {bone}", + "Armature.validation.asymmetric_hand_wrist": "Missing symmetric bones for hands/wrists", + + "Mesh.validation.no_data": "No mesh data", + "Mesh.validation.no_vertex_groups": "No vertex groups found", + "Mesh.validation.no_armature_modifier": "No armature modifier", + "Mesh.validation.valid": "Valid mesh for pose operations", + + "Operation.pose_applied": "Pose applied successfully", "Scene.avatar_toolkit_updater_version_list.name": "Version List", "Scene.avatar_toolkit_updater_version_list.description": "List of available versions" diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 154fc09..66454c2 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -87,9 +87,7 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): # Armature Validation active_armature = get_active_armature(context) if active_armature: - is_valid: bool - message: str - is_valid, message = validate_armature(active_armature) + is_valid, messages = validate_armature(active_armature) if is_valid: info_box: UILayout = col.box() @@ -103,7 +101,9 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') else: col.separator(factor=0.5) - col.label(text=message, icon='ERROR') + # Display each validation message + for message in messages: + col.label(text=message, icon='ERROR') # Pose Mode Controls pose_box: UILayout = layout.box() From 9961223548af96a7dea74b5b5798a903b3902a12 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 4 Dec 2024 14:58:34 +0000 Subject: [PATCH 06/19] Setting Panel Added, Debuging Added. Added the Armature Validation modes now, we have Stritct, Basic and None, it will give a warning to the user in the panel if there have it set to basic or none. Settings panel added, langauge change has been added back. Did some work on it to slightl improve the system. Added dubug area, basically everything but autoload will use logging now, you be able to turn it on/off in debug settings. Did other bits and bobs. --- core/addon_preferences.py | 21 ++++++--- core/auto_load.py | 8 ++++ core/common.py | 30 ++++++------- core/logging_setup.py | 26 +++++++++++ core/properties.py | 49 +++++++++++++++++++- core/translations.py | 75 ++++++++++++++++++------------- core/updater.py | 2 +- functions/pose_mode.py | 4 +- resources/translations/en_US.json | 30 ++++++++++++- ui/main_panel.py | 18 ++++---- ui/quick_access_panel.py | 75 +++++++++++++++++++++---------- ui/settings_panel.py | 75 +++++++++++++++++++++++++++++++ 12 files changed, 322 insertions(+), 91 deletions(-) create mode 100644 core/logging_setup.py create mode 100644 ui/settings_panel.py diff --git a/core/addon_preferences.py b/core/addon_preferences.py index 6b0184b..b9fdd63 100644 --- a/core/addon_preferences.py +++ b/core/addon_preferences.py @@ -2,6 +2,7 @@ import bpy import os import tomllib import json +from ..core.logging_setup import logger from bpy.types import AddonPreferences from typing import Any, Dict @@ -12,22 +13,31 @@ PREFERENCES_FILE = os.path.join(PREFERENCES_DIR, "preferences.json") def get_current_version(): main_dir = os.path.dirname(os.path.dirname(__file__)) manifest_path = os.path.join(main_dir, "blender_manifest.toml") + logger.debug(f"Reading version from manifest: {manifest_path}") with open(manifest_path, 'rb') as f: manifest_data = tomllib.load(f) - return manifest_data.get('version', 'Unknown') + version = manifest_data.get('version', 'Unknown') + logger.info(f"Current addon version: {version}") + return version def save_preference(key: str, value: Any) -> None: """Save a single preference to the JSON file.""" + logger.debug(f"Saving preference: {key} = {value}") prefs = load_preferences() prefs[key] = value with open(PREFERENCES_FILE, 'w') as f: json.dump(prefs, f, indent=4) + logger.info(f"Preference saved: {key}") def load_preferences() -> Dict[str, Any]: """Load all preferences from the JSON file.""" + logger.debug(f"Loading preferences from: {PREFERENCES_FILE}") if os.path.exists(PREFERENCES_FILE): with open(PREFERENCES_FILE, 'r') as f: - return json.load(f) + prefs = json.load(f) + logger.debug(f"Loaded preferences: {prefs}") + return prefs + logger.info("No preferences file found, using defaults") return {} def get_preference(key: str, default: Any = None) -> Any: @@ -40,12 +50,13 @@ class AvatarToolkitPreferences(AddonPreferences): def draw(self, context): layout = self.layout - layout.label(text="Preferences are managed internally.") - # You can add more UI elements here if needed + layout.label(text=f"Version: {get_current_version()}") def get_addon_preferences(context): return context.preferences.addons[AvatarToolkitPreferences.bl_idname].preferences # Initialize preferences if the file doesn't exist if not os.path.exists(PREFERENCES_FILE): - save_preference("language", 0) # Set default language to 0 (auto) \ No newline at end of file + save_preference("language", 0) # Set default language to 0 (auto) + save_preference("validation_mode", "STRICT") # Set default validation mode + save_preference("enable_logging", False) # Set default logging mode \ No newline at end of file diff --git a/core/auto_load.py b/core/auto_load.py index f0c50a5..ea4f86f 100644 --- a/core/auto_load.py +++ b/core/auto_load.py @@ -23,6 +23,14 @@ def init() -> None: """Initialize the auto-loader by discovering modules and classes""" global modules global ordered_classes + + # Configure logging first + from .logging_setup import configure_logging + configure_logging(False) + + from .addon_preferences import get_preference + configure_logging(get_preference("enable_logging", False)) + print("Auto-load init starting") modules = get_all_submodules(Path(__file__).parent.parent) ordered_classes = get_ordered_classes_to_register(modules) diff --git a/core/common.py b/core/common.py index ee3b694..bec7810 100644 --- a/core/common.py +++ b/core/common.py @@ -1,22 +1,11 @@ import bpy import numpy as np -import logging from bpy.types import Context, Object, Modifier from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable +from ..core.logging_setup import logger from ..core.translations import t from ..core.dictionaries import bone_names -logger = logging.getLogger('avatar_toolkit') -logger.setLevel(logging.DEBUG) - -def setup_logging() -> None: - """Configure logging for Avatar Toolkit""" - handler = logging.StreamHandler() - handler.setLevel(logging.DEBUG) - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - handler.setFormatter(formatter) - logger.addHandler(handler) - class ProgressTracker: """Universal progress tracking for Avatar Toolkit operations""" @@ -63,17 +52,23 @@ def get_armature_list(self=None, context: bpy.types.Context = None) -> List[Tupl return [('NONE', t("Armature.validation.no_armature"), '')] return armatures -def validate_armature(armature: bpy.types.Object, validation_level: str = 'standard') -> Tuple[bool, List[str]]: - """Enhanced armature validation with multiple checks and validation levels""" +def validate_armature(armature: bpy.types.Object) -> Tuple[bool, List[str]]: + """Enhanced armature validation with multiple validation modes""" + validation_mode = bpy.context.scene.avatar_toolkit.validation_mode + + # Skip validation if mode is NONE + if validation_mode == 'NONE': + return True, [] + messages = [] - # Basic checks + # Basic checks always run if not NONE if not armature or armature.type != 'ARMATURE' or not armature.data.bones: return False, [t("Armature.validation.basic_check_failed")] found_bones = {bone.name.lower(): bone for bone in armature.data.bones} - # Essential bones check + # Essential bones check (BASIC and STRICT) essential_bones = {'hips', 'spine', 'chest', 'neck', 'head'} missing_bones = [] for bone in essential_bones: @@ -83,7 +78,8 @@ def validate_armature(armature: bpy.types.Object, validation_level: str = 'stand if missing_bones: messages.append(t("Armature.validation.missing_bones", bones=", ".join(missing_bones))) - if validation_level in ['standard', 'strict']: + # Additional checks for STRICT mode only + if validation_mode == 'STRICT': # Hierarchy validation hierarchy = [('hips', 'spine'), ('spine', 'chest'), ('chest', 'neck'), ('neck', 'head')] for parent, child in hierarchy: diff --git a/core/logging_setup.py b/core/logging_setup.py new file mode 100644 index 0000000..f921d5d --- /dev/null +++ b/core/logging_setup.py @@ -0,0 +1,26 @@ +import logging +from typing import Optional + +logger = logging.getLogger('avatar_toolkit') + +def configure_logging(enabled: bool = False) -> None: + """Configure logging for Avatar Toolkit""" + logger.setLevel(logging.DEBUG if enabled else logging.WARNING) + + # Remove existing handlers + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + if enabled: + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + +def update_logging_state(self, context) -> None: + """Update logging state based on user preference""" + from .addon_preferences import save_preference + enabled = self.enable_logging + save_preference("enable_logging", enabled) + configure_logging(enabled) diff --git a/core/properties.py b/core/properties.py index d35eda0..0b2e0a6 100644 --- a/core/properties.py +++ b/core/properties.py @@ -10,11 +10,22 @@ from bpy.props import ( CollectionProperty, PointerProperty ) +from .logging_setup import logger from .translations import t, get_languages_list, update_language -from .addon_preferences import get_preference +from .addon_preferences import get_preference, save_preference from .updater import get_version_list from .common import get_armature_list +def update_validation_mode(self, context): + logger.info(f"Updating validation mode to: {self.validation_mode}") + save_preference("validation_mode", self.validation_mode) + +def update_logging_state(self, context): + logger.info(f"Updating logging state to: {self.enable_logging}") + save_preference("enable_logging", self.enable_logging) + from .logging_setup import configure_logging + configure_logging(self.enable_logging) + class AvatarToolkitSceneProperties(PropertyGroup): """Property group containing Avatar Toolkit scene-level settings and properties""" @@ -30,10 +41,46 @@ class AvatarToolkitSceneProperties(PropertyGroup): description=t("QuickAccess.select_armature") ) + language: EnumProperty( + name=t("Settings.language"), + description=t("Settings.language_desc"), + items=get_languages_list, + update=update_language + ) + + validation_mode: EnumProperty( + name=t("Settings.validation_mode"), + description=t("Settings.validation_mode_desc"), + items=[ + ('STRICT', t("Settings.validation_mode.strict"), t("Settings.validation_mode.strict_desc")), + ('BASIC', t("Settings.validation_mode.basic"), t("Settings.validation_mode.basic_desc")), + ('NONE', t("Settings.validation_mode.none"), t("Settings.validation_mode.none_desc")) + ], + default=get_preference("validation_mode", "STRICT"), + update=update_validation_mode + ) + + enable_logging: BoolProperty( + name=t("Settings.enable_logging"), + description=t("Settings.enable_logging_desc"), + default=False, + update=update_logging_state + ) + + debug_expand: BoolProperty( + name="Debug Settings Expanded", + default=False + ) + def register() -> None: """Register the Avatar Toolkit property group""" + logger.info("Registering Avatar Toolkit properties") bpy.types.Scene.avatar_toolkit = PointerProperty(type=AvatarToolkitSceneProperties) + logger.debug("Properties registered successfully") def unregister() -> None: """Unregister the Avatar Toolkit property group""" + logger.info("Unregistering Avatar Toolkit properties") del bpy.types.Scene.avatar_toolkit + logger.debug("Properties unregistered successfully") + diff --git a/core/translations.py b/core/translations.py index f047964..fc530d8 100644 --- a/core/translations.py +++ b/core/translations.py @@ -1,10 +1,15 @@ import os import json import bpy +import logging from bpy.app.translations import locale -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional, Any +from ..core.logging_setup import logger from .addon_preferences import save_preference, get_preference +# Set up logging +logger = logging.getLogger(__name__) + # Use __file__ to get the current file's directory current_dir = os.path.dirname(os.path.abspath(__file__)) main_dir = os.path.dirname(current_dir) @@ -13,9 +18,15 @@ translations_dir = os.path.join(resources_dir, "translations") dictionary: Dict[str, str] = dict() languages: List[str] = [] +_translation_cache: Dict[str, Dict[str, str]] = {} verbose: bool = True +def get_fallback_language() -> str: + """Return the default fallback language""" + return "en_US" + def load_translations() -> bool: + """Load translations for the selected language""" global dictionary, languages old_dictionary = dictionary.copy() @@ -29,69 +40,73 @@ def load_translations() -> bool: if lang != "auto": languages.append(lang) - language_index = get_preference("language", 0) - # print(f"Loading translations for language index: {language_index}") # Debug print + language_index: int = get_preference("language", 0) + logger.debug(f"Loading translations for language index: {language_index}") if language_index == 0: # "auto" - language = bpy.context.preferences.view.language + language: str = bpy.context.preferences.view.language else: try: language = languages[language_index] except IndexError: language = bpy.context.preferences.view.language - # print(f"Selected language: {language}") # Debug print + logger.debug(f"Selected language: {language}") + + # Check cache first + if language in _translation_cache: + dictionary = _translation_cache[language] + return dictionary != old_dictionary translation_file: str = os.path.join(translations_dir, language + ".json") if os.path.exists(translation_file): - with open(translation_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded translations: {dictionary}") # Debug print + dictionary = _load_translation_file(translation_file) else: custom_language: str = language.split("_")[0] custom_translation_file: str = os.path.join(translations_dir, custom_language + ".json") if os.path.exists(custom_translation_file): - with open(custom_translation_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded custom translations: {dictionary}") # Debug print + dictionary = _load_translation_file(custom_translation_file) else: - print(f"Translation file not found for language: {language}") - default_file: str = os.path.join(translations_dir, "en_US.json") + logger.warning(f"Translation file not found for language: {language}") + default_file: str = os.path.join(translations_dir, get_fallback_language() + ".json") if os.path.exists(default_file): - with open(default_file, 'r', encoding='utf-8') as file: - dictionary = json.load(file)["messages"] - # print(f"Loaded default translations: {dictionary}") # Debug print + dictionary = _load_translation_file(default_file) else: - print("Default translation file 'en_US.json' not found.") + logger.error("Default translation file not found") + _translation_cache[language] = dictionary return dictionary != old_dictionary -def t(phrase: str, default: str = None, **kwargs) -> str: - output: str = dictionary.get(phrase) +def _load_translation_file(file_path: str) -> Dict[str, str]: + """Load and parse a translation file""" + with open(file_path, 'r', encoding='utf-8') as file: + return json.load(file)["messages"] + +def t(phrase: str, default: Optional[str] = None, **kwargs) -> str: + """Get translation for a phrase with optional formatting""" + output: Optional[str] = dictionary.get(phrase) if output is None: if verbose: - print(f'Warning: Unknown phrase: {phrase}') + logger.warning(f'Unknown phrase: {phrase}') return default if default is not None else phrase - # print(f"Translating '{phrase}' to '{output}'") # Debug print return output.format(**kwargs) if kwargs else output def get_language_display_name(lang: str) -> str: - if lang == "auto": - return t("Language.auto", "Automatic") + """Get the display name for a language code""" return t(f"Language.{lang}", lang) -def get_languages_list(self, context) -> List[Tuple[str, str, str]]: - return [(str(i), get_language_display_name(lang), f"Use {lang} language") for i, lang in enumerate(languages)] +def get_languages_list(self: Any, context: Any) -> List[Tuple[str, str, str]]: + """Get list of available languages for UI""" + return [(str(i), get_language_display_name(lang), f"Use {lang} language") + for i, lang in enumerate(languages)] -def update_language(self, context): - print(f"Updating language to: {self.language}") # Debug print +def update_language(self: Any, context: Any) -> None: + """Handle language update and UI refresh""" + logger.info(f"Updating language to: {self.language}") save_preference("language", int(self.language)) load_translations() - # Set a flag to indicate that a language change has occurred context.scene.avatar_toolkit.language_changed = True - # Show popup after language change bpy.ops.avatar_toolkit.translation_restart_popup('INVOKE_DEFAULT') # Initial load of translations -# print("Performing initial load of translations") # Debug print load_translations() diff --git a/core/updater.py b/core/updater.py index 437bf15..66a9f5c 100644 --- a/core/updater.py +++ b/core/updater.py @@ -76,7 +76,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 9 + bl_order = 1 def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/pose_mode.py b/functions/pose_mode.py index 5c3dc50..c8fbc15 100644 --- a/functions/pose_mode.py +++ b/functions/pose_mode.py @@ -1,8 +1,8 @@ import bpy -import logging from typing import Set, Dict, List, Tuple, Optional, Any from bpy.props import StringProperty from bpy.types import Operator, Context, Object, Event, Modifier +from ..core.logging_setup import logger from ..core.translations import t from ..core.common import ( get_active_armature, @@ -16,8 +16,6 @@ from ..core.common import ( ProgressTracker ) -logger = logging.getLogger('avatar_toolkit.pose') - class BatchPoseOperationMixin: """Base class for batch pose operations""" @classmethod diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index ba503a3..f445e83 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -46,6 +46,10 @@ "QuickAccess.apply_pose_as_rest.label": "Apply Pose as Rest", "QuickAccess.apply_pose_as_rest.desc": "Apply current pose as rest pose", "QuickAccess.apply_armature_failed": "Failed to apply armature modifications", + "QuickAccess.validation_basic_warning": "Limited Validation Active", + "QuickAccess.validation_basic_details": "Only essential bone structure is being validated", + "QuickAccess.validation_none_warning": "Validation Disabled", + "QuickAccess.validation_none_details": "No armature validation checks are being performed", "PoseMode.error.start": "Failed to start pose mode: {error}", "PoseMode.error.stop": "Failed to stop pose mode: {error}", @@ -74,6 +78,30 @@ "Operation.pose_applied": "Pose applied successfully", "Scene.avatar_toolkit_updater_version_list.name": "Version List", - "Scene.avatar_toolkit_updater_version_list.description": "List of available versions" + "Scene.avatar_toolkit_updater_version_list.description": "List of available versions", + + "Settings.label": "Settings", + "Settings.language": "Language", + "Settings.language_desc": "Select interface language", + "Settings.validation_mode": "Validation Mode", + "Settings.validation_mode_desc": "Choose how strictly to validate armatures", + "Settings.validation_mode.strict": "Strict", + "Settings.validation_mode.strict_desc": "Full validation including bone hierarchy and symmetry", + "Settings.validation_mode.basic": "Basic", + "Settings.validation_mode.basic_desc": "Essential bones check only", + "Settings.validation_mode.none": "None", + "Settings.validation_mode.none_desc": "No armature validation", + "Settings.debug": "Debug Settings", + "Settings.logging": "Logging", + "Settings.enable_logging": "Enable Debug Logging", + "Settings.enable_logging_desc": "Enable detailed debug logging for troubleshooting", + "Settings.logging_enabled": "Debug logging enabled", + "Settings.logging_disabled": "Debug logging disabled", + "Language.auto": "Automatic", + "Language.en_US": "English", + "Language.ja_JP": "Japanese", + "Language.changed.title": "Language Changed", + "Language.changed.success": "Language changed successfully!", + "Language.changed.restart": "Some UI elements may require restarting Blender" } } diff --git a/ui/main_panel.py b/ui/main_panel.py index 7a802c8..6ae130d 100644 --- a/ui/main_panel.py +++ b/ui/main_panel.py @@ -1,5 +1,5 @@ import bpy -from typing import Optional +from typing import Optional, Set from bpy.types import Panel, Context, UILayout from ..core.translations import t @@ -13,12 +13,12 @@ def draw_title(self: Panel) -> None: # Add a nice header row: UILayout = col.row() - row.scale_y = 1.2 + row.scale_y: float = 1.2 row.label(text=t("AvatarToolkit.label"), icon='ARMATURE_DATA') # Description as a flowing paragraph desc_col: UILayout = col.column() - desc_col.scale_y = 0.6 + desc_col.scale_y: float = 0.6 desc_col.label(text=t("AvatarToolkit.desc1")) desc_col.label(text=t("AvatarToolkit.desc2")) desc_col.label(text=t("AvatarToolkit.desc3")) @@ -26,12 +26,12 @@ def draw_title(self: Panel) -> None: class AvatarToolKit_PT_AvatarToolkitPanel(Panel): """Main panel for Avatar Toolkit containing general information and settings""" - bl_label = t("AvatarToolkit.label") - bl_idname = "OBJECT_PT_avatar_toolkit" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_options = {'DEFAULT_CLOSED'} + bl_label: str = t("AvatarToolkit.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_options: Set[str] = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the main panel layout""" diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 66454c2..0b02111 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -1,6 +1,14 @@ import bpy -from typing import Set, Optional, List, Tuple -from bpy.types import Operator, Panel, Menu, Context, UILayout +from typing import Set, Dict, List, Optional, Tuple +from bpy.types import ( + Operator, + Panel, + Menu, + Context, + UILayout, + WindowManager, + Object +) from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t from ..core.common import ( @@ -20,8 +28,8 @@ from ..functions.pose_mode import ( class AvatarToolKit_OT_Import(Operator): """Import FBX files into Blender with Avatar Toolkit settings""" - bl_idname = "avatar_toolkit.import" - bl_label = t("QuickAccess.import") + bl_idname: str = "avatar_toolkit.import" + bl_label: str = t("QuickAccess.import") def execute(self, context: Context) -> Set[str]: clear_default_objects() @@ -30,8 +38,8 @@ class AvatarToolKit_OT_Import(Operator): class AvatarToolKit_OT_ExportFBX(Operator): """Export selected objects as FBX""" - bl_idname = "avatar_toolkit.export_fbx" - bl_label = t("QuickAccess.export_fbx") + bl_idname: str = "avatar_toolkit.export_fbx" + bl_label: str = t("QuickAccess.export_fbx") def execute(self, context: Context) -> Set[str]: bpy.ops.export_scene.fbx('INVOKE_DEFAULT') @@ -39,8 +47,8 @@ class AvatarToolKit_OT_ExportFBX(Operator): class AvatarToolKit_MT_ExportMenu(Menu): """Export menu containing various export options""" - bl_idname = "AVATAR_TOOLKIT_MT_export_menu" - bl_label = t("QuickAccess.export") + bl_idname: str = "AVATAR_TOOLKIT_MT_export_menu" + bl_label: str = t("QuickAccess.export") def draw(self, context: Context) -> None: layout: UILayout = self.layout @@ -49,22 +57,23 @@ class AvatarToolKit_MT_ExportMenu(Menu): class AvatarToolKit_OT_ExportMenu(Operator): """Open the export menu""" - bl_idname = "avatar_toolkit.export" - bl_label = t("QuickAccess.export") + bl_idname: str = "avatar_toolkit.export" + bl_label: str = t("QuickAccess.export") def execute(self, context: Context) -> Set[str]: - bpy.ops.wm.call_menu(name=AvatarToolKit_MT_ExportMenu.bl_idname) + wm: WindowManager = context.window_manager + wm.call_menu(name=AvatarToolKit_MT_ExportMenu.bl_idname) return {'FINISHED'} class AvatarToolKit_PT_QuickAccessPanel(Panel): """Quick access panel for common Avatar Toolkit operations""" - bl_label = t("QuickAccess.label") - bl_idname = "OBJECT_PT_avatar_toolkit_quick_access" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 0 + bl_label: str = t("QuickAccess.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_quick_access" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 0 @classmethod def poll(cls, context: Context) -> bool: @@ -85,25 +94,41 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): col.prop(context.scene.avatar_toolkit, "active_armature", text="") # Armature Validation - active_armature = get_active_armature(context) + active_armature: Optional[Object] = get_active_armature(context) if active_armature: + is_valid: bool + messages: List[str] is_valid, messages = validate_armature(active_armature) + # Create info box for all validation information + info_box: UILayout = col.box() + if is_valid: - info_box: UILayout = col.box() row: UILayout = info_box.row() split: UILayout = row.split(factor=0.6) split.label(text=t("QuickAccess.valid_armature"), icon='CHECKMARK') - stats: dict = get_armature_stats(active_armature) + stats: Dict[str, int] = get_armature_stats(active_armature) split.label(text=t("QuickAccess.bones_count", count=stats['bone_count'])) if stats['has_pose']: info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') else: - col.separator(factor=0.5) - # Display each validation message + # Display validation failure messages for message in messages: - col.label(text=message, icon='ERROR') + info_box.label(text=message, icon='ERROR') + + # Validation Mode Warnings - always show in info box + validation_mode = context.scene.avatar_toolkit.validation_mode + if validation_mode == 'BASIC': + warning_row = info_box.box() + warning_row.alert = True + warning_row.label(text=t("QuickAccess.validation_basic_warning"), icon='INFO') + warning_row.label(text=t("QuickAccess.validation_basic_details")) + elif validation_mode == 'NONE': + warning_row = info_box.box() + warning_row.alert = True + warning_row.label(text=t("QuickAccess.validation_none_warning"), icon='ERROR') + warning_row.label(text=t("QuickAccess.validation_none_details")) # Pose Mode Controls pose_box: UILayout = layout.box() @@ -130,3 +155,5 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): button_row.scale_y = 1.5 button_row.operator("avatar_toolkit.import", text=t("QuickAccess.import"), icon='IMPORT') button_row.operator("avatar_toolkit.export", text=t("QuickAccess.export"), icon='EXPORT') + + diff --git a/ui/settings_panel.py b/ui/settings_panel.py new file mode 100644 index 0000000..a948ef3 --- /dev/null +++ b/ui/settings_panel.py @@ -0,0 +1,75 @@ +import bpy +from typing import Set, Dict, List, Optional +from bpy.types import ( + Operator, + Panel, + Context, + UILayout, + WindowManager, + Event +) +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t, get_languages_list + +class AvatarToolkit_OT_TranslationRestartPopup(Operator): + """Popup dialog shown after language change to inform about restart requirement""" + bl_idname: str = "avatar_toolkit.translation_restart_popup" + bl_label: str = t("Language.changed.title") + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + + def invoke(self, context: Context, event: Event) -> Set[str]: + wm: WindowManager = context.window_manager + return wm.invoke_props_dialog(self) + + def draw(self, context: Context) -> None: + layout: UILayout = self.layout + layout.label(text=t("Language.changed.success")) + layout.label(text=t("Language.changed.restart")) + +class AvatarToolKit_PT_SettingsPanel(Panel): + """Settings panel for Avatar Toolkit containing language preferences""" + bl_label: str = t("Settings.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_settings" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 2 + + def draw(self, context: Context) -> None: + """Draw the settings panel layout with language selection""" + layout: UILayout = self.layout + + # Language Settings + lang_box: UILayout = layout.box() + col: UILayout = lang_box.column(align=True) + row: UILayout = col.row() + row.scale_y = 1.2 + row.label(text=t("Settings.language"), icon='WORLD') + col.separator() + col.prop(context.scene.avatar_toolkit, "language", text="") + + # Validation Settings + val_box: UILayout = layout.box() + col = val_box.column(align=True) + row = col.row() + row.scale_y = 1.2 + row.label(text=t("Settings.validation_mode"), icon='CHECKMARK') + col.separator() + col.prop(context.scene.avatar_toolkit, "validation_mode", text="") + + # Debug Settings + debug_box = layout.box() + col = debug_box.column() + row = col.row(align=True) + row.prop(context.scene.avatar_toolkit, "debug_expand", + icon="TRIA_DOWN" if context.scene.avatar_toolkit.debug_expand + else "TRIA_RIGHT", + icon_only=True, emboss=False) + row.label(text=t("Settings.debug"), icon='CONSOLE') + + if context.scene.avatar_toolkit.debug_expand: + col = debug_box.column(align=True) + col.prop(context.scene.avatar_toolkit, "enable_logging") From 9cc5a41a98b1f2e2f6804fd055d6151331e12fc9 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 5 Dec 2024 01:13:10 +0000 Subject: [PATCH 07/19] Optimzation Panel Re-Added - Major Improvements all round, Join Meshes improved, Combined Materials, Remove Doubles Improvements. --- core/__init__.py | 0 core/common.py | 87 ++++++- core/exporters/__init__.py | 0 core/importers/__init__.py | 0 core/properties.py | 19 +- core/updater.py | 2 +- functions/__init__.py | 0 functions/optimization/__init__.py | 0 functions/optimization/materials_tools.py | 175 ++++++++++++++ functions/optimization/mesh_tools.py | 103 ++++++++ functions/optimization/remove_doubles.py | 281 ++++++++++++++++++++++ resources/translations/en_US.json | 45 ++++ ui/__init__.py | 0 ui/optimization_panel.py | 50 ++++ ui/settings_panel.py | 2 +- 15 files changed, 755 insertions(+), 9 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/exporters/__init__.py create mode 100644 core/importers/__init__.py create mode 100644 functions/__init__.py create mode 100644 functions/optimization/__init__.py create mode 100644 functions/optimization/materials_tools.py create mode 100644 functions/optimization/mesh_tools.py create mode 100644 functions/optimization/remove_doubles.py create mode 100644 ui/__init__.py create mode 100644 ui/optimization_panel.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/common.py b/core/common.py index bec7810..b5ee6ce 100644 --- a/core/common.py +++ b/core/common.py @@ -146,12 +146,12 @@ def validate_symmetry(bones: Dict[str, bpy.types.Bone], base: str, left: str, ri right_exists = any(pattern in bones for pattern in right_patterns) return left_exists and right_exists - - + def auto_select_single_armature(context: bpy.types.Context) -> None: """Automatically select armature if only one exists in scene""" armatures = get_armature_list(context) - if len(armatures) == 1: + if len(armatures) == 1 and armatures[0][0] != 'NONE': + toolkit = context.scene.avatar_toolkit set_active_armature(context, armatures[0]) def clear_default_objects() -> None: @@ -305,4 +305,83 @@ def apply_armature_to_mesh_with_shapekeys(armature_obj: Object, mesh_obj: Object sk.mute = mute mesh_obj.active_shape_key_index = old_active_index - mesh_obj.show_only_shape_key = old_show_only \ No newline at end of file + mesh_obj.show_only_shape_key = old_show_only + +def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]: + """Validates a list of mesh objects to ensure they are suitable for joining operations""" + if not meshes: + return False, t("Optimization.no_meshes") + if not all(mesh.data for mesh in meshes): + return False, t("Optimization.invalid_mesh_data") + if not all(mesh.type == 'MESH' for mesh in meshes): + return False, t("Optimization.non_mesh_objects") + return True, "" + +def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Tuple[bool, str]: + """Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing""" + try: + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + for mesh in meshes: + mesh.select_set(True) + + if context.selected_objects: + context.view_layer.objects.active = context.selected_objects[0] + + if progress: + progress.step(t("Optimization.joining_meshes")) + bpy.ops.object.join() + + if progress: + progress.step(t("Optimization.applying_transforms")) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + if progress: + progress.step(t("Optimization.fixing_uvs")) + fix_uv_coordinates(context) + + return True, t("Optimization.meshes_joined") + + return False, t("Optimization.no_mesh_selected") + + except Exception as e: + logger.error(f"Failed to join meshes: {str(e)}") + return False, str(e) + +def fix_uv_coordinates(context: Context) -> None: + """Normalizes and fixes UV coordinates for the active mesh object""" + obj: Object = context.object + current_mode: str = context.mode + current_active: Object = context.view_layer.objects.active + current_selected: List[Object] = context.selected_objects.copy() + + try: + bpy.ops.object.mode_set(mode='OBJECT') + obj.select_set(True) + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + + bpy.ops.mesh.select_all(action='SELECT') + + with context.temp_override(active_object=obj): + bpy.ops.uv.select_all(action='SELECT') + bpy.ops.uv.average_islands_scale() + + logger.debug(f"UV Fix - Successfully processed {obj.name}") + + except Exception as e: + logger.warning(f"UV Fix - Skipped processing for {obj.name}: {str(e)}") + + finally: + bpy.ops.object.mode_set(mode='OBJECT') + for sel_obj in current_selected: + sel_obj.select_set(True) + context.view_layer.objects.active = current_active + +def clear_unused_data_blocks(self) -> int: + """Removes all unused data blocks from the current Blender file""" + initial_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + final_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + return initial_count - final_count diff --git a/core/exporters/__init__.py b/core/exporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/importers/__init__.py b/core/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/properties.py b/core/properties.py index 0b2e0a6..edbc96d 100644 --- a/core/properties.py +++ b/core/properties.py @@ -14,7 +14,7 @@ from .logging_setup import logger from .translations import t, get_languages_list, update_language from .addon_preferences import get_preference, save_preference from .updater import get_version_list -from .common import get_armature_list +from .common import get_armature_list, get_active_armature, get_all_meshes def update_validation_mode(self, context): logger.info(f"Updating validation mode to: {self.validation_mode}") @@ -38,7 +38,7 @@ class AvatarToolkitSceneProperties(PropertyGroup): active_armature: EnumProperty( items=get_armature_list, name=t("QuickAccess.select_armature"), - description=t("QuickAccess.select_armature") + description=t("QuickAccess.select_armature"), ) language: EnumProperty( @@ -72,6 +72,20 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=False ) + remove_doubles_merge_distance: FloatProperty( + name=t("Optimization.merge_distance"), + description=t("Optimization.merge_distance_desc"), + default=0.0001, + min=0.00001, + max=0.1 + ) + + remove_doubles_advanced: BoolProperty( + name=t("Optimization.remove_doubles_advanced"), + description=t("Optimization.remove_doubles_advanced_desc"), + default=False + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") @@ -83,4 +97,3 @@ def unregister() -> None: logger.info("Unregistering Avatar Toolkit properties") del bpy.types.Scene.avatar_toolkit logger.debug("Properties unregistered successfully") - diff --git a/core/updater.py b/core/updater.py index 66a9f5c..96e55c8 100644 --- a/core/updater.py +++ b/core/updater.py @@ -76,7 +76,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 1 + bl_order = 3 def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/__init__.py b/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/optimization/__init__.py b/functions/optimization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py new file mode 100644 index 0000000..77a6d19 --- /dev/null +++ b/functions/optimization/materials_tools.py @@ -0,0 +1,175 @@ +import bpy +import re +from typing import Set, Dict, List, Optional, Tuple +from bpy.types import ( + Operator, + Context, + Object, + Material, + NodeTree, + ShaderNodeTexImage +) +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + clear_unused_data_blocks, + ProgressTracker +) + +def textures_match(tex1: ShaderNodeTexImage, tex2: ShaderNodeTexImage) -> bool: + """Compare two texture nodes for matching properties and image data""" + return tex1.image == tex2.image and tex1.extension == tex2.extension + +def consolidate_nodes(node1: ShaderNodeTexImage, node2: ShaderNodeTexImage) -> None: + """Transfer properties from one texture node to another to ensure consistency""" + node2.color_space = node1.color_space + node2.coordinates = node1.coordinates + +def consolidate_textures(node_tree1: NodeTree, node_tree2: NodeTree) -> None: + """Synchronize texture nodes between two material node trees""" + for node1 in node_tree1.nodes: + if node1.type == 'TEX_IMAGE': + for node2 in node_tree2.nodes: + if (node2.type == 'TEX_IMAGE' and node1.image == node2.image): + consolidate_nodes(node1, node2) + node2.image = node1.image + elif node1.type == 'GROUP': + if node1.node_tree and node2.node_tree: + consolidate_textures(node1.node_tree, node2.node_tree) + +def color_match(col1: Tuple[float, ...], col2: Tuple[float, ...], tolerance: float = 0.01) -> bool: + """Compare two color values within a specified tolerance""" + return all(abs(c1 - c2) < tolerance for c1, c2 in zip(col1, col2)) + +def materials_match(mat1: Material, mat2: Material, tolerance: float = 0.01) -> bool: + """Compare two materials for matching properties within tolerance""" + if not color_match(mat1.diffuse_color, mat2.diffuse_color, tolerance): + return False + + if abs(mat1.roughness - mat2.roughness) > tolerance: + return False + + if abs(mat1.metallic - mat2.metallic) > tolerance: + return False + + if abs(mat1.alpha_threshold - mat2.alpha_threshold) > tolerance: + return False + + if not color_match(mat1.emission_color, mat2.emission_color, tolerance): + return False + + if mat1.node_tree and mat2.node_tree: + consolidate_textures(mat1.node_tree, mat2.node_tree) + + return True + +def get_base_name(name: str) -> str: + """Extract the base material name by removing numeric suffixes""" + mat_match = re.match(r"^(.*)\.\d{3}$", name) + return mat_match.group(1) if mat_match else name + +class AvatarToolkit_OT_CombineMaterials(Operator): + """Operator for combining similar materials to reduce duplicate materials""" + bl_idname: str = "avatar_toolkit.combine_materials" + bl_label: str = t("Optimization.combine_materials") + bl_description: str = t("Optimization.combine_materials_desc") + bl_options: Set[str] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + """Execute the material combination operation""" + try: + armature = get_active_armature(context) + meshes = get_all_meshes(context) + + if not meshes: + self.report({'WARNING'}, t("Optimization.no_meshes")) + return {'CANCELLED'} + + if not any(mesh.material_slots for mesh in meshes): + self.report({'WARNING'}, t("Optimization.no_materials")) + return {'CANCELLED'} + + with ProgressTracker(context, 4, "Combining Materials") as progress: + try: + num_combined = self.consolidate_materials(meshes) + except Exception as e: + logger.error(f"Material consolidation failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.consolidation")) + return {'CANCELLED'} + progress.step("Consolidated materials") + + try: + num_cleaned = self.clean_material_slots(meshes) + except Exception as e: + logger.error(f"Material slot cleanup failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.slot_cleanup")) + return {'CANCELLED'} + progress.step("Cleaned material slots") + + try: + num_removed = clear_unused_data_blocks(self) + except Exception as e: + logger.error(f"Data block cleanup failed: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.data_cleanup")) + return {'CANCELLED'} + progress.step("Removed unused data blocks") + + self.report({'INFO'}, t("Optimization.materials_combined", + combined=num_combined, + cleaned=num_cleaned, + removed=num_removed)) + + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to combine materials: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.combine_materials", error=str(e))) + return {'CANCELLED'} + + def consolidate_materials(self, meshes: List[Object]) -> int: + """Consolidate similar materials across all meshes""" + mat_mapping: Dict[str, Material] = {} + num_combined: int = 0 + + for mesh in meshes: + for slot in mesh.material_slots: + mat: Optional[Material] = slot.material + if mat: + base_name: str = get_base_name(mat.name) + + if base_name in mat_mapping: + base_mat: Material = mat_mapping[base_name] + try: + if materials_match(base_mat, mat): + consolidate_textures(base_mat.node_tree, mat.node_tree) + num_combined += 1 + slot.material = base_mat + except AttributeError: + logger.warning(f"Material attribute mismatch: {mat.name}") + continue + else: + mat_mapping[base_name] = mat + + return num_combined + + def clean_material_slots(self, meshes: List[Object]) -> int: + """Remove unused material slots from meshes""" + cleaned_slots = 0 + for obj in meshes: + initial_slots = len(obj.material_slots) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.material_slot_remove_unused() + cleaned_slots += initial_slots - len(obj.material_slots) + return cleaned_slots diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py new file mode 100644 index 0000000..086bdc9 --- /dev/null +++ b/functions/optimization/mesh_tools.py @@ -0,0 +1,103 @@ +import bpy +from typing import Set, List, Tuple, ClassVar +from bpy.types import Operator, Context, Object +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + validate_meshes, + join_mesh_objects, + ProgressTracker +) + +class AvatarToolkit_OT_JoinAllMeshes(Operator): + """Operator to join all meshes in the scene""" + bl_idname: ClassVar[str] = "avatar_toolkit.join_all_meshes" + bl_label: ClassVar[str] = t("Optimization.join_all_meshes") + bl_description: ClassVar[str] = t("Optimization.join_all_meshes_desc") + bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature: Object | None = get_active_armature(context) + if not armature: + return False + valid: bool + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + try: + armature: Object = get_active_armature(context) + meshes: List[Object] = get_all_meshes(context) + + valid: bool + message: str + valid, message = validate_meshes(meshes) + if not valid: + self.report({'WARNING'}, message) + return {'CANCELLED'} + + with ProgressTracker(context, 5, "Joining All Meshes") as progress: + success: bool + success, message = join_mesh_objects(context, meshes, progress) + + if success: + context.view_layer.objects.active = armature + self.report({'INFO'}, message) + return {'FINISHED'} + else: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + except Exception as e: + logger.error(f"Failed to join meshes: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.join_meshes", error=str(e))) + return {'CANCELLED'} + +class AvatarToolkit_OT_JoinSelectedMeshes(Operator): + """Operator to join selected meshes""" + bl_idname: ClassVar[str] = "avatar_toolkit.join_selected_meshes" + bl_label: ClassVar[str] = t("Optimization.join_selected_meshes") + bl_description: ClassVar[str] = t("Optimization.join_selected_meshes_desc") + bl_options: ClassVar[Set[str]] = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature: Object | None = get_active_armature(context) + if not armature: + return False + valid: bool + valid, _ = validate_armature(armature) + return (valid and + context.mode == 'OBJECT' and + len([obj for obj in context.selected_objects if obj.type == 'MESH']) > 1) + + def execute(self, context: Context) -> Set[str]: + try: + selected_meshes: List[Object] = [obj for obj in context.selected_objects if obj.type == 'MESH'] + + valid: bool + message: str + valid, message = validate_meshes(selected_meshes) + if not valid: + self.report({'WARNING'}, message) + return {'CANCELLED'} + + with ProgressTracker(context, 5, "Joining Selected Meshes") as progress: + success: bool + success, message = join_mesh_objects(context, selected_meshes, progress) + + if success: + self.report({'INFO'}, message) + return {'FINISHED'} + else: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + except Exception as e: + logger.error(f"Failed to join selected meshes: {str(e)}") + self.report({'ERROR'}, t("Optimization.error.join_selected", error=str(e))) + return {'CANCELLED'} diff --git a/functions/optimization/remove_doubles.py b/functions/optimization/remove_doubles.py new file mode 100644 index 0000000..8714e5b --- /dev/null +++ b/functions/optimization/remove_doubles.py @@ -0,0 +1,281 @@ +import bpy +import numpy as np +from typing import List, TypedDict, Any, Literal, TypeAlias, cast +from bpy.types import Operator, Context, Object, Event +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + validate_armature +) + +# Constants +MERGE_ITERATION_COUNT = 20 +MERGE_DISTANCE_DEFAULT = 0.0001 + +# Type definitions +ModalReturnType: TypeAlias = Literal['RUNNING_MODAL', 'FINISHED', 'CANCELLED'] + +class MeshEntry(TypedDict): + mesh: Object + shapekeys: list[str] + vertices: int + cur_vertex_pass: int + +def create_duplicate_for_merge(context: Context, mesh: Object, shapekey_name: str) -> Object: + """Creates a duplicate mesh object for merge testing""" + context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + mesh.select_set(True) + bpy.ops.object.duplicate() + bpy.ops.object.shape_key_move(type='TOP') + + duplicate = context.view_layer.objects.active + duplicate.name = f"{shapekey_name}_object_is_{mesh.name}" + return duplicate + +def process_vertex_merging(mesh_data: bpy.types.Mesh, vertices_original: dict[int, Any], current_vertex: int) -> list[int]: + """Process vertex merging and return merged vertex indices""" + merged_vertices = [] + i, j = 0, 0 + + while i < len(vertices_original): + if j + 1 > len(mesh_data.vertices): + merged_vertices.append(i) + j = j - 1 + elif mesh_data.vertices[j].co.xyz != vertices_original[i]: + merged_vertices.append(i) + j = j - 1 + elif vertices_original[i] == vertices_original[current_vertex]: + merged_vertices.append(i) + i, j = i + 1, j + 1 + + return merged_vertices + +class AvatarToolkit_OT_RemoveDoublesAdvanced(Operator): + bl_idname = "avatar_toolkit.remove_doubles_advanced" + bl_label = t("Optimization.remove_doubles_advanced") + bl_description = t("Optimization.remove_doubles_advanced_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> set[str]: + """Execute the advanced remove doubles operator""" + context.scene.avatar_toolkit.remove_doubles_advanced = True + bpy.ops.avatar_toolkit.remove_doubles('INVOKE_DEFAULT') + return {'RUNNING_MODAL'} + +class AvatarToolkit_OT_RemoveDoubles(Operator): + bl_idname = "avatar_toolkit.remove_doubles" + bl_label = t("Optimization.remove_doubles") + bl_description = t("Optimization.remove_doubles_desc") + bl_options = {'REGISTER', 'UNDO'} + + objects_to_do: list[MeshEntry] = [] + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if the operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def draw(self, context: Context) -> None: + """Draw the operator's UI""" + layout = self.layout + layout.prop(context.scene.avatar_toolkit, "remove_doubles_merge_distance") + layout.label(text=t("Optimization.remove_doubles_warning")) + layout.label(text=t("Optimization.remove_doubles_wait")) + + def invoke(self, context: Context, event: Event) -> set[str]: + """Initialize the operator""" + logger.info("Starting modal execution of merge doubles safely") + return context.window_manager.invoke_props_dialog(self) + + def setup_mesh_entry(self, mesh: Object) -> MeshEntry: + """Set up mesh entry data structure""" + mesh_entry: MeshEntry = { + "mesh": mesh, + "shapekeys": [], + "vertices": len(mesh.data.vertices), + "cur_vertex_pass": 0 + } + + if mesh.data.shape_keys: + mesh_entry["shapekeys"] = [shape.name for shape in mesh.data.shape_keys.key_blocks] + + return mesh_entry + + def execute(self, context: Context) -> set[str]: + """Execute the remove doubles operator""" + try: + armature = get_active_armature(context) + if not armature: + self.report({'WARNING'}, t("Optimization.no_armature")) + return {'CANCELLED'} + + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + + objects = get_all_meshes(context) + self.objects_to_do = [] + + for mesh in objects: + if mesh.data.name not in [obj["mesh"].data.name for obj in self.objects_to_do]: + logger.debug(f"Setting up data for object {mesh.name}") + mesh_entry = self.setup_mesh_entry(mesh) + self.objects_to_do.append(mesh_entry) + + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + except Exception as e: + logger.error(f"Error in execute: {str(e)}") + return {'CANCELLED'} + + def modify_mesh(self, context: Context, mesh: MeshEntry) -> None: + """Basic mesh modification for simple cases""" + try: + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + mesh_data = mesh["mesh"].data + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + # Select vertices with different positions in shape keys + for index, point in enumerate(mesh["mesh"].active_shape_key.points): + if point.co.xyz != mesh_data.shape_keys.key_blocks[0].points[index].co.xyz: + mesh_data.vertices[index].select = True + logger.debug(f"Shapekey has moved vertex at index {index}") + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + except Exception as e: + logger.error(f"Error in modify_mesh: {str(e)}") + + def modify_mesh_advanced(self, context: Context, mesh_entry: MeshEntry) -> bool: + """Advanced mesh modification with shape key handling""" + try: + final_merged_vertex_group = [] + initialized_final = False + merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance + + for shapekey_name in mesh_entry["shapekeys"]: + duplicate = create_duplicate_for_merge(context, mesh_entry["mesh"], shapekey_name) + vertices_original = {i: v.co.xyz for i, v in enumerate(duplicate.data.vertices)} + + # Process merging + merged_vertices = process_vertex_merging(duplicate.data, vertices_original, mesh_entry["cur_vertex_pass"]) + + if not initialized_final: + final_merged_vertex_group = merged_vertices.copy() + initialized_final = True + else: + final_merged_vertex_group = [v for v in final_merged_vertex_group if v in merged_vertices] + + bpy.ops.object.delete() + + # Apply final merging + if final_merged_vertex_group: + self.apply_final_merging(context, mesh_entry, final_merged_vertex_group, merge_distance) + + return not (len(final_merged_vertex_group) > 1) + + except Exception as e: + logger.error(f"Error in modify_mesh_advanced: {str(e)}") + return True + + def apply_final_merging(self, context: Context, mesh_entry: MeshEntry, vertex_group: list[int], merge_distance: float) -> None: + """Apply final vertex merging operations""" + mesh = mesh_entry["mesh"] + context.view_layer.objects.active = mesh + mesh.select_set(True) + + bpy.ops.object.mode_set(mode='OBJECT') + select_target_group = [False] * len(mesh.data.vertices) + for vertex_index in vertex_group: + select_target_group[vertex_index] = True + + mesh.data.vertices.foreach_set("select", select_target_group) + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + bpy.ops.object.mode_set(mode='OBJECT') + + def process_simple_mesh(self, context: Context, mesh: MeshEntry, merge_distance: float) -> None: + """Process mesh without shapekeys using simple merge operation""" + logger.debug(f"Processing mesh without shapekeys: {mesh['mesh'].name}") + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + bpy.ops.object.mode_set(mode='EDIT') + mesh["mesh"].data.vertices.foreach_set("select", [False] * len(mesh["mesh"].data.vertices)) + + bpy.ops.mesh.select_all(action="INVERT") + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + def finish_mesh_processing(self, context: Context, mesh: MeshEntry, advanced: bool, merge_distance: float) -> None: + """Complete the mesh processing by performing final merge operations""" + logger.debug("Finishing mesh processing") + + if not advanced: + mesh["mesh"].select_set(True) + context.view_layer.objects.active = mesh["mesh"] + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action="INVERT") + bpy.ops.mesh.remove_doubles(threshold=merge_distance, use_unselected=False) + + bpy.ops.object.mode_set(mode='OBJECT') + mesh["mesh"].select_set(False) + + def modal(self, context: Context, event: Event) -> set[ModalReturnType]: + """Modal operator execution""" + try: + if not self.objects_to_do: + self.report({'INFO'}, t("Optimization.remove_doubles_completed")) + logger.info("Finishing modal execution of merge doubles safely") + return {'FINISHED'} + + mesh = self.objects_to_do[0] + mesh_data = mesh["mesh"].data + advanced = context.scene.avatar_toolkit.remove_doubles_advanced + merge_distance = context.scene.avatar_toolkit.remove_doubles_merge_distance + + if len(mesh['shapekeys']) > 0 and not advanced: + shapekeyname = mesh['shapekeys'].pop(0) + mesh["mesh"].active_shape_key_index = mesh_data.shape_keys.key_blocks.find(shapekeyname) + logger.debug(f"Processing shapekey {shapekeyname}") + self.modify_mesh(context, mesh) + + elif not mesh_data.shape_keys: + self.process_simple_mesh(context, mesh, merge_distance) + self.objects_to_do.pop(0) + + elif not (mesh["cur_vertex_pass"] > mesh["vertices"]) and advanced: + if self.modify_mesh_advanced(context, mesh): + mesh["cur_vertex_pass"] += 1 + + else: + self.finish_mesh_processing(context, mesh, advanced, merge_distance) + self.objects_to_do.pop(0) + + return {'RUNNING_MODAL'} + + except Exception as e: + logger.error(f"Error in modal: {str(e)}") + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index f445e83..8bdc720 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -80,6 +80,51 @@ "Scene.avatar_toolkit_updater_version_list.name": "Version List", "Scene.avatar_toolkit_updater_version_list.description": "List of available versions", + "Optimization.label": "Optimization", + "Optimization.materials_title": "Materials", + "Optimization.cleanup_title": "Mesh Cleanup", + "Optimization.join_meshes_title": "Join Meshes", + "Optimization.combine_materials": "Combine Materials", + "Optimization.combine_materials_desc": "Combine similar materials to reduce draw calls", + "Optimization.remove_doubles": "Remove Doubles", + "Optimization.remove_doubles_desc": "Remove duplicate vertices", + "Optimization.remove_doubles_advanced": "Advanced", + "Optimization.remove_doubles_advanced_desc": "Remove duplicate vertices with advanced options", + "Optimization.join_all_meshes": "Join All", + "Optimization.join_all_meshes_desc": "Join all meshes in the scene", + "Optimization.join_selected_meshes": "Join Selected", + "Optimization.join_selected_meshes_desc": "Join only selected meshes", + "Optimization.no_meshes": "No meshes found to optimize", + "Optimization.materials_combined": "Combined {combined} materials, cleaned {cleaned} slots, and removed {removed} unused data blocks", + "Optimization.error.combine_materials": "Failed to combine materials: {error}", + "Optimization.materials_total": "Total Materials: {count}", + "Optimization.materials_duplicates": "Potential Duplicates: {count}", + "Optimization.no_materials": "No materials found on meshes", + "Optimization.error.consolidation": "Failed to consolidate materials. Check console for details", + "Optimization.combining_materials": "Combining similar materials...", + "Optimization.cleaning_slots": "Cleaning material slots...", + "Optimization.removing_unused": "Removing unused materials...", + "Optimization.selecting_meshes": "Selecting meshes...", + "Optimization.joining_meshes": "Joining meshes...", + "Optimization.applying_transforms": "Applying transforms...", + "Optimization.fixing_uvs": "Fixing UV coordinates...", + "Optimization.finalizing": "Finalizing...", + "Optimization.meshes_joined": "All meshes joined successfully", + "Optimization.selected_meshes_joined": "Selected meshes joined successfully", + "Optimization.no_mesh_selected": "No meshes selected", + "Optimization.select_at_least_two": "Please select at least two meshes", + "Optimization.error.join_meshes": "Failed to join meshes: {error}", + "Optimization.error.join_selected": "Failed to join selected meshes: {error}", + "Optimization.merge_distance": "Merge Distance", + "Optimization.merge_distance_desc": "Distance within which vertices will be merged", + "Optimization.remove_doubles_warning": "This process may take a long time", + "Optimization.remove_doubles_wait": "Blender may seem unresponsive during this operation", + "Optimization.error.remove_doubles": "Failed to remove doubles: {error}", + "Optimization.no_armature": "No armature selected", + "Optimization.processing_mesh": "Processing mesh: {name}", + "Optimization.processing_shapekey": "Processing shape key: {name}", + "Optimization.remove_doubles_completed": "Remove doubles completed successfully", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py new file mode 100644 index 0000000..2e65ec1 --- /dev/null +++ b/ui/optimization_panel.py @@ -0,0 +1,50 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t + +class AvatarToolKit_PT_OptimizationPanel(Panel): + """Panel containing mesh and material optimization tools for avatar optimization""" + bl_label: str = t("Optimization.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_optimization" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 1 + + def draw(self, context: Context) -> None: + """Draws the optimization panel interface with material, mesh cleanup and join mesh tools""" + layout: UILayout = self.layout + + # Materials Box + materials_box: UILayout = layout.box() + col: UILayout = materials_box.column(align=True) + col.label(text=t("Optimization.materials_title"), icon='MATERIAL') + col.separator(factor=0.5) + + # Material Operations + col.operator("avatar_toolkit.combine_materials", icon='MATERIAL') + + # Mesh Cleanup Box + cleanup_box: UILayout = layout.box() + col: UILayout = cleanup_box.column(align=True) + col.label(text=t("Optimization.cleanup_title"), icon='MESH_DATA') + col.separator(factor=0.5) + + # Remove Doubles Row + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.remove_doubles", icon='MESH_DATA') + row.operator("avatar_toolkit.remove_doubles_advanced", icon='PREFERENCES') + + # Join Meshes Box + join_box: UILayout = layout.box() + col: UILayout = join_box.column(align=True) + col.label(text=t("Optimization.join_meshes_title"), icon='OBJECT_DATA') + col.separator(factor=0.5) + + # Join Meshes Row + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.join_all_meshes", icon='OBJECT_DATA') + row.operator("avatar_toolkit.join_selected_meshes", icon='RESTRICT_SELECT_OFF') diff --git a/ui/settings_panel.py b/ui/settings_panel.py index a948ef3..df86987 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -36,7 +36,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order: int = 2 + bl_order: int = 4 def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection""" From 5ce3f9ff680a5c96f79a19a9c6780a6ded7d8f0b Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 5 Dec 2024 13:36:25 +0000 Subject: [PATCH 08/19] Start of Tools Panel Several Improvements and etc. Still need to do the other half of the functions but getting there. --- core/common.py | 27 +++- core/dictionaries.py | 58 ++++++- core/properties.py | 14 ++ functions/tools/__init__.py | 0 functions/tools/bone_tools.py | 226 ++++++++++++++++++++++++++++ functions/tools/convert_resonite.py | 89 +++++++++++ functions/tools/mesh_separation.py | 68 +++++++++ resources/translations/en_US.json | 50 ++++++ ui/tools_panel.py | 104 +++++++++++++ 9 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 functions/tools/__init__.py create mode 100644 functions/tools/bone_tools.py create mode 100644 functions/tools/convert_resonite.py create mode 100644 functions/tools/mesh_separation.py create mode 100644 ui/tools_panel.py diff --git a/core/common.py b/core/common.py index b5ee6ce..69609e0 100644 --- a/core/common.py +++ b/core/common.py @@ -1,6 +1,6 @@ import bpy import numpy as np -from bpy.types import Context, Object, Modifier +from bpy.types import Context, Object, Modifier, EditBone from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.logging_setup import logger from ..core.translations import t @@ -385,3 +385,28 @@ def clear_unused_data_blocks(self) -> int: bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) final_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) return initial_count - final_count + +def simplify_bonename(name: str) -> str: + """Simplify bone name by removing spaces, underscores, dots and converting to lowercase""" + return name.lower().translate(dict.fromkeys(map(ord, u" _."))) + +def duplicate_bone_chain(bones: List[EditBone]) -> List[EditBone]: + """Duplicate a chain of bones while preserving hierarchy""" + new_bones = [] + parent_map = {} + + for bone in bones: + new_bone = duplicate_bone(bone) + if bone.parent and bone.parent in parent_map: + new_bone.parent = parent_map[bone.parent] + parent_map[bone] = new_bone + new_bones.append(new_bone) + + return new_bones + +def restore_bone_transforms(bone: EditBone, transforms: Dict[str, Any]) -> None: + """Restore bone transforms from stored data""" + bone.head = transforms['head'] + bone.tail = transforms['tail'] + bone.roll = transforms['roll'] + bone.matrix = transforms['matrix'] diff --git a/core/dictionaries.py b/core/dictionaries.py index 3d5235d..26f0a0d 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -109,4 +109,60 @@ dont_delete_these_main_bones = [ 'MiddleFinger1_R', 'MiddleFinger2_R', 'MiddleFinger3_R', 'RingFinger1_R', 'RingFinger2_R', 'RingFinger3_R', 'LittleFinger1_R', 'LittleFinger2_R', 'LittleFinger3_R', -] \ No newline at end of file +] + +resonite_translations = { + 'hips': "Hips", + 'spine': "Spine", + 'chest': "Chest", + 'neck': "Neck", + 'head': "Head", + 'left_eye': "Eye.L", + 'right_eye': "Eye.R", + 'right_leg': "UpperLeg.R", + 'right_knee': "Calf.R", + 'right_ankle': "Foot.R", + 'right_toe': 'Toes.R', + 'right_shoulder': "Shoulder.R", + 'right_arm': "UpperArm.R", + 'right_elbow': "ForeArm.R", + 'right_wrist': "Hand.R", + 'left_leg': "UpperLeg.L", + 'left_knee': "Calf.L", + 'left_ankle': "Foot.L", + 'left_toe': "Toes.L", + 'left_shoulder': "Shoulder.L", + 'left_arm': "UpperArm.L", + 'left_elbow': "ForeArm.L", + 'left_wrist': "Hand.L", + 'pinkie_1_l': "pinkie1.L", + 'pinkie_2_l': "pinkie2.L", + 'pinkie_3_l': "pinkie3.L", + 'ring_1_l': "ring1.L", + 'ring_2_l': "ring2.L", + 'ring_3_l': "ring3.L", + 'middle_1_l': "middle1.L", + 'middle_2_l': "middle2.L", + 'middle_3_l': "middle3.L", + 'index_1_l': "index1.L", + 'index_2_l': "index2.L", + 'index_3_l': "index3.L", + 'thumb_1_l': "thumb1.L", + 'thumb_2_l': "thumb2.L", + 'thumb_3_l': "thumb3.L", + 'pinkie_1_r': "pinkie1.R", + 'pinkie_2_r': "pinkie2.R", + 'pinkie_3_r': "pinkie3.R", + 'ring_1_r': "ring1.R", + 'ring_2_r': "ring2.R", + 'ring_3_r': "ring3.R", + 'middle_1_r': "middle1.R", + 'middle_2_r': "middle2.R", + 'middle_3_r': "middle3.R", + 'index_1_r': "index1.R", + 'index_2_r': "index2.R", + 'index_3_r': "index3.R", + 'thumb_1_r': "thumb1.R", + 'thumb_2_r': "thumb2.R", + 'thumb_3_r': "thumb3.R" +} diff --git a/core/properties.py b/core/properties.py index edbc96d..dc90af1 100644 --- a/core/properties.py +++ b/core/properties.py @@ -86,6 +86,20 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=False ) + merge_twist_bones: BoolProperty( + name=t("Tools.merge_twist_bones"), + description=t("Tools.merge_twist_bones_desc"), + default=True + ) + + clean_weights_threshold: FloatProperty( + name=t("Tools.clean_weights_threshold"), + description=t("Tools.clean_weights_threshold_desc"), + default=0.01, + min=0.0000001, + max=0.9999999 + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/tools/__init__.py b/functions/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py new file mode 100644 index 0000000..5cd7b4d --- /dev/null +++ b/functions/tools/bone_tools.py @@ -0,0 +1,226 @@ +import bpy +import re +from bpy.types import Operator, Context, EditBone, Object, Armature, Mesh +from typing import Optional, Dict, Any, List, Tuple +from ...core.translations import t +from ...core.common import ( + get_active_armature, + validate_armature, + get_all_meshes, + ProgressTracker, + validate_bone_hierarchy, + restore_bone_transforms +) + +def duplicate_bone(bone: EditBone) -> EditBone: + """Create a duplicate of the given bone""" + arm = bone.id_data + new_bone = arm.edit_bones.new(bone.name + "_copy") + new_bone.head = bone.head + new_bone.tail = bone.tail + new_bone.roll = bone.roll + new_bone.parent = bone.parent + return new_bone + +class AvatarToolKit_OT_CreateDigitigradeLegs(Operator): + """Operator to convert standard legs to digitigrade setup""" + bl_idname = "avatar_toolkit.create_digitigrade" + bl_label = t("Tools.create_digitigrade") + bl_description = t("Tools.create_digitigrade_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (is_valid and + context.mode == 'EDIT_ARMATURE' and + context.selected_editable_bones is not None and + len(context.selected_editable_bones) == 2) + + def store_bone_chain_data(self, digi0: EditBone) -> Dict[str, Any]: + """Store initial bone chain data""" + chain_data = {} + current = digi0 + while current: + chain_data[current.name] = { + 'head': current.head.copy(), + 'tail': current.tail.copy(), + 'roll': current.roll, + 'matrix': current.matrix.copy(), + 'parent': current.parent.name if current.parent else None + } + if current.children: + current = current.children[0] + else: + break + return chain_data + + def process_leg_chain(self, digi0: EditBone) -> bool: + """Process a single leg bone chain""" + try: + # Get bone chain + digi1: EditBone = digi0.children[0] + digi2: EditBone = digi1.children[0] + digi3: EditBone = digi2.children[0] + digi4: Optional[EditBone] = digi3.children[0] if digi3.children else None + + # Clear roll for all bones + for bone in [digi0, digi1, digi2, digi3] + ([digi4] if digi4 else []): + bone.select = True + bpy.ops.armature.roll_clear() + bpy.ops.armature.select_all(action='DESELECT') + + # Create thigh bone + thigh = duplicate_bone(digi0) + base_name = digi0.name.split('.')[0] + thigh.name = base_name + + # Create and position calf bone + calf = duplicate_bone(digi1) + calf.name = digi1.name.split('.')[0] + calf.parent = thigh + + # Calculate new positions + midpoint = (digi1.tail + digi2.tail) * 0.5 + calf.head = thigh.tail + calf.tail = midpoint + + # Reparent foot to new calf + digi3.parent = calf + + # Mark original bones as non-IK + for bone in [digi0, digi1, digi2]: + if "" not in bone.name: + bone.name = bone.name.split('.')[0] + "" + + return True + + except Exception as e: + self.report({'ERROR'}, t("Tools.digitigrade_error", error=str(e))) + return False + + def execute(self, context: Context) -> set[str]: + """Execute the digitigrade conversion""" + bpy.ops.object.mode_set(mode='EDIT') + + with ProgressTracker(context, len(context.selected_editable_bones), t("Tools.digitigrade")) as progress: + for digi0 in context.selected_editable_bones: + progress.step(t("Tools.processing_leg", bone=digi0.name)) + if not self.process_leg_chain(digi0): + return {'CANCELLED'} + + self.report({'INFO'}, t("Tools.digitigrade_success")) + return {'FINISHED'} + +class AvatarToolKit_OT_DeleteBoneConstraints(Operator): + """Operator to remove all bone constraints from armature""" + bl_idname = "avatar_toolkit.clean_constraints" + bl_label = t("Tools.clean_constraints") + bl_description = t("Tools.clean_constraints_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> set[str]: + """Execute the constraint removal operation""" + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + + constraints_removed = 0 + for bone in armature.pose.bones: + while bone.constraints: + bone.constraints.remove(bone.constraints[0]) + constraints_removed += 1 + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) + return {'FINISHED'} + +class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): + """Operator to remove bones with no vertex weights""" + bl_idname = "avatar_toolkit.clean_weights" + bl_label = t("Tools.clean_weights") + bl_description = t("Tools.clean_weights_desc") + bl_options = {'REGISTER', 'UNDO'} + + def should_preserve_bone(self, bone_name: str, context: Context) -> bool: + """Check if bone should be preserved based on settings""" + if context.scene.avatar_toolkit.merge_twist_bones: + return "twist" in bone_name.lower() + return False + + def execute(self, context: Context) -> set[str]: + """Execute the zero weight bone removal operation""" + armature = get_active_armature(context) + if not armature: + return {'CANCELLED'} + + # Store initial transforms + bpy.ops.object.mode_set(mode='EDIT') + initial_transforms: Dict[str, Dict[str, Any]] = {} + for bone in armature.data.edit_bones: + initial_transforms[bone.name] = { + 'head': bone.head.copy(), + 'tail': bone.tail.copy(), + 'roll': bone.roll, + 'matrix': bone.matrix.copy(), + 'parent': bone.parent.name if bone.parent else None + } + + # Get weighted bones + weighted_bones: List[str] = [] + meshes = get_all_meshes(context) + + for mesh in meshes: + mesh_data: Mesh = mesh.data + for vertex in mesh_data.vertices: + for group in vertex.groups: + if group.weight > context.scene.avatar_toolkit.clean_weights_threshold: + weighted_bones.append(mesh.vertex_groups[group.group].name) + + # Process bone removal + bpy.ops.object.mode_set(mode='EDIT') + armature_data: Armature = armature.data + removed_count = 0 + + for bone in armature_data.edit_bones[:]: # Create a copy of the list + if (bone.name not in weighted_bones and + not self.should_preserve_bone(bone.name, context)): + + # Store children data + children = bone.children + children_data = {child.name: initial_transforms[child.name] for child in children} + + # Reparent children + for child in children: + child.use_connect = False + if bone.parent: + child.parent = bone.parent + + # Remove bone + armature_data.edit_bones.remove(bone) + removed_count += 1 + + # Restore children positions + for child_name, data in children_data.items(): + if child_name in armature_data.edit_bones: + child = armature_data.edit_bones[child_name] + child.head = data['head'] + child.tail = data['tail'] + child.roll = data['roll'] + child.matrix = data['matrix'] + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.clean_weights_success", count=removed_count)) + return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/convert_resonite.py b/functions/tools/convert_resonite.py new file mode 100644 index 0000000..8ab5d99 --- /dev/null +++ b/functions/tools/convert_resonite.py @@ -0,0 +1,89 @@ +import bpy +import re +from typing import Set, Dict, Optional +from bpy.types import Operator, Context +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, simplify_bonename, validate_armature, ProgressTracker +from ...core.dictionaries import bone_names, resonite_translations + +class AvatarToolkit_OT_ConvertResonite(Operator): + """Convert armature bone names to Resonite format with progress tracking and validation""" + bl_idname = "avatar_toolkit.convert_resonite" + bl_label = t("Tools.convert_resonite") + bl_description = t("Tools.convert_resonite_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + if not armature: + logger.warning("No armature selected for Resonite conversion") + self.report({'WARNING'}, t("Armature.validation.no_armature")) + return {'CANCELLED'} + + translate_bone_fails: int = 0 + untranslated_bones: Set[str] = set() + simplified_names: Dict[str, str] = {} + + # Create reverse lookup dictionary + reverse_bone_lookup = {} + for preferred_name, name_list in bone_names.items(): + for name in name_list: + reverse_bone_lookup[name] = preferred_name + + try: + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + # Cache simplified bone names + for bone in armature.data.bones: + simplified_names[bone.name] = simplify_bonename(bone.name) + + total_bones = len(armature.data.bones) + with ProgressTracker(context, total_bones, t("Tools.convert_resonite.operation")) as progress: + for bone in armature.data.bones: + # Remove any existing "" tags + bone.name = re.compile(re.escape(""), re.IGNORECASE).sub("", bone.name) + simplified_name = simplified_names[bone.name] + + if simplified_name in reverse_bone_lookup and reverse_bone_lookup[simplified_name] in resonite_translations: + new_name = resonite_translations[reverse_bone_lookup[simplified_name]] + logger.debug(f"Translating bone: {bone.name} -> {new_name}") + bone.name = new_name + else: + untranslated_bones.add(bone.name) + bone.name = bone.name + "" + translate_bone_fails += 1 + logger.debug(f"Failed to translate bone: {bone.name}") + + progress.step(t("Tools.convert_resonite.processing", name=bone.name)) + + except Exception as e: + logger.error(f"Error during Resonite conversion: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + finally: + try: + bpy.ops.object.mode_set(mode='OBJECT') + except Exception as e: + logger.warning(f"Error returning to object mode: {str(e)}") + + if translate_bone_fails > 0: + logger.info(f"Conversion completed with {translate_bone_fails} untranslated bones") + logger.debug(f"Untranslated bones: {untranslated_bones}") + self.report({'INFO'}, t("Tools.bones_translated_with_fails", translate_bone_fails=translate_bone_fails)) + else: + logger.info("All bones translated successfully") + self.report({'INFO'}, t("Tools.bones_translated_success")) + + return {'FINISHED'} \ No newline at end of file diff --git a/functions/tools/mesh_separation.py b/functions/tools/mesh_separation.py new file mode 100644 index 0000000..6ffb68d --- /dev/null +++ b/functions/tools/mesh_separation.py @@ -0,0 +1,68 @@ +import bpy +from bpy.types import Operator, Context +from ...core.translations import t +from ...core.common import get_active_armature, validate_armature + +class AvatarToolKit_OT_SeparateByMaterials(Operator): + """Operator to separate mesh by materials""" + bl_idname = "avatar_toolkit.separate_materials" + bl_label = t("Tools.separate_materials") + bl_description = t("Tools.separate_materials_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (context.active_object and + context.active_object.type == 'MESH' and + is_valid) + + def execute(self, context: Context) -> set[str]: + """Execute the separation operation""" + try: + obj = context.active_object + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.separate(type='MATERIAL') + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.separate_materials_success")) + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolKit_OT_SeparateByLooseParts(Operator): + """Operator to separate mesh by loose parts""" + bl_idname = "avatar_toolkit.separate_loose" + bl_label = t("Tools.separate_loose") + bl_description = t("Tools.separate_loose_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if operator can be executed""" + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return (context.active_object and + context.active_object.type == 'MESH' and + is_valid) + + def execute(self, context: Context) -> set[str]: + """Execute the separation operation""" + try: + obj = context.active_object + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.separate(type='LOOSE') + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.separate_loose_success")) + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 8bdc720..87acdfb 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -125,6 +125,56 @@ "Optimization.processing_shapekey": "Processing shape key: {name}", "Optimization.remove_doubles_completed": "Remove doubles completed successfully", + "Tools.label": "Tools", + "Tools.general_title": "General Tools", + "Tools.convert_resonite": "Convert to Resonite", + "Tools.convert_resonite_desc": "Convert model for use in Resonite", + "Tools.convert_resonite.operation": "Converting to Resonite", + "Tools.separate_title": "Separation Tools", + "Tools.separate_materials": "By Materials", + "Tools.separate_materials_desc": "Separate mesh by materials", + "Tools.separate_loose": "Loose Parts", + "Tools.separate_loose_desc": "Separate mesh into loose parts", + "Tools.separate_materials_success": "Mesh separated by materials successfully", + "Tools.separate_loose_success": "Mesh separated into loose parts successfully", + "Tools.bone_title": "Bone Tools", + "Tools.create_digitigrade": "Create Digitigrade Legs", + "Tools.create_digitigrade_desc": "Convert legs to digitigrade setup", + "Tools.digitigrade": "Create Digitigrade Legs", + "Tools.digitigrade_desc": "Convert selected leg bones to digitigrade setup", + "Tools.digitigrade_error": "Failed to create digitigrade legs: {error}", + "Tools.digitigrade_success": "Successfully created digitigrade leg setup", + "Tools.processing_leg": "Processing leg bone: {bone}", + "Tools.merge_twist_bones": "Keep Twist Bones", + "Tools.merge_twist_bones_desc": "When checked, twist bones will be kept, even if there are zero-weight", + "Tools.clean_weights": "Remove Zero Weight Bones", + "Tools.clean_weights_desc": "Remove bones with no vertex weights", + "Tools.clean_constraints": "Delete Bone Constraints", + "Tools.clean_constraints_desc": "Remove all bone constraints from armature", + "Tools.clean_constraints_success": "Removed {count} bone constraints", + "Tools.processing_bone_constraints": "Removing constraints from bone: {bone}", + "Tools.clean_weights_success": "Removed {count} zero-weight bones", + "Tools.clean_weights_threshold": "Weight Threshold", + "Tools.clean_weights_threshold_desc": "Minimum weight value to consider a bone as weighted", + "Tools.merge_title": "Merge Tools", + "Tools.merge_to_active": "Merge to Active", + "Tools.merge_to_active_desc": "Merge selected bones to active bone", + "Tools.merge_to_parent": "Merge to Parent", + "Tools.merge_to_parent_desc": "Merge bones to their respective parents", + "Tools.connect_bones": "Connect Bones", + "Tools.connect_bones_desc": "Connect disconnected bones in chain", + "Tools.additional_title": "Additional Tools", + "Tools.apply_transforms": "Apply Transforms", + "Tools.apply_transforms_desc": "Apply all transformations to objects", + "Tools.clean_shapekeys": "Remove Unused Shapekeys", + "Tools.clean_shapekeys_desc": "Remove unused shape keys from meshes", + "Tools.bones_translated_success": "All bones translated successfully", + "Tools.bones_translated_with_fails": "Translation completed with {translate_bone_fails} untranslated bones", + "Tools.storing_transforms": "Storing bone transforms...", + "Tools.analyzing_weights": "Analyzing vertex weights...", + "Tools.removing_bones": "Removing unweighted bones...", + "Tools.verifying_hierarchy": "Verifying bone hierarchy...", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/tools_panel.py b/ui/tools_panel.py new file mode 100644 index 0000000..170a6d6 --- /dev/null +++ b/ui/tools_panel.py @@ -0,0 +1,104 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t + +# Temporary Operator Classes for UI Preview +class AvatarToolkit_OT_MergeToActive(Operator): + bl_idname = "avatar_toolkit.merge_to_active" + bl_label = "Merge to Active" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_MergeToParent(Operator): + bl_idname = "avatar_toolkit.merge_to_parent" + bl_label = "Merge to Parent" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_ConnectBones(Operator): + bl_idname = "avatar_toolkit.connect_bones" + bl_label = "Connect Bones" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_ApplyTransforms(Operator): + bl_idname = "avatar_toolkit.apply_transforms" + bl_label = "Apply Transforms" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolkit_OT_CleanShapekeys(Operator): + bl_idname = "avatar_toolkit.clean_shapekeys" + bl_label = "Remove Unused Shapekeys" + + def execute(self, context: Context) -> Set[str]: + return {'FINISHED'} + +class AvatarToolKit_PT_ToolsPanel(Panel): + """Panel containing various tools for avatar customization and optimization""" + bl_label: str = t("Tools.label") + bl_idname: str = "OBJECT_PT_avatar_toolkit_tools" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 2 + + def draw(self, context: Context) -> None: + """Draw the tools panel interface""" + layout: UILayout = self.layout + + # General Tools + tools_box: UILayout = layout.box() + col: UILayout = tools_box.column(align=True) + col.label(text=t("Tools.general_title"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.operator("avatar_toolkit.convert_resonite", text=t("Tools.convert_resonite"), icon='EXPORT') + + # Separation Tools + sep_box: UILayout = layout.box() + col = sep_box.column(align=True) + col.label(text=t("Tools.separate_title"), icon='MOD_EXPLODE') + col.separator(factor=0.5) + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.separate_materials", text=t("Tools.separate_materials"), icon='MATERIAL') + row.operator("avatar_toolkit.separate_loose", text=t("Tools.separate_loose"), icon='MESH_DATA') + + # Bone Tools + bone_box: UILayout = layout.box() + col = bone_box.column(align=True) + col.label(text=t("Tools.bone_title"), icon='BONE_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.create_digitigrade", text=t("Tools.create_digitigrade"), icon='BONE_DATA') + + # Weight Tools + weight_box: UILayout = bone_box.box() + col = weight_box.column(align=True) + col.prop(context.scene.avatar_toolkit, "merge_twist_bones", text=t("Tools.merge_twist_bones")) + row = col.row(align=True) + row.operator("avatar_toolkit.clean_weights", text=t("Tools.clean_weights"), icon='GROUP_BONE') + row.operator("avatar_toolkit.clean_constraints", text=t("Tools.clean_constraints"), icon='CONSTRAINT_BONE') + + # Merge Tools + merge_box: UILayout = layout.box() + col = merge_box.column(align=True) + col.label(text=t("Tools.merge_title"), icon='AUTOMERGE_ON') + col.separator(factor=0.5) + row = col.row(align=True) + row.operator("avatar_toolkit.merge_to_active", text=t("Tools.merge_to_active"), icon='BONE_DATA') + row.operator("avatar_toolkit.merge_to_parent", text=t("Tools.merge_to_parent"), icon='BONE_DATA') + col.operator("avatar_toolkit.connect_bones", text=t("Tools.connect_bones"), icon='BONE_DATA') + + # Additional Tools + extra_box: UILayout = layout.box() + col = extra_box.column(align=True) + col.label(text=t("Tools.additional_title"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.operator("avatar_toolkit.apply_transforms", text=t("Tools.apply_transforms"), icon='OBJECT_DATA') + col.operator("avatar_toolkit.clean_shapekeys", text=t("Tools.clean_shapekeys"), icon='SHAPEKEY_DATA') From b1631b2868830fe8eb536f599f775f81844441d5 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 5 Dec 2024 14:44:41 +0000 Subject: [PATCH 09/19] Tools Panel Finished. --- core/common.py | 75 +++++++++++++ core/properties.py | 16 +++ functions/tools/additional_tools.py | 91 ++++++++++++++++ functions/tools/merge_tools.py | 161 ++++++++++++++++++++++++++++ resources/translations/en_US.json | 13 +++ ui/tools_panel.py | 36 ------- 6 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 functions/tools/additional_tools.py create mode 100644 functions/tools/merge_tools.py diff --git a/core/common.py b/core/common.py index 69609e0..4b23aac 100644 --- a/core/common.py +++ b/core/common.py @@ -410,3 +410,78 @@ def restore_bone_transforms(bone: EditBone, transforms: Dict[str, Any]) -> None: bone.tail = transforms['tail'] bone.roll = transforms['roll'] bone.matrix = transforms['matrix'] + +def get_vertex_weights(mesh_obj: Object, group_name: str) -> Dict[int, float]: + """Get vertex weights for a specific vertex group""" + weights = {} + group_index = mesh_obj.vertex_groups[group_name].index + for vertex in mesh_obj.data.vertices: + for group in vertex.groups: + if group.group == group_index: + weights[vertex.index] = group.weight + return weights + +def transfer_vertex_weights(mesh_obj: Object, + source_name: str, + target_name: str, + threshold: float = 0.01) -> None: + """Transfer vertex weights from source to target group""" + if source_name not in mesh_obj.vertex_groups: + return + + source_group = mesh_obj.vertex_groups[source_name] + target_group = mesh_obj.vertex_groups.get(target_name) + + if not target_group: + target_group = mesh_obj.vertex_groups.new(name=target_name) + + # Get source weights + weights = get_vertex_weights(mesh_obj, source_name) + + # Transfer weights above threshold + for vertex_index, weight in weights.items(): + if weight > threshold: + target_group.add([vertex_index], weight, 'ADD') + + # Remove source group + mesh_obj.vertex_groups.remove(source_group) + +def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: + """Remove unused shape keys from a mesh object""" + if not mesh_obj.data.shape_keys: + return 0 + + key_blocks = mesh_obj.data.shape_keys.key_blocks + vertex_count = len(mesh_obj.data.vertices) + removed_count = 0 + + # Cache for relative key locations + cache = {} + locations = np.empty(3 * vertex_count, dtype=np.float32) + to_delete = [] + + for key in key_blocks: + if key == key.relative_key: + continue + + # Get current key locations + key.data.foreach_get("co", locations) + + # Get or calculate relative key locations + if key.relative_key.name not in cache: + rel_locations = np.empty(3 * vertex_count, dtype=np.float32) + key.relative_key.data.foreach_get("co", rel_locations) + cache[key.relative_key.name] = rel_locations + + # Compare locations + locations -= cache[key.relative_key.name] + if (np.abs(locations) < tolerance).all(): + if not any(c in key.name for c in "-=~"): # Skip category markers + to_delete.append(key.name) + + # Remove marked shape keys + for key_name in to_delete: + mesh_obj.shape_key_remove(key_blocks[key_name]) + removed_count += 1 + + return removed_count diff --git a/core/properties.py b/core/properties.py index dc90af1..66e2acf 100644 --- a/core/properties.py +++ b/core/properties.py @@ -100,6 +100,22 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=0.9999999 ) + connect_bones_min_distance: FloatProperty( + name=t("Tools.connect_bones_min_distance"), + description=t("Tools.connect_bones_min_distance_desc"), + default=0.005, + min=0.001, + max=0.1 + ) + + merge_weights_threshold: FloatProperty( + name=t("Tools.merge_weights_threshold"), + description=t("Tools.merge_weights_threshold_desc"), + default=0.01, + min=0.0001, + max=1.0 + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/tools/additional_tools.py b/functions/tools/additional_tools.py new file mode 100644 index 0000000..2b3dd1c --- /dev/null +++ b/functions/tools/additional_tools.py @@ -0,0 +1,91 @@ +import bpy +import numpy as np +from bpy.types import Operator, Context +from typing import Set +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, get_all_meshes, validate_armature, remove_unused_shapekeys + +class AvatarToolkit_OT_ApplyTransforms(Operator): + """Apply all transformations to armature and associated meshes""" + bl_idname = "avatar_toolkit.apply_transforms" + bl_label = t("Tools.apply_transforms") + bl_description = t("Tools.apply_transforms_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid and context.mode == 'OBJECT' + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + logger.info(f"Applying transforms to {armature.name} and associated meshes") + + # Select armature and meshes + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) + context.view_layer.objects.active = armature + + meshes = get_all_meshes(context) + for mesh in meshes: + mesh.select_set(True) + + # Apply transforms + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + self.report({'INFO'}, t("Tools.transforms_applied")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to apply transforms: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_CleanShapekeys(Operator): + """Remove unused shape keys from meshes""" + bl_idname = "avatar_toolkit.clean_shapekeys" + bl_label = t("Tools.clean_shapekeys") + bl_description = t("Tools.clean_shapekeys_desc") + bl_options = {'REGISTER', 'UNDO'} + + tolerance: bpy.props.FloatProperty( + name=t("Tools.shapekey_tolerance"), + description=t("Tools.shapekey_tolerance_desc"), + default=0.001, + min=0.0001, + max=0.1 + ) + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0 + + def execute(self, context: Context) -> Set[str]: + try: + logger.info("Starting shape key cleanup") + removed_count = 0 + + for mesh in get_all_meshes(context): + if not mesh.data.shape_keys or not mesh.data.shape_keys.use_relative: + continue + + removed = remove_unused_shapekeys(mesh, self.tolerance) + removed_count += removed + logger.debug(f"Removed {removed} shape keys from {mesh.name}") + + self.report({'INFO'}, t("Tools.shapekeys_removed", count=removed_count)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to clean shape keys: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/functions/tools/merge_tools.py b/functions/tools/merge_tools.py new file mode 100644 index 0000000..b8daada --- /dev/null +++ b/functions/tools/merge_tools.py @@ -0,0 +1,161 @@ +import bpy +import math +from typing import Set, List +from bpy.types import Operator, Context, Armature, EditBone +from ...core.translations import t +from ...core.logging_setup import logger +from ...core.common import get_active_armature, get_all_meshes, get_vertex_weights, transfer_vertex_weights, validate_armature + +class AvatarToolkit_OT_ConnectBones(Operator): + """Connect disconnected bones in chain""" + bl_idname = "avatar_toolkit.connect_bones" + bl_label = t("Tools.connect_bones") + bl_description = t("Tools.connect_bones_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + is_valid, _ = validate_armature(armature) + return is_valid + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + logger.info("Starting bone connection operation") + + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones + bones_connected = 0 + min_distance = context.scene.avatar_toolkit.connect_bones_min_distance + + excluded_bones = {'LeftEye', 'RightEye', 'Head', 'Hips'} + + for bone in edit_bones: + if len(bone.children) == 1 and bone.name not in excluded_bones: + child = bone.children[0] + distance = math.dist(bone.tail, child.head) + + if distance > min_distance: + logger.debug(f"Connecting bone {bone.name} to {child.name}") + bone.tail = child.head + if bone.parent and len(bone.parent.children) == 1: + bone.use_connect = True + bones_connected += 1 + + bpy.ops.object.mode_set(mode='OBJECT') + self.report({'INFO'}, t("Tools.connect_bones_success", count=bones_connected)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to connect bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_MergeToActive(Operator): + """Merge selected bones into active bone and transfer weights""" + bl_idname = "avatar_toolkit.merge_to_active" + bl_label = t("Tools.merge_to_active") + bl_description = t("Tools.merge_to_active_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + return context.mode == 'EDIT_ARMATURE' and context.active_bone + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + active_bone = context.active_bone + selected_bones = [b for b in context.selected_editable_bones if b != active_bone] + + if not selected_bones: + self.report({'WARNING'}, t("Tools.no_bones_selected")) + return {'CANCELLED'} + + logger.info(f"Merging {len(selected_bones)} bones into {active_bone.name}") + + # Store weights before merging + meshes = get_all_meshes(context) + weight_data = {} + for bone in selected_bones: + for mesh in meshes: + if bone.name in mesh.vertex_groups: + weights = get_vertex_weights(mesh, bone.name) + weight_data.setdefault(mesh.name, {})[bone.name] = weights + + # Transfer weights to active bone + threshold = context.scene.avatar_toolkit.merge_weights_threshold + for mesh_name, bone_weights in weight_data.items(): + mesh = bpy.data.objects[mesh_name] + for bone_name, weights in bone_weights.items(): + transfer_vertex_weights(mesh, bone_name, active_bone.name, threshold) + + # Delete merged bones + for bone in selected_bones: + armature.data.edit_bones.remove(bone) + + self.report({'INFO'}, t("Tools.merge_to_active_success", count=len(selected_bones))) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to merge bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_MergeToParent(Operator): + """Merge selected bones into their respective parents and transfer weights""" + bl_idname = "avatar_toolkit.merge_to_parent" + bl_label = t("Tools.merge_to_parent") + bl_description = t("Tools.merge_to_parent_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + return context.mode == 'EDIT_ARMATURE' + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + selected_bones = [b for b in context.selected_editable_bones if b.parent] + + if not selected_bones: + self.report({'WARNING'}, t("Tools.no_bones_with_parent")) + return {'CANCELLED'} + + logger.info(f"Merging {len(selected_bones)} bones to their parents") + + # Store weights before merging + meshes = get_all_meshes(context) + merged_count = 0 + threshold = context.scene.avatar_toolkit.merge_weights_threshold + + for bone in selected_bones: + parent = bone.parent + if not parent: + continue + + # Transfer weights to parent + for mesh in meshes: + if bone.name in mesh.vertex_groups: + transfer_vertex_weights(mesh, bone.name, parent.name, threshold) + + # Delete merged bone + armature.data.edit_bones.remove(bone) + merged_count += 1 + + self.report({'INFO'}, t("Tools.merge_to_parent_success", count=merged_count)) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to merge bones: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 87acdfb..46594a3 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -174,6 +174,19 @@ "Tools.analyzing_weights": "Analyzing vertex weights...", "Tools.removing_bones": "Removing unweighted bones...", "Tools.verifying_hierarchy": "Verifying bone hierarchy...", + "Tools.connect_bones_min_distance": "Minimum Distance", + "Tools.connect_bones_min_distance_desc": "Minimum distance between bones to attempt connection", + "Tools.connect_bones_success": "Connected {count} bones", + "Tools.merge_weights_threshold": "Weight Transfer Threshold", + "Tools.merge_weights_threshold_desc": "Minimum weight value to transfer when merging bones", + "Tools.no_bones_selected": "No bones selected to merge", + "Tools.no_bones_with_parent": "No selected bones with parents found", + "Tools.merge_to_active_success": "Successfully merged {count} bones to active bone", + "Tools.merge_to_parent_success": "Successfully merged {count} bones to their parents", + "Tools.transforms_applied": "Transforms applied successfully", + "Tools.shapekey_tolerance": "Shape Key Tolerance", + "Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used", + "Tools.shapekeys_removed": "Removed {count} unused shape keys", "Settings.label": "Settings", "Settings.language": "Language", diff --git a/ui/tools_panel.py b/ui/tools_panel.py index 170a6d6..a55d734 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -4,42 +4,6 @@ from bpy.types import Panel, Context, UILayout, Operator from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t -# Temporary Operator Classes for UI Preview -class AvatarToolkit_OT_MergeToActive(Operator): - bl_idname = "avatar_toolkit.merge_to_active" - bl_label = "Merge to Active" - - def execute(self, context: Context) -> Set[str]: - return {'FINISHED'} - -class AvatarToolkit_OT_MergeToParent(Operator): - bl_idname = "avatar_toolkit.merge_to_parent" - bl_label = "Merge to Parent" - - def execute(self, context: Context) -> Set[str]: - return {'FINISHED'} - -class AvatarToolkit_OT_ConnectBones(Operator): - bl_idname = "avatar_toolkit.connect_bones" - bl_label = "Connect Bones" - - def execute(self, context: Context) -> Set[str]: - return {'FINISHED'} - -class AvatarToolkit_OT_ApplyTransforms(Operator): - bl_idname = "avatar_toolkit.apply_transforms" - bl_label = "Apply Transforms" - - def execute(self, context: Context) -> Set[str]: - return {'FINISHED'} - -class AvatarToolkit_OT_CleanShapekeys(Operator): - bl_idname = "avatar_toolkit.clean_shapekeys" - bl_label = "Remove Unused Shapekeys" - - def execute(self, context: Context) -> Set[str]: - return {'FINISHED'} - class AvatarToolKit_PT_ToolsPanel(Panel): """Panel containing various tools for avatar customization and optimization""" bl_label: str = t("Tools.label") From 3e187bd18acb204c1fccb0bdda4110663620ead6 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Thu, 5 Dec 2024 15:09:40 +0000 Subject: [PATCH 10/19] Start of MMD Tools - The idea is to have several buttons which kinda mimic what Cats used to do. - These are very basic, don't work very well, will improve before Alpha 1. --- core/dictionaries.py | 76 +++++ core/properties.py | 26 ++ core/updater.py | 2 +- functions/mmd_tools.py | 498 ++++++++++++++++++++++++++++++ resources/translations/en_US.json | 29 ++ ui/mmd_panel.py | 52 ++++ ui/settings_panel.py | 2 +- 7 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 functions/mmd_tools.py create mode 100644 ui/mmd_panel.py diff --git a/core/dictionaries.py b/core/dictionaries.py index 26f0a0d..3d5b8f2 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -166,3 +166,79 @@ resonite_translations = { 'thumb_2_r': "thumb2.R", 'thumb_3_r': "thumb3.R" } + +mmd_bone_renames = { + # Core body + "センター": "Center", + "グルーブ": "Groove", + "腰": "Waist", + "上半身": "Upper Body", + "上半身2": "Upper Body 2", + "下半身": "Lower Body", + + # Head + "首": "Neck", + "頭": "Head", + "両目": "Eyes", + "左目": "Eye_L", + "右目": "Eye_R", + + # Arms + "左肩": "Shoulder_L", + "左腕": "Arm_L", + "左ひじ": "Elbow_L", + "左手首": "Wrist_L", + "右肩": "Shoulder_R", + "右腕": "Arm_R", + "右ひじ": "Elbow_R", + "右手首": "Wrist_R", + + # Fingers + "左親指1": "Thumb1_L", + "左親指2": "Thumb2_L", + "左人指1": "Index1_L", + "左人指2": "Index2_L", + "左人指3": "Index3_L", + "左中指1": "Middle1_L", + "左中指2": "Middle2_L", + "左中指3": "Middle3_L", + "左薬指1": "Ring1_L", + "左薬指2": "Ring2_L", + "左薬指3": "Ring3_L", + "左小指1": "Pinky1_L", + "左小指2": "Pinky2_L", + "左小指3": "Pinky3_L", + + "右親指1": "Thumb1_R", + "右親指2": "Thumb2_R", + "右人指1": "Index1_R", + "右人指2": "Index2_R", + "右人指3": "Index3_R", + "右中指1": "Middle1_R", + "右中指2": "Middle2_R", + "右中指3": "Middle3_R", + "右薬指1": "Ring1_R", + "右薬指2": "Ring2_R", + "右薬指3": "Ring3_R", + "右小指1": "Pinky1_R", + "右小指2": "Pinky2_R", + "右小指3": "Pinky3_R", + + # Legs + "左足": "Leg_L", + "左ひざ": "Knee_L", + "左足首": "Ankle_L", + "右足": "Leg_R", + "右ひざ": "Knee_R", + "右足首": "Ankle_R", + + # Toes + "左つま先": "Toe_L", + "右つま先": "Toe_R", + + # IK bones + "左足IK": "Leg_IK_L", + "右足IK": "Leg_IK_R", + "左つま先IK": "Toe_IK_L", + "右つま先IK": "Toe_IK_R" +} diff --git a/core/properties.py b/core/properties.py index 66e2acf..9dbb70d 100644 --- a/core/properties.py +++ b/core/properties.py @@ -116,6 +116,32 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=1.0 ) + mmd_keep_upper_chest: BoolProperty( + name=t("MMDTools.keep_upper_chest"), + description=t("MMDTools.keep_upper_chest_desc"), + default=True + ) + + mmd_remove_unused_bones: BoolProperty( + name=t("MMDTools.remove_unused"), + description=t("MMDTools.remove_unused_desc"), + default=True + ) + + mmd_merge_distance: FloatProperty( + name=t("MMDTools.merge_distance"), + description=t("MMDTools.merge_distance_desc"), + default=0.001, + min=0.0001, + max=0.1 + ) + + mmd_cleanup_shapekeys: BoolProperty( + name=t("MMDTools.cleanup_shapekeys"), + description=t("MMDTools.cleanup_shapekeys_desc"), + default=True + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/core/updater.py b/core/updater.py index 96e55c8..d490504 100644 --- a/core/updater.py +++ b/core/updater.py @@ -76,7 +76,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 + bl_order = 4 def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/mmd_tools.py b/functions/mmd_tools.py new file mode 100644 index 0000000..93645fa --- /dev/null +++ b/functions/mmd_tools.py @@ -0,0 +1,498 @@ +import bpy +import numpy as np +from typing import Set, Dict, List, Optional, Tuple +from bpy.types import Operator, Context, Object, EditBone, Mesh +from ..core.logging_setup import logger +from ..core.translations import t +from ..core.common import ( + get_active_armature, + validate_armature, + get_all_meshes, + ProgressTracker, + transfer_vertex_weights, + remove_unused_shapekeys +) +from ..core.dictionaries import bone_names, mmd_bone_renames + +class AvatarToolkit_OT_FixBoneNames(Operator): + """Standardize and fix bone names""" + bl_idname = "avatar_toolkit.fix_bone_names" + bl_label = t("MMDTools.fix_bone_names") + bl_description = t("MMDTools.fix_bone_names_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + + with ProgressTracker(context, 3, "Fixing Bone Names") as progress: + bpy.ops.object.mode_set(mode='EDIT') + + # First pass - standardize names + for bone in armature.data.edit_bones: + bone.name = self.standardize_bone_name(bone.name) + progress.step("Standardized names") + + # Second pass - apply MMD mappings + for bone in armature.data.edit_bones: + if bone.name in mmd_bone_renames: + bone.name = mmd_bone_renames[bone.name] + progress.step("Applied MMD mappings") + + # Third pass - fix common names + for bone in armature.data.edit_bones: + self.fix_common_names(bone) + progress.step("Fixed common names") + + self.report({'INFO'}, t("MMDTools.bones_renamed")) + return {'FINISHED'} + + def standardize_bone_name(self, name: str) -> str: + """Standardize bone naming convention""" + prefixes = ['def-', 'def_', 'sk_', 'b_', 'bone_', 'mmd_'] + name_lower = name.lower() + + # Remove common prefixes + for prefix in prefixes: + if name_lower.startswith(prefix): + name = name[len(prefix):] + break + + # Fix side indicators + name = name.replace('_l', '_L').replace('_r', '_R') + name = name.replace('.l', '_L').replace('.r', '_R') + name = name.replace('左', '_L').replace('右', '_R') + + return name + + def fix_common_names(self, bone: EditBone) -> None: + """Fix common bone names to standard names""" + for standard_name, variations in bone_names.items(): + if bone.name.lower() in variations: + bone.name = standard_name + break + +class AvatarToolkit_OT_FixBoneHierarchy(Operator): + """Fix bone parenting and hierarchy""" + bl_idname = "avatar_toolkit.fix_bone_hierarchy" + bl_label = t("MMDTools.fix_hierarchy") + bl_description = t("MMDTools.fix_hierarchy_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + + with ProgressTracker(context, 3, "Fixing Bone Hierarchy") as progress: + bpy.ops.object.mode_set(mode='EDIT') + + # Fix spine chain + self.fix_spine_chain(armature) + progress.step("Fixed spine chain") + + # Fix limb chains + self.fix_limb_chains(armature) + progress.step("Fixed limb chains") + + # Fix bone orientations + self.fix_bone_orientations(armature) + progress.step("Fixed bone orientations") + + self.report({'INFO'}, t("MMDTools.hierarchy_fixed")) + return {'FINISHED'} + + def fix_spine_chain(self, armature: Object) -> None: + """Fix the spine bone chain hierarchy""" + edit_bones = armature.data.edit_bones + spine_chain = ['Hips', 'Spine', 'Chest', 'Neck', 'Head'] + previous = None + + for bone_name in spine_chain: + if bone_name in edit_bones: + bone = edit_bones[bone_name] + if previous: + bone.parent = edit_bones[previous] + previous = bone_name + + def fix_limb_chains(self, armature: Object) -> None: + """Fix arm and leg bone chains""" + edit_bones = armature.data.edit_bones + limb_chains = { + 'Left': { + 'arm': ['Left shoulder', 'Left arm', 'Left elbow', 'Left wrist'], + 'leg': ['Left leg', 'Left knee', 'Left ankle', 'Left toe'] + }, + 'Right': { + 'arm': ['Right shoulder', 'Right arm', 'Right elbow', 'Right wrist'], + 'leg': ['Right leg', 'Right knee', 'Right ankle', 'Right toe'] + } + } + + for side in limb_chains: + for chain in limb_chains[side].values(): + previous = None + for bone_name in chain: + if bone_name in edit_bones: + bone = edit_bones[bone_name] + if previous: + bone.parent = edit_bones[previous] + previous = bone_name + + def fix_bone_orientations(self, armature: Object) -> None: + """Fix bone roll and axis orientations""" + edit_bones = armature.data.edit_bones + + # Fix spine chain orientations + spine_bones = ['Hips', 'Spine', 'Chest'] + for name in spine_bones: + if name in edit_bones: + bone = edit_bones[name] + bone.roll = 0 + bone.tail.y = bone.head.y + + # Fix arm orientations + arm_bones = ['Left arm', 'Right arm', 'Left elbow', 'Right elbow'] + for name in arm_bones: + if name in edit_bones: + bone = edit_bones[name] + bone.roll = 0 if 'Left' in name else np.pi + +class AvatarToolkit_OT_FixBoneWeights(Operator): + """Fix and clean up bone weights""" + bl_idname = "avatar_toolkit.fix_bone_weights" + bl_label = t("MMDTools.fix_weights") + bl_description = t("MMDTools.fix_weights_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + meshes = get_all_meshes(context) + + if not meshes: + self.report({'WARNING'}, t("MMDTools.no_meshes")) + return {'CANCELLED'} + + with ProgressTracker(context, len(meshes), "Fixing Bone Weights") as progress: + for mesh in meshes: + # Clean weights + self.clean_weights(mesh, context.scene.avatar_toolkit.clean_weights_threshold) + + # Handle twist bones + if context.scene.avatar_toolkit.merge_twist_bones: + self.process_twist_bones(mesh) + + # Remove empty groups + self.remove_empty_groups(mesh) + + # Normalize weights + self.normalize_weights(mesh) + + progress.step(f"Processed {mesh.name}") + + self.report({'INFO'}, t("MMDTools.weights_fixed")) + return {'FINISHED'} + + def clean_weights(self, mesh: Object, threshold: float) -> None: + """Remove weights below threshold""" + for vertex_group in mesh.vertex_groups: + for vertex in mesh.data.vertices: + try: + weight = vertex_group.weight(vertex.index) + if weight < threshold: + vertex_group.remove([vertex.index]) + except RuntimeError: + continue + + def process_twist_bones(self, mesh: Object) -> None: + """Process and merge twist bone weights""" + twist_groups = [g for g in mesh.vertex_groups if 'twist' in g.name.lower()] + for group in twist_groups: + base_name = group.name.lower().replace('twist', '').strip('_') + for target in mesh.vertex_groups: + if target.name.lower() == base_name: + transfer_vertex_weights(mesh, group.name, target.name) + break + + def remove_empty_groups(self, mesh: Object) -> None: + """Remove vertex groups with no weights""" + empty_groups = [] + for group in mesh.vertex_groups: + has_weights = False + for vert in mesh.data.vertices: + for g in vert.groups: + if g.group == group.index and g.weight > 0: + has_weights = True + break + if has_weights: + break + if not has_weights: + empty_groups.append(group) + + for group in empty_groups: + mesh.vertex_groups.remove(group) + + def normalize_weights(self, mesh: Object) -> None: + """Normalize vertex weights""" + for vertex in mesh.data.vertices: + total_weight = sum(group.weight for group in vertex.groups) + if total_weight > 0: + for group in vertex.groups: + group.weight /= total_weight + +class AvatarToolkit_OT_FixMMDFeatures(Operator): + """Fix MMD-specific features and settings""" + bl_idname = "avatar_toolkit.fix_mmd_features" + bl_label = t("MMDTools.fix_mmd_features") + bl_description = t("MMDTools.fix_mmd_features_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + meshes = get_all_meshes(context) + + with ProgressTracker(context, 4, "Fixing MMD Features") as progress: + # Process shape keys + for mesh in meshes: + self.process_shape_keys(mesh) + progress.step("Processed shape keys") + + # Fix MMD shading + self.fix_mmd_shading(meshes) + progress.step("Fixed MMD shading") + + # Handle physics cleanup + self.cleanup_physics(armature) + progress.step("Cleaned up physics") + + # Remove unused data + self.cleanup_unused_data(context) + progress.step("Cleaned up unused data") + + return {'FINISHED'} + + def process_shape_keys(self, mesh: Object) -> None: + """Process and clean up shape keys""" + if not mesh.data.shape_keys: + return + + # Clean unused shape keys + remove_unused_shapekeys(mesh) + + # Sort and rename shape keys + shape_keys = mesh.data.shape_keys.key_blocks + for key in shape_keys: + # Handle Japanese prefixes + if key.name.startswith('防'): + key.name = key.name[1:] + # Handle common MMD prefixes + if key.name.startswith('表情'): + key.name = key.name[2:] + + def fix_mmd_shading(self, meshes: List[Object]) -> None: + """Fix MMD material shading settings""" + for mesh in meshes: + for material in mesh.data.materials: + if material: + material.use_backface_culling = True + material.blend_method = 'HASHED' + if material.node_tree: + for node in material.node_tree.nodes: + if node.type == 'BSDF_PRINCIPLED': + node.inputs['Alpha'].default_value = 1.0 + + def cleanup_physics(self, armature: Object) -> None: + """Clean up MMD physics objects""" + physics_objects = [obj for obj in bpy.data.objects + if obj.parent == armature and + (obj.rigid_body or obj.rigid_body_constraint)] + + for obj in physics_objects: + bpy.data.objects.remove(obj, do_unlink=True) + + def cleanup_unused_data(self, context: Context) -> None: + """Clean up unused MMD data""" + # Remove unused actions + for action in bpy.data.actions: + if not action.users: + bpy.data.actions.remove(action) + + # Remove empty vertex groups + for mesh in get_all_meshes(context): + self.remove_empty_groups(mesh) + + def remove_empty_groups(self, mesh: Object) -> None: + """Remove empty vertex groups""" + empty_groups = [] + for group in mesh.vertex_groups: + has_weights = False + for vert in mesh.data.vertices: + for g in vert.groups: + if g.group == group.index and g.weight > 0: + has_weights = True + break + if has_weights: + break + if not has_weights: + empty_groups.append(group) + + for group in empty_groups: + mesh.vertex_groups.remove(group) + +class AvatarToolkit_OT_AdvancedBoneOps(Operator): + """Advanced bone operations and fixes""" + bl_idname = "avatar_toolkit.advanced_bone_ops" + bl_label = t("MMDTools.advanced_bone_ops") + bl_description = t("MMDTools.advanced_bone_ops_desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + + with ProgressTracker(context, 4, "Advanced Bone Operations") as progress: + # Fix zero length bones + self.fix_zero_length_bones(armature) + progress.step("Fixed zero length bones") + + # Connect bones with children + self.connect_bone_chains(armature) + progress.step("Connected bone chains") + + # Handle bone roll values + self.fix_bone_rolls(armature) + progress.step("Fixed bone rolls") + + # Fix bone orientations + self.fix_bone_orientations(armature) + progress.step("Fixed bone orientations") + + return {'FINISHED'} + + def fix_zero_length_bones(self, armature: Object) -> None: + """Fix bones with zero length by extending them""" + min_length = 0.001 + for bone in armature.data.edit_bones: + length = (bone.tail - bone.head).length + if length < min_length: + if bone.parent: + bone.tail = bone.head + bone.parent.vector * 0.1 + else: + bone.tail.z = bone.head.z + 0.1 + + def connect_bone_chains(self, armature: Object) -> None: + """Connect bones that should form chains""" + min_distance = bpy.context.scene.avatar_toolkit.connect_bones_min_distance + + for bone in armature.data.edit_bones: + if len(bone.children) == 1: + child = bone.children[0] + distance = (bone.tail - child.head).length + if distance < min_distance: + child.use_connect = True + child.head = bone.tail + + def fix_bone_rolls(self, armature: Object) -> None: + """Fix bone roll values for proper orientation""" + for bone in armature.data.edit_bones: + if 'spine' in bone.name.lower() or 'chest' in bone.name.lower(): + bone.roll = 0 + elif 'shoulder' in bone.name.lower(): + bone.roll = 0 if 'left' in bone.name.lower() else np.pi + +class AvatarToolkit_OT_CleanupOperations(Operator): + """Cleanup unused data and objects""" + bl_idname = "avatar_toolkit.cleanup_operations" + bl_label = t("MMDTools.cleanup_operations") + bl_description = t("MMDTools.cleanup_operations_desc") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context) -> Set[str]: + armature = get_active_armature(context) + + with ProgressTracker(context, 4, "Cleanup Operations") as progress: + # Remove rigidbodies and joints + self.remove_physics_objects(armature) + progress.step("Removed physics objects") + + # Clear unused animation data + self.clear_unused_animations(armature) + progress.step("Cleared unused animations") + + # Remove empty objects + self.remove_empty_objects() + progress.step("Removed empty objects") + + # Clean up collections + self.cleanup_collections(armature) + progress.step("Cleaned up collections") + + return {'FINISHED'} + + def remove_physics_objects(self, armature: Object) -> None: + """Remove all physics objects and constraints""" + physics_objects = [obj for obj in bpy.data.objects + if obj.parent == armature and + (obj.rigid_body or obj.rigid_body_constraint)] + + for obj in physics_objects: + bpy.data.objects.remove(obj, do_unlink=True) + + def clear_unused_animations(self, armature: Object) -> None: + """Remove unused animation data""" + if armature.animation_data: + if armature.animation_data.action and armature.animation_data.action.users == 0: + bpy.data.actions.remove(armature.animation_data.action) + + # Clear unused NLA tracks + if armature.animation_data.nla_tracks: + for track in armature.animation_data.nla_tracks: + if not track.strips: + armature.animation_data.nla_tracks.remove(track) + + def remove_empty_objects(self) -> None: + """Remove empty objects from the scene""" + empty_objects = [obj for obj in bpy.data.objects + if obj.type == 'EMPTY' and not obj.children] + + for obj in empty_objects: + bpy.data.objects.remove(obj, do_unlink=True) + + def cleanup_collections(self, armature: Object) -> None: + """Clean up and organize collections""" + # Remove empty collections + for collection in bpy.data.collections: + if not collection.objects and not collection.children: + bpy.data.collections.remove(collection) + + # Ensure armature is in main collection + if armature.users_collection[0] != bpy.context.scene.collection: + bpy.context.scene.collection.objects.link(armature) \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 46594a3..d42172e 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -188,6 +188,35 @@ "Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used", "Tools.shapekeys_removed": "Removed {count} unused shape keys", + "MMDTools.label": "MMD Tools", + "MMDTools.basic_tools": "Basic MMD Tools", + "MMDTools.advanced_tools": "Advanced Tools", + "MMDTools.settings": "MMD Settings", + "MMDTools.cleanup": "Cleanup Tools", + "MMDTools.fix_bone_names": "Fix Bone Names", + "MMDTools.fix_bone_names_desc": "Standardize and fix bone names", + "MMDTools.fix_hierarchy": "Fix Bone Hierarchy", + "MMDTools.fix_hierarchy_desc": "Fix bone parenting and hierarchy", + "MMDTools.fix_weights": "Fix Bone Weights", + "MMDTools.fix_weights_desc": "Clean up and normalize bone weights", + "MMDTools.fix_mmd_features": "Fix MMD Features", + "MMDTools.fix_mmd_features_desc": "Fix MMD-specific features and settings", + "MMDTools.advanced_bone_ops": "Advanced Bone Operations", + "MMDTools.advanced_bone_ops_desc": "Perform advanced bone fixes and cleanup", + "MMDTools.keep_upper_chest": "Keep Upper Chest", + "MMDTools.keep_upper_chest_desc": "Keep the upper chest bone during cleanup", + "MMDTools.remove_unused": "Remove Unused Bones", + "MMDTools.remove_unused_desc": "Remove bones with no weights or influence", + "MMDTools.merge_distance": "Merge Distance", + "MMDTools.merge_distance_desc": "Distance threshold for merging vertices", + "MMDTools.cleanup_shapekeys": "Clean Shape Keys", + "MMDTools.cleanup_shapekeys_desc": "Remove unused and duplicate shape keys", + "MMDTools.bones_renamed": "Bone names standardized successfully", + "MMDTools.hierarchy_fixed": "Bone hierarchy fixed successfully", + "MMDTools.weights_fixed": "Bone weights cleaned and normalized", + "MMDTools.no_meshes": "No meshes found to process", + "MMDTools.not_mmd_model": "Selected armature is not an MMD model", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py new file mode 100644 index 0000000..39f5821 --- /dev/null +++ b/ui/mmd_panel.py @@ -0,0 +1,52 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t + +class AvatarToolKit_PT_MMDPanel(Panel): + """Panel containing MMD-specific tools and operations""" + bl_label = t("MMDTools.label") + bl_idname = "OBJECT_PT_avatar_toolkit_mmd" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + + def draw(self, context: Context) -> None: + """Draw the MMD tools panel interface""" + layout = self.layout + + # Basic MMD Tools Box + basic_box = layout.box() + col = basic_box.column(align=True) + col.label(text=t("MMDTools.basic_tools"), icon='ARMATURE_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.fix_bone_names", icon='SORTALPHA') + col.operator("avatar_toolkit.fix_bone_hierarchy", icon='BONE_DATA') + col.operator("avatar_toolkit.fix_bone_weights", icon='GROUP_BONE') + + # Advanced MMD Tools Box + advanced_box = layout.box() + col = advanced_box.column(align=True) + col.label(text=t("MMDTools.advanced_tools"), icon='MODIFIER') + col.separator(factor=0.5) + col.operator("avatar_toolkit.fix_mmd_features", icon='SHAPEKEY_DATA') + col.operator("avatar_toolkit.advanced_bone_ops", icon='CONSTRAINT_BONE') + + # Settings Box + settings_box = layout.box() + col = settings_box.column(align=True) + col.label(text=t("MMDTools.settings"), icon='PREFERENCES') + col.separator(factor=0.5) + col.prop(context.scene.avatar_toolkit, "mmd_keep_upper_chest") + col.prop(context.scene.avatar_toolkit, "mmd_remove_unused_bones") + col.prop(context.scene.avatar_toolkit, "mmd_cleanup_shapekeys") + + # Cleanup Box + cleanup_box = layout.box() + col = cleanup_box.column(align=True) + col.label(text=t("MMDTools.cleanup"), icon='TRASH') + col.separator(factor=0.5) + col.operator("avatar_toolkit.cleanup_operations", icon='BRUSH_DATA') diff --git a/ui/settings_panel.py b/ui/settings_panel.py index df86987..67b782f 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -36,7 +36,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order: int = 4 + bl_order: int = 5 def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection""" From c39f77d6d5de68b8e7d7ab212dc4cc3966902b86 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sun, 8 Dec 2024 06:52:05 +0000 Subject: [PATCH 11/19] Re-do still hate it --- core/common.py | 60 +- core/dictionaries.py | 246 +++------ core/properties.py | 34 +- functions/mmd_tools.py | 890 +++++++++++++++--------------- resources/translations/en_US.json | 46 +- ui/mmd_panel.py | 50 +- 6 files changed, 648 insertions(+), 678 deletions(-) diff --git a/core/common.py b/core/common.py index 4b23aac..e5e817c 100644 --- a/core/common.py +++ b/core/common.py @@ -1,6 +1,6 @@ import bpy import numpy as np -from bpy.types import Context, Object, Modifier, EditBone +from bpy.types import Context, Object, Modifier, EditBone, Operator from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.logging_setup import logger from ..core.translations import t @@ -485,3 +485,61 @@ def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: removed_count += 1 return removed_count + +def save_armature_state(armature: Object) -> Dict[str, Any]: + """Save current armature state for recovery""" + state = { + 'bones': {}, + 'pose': {}, + 'settings': {} + } + + # Save bone data + for bone in armature.data.bones: + state['bones'][bone.name] = { + 'head': bone.head_local.copy(), + 'tail': bone.tail_local.copy(), + 'roll': bone.roll, + 'parent': bone.parent.name if bone.parent else None + } + + # Save pose data if exists + if armature.pose: + for bone in armature.pose.bones: + state['pose'][bone.name] = { + 'location': bone.location.copy(), + 'rotation': bone.rotation_quaternion.copy(), + 'scale': bone.scale.copy() + } + + return state + +def restore_armature_state(armature: Object, state: Dict[str, Any]) -> None: + """Restore armature from saved state""" + bpy.ops.object.mode_set(mode='EDIT') + + # Restore bones + for name, data in state['bones'].items(): + if name in armature.data.edit_bones: + bone = armature.data.edit_bones[name] + bone.head = data['head'] + bone.tail = data['tail'] + bone.roll = data['roll'] + + # Restore parenting + for name, data in state['bones'].items(): + if data['parent'] and name in armature.data.edit_bones: + bone = armature.data.edit_bones[name] + if data['parent'] in armature.data.edit_bones: + bone.parent = armature.data.edit_bones[data['parent']] + + bpy.ops.object.mode_set(mode='POSE') + + # Restore pose if exists + if 'pose' in state: + for name, data in state['pose'].items(): + if name in armature.pose.bones: + bone = armature.pose.bones[name] + bone.location = data['location'] + bone.rotation_quaternion = data['rotation'] + bone.scale = data['scale'] diff --git a/core/dictionaries.py b/core/dictionaries.py index 3d5b8f2..4ec0b07 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -5,89 +5,97 @@ # Note from @989onan: Please make sure to make your names are lowercase in this array. I banged my head metaphorically till I figured that out... # Taken from Tuxedo/Cats bone_names = { - "right_shoulder": ["rightshoulder", "shoulderr", "rshoulder", "valvebipedbip01rclavicle"], - "right_arm": ["rightarm", "armr", "rarm", "upperarmr", "rupperarm", "rightupperarm", "uparmr", "ruparm", "valvebipedbip01rupperarm"], - "right_elbow": ["rightelbow", "elbowr", "relbow", "lowerarmr", "rightlowerarm", "lowerarmr","rlowerarm", "lowarmr", "rlowarm", "forearmr","rforearm", "valvebipedbip01rforearm"], - "right_wrist": ["rightwrist", "wristr", "rwrist", "handr", "righthand", "rhand", "valvebipedbip01rhand"], - - #hand l fingers - "pinkie_0_r": ["littlefinger0r","pinkie0r","rpinkie0","pinkiemetacarpalr"], - "pinkie_1_r": ["littlefinger1r","pinkie1r","rpinkie1","pinkieproximalr", "valvebipedbip01rfinger4"], - "pinkie_2_r": ["littlefinger2r","pinkie2r","rpinkie2","pinkieintermediater", "valvebipedbip01rfinger41"], - "pinkie_3_r": ["littlefinger3r","pinkie3r","rpinkie3","pinkiedistalr", "valvebipedbip01rfinger42"], - - "ring_0_r": ["ringfinger0r","ring0r","rring0","ringmetacarpalr"], - "ring_1_r": ["ringfinger1r","ring1r","rring1","ringproximalr", "valvebipedbip01rfinger3"], - "ring_2_r": ["ringfinger2r","ring2r","rring2","ringintermediater", "valvebipedbip01rfinger31"], - "ring_3_r": ["ringfinger3r","ring3r","rring3","ringdistalr", "valvebipedbip01rfinger32"], - - "middle_0_r": ["middlefinger0r","middle0r","rmiddle0","middlemetacarpalr"], - "middle_1_r": ["middlefinger1r","middle1r","rmiddle1","middleproximalr", "valvebipedbip01rfinger2"], - "middle_2_r": ["middlefinger2r","middle2r","rmiddle2","middleintermediater", "valvebipedbip01rfinger21"], - "middle_3_r": ["middlefinger3r","middle3r","rmiddle3","middledistalr", "valvebipedbip01rfinger22"], - - "index_0_r": ["indexfinger0r","index0r","rindex0","indexmetacarpalr"], - "index_1_r": ["indexfinger1r","index1r","rindex1","indexproximalr", "valvebipedbip01rfinger1"], - "index_2_r": ["indexfinger2r","index2r","rindex2","indexintermediater", "valvebipedbip01rfinger11"], - "index_3_r": ["indexfinger3r","index3r","rindex3","indexdistalr", "valvebipedbip01rfinger12"], - - "thumb_0_r": ["thumb0r","rthumb0","thumbmetacarpalr"], - "thumb_1_r": ['thumb1r',"rthumb1","thumbproximalr", "valvebipedbip01rfinger0"], - "thumb_2_r": ['thumb2r',"rthumb2","thumbintermediater", "valvebipedbip01rfinger01"], - "thumb_3_r": ['thumb3r',"rthumb3","thumbdistalr", "valvebipedbip01rfinger02"], - - "right_leg": ["rightleg", "legr", "rleg", "upperlegr", "rupperleg", "thighr", "rightupperleg", "uplegr", "rupleg", "valvebipedbip01rthigh"], - "right_knee": ["rightknee", "kneer", "rknee", "lowerlegr", "calfr", "rlowerleg", "rcalf", "rightlowerleg", "lowlegr", "rlowleg", "valvebipedbip01rcalf"], - "right_ankle": ["rightankle", "ankler", "rankle", "rightfoot", "footr", "rfoot", "rightfoot", "rightfeet", "feetright", "rfeet", "feetr", "valvebipedbip01rfoot"], - "right_toe": ["righttoe", "toeright", "toer", "rtoe", "toesr", "rtoes", "valvebipedbip01rtoe0"], - - "left_shoulder": ["leftshoulder", "shoulderl", "lshoulder", "valvebipedbip01lclavicle"], - "left_arm": ["leftarm", "arml", "rarm", "upperarml", "lupperarm", "leftupperarm", "uparml", "luparm", "valvebipedbip01lupperarm"], - "left_elbow": ["leftelbow", "elbowl", "lelbow", "lowerarml", "leftlowerarm", "lowerarml", "llowerarm", "lowarml", "llowarm", "forearml","lforearm", "valvebipedbip01lforearm"], - "left_wrist": ["leftwrist", "wristl", "lwrist", "handl", "lefthand", "lhand", "valvebipedbip01lhand"], - - #hand l fingers - - "pinkie_0_l": ["pinkiefinger0l","pinkie0l","lpinkie0","pinkiemetacarpall"], - "pinkie_1_l": ["littlefinger1l","pinkie1l","lpinkie1","pinkieproximall", "valvebipedbip01lfinger4"], - "pinkie_2_l": ["littlefinger2l","pinkie2l","lpinkie2","pinkieintermediatel", "valvebipedbip01lfinger41"], - "pinkie_3_l": ["littlefinger3l","pinkie3l","lpinkie3","pinkiedistall", "valvebipedbip01lfinger42"], - - "ring_0_l": ["ringfinger0l","ring0l","lring0","ringmetacarpall"], - "ring_1_l": ["ringfinger1l","ring1l","lring1","ringproximall", "valvebipedbip01lfinger3"], - "ring_2_l": ["ringfinger2l","ring2l","lring2","ringintermediatel", "valvebipedbip01lfinger31"], - "ring_3_l": ["ringfinger3l","ring3l","lring3","ringdistall", "valvebipedbip01lfinger32"], - - "middle_0_l": ["middlefinger0l","middle_0l","lmiddle0","middlemetacarpall"], - "middle_1_l": ["middlefinger1l","middle_1l","lmiddle1","middleproximall", "valvebipedbip01lfinger2"], - "middle_2_l": ["middlefinger2l","middle_2l","lmiddle2","middleintermediatel", "valvebipedbip01lfinger21"], - "middle_3_l": ["middlefinger3l","middle_3l","lmiddle3","middledistall", "valvebipedbip01lfinger22"], - - "index_0_l": ["indexfinger0l","index0l","lindex0","indexmetacarpall"], - "index_1_l": ["indexfinger1l","index1l","lindex1","indexproximall", "valvebipedbip01lfinger1"], - "index_2_l": ["indexfinger2l","index2l","lindex2","indexintermediatel", "valvebipedbip01lfinger11"], - "index_3_l": ["indexfinger3l","index3l","lindex3","indexdistall", "valvebipedbip01lfinger12"], - - "thumb_0_l": ["thumb0l","lthumb0","thumbmetacarpall"], - "thumb_1_l": ['thumb1l',"lthumb1","thumbproximall", "valvebipedbip01lfinger0"], - "thumb_2_l": ['thumb2l',"lthumb2","thumbintermediatel", "valvebipedbip01lfinger01"], - "thumb_3_l": ['thumb3l',"lthumb3","thumbdistall", "valvebipedbip01lfinger02"], - - "left_leg": ["leftleg", "legl", "lleg", "upperlegl", "lupperleg", "thighl", "leftupperleg", "uplegl", "lupleg", "valvebipedbip01lthigh"], - "left_knee": ["leftknee", "kneel", "lknee", "lowerlegl", "llowerleg", "calfl", "lcalf", "leftlowerleg", 'lowlegl', 'llowleg', "valvebipedbip01lcalf"], - "left_ankle": ["leftankle", "anklel", "rankle", "leftfoot", "footl", "lfoot", "leftfoot", "leftfeet", "feetleft", "lfeet", "feetl", "valvebipedbip01lfoot"], - "left_toe": ["lefttoe", "toeleft", "toel", "ltoe", "toesl", "ltoes", "valvebipedbip01ltoe0"], - - "hips": ["pelvis", "hips", "hip", "valvebipedbip01pelvis"], - "spine": ["torso", "spine", "valvebipedbip01spine"], - "chest": ["chest", "valvebipedbip01spine1"], - "upper_chest": ["upperchest", "valvebipedbip01spine4"], - "neck": ["neck", "valvebipedbip01neck1"], - "head": ["head", "valvebipedbip01head1"], - "left_eye": ["eyeleft", "lefteye", "eyel", "leye"], - "right_eye": ["eyeright", "righteye", "eyer", "reye"], + "right_shoulder": ["rightshoulder", "shoulderr", "rshoulder", "valvebipedbip01rclavicle", "右肩"], + "right_arm": ["rightarm", "armr", "rarm", "upperarmr", "rupperarm", "rightupperarm", "uparmr", "ruparm", "valvebipedbip01rupperarm", "右腕"], + "right_elbow": ["rightelbow", "elbowr", "relbow", "lowerarmr", "rightlowerarm", "lowerarmr", "rlowerarm", "lowarmr", "rlowarm", "forearmr", "rforearm", "valvebipedbip01rforearm", "右ひじ"], + "right_wrist": ["rightwrist", "wristr", "rwrist", "handr", "righthand", "rhand", "valvebipedbip01rhand", "右手首"], + "pinkie_0_r": ["littlefinger0r", "pinkie0r", "rpinkie0", "pinkiemetacarpalr", "右小指0"], + "pinkie_1_r": ["littlefinger1r", "pinkie1r", "rpinkie1", "pinkieproximalr", "valvebipedbip01rfinger4", "右小指1"], + "pinkie_2_r": ["littlefinger2r", "pinkie2r", "rpinkie2", "pinkieintermediater", "valvebipedbip01rfinger41", "右小指2"], + "pinkie_3_r": ["littlefinger3r", "pinkie3r", "rpinkie3", "pinkiedistalr", "valvebipedbip01rfinger42", "右小指3"], + "ring_0_r": ["ringfinger0r", "ring0r", "rring0", "ringmetacarpalr", "右薬指0"], + "ring_1_r": ["ringfinger1r", "ring1r", "rring1", "ringproximalr", "valvebipedbip01rfinger3", "右薬指1"], + "ring_2_r": ["ringfinger2r", "ring2r", "rring2", "ringintermediater", "valvebipedbip01rfinger31", "右薬指2"], + "ring_3_r": ["ringfinger3r", "ring3r", "rring3", "ringdistalr", "valvebipedbip01rfinger32", "右薬指3"], + "middle_0_r": ["middlefinger0r", "middle0r", "rmiddle0", "middlemetacarpalr", "右中指0"], + "middle_1_r": ["middlefinger1r", "middle1r", "rmiddle1", "middleproximalr", "valvebipedbip01rfinger2", "右中指1"], + "middle_2_r": ["middlefinger2r", "middle2r", "rmiddle2", "middleintermediater", "valvebipedbip01rfinger21", "右中指2"], + "middle_3_r": ["middlefinger3r", "middle3r", "rmiddle3", "middledistalr", "valvebipedbip01rfinger22", "右中指3"], + "index_0_r": ["indexfinger0r", "index0r", "rindex0", "indexmetacarpalr", "右人差指0"], + "index_1_r": ["indexfinger1r", "index1r", "rindex1", "indexproximalr", "valvebipedbip01rfinger1", "右人差指1"], + "index_2_r": ["indexfinger2r", "index2r", "rindex2", "indexintermediater", "valvebipedbip01rfinger11", "右人差指2"], + "index_3_r": ["indexfinger3r", "index3r", "rindex3", "indexdistalr", "valvebipedbip01rfinger12", "右人差指3"], + "thumb_0_r": ["thumb0r", "rthumb0", "thumbmetacarpalr", "右親指0"], + "thumb_1_r": ["thumb1r", "rthumb1", "thumbproximalr", "valvebipedbip01rfinger0", "右親指1"], + "thumb_2_r": ["thumb2r", "rthumb2", "thumbintermediater", "valvebipedbip01rfinger01", "右親指2"], + "thumb_3_r": ["thumb3r", "rthumb3", "thumbdistalr", "valvebipedbip01rfinger02", "右親指3"], + "right_leg": ["rightleg", "legr", "rleg", "upperlegr", "rupperleg", "thighr", "rightupperleg", "uplegr", "rupleg", "valvebipedbip01rthigh", "右足"], + "right_knee": ["rightknee", "kneer", "rknee", "lowerlegr", "calfr", "rlowerleg", "rcalf", "rightlowerleg", "lowlegr", "rlowleg", "valvebipedbip01rcalf", "右ひざ"], + "right_ankle": ["rightankle", "ankler", "rankle", "rightfoot", "footr", "rfoot", "rightfoot", "rightfeet", "feetright", "rfeet", "feetr", "valvebipedbip01rfoot", "右足首"], + "right_toe": ["righttoe", "toeright", "toer", "rtoe", "toesr", "rtoes", "valvebipedbip01rtoe0", "右つま先"], + "left_shoulder": ["leftshoulder", "shoulderl", "lshoulder", "valvebipedbip01lclavicle", "左肩"], + "left_arm": ["leftarm", "arml", "larm", "upperarml", "lupperarm", "leftupperarm", "uparml", "luparm", "valvebipedbip01lupperarm", "左腕"], + "left_elbow": ["leftelbow", "elbowl", "lelbow", "lowerarml", "leftlowerarm", "lowerarml", "llowerarm", "lowarml", "llowarm", "forearml", "lforearm", "valvebipedbip01lforearm", "左ひじ"], + "left_wrist": ["leftwrist", "wristl", "lwrist", "handl", "lefthand", "lhand", "valvebipedbip01lhand", "左手首"], + "pinkie_0_l": ["pinkiefinger0l", "pinkie0l", "lpinkie0", "pinkiemetacarpall", "左小指0"], + "pinkie_1_l": ["littlefinger1l", "pinkie1l", "lpinkie1", "pinkieproximall", "valvebipedbip01lfinger4", "左小指1"], + "pinkie_2_l": ["littlefinger2l", "pinkie2l", "lpinkie2", "pinkieintermediatel", "valvebipedbip01lfinger41", "左小指2"], + "pinkie_3_l": ["littlefinger3l", "pinkie3l", "lpinkie3", "pinkiedistall", "valvebipedbip01lfinger42", "左小指3"], + "ring_0_l": ["ringfinger0l", "ring0l", "lring0", "ringmetacarpall", "左薬指0"], + "ring_1_l": ["ringfinger1l", "ring1l", "lring1", "ringproximall", "valvebipedbip01lfinger3", "左薬指1"], + "ring_2_l": ["ringfinger2l", "ring2l", "lring2", "ringintermediatel", "valvebipedbip01lfinger31", "左薬指2"], + "ring_3_l": ["ringfinger3l", "ring3l", "lring3", "ringdistall", "valvebipedbip01lfinger32", "左薬指3"], + "middle_0_l": ["middlefinger0l", "middle_0l", "lmiddle0", "middlemetacarpall", "左中指0"], + "middle_1_l": ["middlefinger1l", "middle_1l", "lmiddle1", "middleproximall", "valvebipedbip01lfinger2", "左中指1"], + "middle_2_l": ["middlefinger2l", "middle_2l", "lmiddle2", "middleintermediatel", "valvebipedbip01lfinger21", "左中指2"], + "middle_3_l": ["middlefinger3l", "middle_3l", "lmiddle3", "middledistall", "valvebipedbip01lfinger22", "左中指3"], + "index_0_l": ["indexfinger0l", "index0l", "lindex0", "indexmetacarpall", "左人差指0"], + "index_1_l": ["indexfinger1l", "index1l", "lindex1", "indexproximall", "valvebipedbip01lfinger1", "左人差指1"], + "index_2_l": ["indexfinger2l", "index2l", "lindex2", "indexintermediatel", "valvebipedbip01lfinger11", "左人差指2"], + "index_3_l": ["indexfinger3l", "index3l", "lindex3", "indexdistall", "valvebipedbip01lfinger12", "左人差指3"], + "thumb_0_l": ["thumb0l", "lthumb0", "thumbmetacarpall", "左親指0"], + "thumb_1_l": ["thumb1l", "lthumb1", "thumbproximall", "valvebipedbip01lfinger0", "左親指1"], + "thumb_2_l": ["thumb2l", "lthumb2", "thumbintermediatel", "valvebipedbip01lfinger01", "左親指2"], + "thumb_3_l": ["thumb3l", "lthumb3", "thumbdistall", "valvebipedbip01lfinger02", "左親指3"], + "left_leg": ["leftleg", "legl", "lleg", "upperlegl", "lupperleg", "thighl", "leftupperleg", "uplegl", "lupleg", "valvebipedbip01lthigh", "左足"], + "left_knee": ["leftknee", "kneel", "lknee", "lowerlegl", "llowerleg", "calfl", "lcalf", "leftlowerleg", "lowlegl", "llowleg", "valvebipedbip01lcalf", "左ひざ"], + "left_ankle": ["leftankle", "anklel", "lankle", "leftfoot", "footl", "lfoot", "leftfoot", "leftfeet", "feetleft", "lfeet", "feetl", "valvebipedbip01lfoot", "左足首"], + "left_toe": ["lefttoe", "toeleft", "toel", "ltoe", "toesl", "ltoes", "valvebipedbip01ltoe0", "左つま先"], + "hips": ["pelvis", "hips", "hip", "valvebipedbip01pelvis", "腰"], + "spine": ["torso", "spine", "valvebipedbip01spine", "脊椎"], + "chest": ["chest", "valvebipedbip01spine1", "胸"], + "upper_chest": ["upperchest", "valvebipedbip01spine4", "上胸"], + "neck": ["neck", "valvebipedbip01neck1", "首"], + "head": ["head", "valvebipedbip01head1", "頭"], + "left_eye": ["eyeleft", "lefteye", "eyel", "leye", "左目"], + "right_eye": ["eyeright", "righteye", "eyer", "reye", "右目"], } + +# Add VRM bone name variations +bone_names.update({ + 'hips': bone_names['hips'] + ['j_bip_c_hips', 'j_hips', 'vrm_hips'], + 'spine': bone_names['spine'] + ['j_bip_c_spine', 'j_spine', 'vrm_spine'], + 'chest': bone_names['chest'] + ['j_bip_c_chest', 'j_chest', 'vrm_chest'], + 'upper_chest': bone_names['upper_chest'] + ['j_bip_c_upper_chest', 'j_upper_chest', 'vrm_upperchest'], + 'neck': bone_names['neck'] + ['j_bip_c_neck', 'j_neck', 'vrm_neck'], + 'head': bone_names['head'] + ['j_bip_c_head', 'j_head', 'vrm_head'], + + # VRM specific finger naming + 'thumb_0_l': bone_names['thumb_0_l'] + ['thumb_metacarpal_l', 'j_thumb1_l'], + 'index_0_l': bone_names['index_0_l'] + ['index_metacarpal_l', 'j_index1_l'], + 'middle_0_l': bone_names['middle_0_l'] + ['middle_metacarpal_l', 'j_middle1_l'], + 'ring_0_l': bone_names['ring_0_l'] + ['ring_metacarpal_l', 'j_ring1_l'], + 'pinkie_0_l': bone_names['pinkie_0_l'] + ['little_metacarpal_l', 'j_little1_l'], + + # Mirror for right side + 'thumb_0_r': bone_names['thumb_0_r'] + ['thumb_metacarpal_r', 'j_thumb1_r'], + 'index_0_r': bone_names['index_0_r'] + ['index_metacarpal_r', 'j_index1_r'], + 'middle_0_r': bone_names['middle_0_r'] + ['middle_metacarpal_r', 'j_middle1_r'], + 'ring_0_r': bone_names['ring_0_r'] + ['ring_metacarpal_r', 'j_ring1_r'], + 'pinkie_0_r': bone_names['pinkie_0_r'] + ['little_metacarpal_r', 'j_little1_r'] +}) + # array taken from cats dont_delete_these_main_bones = [ 'Hips', 'Spine', 'Chest', 'Upper Chest', 'Neck', 'Head', @@ -166,79 +174,3 @@ resonite_translations = { 'thumb_2_r': "thumb2.R", 'thumb_3_r': "thumb3.R" } - -mmd_bone_renames = { - # Core body - "センター": "Center", - "グルーブ": "Groove", - "腰": "Waist", - "上半身": "Upper Body", - "上半身2": "Upper Body 2", - "下半身": "Lower Body", - - # Head - "首": "Neck", - "頭": "Head", - "両目": "Eyes", - "左目": "Eye_L", - "右目": "Eye_R", - - # Arms - "左肩": "Shoulder_L", - "左腕": "Arm_L", - "左ひじ": "Elbow_L", - "左手首": "Wrist_L", - "右肩": "Shoulder_R", - "右腕": "Arm_R", - "右ひじ": "Elbow_R", - "右手首": "Wrist_R", - - # Fingers - "左親指1": "Thumb1_L", - "左親指2": "Thumb2_L", - "左人指1": "Index1_L", - "左人指2": "Index2_L", - "左人指3": "Index3_L", - "左中指1": "Middle1_L", - "左中指2": "Middle2_L", - "左中指3": "Middle3_L", - "左薬指1": "Ring1_L", - "左薬指2": "Ring2_L", - "左薬指3": "Ring3_L", - "左小指1": "Pinky1_L", - "左小指2": "Pinky2_L", - "左小指3": "Pinky3_L", - - "右親指1": "Thumb1_R", - "右親指2": "Thumb2_R", - "右人指1": "Index1_R", - "右人指2": "Index2_R", - "右人指3": "Index3_R", - "右中指1": "Middle1_R", - "右中指2": "Middle2_R", - "右中指3": "Middle3_R", - "右薬指1": "Ring1_R", - "右薬指2": "Ring2_R", - "右薬指3": "Ring3_R", - "右小指1": "Pinky1_R", - "右小指2": "Pinky2_R", - "右小指3": "Pinky3_R", - - # Legs - "左足": "Leg_L", - "左ひざ": "Knee_L", - "左足首": "Ankle_L", - "右足": "Leg_R", - "右ひざ": "Knee_R", - "右足首": "Ankle_R", - - # Toes - "左つま先": "Toe_L", - "右つま先": "Toe_R", - - # IK bones - "左足IK": "Leg_IK_L", - "右足IK": "Leg_IK_R", - "左つま先IK": "Toe_IK_L", - "右つま先IK": "Toe_IK_R" -} diff --git a/core/properties.py b/core/properties.py index 9dbb70d..83be578 100644 --- a/core/properties.py +++ b/core/properties.py @@ -116,30 +116,22 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=1.0 ) - mmd_keep_upper_chest: BoolProperty( - name=t("MMDTools.keep_upper_chest"), - description=t("MMDTools.keep_upper_chest_desc"), + mmd_process_twist_bones: BoolProperty( + name=t("MMD.process_twist_bones"), + description=t("MMD.process_twist_bones_desc"), + default=True + ) + + mmd_connect_bones: BoolProperty( + name=t("MMD.connect_bones"), + description=t("MMD.connect_bones_desc"), default=True ) - mmd_remove_unused_bones: BoolProperty( - name=t("MMDTools.remove_unused"), - description=t("MMDTools.remove_unused_desc"), - default=True - ) - - mmd_merge_distance: FloatProperty( - name=t("MMDTools.merge_distance"), - description=t("MMDTools.merge_distance_desc"), - default=0.001, - min=0.0001, - max=0.1 - ) - - mmd_cleanup_shapekeys: BoolProperty( - name=t("MMDTools.cleanup_shapekeys"), - description=t("MMDTools.cleanup_shapekeys_desc"), - default=True + save_backup_state: BoolProperty( + name="Save Backup State", + description="Save the initial state of the armature before standardizing bones", + default=False ) def register() -> None: diff --git a/functions/mmd_tools.py b/functions/mmd_tools.py index 93645fa..8a85799 100644 --- a/functions/mmd_tools.py +++ b/functions/mmd_tools.py @@ -1,498 +1,502 @@ import bpy -import numpy as np -from typing import Set, Dict, List, Optional, Tuple -from bpy.types import Operator, Context, Object, EditBone, Mesh +from typing import Tuple, Set, Dict +from bpy.types import Operator, Context, Object +from mathutils import Vector +from ..core.common import ( + ProgressTracker, + get_active_armature, + validate_meshes, + simplify_bonename, + duplicate_bone_chain, + save_armature_state, + restore_armature_state, + get_all_meshes, + validate_bone_hierarchy, + transfer_vertex_weights, + get_vertex_weights +) from ..core.logging_setup import logger from ..core.translations import t -from ..core.common import ( - get_active_armature, - validate_armature, - get_all_meshes, - ProgressTracker, - transfer_vertex_weights, - remove_unused_shapekeys -) -from ..core.dictionaries import bone_names, mmd_bone_renames +from ..core.dictionaries import bone_names -class AvatarToolkit_OT_FixBoneNames(Operator): - """Standardize and fix bone names""" - bl_idname = "avatar_toolkit.fix_bone_names" - bl_label = t("MMDTools.fix_bone_names") - bl_description = t("MMDTools.fix_bone_names_desc") +class AvatarToolkit_OT_StandardizeMMDBones(Operator): + bl_idname = "avatar_toolkit.mmd_standardize_bones" + bl_label = t("MMD.standardize_bones") bl_options = {'REGISTER', 'UNDO'} - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_active_armature(context) - if not armature: - return False - valid, _ = validate_armature(armature) - return valid + def standardize_bone_names(self, armature: Object) -> None: + """Standardize bone names using MMD to Unity/VRChat conventions""" + for bone in armature.data.bones: + simplified_name = simplify_bonename(bone.name) + for standard_name, variations in bone_names.items(): + if simplified_name in variations: + bone.name = standard_name + break - def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) - - with ProgressTracker(context, 3, "Fixing Bone Names") as progress: - bpy.ops.object.mode_set(mode='EDIT') - - # First pass - standardize names - for bone in armature.data.edit_bones: - bone.name = self.standardize_bone_name(bone.name) - progress.step("Standardized names") + def process_lr_bones(self, armature: Object) -> None: + """Process left/right bone pairs for consistency""" + for bone in armature.data.bones: + if bone.name.endswith(('_l', '_r', '.l', '.r', 'Left', 'Right')): + base_name = bone.name.rsplit('_', 1)[0] + side = '_l' if any(s in bone.name.lower() for s in ('left', '_l', '.l')) else '_r' + bone.name = f"{base_name}{side}" - # Second pass - apply MMD mappings - for bone in armature.data.edit_bones: - if bone.name in mmd_bone_renames: - bone.name = mmd_bone_renames[bone.name] - progress.step("Applied MMD mappings") + def resolve_name_conflicts(self, armature: Object) -> None: + """Handle duplicate bone names""" + used_names = set() + for bone in armature.data.bones: + base_name = bone.name + counter = 1 + while bone.name in used_names: + bone.name = f"{base_name}_{counter}" + counter += 1 + used_names.add(bone.name) - # Third pass - fix common names - for bone in armature.data.edit_bones: - self.fix_common_names(bone) - progress.step("Fixed common names") - - self.report({'INFO'}, t("MMDTools.bones_renamed")) - return {'FINISHED'} - - def standardize_bone_name(self, name: str) -> str: - """Standardize bone naming convention""" - prefixes = ['def-', 'def_', 'sk_', 'b_', 'bone_', 'mmd_'] - name_lower = name.lower() - - # Remove common prefixes - for prefix in prefixes: - if name_lower.startswith(prefix): - name = name[len(prefix):] - break - - # Fix side indicators - name = name.replace('_l', '_L').replace('_r', '_R') - name = name.replace('.l', '_L').replace('.r', '_R') - name = name.replace('左', '_L').replace('右', '_R') - - return name - - def fix_common_names(self, bone: EditBone) -> None: - """Fix common bone names to standard names""" - for standard_name, variations in bone_names.items(): - if bone.name.lower() in variations: - bone.name = standard_name - break - -class AvatarToolkit_OT_FixBoneHierarchy(Operator): - """Fix bone parenting and hierarchy""" - bl_idname = "avatar_toolkit.fix_bone_hierarchy" - bl_label = t("MMDTools.fix_hierarchy") - bl_description = t("MMDTools.fix_hierarchy_desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_active_armature(context) - if not armature: - return False - valid, _ = validate_armature(armature) - return valid - - def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) - - with ProgressTracker(context, 3, "Fixing Bone Hierarchy") as progress: - bpy.ops.object.mode_set(mode='EDIT') - - # Fix spine chain - self.fix_spine_chain(armature) - progress.step("Fixed spine chain") - - # Fix limb chains - self.fix_limb_chains(armature) - progress.step("Fixed limb chains") - - # Fix bone orientations - self.fix_bone_orientations(armature) - progress.step("Fixed bone orientations") - - self.report({'INFO'}, t("MMDTools.hierarchy_fixed")) - return {'FINISHED'} - - def fix_spine_chain(self, armature: Object) -> None: - """Fix the spine bone chain hierarchy""" + def process_spine_chain(self, armature: Object) -> None: + """Process spine bones for VRChat compatibility""" + bpy.ops.object.mode_set(mode='EDIT') edit_bones = armature.data.edit_bones - spine_chain = ['Hips', 'Spine', 'Chest', 'Neck', 'Head'] - previous = None - for bone_name in spine_chain: - if bone_name in edit_bones: - bone = edit_bones[bone_name] - if previous: - bone.parent = edit_bones[previous] - previous = bone_name - - def fix_limb_chains(self, armature: Object) -> None: - """Fix arm and leg bone chains""" - edit_bones = armature.data.edit_bones - limb_chains = { - 'Left': { - 'arm': ['Left shoulder', 'Left arm', 'Left elbow', 'Left wrist'], - 'leg': ['Left leg', 'Left knee', 'Left ankle', 'Left toe'] - }, - 'Right': { - 'arm': ['Right shoulder', 'Right arm', 'Right elbow', 'Right wrist'], - 'leg': ['Right leg', 'Right knee', 'Right ankle', 'Right toe'] - } + spine_bones = { + 'hips': None, + 'spine': None, + 'chest': None, + 'upper_chest': None, + 'neck': None, + 'head': None } - for side in limb_chains: - for chain in limb_chains[side].values(): - previous = None - for bone_name in chain: - if bone_name in edit_bones: - bone = edit_bones[bone_name] - if previous: - bone.parent = edit_bones[previous] - previous = bone_name + # Map existing spine bones + for bone in edit_bones: + simplified = simplify_bonename(bone.name) + for spine_name in spine_bones.keys(): + if simplified in bone_names[spine_name]: + spine_bones[spine_name] = bone + break + + # Create missing spine bones + if spine_bones['spine'] and not spine_bones['chest']: + chest = edit_bones.new('chest') + chest.head = spine_bones['spine'].tail + chest.tail = spine_bones['neck'].head if spine_bones['neck'] else spine_bones['head'].head + spine_bones['chest'] = chest + + # Set up spine hierarchy + if spine_bones['hips']: + for i, key in enumerate(['spine', 'chest', 'upper_chest', 'neck', 'head']): + if spine_bones[key]: + prev_key = list(spine_bones.keys())[i] + if spine_bones[prev_key]: + spine_bones[key].parent = spine_bones[prev_key] - def fix_bone_orientations(self, armature: Object) -> None: - """Fix bone roll and axis orientations""" + def correct_bone_orientations(self, armature: Object) -> None: + """Automatically correct bone orientations to align with Unity's axes""" + bpy.ops.object.mode_set(mode='EDIT') edit_bones = armature.data.edit_bones - # Fix spine chain orientations - spine_bones = ['Hips', 'Spine', 'Chest'] - for name in spine_bones: - if name in edit_bones: - bone = edit_bones[name] - bone.roll = 0 - bone.tail.y = bone.head.y - - # Fix arm orientations - arm_bones = ['Left arm', 'Right arm', 'Left elbow', 'Right elbow'] - for name in arm_bones: - if name in edit_bones: - bone = edit_bones[name] - bone.roll = 0 if 'Left' in name else np.pi - -class AvatarToolkit_OT_FixBoneWeights(Operator): - """Fix and clean up bone weights""" - bl_idname = "avatar_toolkit.fix_bone_weights" - bl_label = t("MMDTools.fix_weights") - bl_description = t("MMDTools.fix_weights_desc") - bl_options = {'REGISTER', 'UNDO'} + # Define standard orientations + orientations = { + 'spine': Vector((0, 0, 1)), # Points up + 'chest': Vector((0, 0, 1)), + 'neck': Vector((0, 0, 1)), + 'head': Vector((0, 0, 1)), + 'shoulder': Vector((1, 0, 0)), # Points outward + 'arm': Vector((0, -1, 0)), # Points down + 'elbow': Vector((0, -1, 0)), + 'leg': Vector((0, -1, 0)), + 'knee': Vector((0, -1, 0)), + 'foot': Vector((1, 0, 0)), # Points forward + } + + for bone in edit_bones: + simplified_name = simplify_bonename(bone.name) + for bone_type, direction in orientations.items(): + if bone_type in simplified_name: + # Calculate new tail position while maintaining length + length = (bone.tail - bone.head).length + bone.tail = bone.head + direction * length + break @classmethod def poll(cls, context: Context) -> bool: - armature = get_active_armature(context) - if not armature: - return False - valid, _ = validate_armature(armature) - return valid + """Check if there is an active armature in the scene""" + return get_active_armature(context) is not None def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) - meshes = get_all_meshes(context) - - if not meshes: - self.report({'WARNING'}, t("MMDTools.no_meshes")) + try: + armature = get_active_armature(context) + + # Save initial state if enabled + if context.scene.avatar_toolkit.save_backup_state: + self.initial_state = save_armature_state(armature) + + with ProgressTracker(context, 6, "Standardizing Bones") as progress: + # Step 1: Standardize bone names + self.standardize_bone_names(armature) + progress.step("Standardized bone names") + + # Step 3: Process left/right bones + self.process_lr_bones(armature) + progress.step("Processed left/right bones") + + # Step 4: Handle name conflicts + self.resolve_name_conflicts(armature) + progress.step("Resolved naming conflicts") + + # Step 5: Process spine chain + self.process_spine_chain(armature) + progress.step("Processed spine chain") + + # Step 6: Correct bone orientations + self.correct_bone_orientations(armature) + progress.step("Corrected bone orientations") + + self.report({'INFO'}, t("MMD.bones_standardized")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Bone standardization failed: {str(e)}") + if hasattr(self, 'initial_state'): + restore_armature_state(armature, self.initial_state) + self.report({'ERROR'}, str(e)) return {'CANCELLED'} - - with ProgressTracker(context, len(meshes), "Fixing Bone Weights") as progress: - for mesh in meshes: - # Clean weights - self.clean_weights(mesh, context.scene.avatar_toolkit.clean_weights_threshold) - - # Handle twist bones - if context.scene.avatar_toolkit.merge_twist_bones: - self.process_twist_bones(mesh) - - # Remove empty groups - self.remove_empty_groups(mesh) - - # Normalize weights - self.normalize_weights(mesh) - - progress.step(f"Processed {mesh.name}") - self.report({'INFO'}, t("MMDTools.weights_fixed")) - return {'FINISHED'} - - def clean_weights(self, mesh: Object, threshold: float) -> None: - """Remove weights below threshold""" - for vertex_group in mesh.vertex_groups: - for vertex in mesh.data.vertices: - try: - weight = vertex_group.weight(vertex.index) - if weight < threshold: - vertex_group.remove([vertex.index]) - except RuntimeError: - continue - - def process_twist_bones(self, mesh: Object) -> None: - """Process and merge twist bone weights""" - twist_groups = [g for g in mesh.vertex_groups if 'twist' in g.name.lower()] - for group in twist_groups: - base_name = group.name.lower().replace('twist', '').strip('_') - for target in mesh.vertex_groups: - if target.name.lower() == base_name: - transfer_vertex_weights(mesh, group.name, target.name) - break - - def remove_empty_groups(self, mesh: Object) -> None: - """Remove vertex groups with no weights""" - empty_groups = [] - for group in mesh.vertex_groups: - has_weights = False - for vert in mesh.data.vertices: - for g in vert.groups: - if g.group == group.index and g.weight > 0: - has_weights = True - break - if has_weights: - break - if not has_weights: - empty_groups.append(group) - - for group in empty_groups: - mesh.vertex_groups.remove(group) - - def normalize_weights(self, mesh: Object) -> None: - """Normalize vertex weights""" - for vertex in mesh.data.vertices: - total_weight = sum(group.weight for group in vertex.groups) - if total_weight > 0: - for group in vertex.groups: - group.weight /= total_weight - -class AvatarToolkit_OT_FixMMDFeatures(Operator): - """Fix MMD-specific features and settings""" - bl_idname = "avatar_toolkit.fix_mmd_features" - bl_label = t("MMDTools.fix_mmd_features") - bl_description = t("MMDTools.fix_mmd_features_desc") +class AvatarToolkit_OT_ProcessMMDWeights(Operator): + bl_idname = "avatar_toolkit.mmd_process_weights" + bl_label = t("MMD.process_weights") bl_options = {'REGISTER', 'UNDO'} - @classmethod - def poll(cls, context: Context) -> bool: - armature = get_active_armature(context) - if not armature: - return False - valid, _ = validate_armature(armature) - return valid + def merge_bone_weights(self, context: Context, mesh: Object, source: str, target: str) -> None: + """Transfer weights from source bone to target bone""" + transfer_vertex_weights( + mesh, + source, + target, + context.scene.avatar_toolkit.merge_weights_threshold + ) - def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) - meshes = get_all_meshes(context) + def process_eye_weights(self, context: Context, mesh: Object) -> None: + """Handle special cases for eye bone weights""" + eye_bones = { + 'eye_l': ['eyel', 'lefteye', 'eye.l'], + 'eye_r': ['eyer', 'righteye', 'eye.r'] + } - with ProgressTracker(context, 4, "Fixing MMD Features") as progress: - # Process shape keys - for mesh in meshes: - self.process_shape_keys(mesh) - progress.step("Processed shape keys") - - # Fix MMD shading - self.fix_mmd_shading(meshes) - progress.step("Fixed MMD shading") - - # Handle physics cleanup - self.cleanup_physics(armature) - progress.step("Cleaned up physics") - - # Remove unused data - self.cleanup_unused_data(context) - progress.step("Cleaned up unused data") - - return {'FINISHED'} + for target, sources in eye_bones.items(): + for source in sources: + if source in mesh.vertex_groups: + self.merge_bone_weights(context, mesh, source, target) - def process_shape_keys(self, mesh: Object) -> None: - """Process and clean up shape keys""" - if not mesh.data.shape_keys: + def process_twist_bones(self, context: Context, mesh: Object) -> None: + """Process and merge twist bone weights""" + if not context.scene.avatar_toolkit.mmd_process_twist_bones: return - # Clean unused shape keys - remove_unused_shapekeys(mesh) + twist_pairs = [ + ('arm_twist_l', 'left_arm'), + ('arm_twist_r', 'right_arm'), + ('forearm_twist_l', 'left_elbow'), + ('forearm_twist_r', 'right_elbow') + ] - # Sort and rename shape keys - shape_keys = mesh.data.shape_keys.key_blocks - for key in shape_keys: - # Handle Japanese prefixes - if key.name.startswith('防'): - key.name = key.name[1:] - # Handle common MMD prefixes - if key.name.startswith('表情'): - key.name = key.name[2:] + for twist, target in twist_pairs: + if twist in mesh.vertex_groups: + self.merge_bone_weights(context, mesh, twist, target) - def fix_mmd_shading(self, meshes: List[Object]) -> None: - """Fix MMD material shading settings""" - for mesh in meshes: - for material in mesh.data.materials: - if material: - material.use_backface_culling = True - material.blend_method = 'HASHED' - if material.node_tree: - for node in material.node_tree.nodes: - if node.type == 'BSDF_PRINCIPLED': - node.inputs['Alpha'].default_value = 1.0 - - def cleanup_physics(self, armature: Object) -> None: - """Clean up MMD physics objects""" - physics_objects = [obj for obj in bpy.data.objects - if obj.parent == armature and - (obj.rigid_body or obj.rigid_body_constraint)] + def cleanup_vertex_groups(self, context: Context, mesh: Object) -> None: + """Remove empty and unused vertex groups""" + threshold = context.scene.avatar_toolkit.clean_weights_threshold - for obj in physics_objects: - bpy.data.objects.remove(obj, do_unlink=True) - - def cleanup_unused_data(self, context: Context) -> None: - """Clean up unused MMD data""" - # Remove unused actions - for action in bpy.data.actions: - if not action.users: - bpy.data.actions.remove(action) + # Get list of used bones from armature + armature = mesh.find_armature() + if not armature: + return + + valid_bones = set(bone.name for bone in armature.data.bones) + + # Remove unused groups + for group in mesh.vertex_groups[:]: + if group.name not in valid_bones: + mesh.vertex_groups.remove(group) + continue - # Remove empty vertex groups - for mesh in get_all_meshes(context): - self.remove_empty_groups(mesh) - - def remove_empty_groups(self, mesh: Object) -> None: - """Remove empty vertex groups""" - empty_groups = [] - for group in mesh.vertex_groups: + # Check if group has any weights above threshold has_weights = False for vert in mesh.data.vertices: - for g in vert.groups: - if g.group == group.index and g.weight > 0: - has_weights = True - break + for group_element in vert.groups: + if group_element.group == group.index: + if group_element.weight > threshold: + has_weights = True + break if has_weights: break + if not has_weights: - empty_groups.append(group) - - for group in empty_groups: - mesh.vertex_groups.remove(group) + mesh.vertex_groups.remove(group) -class AvatarToolkit_OT_AdvancedBoneOps(Operator): - """Advanced bone operations and fixes""" - bl_idname = "avatar_toolkit.advanced_bone_ops" - bl_label = t("MMDTools.advanced_bone_ops") - bl_description = t("MMDTools.advanced_bone_ops_desc") - bl_options = {'REGISTER', 'UNDO'} + def merge_remaining_weights(self, context: Context, mesh: Object) -> None: + """Process remaining weight merging cases""" + # Common MMD weight merge pairs + merge_pairs = [ + # Finger weights + ('pinky', 'pinkie'), + ('thumb0', 'thumb_0'), + ('index0', 'index_0'), + ('middle0', 'middle_0'), + ('ring0', 'ring_0'), + + # Additional arm weights + ('upperarm', 'arm'), + ('lowerarm', 'elbow'), + ('wrist', 'hand'), + + # Leg weights + ('upperleg', 'leg'), + ('lowerleg', 'knee'), + ('ankle', 'foot'), + + # Spine weights + ('spine1', 'chest'), + ('spine2', 'upper_chest'), + ] + + for source, target in merge_pairs: + for suffix in ['_l', '_r', '.l', '.r']: + source_name = f"{source}{suffix}" + target_name = f"{target}{suffix}" + if source_name in mesh.vertex_groups: + self.merge_bone_weights(context, mesh, source_name, target_name) + + @classmethod + def poll(cls, context: Context) -> bool: + """Check if there is an active armature in the scene""" + return get_active_armature(context) is not None def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) - - with ProgressTracker(context, 4, "Advanced Bone Operations") as progress: - # Fix zero length bones - self.fix_zero_length_bones(armature) - progress.step("Fixed zero length bones") + try: + meshes = get_all_meshes(context) - # Connect bones with children - self.connect_bone_chains(armature) - progress.step("Connected bone chains") + # Save initial state + if context.scene.avatar_toolkit.save_backup_state: + self.initial_states = {mesh: get_vertex_weights(mesh) for mesh in meshes} - # Handle bone roll values - self.fix_bone_rolls(armature) - progress.step("Fixed bone rolls") + with ProgressTracker(context, len(meshes) * 4, "Processing Weights") as progress: + for mesh in meshes: + # Step 1: Process eye weights + self.process_eye_weights(context, mesh) + progress.step(f"Processed eye weights for {mesh.name}") + + # Step 2: Process twist bones + self.process_twist_bones(context, mesh) + progress.step(f"Processed twist bones for {mesh.name}") + + # Step 3: Merge remaining weights + self.merge_remaining_weights(context, mesh) + progress.step(f"Merged weights for {mesh.name}") + + # Step 4: Cleanup + self.cleanup_vertex_groups(context, mesh) + progress.step(f"Cleaned up weights for {mesh.name}") + + self.report({'INFO'}, t("MMD.weights_processed")) + return {'FINISHED'} - # Fix bone orientations - self.fix_bone_orientations(armature) - progress.step("Fixed bone orientations") - - return {'FINISHED'} + except Exception as e: + logger.error(f"Weight processing failed: {str(e)}") + if hasattr(self, 'initial_states'): + for mesh, state in self.initial_states.items(): + restore_mesh_weights_state(mesh, state) + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} - def fix_zero_length_bones(self, armature: Object) -> None: - """Fix bones with zero length by extending them""" - min_length = 0.001 - for bone in armature.data.edit_bones: - length = (bone.tail - bone.head).length - if length < min_length: - if bone.parent: - bone.tail = bone.head + bone.parent.vector * 0.1 - else: - bone.tail.z = bone.head.z + 0.1 - - def connect_bone_chains(self, armature: Object) -> None: - """Connect bones that should form chains""" - min_distance = bpy.context.scene.avatar_toolkit.connect_bones_min_distance - - for bone in armature.data.edit_bones: - if len(bone.children) == 1: - child = bone.children[0] - distance = (bone.tail - child.head).length - if distance < min_distance: - child.use_connect = True - child.head = bone.tail - - def fix_bone_rolls(self, armature: Object) -> None: - """Fix bone roll values for proper orientation""" - for bone in armature.data.edit_bones: - if 'spine' in bone.name.lower() or 'chest' in bone.name.lower(): - bone.roll = 0 - elif 'shoulder' in bone.name.lower(): - bone.roll = 0 if 'left' in bone.name.lower() else np.pi - -class AvatarToolkit_OT_CleanupOperations(Operator): - """Cleanup unused data and objects""" - bl_idname = "avatar_toolkit.cleanup_operations" - bl_label = t("MMDTools.cleanup_operations") - bl_description = t("MMDTools.cleanup_operations_desc") +class AvatarToolkit_OT_FixMMDHierarchy(Operator): + bl_idname = "avatar_toolkit.mmd_fix_hierarchy" + bl_label = t("MMD.fix_hierarchy") bl_options = {'REGISTER', 'UNDO'} - def execute(self, context: Context) -> Set[str]: - armature = get_active_armature(context) + def fix_bone_parenting(self, armature: Object) -> None: + """Fix bone parenting to match standard hierarchy""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones - with ProgressTracker(context, 4, "Cleanup Operations") as progress: - # Remove rigidbodies and joints - self.remove_physics_objects(armature) - progress.step("Removed physics objects") - - # Clear unused animation data - self.clear_unused_animations(armature) - progress.step("Cleared unused animations") - - # Remove empty objects - self.remove_empty_objects() - progress.step("Removed empty objects") - - # Clean up collections - self.cleanup_collections(armature) - progress.step("Cleaned up collections") - - return {'FINISHED'} - - def remove_physics_objects(self, armature: Object) -> None: - """Remove all physics objects and constraints""" - physics_objects = [obj for obj in bpy.data.objects - if obj.parent == armature and - (obj.rigid_body or obj.rigid_body_constraint)] + # Define parent-child relationships + hierarchy_map = { + 'hips': ['spine', 'left_leg', 'right_leg'], + 'spine': ['chest'], + 'chest': ['upper_chest', 'left_shoulder', 'right_shoulder'], + 'upper_chest': ['neck'], + 'neck': ['head'], + 'head': ['left_eye', 'right_eye'], + 'left_shoulder': ['left_arm'], + 'right_shoulder': ['right_arm'], + 'left_arm': ['left_elbow'], + 'right_arm': ['right_elbow'], + 'left_elbow': ['left_wrist'], + 'right_elbow': ['right_wrist'], + 'left_leg': ['left_knee'], + 'right_leg': ['right_knee'], + 'left_knee': ['left_ankle'], + 'right_knee': ['right_ankle'], + 'left_ankle': ['left_toe'], + 'right_ankle': ['right_toe'] + } - for obj in physics_objects: - bpy.data.objects.remove(obj, do_unlink=True) + # Apply parenting + for parent_name, children in hierarchy_map.items(): + parent_bone = None + for bone in edit_bones: + if simplify_bonename(bone.name) in bone_names[parent_name]: + parent_bone = bone + break + + if parent_bone: + for child_name in children: + for bone in edit_bones: + if simplify_bonename(bone.name) in bone_names[child_name]: + bone.parent = parent_bone - def clear_unused_animations(self, armature: Object) -> None: - """Remove unused animation data""" - if armature.animation_data: - if armature.animation_data.action and armature.animation_data.action.users == 0: - bpy.data.actions.remove(armature.animation_data.action) + def connect_bones(self, context: Context, armature: Object) -> None: + """Connect bones to their children where appropriate""" + if not context.scene.avatar_toolkit.mmd_connect_bones: + return - # Clear unused NLA tracks - if armature.animation_data.nla_tracks: - for track in armature.animation_data.nla_tracks: - if not track.strips: - armature.animation_data.nla_tracks.remove(track) - - def remove_empty_objects(self) -> None: - """Remove empty objects from the scene""" - empty_objects = [obj for obj in bpy.data.objects - if obj.type == 'EMPTY' and not obj.children] + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones + min_distance = context.scene.avatar_toolkit.connect_bones_min_distance - for obj in empty_objects: - bpy.data.objects.remove(obj, do_unlink=True) + for bone in edit_bones: + if bone.children: + for child in bone.children: + # Check if bones are close enough to connect + distance = (bone.tail - child.head).length + if distance < min_distance: + bone.tail = child.head + child.use_connect = True - def cleanup_collections(self, armature: Object) -> None: - """Clean up and organize collections""" - # Remove empty collections - for collection in bpy.data.collections: - if not collection.objects and not collection.children: - bpy.data.collections.remove(collection) + def validate_hierarchy(self, armature: Object) -> bool: + """Validate final bone hierarchy""" + # Check essential parent-child relationships + essential_pairs = [ + ('spine', 'hips'), + ('chest', 'spine'), + ('neck', 'chest'), + ('head', 'neck') + ] + + for child, parent in essential_pairs: + if not validate_bone_hierarchy(armature.data.bones, parent, child): + return False - # Ensure armature is in main collection - if armature.users_collection[0] != bpy.context.scene.collection: - bpy.context.scene.collection.objects.link(armature) \ No newline at end of file + return True + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + + # Save initial state + if context.scene.avatar_toolkit.save_backup_state: + self.initial_state = save_armature_state(armature) + + with ProgressTracker(context, 3, "Fixing Bone Hierarchy") as progress: + # Step 1: Fix bone parenting + self.fix_bone_parenting(armature) + progress.step("Fixed bone parenting") + + # Step 2: Connect bones + self.connect_bones(context, armature) + progress.step("Connected bones") + + # Step 3: Validate hierarchy + if not self.validate_hierarchy(armature): + self.report({'WARNING'}, t("MMD.hierarchy_validation_warning")) + progress.step("Validated hierarchy") + + self.report({'INFO'}, t("MMD.hierarchy_fixed")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Hierarchy fix failed: {str(e)}") + if hasattr(self, 'initial_state'): + restore_armature_state(armature, self.initial_state) + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class AvatarToolkit_OT_CleanupMMDArmature(Operator): + bl_idname = "avatar_toolkit.mmd_cleanup_armature" + bl_label = t("MMD.cleanup_armature") + bl_options = {'REGISTER', 'UNDO'} + + def remove_unused_bones(self, context: Context, armature: Object) -> None: + """Remove bones that aren't in the standard hierarchy or affecting weights""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones + + # Get all bones affecting vertex groups + used_bones = set() + for mesh in get_all_meshes(context): + used_bones.update(group.name for group in mesh.vertex_groups) + + # Add essential bones from dictionary + essential_bones = set(bone_names.keys()) + + # Remove non-essential, unused bones + for bone in edit_bones[:]: # Slice to avoid modification during iteration + simplified_name = simplify_bonename(bone.name) + if (not any(simplified_name in variations for variations in bone_names.values()) and + bone.name not in used_bones): + edit_bones.remove(bone) + + def fix_bone_orientations(self, armature: Object) -> None: + """Fix bone orientations for Unity/VRChat compatibility""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = armature.data.edit_bones + + # Standard bone alignments + alignments = { + 'spine': (0, 0, 1), # Points up + 'chest': (0, 0, 1), + 'neck': (0, 0, 1), + 'head': (0, 0, 1), + 'shoulder': (1, 0, 0), # Points outward + 'arm': (0, -1, 0), # Points down + 'elbow': (0, -1, 0), + 'leg': (0, -1, 0), + 'knee': (0, -1, 0), + 'foot': (1, 0, 0), # Points forward + } + + for bone in edit_bones: + simplified_name = simplify_bonename(bone.name) + for bone_type, direction in alignments.items(): + if bone_type in simplified_name: + # Calculate new tail position while maintaining length + length = (bone.tail - bone.head).length + bone.tail = bone.head + Vector(direction) * length + break + + def execute(self, context: Context) -> Set[str]: + try: + armature = get_active_armature(context) + + # Save initial state + if context.scene.avatar_toolkit.save_backup_state: + self.initial_state = save_armature_state(armature) + + with ProgressTracker(context, 2, "Cleaning Up Armature") as progress: + # Step 1: Remove unused bones + self.remove_unused_bones(context, armature) + progress.step("Removed unused bones") + + # Step 2: Fix bone orientations + self.fix_bone_orientations(armature) + progress.step("Fixed bone orientations") + + self.report({'INFO'}, t("MMD.cleanup_completed")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Armature cleanup failed: {str(e)}") + if hasattr(self, 'initial_state'): + restore_armature_state(armature, self.initial_state) + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index d42172e..7067f0b 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -188,34 +188,24 @@ "Tools.shapekey_tolerance_desc": "Minimum difference to consider a shape key as used", "Tools.shapekeys_removed": "Removed {count} unused shape keys", - "MMDTools.label": "MMD Tools", - "MMDTools.basic_tools": "Basic MMD Tools", - "MMDTools.advanced_tools": "Advanced Tools", - "MMDTools.settings": "MMD Settings", - "MMDTools.cleanup": "Cleanup Tools", - "MMDTools.fix_bone_names": "Fix Bone Names", - "MMDTools.fix_bone_names_desc": "Standardize and fix bone names", - "MMDTools.fix_hierarchy": "Fix Bone Hierarchy", - "MMDTools.fix_hierarchy_desc": "Fix bone parenting and hierarchy", - "MMDTools.fix_weights": "Fix Bone Weights", - "MMDTools.fix_weights_desc": "Clean up and normalize bone weights", - "MMDTools.fix_mmd_features": "Fix MMD Features", - "MMDTools.fix_mmd_features_desc": "Fix MMD-specific features and settings", - "MMDTools.advanced_bone_ops": "Advanced Bone Operations", - "MMDTools.advanced_bone_ops_desc": "Perform advanced bone fixes and cleanup", - "MMDTools.keep_upper_chest": "Keep Upper Chest", - "MMDTools.keep_upper_chest_desc": "Keep the upper chest bone during cleanup", - "MMDTools.remove_unused": "Remove Unused Bones", - "MMDTools.remove_unused_desc": "Remove bones with no weights or influence", - "MMDTools.merge_distance": "Merge Distance", - "MMDTools.merge_distance_desc": "Distance threshold for merging vertices", - "MMDTools.cleanup_shapekeys": "Clean Shape Keys", - "MMDTools.cleanup_shapekeys_desc": "Remove unused and duplicate shape keys", - "MMDTools.bones_renamed": "Bone names standardized successfully", - "MMDTools.hierarchy_fixed": "Bone hierarchy fixed successfully", - "MMDTools.weights_fixed": "Bone weights cleaned and normalized", - "MMDTools.no_meshes": "No meshes found to process", - "MMDTools.not_mmd_model": "Selected armature is not an MMD model", + "MMD.label": "MMD Tools", + "MMD.bone_standardization": "Bone Standardization", + "MMD.weight_processing": "Weight Processing", + "MMD.hierarchy": "Bone Hierarchy", + "MMD.cleanup": "Cleanup", + "MMD.no_armature": "No armature selected", + "MMD.no_meshes": "No meshes found", + "MMD.validation.rigify_unsupported": "Rigify armatures are not supported", + "MMD.validation.multi_user_mesh": "Multi-user mesh detected: {mesh}", + "MMD.bones_standardized": "Bones standardized successfully", + "MMD.weights_processed": "Weights processed successfully", + "MMD.hierarchy_fixed": "Bone hierarchy fixed successfully", + "MMD.hierarchy_validation_warning": "Some hierarchy relationships could not be validated", + "MMD.cleanup_completed": "Armature cleanup completed", + "MMD.process_twist_bones": "Process Twist Bones", + "MMD.process_twist_bones_desc": "Transfer weights from twist bones to their parent bones", + "MMD.connect_bones": "Connect Bones", + "MMD.connect_bones_desc": "Connect bones in chain where appropriate", "Settings.label": "Settings", "Settings.language": "Language", diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py index 39f5821..a4b384b 100644 --- a/ui/mmd_panel.py +++ b/ui/mmd_panel.py @@ -5,48 +5,42 @@ from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t class AvatarToolKit_PT_MMDPanel(Panel): - """Panel containing MMD-specific tools and operations""" - bl_label = t("MMDTools.label") + """Panel containing MMD conversion and optimization tools""" + bl_label = t("MMD.label") bl_idname = "OBJECT_PT_avatar_toolkit_mmd" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 + bl_order = 2 def draw(self, context: Context) -> None: - """Draw the MMD tools panel interface""" - layout = self.layout + layout: UILayout = self.layout - # Basic MMD Tools Box - basic_box = layout.box() - col = basic_box.column(align=True) - col.label(text=t("MMDTools.basic_tools"), icon='ARMATURE_DATA') + # Bone Standardization Box + bone_box: UILayout = layout.box() + col: UILayout = bone_box.column(align=True) + col.label(text=t("MMD.bone_standardization"), icon='ARMATURE_DATA') col.separator(factor=0.5) - col.operator("avatar_toolkit.fix_bone_names", icon='SORTALPHA') - col.operator("avatar_toolkit.fix_bone_hierarchy", icon='BONE_DATA') - col.operator("avatar_toolkit.fix_bone_weights", icon='GROUP_BONE') + col.operator("avatar_toolkit.mmd_standardize_bones", icon='BONE_DATA') - # Advanced MMD Tools Box - advanced_box = layout.box() - col = advanced_box.column(align=True) - col.label(text=t("MMDTools.advanced_tools"), icon='MODIFIER') + # Weight Processing Box + weight_box: UILayout = layout.box() + col = weight_box.column(align=True) + col.label(text=t("MMD.weight_processing"), icon='GROUP_VERTEX') col.separator(factor=0.5) - col.operator("avatar_toolkit.fix_mmd_features", icon='SHAPEKEY_DATA') - col.operator("avatar_toolkit.advanced_bone_ops", icon='CONSTRAINT_BONE') + col.operator("avatar_toolkit.mmd_process_weights", icon='WPAINT_HLT') - # Settings Box - settings_box = layout.box() - col = settings_box.column(align=True) - col.label(text=t("MMDTools.settings"), icon='PREFERENCES') + # Hierarchy Box + hierarchy_box: UILayout = layout.box() + col = hierarchy_box.column(align=True) + col.label(text=t("MMD.hierarchy"), icon='OUTLINER') col.separator(factor=0.5) - col.prop(context.scene.avatar_toolkit, "mmd_keep_upper_chest") - col.prop(context.scene.avatar_toolkit, "mmd_remove_unused_bones") - col.prop(context.scene.avatar_toolkit, "mmd_cleanup_shapekeys") + col.operator("avatar_toolkit.mmd_fix_hierarchy", icon='CONSTRAINT_BONE') # Cleanup Box - cleanup_box = layout.box() + cleanup_box: UILayout = layout.box() col = cleanup_box.column(align=True) - col.label(text=t("MMDTools.cleanup"), icon='TRASH') + col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') col.separator(factor=0.5) - col.operator("avatar_toolkit.cleanup_operations", icon='BRUSH_DATA') + col.operator("avatar_toolkit.mmd_cleanup_armature", icon='MODIFIER') From 1e0fe403aa32e50688007043e6bfe77a2e4ec36f Mon Sep 17 00:00:00 2001 From: Yusarina Date: Fri, 13 Dec 2024 01:59:28 +0000 Subject: [PATCH 12/19] Re-do 3rd attempt I hate MMD stuff --- core/common.py | 58 --- core/properties.py | 48 +-- functions/mmd_tools.py | 907 ++++++++++++++++++++--------------------- ui/mmd_panel.py | 37 +- 4 files changed, 473 insertions(+), 577 deletions(-) diff --git a/core/common.py b/core/common.py index e5e817c..916e6ef 100644 --- a/core/common.py +++ b/core/common.py @@ -485,61 +485,3 @@ def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: removed_count += 1 return removed_count - -def save_armature_state(armature: Object) -> Dict[str, Any]: - """Save current armature state for recovery""" - state = { - 'bones': {}, - 'pose': {}, - 'settings': {} - } - - # Save bone data - for bone in armature.data.bones: - state['bones'][bone.name] = { - 'head': bone.head_local.copy(), - 'tail': bone.tail_local.copy(), - 'roll': bone.roll, - 'parent': bone.parent.name if bone.parent else None - } - - # Save pose data if exists - if armature.pose: - for bone in armature.pose.bones: - state['pose'][bone.name] = { - 'location': bone.location.copy(), - 'rotation': bone.rotation_quaternion.copy(), - 'scale': bone.scale.copy() - } - - return state - -def restore_armature_state(armature: Object, state: Dict[str, Any]) -> None: - """Restore armature from saved state""" - bpy.ops.object.mode_set(mode='EDIT') - - # Restore bones - for name, data in state['bones'].items(): - if name in armature.data.edit_bones: - bone = armature.data.edit_bones[name] - bone.head = data['head'] - bone.tail = data['tail'] - bone.roll = data['roll'] - - # Restore parenting - for name, data in state['bones'].items(): - if data['parent'] and name in armature.data.edit_bones: - bone = armature.data.edit_bones[name] - if data['parent'] in armature.data.edit_bones: - bone.parent = armature.data.edit_bones[data['parent']] - - bpy.ops.object.mode_set(mode='POSE') - - # Restore pose if exists - if 'pose' in state: - for name, data in state['pose'].items(): - if name in armature.pose.bones: - bone = armature.pose.bones[name] - bone.location = data['location'] - bone.rotation_quaternion = data['rotation'] - bone.scale = data['scale'] diff --git a/core/properties.py b/core/properties.py index 83be578..6725b26 100644 --- a/core/properties.py +++ b/core/properties.py @@ -87,53 +87,31 @@ class AvatarToolkitSceneProperties(PropertyGroup): ) merge_twist_bones: BoolProperty( - name=t("Tools.merge_twist_bones"), - description=t("Tools.merge_twist_bones_desc"), + name=t("MMD.merge_twist_bones"), + description=t("MMD.merge_twist_bones_desc"), default=True ) - clean_weights_threshold: FloatProperty( - name=t("Tools.clean_weights_threshold"), - description=t("Tools.clean_weights_threshold_desc"), - default=0.01, - min=0.0000001, - max=0.9999999 + keep_twist_bones: BoolProperty( + name=t("MMD.keep_twist_bones"), + description=t("MMD.keep_twist_bones_desc"), + default=False ) - connect_bones_min_distance: FloatProperty( - name=t("Tools.connect_bones_min_distance"), - description=t("Tools.connect_bones_min_distance_desc"), - default=0.005, - min=0.001, - max=0.1 + keep_upper_chest: BoolProperty( + name=t("MMD.keep_upper_chest"), + description=t("MMD.keep_upper_chest_desc"), + default=True ) merge_weights_threshold: FloatProperty( - name=t("Tools.merge_weights_threshold"), - description=t("Tools.merge_weights_threshold_desc"), + name=t("MMD.merge_weights_threshold"), + description=t("MMD.merge_weights_threshold_desc"), default=0.01, - min=0.0001, + min=0.0, max=1.0 ) - mmd_process_twist_bones: BoolProperty( - name=t("MMD.process_twist_bones"), - description=t("MMD.process_twist_bones_desc"), - default=True - ) - - mmd_connect_bones: BoolProperty( - name=t("MMD.connect_bones"), - description=t("MMD.connect_bones_desc"), - default=True - ) - - save_backup_state: BoolProperty( - name="Save Backup State", - description="Save the initial state of the armature before standardizing bones", - default=False - ) - def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/mmd_tools.py b/functions/mmd_tools.py index 8a85799..0ce96f3 100644 --- a/functions/mmd_tools.py +++ b/functions/mmd_tools.py @@ -1,62 +1,190 @@ import bpy -from typing import Tuple, Set, Dict -from bpy.types import Operator, Context, Object from mathutils import Vector +from typing import Dict, List, Tuple, Set, Optional +from bpy.types import Object, Armature, EditBone, Bone, Operator, Context +from ..core.logging_setup import logger from ..core.common import ( ProgressTracker, get_active_armature, - validate_meshes, - simplify_bonename, - duplicate_bone_chain, - save_armature_state, - restore_armature_state, - get_all_meshes, - validate_bone_hierarchy, - transfer_vertex_weights, - get_vertex_weights + validate_armature, + get_vertex_weights, + transfer_vertex_weights ) -from ..core.logging_setup import logger from ..core.translations import t from ..core.dictionaries import bone_names -class AvatarToolkit_OT_StandardizeMMDBones(Operator): - bl_idname = "avatar_toolkit.mmd_standardize_bones" - bl_label = t("MMD.standardize_bones") +class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): + """MMD Bone standardization system""" + bl_idname = "avatar_toolkit.standardize_mmd" + bl_label = t("MMD.standardize") bl_options = {'REGISTER', 'UNDO'} - - def standardize_bone_names(self, armature: Object) -> None: - """Standardize bone names using MMD to Unity/VRChat conventions""" - for bone in armature.data.bones: - simplified_name = simplify_bonename(bone.name) - for standard_name, variations in bone_names.items(): - if simplified_name in variations: - bone.name = standard_name - break - - def process_lr_bones(self, armature: Object) -> None: - """Process left/right bone pairs for consistency""" - for bone in armature.data.bones: - if bone.name.endswith(('_l', '_r', '.l', '.r', 'Left', 'Right')): - base_name = bone.name.rsplit('_', 1)[0] - side = '_l' if any(s in bone.name.lower() for s in ('left', '_l', '.l')) else '_r' - bone.name = f"{base_name}{side}" - - def resolve_name_conflicts(self, armature: Object) -> None: - """Handle duplicate bone names""" - used_names = set() - for bone in armature.data.bones: - base_name = bone.name - counter = 1 - while bone.name in used_names: - bone.name = f"{base_name}_{counter}" - counter += 1 - used_names.add(bone.name) - - def process_spine_chain(self, armature: Object) -> None: - """Process spine bones for VRChat compatibility""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones + + def __init__(self): + self.bone_mapping: Dict[str, str] = {} + self.processed_bones: Set[str] = set() + def execute(self, context: Context) -> Set[str]: + self.armature = get_active_armature(context) + + if not self.armature: + self.report({'ERROR'}, t("MMD.no_armature")) + return {'CANCELLED'} + + try: + with ProgressTracker(context, 5, "MMD Standardization") as progress: + # Step 1: Process bone names + self.process_bone_names(context) + progress.step("Processed bone names") + + # Step 2: Fix bone structure + self.fix_bone_structure(context) + progress.step("Fixed bone structure") + + # Step 3: Process weights + self.process_weights(context) + progress.step("Processed weights") + + # Step 4: Clean up + self.cleanup_armature(context) + progress.step("Cleaned up armature") + + # Step 5: Final validation + self.validate_results(context) + progress.step("Validated results") + + self.report({'INFO'}, t("MMD.standardization_complete")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"MMD Standardization failed: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def standardize_armature(self) -> Tuple[bool, str]: + """Main standardization process""" + if not self.armature: + return False, t("MMD.no_armature") + + try: + with ProgressTracker(self.context, 5, "MMD Standardization") as progress: + # Step 1: Process bone names + self.process_bone_names() + progress.step("Processed bone names") + + # Step 2: Fix bone structure + self.fix_bone_structure() + progress.step("Fixed bone structure") + + # Step 3: Process weights + self.process_weights() + progress.step("Processed weights") + + # Step 4: Clean up + self.cleanup_armature() + progress.step("Cleaned up armature") + + # Step 5: Final validation + self.validate_results() + progress.step("Validated results") + + return True, t("MMD.standardization_complete") + + except Exception as e: + logger.error(f"MMD Standardization failed: {str(e)}") + return False, str(e) + + def process_bone_names(self, context: Context) -> None: + """Process and standardize bone names""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = self.armature.data.edit_bones + + for bone in edit_bones: + new_name = self.standardize_bone_name(bone.name) + if new_name != bone.name: + self.bone_mapping[bone.name] = new_name + bone.name = new_name + + def translate_japanese_bone_name(self, name: str) -> str: + """Translate Japanese bone names to English standardized names""" + from ..core.dictionaries import bone_names + + # Convert to lowercase for matching + name_lower = name.lower() + + # Check each bone category for Japanese character matches + for bone_category, variations in bone_names.items(): + for variation in variations: + if variation in name_lower: + # If Japanese characters are found, return the standardized name + return bone_category + + # If no match found, return original name + return name + + def standardize_bone_name(self, name: str) -> str: + """Standardize individual bone names""" + # First translate Japanese names + result = self.translate_japanese_bone_name(name) + + # Remove common prefixes + prefixes = ['ValveBiped_', 'Bip01_', 'MMD_', 'Armature|'] + for prefix in prefixes: + if result.lower().startswith(prefix.lower()): + result = result[len(prefix):] + + # Handle left/right conventions + if result.endswith('_L') or result.endswith('.L'): + result = f"{result[:-2]}.L" + elif result.endswith('_R') or result.endswith('.R'): + result = f"{result[:-2]}.R" + + return result + + def fix_bone_structure(self, context: Context) -> None: + """Fix bone hierarchy and orientations""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = self.armature.data.edit_bones + + # Process spine hierarchy + self.process_spine_chain(context) + + # Fix bone orientations + self.fix_bone_orientations(context) + + # Connect appropriate bones + self.connect_bones(context) + + def process_weights(self, context: Context) -> None: + """Process and clean up vertex weights""" + for mesh in self.get_associated_meshes(context): + # Transfer weights based on bone mapping + for old_name, new_name in self.bone_mapping.items(): + if old_name != new_name: + transfer_vertex_weights(mesh, old_name, new_name) + + # Clean up zero weights + self.cleanup_vertex_groups(mesh, context) + + def cleanup_armature(self, context: Context) -> None: + """Perform final cleanup operations""" + # Remove unused bones + self.remove_unused_bones(context) + + # Clean up constraints + self.cleanup_constraints(context) + + # Fix zero-length bones + self.fix_zero_length_bones(context) + + def get_associated_meshes(self, context: Context) -> List[Object]: + """Get all mesh objects associated with the armature""" + return [obj for obj in bpy.data.objects + if obj.type == 'MESH' + and obj.parent == self.armature] + + def process_spine_chain(self, context: Context) -> None: + """Process and fix spine bone chain hierarchy""" + edit_bones = self.armature.data.edit_bones spine_bones = { 'hips': None, 'spine': None, @@ -66,437 +194,308 @@ class AvatarToolkit_OT_StandardizeMMDBones(Operator): 'head': None } - # Map existing spine bones + # Find spine bones using bone_names dictionary for bone in edit_bones: - simplified = simplify_bonename(bone.name) - for spine_name in spine_bones.keys(): - if simplified in bone_names[spine_name]: - spine_bones[spine_name] = bone + for spine_part, _ in spine_bones.items(): + if any(alt_name in bone.name.lower() for alt_name in bone_names[spine_part]): + spine_bones[spine_part] = bone break - # Create missing spine bones - if spine_bones['spine'] and not spine_bones['chest']: - chest = edit_bones.new('chest') - chest.head = spine_bones['spine'].tail - chest.tail = spine_bones['neck'].head if spine_bones['neck'] else spine_bones['head'].head - spine_bones['chest'] = chest - # Set up spine hierarchy - if spine_bones['hips']: - for i, key in enumerate(['spine', 'chest', 'upper_chest', 'neck', 'head']): - if spine_bones[key]: - prev_key = list(spine_bones.keys())[i] - if spine_bones[prev_key]: - spine_bones[key].parent = spine_bones[prev_key] - - def correct_bone_orientations(self, armature: Object) -> None: - """Automatically correct bone orientations to align with Unity's axes""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones - - # Define standard orientations - orientations = { - 'spine': Vector((0, 0, 1)), # Points up - 'chest': Vector((0, 0, 1)), - 'neck': Vector((0, 0, 1)), - 'head': Vector((0, 0, 1)), - 'shoulder': Vector((1, 0, 0)), # Points outward - 'arm': Vector((0, -1, 0)), # Points down - 'elbow': Vector((0, -1, 0)), - 'leg': Vector((0, -1, 0)), - 'knee': Vector((0, -1, 0)), - 'foot': Vector((1, 0, 0)), # Points forward - } - - for bone in edit_bones: - simplified_name = simplify_bonename(bone.name) - for bone_type, direction in orientations.items(): - if bone_type in simplified_name: - # Calculate new tail position while maintaining length - length = (bone.tail - bone.head).length - bone.tail = bone.head + direction * length - break - - @classmethod - def poll(cls, context: Context) -> bool: - """Check if there is an active armature in the scene""" - return get_active_armature(context) is not None - - def execute(self, context: Context) -> Set[str]: - try: - armature = get_active_armature(context) - - # Save initial state if enabled - if context.scene.avatar_toolkit.save_backup_state: - self.initial_state = save_armature_state(armature) - - with ProgressTracker(context, 6, "Standardizing Bones") as progress: - # Step 1: Standardize bone names - self.standardize_bone_names(armature) - progress.step("Standardized bone names") - - # Step 3: Process left/right bones - self.process_lr_bones(armature) - progress.step("Processed left/right bones") - - # Step 4: Handle name conflicts - self.resolve_name_conflicts(armature) - progress.step("Resolved naming conflicts") - - # Step 5: Process spine chain - self.process_spine_chain(armature) - progress.step("Processed spine chain") - - # Step 6: Correct bone orientations - self.correct_bone_orientations(armature) - progress.step("Corrected bone orientations") - - self.report({'INFO'}, t("MMD.bones_standardized")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Bone standardization failed: {str(e)}") - if hasattr(self, 'initial_state'): - restore_armature_state(armature, self.initial_state) - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - -class AvatarToolkit_OT_ProcessMMDWeights(Operator): - bl_idname = "avatar_toolkit.mmd_process_weights" - bl_label = t("MMD.process_weights") - bl_options = {'REGISTER', 'UNDO'} - - def merge_bone_weights(self, context: Context, mesh: Object, source: str, target: str) -> None: - """Transfer weights from source bone to target bone""" - transfer_vertex_weights( - mesh, - source, - target, - context.scene.avatar_toolkit.merge_weights_threshold - ) - - def process_eye_weights(self, context: Context, mesh: Object) -> None: - """Handle special cases for eye bone weights""" - eye_bones = { - 'eye_l': ['eyel', 'lefteye', 'eye.l'], - 'eye_r': ['eyer', 'righteye', 'eye.r'] - } - - for target, sources in eye_bones.items(): - for source in sources: - if source in mesh.vertex_groups: - self.merge_bone_weights(context, mesh, source, target) - - def process_twist_bones(self, context: Context, mesh: Object) -> None: - """Process and merge twist bone weights""" - if not context.scene.avatar_toolkit.mmd_process_twist_bones: - return - - twist_pairs = [ - ('arm_twist_l', 'left_arm'), - ('arm_twist_r', 'right_arm'), - ('forearm_twist_l', 'left_elbow'), - ('forearm_twist_r', 'right_elbow') + hierarchy = [ + ('hips', 'spine'), + ('spine', 'chest'), + ('chest', 'neck'), + ('neck', 'head') ] - for twist, target in twist_pairs: - if twist in mesh.vertex_groups: - self.merge_bone_weights(context, mesh, twist, target) + for parent_name, child_name in hierarchy: + parent = spine_bones.get(parent_name) + child = spine_bones.get(child_name) + if parent and child: + child.parent = parent + child.use_connect = True - def cleanup_vertex_groups(self, context: Context, mesh: Object) -> None: - """Remove empty and unused vertex groups""" - threshold = context.scene.avatar_toolkit.clean_weights_threshold + def fix_bone_orientations(self, context: Context) -> None: + """Fix bone orientations for standard pose compatibility""" + edit_bones = self.armature.data.edit_bones - # Get list of used bones from armature - armature = mesh.find_armature() - if not armature: - return - - valid_bones = set(bone.name for bone in armature.data.bones) + # Process arm bones + arm_pairs = [ + ('upper_arm', 'forearm'), + ('forearm', 'hand') + ] - # Remove unused groups - for group in mesh.vertex_groups[:]: - if group.name not in valid_bones: - mesh.vertex_groups.remove(group) + for side in ['.L', '.R']: + for parent, child in arm_pairs: + parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None) + child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None) + + if parent_bone and child_bone: + child_bone.use_connect = True + child_bone.use_inherit_rotation = True + + # Process leg bones + leg_pairs = [ + ('thigh', 'shin'), + ('shin', 'foot') + ] + + for side in ['.L', '.R']: + for parent, child in leg_pairs: + parent_bone = next((b for b in edit_bones if b.name.lower().startswith(parent) and b.name.endswith(side)), None) + child_bone = next((b for b in edit_bones if b.name.lower().startswith(child) and b.name.endswith(side)), None) + + if parent_bone and child_bone: + child_bone.use_connect = True + child_bone.use_inherit_rotation = True + + def remove_unused_bones(self, context: Context) -> None: + """Remove unused and unnecessary bones from the armature""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = self.armature.data.edit_bones + + # Get list of bones that have vertex weights + used_bones = set() + for mesh in self.get_associated_meshes(context): + for group in mesh.vertex_groups: + used_bones.add(group.name) + + # Get list of bones to keep based on settings + toolkit = context.scene.avatar_toolkit + keep_upper_chest = toolkit.keep_upper_chest + keep_twist = toolkit.keep_twist_bones + + # Remove unused bones + for bone in edit_bones: + # Skip if bone has weights + if bone.name in used_bones: continue - # Check if group has any weights above threshold - has_weights = False - for vert in mesh.data.vertices: - for group_element in vert.groups: - if group_element.group == group.index: - if group_element.weight > threshold: - has_weights = True - break - if has_weights: - break - - if not has_weights: - mesh.vertex_groups.remove(group) + # Skip if bone is upper chest and we want to keep it + if 'upper_chest' in bone.name.lower() and keep_upper_chest: + continue + + # Skip if bone is twist bone and we want to keep them + if 'twist' in bone.name.lower() and keep_twist: + continue + + # Remove the bone + edit_bones.remove(bone) - def merge_remaining_weights(self, context: Context, mesh: Object) -> None: - """Process remaining weight merging cases""" - # Common MMD weight merge pairs - merge_pairs = [ - # Finger weights - ('pinky', 'pinkie'), - ('thumb0', 'thumb_0'), - ('index0', 'index_0'), - ('middle0', 'middle_0'), - ('ring0', 'ring_0'), - - # Additional arm weights - ('upperarm', 'arm'), - ('lowerarm', 'elbow'), - ('wrist', 'hand'), - - # Leg weights - ('upperleg', 'leg'), - ('lowerleg', 'knee'), - ('ankle', 'foot'), - - # Spine weights - ('spine1', 'chest'), - ('spine2', 'upper_chest'), + def connect_bones(self, context: Context) -> None: + """Connect bones that should be connected in the hierarchy""" + edit_bones = self.armature.data.edit_bones + + connect_chains = [ + ['hips', 'spine', 'chest', 'neck', 'head'], + ['shoulder.L', 'upper_arm.L', 'forearm.L', 'hand.L'], + ['shoulder.R', 'upper_arm.R', 'forearm.R', 'hand.R'], + ['thigh.L', 'shin.L', 'foot.L', 'toe.L'], + ['thigh.R', 'shin.R', 'foot.R', 'toe.R'] ] - for source, target in merge_pairs: - for suffix in ['_l', '_r', '.l', '.r']: - source_name = f"{source}{suffix}" - target_name = f"{target}{suffix}" - if source_name in mesh.vertex_groups: - self.merge_bone_weights(context, mesh, source_name, target_name) + for chain in connect_chains: + prev_bone = None + for bone_name in chain: + bone = next((b for b in edit_bones if b.name.lower().endswith(bone_name.lower())), None) + if bone and prev_bone: + bone.parent = prev_bone + bone.use_connect = True + prev_bone = bone + + def cleanup_vertex_groups(self, mesh_obj: Object, context: Context) -> None: + """Clean up vertex groups by removing zero weights and merging similar groups""" + threshold = context.scene.avatar_toolkit.merge_weights_threshold + + # Get list of vertex groups + vertex_groups = mesh_obj.vertex_groups + + # Track groups to remove + groups_to_remove = set() + + # Check each vertex group + for group in vertex_groups: + weights = get_vertex_weights(mesh_obj, group.name) + + # If no weights above threshold, mark for removal + if not any(weight > threshold for weight in weights.values()): + groups_to_remove.add(group.name) + + # Remove empty groups + for group_name in groups_to_remove: + group = vertex_groups.get(group_name) + if group: + vertex_groups.remove(group) + + def validate_results(self, context: Context) -> None: + """Validate the results of standardization""" + valid, messages = validate_armature(self.armature) + if not valid: + raise ValueError("\n".join(messages)) + + def cleanup_constraints(self, context: Context) -> None: + """Clean up and fix bone constraints""" + bpy.ops.object.mode_set(mode='POSE') + + # Process each pose bone + for pose_bone in self.armature.pose.bones: + constraints_to_remove = [] + + for constraint in pose_bone.constraints: + should_remove = False + + # Handle IK constraints + if constraint.type == 'IK': + if not constraint.target or constraint.target != self.armature: + should_remove = True + elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: + should_remove = True + + # Handle MMD additional rotation constraints + elif constraint.name == 'mmd_additional_rotation': + if not constraint.target or constraint.target != self.armature: + should_remove = True + elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: + should_remove = True + + # Handle transformation constraints + elif constraint.type in {'COPY_ROTATION', 'COPY_LOCATION', 'COPY_TRANSFORMS'}: + if not constraint.target or constraint.target != self.armature: + should_remove = True + elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: + should_remove = True + + if should_remove: + constraints_to_remove.append(constraint) + + # Remove invalid constraints + for constraint in constraints_to_remove: + pose_bone.constraints.remove(constraint) + + def fix_zero_length_bones(self, context: Context) -> None: + """Fix zero-length bones by setting minimal length""" + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = self.armature.data.edit_bones + + min_length = 0.01 # Minimum bone length in Blender units + + for bone in edit_bones: + # Calculate bone length + bone_length = (bone.tail - bone.head).length + + if bone_length < min_length: + # Set minimal length while preserving direction + if bone.parent: + # Use parent's orientation as reference + direction = bone.parent.tail - bone.parent.head + direction.normalize() + else: + # Default to Z-axis if no parent + direction = mathutils.Vector((0, 0, 1)) + + bone.tail = bone.head + (direction * min_length) + +class FixUnmovableBonesOperator(bpy.types.Operator): + bl_idname = "avatar_toolkit.fix_unmovable_bones" + bl_label = t("MMD.fix_unmovable_bones") + bl_description = t("MMD.fix_unmovable_bones_desc") + bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context: Context) -> bool: - """Check if there is an active armature in the scene""" - return get_active_armature(context) is not None + def poll(cls, context): + armature = get_active_armature(context) + return armature is not None and armature.type == 'ARMATURE' + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + self.report({'ERROR'}, t("MMD.no_armature")) + return {'CANCELLED'} - def execute(self, context: Context) -> Set[str]: try: - meshes = get_all_meshes(context) - - # Save initial state - if context.scene.avatar_toolkit.save_backup_state: - self.initial_states = {mesh: get_vertex_weights(mesh) for mesh in meshes} - - with ProgressTracker(context, len(meshes) * 4, "Processing Weights") as progress: + with ProgressTracker(context, 2, "Unlocking Transforms") as progress: + # Unlock armature transforms + progress.step("Unlocking armature transforms") + for attr in ('location', 'rotation', 'scale'): + for i in range(3): + setattr(armature, f"lock_{attr}", [False] * 3) + + # Unlock bone transforms + progress.step("Unlocking bone transforms") + for bone in armature.pose.bones: + for attr in ('location', 'rotation', 'scale'): + setattr(bone, f"lock_{attr}", [False] * 3) + + self.report({'INFO'}, t("MMD.transforms_unlocked")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error unlocking transforms: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +class ReparentMeshesOperator(bpy.types.Operator): + bl_idname = "avatar_toolkit.reparent_meshes" + bl_label = t("MMD.reparent_meshes") + bl_description = t("MMD.reparent_meshes_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature is not None and get_all_meshes(context) + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + self.report({'ERROR'}, t("MMD.no_armature")) + return {'CANCELLED'} + + meshes = get_all_meshes(context) + if not meshes: + self.report({'ERROR'}, t("MMD.no_meshes")) + return {'CANCELLED'} + + try: + with ProgressTracker(context, len(meshes) + 1, "Reparenting Meshes") as progress: + # Get or create main collection + main_collection = self._get_main_collection(context) + progress.step("Setting up collections") + + # Process each mesh for mesh in meshes: - # Step 1: Process eye weights - self.process_eye_weights(context, mesh) - progress.step(f"Processed eye weights for {mesh.name}") - - # Step 2: Process twist bones - self.process_twist_bones(context, mesh) - progress.step(f"Processed twist bones for {mesh.name}") - - # Step 3: Merge remaining weights - self.merge_remaining_weights(context, mesh) - progress.step(f"Merged weights for {mesh.name}") - - # Step 4: Cleanup - self.cleanup_vertex_groups(context, mesh) - progress.step(f"Cleaned up weights for {mesh.name}") + progress.step(f"Processing {mesh.name}") + self._process_mesh(mesh, armature, main_collection) - self.report({'INFO'}, t("MMD.weights_processed")) + self.report({'INFO'}, t("MMD.reparenting_complete")) return {'FINISHED'} - + except Exception as e: - logger.error(f"Weight processing failed: {str(e)}") - if hasattr(self, 'initial_states'): - for mesh, state in self.initial_states.items(): - restore_mesh_weights_state(mesh, state) + logger.error(f"Error reparenting meshes: {str(e)}") self.report({'ERROR'}, str(e)) return {'CANCELLED'} -class AvatarToolkit_OT_FixMMDHierarchy(Operator): - bl_idname = "avatar_toolkit.mmd_fix_hierarchy" - bl_label = t("MMD.fix_hierarchy") - bl_options = {'REGISTER', 'UNDO'} + def _get_main_collection(self, context) -> bpy.types.Collection: + """Get or create the main collection for the armature""" + if hasattr(context.scene, 'collection'): + return context.scene.collection + return context.scene.collection - def fix_bone_parenting(self, armature: Object) -> None: - """Fix bone parenting to match standard hierarchy""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones - - # Define parent-child relationships - hierarchy_map = { - 'hips': ['spine', 'left_leg', 'right_leg'], - 'spine': ['chest'], - 'chest': ['upper_chest', 'left_shoulder', 'right_shoulder'], - 'upper_chest': ['neck'], - 'neck': ['head'], - 'head': ['left_eye', 'right_eye'], - 'left_shoulder': ['left_arm'], - 'right_shoulder': ['right_arm'], - 'left_arm': ['left_elbow'], - 'right_arm': ['right_elbow'], - 'left_elbow': ['left_wrist'], - 'right_elbow': ['right_wrist'], - 'left_leg': ['left_knee'], - 'right_leg': ['right_knee'], - 'left_knee': ['left_ankle'], - 'right_knee': ['right_ankle'], - 'left_ankle': ['left_toe'], - 'right_ankle': ['right_toe'] - } - - # Apply parenting - for parent_name, children in hierarchy_map.items(): - parent_bone = None - for bone in edit_bones: - if simplify_bonename(bone.name) in bone_names[parent_name]: - parent_bone = bone - break - - if parent_bone: - for child_name in children: - for bone in edit_bones: - if simplify_bonename(bone.name) in bone_names[child_name]: - bone.parent = parent_bone + def _process_mesh(self, mesh: bpy.types.Object, + armature: bpy.types.Object, + main_collection: bpy.types.Collection) -> None: + """Process individual mesh parenting and collection management""" + # Unlink from other collections + for col in mesh.users_collection: + if col != main_collection: + col.objects.unlink(mesh) - def connect_bones(self, context: Context, armature: Object) -> None: - """Connect bones to their children where appropriate""" - if not context.scene.avatar_toolkit.mmd_connect_bones: - return - - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones - min_distance = context.scene.avatar_toolkit.connect_bones_min_distance - - for bone in edit_bones: - if bone.children: - for child in bone.children: - # Check if bones are close enough to connect - distance = (bone.tail - child.head).length - if distance < min_distance: - bone.tail = child.head - child.use_connect = True + # Ensure mesh is in main collection + if mesh.name not in main_collection.objects: + main_collection.objects.link(mesh) - def validate_hierarchy(self, armature: Object) -> bool: - """Validate final bone hierarchy""" - # Check essential parent-child relationships - essential_pairs = [ - ('spine', 'hips'), - ('chest', 'spine'), - ('neck', 'chest'), - ('head', 'neck') - ] - - for child, parent in essential_pairs: - if not validate_bone_hierarchy(armature.data.bones, parent, child): - return False - - return True - - def execute(self, context: Context) -> Set[str]: - try: - armature = get_active_armature(context) - - # Save initial state - if context.scene.avatar_toolkit.save_backup_state: - self.initial_state = save_armature_state(armature) - - with ProgressTracker(context, 3, "Fixing Bone Hierarchy") as progress: - # Step 1: Fix bone parenting - self.fix_bone_parenting(armature) - progress.step("Fixed bone parenting") - - # Step 2: Connect bones - self.connect_bones(context, armature) - progress.step("Connected bones") - - # Step 3: Validate hierarchy - if not self.validate_hierarchy(armature): - self.report({'WARNING'}, t("MMD.hierarchy_validation_warning")) - progress.step("Validated hierarchy") - - self.report({'INFO'}, t("MMD.hierarchy_fixed")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Hierarchy fix failed: {str(e)}") - if hasattr(self, 'initial_state'): - restore_armature_state(armature, self.initial_state) - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} - -class AvatarToolkit_OT_CleanupMMDArmature(Operator): - bl_idname = "avatar_toolkit.mmd_cleanup_armature" - bl_label = t("MMD.cleanup_armature") - bl_options = {'REGISTER', 'UNDO'} - - def remove_unused_bones(self, context: Context, armature: Object) -> None: - """Remove bones that aren't in the standard hierarchy or affecting weights""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones - - # Get all bones affecting vertex groups - used_bones = set() - for mesh in get_all_meshes(context): - used_bones.update(group.name for group in mesh.vertex_groups) - - # Add essential bones from dictionary - essential_bones = set(bone_names.keys()) - - # Remove non-essential, unused bones - for bone in edit_bones[:]: # Slice to avoid modification during iteration - simplified_name = simplify_bonename(bone.name) - if (not any(simplified_name in variations for variations in bone_names.values()) and - bone.name not in used_bones): - edit_bones.remove(bone) - - def fix_bone_orientations(self, armature: Object) -> None: - """Fix bone orientations for Unity/VRChat compatibility""" - bpy.ops.object.mode_set(mode='EDIT') - edit_bones = armature.data.edit_bones - - # Standard bone alignments - alignments = { - 'spine': (0, 0, 1), # Points up - 'chest': (0, 0, 1), - 'neck': (0, 0, 1), - 'head': (0, 0, 1), - 'shoulder': (1, 0, 0), # Points outward - 'arm': (0, -1, 0), # Points down - 'elbow': (0, -1, 0), - 'leg': (0, -1, 0), - 'knee': (0, -1, 0), - 'foot': (1, 0, 0), # Points forward - } - - for bone in edit_bones: - simplified_name = simplify_bonename(bone.name) - for bone_type, direction in alignments.items(): - if bone_type in simplified_name: - # Calculate new tail position while maintaining length - length = (bone.tail - bone.head).length - bone.tail = bone.head + Vector(direction) * length - break - - def execute(self, context: Context) -> Set[str]: - try: - armature = get_active_armature(context) - - # Save initial state - if context.scene.avatar_toolkit.save_backup_state: - self.initial_state = save_armature_state(armature) - - with ProgressTracker(context, 2, "Cleaning Up Armature") as progress: - # Step 1: Remove unused bones - self.remove_unused_bones(context, armature) - progress.step("Removed unused bones") - - # Step 2: Fix bone orientations - self.fix_bone_orientations(armature) - progress.step("Fixed bone orientations") - - self.report({'INFO'}, t("MMD.cleanup_completed")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Armature cleanup failed: {str(e)}") - if hasattr(self, 'initial_state'): - restore_armature_state(armature, self.initial_state) - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} + # Set parent to armature + mesh.parent = armature + if not mesh.parent_type == 'ARMATURE': + mesh.parent_type = 'ARMATURE' \ No newline at end of file diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py index a4b384b..4232bfb 100644 --- a/ui/mmd_panel.py +++ b/ui/mmd_panel.py @@ -1,46 +1,23 @@ import bpy from typing import Set -from bpy.types import Panel, Context, UILayout, Operator +from bpy.types import Panel, Context, UILayout from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t class AvatarToolKit_PT_MMDPanel(Panel): - """Panel containing MMD conversion and optimization tools""" + """Panel containing MMD bone standardization tools""" bl_label = t("MMD.label") bl_idname = "OBJECT_PT_avatar_toolkit_mmd" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 2 + bl_order = 3 def draw(self, context: Context) -> None: layout: UILayout = self.layout + toolkit = context.scene.avatar_toolkit - # Bone Standardization Box - bone_box: UILayout = layout.box() - col: UILayout = bone_box.column(align=True) - col.label(text=t("MMD.bone_standardization"), icon='ARMATURE_DATA') - col.separator(factor=0.5) - col.operator("avatar_toolkit.mmd_standardize_bones", icon='BONE_DATA') - - # Weight Processing Box - weight_box: UILayout = layout.box() - col = weight_box.column(align=True) - col.label(text=t("MMD.weight_processing"), icon='GROUP_VERTEX') - col.separator(factor=0.5) - col.operator("avatar_toolkit.mmd_process_weights", icon='WPAINT_HLT') - - # Hierarchy Box - hierarchy_box: UILayout = layout.box() - col = hierarchy_box.column(align=True) - col.label(text=t("MMD.hierarchy"), icon='OUTLINER') - col.separator(factor=0.5) - col.operator("avatar_toolkit.mmd_fix_hierarchy", icon='CONSTRAINT_BONE') - - # Cleanup Box - cleanup_box: UILayout = layout.box() - col = cleanup_box.column(align=True) - col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') - col.separator(factor=0.5) - col.operator("avatar_toolkit.mmd_cleanup_armature", icon='MODIFIER') + # Add merge twist bones option + layout.prop(toolkit, "keep_twist_bones") + layout.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') From f4dc74d091558ce1cb9cf15b2d1e161d4e457256 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sat, 14 Dec 2024 01:10:24 +0000 Subject: [PATCH 13/19] Fixes --- core/dictionaries.py | 310 +++++++++++++---- functions/mmd_tools.py | 603 +++++++++++++++++++++++++--------- functions/tools/bone_tools.py | 2 +- ui/mmd_panel.py | 31 +- 4 files changed, 720 insertions(+), 226 deletions(-) diff --git a/core/dictionaries.py b/core/dictionaries.py index 4ec0b07..9a54b05 100644 --- a/core/dictionaries.py +++ b/core/dictionaries.py @@ -5,72 +5,252 @@ # Note from @989onan: Please make sure to make your names are lowercase in this array. I banged my head metaphorically till I figured that out... # Taken from Tuxedo/Cats bone_names = { - "right_shoulder": ["rightshoulder", "shoulderr", "rshoulder", "valvebipedbip01rclavicle", "右肩"], - "right_arm": ["rightarm", "armr", "rarm", "upperarmr", "rupperarm", "rightupperarm", "uparmr", "ruparm", "valvebipedbip01rupperarm", "右腕"], - "right_elbow": ["rightelbow", "elbowr", "relbow", "lowerarmr", "rightlowerarm", "lowerarmr", "rlowerarm", "lowarmr", "rlowarm", "forearmr", "rforearm", "valvebipedbip01rforearm", "右ひじ"], - "right_wrist": ["rightwrist", "wristr", "rwrist", "handr", "righthand", "rhand", "valvebipedbip01rhand", "右手首"], - "pinkie_0_r": ["littlefinger0r", "pinkie0r", "rpinkie0", "pinkiemetacarpalr", "右小指0"], - "pinkie_1_r": ["littlefinger1r", "pinkie1r", "rpinkie1", "pinkieproximalr", "valvebipedbip01rfinger4", "右小指1"], - "pinkie_2_r": ["littlefinger2r", "pinkie2r", "rpinkie2", "pinkieintermediater", "valvebipedbip01rfinger41", "右小指2"], - "pinkie_3_r": ["littlefinger3r", "pinkie3r", "rpinkie3", "pinkiedistalr", "valvebipedbip01rfinger42", "右小指3"], - "ring_0_r": ["ringfinger0r", "ring0r", "rring0", "ringmetacarpalr", "右薬指0"], - "ring_1_r": ["ringfinger1r", "ring1r", "rring1", "ringproximalr", "valvebipedbip01rfinger3", "右薬指1"], - "ring_2_r": ["ringfinger2r", "ring2r", "rring2", "ringintermediater", "valvebipedbip01rfinger31", "右薬指2"], - "ring_3_r": ["ringfinger3r", "ring3r", "rring3", "ringdistalr", "valvebipedbip01rfinger32", "右薬指3"], - "middle_0_r": ["middlefinger0r", "middle0r", "rmiddle0", "middlemetacarpalr", "右中指0"], - "middle_1_r": ["middlefinger1r", "middle1r", "rmiddle1", "middleproximalr", "valvebipedbip01rfinger2", "右中指1"], - "middle_2_r": ["middlefinger2r", "middle2r", "rmiddle2", "middleintermediater", "valvebipedbip01rfinger21", "右中指2"], - "middle_3_r": ["middlefinger3r", "middle3r", "rmiddle3", "middledistalr", "valvebipedbip01rfinger22", "右中指3"], - "index_0_r": ["indexfinger0r", "index0r", "rindex0", "indexmetacarpalr", "右人差指0"], - "index_1_r": ["indexfinger1r", "index1r", "rindex1", "indexproximalr", "valvebipedbip01rfinger1", "右人差指1"], - "index_2_r": ["indexfinger2r", "index2r", "rindex2", "indexintermediater", "valvebipedbip01rfinger11", "右人差指2"], - "index_3_r": ["indexfinger3r", "index3r", "rindex3", "indexdistalr", "valvebipedbip01rfinger12", "右人差指3"], - "thumb_0_r": ["thumb0r", "rthumb0", "thumbmetacarpalr", "右親指0"], - "thumb_1_r": ["thumb1r", "rthumb1", "thumbproximalr", "valvebipedbip01rfinger0", "右親指1"], - "thumb_2_r": ["thumb2r", "rthumb2", "thumbintermediater", "valvebipedbip01rfinger01", "右親指2"], - "thumb_3_r": ["thumb3r", "rthumb3", "thumbdistalr", "valvebipedbip01rfinger02", "右親指3"], - "right_leg": ["rightleg", "legr", "rleg", "upperlegr", "rupperleg", "thighr", "rightupperleg", "uplegr", "rupleg", "valvebipedbip01rthigh", "右足"], - "right_knee": ["rightknee", "kneer", "rknee", "lowerlegr", "calfr", "rlowerleg", "rcalf", "rightlowerleg", "lowlegr", "rlowleg", "valvebipedbip01rcalf", "右ひざ"], - "right_ankle": ["rightankle", "ankler", "rankle", "rightfoot", "footr", "rfoot", "rightfoot", "rightfeet", "feetright", "rfeet", "feetr", "valvebipedbip01rfoot", "右足首"], - "right_toe": ["righttoe", "toeright", "toer", "rtoe", "toesr", "rtoes", "valvebipedbip01rtoe0", "右つま先"], - "left_shoulder": ["leftshoulder", "shoulderl", "lshoulder", "valvebipedbip01lclavicle", "左肩"], - "left_arm": ["leftarm", "arml", "larm", "upperarml", "lupperarm", "leftupperarm", "uparml", "luparm", "valvebipedbip01lupperarm", "左腕"], - "left_elbow": ["leftelbow", "elbowl", "lelbow", "lowerarml", "leftlowerarm", "lowerarml", "llowerarm", "lowarml", "llowarm", "forearml", "lforearm", "valvebipedbip01lforearm", "左ひじ"], - "left_wrist": ["leftwrist", "wristl", "lwrist", "handl", "lefthand", "lhand", "valvebipedbip01lhand", "左手首"], - "pinkie_0_l": ["pinkiefinger0l", "pinkie0l", "lpinkie0", "pinkiemetacarpall", "左小指0"], - "pinkie_1_l": ["littlefinger1l", "pinkie1l", "lpinkie1", "pinkieproximall", "valvebipedbip01lfinger4", "左小指1"], - "pinkie_2_l": ["littlefinger2l", "pinkie2l", "lpinkie2", "pinkieintermediatel", "valvebipedbip01lfinger41", "左小指2"], - "pinkie_3_l": ["littlefinger3l", "pinkie3l", "lpinkie3", "pinkiedistall", "valvebipedbip01lfinger42", "左小指3"], - "ring_0_l": ["ringfinger0l", "ring0l", "lring0", "ringmetacarpall", "左薬指0"], - "ring_1_l": ["ringfinger1l", "ring1l", "lring1", "ringproximall", "valvebipedbip01lfinger3", "左薬指1"], - "ring_2_l": ["ringfinger2l", "ring2l", "lring2", "ringintermediatel", "valvebipedbip01lfinger31", "左薬指2"], - "ring_3_l": ["ringfinger3l", "ring3l", "lring3", "ringdistall", "valvebipedbip01lfinger32", "左薬指3"], - "middle_0_l": ["middlefinger0l", "middle_0l", "lmiddle0", "middlemetacarpall", "左中指0"], - "middle_1_l": ["middlefinger1l", "middle_1l", "lmiddle1", "middleproximall", "valvebipedbip01lfinger2", "左中指1"], - "middle_2_l": ["middlefinger2l", "middle_2l", "lmiddle2", "middleintermediatel", "valvebipedbip01lfinger21", "左中指2"], - "middle_3_l": ["middlefinger3l", "middle_3l", "lmiddle3", "middledistall", "valvebipedbip01lfinger22", "左中指3"], - "index_0_l": ["indexfinger0l", "index0l", "lindex0", "indexmetacarpall", "左人差指0"], - "index_1_l": ["indexfinger1l", "index1l", "lindex1", "indexproximall", "valvebipedbip01lfinger1", "左人差指1"], - "index_2_l": ["indexfinger2l", "index2l", "lindex2", "indexintermediatel", "valvebipedbip01lfinger11", "左人差指2"], - "index_3_l": ["indexfinger3l", "index3l", "lindex3", "indexdistall", "valvebipedbip01lfinger12", "左人差指3"], - "thumb_0_l": ["thumb0l", "lthumb0", "thumbmetacarpall", "左親指0"], - "thumb_1_l": ["thumb1l", "lthumb1", "thumbproximall", "valvebipedbip01lfinger0", "左親指1"], - "thumb_2_l": ["thumb2l", "lthumb2", "thumbintermediatel", "valvebipedbip01lfinger01", "左親指2"], - "thumb_3_l": ["thumb3l", "lthumb3", "thumbdistall", "valvebipedbip01lfinger02", "左親指3"], - "left_leg": ["leftleg", "legl", "lleg", "upperlegl", "lupperleg", "thighl", "leftupperleg", "uplegl", "lupleg", "valvebipedbip01lthigh", "左足"], - "left_knee": ["leftknee", "kneel", "lknee", "lowerlegl", "llowerleg", "calfl", "lcalf", "leftlowerleg", "lowlegl", "llowleg", "valvebipedbip01lcalf", "左ひざ"], - "left_ankle": ["leftankle", "anklel", "lankle", "leftfoot", "footl", "lfoot", "leftfoot", "leftfeet", "feetleft", "lfeet", "feetl", "valvebipedbip01lfoot", "左足首"], - "left_toe": ["lefttoe", "toeleft", "toel", "ltoe", "toesl", "ltoes", "valvebipedbip01ltoe0", "左つま先"], - "hips": ["pelvis", "hips", "hip", "valvebipedbip01pelvis", "腰"], - "spine": ["torso", "spine", "valvebipedbip01spine", "脊椎"], - "chest": ["chest", "valvebipedbip01spine1", "胸"], - "upper_chest": ["upperchest", "valvebipedbip01spine4", "上胸"], - "neck": ["neck", "valvebipedbip01neck1", "首"], - "head": ["head", "valvebipedbip01head1", "頭"], - "left_eye": ["eyeleft", "lefteye", "eyel", "leye", "左目"], - "right_eye": ["eyeright", "righteye", "eyer", "reye", "右目"], -} + # Right side bones + "right_shoulder": [ + "rightshoulder", "shoulderr", "rshoulder", "valvebipedbip01rclavicle", + "右肩", "肩.r", "肩+.r", "右肩+", "右肩", "右肩+", "肩+r", "肩+右", "ik_肩.r" + ], + "right_arm": [ + "rightarm", "armr", "rarm", "upperarmr", "rupperarm", "rightupperarm", + "uparmr", "ruparm", "valvebipedbip01rupperarm", "右腕", "腕.r", "右腕", "ik_腕.r" + ], + "right_elbow": [ + "rightelbow", "elbowr", "relbow", "lowerarmr", "rightlowerarm", + "rlowerarm", "lowarmr", "rlowarm", "forearmr", "rforearm", + "valvebipedbip01rforearm", "右ひじ", "ひじ.r", "ik_ひじ.r" + ], + "right_wrist": [ + "rightwrist", "wristr", "rwrist", "handr", "righthand", "rhand", + "valvebipedbip01rhand", "右手首", "手首.r", "ik_手首.r" + ], + "pinkie_0_r": [ + "littlefinger0r", "pinkie0r", "rpinkie0", "pinkiemetacarpalr", "右小指0" + ], + "pinkie_1_r": [ + "littlefinger1r", "pinkie1r", "rpinkie1", "pinkieproximalr", + "valvebipedbip01rfinger4", "右小指1" + ], + "pinkie_2_r": [ + "littlefinger2r", "pinkie2r", "rpinkie2", "pinkieintermediater", + "valvebipedbip01rfinger41", "右小指2" + ], + "pinkie_3_r": [ + "littlefinger3r", "pinkie3r", "rpinkie3", "pinkiedistalr", + "valvebipedbip01rfinger42", "右小指3" + ], + "ring_0_r": [ + "ringfinger0r", "ring0r", "rring0", "ringmetacarpalr", "右薬指0" + ], + "ring_1_r": [ + "ringfinger1r", "ring1r", "rring1", "ringproximalr", + "valvebipedbip01rfinger3", "右薬指1" + ], + "ring_2_r": [ + "ringfinger2r", "ring2r", "rring2", "ringintermediater", + "valvebipedbip01rfinger31", "右薬指2" + ], + "ring_3_r": [ + "ringfinger3r", "ring3r", "rring3", "ringdistalr", + "valvebipedbip01rfinger32", "右薬指3" + ], + "middle_0_r": [ + "middlefinger0r", "middle0r", "rmiddle0", "middlemetacarpalr", "右中指0" + ], + "middle_1_r": [ + "middlefinger1r", "middle1r", "rmiddle1", "middleproximalr", + "valvebipedbip01rfinger2", "右中指1" + ], + "middle_2_r": [ + "middlefinger2r", "middle2r", "rmiddle2", "middleintermediater", + "valvebipedbip01rfinger21", "右中指2" + ], + "middle_3_r": [ + "middlefinger3r", "middle3r", "rmiddle3", "middledistalr", + "valvebipedbip01rfinger22", "右中指3" + ], + "index_0_r": [ + "indexfinger0r", "index0r", "rindex0", "indexmetacarpalr", "右人差指0" + ], + "index_1_r": [ + "indexfinger1r", "index1r", "rindex1", "indexproximalr", + "valvebipedbip01rfinger1", "右人差指1" + ], + "index_2_r": [ + "indexfinger2r", "index2r", "rindex2", "indexintermediater", + "valvebipedbip01rfinger11", "右人差指2" + ], + "index_3_r": [ + "indexfinger3r", "index3r", "rindex3", "indexdistalr", + "valvebipedbip01rfinger12", "右人差指3" + ], + "thumb_0_r": [ + "thumb0r", "rthumb0", "thumbmetacarpalr", "右親指0" + ], + "thumb_1_r": [ + "thumb1r", "rthumb1", "thumbproximalr", "valvebipedbip01rfinger0", "右親指1" + ], + "thumb_2_r": [ + "thumb2r", "rthumb2", "thumbintermediater", "valvebipedbip01rfinger01", "右親指2" + ], + "thumb_3_r": [ + "thumb3r", "rthumb3", "thumbdistalr", "valvebipedbip01rfinger02", "右親指3" + ], + "right_leg": [ + "rightleg", "legr", "rleg", "upperlegr", "rupperleg", "thighr", + "rightupperleg", "uplegr", "rupleg", "valvebipedbip01rthigh", + "右足", "足.r", "ik_足.r" + ], + "right_knee": [ + "rightknee", "kneer", "rknee", "lowerlegr", "rightlowerleg", + "rlowerleg", "lowlegr", "rlowleg", "calfr", "rcalf", + "valvebipedbip01rcalf", "右ひざ", "ひざ.r", "すね.r", "ik_ひざ.r" + ], + "right_ankle": [ + "rightankle", "ankler", "rankle", "rightfoot", "footr", "rfoot", + "rightfeet", "feetright", "rfeet", "feetr", "valvebipedbip01rfoot", + "右足首", "足首.r", "ik_足首.r" + ], + "right_toe": [ + "righttoe", "toeright", "toer", "rtoe", "toesr", "rtoes", + "valvebipedbip01rtoe0", "右つま先", "つま先.r", "ik_つま先.r" + ], + # Left side bones + "left_shoulder": [ + "leftshoulder", "shoulderl", "lshoulder", "valvebipedbip01lclavicle", + "左肩", "肩.l", "肩+.l", "左肩+", "左肩", "左肩+", "肩+l", "肩+左", "ik_肩.l" + ], + "left_arm": [ + "leftarm", "arml", "larm", "upperarml", "lupperarm", "leftupperarm", + "uparml", "luparm", "valvebipedbip01lupperarm", "左腕", "腕.l", "左腕", "ik_腕.l" + ], + "left_elbow": [ + "leftelbow", "elbowl", "lelbow", "lowerarml", "leftlowerarm", + "llowerarm", "lowarml", "llowarm", "forearml", "lforearm", + "valvebipedbip01lforearm", "左ひじ", "ひじ.l", "すね.l", "ik_ひじ.l" + ], + "left_wrist": [ + "leftwrist", "wristl", "lwrist", "handl", "lefthand", "lhand", + "valvebipedbip01lhand", "左手首", "手首.l", "ik_手首.l" + ], + "pinkie_0_l": [ + "pinkiefinger0l", "pinkie0l", "lpinkie0", "pinkiemetacarpall", "左小指0" + ], + "pinkie_1_l": [ + "littlefinger1l", "pinkie1l", "lpinkie1", "pinkieproximall", + "valvebipedbip01lfinger4", "左小指1" + ], + "pinkie_2_l": [ + "littlefinger2l", "pinkie2l", "lpinkie2", "pinkieintermediatel", + "valvebipedbip01lfinger41", "左小指2" + ], + "pinkie_3_l": [ + "littlefinger3l", "pinkie3l", "lpinkie3", "pinkiedistall", + "valvebipedbip01lfinger42", "左小指3" + ], + "ring_0_l": [ + "ringfinger0l", "ring0l", "lring0", "ringmetacarpall", "左薬指0" + ], + "ring_1_l": [ + "ringfinger1l", "ring1l", "lring1", "ringproximall", + "valvebipedbip01lfinger3", "左薬指1" + ], + "ring_2_l": [ + "ringfinger2l", "ring2l", "lring2", "ringintermediatel", + "valvebipedbip01lfinger31", "左薬指2" + ], + "ring_3_l": [ + "ringfinger3l", "ring3l", "lring3", "ringdistall", + "valvebipedbip01lfinger32", "左薬指3" + ], + "middle_0_l": [ + "middlefinger0l", "middle_0l", "lmiddle0", "middlemetacarpall", "左中指0" + ], + "middle_1_l": [ + "middlefinger1l", "middle_1l", "lmiddle1", "middleproximall", + "valvebipedbip01lfinger2", "左中指1" + ], + "middle_2_l": [ + "middlefinger2l", "middle_2l", "lmiddle2", "middleintermediatel", + "valvebipedbip01lfinger21", "左中指2" + ], + "middle_3_l": [ + "middlefinger3l", "middle_3l", "lmiddle3", "middledistall", + "valvebipedbip01lfinger22", "左中指3" + ], + "index_0_l": [ + "indexfinger0l", "index0l", "lindex0", "indexmetacarpall", "左人差指0" + ], + "index_1_l": [ + "indexfinger1l", "index1l", "lindex1", "indexproximall", + "valvebipedbip01lfinger1", "左人差指1" + ], + "index_2_l": [ + "indexfinger2l", "index2l", "lindex2", "indexintermediatel", + "valvebipedbip01lfinger11", "左人差指2" + ], + "index_3_l": [ + "indexfinger3l", "index3l", "lindex3", "indexdistall", + "valvebipedbip01lfinger12", "左人差指3" + ], + "thumb_0_l": [ + "thumb0l", "lthumb0", "thumbmetacarpall", "左親指0" + ], + "thumb_1_l": [ + "thumb1l", "lthumb1", "thumbproximall", "valvebipedbip01lfinger0", "左親指1" + ], + "thumb_2_l": [ + "thumb2l", "lthumb2", "thumbintermediatel", "valvebipedbip01lfinger01", "左親指2" + ], + "thumb_3_l": [ + "thumb3l", "lthumb3", "thumbdistall", "valvebipedbip01lfinger02", "左親指3" + ], + "left_leg": [ + "leftleg", "legl", "lleg", "upperlegl", "lupperleg", "thighl", + "leftupperleg", "uplegl", "lupleg", "valvebipedbip01lthigh", + "左足", "足.l", "ik_足.l" + ], + "left_knee": [ + "leftknee", "kneel", "lknee", "lowerlegl", "leftlowerleg", + "llowerleg", "lowlegl", "llowleg", "calfl", "lcalf", + "valvebipedbip01lcalf", "左ひざ", "ひざ.l", "すね.l", "ik_ひざ.l" + ], + "left_ankle": [ + "leftankle", "anklel", "lankle", "leftfoot", "footl", "lfoot", + "leftfeet", "feetleft", "lfeet", "feetl", "valvebipedbip01lfoot", + "左足首", "足首.l", "ik_足首.l" + ], + "left_toe": [ + "lefttoe", "toeleft", "toel", "ltoe", "toesl", "ltoes", + "valvebipedbip01ltoe0", "左つま先", "つま先.l", "ik_つま先.l" + ], + + # Central bones + "hips": [ + "pelvis", "hips", "hip", "valvebipedbip01pelvis", "腰", "ik_腰" + ], + "spine": [ + "torso", "spine", "valvebipedbip01spine", "脊椎", "ik_脊椎" + ], + "chest": [ + "chest", "valvebipedbip01spine1", "胸", "ik_胸" + ], + "upper_chest": [ + "upperchest", "valvebipedbip01spine4", "上胸", "ik_上胸" + ], + "neck": [ + "neck", "valvebipedbip01neck1", "首", "ik_首" + ], + "head": [ + "head", "valvebipedbip01head1", "頭", "ik_頭" + ], + "left_eye": [ + "eyeleft", "lefteye", "eyel", "leye", "左目", "ik_左目" + ], + "right_eye": [ + "eyeright", "righteye", "eyer", "reye", "右目", "ik_右目" + ], +} # Add VRM bone name variations bone_names.update({ diff --git a/functions/mmd_tools.py b/functions/mmd_tools.py index 0ce96f3..884509a 100644 --- a/functions/mmd_tools.py +++ b/functions/mmd_tools.py @@ -8,10 +8,11 @@ from ..core.common import ( get_active_armature, validate_armature, get_vertex_weights, - transfer_vertex_weights + transfer_vertex_weights, + get_all_meshes ) from ..core.translations import t -from ..core.dictionaries import bone_names +from ..core.dictionaries import bone_names, dont_delete_these_main_bones class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): """MMD Bone standardization system""" @@ -59,99 +60,62 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): logger.error(f"MMD Standardization failed: {str(e)}") self.report({'ERROR'}, str(e)) return {'CANCELLED'} - - def standardize_armature(self) -> Tuple[bool, str]: - """Main standardization process""" - if not self.armature: - return False, t("MMD.no_armature") - - try: - with ProgressTracker(self.context, 5, "MMD Standardization") as progress: - # Step 1: Process bone names - self.process_bone_names() - progress.step("Processed bone names") - - # Step 2: Fix bone structure - self.fix_bone_structure() - progress.step("Fixed bone structure") - - # Step 3: Process weights - self.process_weights() - progress.step("Processed weights") - - # Step 4: Clean up - self.cleanup_armature() - progress.step("Cleaned up armature") - - # Step 5: Final validation - self.validate_results() - progress.step("Validated results") - - return True, t("MMD.standardization_complete") - - except Exception as e: - logger.error(f"MMD Standardization failed: {str(e)}") - return False, str(e) def process_bone_names(self, context: Context) -> None: """Process and standardize bone names""" bpy.ops.object.mode_set(mode='EDIT') edit_bones = self.armature.data.edit_bones + # First pass - handle IK bones + ik_bones = [bone for bone in edit_bones if 'IK' in bone.name or 'IK' in bone.name] + for bone in ik_bones: + new_name = f"ik_{self.standardize_bone_name(bone.name.replace('IK', '').replace('IK', ''))}" + self.bone_mapping[bone.name] = new_name + bone.name = new_name + + # Second pass - standard bones for bone in edit_bones: - new_name = self.standardize_bone_name(bone.name) - if new_name != bone.name: - self.bone_mapping[bone.name] = new_name - bone.name = new_name + if bone not in ik_bones: + new_name = self.standardize_bone_name(bone.name) + if new_name != bone.name: + self.bone_mapping[bone.name] = new_name + bone.name = new_name def translate_japanese_bone_name(self, name: str) -> str: """Translate Japanese bone names to English standardized names""" - from ..core.dictionaries import bone_names - - # Convert to lowercase for matching name_lower = name.lower() - # Check each bone category for Japanese character matches for bone_category, variations in bone_names.items(): for variation in variations: if variation in name_lower: - # If Japanese characters are found, return the standardized name return bone_category - # If no match found, return original name return name def standardize_bone_name(self, name: str) -> str: """Standardize individual bone names""" - # First translate Japanese names result = self.translate_japanese_bone_name(name) - # Remove common prefixes prefixes = ['ValveBiped_', 'Bip01_', 'MMD_', 'Armature|'] for prefix in prefixes: if result.lower().startswith(prefix.lower()): result = result[len(prefix):] - # Handle left/right conventions if result.endswith('_L') or result.endswith('.L'): result = f"{result[:-2]}.L" elif result.endswith('_R') or result.endswith('.R'): result = f"{result[:-2]}.R" return result + return result def fix_bone_structure(self, context: Context) -> None: """Fix bone hierarchy and orientations""" bpy.ops.object.mode_set(mode='EDIT') edit_bones = self.armature.data.edit_bones - # Process spine hierarchy self.process_spine_chain(context) - - # Fix bone orientations self.fix_bone_orientations(context) - - # Connect appropriate bones self.connect_bones(context) def process_weights(self, context: Context) -> None: @@ -167,13 +131,8 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): def cleanup_armature(self, context: Context) -> None: """Perform final cleanup operations""" - # Remove unused bones self.remove_unused_bones(context) - - # Clean up constraints self.cleanup_constraints(context) - - # Fix zero-length bones self.fix_zero_length_bones(context) def get_associated_meshes(self, context: Context) -> List[Object]: @@ -184,6 +143,7 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): def process_spine_chain(self, context: Context) -> None: """Process and fix spine bone chain hierarchy""" + bpy.ops.object.mode_set(mode='EDIT') edit_bones = self.armature.data.edit_bones spine_bones = { 'hips': None, @@ -220,7 +180,29 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): """Fix bone orientations for standard pose compatibility""" edit_bones = self.armature.data.edit_bones - # Process arm bones + # Define standardized roll values for key bones + roll_values = { + 'upper_arm.L': -0.1, + 'upper_arm.R': 0.1, + 'forearm.L': -0.1, + 'forearm.R': 0.1, + 'thigh.L': 0.0, + 'thigh.R': 0.0, + 'shin.L': 0.0, + 'shin.R': 0.0, + 'foot.L': 0.0, + 'foot.R': 0.0, + 'spine': 0.0, + 'chest': 0.0, + 'neck': 0.0 + } + + # Apply roll corrections + for bone in edit_bones: + if bone.name.lower() in roll_values: + bone.roll = roll_values[bone.name.lower()] + + # Process arm chains arm_pairs = [ ('upper_arm', 'forearm'), ('forearm', 'hand') @@ -235,7 +217,7 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): child_bone.use_connect = True child_bone.use_inherit_rotation = True - # Process leg bones + # Process leg chains leg_pairs = [ ('thigh', 'shin'), ('shin', 'foot') @@ -249,6 +231,12 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): if parent_bone and child_bone: child_bone.use_connect = True child_bone.use_inherit_rotation = True + + # Align twist bones if present + twist_bones = [b for b in edit_bones if 'twist' in b.name.lower()] + for twist_bone in twist_bones: + if twist_bone.parent: + twist_bone.roll = twist_bone.parent.roll def remove_unused_bones(self, context: Context) -> None: """Remove unused and unnecessary bones from the armature""" @@ -261,27 +249,29 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): for group in mesh.vertex_groups: used_bones.add(group.name) - # Get list of bones to keep based on settings - toolkit = context.scene.avatar_toolkit - keep_upper_chest = toolkit.keep_upper_chest - keep_twist = toolkit.keep_twist_bones + # Get list of essential bones to always keep + essential_bones = { + 'hips', 'spine', 'chest', 'upper_chest', 'neck', 'head', + 'left_leg', 'right_leg', 'left_knee', 'right_knee', + 'left_ankle', 'right_ankle', 'left_toe', 'right_toe' + } + + # Add any additional bones you want to preserve + essential_bones.update(dont_delete_these_main_bones) # Remove unused bones for bone in edit_bones: + # Skip if bone is essential + if bone.name.lower() in essential_bones: + continue + # Skip if bone has weights if bone.name in used_bones: continue - - # Skip if bone is upper chest and we want to keep it - if 'upper_chest' in bone.name.lower() and keep_upper_chest: - continue - - # Skip if bone is twist bone and we want to keep them - if 'twist' in bone.name.lower() and keep_twist: - continue - + # Remove the bone - edit_bones.remove(bone) + edit_bones.remove(bone) + def connect_bones(self, context: Context) -> None: """Connect bones that should be connected in the hierarchy""" @@ -308,21 +298,16 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): """Clean up vertex groups by removing zero weights and merging similar groups""" threshold = context.scene.avatar_toolkit.merge_weights_threshold - # Get list of vertex groups vertex_groups = mesh_obj.vertex_groups - # Track groups to remove groups_to_remove = set() - # Check each vertex group for group in vertex_groups: weights = get_vertex_weights(mesh_obj, group.name) - # If no weights above threshold, mark for removal if not any(weight > threshold for weight in weights.values()): groups_to_remove.add(group.name) - # Remove empty groups for group_name in groups_to_remove: group = vertex_groups.get(group_name) if group: @@ -335,41 +320,11 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): raise ValueError("\n".join(messages)) def cleanup_constraints(self, context: Context) -> None: - """Clean up and fix bone constraints""" + """Remove all constraints from the armature.""" bpy.ops.object.mode_set(mode='POSE') - - # Process each pose bone + for pose_bone in self.armature.pose.bones: - constraints_to_remove = [] - - for constraint in pose_bone.constraints: - should_remove = False - - # Handle IK constraints - if constraint.type == 'IK': - if not constraint.target or constraint.target != self.armature: - should_remove = True - elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: - should_remove = True - - # Handle MMD additional rotation constraints - elif constraint.name == 'mmd_additional_rotation': - if not constraint.target or constraint.target != self.armature: - should_remove = True - elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: - should_remove = True - - # Handle transformation constraints - elif constraint.type in {'COPY_ROTATION', 'COPY_LOCATION', 'COPY_TRANSFORMS'}: - if not constraint.target or constraint.target != self.armature: - should_remove = True - elif not constraint.subtarget or constraint.subtarget not in self.armature.data.bones: - should_remove = True - - if should_remove: - constraints_to_remove.append(constraint) - - # Remove invalid constraints + constraints_to_remove = [constraint for constraint in pose_bone.constraints] for constraint in constraints_to_remove: pose_bone.constraints.remove(constraint) @@ -381,59 +336,17 @@ class AVATAR_TOOLKIT_OT_StandardizeMmd(Operator): min_length = 0.01 # Minimum bone length in Blender units for bone in edit_bones: - # Calculate bone length bone_length = (bone.tail - bone.head).length if bone_length < min_length: - # Set minimal length while preserving direction if bone.parent: - # Use parent's orientation as reference direction = bone.parent.tail - bone.parent.head direction.normalize() else: - # Default to Z-axis if no parent - direction = mathutils.Vector((0, 0, 1)) + direction = Vector((0, 0, 1)) bone.tail = bone.head + (direction * min_length) -class FixUnmovableBonesOperator(bpy.types.Operator): - bl_idname = "avatar_toolkit.fix_unmovable_bones" - bl_label = t("MMD.fix_unmovable_bones") - bl_description = t("MMD.fix_unmovable_bones_desc") - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - armature = get_active_armature(context) - return armature is not None and armature.type == 'ARMATURE' - - def execute(self, context): - armature = get_active_armature(context) - if not armature: - self.report({'ERROR'}, t("MMD.no_armature")) - return {'CANCELLED'} - - try: - with ProgressTracker(context, 2, "Unlocking Transforms") as progress: - # Unlock armature transforms - progress.step("Unlocking armature transforms") - for attr in ('location', 'rotation', 'scale'): - for i in range(3): - setattr(armature, f"lock_{attr}", [False] * 3) - - # Unlock bone transforms - progress.step("Unlocking bone transforms") - for bone in armature.pose.bones: - for attr in ('location', 'rotation', 'scale'): - setattr(bone, f"lock_{attr}", [False] * 3) - - self.report({'INFO'}, t("MMD.transforms_unlocked")) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Error unlocking transforms: {str(e)}") - self.report({'ERROR'}, str(e)) - return {'CANCELLED'} class ReparentMeshesOperator(bpy.types.Operator): bl_idname = "avatar_toolkit.reparent_meshes" @@ -498,4 +411,382 @@ class ReparentMeshesOperator(bpy.types.Operator): # Set parent to armature mesh.parent = armature if not mesh.parent_type == 'ARMATURE': - mesh.parent_type = 'ARMATURE' \ No newline at end of file + mesh.parent_type = 'ARMATURE' + +class AVATAR_TOOLKIT_OT_ConvertMmdMorphs(Operator): + """Convert MMD morph data to shape keys""" + bl_idname = "avatar_toolkit.convert_mmd_morphs" + bl_label = t("MMD.convert_morphs") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature is not None and get_all_meshes(context) + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + self.report({'ERROR'}, t("MMD.no_armature")) + return {'CANCELLED'} + + try: + with ProgressTracker(context, 3, "Converting MMD Morphs") as progress: + # Convert bone morphs to shape keys + if hasattr(armature, 'mmd_root') and armature.mmd_root.bone_morphs: + self.process_bone_morphs(context, armature, progress) + + progress.step("Processed bone morphs") + + # Clean up unused data + self.cleanup_unused_data(context) + progress.step("Cleaned up data") + + # Validate results + self.validate_results(context) + progress.step("Validated results") + + self.report({'INFO'}, t("MMD.conversion_complete")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error converting MMD morphs: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def process_bone_morphs(self, context, armature, progress): + """Process bone morphs into shape keys""" + for morph in armature.mmd_root.bone_morphs: + for mesh in get_all_meshes(context): + # Create armature modifier + mod = mesh.modifiers.new(morph.name, 'ARMATURE') + mod.object = armature + + # Apply as shape key + with context.temp_override(object=mesh): + bpy.ops.object.modifier_apply(modifier=mod.name) + +class AVATAR_TOOLKIT_OT_CleanupMmdModel(Operator): + """Clean up MMD model by removing unused data and fixing display settings""" + bl_idname = "avatar_toolkit.cleanup_mmd" + bl_label = t("MMD.cleanup") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + self.report({'ERROR'}, t("MMD.no_armature")) + return {'CANCELLED'} + + try: + with ProgressTracker(context, 4, "Cleaning MMD Model") as progress: + # Remove rigid bodies and joints + self.remove_physics_objects(armature) + progress.step("Removed physics objects") + + # Clean up collections and hierarchy + self.cleanup_hierarchy(context, armature) + progress.step("Cleaned hierarchy") + + # Fix viewport settings + self.fix_viewport_settings(context) + progress.step("Fixed viewport") + + # Final cleanup + clear_unused_data_blocks() + progress.step("Cleared unused data") + + self.report({'INFO'}, t("MMD.cleanup_complete")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error cleaning MMD model: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def remove_physics_objects(self, armature): + """Remove physics-related objects""" + to_delete = [] + for child in armature.children: + if any(x in child.name.lower() for x in ['rigidbodies', 'joints', 'physics']): + to_delete.append(child) + + for obj in to_delete: + bpy.data.objects.remove(obj, do_unlink=True) + + def cleanup_hierarchy(self, context, armature): + """Clean up object hierarchy and collections""" + meshes = get_all_meshes(context) + for mesh in meshes: + # Ensure proper parenting + mesh.parent = armature + mesh.parent_type = 'ARMATURE' + + # Clean up collections + for col in mesh.users_collection: + if col != context.scene.collection: + col.objects.unlink(mesh) + + if mesh.name not in context.scene.collection.objects: + context.scene.collection.objects.link(mesh) + + def fix_viewport_settings(self, context): + """Fix viewport display settings""" + # Set armature display + armature = get_active_armature(context) + armature.data.display_type = 'OCTAHEDRAL' + armature.show_in_front = True + + # Set viewport shading + for area in context.screen.areas: + if area.type == 'VIEW_3D': + space = area.spaces[0] + space.shading.type = 'MATERIAL' + space.clip_start = 0.01 + space.clip_end = 300 + +class AVATAR_TOOLKIT_OT_FixMeshes(Operator): + """Clean up and optimize mesh materials, shading, and shape keys""" + bl_idname = "avatar_toolkit.fix_meshes" + bl_label = t("Optimization.fix_meshes") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature is not None and get_all_meshes(context) + + def execute(self, context): + try: + meshes = get_all_meshes(context) + if not meshes: + self.report({'ERROR'}, t("Optimization.no_meshes")) + return {'CANCELLED'} + + with ProgressTracker(context, len(meshes), "Fixing Meshes") as progress: + for mesh in meshes: + self.process_mesh(context, mesh) + progress.step(f"Processed {mesh.name}") + + self.report({'INFO'}, t("Optimization.meshes_fixed")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error fixing meshes: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def process_mesh(self, context: Context, mesh: Object) -> None: + """Process and fix individual mesh""" + # Unlock transforms + for i in range(3): + mesh.lock_location[i] = False + mesh.lock_rotation[i] = False + mesh.lock_scale[i] = False + + # Process shape keys + if mesh.data.shape_keys: + self.fix_shape_keys(mesh) + + # Process materials + self.fix_materials(context, mesh) + + def fix_shape_keys(self, mesh: Object) -> None: + """Fix and clean up shape keys""" + if not mesh.data.shape_keys: + return + + shape_keys = mesh.data.shape_keys.key_blocks + + # Rename basis + if shape_keys[0].name != "Basis": + shape_keys[0].name = "Basis" + + # Clean up names + for key in shape_keys: + # Remove common prefixes/suffixes + clean_name = key.name + for prefix in ['Face.M F00 000 Fcl ', 'Face.M F00 000 00 Fcl ']: + clean_name = clean_name.replace(prefix, '') + + # Replace underscores with spaces + clean_name = clean_name.replace('_', ' ') + key.name = clean_name + + # Sort shape keys by category + categories = ['MTH', 'EYE', 'BRW', 'ALL'] + + # Create sorted list of shape key names + ordered_names = [] + + # Add categorized keys first + for category in categories: + category_keys = [key.name for key in shape_keys if key.name.startswith(category)] + ordered_names.extend(sorted(category_keys)) + + # Add remaining keys + remaining = [key.name for key in shape_keys if not any(key.name.startswith(c) for c in categories)] + ordered_names.extend(sorted(remaining)) + + # Reorder using context override + with bpy.context.temp_override(active_object=mesh, selected_objects=[mesh]): + for idx, name in enumerate(ordered_names): + mesh.active_shape_key_index = shape_keys.find(name) + while mesh.active_shape_key_index > idx: + bpy.ops.object.shape_key_move(type='UP') + + + def fix_materials(self, context: Context, mesh: Object) -> None: + """Fix and optimize materials""" + for slot in mesh.material_slots: + if not slot.material: + continue + + material = slot.material + + # Set up basic material properties + material.use_backface_culling = True + material.blend_method = 'HASHED' + material.shadow_method = 'HASHED' + + # Clean up material name + material.name = self.clean_material_name(material.name) + + # Consolidate similar materials + for other_slot in mesh.material_slots: + if other_slot.material and other_slot.material != material: + if materials_match(material, other_slot.material): + other_slot.material = material + + def clean_material_name(self, name: str) -> str: + """Clean up material name""" + # Remove common prefixes/suffixes + prefixes = ['material', 'mat', 'mtl', 'material.'] + for prefix in prefixes: + if name.lower().startswith(prefix): + name = name[len(prefix):] + + # Remove numbers at end + while name and name[-1].isdigit(): + name = name[:-1] + + return name.strip() + +class AVATAR_TOOLKIT_OT_ValidateMeshes(Operator): + """Validate meshes and UV maps for common issues""" + bl_idname = "avatar_toolkit.validate_meshes" + bl_label = t("Validation.check_meshes") + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + armature = get_active_armature(context) + if not armature: + self.report({'ERROR'}, t("Validation.no_armature")) + return {'CANCELLED'} + + try: + with ProgressTracker(context, 3, "Validating Meshes") as progress: + # Check bone hierarchy + hierarchy_issues = self.validate_bone_hierarchy(armature) + progress.step("Checked bone hierarchy") + + # Check UV coordinates + uv_issues = self.validate_uv_maps(context) + progress.step("Checked UV maps") + + # Generate report + self.generate_validation_report(context, hierarchy_issues, uv_issues) + progress.step("Generated report") + + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error validating meshes: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def validate_bone_hierarchy(self, armature: Object) -> List[str]: + """Validate bone hierarchy against standard structure""" + issues = [] + + # Define expected hierarchy + hierarchy = [ + ['hips', 'spine', 'chest', 'neck', 'head'], + ['hips', 'left_leg', 'left_knee', 'left_ankle'], + ['hips', 'right_leg', 'right_knee', 'right_ankle'], + ['chest', 'left_shoulder', 'left_arm', 'left_elbow', 'left_wrist'], + ['chest', 'right_shoulder', 'right_arm', 'right_elbow', 'right_wrist'] + ] + + for chain in hierarchy: + previous = None + for bone_name in chain: + # Check if bone exists + bone = None + for alt_name in bone_names[bone_name]: + if alt_name in armature.data.bones: + bone = armature.data.bones[alt_name] + break + + if not bone: + issues.append(t("Validation.missing_bone", bone=bone_name)) + continue + + # Check parent relationship + if previous: + if not bone.parent: + issues.append(t("Validation.no_parent", bone=bone.name)) + elif bone.parent.name != previous.name: + issues.append(t("Validation.wrong_parent", + bone=bone.name, + expected=previous.name, + actual=bone.parent.name)) + previous = bone + + return issues + + def validate_uv_maps(self, context: Context) -> Dict[str, int]: + """Check UV maps for issues""" + issues = {'nan_coords': 0, 'missing_uvs': 0} + + for mesh in get_all_meshes(context): + if not mesh.data.uv_layers: + issues['missing_uvs'] += 1 + continue + + for uv_layer in mesh.data.uv_layers: + for uv in uv_layer.data: + if math.isnan(uv.uv.x): + uv.uv.x = 0 + issues['nan_coords'] += 1 + if math.isnan(uv.uv.y): + uv.uv.y = 0 + issues['nan_coords'] += 1 + + return issues + + def generate_validation_report(self, context: Context, + hierarchy_issues: List[str], + uv_issues: Dict[str, int]) -> None: + """Generate and display validation report""" + report_lines = [] + + # Add hierarchy issues + if hierarchy_issues: + report_lines.append(t("Validation.hierarchy_issues")) + report_lines.extend(hierarchy_issues) + + # Add UV issues + if uv_issues['nan_coords'] > 0: + report_lines.append(t("Validation.uv_nan_coords", + count=uv_issues['nan_coords'])) + + if uv_issues['missing_uvs'] > 0: + report_lines.append(t("Validation.missing_uvs", + count=uv_issues['missing_uvs'])) + + # Show report + if report_lines: + self.report({'WARNING'}, "\n".join(report_lines)) + else: + self.report({'INFO'}, t("Validation.no_issues")) diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 5cd7b4d..2e60524 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -186,7 +186,7 @@ class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): mesh_data: Mesh = mesh.data for vertex in mesh_data.vertices: for group in vertex.groups: - if group.weight > context.scene.avatar_toolkit.clean_weights_threshold: + if group.weight > context.scene.avatar_toolkit.merge_weights_threshold: weighted_bones.append(mesh.vertex_groups[group.group].name) # Process bone removal diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py index 4232bfb..96210b5 100644 --- a/ui/mmd_panel.py +++ b/ui/mmd_panel.py @@ -5,7 +5,7 @@ from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t class AvatarToolKit_PT_MMDPanel(Panel): - """Panel containing MMD bone standardization tools""" + """Panel containing MMD bone standardization and cleanup tools""" bl_label = t("MMD.label") bl_idname = "OBJECT_PT_avatar_toolkit_mmd" bl_space_type = 'VIEW_3D' @@ -18,6 +18,29 @@ class AvatarToolKit_PT_MMDPanel(Panel): layout: UILayout = self.layout toolkit = context.scene.avatar_toolkit - # Add merge twist bones option - layout.prop(toolkit, "keep_twist_bones") - layout.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') + # Bone Settings Box + bone_box: UILayout = layout.box() + col: UILayout = bone_box.column(align=True) + col.label(text=t("MMD.bone_settings"), icon='BONE_DATA') + col.separator(factor=0.5) + col.prop(toolkit, "keep_twist_bones") + col.prop(toolkit, "keep_upper_chest") + col.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') + + # Mesh Tools Box + mesh_box: UILayout = layout.box() + col = mesh_box.column(align=True) + col.label(text=t("MMD.mesh_tools"), icon='MESH_DATA') + col.separator(factor=0.5) + row: UILayout = col.row(align=True) + row.operator("avatar_toolkit.fix_meshes", icon='MODIFIER') + row.operator("avatar_toolkit.validate_meshes", icon='CHECKMARK') + + # Cleanup Box + cleanup_box: UILayout = layout.box() + col = cleanup_box.column(align=True) + col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') + col.separator(factor=0.5) + col.operator("avatar_toolkit.cleanup_mmd", icon='SHADERFX') + col.operator("avatar_toolkit.convert_mmd_morphs", icon='SHAPEKEY_DATA') + col.operator("avatar_toolkit.reparent_meshes", icon='OUTLINER_OB_ARMATURE') From 87a351cea41b66c73e144da0e88579dd380ca657 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sun, 15 Dec 2024 20:14:26 +0000 Subject: [PATCH 14/19] Added Eye tracking and Visemes --- core/common.py | 91 ++++ core/properties.py | 169 ++++++ core/updater.py | 1 + functions/eye_tracking.py | 867 ++++++++++++++++++++++++++++++ functions/visemes.py | 335 ++++++++++++ resources/translations/en_US.json | 97 ++++ ui/eye_tracking_panel.py | 143 +++++ ui/main_panel.py | 1 - ui/mmd_panel.py | 79 +-- ui/optimization_panel.py | 1 + ui/settings_panel.py | 1 + ui/tools_panel.py | 1 + ui/visemes_panel.py | 60 +++ 13 files changed, 1807 insertions(+), 39 deletions(-) create mode 100644 functions/eye_tracking.py create mode 100644 functions/visemes.py create mode 100644 ui/eye_tracking_panel.py create mode 100644 ui/visemes_panel.py diff --git a/core/common.py b/core/common.py index 916e6ef..253acc6 100644 --- a/core/common.py +++ b/core/common.py @@ -485,3 +485,94 @@ def remove_unused_shapekeys(mesh_obj: Object, tolerance: float = 0.001) -> int: removed_count += 1 return removed_count + +def has_shapekeys(mesh_obj: Object) -> bool: + return mesh_obj.data.shape_keys is not None + +# Identifier to indicate that an EnumProperty is empty +# This is the default identifier used when a wrapped items function returns an empty list +# This identifier needs to be something that should never normally be used, so as to avoid the possibility of +# conflicting with an enum value that exists. +_empty_enum_identifier = 'Cats_empty_enum_identifier' + +# names - The first object will be the first one in the list. So the first one has to be the one that exists in the most models +# no_basis - If this is true the Basis will not be available in the list +def get_shapekeys(context, names, is_mouth, no_basis, return_list): + choices = [] + choices_simple = [] + meshes_list = get_meshes_objects(check=False) + + if meshes_list: + if is_mouth: + meshes = [get_objects().get(context.scene.mesh_name_viseme)] + else: + meshes = [get_objects().get(context.scene.mesh_name_eye)] + else: + return choices + + for mesh in meshes: + if not mesh or not has_shapekeys(mesh): + return choices + + for shapekey in mesh.data.shape_keys.key_blocks: + name = shapekey.name + if name in choices_simple: + continue + if no_basis and name == 'Basis': + continue + # 1. Will be returned by context.scene + # 2. Will be shown in lists + # 3. will be shown in the hover description (below description) + choices.append((name, name, name)) + choices_simple.append(name) + + _sort_enum_choices_by_identifier_lower(choices) + + choices2 = [] + for name in names: + if name in choices_simple and len(choices) > 1 and choices[0][0] != name: + continue + choices2.append((name, name, name)) + + choices2.extend(choices) + + if return_list: + shape_list = [] + for choice in choices2: + shape_list.append(choice[0]) + return shape_list + + return choices2 + +# Default sorting for dynamic EnumProperty items +def _sort_enum_choices_by_identifier_lower(choices, in_place=True): + """Sort a list of enum choices (items) by the lowercase of their identifier. + + Sorting is performed in-place by default, but can be changed by setting in_place=False. + + Returns the sorted list of enum choices.""" + + def identifier_lower(choice): + return choice[0].lower() + + if in_place: + choices.sort(key=identifier_lower) + else: + choices = sorted(choices, key=identifier_lower) + return choices + +def is_enum_empty(string): + """Returns True only if the tested string is the string that signifies that an EnumProperty is empty. + + Returns False in all other cases.""" + return _empty_enum_identifier == string + + +# This function isn't needed since you can 'not is_enum_empty(string)', but is included for code clarity and readability +def is_enum_non_empty(string): + """Returns False only if the tested string is not the string that signifies that an EnumProperty is empty. + + Returns True in all other cases.""" + return _empty_enum_identifier != string + + diff --git a/core/properties.py b/core/properties.py index 6725b26..a294f2b 100644 --- a/core/properties.py +++ b/core/properties.py @@ -15,6 +15,8 @@ from .translations import t, get_languages_list, update_language from .addon_preferences import get_preference, save_preference from .updater import get_version_list from .common import get_armature_list, get_active_armature, get_all_meshes +from ..functions.visemes import VisemePreview +from ..functions.eye_tracking import set_rotation def update_validation_mode(self, context): logger.info(f"Updating validation mode to: {self.validation_mode}") @@ -26,6 +28,11 @@ def update_logging_state(self, context): from .logging_setup import configure_logging configure_logging(self.enable_logging) +def update_shape_intensity(self, context): + if self.viseme_preview_mode: + from ..functions.visemes import VisemePreview + VisemePreview.update_preview(context) + class AvatarToolkitSceneProperties(PropertyGroup): """Property group containing Avatar Toolkit scene-level settings and properties""" @@ -112,6 +119,168 @@ class AvatarToolkitSceneProperties(PropertyGroup): max=1.0 ) + viseme_preview_mode: BoolProperty( + name=t("Visemes.preview_mode"), + description=t("Visemes.preview_mode_desc"), + default=False + ) + + viseme_preview_selection: StringProperty( + name=t("Visemes.preview_selection"), + description=t("Visemes.preview_selection_desc"), + default="vrc.v_aa" + ) + + mouth_a: StringProperty( + name=t("Visemes.mouth_a"), + description=t("Visemes.mouth_a_desc") + ) + + mouth_o: StringProperty( + name=t("Visemes.mouth_o"), + description=t("Visemes.mouth_o_desc") + ) + + mouth_ch: StringProperty( + name=t("Visemes.mouth_ch"), + description=t("Visemes.mouth_ch_desc") + ) + + shape_intensity: FloatProperty( + name=t("Visemes.shape_intensity"), + description=t("Visemes.shape_intensity_desc"), + default=1.0, + min=0.0, + max=2.0, + precision=3, + update=update_shape_intensity + ) + + viseme_preview_selection: EnumProperty( + name=t("Visemes.preview_selection"), + description=t("Visemes.preview_selection_desc"), + items=[ + ('vrc.v_aa', 'AA', 'A as in "bat"'), + ('vrc.v_ch', 'CH', 'Ch as in "choose"'), + ('vrc.v_dd', 'DD', 'D as in "dog"'), + ('vrc.v_ih', 'IH', 'I as in "bit"'), + ('vrc.v_ff', 'FF', 'F as in "fox"'), + ('vrc.v_e', 'E', 'E as in "bet"'), + ('vrc.v_kk', 'KK', 'K as in "cat"'), + ('vrc.v_nn', 'NN', 'N as in "net"'), + ('vrc.v_oh', 'OH', 'O as in "hot"'), + ('vrc.v_ou', 'OU', 'O as in "go"'), + ('vrc.v_pp', 'PP', 'P as in "pat"'), + ('vrc.v_rr', 'RR', 'R as in "red"'), + ('vrc.v_sil', 'SIL', 'Silence'), + ('vrc.v_ss', 'SS', 'S as in "sit"'), + ('vrc.v_th', 'TH', 'Th as in "think"') + ], + update=lambda s, c: VisemePreview.update_preview(c) +) + eye_mode: EnumProperty( + name=t("EyeTracking.mode"), + items=[ + ('CREATION', t("EyeTracking.mode.creation"), ""), + ('TESTING', t("EyeTracking.mode.testing"), "") + ], + default='CREATION' + ) + + eye_rotation_x: FloatProperty( + name=t("EyeTracking.rotation.x"), + update=set_rotation + ) + + eye_rotation_y: FloatProperty( + name=t("EyeTracking.rotation.y"), + update=set_rotation + ) + + mesh_name_eye: StringProperty( + name=t("EyeTracking.mesh_name"), + description=t("EyeTracking.mesh_name_desc") + ) + + head: StringProperty( + name=t("EyeTracking.head_bone"), + description=t("EyeTracking.head_bone_desc") + ) + + eye_left: StringProperty( + name=t("EyeTracking.eye_left"), + description=t("EyeTracking.eye_left_desc") + ) + + eye_right: StringProperty( + name=t("EyeTracking.eye_right"), + description=t("EyeTracking.eye_right_desc") + ) + + disable_eye_movement: BoolProperty( + name=t("EyeTracking.disable_movement"), + description=t("EyeTracking.disable_movement_desc"), + default=False + ) + + disable_eye_blinking: BoolProperty( + name=t("EyeTracking.disable_blinking"), + description=t("EyeTracking.disable_blinking_desc"), + default=False + ) + + eye_distance: FloatProperty( + name=t("EyeTracking.distance"), + description=t("EyeTracking.distance_desc"), + default=0.0, + min=-1.0, + max=1.0 + ) + + iris_height: FloatProperty( + name=t("EyeTracking.iris_height"), + description=t("EyeTracking.iris_height_desc"), + default=0.0, + min=-1.0, + max=1.0 + ) + + eye_blink_shape: FloatProperty( + name=t("EyeTracking.blink_shape"), + description=t("EyeTracking.blink_shape_desc"), + default=1.0, + min=0.0, + max=1.0 + ) + + eye_lowerlid_shape: FloatProperty( + name=t("EyeTracking.lowerlid_shape"), + description=t("EyeTracking.lowerlid_shape_desc"), + default=1.0, + min=0.0, + max=1.0 + ) + + wink_left: StringProperty( + name=t("EyeTracking.wink_left"), + description=t("EyeTracking.wink_left_desc") + ) + + wink_right: StringProperty( + name=t("EyeTracking.wink_right"), + description=t("EyeTracking.wink_right_desc") + ) + + lowerlid_left: StringProperty( + name=t("EyeTracking.lowerlid_left"), + description=t("EyeTracking.lowerlid_left_desc") + ) + + lowerlid_right: StringProperty( + name=t("EyeTracking.lowerlid_right"), + description=t("EyeTracking.lowerlid_right_desc") + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/core/updater.py b/core/updater.py index d490504..ffab4b5 100644 --- a/core/updater.py +++ b/core/updater.py @@ -77,6 +77,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order = 4 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: bpy.types.Context) -> None: layout = self.layout diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py new file mode 100644 index 0000000..f8fe3d5 --- /dev/null +++ b/functions/eye_tracking.py @@ -0,0 +1,867 @@ +import os +import bpy +import copy +import math +import bmesh +import mathutils +import json +from bpy.types import Operator, Object, Context +from typing import Optional, Dict, Tuple, Set +from collections import OrderedDict +from random import random +from itertools import chain + +from ..core.logging_setup import logger +from ..core.translations import t +from ..core.common import ( + ProgressTracker, + get_active_armature, + get_all_meshes, + get_armature_list, + validate_armature, + validate_mesh_for_pose, + cache_vertex_positions, + apply_vertex_positions +) + +VALID_EYE_NAMES = { + 'left': ['LeftEye', 'Eye_L', 'eye_L', 'eye.L', 'EyeLeft', 'left_eye', 'l_eye'], + 'right': ['RightEye', 'Eye_R', 'eye_R', 'eye.R', 'EyeRight', 'right_eye', 'r_eye'] +} + +class EyeTrackingBackup: + def __init__(self): + self.backup_path = os.path.join(bpy.app.tempdir, "eye_tracking_backup.json") + self.bone_positions: Dict[str, Dict[str, Tuple[float, float, float]]] = {} + + def store_bone_positions(self, armature) -> bool: + try: + self.bone_positions = { + 'LeftEye': { + 'head': tuple(armature.data.bones['LeftEye'].head_local), + 'tail': tuple(armature.data.bones['LeftEye'].tail_local) + }, + 'RightEye': { + 'head': tuple(armature.data.bones['RightEye'].head_local), + 'tail': tuple(armature.data.bones['RightEye'].tail_local) + } + } + + with open(self.backup_path, 'w') as f: + json.dump(self.bone_positions, f) + return True + except Exception as e: + logger.error(f"Backup failed: {str(e)}") + return False + + def restore_bone_positions(self, armature) -> bool: + try: + if not os.path.exists(self.backup_path): + return False + + with open(self.backup_path, 'r') as f: + backup_data = json.load(f) + + bpy.ops.object.mode_set(mode='EDIT') + + for bone_name, positions in backup_data.items(): + if bone_name in armature.data.edit_bones: + bone = armature.data.edit_bones[bone_name] + bone.head = positions['head'] + bone.tail = positions['tail'] + + return True + except Exception as e: + logger.error(f"Restore failed: {str(e)}") + return False + +class EyeTrackingValidator: + @staticmethod + def find_eye_vertex_groups(mesh_name: str) -> Tuple[str, str]: + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return None, None + + left_group = None + right_group = None + + for group in mesh.vertex_groups: + if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['left']): + left_group = group.name + if any(name.lower() in group.name.lower() for name in VALID_EYE_NAMES['right']): + right_group = group.name + + return left_group, right_group + + @staticmethod + def validate_setup(context, mesh_name: str) -> Tuple[bool, str]: + armature = get_active_armature(context) + if not armature: + return False, t('EyeTracking.validation.noArmature') + + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return False, t('EyeTracking.validation.noMesh', mesh=mesh_name) + + if not mesh.data.shape_keys: + return False, t('EyeTracking.validation.noShapekeys') + + left_group, right_group = EyeTrackingValidator.find_eye_vertex_groups(mesh_name) + missing_groups = [] + + if not left_group: + missing_groups.append(t('EyeTracking.validation.leftEye')) + if not right_group: + missing_groups.append(t('EyeTracking.validation.rightEye')) + + if missing_groups: + return False, t('EyeTracking.validation.missingGroups', groups=', '.join(missing_groups)) + + required_bones = [context.scene.avatar_toolkit.head, + context.scene.avatar_toolkit.eye_left, + context.scene.avatar_toolkit.eye_right] + missing_bones = [bone for bone in required_bones if bone not in armature.data.bones] + + if missing_bones: + return False, t('EyeTracking.validation.missingBones', bones=', '.join(missing_bones)) + + return True, t('EyeTracking.validation.success') + +class CreateEyesButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.create_eye_tracking' + bl_label = t('EyeTracking.create.label') + bl_description = t('EyeTracking.create.desc') + bl_options = {'REGISTER', 'UNDO'} + + mesh = None + + @classmethod + def poll(cls, context): + if not get_all_meshes(context): + return False + + toolkit = context.scene.avatar_toolkit + if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: + return False + + if toolkit.disable_eye_blinking and toolkit.disable_eye_movement: + return False + + return True + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + with ProgressTracker(context, 100, "Creating Eye Tracking") as progress: + # Validate setup + validator = EyeTrackingValidator() + is_valid, message = validator.validate_setup(context, toolkit.mesh_name_eye) + if not is_valid: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + # Create backup + backup = EyeTrackingBackup() + if not backup.store_bone_positions(armature): + logger.warning("Failed to create backup") + + try: + # Set active object and mode + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + progress.step("Setting up bones") + + self.mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + + # Set up bones + head = armature.data.edit_bones.get(toolkit.head) + old_eye_left = armature.data.edit_bones.get(toolkit.eye_left) + old_eye_right = armature.data.edit_bones.get(toolkit.eye_right) + + if not toolkit.disable_eye_blinking: + if not all([toolkit.wink_left, toolkit.wink_right, + toolkit.lowerlid_left, toolkit.lowerlid_right]): + self.report({'ERROR'}, t('EyeTracking.error.noShapeSelected')) + return {'CANCELLED'} + + progress.step("Processing vertex groups") + + # Create new eye bones + new_left_eye = armature.data.edit_bones.new('LeftEye') + new_right_eye = armature.data.edit_bones.new('RightEye') + + # Parent them + new_left_eye.parent = head + new_right_eye.parent = head + + # Calculate positions + fix_eye_position(context, old_eye_left, new_left_eye, head, False) + fix_eye_position(context, old_eye_right, new_right_eye, head, True) + + progress.step("Processing shape keys") + + # Process shape keys + if not toolkit.disable_eye_movement: + self.copy_vertex_group(old_eye_left.name, 'LeftEye') + self.copy_vertex_group(old_eye_right.name, 'RightEye') + + # Handle shape keys + shapes = [toolkit.wink_left, toolkit.wink_right, + toolkit.lowerlid_left, toolkit.lowerlid_right] + new_shapes = ['vrc.blink_left', 'vrc.blink_right', + 'vrc.lowerlid_left', 'vrc.lowerlid_right'] + + progress.step("Finalizing setup") + + # Reset modes and cleanup + bpy.ops.object.mode_set(mode='OBJECT') + + # Update scene properties + toolkit.eye_mode = 'TESTING' + + self.report({'INFO'}, t('EyeTracking.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Eye tracking setup failed: {str(e)}") + if backup.restore_bone_positions(get_active_armature(context)): + logger.info("Restored from backup") + return {'CANCELLED'} + +class StartTestingButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.start_eye_testing' + bl_label = t('EyeTracking.testing.start.label') + bl_description = t('EyeTracking.testing.start.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones + + def execute(self, context): + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + armature.data.pose_position = 'POSE' + + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + # Save initial rotations + eye_left.rotation_mode = 'XYZ' + eye_left_rot = copy.deepcopy(eye_left.rotation_euler) + eye_right.rotation_mode = 'XYZ' + eye_right_rot = copy.deepcopy(eye_right.rotation_euler) + + if not all([eye_left, eye_right, eye_left_data, eye_right_data]): + return {'FINISHED'} + + # Reset shape keys + mesh = bpy.data.objects[context.scene.avatar_toolkit.mesh_name_eye] + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + # Clear transforms + for pb in armature.data.bones: + pb.select = True + bpy.ops.pose.transforms_clear() + for pb in armature.data.bones: + pb.select = False + pb.hide = True + + eye_left_data.hide = False + eye_right_data.hide = False + + context.scene.avatar_toolkit.eye_rotation_x = 0 + context.scene.avatar_toolkit.eye_rotation_y = 0 + + return {'FINISHED'} + +class StopTestingButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.stop_eye_testing' + bl_label = t('EyeTracking.testing.stop.label') + bl_description = t('EyeTracking.testing.stop.desc') + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + toolkit = context.scene.avatar_toolkit + + if eye_left: + toolkit.eye_rotation_x = 0 + toolkit.eye_rotation_y = 0 + + if not context.object or context.object.mode != 'POSE': + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='POSE') + + armature = get_active_armature(context) + for pb in armature.data.bones: + pb.hide = False + pb.select = True + bpy.ops.pose.transforms_clear() + for pb in armature.data.bones: + pb.select = False + + mesh = bpy.data.objects[toolkit.mesh_name_eye] + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + eye_left = None + eye_right = None + eye_left_data = None + eye_right_data = None + eye_left_rot = [] + eye_right_rot = [] + + return {'FINISHED'} + +def set_rotation(self, context): + global eye_left, eye_right, eye_left_rot, eye_right_rot + toolkit = context.scene.avatar_toolkit + + if not eye_left or not eye_right: + StartTestingButton.execute(StartTestingButton, context) + return None + + eye_left.rotation_mode = 'XYZ' + eye_right.rotation_mode = 'XYZ' + + x_rotation = math.radians(toolkit.eye_rotation_x) + y_rotation = math.radians(toolkit.eye_rotation_y) + + eye_left.rotation_euler[0] = eye_left_rot[0] + x_rotation + eye_left.rotation_euler[1] = eye_left_rot[1] + y_rotation + + eye_right.rotation_euler[0] = eye_right_rot[0] + x_rotation + eye_right.rotation_euler[1] = eye_right_rot[1] + y_rotation + + return None + +class ResetRotationButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.reset_eye_rotation' + bl_label = t('EyeTracking.reset.label') + bl_description = t('EyeTracking.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and 'LeftEye' in armature.pose.bones and 'RightEye' in armature.pose.bones + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + toolkit.eye_rotation_x = 0 + toolkit.eye_rotation_y = 0 + + global eye_left, eye_right, eye_left_data, eye_right_data + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + for eye in [eye_left, eye_right]: + eye.rotation_mode = 'XYZ' + for i in range(3): + eye.rotation_euler[i] = 0 + + return {'FINISHED'} + +class AdjustEyesButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.adjust_eyes' + bl_label = t('EyeTracking.adjust.label') + bl_description = t('EyeTracking.adjust.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + if toolkit.disable_eye_movement: + return {'FINISHED'} + + mesh_name = toolkit.mesh_name_eye + mesh = bpy.data.objects.get(mesh_name) + + if not mesh: + self.report({'ERROR'}, t('EyeTracking.error.noMesh')) + return {'CANCELLED'} + + for eye in ['LeftEye', 'RightEye']: + if not any(g.group == mesh.vertex_groups[eye].index for v in mesh.data.vertices for g in v.groups): + self.report({'ERROR'}, t('EyeTracking.error.noVertexGroup', bone=eye)) + return {'CANCELLED'} + + armature = get_active_armature(context) + bpy.ops.object.mode_set(mode='EDIT') + + new_eye_left = armature.data.edit_bones.get('LeftEye') + new_eye_right = armature.data.edit_bones.get('RightEye') + old_eye_left = armature.pose.bones.get(toolkit.eye_left) + old_eye_right = armature.pose.bones.get(toolkit.eye_right) + + fix_eye_position(context, old_eye_left, new_eye_left, None, False) + fix_eye_position(context, old_eye_right, new_eye_right, None, True) + + bpy.ops.object.mode_set(mode='POSE') + + global eye_left, eye_right, eye_left_data, eye_right_data + eye_left = armature.pose.bones.get('LeftEye') + eye_right = armature.pose.bones.get('RightEye') + eye_left_data = armature.data.bones.get('LeftEye') + eye_right_data = armature.data.bones.get('RightEye') + + return {'FINISHED'} + +class StartIrisHeightButton(bpy.types.Operator): + bl_idname = 'avatar_toolkit.adjust_iris_height' + bl_label = t('EyeTracking.iris.label') + bl_description = t('EyeTracking.iris.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + if toolkit.disable_eye_movement: + return {'FINISHED'} + + armature = get_active_armature(context) + armature.hide_viewport = True + + mesh = bpy.data.objects[toolkit.mesh_name_eye] + mesh.select_set(True) + context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + + if len(mesh.vertex_groups) > 0: + bpy.ops.mesh.select_mode(type='VERT') + + for vg_name in ['LeftEye', 'RightEye']: + vg = mesh.vertex_groups.get(vg_name) + if vg: + bpy.ops.object.vertex_group_set_active(group=vg.name) + bpy.ops.object.vertex_group_select() + + bm = bmesh.from_edit_mesh(mesh.data) + for v in bm.verts: + if v.select: + v.co.y += toolkit.iris_height * 0.01 + logger.debug(f"Adjusted vertex position: {v.co}") + bmesh.update_edit_mesh(mesh.data) + + return {'FINISHED'} + +class TestBlinking(bpy.types.Operator): + bl_idname = 'avatar_toolkit.test_blinking' + bl_label = t('EyeTracking.blink.test.label') + bl_description = t('EyeTracking.blink.test.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + return (mesh and mesh.data.shape_keys and + all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.blink_left', 'vrc.blink_right'])) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + shapes = ['vrc.blink_left', 'vrc.blink_right'] + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = toolkit.eye_blink_shape if shape_key.name in shapes else 0 + + return {'FINISHED'} + +class TestLowerlid(bpy.types.Operator): + bl_idname = 'avatar_toolkit.test_lowerlid' + bl_label = t('EyeTracking.lowerlid.test.label') + bl_description = t('EyeTracking.lowerlid.test.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + return (mesh and mesh.data.shape_keys and + all(key in mesh.data.shape_keys.key_blocks for key in ['vrc.lowerlid_left', 'vrc.lowerlid_right'])) + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + shapes = OrderedDict() + shapes['vrc.lowerlid_left'] = toolkit.eye_lowerlid_shape + shapes['vrc.lowerlid_right'] = toolkit.eye_lowerlid_shape + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = toolkit.eye_lowerlid_shape if shape_key.name in shapes else 0 + + return {'FINISHED'} + +class ResetBlinkTest(bpy.types.Operator): + bl_idname = 'avatar_toolkit.reset_blink_test' + bl_label = t('EyeTracking.blink.reset.label') + bl_description = t('EyeTracking.blink.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + mesh = bpy.data.objects[toolkit.mesh_name_eye] + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + toolkit.eye_blink_shape = 1 + toolkit.eye_lowerlid_shape = 1 + + return {'FINISHED'} + +def fix_eye_position(context, old_eye, new_eye, head, right_side): + toolkit = context.scene.avatar_toolkit + scale = -toolkit.eye_distance + 1 + mesh = bpy.data.objects[toolkit.mesh_name_eye] + + if not toolkit.disable_eye_movement: + if head: + coords_eye = find_center_vector_of_vertex_group(mesh, old_eye.name) + else: + coords_eye = find_center_vector_of_vertex_group(mesh, new_eye.name) + + if coords_eye is False: + return + + if head: + p1 = mesh.matrix_world @ head.head + p2 = mesh.matrix_world @ coords_eye + length = (p1 - p2).length + logger.debug(f"Eye distance: {length}") + + x_cord, y_cord, z_cord = get_bone_orientations() + + if toolkit.disable_eye_movement: + if head is not None: + new_eye.head[x_cord] = head.head[x_cord] + (0.05 if right_side else -0.05) + new_eye.head[y_cord] = head.head[y_cord] + new_eye.head[z_cord] = head.head[z_cord] + else: + new_eye.head[x_cord] = old_eye.head[x_cord] + scale * (coords_eye[0] - old_eye.head[x_cord]) + new_eye.head[y_cord] = old_eye.head[y_cord] + scale * (coords_eye[1] - old_eye.head[y_cord]) + new_eye.head[z_cord] = old_eye.head[z_cord] + scale * (coords_eye[2] - old_eye.head[z_cord]) + + new_eye.tail[x_cord] = new_eye.head[x_cord] + new_eye.tail[y_cord] = new_eye.head[y_cord] + new_eye.tail[z_cord] = new_eye.head[z_cord] + 0.1 + +def repair_shapekeys(mesh_name, vertex_group): + """Fix VRC shape keys by slightly adjusting vertex positions""" + armature = get_active_armature(bpy.context) + mesh = bpy.data.objects[mesh_name] + mesh.select_set(True) + bpy.context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + bm = bmesh.new() + bm.from_mesh(mesh.data) + bm.verts.ensure_lookup_table() + + logger.debug(f'Processing vertex group: {vertex_group}') + group = mesh.vertex_groups.get(vertex_group) + if group is None: + logger.warning(f'Group {vertex_group} not found, using fallback method') + repair_shapekeys_mouth(mesh_name) + return + + vcoords = None + gi = group.index + for v in mesh.data.vertices: + for g in v.groups: + if g.group == gi: + vcoords = v.co.xyz + + if not vcoords: + return + + logger.info('Repairing shape keys') + moved = False + i = 0 + for key in bm.verts.layers.shape.keys(): + if not key.startswith('vrc.'): + continue + logger.debug(f'Repairing shape: {key}') + value = bm.verts.layers.shape.get(key) + for index, vert in enumerate(bm.verts): + if vert.co.xyz == vcoords: + if index < i: + continue + shapekey = vert + shapekey_coords = mesh.matrix_world @ shapekey[value] + shapekey_coords[0] -= 0.00007 * randBoolNumber() + shapekey_coords[1] -= 0.00007 * randBoolNumber() + shapekey_coords[2] -= 0.00007 * randBoolNumber() + shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords + logger.debug(f'Repaired shape: {key}') + i += 1 + moved = True + break + + bm.to_mesh(mesh.data) + + if not moved: + logger.warning('Shape key repair failed, using random method') + repair_shapekeys_mouth(mesh_name) + +def randBoolNumber(): + return -1 if random() < 0.5 else 1 + +def repair_shapekeys_mouth(mesh_name): + mesh = bpy.data.objects[mesh_name] + mesh.select_set(True) + bpy.context.view_layer.objects.active = mesh + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.object.mode_set(mode='OBJECT') + + bm = bmesh.new() + bm.from_mesh(mesh.data) + bm.verts.ensure_lookup_table() + + moved = False + for key in bm.verts.layers.shape.keys(): + if not key.startswith('vrc'): + continue + value = bm.verts.layers.shape.get(key) + for vert in bm.verts: + shapekey = vert + shapekey_coords = mesh.matrix_world @ shapekey[value] + shapekey_coords[0] -= 0.00007 + shapekey_coords[1] -= 0.00007 + shapekey_coords[2] -= 0.00007 + shapekey[value] = mesh.matrix_world.inverted() @ shapekey_coords + moved = True + break + + bm.to_mesh(mesh.data) + + if not moved: + logger.error('Random shape key repair failed') + +def get_bone_orientations(): + """Get bone orientation axes""" + return (0, 1, 2) # x, y, z coordinates + +def find_center_vector_of_vertex_group(mesh, group_name): + """Calculate center position of vertex group""" + group = mesh.vertex_groups.get(group_name) + if not group: + return False + + vertices = [] + for vert in mesh.data.vertices: + for g in vert.groups: + if g.group == group.index: + vertices.append(vert.co) + + if not vertices: + return False + + return sum((v for v in vertices), mathutils.Vector()) / len(vertices) + +def vertex_group_exists(mesh_obj, group_name): + """Check if vertex group exists and has weights""" + if not mesh_obj or group_name not in mesh_obj.vertex_groups: + return False + + group = mesh_obj.vertex_groups[group_name] + for vert in mesh_obj.data.vertices: + for g in vert.groups: + if g.group == group.index and g.weight > 0: + return True + return False + +def copy_vertex_group(self, vertex_group, rename_to): + """Copy vertex group with new name""" + vertex_group_index = 0 + for group in self.mesh.vertex_groups: + if group.name == vertex_group: + self.mesh.vertex_groups.active_index = vertex_group_index + bpy.ops.object.vertex_group_copy() + self.mesh.vertex_groups[vertex_group + '_copy'].name = rename_to + break + vertex_group_index += 1 + +def copy_shape_key(self, context, from_shape, new_names, new_index): + """Copy shape key with new name""" + blinking = not context.scene.avatar_toolkit.disable_eye_blinking + new_name = new_names[new_index - 1] + + # Rename existing shapekey if it exists + for shapekey in self.mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + if shapekey.name == new_name: + shapekey.name = shapekey.name + '_old' + if from_shape == new_name: + from_shape = shapekey.name + + # Create new shape key + for index, shapekey in enumerate(self.mesh.data.shape_keys.key_blocks): + if from_shape == shapekey.name: + self.mesh.active_shape_key_index = index + shapekey.value = 1 + self.mesh.shape_key_add(name=new_name, from_mix=blinking) + break + + # Reset shape keys + for shapekey in self.mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + self.mesh.active_shape_key_index = 0 + + return from_shape + +# Global state for eye tracking +eye_left = None +eye_right = None +eye_left_data = None +eye_right_data = None +eye_left_rot = [] +eye_right_rot = [] + +class VertexGroupCache: + """Cache for vertex group operations""" + _cache = {} + + @classmethod + def get_vertex_indices(cls, mesh_name: str, group_name: str) -> Optional[set]: + cache_key = f"{mesh_name}_{group_name}" + + if cache_key in cls._cache: + return cls._cache[cache_key] + + mesh = bpy.data.objects.get(mesh_name) + if not mesh: + return None + + group = mesh.vertex_groups.get(group_name) + if not group: + return None + + indices = {v.index for v in mesh.data.vertices + if any(g.group == group.index for g in v.groups)} + + cls._cache[cache_key] = indices + return indices + + @classmethod + def clear_cache(cls): + cls._cache.clear() + +class RotateEyeBonesForAv3Button(Operator): + """Reorient eye bones for proper VRChat eye tracking""" + bl_idname = "avatar_toolkit.rotate_eye_bones" + bl_label = t("EyeTracking.rotate.label") + bl_description = t("EyeTracking.rotate.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + armature = get_active_armature(context) + return armature and all(bone in armature.pose.bones for bone in ['LeftEye', 'RightEye']) + + def execute(self, context): + armature = get_active_armature(context) + straight_up_matrix = mathutils.Matrix.Rotation(math.pi/2, 3, 'X') + + bpy.ops.object.mode_set(mode='EDIT') + + for eye_name in ['LeftEye', 'RightEye']: + eye_bone = armature.data.edit_bones[eye_name] + new_matrix = straight_up_matrix.to_4x4() + new_matrix.translation = eye_bone.matrix.translation + eye_bone.matrix = new_matrix + + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} + +class ResetEyeTrackingButton(Operator): + """Reset all eye tracking settings and state""" + bl_idname = 'avatar_toolkit.reset_eye_tracking' + bl_label = t('EyeTracking.reset.label') + bl_description = t('EyeTracking.reset.desc') + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + eye_left = eye_right = eye_left_data = eye_right_data = None + eye_left_rot = eye_right_rot = [] + context.scene.avatar_toolkit.eye_mode = 'CREATION' + return {'FINISHED'} + +def validate_weights(mesh_obj: Object, vertex_group: str) -> bool: + """Validate vertex group weights""" + group = mesh_obj.vertex_groups.get(vertex_group) + if not group: + return False + + for vertex in mesh_obj.data.vertices: + for group_element in vertex.groups: + if group_element.group == group.index and group_element.weight > 0: + return True + return False + +def get_eye_bone_names(armature: Object) -> Dict[str, str]: + """Get standardized eye bone names""" + eye_bones = {'left': None, 'right': None} + + for bone in armature.data.bones: + if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['left']): + eye_bones['left'] = bone.name + if any(name.lower() in bone.name.lower() for name in VALID_EYE_NAMES['right']): + eye_bones['right'] = bone.name + + return eye_bones + +def stop_testing(context: Context) -> None: + """Stop eye tracking testing mode""" + global eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot + + if not all([eye_left, eye_right, eye_left_data, eye_right_data, eye_left_rot, eye_right_rot]): + return + + armature = get_active_armature(context) + if not armature: + return + + bpy.ops.object.mode_set(mode='POSE') + + # Reset rotations + context.scene.avatar_toolkit.eye_rotation_x = 0 + context.scene.avatar_toolkit.eye_rotation_y = 0 + + # Clear transforms + for bone in armature.data.bones: + bone.hide = False + bone.select = True + bpy.ops.pose.transforms_clear() + + # Reset shape keys + mesh = bpy.data.objects.get(context.scene.avatar_toolkit.mesh_name_eye) + if mesh and mesh.data.shape_keys: + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + # Clear globals + eye_left = eye_right = eye_left_data = eye_right_data = None + eye_left_rot = eye_right_rot = [] diff --git a/functions/visemes.py b/functions/visemes.py new file mode 100644 index 0000000..f6ac3b5 --- /dev/null +++ b/functions/visemes.py @@ -0,0 +1,335 @@ +# MIT License +# This code was taken from Cats Blender Plugin Unoffical, some of this code is by the original developers, however was improved by myself. +# Didn't think it was necessary to re-make something that works well. + +import bpy +from typing import Dict, List, Optional, Tuple, Any, Set +from bpy.types import Operator, Context, Object, ShapeKey +from collections import OrderedDict +from ..core.logging_setup import logger +from ..core.translations import t +from ..core.common import ( + get_active_armature, + validate_armature, + get_all_meshes, + validate_mesh_for_pose +) + +class VisemeCache: + """Caches generated viseme shape data""" + _cache: Dict = {} + + @classmethod + def get_cached_shape(cls, key: str, mix_data: List) -> Optional[List]: + cache_key = (key, tuple(tuple(x) for x in mix_data)) + return cls._cache.get(cache_key) + + @classmethod + def cache_shape(cls, key: str, mix_data: List, shape_data: List) -> None: + cache_key = (key, tuple(tuple(x) for x in mix_data)) + cls._cache[cache_key] = shape_data + +class VisemePreview: + """Handles viseme preview functionality""" + _preview_data: Dict = {} + _active: bool = False + _preview_shapes: Optional[OrderedDict] = None + + @classmethod + def start_preview(cls, context: Context, mesh: Object, shapes: List[str]) -> bool: + if not mesh or not mesh.data or not mesh.data.shape_keys: + return False + + cls._active = True + cls._preview_data = {} + + # Store original values + for shape_key in mesh.data.shape_keys.key_blocks: + cls._preview_data[shape_key.name] = shape_key.value + + # Get properties from avatar_toolkit + props = context.scene.avatar_toolkit + shape_a = props.mouth_a + shape_o = props.mouth_o + shape_ch = props.mouth_ch + + + cls._preview_shapes = OrderedDict() + cls._preview_shapes['vrc.v_aa'] = {'mix': [[(shape_a), (0.9998)]]} + cls._preview_shapes['vrc.v_ch'] = {'mix': [[(shape_ch), (0.9996)]]} + cls._preview_shapes['vrc.v_dd'] = {'mix': [[(shape_a), (0.3)], [(shape_ch), (0.7)]]} + cls._preview_shapes['vrc.v_ih'] = {'mix': [[(shape_ch), (0.7)], [(shape_o), (0.3)]]} + cls._preview_shapes['vrc.v_ff'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.4)]]} + cls._preview_shapes['vrc.v_e'] = {'mix': [[(shape_a), (0.5)], [(shape_ch), (0.2)]]} + cls._preview_shapes['vrc.v_kk'] = {'mix': [[(shape_a), (0.7)], [(shape_ch), (0.4)]]} + cls._preview_shapes['vrc.v_nn'] = {'mix': [[(shape_a), (0.2)], [(shape_ch), (0.7)]]} + cls._preview_shapes['vrc.v_oh'] = {'mix': [[(shape_a), (0.2)], [(shape_o), (0.8)]]} + cls._preview_shapes['vrc.v_ou'] = {'mix': [[(shape_o), (0.9994)]]} + cls._preview_shapes['vrc.v_pp'] = {'mix': [[(shape_a), (0.0004)], [(shape_o), (0.0004)]]} + cls._preview_shapes['vrc.v_rr'] = {'mix': [[(shape_ch), (0.5)], [(shape_o), (0.3)]]} + cls._preview_shapes['vrc.v_sil'] = {'mix': [[(shape_a), (0.0002)], [(shape_ch), (0.0002)]]} + cls._preview_shapes['vrc.v_ss'] = {'mix': [[(shape_ch), (0.8)]]} + cls._preview_shapes['vrc.v_th'] = {'mix': [[(shape_a), (0.4)], [(shape_o), (0.15)]]} + + return True + + @classmethod + def update_preview(cls, context: Context) -> None: + if not cls._active or not cls._preview_shapes: + return + + mesh = context.active_object + props = context.scene.avatar_toolkit + viseme_data = cls._preview_shapes.get(props.viseme_preview_selection) + if viseme_data: + cls.show_viseme(context, mesh, props.viseme_preview_selection, viseme_data['mix']) + + @classmethod + def show_viseme(cls, context: Context, mesh: Object, viseme_name: str, mix_data: List) -> None: + if not cls._active: + return + + # Get shape intensity from properties + intensity = context.scene.avatar_toolkit.shape_intensity + + for shape_key in mesh.data.shape_keys.key_blocks: + shape_key.value = 0 + + for shape_name, value in mix_data: + if shape_name in mesh.data.shape_keys.key_blocks: + # Apply intensity to the preview value + mesh.data.shape_keys.key_blocks[shape_name].value = value * intensity + + context.view_layer.update() + + + @classmethod + def end_preview(cls, mesh: Object) -> None: + if not cls._active: + return + + for shape_name, value in cls._preview_data.items(): + if shape_name in mesh.data.shape_keys.key_blocks: + mesh.data.shape_keys.key_blocks[shape_name].value = value + + cls._active = False + cls._preview_data.clear() + cls._preview_shapes = None + +class ATOOLKIT_OT_preview_visemes(Operator): + bl_idname = "avatar_toolkit.preview_visemes" + bl_label = t("Visemes.preview_label") + bl_description = t("Visemes.preview_desc") + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid and context.active_object and context.active_object.type == 'MESH' + + def execute(self, context: Context) -> Set[str]: + props = context.scene.avatar_toolkit + mesh = context.active_object + + if props.viseme_preview_mode: + VisemePreview.end_preview(mesh) + props.viseme_preview_mode = False + else: + if not mesh.data.shape_keys: + self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) + return {'CANCELLED'} + + if VisemePreview.start_preview(context, mesh, [props.mouth_a, props.mouth_o, props.mouth_ch]): + props.viseme_preview_mode = True + props.viseme_preview_selection = 'vrc.v_aa' + + return {'FINISHED'} + +def validate_deformation(mesh, mix_data): + """Validates if shape key deformations are within reasonable ranges""" + base_coords = [v.co.copy() for v in mesh.data.shape_keys.key_blocks['Basis'].data] + max_deform = 0 + + for shape_data in mix_data: + shape_name, value = shape_data + if shape_name in mesh.data.shape_keys.key_blocks: + shape_key = mesh.data.shape_keys.key_blocks[shape_name] + for i, v in enumerate(shape_key.data): + deform = (v.co - base_coords[i]).length * value + max_deform = max(max_deform, deform) + + mesh_size = max(mesh.dimensions) + return max_deform < (mesh_size * 0.4) + +class ATOOLKIT_OT_create_visemes(Operator): + bl_idname = "avatar_toolkit.create_visemes" + bl_label = t("Visemes.create_label") + bl_description = t("Visemes.create_desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + if not armature: + return False + valid, _ = validate_armature(armature) + return valid and context.active_object and context.active_object.type == 'MESH' + + def execute(self, context: Context) -> Set[str]: + props = context.scene.avatar_toolkit + mesh = context.active_object + + if not mesh.data.shape_keys: + self.report({'ERROR'}, t("Visemes.error.no_shapekeys")) + return {'CANCELLED'} + + if props.mouth_a == "Basis" or props.mouth_o == "Basis" or props.mouth_ch == "Basis": + self.report({'ERROR'}, t("Visemes.error.select_shapekeys")) + return {'CANCELLED'} + + try: + self.create_visemes(context, mesh) + self.report({'INFO'}, t("Visemes.success")) + return {'FINISHED'} + except Exception as e: + logger.error(f"Error creating visemes: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + + def create_visemes(self, context: Context, mesh: Object) -> None: + """Creates viseme shape keys by mixing existing shape keys""" + props = context.scene.avatar_toolkit + wm = context.window_manager + + # Store original shape key names + shapes = [props.mouth_a, props.mouth_o, props.mouth_ch] + renamed_shapes = shapes.copy() + + # Temporarily rename selected shapes to avoid conflicts + for shapekey in mesh.data.shape_keys.key_blocks: + if shapekey.name == props.mouth_a: + shapekey.name = f"{shapekey.name}_old" + props.mouth_a = shapekey.name + renamed_shapes[0] = shapekey.name + elif shapekey.name == props.mouth_o: + if props.mouth_a != props.mouth_o: + shapekey.name = f"{shapekey.name}_old" + props.mouth_o = shapekey.name + renamed_shapes[1] = shapekey.name + elif shapekey.name == props.mouth_ch: + if props.mouth_a != props.mouth_ch and props.mouth_o != props.mouth_ch: + shapekey.name = f"{shapekey.name}_old" + props.mouth_ch = shapekey.name + renamed_shapes[2] = shapekey.name + + # Define viseme shape key data + shapekey_data = OrderedDict() + shapekey_data['vrc.v_aa'] = {'mix': [[(props.mouth_a), (0.9998)]]} + shapekey_data['vrc.v_ch'] = {'mix': [[(props.mouth_ch), (0.9996)]]} + shapekey_data['vrc.v_dd'] = {'mix': [[(props.mouth_a), (0.3)], [(props.mouth_ch), (0.7)]]} + shapekey_data['vrc.v_ih'] = {'mix': [[(props.mouth_ch), (0.7)], [(props.mouth_o), (0.3)]]} + shapekey_data['vrc.v_ff'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.4)]]} + shapekey_data['vrc.v_e'] = {'mix': [[(props.mouth_a), (0.5)], [(props.mouth_ch), (0.2)]]} + shapekey_data['vrc.v_kk'] = {'mix': [[(props.mouth_a), (0.7)], [(props.mouth_ch), (0.4)]]} + shapekey_data['vrc.v_nn'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_ch), (0.7)]]} + shapekey_data['vrc.v_oh'] = {'mix': [[(props.mouth_a), (0.2)], [(props.mouth_o), (0.8)]]} + shapekey_data['vrc.v_ou'] = {'mix': [[(props.mouth_o), (0.9994)]]} + shapekey_data['vrc.v_pp'] = {'mix': [[(props.mouth_a), (0.0004)], [(props.mouth_o), (0.0004)]]} + shapekey_data['vrc.v_rr'] = {'mix': [[(props.mouth_ch), (0.5)], [(props.mouth_o), (0.3)]]} + shapekey_data['vrc.v_sil'] = {'mix': [[(props.mouth_a), (0.0002)], [(props.mouth_ch), (0.0002)]]} + shapekey_data['vrc.v_ss'] = {'mix': [[(props.mouth_ch), (0.8)]]} + shapekey_data['vrc.v_th'] = {'mix': [[(props.mouth_a), (0.4)], [(props.mouth_o), (0.15)]]} + + # Create progress tracker + total_steps = len(shapekey_data) + wm.progress_begin(0, total_steps) + + # Create viseme shape keys + for index, (key, data) in enumerate(shapekey_data.items()): + wm.progress_update(index) + + # Check cache first + cached_data = VisemeCache.get_cached_shape(key, data['mix']) + if cached_data: + continue + + # Create new shape key + self.mix_shapekey(context, renamed_shapes, data['mix'], key) + + # Cache the new shape key data + shape_data = [v.co.copy() for v in mesh.data.shape_keys.key_blocks[key].data] + VisemeCache.cache_shape(key, data['mix'], shape_data) + + # Restore original shape key names + self.restore_shape_names(context, mesh, shapes, renamed_shapes) + + # Cleanup and finalize + mesh.active_shape_key_index = 0 + wm.progress_end() + + def mix_shapekey(self, context: Context, shapes: List[str], mix_data: List, new_name: str) -> None: + """Creates a new shape key by mixing existing ones""" + mesh = context.active_object + + # Remove existing shape key if it exists + if new_name in mesh.data.shape_keys.key_blocks: + mesh.active_shape_key_index = mesh.data.shape_keys.key_blocks.find(new_name) + bpy.ops.object.shape_key_remove() + + # Reset all shape keys + for shapekey in mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + + # Set mix values + for shape_name, value in mix_data: + if shape_name in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks[shape_name] + shapekey.value = value + + # Create mixed shape key + mesh.shape_key_add(name=new_name, from_mix=True) + + # Reset values and restore shape key settings + for shapekey in mesh.data.shape_keys.key_blocks: + shapekey.value = 0 + if shapekey.name in shapes: + shapekey.slider_max = 1 + + def restore_shape_names(self, context: Context, mesh: Object, original_names: List[str], current_names: List[str]) -> None: + """Restores original shape key names""" + props = context.scene.avatar_toolkit + + # Restore mouth_a + if original_names[0] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[0]) + if shapekey: + shapekey.name = original_names[0] + if current_names[2] == current_names[0]: + current_names[2] = original_names[0] + if current_names[1] == current_names[0]: + current_names[1] = original_names[0] + current_names[0] = original_names[0] + + # Restore mouth_o + if original_names[1] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[1]) + if shapekey: + shapekey.name = original_names[1] + if current_names[2] == current_names[1]: + current_names[2] = original_names[1] + current_names[1] = original_names[1] + + # Restore mouth_ch + if original_names[2] not in mesh.data.shape_keys.key_blocks: + shapekey = mesh.data.shape_keys.key_blocks.get(current_names[2]) + if shapekey: + shapekey.name = original_names[2] + current_names[2] = original_names[2] + + # Update properties + props.mouth_a = current_names[0] + props.mouth_o = current_names[1] + props.mouth_ch = current_names[2] diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 7067f0b..635bc20 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -207,6 +207,103 @@ "MMD.connect_bones": "Connect Bones", "MMD.connect_bones_desc": "Connect bones in chain where appropriate", + "Visemes.panel_label": "Visemes", + "Visemes.shape_selection": "Shape Key Selection", + "Visemes.controls": "Viseme Controls", + "Visemes.no_shapekeys": "Select a mesh with shape keys", + "Visemes.mouth_a": "A Shape", + "Visemes.mouth_a_desc": "Shape key for 'A' sound", + "Visemes.mouth_o": "O Shape", + "Visemes.mouth_o_desc": "Shape key for 'O' sound", + "Visemes.mouth_ch": "CH Shape", + "Visemes.mouth_ch_desc": "Shape key for 'CH' sound", + "Visemes.shape_intensity": "Shape Intensity", + "Visemes.shape_intensity_desc": "Intensity multiplier for viseme shapes", + "Visemes.start_preview": "Start Preview", + "Visemes.stop_preview": "Stop Preview", + "Visemes.preview_mode_desc": "Toggle viseme preview mode", + "Visemes.preview_selection": "Preview Selection", + "Visemes.preview_selection_desc": "Select viseme to preview", + "Visemes.preview_label": "Preview Visemes", + "Visemes.preview_desc": "Preview viseme shapes in viewport", + "Visemes.create_label": "Create Visemes", + "Visemes.create_desc": "Create VRC viseme shape keys", + "Visemes.error.no_shapekeys": "Mesh has no shape keys", + "Visemes.error.select_shapekeys": "Please select shape keys for A, O and CH", + "Visemes.success": "Visemes created successfully", + + "EyeTracking.label": "Eye Tracking", + "EyeTracking.setup": "Eye Tracking Setup", + "EyeTracking.mesh_select": "Mesh Selection", + "EyeTracking.bones": "Bone Selection", + "EyeTracking.head_bone": "Head Bone", + "EyeTracking.eye_left": "Left Eye Bone", + "EyeTracking.eye_right": "Right Eye Bone", + "EyeTracking.shapekeys": "Shape Key Selection", + "EyeTracking.options": "Options", + "EyeTracking.rotation": "Eye Rotation", + "EyeTracking.rotation.x": "Vertical Rotation", + "EyeTracking.rotation.y": "Horizontal Rotation", + "EyeTracking.adjust": "Eye Adjustments", + "EyeTracking.blinking": "Blinking Controls", + "EyeTracking.no_shapekeys": "No shape keys found on selected mesh", + "EyeTracking.no_armature": "No armature selected", + "EyeTracking.no_mesh": "No mesh found", + "EyeTracking.create.label": "Create Eye Tracking", + "EyeTracking.create.desc": "Set up eye tracking bones and shape keys", + "EyeTracking.testing.start.label": "Start Testing", + "EyeTracking.testing.start.desc": "Enter eye tracking test mode", + "EyeTracking.testing.stop.label": "Stop Testing", + "EyeTracking.testing.stop.desc": "Exit eye tracking test mode", + "EyeTracking.reset.label": "Reset Eye Tracking", + "EyeTracking.reset.desc": "Reset all eye tracking settings", + "EyeTracking.rotate.label": "Rotate Eye Bones", + "EyeTracking.rotate.desc": "Rotate eye bones for VRChat compatibility", + "EyeTracking.iris.label": "Adjust Iris Height", + "EyeTracking.iris.desc": "Adjust the height of iris vertices", + "EyeTracking.blink.test.label": "Test Blink", + "EyeTracking.blink.test.desc": "Test eye blinking shape keys", + "EyeTracking.lowerlid.test.label": "Test Lower Lid", + "EyeTracking.lowerlid.test.desc": "Test lower lid shape keys", + "EyeTracking.blink.reset.label": "Reset Blink Test", + "EyeTracking.blink.reset.desc": "Reset blink testing values", + "EyeTracking.validation.noArmature": "No armature found in scene", + "EyeTracking.validation.noMesh": "Mesh '{mesh}' not found", + "EyeTracking.validation.noShapekeys": "Selected mesh has no shape keys", + "EyeTracking.validation.leftEye": "Left Eye", + "EyeTracking.validation.rightEye": "Right Eye", + "EyeTracking.validation.missingGroups": "Missing vertex groups: {groups}", + "EyeTracking.validation.missingBones": "Missing required bones: {bones}", + "EyeTracking.validation.success": "Eye tracking setup validated successfully", + "EyeTracking.error.noMesh": "No mesh selected for eye tracking", + "EyeTracking.error.noVertexGroup": "No vertex group found for bone: {bone}", + "EyeTracking.error.noShapeSelected": "Please select all required shape keys", + "EyeTracking.success": "Eye tracking setup completed successfully", + "EyeTracking.mode_select": "Mode Selection", + "EyeTracking.mesh_setup": "Mesh Setup", + "EyeTracking.bone_setup": "Bone Setup", + "EyeTracking.shapekey_setup": "Shape Key Setup", + "EyeTracking.testing": "Testing Mode", + "EyeTracking.rotation_controls": "Eye Rotation Controls", + "EyeTracking.adjustments": "Eye Adjustments", + "EyeTracking.blink_testing": "Blink Testing", + "EyeTracking.wink_left": "Left Wink", + "EyeTracking.wink_right": "Right Wink", + "EyeTracking.lowerlid_left": "Left Lower Lid", + "EyeTracking.lowerlid_right": "Right Lower Lid", + "EyeTracking.mode.creation": "Creation Mode", + "EyeTracking.mode.testing": "Testing Mode", + "EyeTracking.disable_blinking": "Disable Eye Blinking", + "EyeTracking.disable_movement": "Disable Eye Movement", + "EyeTracking.distance": "Eye Distance", + "EyeTracking.distance_desc": "Adjust the distance between eyes", + "EyeTracking.mode": "Eye Tracking Mode", + "EyeTracking.mesh_name": "Mesh", + "EyeTracking.mesh_name_desc": "Select mesh for eye tracking", + "EyeTracking.head_bone_desc": "Select head bone", + "EyeTracking.eye_left_desc": "Select left eye bone", + "EyeTracking.eye_right_desc": "Select right eye bone", + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py new file mode 100644 index 0000000..4ce4017 --- /dev/null +++ b/ui/eye_tracking_panel.py @@ -0,0 +1,143 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.common import get_active_armature, get_all_meshes +from ..functions.eye_tracking import ( + CreateEyesButton, + StartTestingButton, + StopTestingButton, + ResetRotationButton, + AdjustEyesButton, + TestBlinking, + TestLowerlid, + ResetBlinkTest, + ResetEyeTrackingButton, + RotateEyeBonesForAv3Button +) + +class AvatarToolKit_PT_EyeTrackingPanel(Panel): + """Panel containing eye tracking setup and testing tools""" + bl_label = t("EyeTracking.label") + bl_idname = "VIEW3D_PT_avatar_toolkit_eye_tracking" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the eye tracking panel interface""" + layout = self.layout + toolkit = context.scene.avatar_toolkit + + # Mode Selection Box + mode_box = layout.box() + col = mode_box.column(align=True) + col.label(text=t("EyeTracking.mode_select"), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "eye_mode", expand=True) + + if toolkit.eye_mode == 'CREATION': + # Mesh Setup Box + mesh_box = layout.box() + col = mesh_box.column(align=True) + col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA') + col.separator(factor=0.5) + col.prop(toolkit, "mesh_name_eye", text="") + + # Bone Setup Box + bone_box = layout.box() + col = bone_box.column(align=True) + col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') + col.separator(factor=0.5) + + armature = get_active_armature(context) + if armature: + col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) + col.prop_search(toolkit, "eye_left", armature.data, "bones", text=t("EyeTracking.eye_left")) + col.prop_search(toolkit, "eye_right", armature.data, "bones", text=t("EyeTracking.eye_right")) + else: + col.label(text=t("EyeTracking.no_armature"), icon='ERROR') + + # Shapekey Setup Box + shape_box = layout.box() + col = shape_box.column(align=True) + col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') + col.separator(factor=0.5) + + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + if mesh and mesh.data.shape_keys: + col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left")) + col.prop_search(toolkit, "wink_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_right")) + col.prop_search(toolkit, "lowerlid_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_left")) + col.prop_search(toolkit, "lowerlid_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_right")) + else: + col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR') + + # Options Box + options_box = layout.box() + col = options_box.column(align=True) + col.label(text=t("EyeTracking.options"), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "disable_eye_blinking") + col.prop(toolkit, "disable_eye_movement") + if not toolkit.disable_eye_movement: + col.prop(toolkit, "eye_distance") + + # Create Button + row = layout.row(align=True) + row.scale_y = 1.5 + row.operator(CreateEyesButton.bl_idname, icon='PLAY') + + else: + if context.mode != 'POSE': + # Testing Start Box + test_box = layout.box() + col = test_box.column(align=True) + col.label(text=t("EyeTracking.testing"), icon='PLAY') + col.separator(factor=0.5) + row = col.row(align=True) + row.scale_y = 1.5 + row.operator(StartTestingButton.bl_idname, icon='PLAY') + else: + # Eye Rotation Box + rotation_box = layout.box() + col = rotation_box.column(align=True) + col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') + col.separator(factor=0.5) + col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x")) + col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y")) + col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK') + + # Eye Adjustment Box + adjust_box = layout.box() + col = adjust_box.column(align=True) + col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER') + col.separator(factor=0.5) + col.prop(toolkit, "eye_distance") + col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO') + + # Blinking Test Box + blink_box = layout.box() + col = blink_box.column(align=True) + col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF') + col.separator(factor=0.5) + row = col.row(align=True) + row.prop(toolkit, "eye_blink_shape") + row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF') + row = col.row(align=True) + row.prop(toolkit, "eye_lowerlid_shape") + row.operator(TestLowerlid.bl_idname, icon='RESTRICT_VIEW_OFF') + col.operator(ResetBlinkTest.bl_idname, icon='LOOP_BACK') + + # Stop Testing Button + row = layout.row(align=True) + row.scale_y = 1.5 + row.operator(StopTestingButton.bl_idname, icon='PAUSE') + + # Reset Button + row = layout.row(align=True) + row.operator(ResetEyeTrackingButton.bl_idname, icon='FILE_REFRESH') diff --git a/ui/main_panel.py b/ui/main_panel.py index 6ae130d..16bf637 100644 --- a/ui/main_panel.py +++ b/ui/main_panel.py @@ -31,7 +31,6 @@ class AvatarToolKit_PT_AvatarToolkitPanel(Panel): bl_space_type: str = 'VIEW_3D' bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME - bl_options: Set[str] = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the main panel layout""" diff --git a/ui/mmd_panel.py b/ui/mmd_panel.py index 96210b5..d7333dc 100644 --- a/ui/mmd_panel.py +++ b/ui/mmd_panel.py @@ -1,46 +1,49 @@ -import bpy -from typing import Set -from bpy.types import Panel, Context, UILayout -from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME -from ..core.translations import t +# MMD Tools disabled for the time being unto it can be fixed. -class AvatarToolKit_PT_MMDPanel(Panel): - """Panel containing MMD bone standardization and cleanup tools""" - bl_label = t("MMD.label") - bl_idname = "OBJECT_PT_avatar_toolkit_mmd" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = CATEGORY_NAME - bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 +# import bpy +# from typing import Set +# from bpy.types import Panel, Context, UILayout +# from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +# from ..core.translations import t - def draw(self, context: Context) -> None: - layout: UILayout = self.layout - toolkit = context.scene.avatar_toolkit +# class AvatarToolKit_PT_MMDPanel(Panel): +# """Panel containing MMD bone standardization and cleanup tools""" +# bl_label = t("MMD.label") +# bl_idname = "OBJECT_PT_avatar_toolkit_mmd" +# bl_space_type = 'VIEW_3D' +# bl_region_type = 'UI' +# bl_category = CATEGORY_NAME +# bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname +# bl_order = 3 +# bl_options = {'DEFAULT_CLOSED'} + +# def draw(self, context: Context) -> None: +# layout: UILayout = self.layout +# toolkit = context.scene.avatar_toolkit # Bone Settings Box - bone_box: UILayout = layout.box() - col: UILayout = bone_box.column(align=True) - col.label(text=t("MMD.bone_settings"), icon='BONE_DATA') - col.separator(factor=0.5) - col.prop(toolkit, "keep_twist_bones") - col.prop(toolkit, "keep_upper_chest") - col.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') +# bone_box: UILayout = layout.box() +# col: UILayout = bone_box.column(align=True) +# col.label(text=t("MMD.bone_settings"), icon='BONE_DATA') +# col.separator(factor=0.5) +# col.prop(toolkit, "keep_twist_bones") +# col.prop(toolkit, "keep_upper_chest") +# col.operator("avatar_toolkit.standardize_mmd", icon='BONE_DATA') # Mesh Tools Box - mesh_box: UILayout = layout.box() - col = mesh_box.column(align=True) - col.label(text=t("MMD.mesh_tools"), icon='MESH_DATA') - col.separator(factor=0.5) - row: UILayout = col.row(align=True) - row.operator("avatar_toolkit.fix_meshes", icon='MODIFIER') - row.operator("avatar_toolkit.validate_meshes", icon='CHECKMARK') +# mesh_box: UILayout = layout.box() +# col = mesh_box.column(align=True) +# col.label(text=t("MMD.mesh_tools"), icon='MESH_DATA') +# col.separator(factor=0.5) +# row: UILayout = col.row(align=True) +# row.operator("avatar_toolkit.fix_meshes", icon='MODIFIER') +# row.operator("avatar_toolkit.validate_meshes", icon='CHECKMARK') # Cleanup Box - cleanup_box: UILayout = layout.box() - col = cleanup_box.column(align=True) - col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') - col.separator(factor=0.5) - col.operator("avatar_toolkit.cleanup_mmd", icon='SHADERFX') - col.operator("avatar_toolkit.convert_mmd_morphs", icon='SHAPEKEY_DATA') - col.operator("avatar_toolkit.reparent_meshes", icon='OUTLINER_OB_ARMATURE') +# cleanup_box: UILayout = layout.box() +# col = cleanup_box.column(align=True) +# col.label(text=t("MMD.cleanup"), icon='BRUSH_DATA') +# col.separator(factor=0.5) +# col.operator("avatar_toolkit.cleanup_mmd", icon='SHADERFX') +# col.operator("avatar_toolkit.convert_mmd_morphs", icon='SHAPEKEY_DATA') +# col.operator("avatar_toolkit.reparent_meshes", icon='OUTLINER_OB_ARMATURE') diff --git a/ui/optimization_panel.py b/ui/optimization_panel.py index 2e65ec1..57e9802 100644 --- a/ui/optimization_panel.py +++ b/ui/optimization_panel.py @@ -13,6 +13,7 @@ class AvatarToolKit_PT_OptimizationPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 1 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draws the optimization panel interface with material, mesh cleanup and join mesh tools""" diff --git a/ui/settings_panel.py b/ui/settings_panel.py index 67b782f..6650698 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -37,6 +37,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 5 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the settings panel layout with language selection""" diff --git a/ui/tools_panel.py b/ui/tools_panel.py index a55d734..ce0614a 100644 --- a/ui/tools_panel.py +++ b/ui/tools_panel.py @@ -13,6 +13,7 @@ class AvatarToolKit_PT_ToolsPanel(Panel): bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 2 + bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: """Draw the tools panel interface""" diff --git a/ui/visemes_panel.py b/ui/visemes_panel.py new file mode 100644 index 0000000..33f4d9f --- /dev/null +++ b/ui/visemes_panel.py @@ -0,0 +1,60 @@ +from bpy.types import Panel, Context, UILayout +from ..core.translations import t +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME + +class AvatarToolKit_PT_VisemesPanel(Panel): + """Panel containing viseme creation and preview tools""" + bl_label: str = t("Visemes.panel_label") + bl_idname: str = "VIEW3D_PT_avatar_toolkit_visemes" + bl_space_type: str = 'VIEW_3D' + bl_region_type: str = 'UI' + bl_category: str = CATEGORY_NAME + bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order: int = 4 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the visemes panel interface""" + layout: UILayout = self.layout + props = context.scene.avatar_toolkit + + # Check for valid mesh with shape keys + if not context.active_object or context.active_object.type != 'MESH' or not context.active_object.data.shape_keys: + layout.label(text=t("Visemes.no_shapekeys")) + return + + # Shape Key Selection Box + shape_box: UILayout = layout.box() + col: UILayout = shape_box.column(align=True) + col.label(text=t("Visemes.shape_selection"), icon='SHAPEKEY_DATA') + col.separator(factor=0.5) + + # Shape key selection with valid data + shape_keys = context.active_object.data.shape_keys + col.prop_search(props, "mouth_a", shape_keys, "key_blocks", text=t("Visemes.mouth_a")) + col.prop_search(props, "mouth_o", shape_keys, "key_blocks", text=t("Visemes.mouth_o")) + col.prop_search(props, "mouth_ch", shape_keys, "key_blocks", text=t("Visemes.mouth_ch")) + + # Shape intensity slider + col.separator() + col.prop(props, "shape_intensity", slider=True) + + # Preview Box + preview_box: UILayout = layout.box() + col = preview_box.column(align=True) + col.label(text=t("Visemes.preview_label"), icon='HIDE_OFF') + col.separator(factor=0.5) + + if props.viseme_preview_mode: + col.prop(props, "viseme_preview_selection", text="") + col.separator() + + preview_text = t("Visemes.stop_preview") if props.viseme_preview_mode else t("Visemes.start_preview") + col.operator("avatar_toolkit.preview_visemes", text=preview_text, icon='HIDE_OFF') + + # Create Box + create_box: UILayout = layout.box() + col = create_box.column(align=True) + col.label(text=t("Visemes.create_label"), icon='ADD') + col.separator(factor=0.5) + col.operator("avatar_toolkit.create_visemes", icon='ADD') From 191689096689805d166ca80f060f1832f8d69e5b Mon Sep 17 00:00:00 2001 From: Yusarina Date: Sun, 15 Dec 2024 22:04:09 +0000 Subject: [PATCH 15/19] Eye tracking fixes --- core/properties.py | 12 ++ functions/eye_tracking.py | 282 +++++++++++++++++++----------- resources/translations/en_US.json | 11 ++ ui/eye_tracking_panel.py | 232 ++++++++++++++---------- 4 files changed, 341 insertions(+), 196 deletions(-) diff --git a/core/properties.py b/core/properties.py index a294f2b..074d127 100644 --- a/core/properties.py +++ b/core/properties.py @@ -177,7 +177,19 @@ class AvatarToolkitSceneProperties(PropertyGroup): ('vrc.v_th', 'TH', 'Th as in "think"') ], update=lambda s, c: VisemePreview.update_preview(c) + ) + + eye_tracking_type: EnumProperty( + name=t("EyeTracking.type"), + description=t("EyeTracking.type_desc"), + items=[ + ('AV3', t("EyeTracking.type.av3"), t("EyeTracking.type.av3_desc")), + ('SDK2', t("EyeTracking.type.sdk2"), t("EyeTracking.type.sdk2_desc")) + ], + default='AV3' +) + eye_mode: EnumProperty( name=t("EyeTracking.mode"), items=[ diff --git a/functions/eye_tracking.py b/functions/eye_tracking.py index f8fe3d5..0f1169d 100644 --- a/functions/eye_tracking.py +++ b/functions/eye_tracking.py @@ -29,6 +29,177 @@ VALID_EYE_NAMES = { 'right': ['RightEye', 'Eye_R', 'eye_R', 'eye.R', 'EyeRight', 'right_eye', 'r_eye'] } +class CreateEyesAV3Button(bpy.types.Operator): + """Create eye tracking setup for VRChat Avatar 3.0""" + bl_idname = 'avatar_toolkit.create_eye_tracking_av3' + bl_label = t('EyeTracking.create.av3.label') + bl_description = t('EyeTracking.create.av3.desc') + bl_options = {'REGISTER', 'UNDO'} + + mesh = None + + @classmethod + def poll(cls, context): + toolkit = context.scene.avatar_toolkit + if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: + return False + return True + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + with ProgressTracker(context, 100, "Creating AV3 Eye Tracking") as progress: + try: + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + progress.step("Setting up bones") + + # Set up bones + head = armature.data.edit_bones.get(toolkit.head) + old_eye_left = armature.data.edit_bones.get(toolkit.eye_left) + old_eye_right = armature.data.edit_bones.get(toolkit.eye_right) + + # Store original names and transformations + left_name = old_eye_left.name + right_name = old_eye_right.name + left_matrix = old_eye_left.matrix.copy() + right_matrix = old_eye_right.matrix.copy() + left_length = old_eye_left.length + right_length = old_eye_right.length + + # Unparent and remove original bones + old_eye_left.parent = None + old_eye_right.parent = None + armature.data.edit_bones.remove(old_eye_left) + armature.data.edit_bones.remove(old_eye_right) + + # Create new eye bones with original names + new_left_eye = armature.data.edit_bones.new(left_name) + new_right_eye = armature.data.edit_bones.new(right_name) + + # Parent them + new_left_eye.parent = head + new_right_eye.parent = head + + # Calculate straight up orientation matrix + straight_up_matrix = mathutils.Matrix.Rotation(math.pi/2, 3, 'X') + + # Apply rotation while preserving position + for eye_data in [(new_left_eye, left_matrix, left_length), + (new_right_eye, right_matrix, right_length)]: + new_eye, orig_matrix, length = eye_data + new_matrix = straight_up_matrix.to_4x4() + new_matrix.translation = orig_matrix.translation + new_eye.matrix = new_matrix + new_eye.length = length + + # Disable mirroring to prevent unwanted behavior + armature.data.use_mirror_x = False + + + progress.step("Finalizing setup") + bpy.ops.object.mode_set(mode='OBJECT') + + self.report({'INFO'}, t('EyeTracking.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Eye tracking setup failed: {str(e)}") + return {'CANCELLED'} + +class CreateEyesSDK2Button(bpy.types.Operator): + """Create eye tracking setup for VRChat SDK2""" + bl_idname = 'avatar_toolkit.create_eye_tracking_sdk2' + bl_label = t('EyeTracking.create.sdk2.label') + bl_description = t('EyeTracking.create.sdk2.desc') + bl_options = {'REGISTER', 'UNDO'} + + mesh = None + + @classmethod + def poll(cls, context): + if not get_all_meshes(context): + return False + + toolkit = context.scene.avatar_toolkit + if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: + return False + + if toolkit.disable_eye_blinking and toolkit.disable_eye_movement: + return False + + return True + + def execute(self, context): + toolkit = context.scene.avatar_toolkit + armature = get_active_armature(context) + + with ProgressTracker(context, 100, "Creating SDK2 Eye Tracking") as progress: + # Validate setup + validator = EyeTrackingValidator() + is_valid, message = validator.validate_setup(context, toolkit.mesh_name_eye) + if not is_valid: + self.report({'ERROR'}, message) + return {'CANCELLED'} + + try: + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + progress.step("Setting up bones") + + self.mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + + # Set up bones + head = armature.data.edit_bones.get(toolkit.head) + old_eye_left = armature.data.edit_bones.get(toolkit.eye_left) + old_eye_right = armature.data.edit_bones.get(toolkit.eye_right) + + # Create new eye bones + new_left_eye = armature.data.edit_bones.new('LeftEye') + new_right_eye = armature.data.edit_bones.new('RightEye') + + # Parent them + new_left_eye.parent = head + new_right_eye.parent = head + + # Calculate positions for SDK2 style + fix_eye_position(context, old_eye_left, new_left_eye, head, False) + fix_eye_position(context, old_eye_right, new_right_eye, head, True) + + progress.step("Processing vertex groups") + if not toolkit.disable_eye_movement: + # Switch to object mode for vertex group operations + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + self.mesh.select_set(True) + context.view_layer.objects.active = self.mesh + + copy_vertex_group(self, old_eye_left.name, 'LeftEye') + copy_vertex_group(self, old_eye_right.name, 'RightEye') + + # Return to armature edit mode + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + + progress.step("Processing shape keys") + if not toolkit.disable_eye_blinking: + shapes = [toolkit.wink_left, toolkit.wink_right, + toolkit.lowerlid_left, toolkit.lowerlid_right] + new_shapes = ['vrc.blink_left', 'vrc.blink_right', + 'vrc.lowerlid_left', 'vrc.lowerlid_right'] + + progress.step("Finalizing setup") + bpy.ops.object.mode_set(mode='OBJECT') + toolkit.eye_mode = 'TESTING' + + self.report({'INFO'}, t('EyeTracking.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Eye tracking setup failed: {str(e)}") + return {'CANCELLED'} + class EyeTrackingBackup: def __init__(self): self.backup_path = os.path.join(bpy.app.tempdir, "eye_tracking_backup.json") @@ -126,108 +297,6 @@ class EyeTrackingValidator: return False, t('EyeTracking.validation.missingBones', bones=', '.join(missing_bones)) return True, t('EyeTracking.validation.success') - -class CreateEyesButton(bpy.types.Operator): - bl_idname = 'avatar_toolkit.create_eye_tracking' - bl_label = t('EyeTracking.create.label') - bl_description = t('EyeTracking.create.desc') - bl_options = {'REGISTER', 'UNDO'} - - mesh = None - - @classmethod - def poll(cls, context): - if not get_all_meshes(context): - return False - - toolkit = context.scene.avatar_toolkit - if not toolkit.head or not toolkit.eye_left or not toolkit.eye_right: - return False - - if toolkit.disable_eye_blinking and toolkit.disable_eye_movement: - return False - - return True - - def execute(self, context): - toolkit = context.scene.avatar_toolkit - armature = get_active_armature(context) - - with ProgressTracker(context, 100, "Creating Eye Tracking") as progress: - # Validate setup - validator = EyeTrackingValidator() - is_valid, message = validator.validate_setup(context, toolkit.mesh_name_eye) - if not is_valid: - self.report({'ERROR'}, message) - return {'CANCELLED'} - - # Create backup - backup = EyeTrackingBackup() - if not backup.store_bone_positions(armature): - logger.warning("Failed to create backup") - - try: - # Set active object and mode - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='EDIT') - progress.step("Setting up bones") - - self.mesh = bpy.data.objects.get(toolkit.mesh_name_eye) - - # Set up bones - head = armature.data.edit_bones.get(toolkit.head) - old_eye_left = armature.data.edit_bones.get(toolkit.eye_left) - old_eye_right = armature.data.edit_bones.get(toolkit.eye_right) - - if not toolkit.disable_eye_blinking: - if not all([toolkit.wink_left, toolkit.wink_right, - toolkit.lowerlid_left, toolkit.lowerlid_right]): - self.report({'ERROR'}, t('EyeTracking.error.noShapeSelected')) - return {'CANCELLED'} - - progress.step("Processing vertex groups") - - # Create new eye bones - new_left_eye = armature.data.edit_bones.new('LeftEye') - new_right_eye = armature.data.edit_bones.new('RightEye') - - # Parent them - new_left_eye.parent = head - new_right_eye.parent = head - - # Calculate positions - fix_eye_position(context, old_eye_left, new_left_eye, head, False) - fix_eye_position(context, old_eye_right, new_right_eye, head, True) - - progress.step("Processing shape keys") - - # Process shape keys - if not toolkit.disable_eye_movement: - self.copy_vertex_group(old_eye_left.name, 'LeftEye') - self.copy_vertex_group(old_eye_right.name, 'RightEye') - - # Handle shape keys - shapes = [toolkit.wink_left, toolkit.wink_right, - toolkit.lowerlid_left, toolkit.lowerlid_right] - new_shapes = ['vrc.blink_left', 'vrc.blink_right', - 'vrc.lowerlid_left', 'vrc.lowerlid_right'] - - progress.step("Finalizing setup") - - # Reset modes and cleanup - bpy.ops.object.mode_set(mode='OBJECT') - - # Update scene properties - toolkit.eye_mode = 'TESTING' - - self.report({'INFO'}, t('EyeTracking.success')) - return {'FINISHED'} - - except Exception as e: - logger.error(f"Eye tracking setup failed: {str(e)}") - if backup.restore_bone_positions(get_active_armature(context)): - logger.info("Restored from backup") - return {'CANCELLED'} class StartTestingButton(bpy.types.Operator): bl_idname = 'avatar_toolkit.start_eye_testing' @@ -318,6 +387,8 @@ class StopTestingButton(bpy.types.Operator): eye_left_rot = [] eye_right_rot = [] + bpy.ops.object.mode_set(mode='OBJECT') + return {'FINISHED'} def set_rotation(self, context): @@ -695,6 +766,12 @@ def vertex_group_exists(mesh_obj, group_name): def copy_vertex_group(self, vertex_group, rename_to): """Copy vertex group with new name""" vertex_group_index = 0 + # Select and make mesh active + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='DESELECT') + self.mesh.select_set(True) + bpy.context.view_layer.objects.active = self.mesh + for group in self.mesh.vertex_groups: if group.name == vertex_group: self.mesh.vertex_groups.active_index = vertex_group_index @@ -703,6 +780,7 @@ def copy_vertex_group(self, vertex_group, rename_to): break vertex_group_index += 1 + def copy_shape_key(self, context, from_shape, new_names, new_index): """Copy shape key with new name""" blinking = not context.scene.avatar_toolkit.disable_eye_blinking diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 635bc20..ab0cf37 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -303,6 +303,17 @@ "EyeTracking.head_bone_desc": "Select head bone", "EyeTracking.eye_left_desc": "Select left eye bone", "EyeTracking.eye_right_desc": "Select right eye bone", + "EyeTracking.type": "Eye Tracking Type", + "EyeTracking.type_desc": "Select the type of eye tracking setup to create", + "EyeTracking.create.av3.label": "Create AV3 Eye Tracking", + "EyeTracking.create.av3.desc": "Set up eye tracking for VRChat Avatar 3.0", + "EyeTracking.create.sdk2.label": "Create SDK2 Eye Tracking", + "EyeTracking.create.sdk2.desc": "Set up eye tracking for VRChat SDK2", + "EyeTracking.sdk_version": "SDK Version", + "EyeTracking.type.av3": "Avatar 3.0", + "EyeTracking.type.av3_desc": "VRChat Avatar 3.0 eye tracking setup", + "EyeTracking.type.sdk2": "SDK2 (Legacy)", + "EyeTracking.type.sdk2_desc": "VRChat SDK2 eye tracking setup", "Settings.label": "Settings", "Settings.language": "Language", diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py index 4ce4017..8e2c1c7 100644 --- a/ui/eye_tracking_panel.py +++ b/ui/eye_tracking_panel.py @@ -5,7 +5,8 @@ from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME from ..core.translations import t from ..core.common import get_active_armature, get_all_meshes from ..functions.eye_tracking import ( - CreateEyesButton, + CreateEyesAV3Button, + CreateEyesSDK2Button, StartTestingButton, StopTestingButton, ResetRotationButton, @@ -33,110 +34,153 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): layout = self.layout toolkit = context.scene.avatar_toolkit - # Mode Selection Box - mode_box = layout.box() - col = mode_box.column(align=True) - col.label(text=t("EyeTracking.mode_select"), icon='TOOL_SETTINGS') + # SDK Version Selection Box + sdk_box = layout.box() + col = sdk_box.column(align=True) + col.label(text=t("EyeTracking.sdk_version"), icon='PRESET') col.separator(factor=0.5) - col.prop(toolkit, "eye_mode", expand=True) + row = col.row(align=True) + row.prop(toolkit, "eye_tracking_type", expand=True) - if toolkit.eye_mode == 'CREATION': - # Mesh Setup Box - mesh_box = layout.box() - col = mesh_box.column(align=True) - col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA') + if toolkit.eye_tracking_type == 'SDK2': + # Mode Selection Box + mode_box = layout.box() + col = mode_box.column(align=True) + col.label(text=t("EyeTracking.setup"), icon='TOOL_SETTINGS') col.separator(factor=0.5) - col.prop(toolkit, "mesh_name_eye", text="") + col.prop(toolkit, "eye_mode", expand=True) - # Bone Setup Box - bone_box = layout.box() - col = bone_box.column(align=True) - col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') - col.separator(factor=0.5) - - armature = get_active_armature(context) - if armature: - col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) - col.prop_search(toolkit, "eye_left", armature.data, "bones", text=t("EyeTracking.eye_left")) - col.prop_search(toolkit, "eye_right", armature.data, "bones", text=t("EyeTracking.eye_right")) + if toolkit.eye_mode == 'CREATION': + self.draw_creation_mode(context, layout) else: - col.label(text=t("EyeTracking.no_armature"), icon='ERROR') + self.draw_testing_mode(context, layout) + else: + # AV3 bone setup only + self.draw_av3_setup(context, layout) - # Shapekey Setup Box - shape_box = layout.box() - col = shape_box.column(align=True) - col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') + def draw_av3_setup(self, context: Context, layout: UILayout) -> None: + toolkit = context.scene.avatar_toolkit + + # Bone Setup Box + bone_box = layout.box() + col = bone_box.column(align=True) + col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') + col.separator(factor=0.5) + + armature = get_active_armature(context) + if armature: + col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) + col.prop_search(toolkit, "eye_left", armature.data, "bones", text=t("EyeTracking.eye_left")) + col.prop_search(toolkit, "eye_right", armature.data, "bones", text=t("EyeTracking.eye_right")) + else: + col.label(text=t("EyeTracking.no_armature"), icon='ERROR') + + # Create Button + row = layout.row(align=True) + row.scale_y = 1.5 + row.operator(CreateEyesAV3Button.bl_idname, icon='PLAY') + + def draw_creation_mode(self, context: Context, layout: UILayout) -> None: + toolkit = context.scene.avatar_toolkit + + # Bone Setup Box + bone_box = layout.box() + col = bone_box.column(align=True) + col.label(text=t("EyeTracking.bone_setup"), icon='BONE_DATA') + col.separator(factor=0.5) + + armature = get_active_armature(context) + if armature: + col.prop_search(toolkit, "head", armature.data, "bones", text=t("EyeTracking.head_bone")) + col.prop_search(toolkit, "eye_left", armature.data, "bones", text=t("EyeTracking.eye_left")) + col.prop_search(toolkit, "eye_right", armature.data, "bones", text=t("EyeTracking.eye_right")) + else: + col.label(text=t("EyeTracking.no_armature"), icon='ERROR') + + # Mesh Setup Box + mesh_box = layout.box() + col = mesh_box.column(align=True) + col.label(text=t("EyeTracking.mesh_setup"), icon='MESH_DATA') + col.separator(factor=0.5) + col.prop_search(toolkit, "mesh_name_eye", bpy.data, "objects", text="") + + # Shape Key Setup Box + shape_box = layout.box() + col = shape_box.column(align=True) + col.label(text=t("EyeTracking.shapekey_setup"), icon='SHAPEKEY_DATA') + col.separator(factor=0.5) + + mesh = bpy.data.objects.get(toolkit.mesh_name_eye) + if mesh and mesh.data.shape_keys: + col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left")) + col.prop_search(toolkit, "wink_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_right")) + col.prop_search(toolkit, "lowerlid_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_left")) + col.prop_search(toolkit, "lowerlid_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_right")) + else: + col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR') + + # Options Box + options_box = layout.box() + col = options_box.column(align=True) + col.label(text=t("EyeTracking.options"), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "disable_eye_blinking") + col.prop(toolkit, "disable_eye_movement") + if not toolkit.disable_eye_movement: + col.prop(toolkit, "eye_distance") + + # Create Button + row = layout.row(align=True) + row.scale_y = 1.5 + row.operator(CreateEyesSDK2Button.bl_idname, icon='PLAY') + + def draw_testing_mode(self, context: Context, layout: UILayout) -> None: + toolkit = context.scene.avatar_toolkit + + if context.mode != 'POSE': + # Testing Start Box + test_box = layout.box() + col = test_box.column(align=True) + col.label(text=t("EyeTracking.testing"), icon='PLAY') col.separator(factor=0.5) - - mesh = bpy.data.objects.get(toolkit.mesh_name_eye) - if mesh and mesh.data.shape_keys: - col.prop_search(toolkit, "wink_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_left")) - col.prop_search(toolkit, "wink_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.wink_right")) - col.prop_search(toolkit, "lowerlid_left", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_left")) - col.prop_search(toolkit, "lowerlid_right", mesh.data.shape_keys, "key_blocks", text=t("EyeTracking.lowerlid_right")) - else: - col.label(text=t("EyeTracking.no_shapekeys"), icon='ERROR') - - # Options Box - options_box = layout.box() - col = options_box.column(align=True) - col.label(text=t("EyeTracking.options"), icon='SETTINGS') + row = col.row(align=True) + row.scale_y = 1.5 + row.operator(StartTestingButton.bl_idname, icon='PLAY') + else: + # Eye Rotation Box + rotation_box = layout.box() + col = rotation_box.column(align=True) + col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') col.separator(factor=0.5) - col.prop(toolkit, "disable_eye_blinking") - col.prop(toolkit, "disable_eye_movement") - if not toolkit.disable_eye_movement: - col.prop(toolkit, "eye_distance") + col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x")) + col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y")) + col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK') - # Create Button + # Eye Adjustment Box + adjust_box = layout.box() + col = adjust_box.column(align=True) + col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER') + col.separator(factor=0.5) + col.prop(toolkit, "eye_distance") + col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO') + + # Blinking Test Box + blink_box = layout.box() + col = blink_box.column(align=True) + col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF') + col.separator(factor=0.5) + row = col.row(align=True) + row.prop(toolkit, "eye_blink_shape") + row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF') + row = col.row(align=True) + row.prop(toolkit, "eye_lowerlid_shape") + row.operator(TestLowerlid.bl_idname, icon='RESTRICT_VIEW_OFF') + col.operator(ResetBlinkTest.bl_idname, icon='LOOP_BACK') + + # Stop Testing Button row = layout.row(align=True) row.scale_y = 1.5 - row.operator(CreateEyesButton.bl_idname, icon='PLAY') - - else: - if context.mode != 'POSE': - # Testing Start Box - test_box = layout.box() - col = test_box.column(align=True) - col.label(text=t("EyeTracking.testing"), icon='PLAY') - col.separator(factor=0.5) - row = col.row(align=True) - row.scale_y = 1.5 - row.operator(StartTestingButton.bl_idname, icon='PLAY') - else: - # Eye Rotation Box - rotation_box = layout.box() - col = rotation_box.column(align=True) - col.label(text=t("EyeTracking.rotation_controls"), icon='DRIVER_ROTATIONAL_DIFFERENCE') - col.separator(factor=0.5) - col.prop(toolkit, "eye_rotation_x", text=t("EyeTracking.rotation.x")) - col.prop(toolkit, "eye_rotation_y", text=t("EyeTracking.rotation.y")) - col.operator(ResetRotationButton.bl_idname, icon='LOOP_BACK') - - # Eye Adjustment Box - adjust_box = layout.box() - col = adjust_box.column(align=True) - col.label(text=t("EyeTracking.adjustments"), icon='MODIFIER') - col.separator(factor=0.5) - col.prop(toolkit, "eye_distance") - col.operator(AdjustEyesButton.bl_idname, icon='CON_TRACKTO') - - # Blinking Test Box - blink_box = layout.box() - col = blink_box.column(align=True) - col.label(text=t("EyeTracking.blink_testing"), icon='HIDE_OFF') - col.separator(factor=0.5) - row = col.row(align=True) - row.prop(toolkit, "eye_blink_shape") - row.operator(TestBlinking.bl_idname, icon='RESTRICT_VIEW_OFF') - row = col.row(align=True) - row.prop(toolkit, "eye_lowerlid_shape") - row.operator(TestLowerlid.bl_idname, icon='RESTRICT_VIEW_OFF') - col.operator(ResetBlinkTest.bl_idname, icon='LOOP_BACK') - - # Stop Testing Button - row = layout.row(align=True) - row.scale_y = 1.5 - row.operator(StopTestingButton.bl_idname, icon='PAUSE') + row.operator(StopTestingButton.bl_idname, icon='PAUSE') # Reset Button row = layout.row(align=True) From 2af7a4739a4111bfdf99bdcb3f75d2f980572a47 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 16 Dec 2024 01:34:38 +0000 Subject: [PATCH 16/19] Armature Merging --- core/common.py | 34 +- core/properties.py | 64 +++ functions/custom_tools/armature_merging.py | 487 +++++++++++++++++++++ functions/custom_tools/mesh_attachment.py | 0 resources/translations/en_US.json | 45 ++ ui/custom_avatar_panel.py | 231 ++++++++++ 6 files changed, 852 insertions(+), 9 deletions(-) create mode 100644 functions/custom_tools/armature_merging.py create mode 100644 functions/custom_tools/mesh_attachment.py create mode 100644 ui/custom_avatar_panel.py diff --git a/core/common.py b/core/common.py index 253acc6..548c6f4 100644 --- a/core/common.py +++ b/core/common.py @@ -317,7 +317,7 @@ def validate_meshes(meshes: List[Object]) -> Tuple[bool, str]: return False, t("Optimization.non_mesh_objects") return True, "" -def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Tuple[bool, str]: +def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional[ProgressTracker] = None) -> Optional[Object]: """Combines multiple mesh objects into a single mesh with proper cleanup and UV fixing""" try: bpy.ops.object.mode_set(mode='OBJECT') @@ -341,13 +341,16 @@ def join_mesh_objects(context: Context, meshes: List[Object], progress: Optional progress.step(t("Optimization.fixing_uvs")) fix_uv_coordinates(context) - return True, t("Optimization.meshes_joined") + # Return the joined mesh object + return context.active_object + + else: + # No objects were selected, return None + return None - return False, t("Optimization.no_mesh_selected") - except Exception as e: logger.error(f"Failed to join meshes: {str(e)}") - return False, str(e) + return None def fix_uv_coordinates(context: Context) -> None: """Normalizes and fixes UV coordinates for the active mesh object""" @@ -378,12 +381,14 @@ def fix_uv_coordinates(context: Context) -> None: for sel_obj in current_selected: sel_obj.select_set(True) context.view_layer.objects.active = current_active - -def clear_unused_data_blocks(self) -> int: +# This should be at the top level, not indented inside any class or function +def clear_unused_data_blocks() -> int: """Removes all unused data blocks from the current Blender file""" - initial_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + initial_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) + if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - final_count: int = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) + final_count = sum(len(getattr(bpy.data, attr)) for attr in dir(bpy.data) + if isinstance(getattr(bpy.data, attr), bpy.types.bpy_prop_collection)) return initial_count - final_count def simplify_bonename(name: str) -> str: @@ -575,4 +580,15 @@ def is_enum_non_empty(string): Returns True in all other cases.""" return _empty_enum_identifier != string +def fix_zero_length_bones(armature: Object) -> None: + """Fix zero length bones by setting a minimum length""" + if not armature: + return + + bpy.ops.object.mode_set(mode='EDIT') + for bone in armature.data.edit_bones: + if bone.length < 0.001: + bone.length = 0.001 + bpy.ops.object.mode_set(mode='OBJECT') + diff --git a/core/properties.py b/core/properties.py index 074d127..140c6e9 100644 --- a/core/properties.py +++ b/core/properties.py @@ -293,6 +293,70 @@ class AvatarToolkitSceneProperties(PropertyGroup): description=t("EyeTracking.lowerlid_right_desc") ) + merge_mode: EnumProperty( + name=t('CustomPanel.merge_mode'), + description=t('CustomPanel.merge_mode_desc'), + items=[ + ('ARMATURE', t('CustomPanel.mode.armature'), t('CustomPanel.mode.armature_desc')), + ('MESH', t('CustomPanel.mode.mesh'), t('CustomPanel.mode.mesh_desc')) + ], + default='ARMATURE' + ) + + merge_armature_into: StringProperty( + name=t('CustomPanel.merge_into'), + description=t('CustomPanel.merge_into_desc'), + default="" + ) + + merge_armature: StringProperty( + name=t('CustomPanel.merge_from'), + description=t('CustomPanel.merge_from_desc'), + default="" + ) + + attach_mesh: StringProperty( + name=t('CustomPanel.attach_mesh'), + description=t('CustomPanel.attach_mesh_desc'), + default="" + ) + + attach_bone: StringProperty( + name=t('CustomPanel.attach_bone'), + description=t('CustomPanel.attach_bone_desc'), + default="" + ) + + merge_all_bones: BoolProperty( + name=t('CustomPanel.merge_all_bones'), + description=t('CustomPanel.merge_all_bones_desc'), + default=True + ) + + apply_transforms: BoolProperty( + name=t('CustomPanel.apply_transforms'), + description=t('CustomPanel.apply_transforms_desc'), + default=True + ) + + join_meshes: BoolProperty( + name=t('CustomPanel.join_meshes'), + description=t('CustomPanel.join_meshes_desc'), + default=True + ) + + remove_zero_weights: BoolProperty( + name=t('CustomPanel.remove_zero_weights'), + description=t('CustomPanel.remove_zero_weights_desc'), + default=True + ) + + cleanup_shape_keys: BoolProperty( + name=t('CustomPanel.cleanup_shape_keys'), + description=t('CustomPanel.cleanup_shape_keys_desc'), + default=True + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py new file mode 100644 index 0000000..2ff4e77 --- /dev/null +++ b/functions/custom_tools/armature_merging.py @@ -0,0 +1,487 @@ +import bpy +import numpy as np +from typing import List, Optional, Dict, Set +from mathutils import Vector +from bpy.types import Context, Object, Operator + +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + get_all_meshes, + fix_zero_length_bones, + clear_unused_data_blocks, + validate_armature, + join_mesh_objects, + fix_uv_coordinates, + remove_unused_shapekeys +) + +class AvatarToolkit_OT_MergeArmature(Operator): + bl_idname = 'avatar_toolkit.merge_armatures' + bl_label = t('MergeArmature.label') + bl_description = t('MergeArmature.desc') + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return len(get_all_meshes(context)) > 1 + + def execute(self, context): + try: + wm = context.window_manager + wm.progress_begin(0, 100) + + # Get both armatures + base_armature_name = context.scene.merge_armature_into + merge_armature_name = context.scene.merge_armature + base_armature = bpy.data.objects.get(base_armature_name) + merge_armature = bpy.data.objects.get(merge_armature_name) + + if not base_armature or not merge_armature: + logger.error(f"Armature not found: {merge_armature_name}") + self.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) + return {'CANCELLED'} + + # Remove Rigid Bodies and Joints + delete_rigidbodies_and_joints(base_armature) + delete_rigidbodies_and_joints(merge_armature) + wm.progress_update(40) + + # Check parents and transformations + if not validate_parents_and_transforms(merge_armature, base_armature, context): + wm.progress_end() + return {'CANCELLED'} + wm.progress_update(80) + + # Get settings from scene properties + merge_all_bones = context.scene.avatar_toolkit.merge_all_bones + join_meshes = context.scene.avatar_toolkit.join_meshes + + # Merge armatures + merge_armatures( + base_armature_name, + merge_armature_name, + mesh_only=False, + merge_all_bones=context.scene.avatar_toolkit.merge_all_bones, + join_meshes=join_meshes, + operator=self + ) + wm.progress_update(90) + + wm.progress_update(100) + wm.progress_end() + + self.report({'INFO'}, t('MergeArmature.success')) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Error merging armatures: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +def calculate_bone_orientation(mesh, vertices): + """Calculate optimal bone orientation based on mesh geometry.""" + + if not vertices: + return Vector((0, 0, 0.1)), 0.0 + + coords = [mesh.data.vertices[v.index].co for v in vertices] + min_co = Vector(map(min, zip(*coords))) + max_co = Vector(map(max, zip(*coords))) + dimensions = max_co - min_co + + roll_angle = 0.0 + + return dimensions, roll_angle + +def delete_rigidbodies_and_joints(armature: Object): + """Delete rigid bodies and joints associated with the armature.""" + to_delete = [] + parent = armature + while parent.parent: + parent = parent.parent + + for child in parent.children: + if 'rigidbodies' in child.name.lower() or 'joints' in child.name.lower(): + to_delete.append(child) + for grandchild in child.children: + if 'rigidbodies' in grandchild.name.lower() or 'joints' in grandchild.name.lower(): + to_delete.append(grandchild) + + for obj in to_delete: + bpy.data.objects.remove(obj, do_unlink=True) + +def validate_parents_and_transforms(merge_armature: Object, base_armature: Object, context: Context) -> bool: + """Validate parents and transformations of armatures before merging.""" + merge_parent = merge_armature.parent + base_parent = base_armature.parent + + if merge_parent or base_parent: + if context.scene.merge_all_bones: + for armature, parent in [(merge_armature, merge_parent), (base_armature, base_parent)]: + if parent: + if not is_transform_clean(parent): + logger.error("Parent transforms are not clean") + return False + bpy.data.objects.remove(parent, do_unlink=True) + else: + logger.error("Parent relationships need fixing") + return False + return True + +def is_transform_clean(obj: Object) -> bool: + """Check if an object's transforms are at default values.""" + for i in range(3): + if obj.scale[i] != 1 or obj.location[i] != 0 or obj.rotation_euler[i] != 0: + return False + return True + +def prepare_mesh_vertex_groups(mesh: Object): + """Prepare mesh by assigning all vertices to a new vertex group.""" + if mesh.vertex_groups: + for vg in mesh.vertex_groups: + mesh.vertex_groups.remove(vg) + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + vg = mesh.vertex_groups.new(name=mesh.name) + bpy.ops.object.vertex_group_assign() + bpy.ops.object.mode_set(mode='OBJECT') + +def merge_armatures( + base_armature_name: str, + merge_armature_name: str, + mesh_only: bool, + merge_all_bones: bool = False, + join_meshes: bool = False, + operator=None +): + """Main function to merge two armatures.""" + logger.info(f"Merging armatures: {merge_armature_name} into {base_armature_name}") + tolerance = 0.00008726647 # around 0.005 degrees + + base_armature = bpy.data.objects.get(base_armature_name) + merge_armature = bpy.data.objects.get(merge_armature_name) + + if not base_armature or not merge_armature: + logger.error(f"Armature not found: {merge_armature_name}") + if operator: + operator.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) + return + + # Check transforms early + if not validate_merge_armature_transforms(base_armature, merge_armature, None, tolerance): + if not bpy.context.scene.avatar_toolkit.apply_transforms: + logger.error("Transforms not aligned - user notification sent") + if operator: + operator.report({'ERROR'}, t('MergeArmature.error.transforms_not_aligned')) + return + + # Apply transforms if enabled + if bpy.context.scene.avatar_toolkit.apply_transforms: + for obj in [base_armature, merge_armature]: + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + obj.select_set(False) + + # Validate and fix armatures + fix_zero_length_bones(base_armature) + fix_zero_length_bones(merge_armature) + + # Store original parent relationships + original_parents = {} + for bone in merge_armature.data.bones: + original_parents[bone.name] = bone.parent.name if bone.parent else None + + # Get base bone names + base_bone_names = set(bone.name for bone in base_armature.data.bones) + + # Switch to edit mode on merge armature and rename bones + bpy.context.view_layer.objects.active = merge_armature + bpy.ops.object.mode_set(mode='EDIT') + + # Handle bone renaming based on merge_all_bones setting + for bone in merge_armature.data.edit_bones: + if not merge_all_bones: + # Only rename bones that don't exist in base armature + if bone.name not in base_bone_names: + bone.name += '.merge' + else: + # Rename all bones from merge armature + bone.name += '.merge' + + # Return to object mode + bpy.ops.object.mode_set(mode='OBJECT') + + # Select and join armatures + bpy.ops.object.select_all(action='DESELECT') + base_armature.select_set(True) + merge_armature.select_set(True) + bpy.context.view_layer.objects.active = base_armature + bpy.ops.object.join() + + # Restore parent relationships + bpy.ops.object.mode_set(mode='EDIT') + for bone in base_armature.data.edit_bones: + base_name = bone.name.replace('.merge', '') + if base_name in original_parents: + parent_name = original_parents[base_name] + if parent_name: + parent_bone = base_armature.data.edit_bones.get(parent_name) + if parent_bone: + bone.parent = parent_bone + + bpy.ops.object.mode_set(mode='OBJECT') + + # Update mesh parenting + for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.parent == merge_armature: + obj.parent = base_armature + + # Process vertex groups if not mesh_only + if not mesh_only: + meshes = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature] + process_vertex_groups(meshes) + + # Remove zero weight vertex groups if enabled + if bpy.context.scene.avatar_toolkit.remove_zero_weights: + bpy.context.view_layer.objects.active = base_armature + for mesh in meshes: + bpy.context.view_layer.objects.active = mesh + bpy.ops.avatar_toolkit.clean_weights() + + # Join meshes if requested + if join_meshes: + meshes_to_join = [obj for obj in bpy.data.objects if obj.type == 'MESH' and obj.parent == base_armature] + if meshes_to_join: + joined_mesh = join_mesh_objects(bpy.context, meshes_to_join) + if joined_mesh: + logger.info(f"Joined meshes into {joined_mesh.name}") + + # Clean up shape keys if enabled + if bpy.context.scene.avatar_toolkit.cleanup_shape_keys: + for obj in bpy.data.objects: + if obj.type == 'MESH' and obj.parent == base_armature: + remove_unused_shapekeys(obj) + + # Remove any remaining .merge bones + bpy.context.view_layer.objects.active = base_armature + bpy.ops.object.mode_set(mode='EDIT') + edit_bones = base_armature.data.edit_bones + bones_to_remove = [bone for bone in edit_bones if bone.name.endswith('.merge')] + for bone in bones_to_remove: + edit_bones.remove(bone) + bpy.ops.object.mode_set(mode='OBJECT') + + # Final cleanup + clear_unused_data_blocks() + + +def validate_merge_armature_transforms( + base_armature: Object, + merge_armature: Object, + mesh_merge: Optional[Object], + tolerance: float +) -> bool: + """Validate transforms of both armatures and mesh.""" + for i in [0, 1, 2]: + if abs(base_armature.scale[i] - merge_armature.scale[i]) > tolerance: + return False + + if abs(merge_armature.rotation_euler[i]) > tolerance or \ + (mesh_merge and abs(mesh_merge.rotation_euler[i]) > tolerance): + return False + + return True + +def adjust_merge_armature_transforms( + merge_armature: Object, + mesh_merge: Object +): + """Adjust transforms of the merge armature.""" + old_loc = list(merge_armature.location) + old_scale = list(merge_armature.scale) + + for i in [0, 1, 2]: + merge_armature.location[i] = (mesh_merge.location[i] * old_scale[i]) + old_loc[i] + merge_armature.rotation_euler[i] = mesh_merge.rotation_euler[i] + merge_armature.scale[i] = mesh_merge.scale[i] * old_scale[i] + + for i in [0, 1, 2]: + mesh_merge.location[i] = 0 + mesh_merge.rotation_euler[i] = 0 + mesh_merge.scale[i] = 1 + + +def detect_bones_to_merge( + base_edit_bones: bpy.types.ArmatureEditBones, + merge_edit_bones: bpy.types.ArmatureEditBones, + tolerance: float, + merge_all_bones: bool +) -> List[str]: + """Detect corresponding bones between base and merge armatures using smart detection and position tolerance.""" + bones_to_merge = [] + + # Cache base bone positions + base_bones_positions = { + bone.name: np.array(bone.head) for bone in base_edit_bones + } + + # Smart bone detection + for merge_bone in merge_edit_bones: + merge_bone_position = np.array(merge_bone.head) + found_match = False + + if merge_all_bones and merge_bone.name in base_bones_positions: + # If merging same bones by name + bones_to_merge.append(merge_bone.name) + found_match = True + else: + # Find bones with close positions + for base_bone_name, base_bone_position in base_bones_positions.items(): + if np.linalg.norm(merge_bone_position - base_bone_position) <= tolerance: + bones_to_merge.append(base_bone_name) + found_match = True + break + + if not found_match: + # Handle unmatched bones if needed + pass + + return bones_to_merge + + +def process_vertex_groups(meshes: List[Object]): + """Process vertex groups in meshes.""" + for mesh in meshes: + vg_names = {vg.name for vg in mesh.vertex_groups} + merge_vg_names = [vg_name for vg_name in vg_names if vg_name.endswith('.merge')] + + for vg_merge_name in merge_vg_names: + base_name = vg_merge_name[:-6] + vg_merge = mesh.vertex_groups.get(vg_merge_name) + vg_base = mesh.vertex_groups.get(base_name) + + if vg_merge is None: + continue + + if vg_base: + mix_vertex_groups(mesh, vg_merge_name, base_name) + else: + vg_merge.name = base_name + +def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str): + """Mix vertex group weights.""" + vg_from = mesh.vertex_groups.get(vg_from_name) + vg_to = mesh.vertex_groups.get(vg_to_name) + + if not vg_from or not vg_to: + return + + num_vertices = len(mesh.data.vertices) + weights_from = np.zeros(num_vertices) + weights_to = np.zeros(num_vertices) + + idx_from = vg_from.index + idx_to = vg_to.index + + for v in mesh.data.vertices: + for g in v.groups: + if g.group == idx_from: + weights_from[v.index] = g.weight + elif g.group == idx_to: + weights_to[v.index] = g.weight + + weights_combined = np.clip(weights_from + weights_to, 0.0, 1.0) + vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE') + mesh.vertex_groups.remove(vg_from) + +def add_armature_modifier(mesh: Object, armature: Object): + """Add armature modifier to mesh.""" + for mod in mesh.modifiers: + if mod.type == 'ARMATURE': + mesh.modifiers.remove(mod) + + modifier = mesh.modifiers.new('Armature', 'ARMATURE') + modifier.object = armature + +def remove_unused_vertex_groups(mesh: Object): + """Remove vertex groups with no weights.""" + for vg in mesh.vertex_groups: + has_weights = False + for vert in mesh.data.vertices: + for group in vert.groups: + if group.group == vg.index and group.weight > 0.001: + has_weights = True + break + if has_weights: + break + if not has_weights: + mesh.vertex_groups.remove(vg) + +def apply_armature_to_mesh(armature: Object, mesh: Object): + """Apply armature deformation to mesh.""" + armature_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE') + armature_mod.object = armature + + if bpy.app.version >= (3, 5): + mesh.modifiers.move(mesh.modifiers.find(armature_mod.name), 0) + else: + for _ in range(len(mesh.modifiers) - 1): + bpy.ops.object.modifier_move_up(modifier=armature_mod.name) + + with bpy.context.temp_override(object=mesh): + bpy.ops.object.modifier_apply(modifier=armature_mod.name) + +def apply_armature_to_mesh_with_shapekeys(armature: Object, mesh: Object, context: Context): + """Apply armature deformation to mesh with shape keys.""" + old_active_index = mesh.active_shape_key_index + old_show_only = mesh.show_only_shape_key + mesh.show_only_shape_key = True + + shape_keys = mesh.data.shape_keys.key_blocks + vertex_groups = [] + mutes = [] + + for sk in shape_keys: + vertex_groups.append(sk.vertex_group) + sk.vertex_group = '' + mutes.append(sk.mute) + sk.mute = False + + disabled_mods = [] + for mod in mesh.modifiers: + if mod.show_viewport: + mod.show_viewport = False + disabled_mods.append(mod) + + arm_mod = mesh.modifiers.new('PoseToRest', 'ARMATURE') + arm_mod.object = armature + + co_length = len(mesh.data.vertices) * 3 + eval_cos = np.empty(co_length, dtype=np.single) + + for i, shape_key in enumerate(shape_keys): + mesh.active_shape_key_index = i + + depsgraph = context.evaluated_depsgraph_get() + eval_mesh = mesh.evaluated_get(depsgraph) + eval_mesh.data.vertices.foreach_get('co', eval_cos) + + shape_key.data.foreach_set('co', eval_cos) + if i == 0: + mesh.data.vertices.foreach_set('co', eval_cos) + + for mod in disabled_mods: + mod.show_viewport = True + + mesh.modifiers.remove(arm_mod) + + for sk, vg, mute in zip(shape_keys, vertex_groups, mutes): + sk.vertex_group = vg + sk.mute = mute + + mesh.active_shape_key_index = old_active_index + mesh.show_only_shape_key = old_show_only diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index ab0cf37..62823e7 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -315,6 +315,51 @@ "EyeTracking.type.sdk2": "SDK2 (Legacy)", "EyeTracking.type.sdk2_desc": "VRChat SDK2 eye tracking setup", + "CustomPanel.label": "Custom Avatar Tools", + "CustomPanel.merge_mode": "Merge Mode", + "CustomPanel.merge_mode_desc": "Select mode for merging operations", + "CustomPanel.mode.armature": "Armature", + "CustomPanel.mode.armature_desc": "Merge armatures together", + "CustomPanel.mode.mesh": "Mesh", + "CustomPanel.mode.mesh_desc": "Attach meshes to armature", + "CustomPanel.mergeArmatures": "Merge Armatures", + "CustomPanel.warn.twoArmatures": "Need at least two armatures to merge", + "CustomPanel.warn.noArmOrMesh1": "No armature or meshes found", + "CustomPanel.warn.noArmOrMesh2": "Please add required objects first", + "CustomPanel.merge_into": "Merge Into", + "CustomPanel.merge_into_desc": "Target armature to merge into", + "CustomPanel.merge_from": "Merge From", + "CustomPanel.merge_from_desc": "Source armature to merge", + "CustomPanel.toMerge": "To Merge", + "CustomPanel.attachMesh1": "Attach Mesh", + "CustomPanel.attachMesh2": "Select Mesh", + "CustomPanel.attach_mesh": "Mesh to Attach", + "CustomPanel.attach_mesh_desc": "Select mesh to attach", + "CustomPanel.attachToBone": "Attach to Bone", + "CustomPanel.attach_bone": "Target Bone", + "CustomPanel.attach_bone_desc": "Select bone to attach to", + "CustomPanel.merge_same_bones": "Merge Same Bones", + "CustomPanel.merge_same_bones_desc": "Merge bones with matching names", + "CustomPanel.apply_transforms": "Apply Transforms", + "CustomPanel.apply_transforms_desc": "Apply all transformations before merging", + "CustomPanel.join_meshes": "Join Meshes", + "CustomPanel.join_meshes_desc": "Join meshes after merging", + "CustomPanel.remove_zero_weights": "Remove Zero Weights", + "CustomPanel.remove_zero_weights_desc": "Remove vertex groups with no weights", + "CustomPanel.cleanup_shape_keys": "Clean Shape Keys", + "CustomPanel.cleanup_shape_keys_desc": "Remove unused shape keys", + "CustomPanel.merge_all_bones": "Merge Same Bones", + "CustomPanel.merge_all_bones_desc": "Merge bones with matching names", + "CustomPanel.mergeInto": "Merge Into", + "MergeArmature.label": "Merge Armatures", + "MergeArmature.desc": "Merge two armatures together", + "MergeArmature.error.notFound": "Armature '{name}' not found", + "MergeArmature.success": "Armatures merged successfully", + "MergeArmature.error.checkTransforms": "Please check parent transformations", + "MergeArmature.error.pleaseFix": "Please fix parent relationships", + "MergeArmature.error.transforms_not_aligned": "Transforms must be applied to merge this armature, either do this via the manual method or via apply transform checkmark", + + "Settings.label": "Settings", "Settings.language": "Language", "Settings.language_desc": "Select interface language", diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py new file mode 100644 index 0000000..4332e1b --- /dev/null +++ b/ui/custom_avatar_panel.py @@ -0,0 +1,231 @@ +import bpy +from typing import Set +from bpy.types import Panel, Context, UILayout, Operator +from .main_panel import AvatarToolKit_PT_AvatarToolkitPanel, CATEGORY_NAME +from ..core.translations import t +from ..core.common import ( + get_active_armature, + get_all_meshes, + validate_armature, + get_armature_list +) + +class AvatarToolkit_OT_SearchMergeArmatureInto(Operator): + bl_idname = "avatar_toolkit.search_merge_armature_into" + bl_label = "" + bl_description = t('CustomPanel.search_merge_into_desc') + bl_property = "search_merge_armature_into_enum" + + # Define the enum property within the operator class + search_merge_armature_into_enum: bpy.props.EnumProperty( + name=t('CustomPanel.merge_into'), + description=t('CustomPanel.merge_into_desc'), + items=get_armature_list + ) + + def execute(self, context): + context.scene.avatar_toolkit.merge_armature_into = self.search_merge_armature_into_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchMergeArmature(Operator): + bl_idname = "avatar_toolkit.search_merge_armature" + bl_label = "" + bl_description = t('CustomPanel.search_merge_desc') + bl_property = "search_merge_armature_enum" + + search_merge_armature_enum: bpy.props.EnumProperty( + name=t('CustomPanel.merge_from'), + description=t('CustomPanel.merge_from_desc'), + items=get_armature_list + ) + + def execute(self, context): + context.scene.avatar_toolkit.merge_armature = self.search_merge_armature_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchAttachMesh(Operator): + bl_idname = "avatar_toolkit.search_attach_mesh" + bl_label = "" + bl_description = t('CustomPanel.search_mesh_desc') + bl_property = "search_attach_mesh_enum" + + search_attach_mesh_enum: bpy.props.EnumProperty( + name=t('CustomPanel.attach_mesh'), + description=t('CustomPanel.attach_mesh_desc'), + items=lambda self, context: [ + (obj.name, obj.name, "") + for obj in get_all_meshes(context) + ] + ) + + def execute(self, context): + context.scene.avatar_toolkit.attach_mesh = self.search_attach_mesh_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolkit_OT_SearchAttachBone(Operator): + bl_idname = "avatar_toolkit.search_attach_bone" + bl_label = "" + bl_description = t('CustomPanel.search_bone_desc') + bl_property = "search_attach_bone_enum" + + search_attach_bone_enum: bpy.props.EnumProperty( + name=t('CustomPanel.attach_bone'), + description=t('CustomPanel.attach_bone_desc'), + items=lambda self, context: [ + (bone.name, bone.name, "") + for bone in get_active_armature(context).data.bones + ] if get_active_armature(context) else [] + ) + + def execute(self, context): + context.scene.avatar_toolkit.attach_bone = self.search_attach_bone_enum + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} + +class AvatarToolKit_PT_CustomPanel(Panel): + """Panel containing tools for custom avatar creation and merging""" + bl_label = t('CustomPanel.label') + bl_idname = "VIEW3D_PT_avatar_toolkit_custom" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = CATEGORY_NAME + bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname + bl_order = 3 + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context: Context) -> None: + """Draw the custom avatar tools panel interface""" + layout: UILayout = self.layout + toolkit = context.scene.avatar_toolkit + + # Mode Selection Box + mode_box: UILayout = layout.box() + col: UILayout = mode_box.column(align=True) + col.label(text=t('CustomPanel.merge_mode'), icon='TOOL_SETTINGS') + col.separator(factor=0.5) + + # Create a row for the mode buttons with increased scale + row: UILayout = col.row(align=True) + row.scale_y = 1.5 + row.prop(toolkit, "merge_mode", expand=True) + + # Armature Merging Tools + if toolkit.merge_mode == 'ARMATURE': + self.draw_armature_tools(layout, context) + # Mesh Attachment Tools + else: + self.draw_mesh_tools(layout, context) + + def draw_armature_tools(self, layout: UILayout, context: Context) -> None: + """Draw the armature merging tools section""" + toolkit = context.scene.avatar_toolkit + + # Merge Settings Box + settings_box: UILayout = layout.box() + col: UILayout = settings_box.column(align=True) + col.label(text=t('CustomPanel.mergeArmatures'), icon='ARMATURE_DATA') + col.separator(factor=0.5) + + if len(get_armature_list(context)) <= 1: + col.label(text=t('CustomPanel.warn.twoArmatures'), icon='INFO') + return + + # Merge Options + options_box: UILayout = layout.box() + col: UILayout = options_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "merge_all_bones") + col.prop(toolkit, "apply_transforms") + col.prop(toolkit, "join_meshes") + col.prop(toolkit, "remove_zero_weights") + col.prop(toolkit, "cleanup_shape_keys") + + # Armature Selection Box + selection_box: UILayout = layout.box() + col: UILayout = selection_box.column(align=True) + col.label(text=t('QuickAccess.select_armature'), icon='BONE_DATA') + col.separator(factor=0.5) + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.mergeInto')) + row.operator("avatar_toolkit.search_merge_armature_into", + text=toolkit.merge_armature_into, + icon='ARMATURE_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.toMerge')) + row.operator("avatar_toolkit.search_merge_armature", + text=toolkit.merge_armature, + icon='ARMATURE_DATA') + + # Merge Button + merge_col: UILayout = layout.column(align=True) + merge_col.scale_y = 1.2 + merge_col.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA') + + def draw_mesh_tools(self, layout: UILayout, context: Context) -> None: + """Draw the mesh attachment tools section""" + toolkit = context.scene.avatar_toolkit + + # Mesh Tools Box + tools_box: UILayout = layout.box() + col: UILayout = tools_box.column(align=True) + col.label(text=t('CustomPanel.attachMesh1'), icon='MESH_DATA') + col.separator(factor=0.5) + + if not get_active_armature(context) or not get_all_meshes(context): + col.label(text=t('CustomPanel.warn.noArmOrMesh1'), icon='INFO') + col.label(text=t('CustomPanel.warn.noArmOrMesh2')) + return + + # Mesh Options Box + options_box: UILayout = layout.box() + col: UILayout = options_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='SETTINGS') + col.separator(factor=0.5) + col.prop(toolkit, "join_meshes") + + # Selection Box + selection_box: UILayout = layout.box() + col: UILayout = selection_box.column(align=True) + col.label(text=t('Tools.merge_title'), icon='OBJECT_DATA') + col.separator(factor=0.5) + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.mergeInto')) + row.operator("avatar_toolkit.search_merge_armature_into", + text=toolkit.merge_armature_into, + icon='ARMATURE_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.attachMesh2')) + row.operator("avatar_toolkit.search_attach_mesh", + text=toolkit.attach_mesh, + icon='MESH_DATA') + + row: UILayout = col.row(align=True) + row.label(text=t('CustomPanel.attachToBone')) + row.operator("avatar_toolkit.search_attach_bone", + text=toolkit.attach_bone, + icon='BONE_DATA') + + # Attach Button + attach_col: UILayout = layout.column(align=True) + attach_col.scale_y = 1.2 + attach_col.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA') From 847bf68f9de7cbff2cbab8984ad8a514a7266fa7 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 16 Dec 2024 01:40:03 +0000 Subject: [PATCH 17/19] Fixes --- functions/optimization/materials_tools.py | 2 +- functions/optimization/mesh_tools.py | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/functions/optimization/materials_tools.py b/functions/optimization/materials_tools.py index 77a6d19..2741105 100644 --- a/functions/optimization/materials_tools.py +++ b/functions/optimization/materials_tools.py @@ -119,7 +119,7 @@ class AvatarToolkit_OT_CombineMaterials(Operator): progress.step("Cleaned material slots") try: - num_removed = clear_unused_data_blocks(self) + num_removed = clear_unused_data_blocks() except Exception as e: logger.error(f"Data block cleanup failed: {str(e)}") self.report({'ERROR'}, t("Optimization.error.data_cleanup")) diff --git a/functions/optimization/mesh_tools.py b/functions/optimization/mesh_tools.py index 086bdc9..aac4a02 100644 --- a/functions/optimization/mesh_tools.py +++ b/functions/optimization/mesh_tools.py @@ -41,15 +41,14 @@ class AvatarToolkit_OT_JoinAllMeshes(Operator): return {'CANCELLED'} with ProgressTracker(context, 5, "Joining All Meshes") as progress: - success: bool - success, message = join_mesh_objects(context, meshes, progress) + joined_mesh = join_mesh_objects(context, meshes, progress) - if success: + if joined_mesh: context.view_layer.objects.active = armature - self.report({'INFO'}, message) + self.report({'INFO'}, t("Optimization.meshes_joined")) return {'FINISHED'} else: - self.report({'ERROR'}, message) + self.report({'ERROR'}, t("Optimization.error.join_meshes")) return {'CANCELLED'} except Exception as e: @@ -87,14 +86,13 @@ class AvatarToolkit_OT_JoinSelectedMeshes(Operator): return {'CANCELLED'} with ProgressTracker(context, 5, "Joining Selected Meshes") as progress: - success: bool - success, message = join_mesh_objects(context, selected_meshes, progress) + joined_mesh = join_mesh_objects(context, selected_meshes, progress) - if success: - self.report({'INFO'}, message) + if joined_mesh: + self.report({'INFO'}, t("Optimization.selected_meshes_joined")) return {'FINISHED'} else: - self.report({'ERROR'}, message) + self.report({'ERROR'}, t("Optimization.error.join_selected")) return {'CANCELLED'} except Exception as e: From c081b892331d2b0ac8f94c88f9420659f7421854 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 16 Dec 2024 12:29:35 +0000 Subject: [PATCH 18/19] Attach Meshes --- core/common.py | 24 ++++ core/properties.py | 46 ++++--- functions/custom_tools/armature_merging.py | 30 +---- functions/custom_tools/mesh_attachment.py | 130 ++++++++++++++++++++ resources/translations/en_US.json | 86 +++++++------ ui/custom_avatar_panel.py | 136 ++++++++++----------- 6 files changed, 300 insertions(+), 152 deletions(-) diff --git a/core/common.py b/core/common.py index 548c6f4..c4e7300 100644 --- a/core/common.py +++ b/core/common.py @@ -1,5 +1,6 @@ import bpy import numpy as np +from mathutils import Vector from bpy.types import Context, Object, Modifier, EditBone, Operator from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable from ..core.logging_setup import logger @@ -592,3 +593,26 @@ def fix_zero_length_bones(armature: Object) -> None: bpy.ops.object.mode_set(mode='OBJECT') +def calculate_bone_orientation(mesh, vertices): + """Calculate optimal bone orientation based on mesh geometry.""" + + if not vertices: + return Vector((0, 0, 0.1)), 0.0 + + coords = [mesh.data.vertices[v.index].co for v in vertices] + min_co = Vector(map(min, zip(*coords))) + max_co = Vector(map(max, zip(*coords))) + dimensions = max_co - min_co + + roll_angle = 0.0 + + return dimensions, roll_angle + +def add_armature_modifier(mesh: Object, armature: Object): + """Add armature modifier to mesh.""" + for mod in mesh.modifiers: + if mod.type == 'ARMATURE': + mesh.modifiers.remove(mod) + + modifier = mesh.modifiers.new('Armature', 'ARMATURE') + modifier.object = armature \ No newline at end of file diff --git a/core/properties.py b/core/properties.py index 140c6e9..bdf045b 100644 --- a/core/properties.py +++ b/core/properties.py @@ -304,59 +304,69 @@ class AvatarToolkitSceneProperties(PropertyGroup): ) merge_armature_into: StringProperty( - name=t('CustomPanel.merge_into'), - description=t('CustomPanel.merge_into_desc'), + name=t('MergeArmature.into'), + description=t('MergeArmature.into_desc'), default="" ) merge_armature: StringProperty( - name=t('CustomPanel.merge_from'), - description=t('CustomPanel.merge_from_desc'), + name=t('MergeArmature.from'), + description=t('MergeArmature.from_desc'), default="" ) attach_mesh: StringProperty( - name=t('CustomPanel.attach_mesh'), - description=t('CustomPanel.attach_mesh_desc'), + name=t('AttachMesh.select'), + description=t('AttachMesh.select_desc'), default="" ) attach_bone: StringProperty( - name=t('CustomPanel.attach_bone'), - description=t('CustomPanel.attach_bone_desc'), + name=t('AttachBone.select'), + description=t('AttachBone.select_desc'), default="" ) merge_all_bones: BoolProperty( - name=t('CustomPanel.merge_all_bones'), - description=t('CustomPanel.merge_all_bones_desc'), + name=t('MergeArmature.merge_all'), + description=t('MergeArmature.merge_all_desc'), default=True ) apply_transforms: BoolProperty( - name=t('CustomPanel.apply_transforms'), - description=t('CustomPanel.apply_transforms_desc'), + name=t('MergeArmature.apply_transforms'), + description=t('MergeArmature.apply_transforms_desc'), default=True ) join_meshes: BoolProperty( - name=t('CustomPanel.join_meshes'), - description=t('CustomPanel.join_meshes_desc'), + name=t('MergeArmature.join_meshes'), + description=t('MergeArmature.join_meshes_desc'), default=True ) remove_zero_weights: BoolProperty( - name=t('CustomPanel.remove_zero_weights'), - description=t('CustomPanel.remove_zero_weights_desc'), + name=t('MergeArmature.remove_zero_weights'), + description=t('MergeArmature.remove_zero_weights_desc'), default=True ) cleanup_shape_keys: BoolProperty( - name=t('CustomPanel.cleanup_shape_keys'), - description=t('CustomPanel.cleanup_shape_keys_desc'), + name=t('MergeArmature.cleanup_shape_keys'), + description=t('MergeArmature.cleanup_shape_keys_desc'), default=True ) + attach_mesh: StringProperty( + name=t("Tools.attach_mesh_select"), + description=t("Tools.attach_mesh_select_desc") + ) + + attach_bone: StringProperty( + name=t("Tools.attach_bone_select"), + description=t("Tools.attach_bone_select_desc") + ) + def register() -> None: """Register the Avatar Toolkit property group""" logger.info("Registering Avatar Toolkit properties") diff --git a/functions/custom_tools/armature_merging.py b/functions/custom_tools/armature_merging.py index 2ff4e77..7164652 100644 --- a/functions/custom_tools/armature_merging.py +++ b/functions/custom_tools/armature_merging.py @@ -1,19 +1,15 @@ import bpy import numpy as np from typing import List, Optional, Dict, Set -from mathutils import Vector from bpy.types import Context, Object, Operator from ...core.logging_setup import logger from ...core.translations import t from ...core.common import ( - get_active_armature, get_all_meshes, fix_zero_length_bones, clear_unused_data_blocks, - validate_armature, join_mesh_objects, - fix_uv_coordinates, remove_unused_shapekeys ) @@ -40,7 +36,7 @@ class AvatarToolkit_OT_MergeArmature(Operator): if not base_armature or not merge_armature: logger.error(f"Armature not found: {merge_armature_name}") - self.report({'ERROR'}, t('MergeArmature.error.notFound', name=merge_armature_name)) + self.report({'ERROR'}, t('MergeArmature.error.not_found', name=merge_armature_name)) return {'CANCELLED'} # Remove Rigid Bodies and Joints @@ -80,21 +76,6 @@ class AvatarToolkit_OT_MergeArmature(Operator): self.report({'ERROR'}, str(e)) return {'CANCELLED'} -def calculate_bone_orientation(mesh, vertices): - """Calculate optimal bone orientation based on mesh geometry.""" - - if not vertices: - return Vector((0, 0, 0.1)), 0.0 - - coords = [mesh.data.vertices[v.index].co for v in vertices] - min_co = Vector(map(min, zip(*coords))) - max_co = Vector(map(max, zip(*coords))) - dimensions = max_co - min_co - - roll_angle = 0.0 - - return dimensions, roll_angle - def delete_rigidbodies_and_joints(armature: Object): """Delete rigid bodies and joints associated with the armature.""" to_delete = [] @@ -398,15 +379,6 @@ def mix_vertex_groups(mesh: Object, vg_from_name: str, vg_to_name: str): vg_to.add(range(num_vertices), weights_combined.tolist(), 'REPLACE') mesh.vertex_groups.remove(vg_from) -def add_armature_modifier(mesh: Object, armature: Object): - """Add armature modifier to mesh.""" - for mod in mesh.modifiers: - if mod.type == 'ARMATURE': - mesh.modifiers.remove(mod) - - modifier = mesh.modifiers.new('Armature', 'ARMATURE') - modifier.object = armature - def remove_unused_vertex_groups(mesh: Object): """Remove vertex groups with no weights.""" for vg in mesh.vertex_groups: diff --git a/functions/custom_tools/mesh_attachment.py b/functions/custom_tools/mesh_attachment.py index e69de29..560521f 100644 --- a/functions/custom_tools/mesh_attachment.py +++ b/functions/custom_tools/mesh_attachment.py @@ -0,0 +1,130 @@ +import bpy +from bpy.types import Operator, Context, Object +from mathutils import Vector +from typing import Set, Optional + +from ...core.logging_setup import logger +from ...core.translations import t +from ...core.common import ( + get_active_armature, + validate_armature, + get_all_meshes, + ProgressTracker, + calculate_bone_orientation, + add_armature_modifier +) + +class AvatarToolkit_OT_AttachMesh(Operator): + """Attach a mesh to an armature bone with automatic weight setup""" + bl_idname = "avatar_toolkit.attach_mesh" + bl_label = t("AttachMesh.label") + bl_description = t("AttachMesh.desc") + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: Context) -> bool: + armature = get_active_armature(context) + return armature is not None and context.mode == 'OBJECT' and len(get_all_meshes(context)) > 0 + + def execute(self, context: Context) -> Set[str]: + try: + logger.info("Starting mesh attachment process") + + mesh_name = context.scene.avatar_toolkit.attach_mesh + armature = get_active_armature(context) + attach_bone_name = context.scene.avatar_toolkit.attach_bone + mesh = bpy.data.objects.get(mesh_name) + + with ProgressTracker(context, 10, "Attaching Mesh") as progress: + # Validation steps + is_valid, error_msg = validate_mesh_transforms(mesh) + if not is_valid: + raise ValueError(error_msg) + progress.step(t("AttachMesh.validate_transforms")) + + is_valid, error_msg = validate_mesh_name(armature, mesh_name) + if not is_valid: + raise ValueError(error_msg) + progress.step(t("AttachMesh.validate_name")) + + # Parent mesh to armature + mesh.parent = armature + mesh.parent_type = 'OBJECT' + progress.step(t("AttachMesh.parent_mesh")) + + # Setup vertex groups + if mesh.vertex_groups: + for vg in mesh.vertex_groups: + mesh.vertex_groups.remove(vg) + + bpy.ops.object.select_all(action='DESELECT') + mesh.select_set(True) + context.view_layer.objects.active = mesh + + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + vg = mesh.vertex_groups.new(name=mesh_name) + bpy.ops.object.vertex_group_assign() + bpy.ops.object.mode_set(mode='OBJECT') + progress.step(t("AttachMesh.setup_weights")) + + # Create and setup bone + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='EDIT') + + attach_to_bone = armature.data.edit_bones.get(attach_bone_name) + if not attach_to_bone: + raise ValueError(t("AttachMesh.error.bone_not_found", bone=attach_bone_name)) + + mesh_bone = armature.data.edit_bones.new(mesh_name) + mesh_bone.parent = attach_to_bone + progress.step(t("AttachMesh.create_bone")) + + # Calculate bone placement + verts_in_group = [v for v in mesh.data.vertices + for g in v.groups if g.group == vg.index] + dimensions, roll_angle = calculate_bone_orientation(mesh, verts_in_group) + + # Set bone position and orientation + center = Vector((0, 0, 0)) + for v in verts_in_group: + center += mesh.data.vertices[v.index].co + center /= len(verts_in_group) + + mesh_bone.head = center + mesh_bone.tail = center + Vector((0, 0, max(0.1, dimensions.z))) + mesh_bone.roll = roll_angle + progress.step(t("AttachMesh.position_bone")) + + bpy.ops.object.mode_set(mode='OBJECT') + add_armature_modifier(mesh, armature) + progress.step(t("AttachMesh.add_modifier")) + + logger.info(f"Successfully attached mesh {mesh_name} to bone {attach_bone_name}") + self.report({'INFO'}, t("AttachMesh.success")) + return {'FINISHED'} + + except Exception as e: + logger.error(f"Failed to attach mesh: {str(e)}") + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + +def validate_mesh_transforms(mesh): + """Validate mesh transforms are suitable for attaching.""" + if not mesh: + return False, "Mesh not found" + + # Check for non-uniform scale + scale = mesh.scale + if abs(scale[0] - scale[1]) > 0.001 or abs(scale[1] - scale[2]) > 0.001: + return False, "Mesh has non-uniform scale. Please apply scale (Ctrl+A)" + + return True, "" + +def validate_mesh_name(armature, mesh_name): + """Validate mesh name doesn't conflict with existing bones.""" + if mesh_name in armature.data.bones: + return False, f"Bone named '{mesh_name}' already exists in armature" + return True, "" \ No newline at end of file diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index 62823e7..dd82000 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -317,48 +317,64 @@ "CustomPanel.label": "Custom Avatar Tools", "CustomPanel.merge_mode": "Merge Mode", - "CustomPanel.merge_mode_desc": "Select mode for merging operations", + "CustomPanel.mesh_selection": "Mesh Selection", + "CustomPanel.select_mesh": "Select Mesh", + "CustomPanel.select_bone": "Select Bone", + "CustomPanel.select_armature": "Select Armature", "CustomPanel.mode.armature": "Armature", "CustomPanel.mode.armature_desc": "Merge armatures together", "CustomPanel.mode.mesh": "Mesh", "CustomPanel.mode.mesh_desc": "Attach meshes to armature", - "CustomPanel.mergeArmatures": "Merge Armatures", - "CustomPanel.warn.twoArmatures": "Need at least two armatures to merge", - "CustomPanel.warn.noArmOrMesh1": "No armature or meshes found", - "CustomPanel.warn.noArmOrMesh2": "Please add required objects first", - "CustomPanel.merge_into": "Merge Into", - "CustomPanel.merge_into_desc": "Target armature to merge into", - "CustomPanel.merge_from": "Merge From", - "CustomPanel.merge_from_desc": "Source armature to merge", - "CustomPanel.toMerge": "To Merge", - "CustomPanel.attachMesh1": "Attach Mesh", - "CustomPanel.attachMesh2": "Select Mesh", - "CustomPanel.attach_mesh": "Mesh to Attach", - "CustomPanel.attach_mesh_desc": "Select mesh to attach", - "CustomPanel.attachToBone": "Attach to Bone", - "CustomPanel.attach_bone": "Target Bone", - "CustomPanel.attach_bone_desc": "Select bone to attach to", - "CustomPanel.merge_same_bones": "Merge Same Bones", - "CustomPanel.merge_same_bones_desc": "Merge bones with matching names", - "CustomPanel.apply_transforms": "Apply Transforms", - "CustomPanel.apply_transforms_desc": "Apply all transformations before merging", - "CustomPanel.join_meshes": "Join Meshes", - "CustomPanel.join_meshes_desc": "Join meshes after merging", - "CustomPanel.remove_zero_weights": "Remove Zero Weights", - "CustomPanel.remove_zero_weights_desc": "Remove vertex groups with no weights", - "CustomPanel.cleanup_shape_keys": "Clean Shape Keys", - "CustomPanel.cleanup_shape_keys_desc": "Remove unused shape keys", - "CustomPanel.merge_all_bones": "Merge Same Bones", - "CustomPanel.merge_all_bones_desc": "Merge bones with matching names", - "CustomPanel.mergeInto": "Merge Into", + + "AttachMesh.label": "Attach Mesh", + "AttachMesh.desc": "Attach a mesh to an armature bone with automatic weight setup", + "AttachMesh.search_desc": "Search for meshes to attach", + "AttachMesh.select": "Select Mesh to Attach", + "AttachMesh.select_desc": "Choose a mesh to attach to the armature", + "AttachMesh.success": "Mesh attached successfully", + "AttachMesh.warn_no_armature": "Select an armature and mesh to attach", + "AttachMesh.validate_transforms": "Validating mesh transforms", + "AttachMesh.validate_name": "Validating mesh name", + "AttachMesh.parent_mesh": "Parenting mesh to armature", + "AttachMesh.setup_weights": "Setting up vertex weights", + "AttachMesh.create_bone": "Creating attachment bone", + "AttachMesh.position_bone": "Positioning bone", + "AttachMesh.add_modifier": "Adding armature modifier", + "AttachMesh.error.bone_not_found": "Attach bone '{bone}' not found", + "AttachMesh.error.mesh_not_found": "Mesh not found", + "AttachMesh.error.non_uniform_scale": "Mesh has non-uniform scale. Please apply scale", + "AttachBone.search_desc": "Search for target bone", + "AttachBone.select": "Select Target Bone", + "AttachBone.select_desc": "Choose the bone to attach the mesh to", + "MergeArmature.label": "Merge Armatures", "MergeArmature.desc": "Merge two armatures together", - "MergeArmature.error.notFound": "Armature '{name}' not found", - "MergeArmature.success": "Armatures merged successfully", - "MergeArmature.error.checkTransforms": "Please check parent transformations", - "MergeArmature.error.pleaseFix": "Please fix parent relationships", + "MergeArmature.options": "Merge Options", + "MergeArmature.warn_two": "Need at least two armatures to merge", + "MergeArmature.into": "Merge Into", + "MergeArmature.into_desc": "Target armature to merge into", + "MergeArmature.into_search_desc": "Search for target armature", + "MergeArmature.from": "Merge From", + "MergeArmature.from_desc": "Source armature to merge from", + "MergeArmature.from_search_desc": "Search for source armature", + "MergeArmature.error.not_found": "Armature '{name}' not found", "MergeArmature.error.transforms_not_aligned": "Transforms must be applied to merge this armature, either do this via the manual method or via apply transform checkmark", - + "MergeArmature.error.check_transforms": "Please check parent transformations", + "MergeArmature.error.fix_parents": "Please fix parent relationships", + "MergeArmature.progress.removing_rigidbodies": "Removing rigid bodies and joints", + "MergeArmature.progress.validating": "Validating armatures", + "MergeArmature.progress.merging": "Merging armatures", + "MergeArmature.success": "Armatures merged successfully", + "MergeArmature.merge_all": "Merge Same Bones", + "MergeArmature.merge_all_desc": "Merge bones with matching names", + "MergeArmature.apply_transforms": "Apply Transforms", + "MergeArmature.apply_transforms_desc": "Apply all transformations before merging", + "MergeArmature.join_meshes": "Join Meshes", + "MergeArmature.join_meshes_desc": "Join meshes after merging", + "MergeArmature.remove_zero_weights": "Remove Zero Weights", + "MergeArmature.remove_zero_weights_desc": "Remove vertex groups with no weights", + "MergeArmature.cleanup_shape_keys": "Clean Shape Keys", + "MergeArmature.cleanup_shape_keys_desc": "Remove unused shape keys", "Settings.label": "Settings", "Settings.language": "Language", diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py index 4332e1b..ed4347c 100644 --- a/ui/custom_avatar_panel.py +++ b/ui/custom_avatar_panel.py @@ -13,13 +13,12 @@ from ..core.common import ( class AvatarToolkit_OT_SearchMergeArmatureInto(Operator): bl_idname = "avatar_toolkit.search_merge_armature_into" bl_label = "" - bl_description = t('CustomPanel.search_merge_into_desc') + bl_description = t('MergeArmature.into_search_desc') bl_property = "search_merge_armature_into_enum" - # Define the enum property within the operator class search_merge_armature_into_enum: bpy.props.EnumProperty( - name=t('CustomPanel.merge_into'), - description=t('CustomPanel.merge_into_desc'), + name=t('MergeArmature.into'), + description=t('MergeArmature.into_desc'), items=get_armature_list ) @@ -34,12 +33,12 @@ class AvatarToolkit_OT_SearchMergeArmatureInto(Operator): class AvatarToolkit_OT_SearchMergeArmature(Operator): bl_idname = "avatar_toolkit.search_merge_armature" bl_label = "" - bl_description = t('CustomPanel.search_merge_desc') + bl_description = t('MergeArmature.from_search_desc') bl_property = "search_merge_armature_enum" search_merge_armature_enum: bpy.props.EnumProperty( - name=t('CustomPanel.merge_from'), - description=t('CustomPanel.merge_from_desc'), + name=t('MergeArmature.from'), + description=t('MergeArmature.from_desc'), items=get_armature_list ) @@ -54,15 +53,17 @@ class AvatarToolkit_OT_SearchMergeArmature(Operator): class AvatarToolkit_OT_SearchAttachMesh(Operator): bl_idname = "avatar_toolkit.search_attach_mesh" bl_label = "" - bl_description = t('CustomPanel.search_mesh_desc') + bl_description = t('AttachMesh.search_desc') bl_property = "search_attach_mesh_enum" search_attach_mesh_enum: bpy.props.EnumProperty( - name=t('CustomPanel.attach_mesh'), - description=t('CustomPanel.attach_mesh_desc'), + name=t('AttachMesh.select'), + description=t('AttachMesh.select_desc'), items=lambda self, context: [ (obj.name, obj.name, "") - for obj in get_all_meshes(context) + for obj in bpy.data.objects + if obj.type == 'MESH' + and not any(mod.type == 'ARMATURE' for mod in obj.modifiers) ] ) @@ -77,12 +78,12 @@ class AvatarToolkit_OT_SearchAttachMesh(Operator): class AvatarToolkit_OT_SearchAttachBone(Operator): bl_idname = "avatar_toolkit.search_attach_bone" bl_label = "" - bl_description = t('CustomPanel.search_bone_desc') + bl_description = t('AttachBone.search_desc') bl_property = "search_attach_bone_enum" search_attach_bone_enum: bpy.props.EnumProperty( - name=t('CustomPanel.attach_bone'), - description=t('CustomPanel.attach_bone_desc'), + name=t('AttachBone.select'), + description=t('AttachBone.select_desc'), items=lambda self, context: [ (bone.name, bone.name, "") for bone in get_active_armature(context).data.bones @@ -109,7 +110,6 @@ class AvatarToolKit_PT_CustomPanel(Panel): bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: - """Draw the custom avatar tools panel interface""" layout: UILayout = self.layout toolkit = context.scene.avatar_toolkit @@ -119,113 +119,109 @@ class AvatarToolKit_PT_CustomPanel(Panel): col.label(text=t('CustomPanel.merge_mode'), icon='TOOL_SETTINGS') col.separator(factor=0.5) - # Create a row for the mode buttons with increased scale row: UILayout = col.row(align=True) row.scale_y = 1.5 row.prop(toolkit, "merge_mode", expand=True) - # Armature Merging Tools if toolkit.merge_mode == 'ARMATURE': self.draw_armature_tools(layout, context) - # Mesh Attachment Tools else: self.draw_mesh_tools(layout, context) def draw_armature_tools(self, layout: UILayout, context: Context) -> None: - """Draw the armature merging tools section""" toolkit = context.scene.avatar_toolkit # Merge Settings Box settings_box: UILayout = layout.box() col: UILayout = settings_box.column(align=True) - col.label(text=t('CustomPanel.mergeArmatures'), icon='ARMATURE_DATA') + col.label(text=t('MergeArmature.label'), icon='ARMATURE_DATA') col.separator(factor=0.5) if len(get_armature_list(context)) <= 1: - col.label(text=t('CustomPanel.warn.twoArmatures'), icon='INFO') + col.label(text=t('MergeArmature.warn_two'), icon='INFO') return - # Merge Options + # Options Box with better spacing options_box: UILayout = layout.box() col: UILayout = options_box.column(align=True) - col.label(text=t('Tools.merge_title'), icon='SETTINGS') - col.separator(factor=0.5) - col.prop(toolkit, "merge_all_bones") - col.prop(toolkit, "apply_transforms") - col.prop(toolkit, "join_meshes") - col.prop(toolkit, "remove_zero_weights") - col.prop(toolkit, "cleanup_shape_keys") - - # Armature Selection Box - selection_box: UILayout = layout.box() - col: UILayout = selection_box.column(align=True) - col.label(text=t('QuickAccess.select_armature'), icon='BONE_DATA') + col.label(text=t('MergeArmature.options'), icon='SETTINGS') col.separator(factor=0.5) + # Group related options together + transform_col = col.column(align=True) + transform_col.prop(toolkit, "merge_all_bones") + transform_col.prop(toolkit, "apply_transforms") + + col.separator(factor=0.5) + + cleanup_col = col.column(align=True) + cleanup_col.prop(toolkit, "join_meshes") + cleanup_col.prop(toolkit, "remove_zero_weights") + cleanup_col.prop(toolkit, "cleanup_shape_keys") + + # Selection Box with consistent styling + selection_box: UILayout = layout.box() + col: UILayout = selection_box.column(align=True) + col.label(text=t('CustomPanel.select_armature'), icon='BONE_DATA') + col.separator(factor=0.5) + + # Armature selection with better alignment row: UILayout = col.row(align=True) - row.label(text=t('CustomPanel.mergeInto')) + row.label(text=t('MergeArmature.into'), icon='ARMATURE_DATA') row.operator("avatar_toolkit.search_merge_armature_into", - text=toolkit.merge_armature_into, - icon='ARMATURE_DATA') + text=toolkit.merge_armature_into) row: UILayout = col.row(align=True) - row.label(text=t('CustomPanel.toMerge')) + row.label(text=t('MergeArmature.from'), icon='ARMATURE_DATA') row.operator("avatar_toolkit.search_merge_armature", - text=toolkit.merge_armature, - icon='ARMATURE_DATA') + text=toolkit.merge_armature) - # Merge Button - merge_col: UILayout = layout.column(align=True) - merge_col.scale_y = 1.2 - merge_col.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA') + # Merge button with emphasis + merge_box: UILayout = layout.box() + col = merge_box.column(align=True) + row = col.row(align=True) + row.scale_y = 1.5 + row.operator("avatar_toolkit.merge_armatures", icon='ARMATURE_DATA') def draw_mesh_tools(self, layout: UILayout, context: Context) -> None: - """Draw the mesh attachment tools section""" toolkit = context.scene.avatar_toolkit # Mesh Tools Box tools_box: UILayout = layout.box() col: UILayout = tools_box.column(align=True) - col.label(text=t('CustomPanel.attachMesh1'), icon='MESH_DATA') + col.label(text=t('AttachMesh.label'), icon='MESH_DATA') col.separator(factor=0.5) if not get_active_armature(context) or not get_all_meshes(context): - col.label(text=t('CustomPanel.warn.noArmOrMesh1'), icon='INFO') - col.label(text=t('CustomPanel.warn.noArmOrMesh2')) + col.label(text=t('AttachMesh.warn_no_armature'), icon='INFO') return - # Mesh Options Box - options_box: UILayout = layout.box() - col: UILayout = options_box.column(align=True) - col.label(text=t('Tools.merge_title'), icon='SETTINGS') - col.separator(factor=0.5) - col.prop(toolkit, "join_meshes") - - # Selection Box + # Selection Box with consistent styling selection_box: UILayout = layout.box() col: UILayout = selection_box.column(align=True) - col.label(text=t('Tools.merge_title'), icon='OBJECT_DATA') + col.label(text=t('CustomPanel.mesh_selection'), icon='OBJECT_DATA') col.separator(factor=0.5) + # Selection rows with icons and better alignment row: UILayout = col.row(align=True) - row.label(text=t('CustomPanel.mergeInto')) + row.label(text=t('CustomPanel.select_armature'), icon='ARMATURE_DATA') row.operator("avatar_toolkit.search_merge_armature_into", - text=toolkit.merge_armature_into, - icon='ARMATURE_DATA') + text=toolkit.merge_armature_into) row: UILayout = col.row(align=True) - row.label(text=t('CustomPanel.attachMesh2')) + row.label(text=t('CustomPanel.select_mesh'), icon='MESH_DATA') row.operator("avatar_toolkit.search_attach_mesh", - text=toolkit.attach_mesh, - icon='MESH_DATA') + text=toolkit.attach_mesh) row: UILayout = col.row(align=True) - row.label(text=t('CustomPanel.attachToBone')) + row.label(text=t('CustomPanel.select_bone'), icon='BONE_DATA') row.operator("avatar_toolkit.search_attach_bone", - text=toolkit.attach_bone, - icon='BONE_DATA') + text=toolkit.attach_bone) + + # Attach button with emphasis + attach_box: UILayout = layout.box() + col = attach_box.column(align=True) + row = col.row(align=True) + row.scale_y = 1.5 + row.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA') - # Attach Button - attach_col: UILayout = layout.column(align=True) - attach_col.scale_y = 1.2 - attach_col.operator("avatar_toolkit.attach_mesh", icon='ARMATURE_DATA') From aaad062b416fbcff461d520bc91318b7d2f9b46c Mon Sep 17 00:00:00 2001 From: Yusarina Date: Mon, 16 Dec 2024 12:57:51 +0000 Subject: [PATCH 19/19] Finale Fixes --- core/properties.py | 9 +++++++++ core/updater.py | 2 +- functions/tools/bone_tools.py | 7 +++++++ ui/custom_avatar_panel.py | 2 +- ui/eye_tracking_panel.py | 2 +- ui/quick_access_panel.py | 5 ----- ui/settings_panel.py | 2 +- ui/visemes_panel.py | 2 +- 8 files changed, 21 insertions(+), 10 deletions(-) diff --git a/core/properties.py b/core/properties.py index bdf045b..1aa4a28 100644 --- a/core/properties.py +++ b/core/properties.py @@ -93,6 +93,15 @@ class AvatarToolkitSceneProperties(PropertyGroup): default=False ) + connect_bones_min_distance: FloatProperty( + name=t("Tools.connect_bones_min_distance"), + description=t("Tools.connect_bones_min_distance_desc"), + default=0.001, + min=0.0001, + max=0.1, + precision=4 + ) + merge_twist_bones: BoolProperty( name=t("MMD.merge_twist_bones"), description=t("MMD.merge_twist_bones_desc"), diff --git a/core/updater.py b/core/updater.py index ffab4b5..c22a4cc 100644 --- a/core/updater.py +++ b/core/updater.py @@ -76,7 +76,7 @@ class AvatarToolkit_PT_UpdaterPanel(bpy.types.Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 4 + bl_order = 8 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: bpy.types.Context) -> None: diff --git a/functions/tools/bone_tools.py b/functions/tools/bone_tools.py index 2e60524..68e395e 100644 --- a/functions/tools/bone_tools.py +++ b/functions/tools/bone_tools.py @@ -135,6 +135,12 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): def execute(self, context: Context) -> set[str]: """Execute the constraint removal operation""" armature = get_active_armature(context) + + # Select armature and make it active before changing mode + bpy.ops.object.select_all(action='DESELECT') + armature.select_set(True) + context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode='POSE') constraints_removed = 0 @@ -147,6 +153,7 @@ class AvatarToolKit_OT_DeleteBoneConstraints(Operator): self.report({'INFO'}, t("Tools.clean_constraints_success", count=constraints_removed)) return {'FINISHED'} + class AvatarToolKit_OT_RemoveZeroWeightBones(Operator): """Operator to remove bones with no vertex weights""" bl_idname = "avatar_toolkit.clean_weights" diff --git a/ui/custom_avatar_panel.py b/ui/custom_avatar_panel.py index ed4347c..9c2c378 100644 --- a/ui/custom_avatar_panel.py +++ b/ui/custom_avatar_panel.py @@ -106,7 +106,7 @@ class AvatarToolKit_PT_CustomPanel(Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 + bl_order = 4 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: diff --git a/ui/eye_tracking_panel.py b/ui/eye_tracking_panel.py index 8e2c1c7..ffb59fe 100644 --- a/ui/eye_tracking_panel.py +++ b/ui/eye_tracking_panel.py @@ -26,7 +26,7 @@ class AvatarToolKit_PT_EyeTrackingPanel(Panel): bl_region_type = 'UI' bl_category = CATEGORY_NAME bl_parent_id = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order = 3 + bl_order = 6 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index 0b02111..5bfceae 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -75,11 +75,6 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname bl_order: int = 0 - @classmethod - def poll(cls, context: Context) -> bool: - """Only show panel in Object or Pose mode""" - return context.mode in {'OBJECT', 'POSE'} - def draw(self, context: Context) -> None: """Draw the panel layout""" layout: UILayout = self.layout diff --git a/ui/settings_panel.py b/ui/settings_panel.py index 6650698..ed32263 100644 --- a/ui/settings_panel.py +++ b/ui/settings_panel.py @@ -36,7 +36,7 @@ class AvatarToolKit_PT_SettingsPanel(Panel): bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order: int = 5 + bl_order: int = 7 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: diff --git a/ui/visemes_panel.py b/ui/visemes_panel.py index 33f4d9f..d79d041 100644 --- a/ui/visemes_panel.py +++ b/ui/visemes_panel.py @@ -10,7 +10,7 @@ class AvatarToolKit_PT_VisemesPanel(Panel): bl_region_type: str = 'UI' bl_category: str = CATEGORY_NAME bl_parent_id: str = AvatarToolKit_PT_AvatarToolkitPanel.bl_idname - bl_order: int = 4 + bl_order: int = 5 bl_options = {'DEFAULT_CLOSED'} def draw(self, context: Context) -> None: