From a929f68ad423d4d41fb7b6f245038e969e170708 Mon Sep 17 00:00:00 2001 From: Yusarina Date: Wed, 19 Nov 2025 06:35:06 +0000 Subject: [PATCH] Holy shit this was a pain - Truly fixes PMX Import lol, i messed up completely - Updated MMD Tools to use Cats One --- core/importers/importer.py | 44 +- core/mmd/__init__.py | 2 +- core/mmd/bpyutils.py | 208 +++-- core/mmd/core/__init__.py | 2 +- core/mmd/core/bone.py | 284 +++---- core/mmd/core/camera.py | 359 +++----- core/mmd/core/exceptions.py | 12 +- core/mmd/core/lamp.py | 49 +- core/mmd/core/material.py | 248 ++---- core/mmd/core/model.py | 927 +++++++++++++------- core/mmd/core/morph.py | 204 +++-- core/mmd/core/pmx/__init__.py | 354 ++++---- core/mmd/core/pmx/importer.py | 109 ++- core/mmd/core/rigid_body.py | 65 +- core/mmd/core/sdef.py | 135 ++- core/mmd/core/shader.py | 109 +-- core/mmd/core/translations.py | 137 ++- core/mmd/core/vmd/__init__.py | 2 +- core/mmd/core/vmd/importer.py | 28 +- core/mmd/cycles_converter.py | 111 ++- core/mmd/operators/__init__.py | 2 +- core/mmd/operators/fileio.py | 1209 +++++++++++++++++++++++++++ core/mmd/operators/material.py | 242 +++--- core/mmd/operators/misc.py | 115 ++- core/mmd/operators/model_edit.py | 379 ++++++--- core/mmd/operators/morph.py | 514 +++++++++--- core/mmd/operators/rigid_body.py | 82 +- core/mmd/operators/sdef.py | 36 +- core/mmd/operators/translations.py | 302 ++++++- core/mmd/operators/view.py | 97 +-- core/mmd/properties/material.py | 50 +- core/mmd/properties/morph.py | 107 ++- core/mmd/properties/pose_bone.py | 114 ++- core/mmd/properties/rigid_body.py | 77 +- core/mmd/properties/root.py | 143 ++-- core/mmd/properties/translations.py | 8 +- core/mmd/translations.py | 149 +--- core/mmd/utils.py | 173 ++-- 38 files changed, 4479 insertions(+), 2709 deletions(-) create mode 100644 core/mmd/operators/fileio.py diff --git a/core/importers/importer.py b/core/importers/importer.py index 77f0a87..cc5ab4b 100644 --- a/core/importers/importer.py +++ b/core/importers/importer.py @@ -8,7 +8,6 @@ from bpy_extras.io_utils import ImportHelper from typing import Optional, Callable, Dict, List, Union, Set from ..common import clear_default_objects from ..translations import t -from ..mmd.core.pmx.importer import PMXImporter import traceback # Configure logging @@ -203,34 +202,31 @@ class AvatarToolKit_OT_Import(Operator, ImportHelper): def import_pmx_file(filepath: str) -> None: """ - Import a PMX file using the MMD Tools PMXImporter + Import a PMX file using the MMD Tools import operator Args: filepath: Path to the PMX file """ - # Default import settings - import_settings = { - "filepath": filepath, - "scale": 0.08, - "types": {"MESH", "ARMATURE", "MORPHS", "DISPLAY"}, - "clean_model": True, - "remove_doubles": False, - "fix_IK_links": True, - "ik_loop_factor": 3, - "use_mipmap": True, - "sph_blend_factor": 1.0, - "spa_blend_factor": 1.0, - "rename_LR_bones": False, - "use_underscore": False, - "apply_bone_fixed_axis": False, - } - - # Create and execute the importer - importer = PMXImporter() + # Use the MMD Tools operator to import PMX files (CATS-compatible) + # Must pass files + directory like CATS does, not just filepath try: - importer.execute(**import_settings) + directory = os.path.dirname(filepath) + filename = os.path.basename(filepath) + + bpy.ops.mmd_tools.import_model('EXEC_DEFAULT', + files=[{'name': filename}], + directory=directory, + scale=0.08, + types={'MESH', 'ARMATURE', 'MORPHS', 'DISPLAY'}, + clean_model=False, # Disable cleaning to preserve morph indices + remove_doubles=False, + fix_ik_links=False, + ik_loop_factor=5, + apply_bone_fixed_axis=False, + rename_bones=False, + use_underscore=False) logger.info(f"Successfully imported PMX file: {filepath}") - except Exception: - logger.error(f"Failed to import PMX file: {traceback.format_exc()}", exc_info=True) + except (AttributeError, TypeError, ValueError) as e: + logger.error(f"Failed to import PMX file: {e}", exc_info=True) raise diff --git a/core/mmd/__init__.py b/core/mmd/__init__.py index af8a62d..27b0111 100644 --- a/core/mmd/__init__.py +++ b/core/mmd/__init__.py @@ -23,4 +23,4 @@ try: else: AVATAR_TOOLKIT_VERSION = '0.2.1' except Exception: - AVATAR_TOOLKIT_VERSION = '0.2.1' \ No newline at end of file + AVATAR_TOOLKIT_VERSION = '0.2.1' diff --git a/core/mmd/bpyutils.py b/core/mmd/bpyutils.py index 3bc6d28..73080f5 100644 --- a/core/mmd/bpyutils.py +++ b/core/mmd/bpyutils.py @@ -1,18 +1,13 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2013 MMD Tools authors +# This file is part of MMD Tools. import contextlib -from typing import Generator, List, Optional, TypeVar, Any, Set, Tuple, Dict, Union +import math +from typing import Generator, List, Optional, TypeVar +import bmesh import bpy -from bpy.types import Object, Context, ID, Key, ShapeKey, FCurve, LayerCollection, Collection -from bpy.types import AddonPreferences, Addon, WindowManager, Area, Region, Window - -from ..logging_setup import logger +from mathutils import Matrix class Props: # For API changes of only name changed properties @@ -24,7 +19,7 @@ class Props: # For API changes of only name changed properties class __EditMode: - def __init__(self, obj: Object): + def __init__(self, obj): if not isinstance(obj, bpy.types.Object): raise ValueError self.__prevMode = obj.mode @@ -34,10 +29,10 @@ class __EditMode: if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") - def __enter__(self) -> Any: + def __enter__(self): return self.__obj.data - def __exit__(self, type: Any, value: Any, traceback: Any) -> None: + def __exit__(self, exc_type, exc_value, traceback): if self.__prevMode == "EDIT": bpy.ops.object.mode_set(mode="OBJECT") # update edited data bpy.ops.object.mode_set(mode=self.__prevMode) @@ -45,43 +40,46 @@ class __EditMode: class __SelectObjects: - def __init__(self, active_object: Object, selected_objects: Optional[List[Object]] = None): + def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None): if not isinstance(active_object, bpy.types.Object): raise ValueError try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: - logger.debug("Failed to set object mode") pass - context = FnContext.ensure_context() + contenxt = FnContext.ensure_context() - for i in context.selected_objects: + for i in contenxt.selected_objects: i.select_set(False) self.__active_object = active_object - self.__selected_objects = tuple(set(selected_objects) | set([active_object])) if selected_objects else (active_object,) + self.__selected_objects = tuple(set(selected_objects) | {active_object}) if selected_objects else (active_object,) self.__hides: List[bool] = [] for i in self.__selected_objects: self.__hides.append(i.hide_get()) - FnContext.select_object(context, i) - FnContext.set_active_object(context, active_object) + FnContext.select_object(contenxt, i) + FnContext.set_active_object(contenxt, active_object) - def __enter__(self) -> Object: + def __enter__(self) -> bpy.types.Object: return self.__active_object - def __exit__(self, type: Any, value: Any, traceback: Any) -> None: - for i, j in zip(self.__selected_objects, self.__hides): - i.hide_set(j) + def __exit__(self, exc_type, exc_value, traceback): + for i, j in zip(self.__selected_objects, self.__hides, strict=False): + try: + i.hide_set(j) + except ReferenceError: + # Object may no longer exist, so skip restoring hidden state. + pass -def setParent(obj: Object, parent: Object) -> None: +def setParent(obj, parent): with select_object(parent, objects=[parent, obj]): bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False) -def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: +def setParentToBone(obj, parent, bone_name): with select_object(parent, objects=[parent, obj]): bpy.ops.object.mode_set(mode="POSE") parent.data.bones.active = parent.data.bones[bone_name] @@ -89,7 +87,7 @@ def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: bpy.ops.object.mode_set(mode="OBJECT") -def edit_object(obj: Object) -> __EditMode: +def edit_object(obj): """Set the object interaction mode to 'EDIT' It is recommended to use 'edit_object' with 'with' statement like the following code. @@ -100,7 +98,7 @@ def edit_object(obj: Object) -> __EditMode: return __EditMode(obj) -def select_object(obj: Object, objects: Optional[List[Object]] = None) -> __SelectObjects: +def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None): """Select objects. It is recommended to use 'select_object' with 'with' statement like the following code. @@ -109,27 +107,26 @@ def select_object(obj: Object, objects: Optional[List[Object]] = None) -> __Sele with select_object(obj): some functions... """ - # TODO: Reimplement with bpy.context.temp_override (If it ain't broke, don't fix it.) + # TODO: Consider reimplementing with bpy.context.temp_override, + # but note that Blender's new API has stability issues. + # temp_override is prone to crashes, making the current approach safer. + # If it ain't broke, don't fix it. return __SelectObjects(obj, objects) -def duplicateObject(obj: Object, total_len: int) -> List[Object]: +def duplicateObject(obj, total_len): return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len) -def createObject(name: str = "Object", object_data: Optional[ID] = None, target_scene: Optional[bpy.types.Scene] = None) -> Object: +def createObject(name="Object", object_data=None, target_scene=None): context = FnContext.ensure_context(target_scene) return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data)) -def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object: - import bmesh - +def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None): if target_object is None: - target_object = createObject(name="Sphere") - logger.debug(f"Created new sphere object: {target_object.name}") - else: - logger.debug(f"Using existing object for sphere: {target_object.name}") + mesh_data = bpy.data.meshes.new("Sphere") + target_object = createObject(name="Sphere", object_data=mesh_data) mesh = target_object.data bm = bmesh.new() @@ -146,15 +143,10 @@ def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, targe return target_object -def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optional[Object] = None) -> Object: - import bmesh - from mathutils import Matrix - +def makeBox(size=(1, 1, 1), target_object=None): if target_object is None: - target_object = createObject(name="Box") - logger.debug(f"Created new box object: {target_object.name}") - else: - logger.debug(f"Using existing object for box: {target_object.name}") + mesh_data = bpy.data.meshes.new("Box") + target_object = createObject(name="Box", object_data=mesh_data) mesh = target_object.data bm = bmesh.new() @@ -170,16 +162,10 @@ def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optiona return target_object -def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, height: float = 1.0, target_object: Optional[Object] = None) -> Object: - import math - import bmesh - +def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None): if target_object is None: - target_object = createObject(name="Capsule") - logger.debug(f"Created new capsule object: {target_object.name}") - else: - logger.debug(f"Using existing object for capsule: {target_object.name}") - + mesh_data = bpy.data.meshes.new("Capsule") + target_object = createObject(name="Capsule", object_data=mesh_data) height = max(height, 1e-3) mesh = target_object.data @@ -188,8 +174,11 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig top = (0, 0, height / 2 + radius) verts.new(top) - # f = lambda i: radius*i/ring_count - f = lambda i: radius * math.sin(0.5 * math.pi * i / ring_count) + # def f(i): + # return radius * i / ring_count + def f(i): + return radius * math.sin(0.5 * math.pi * i / ring_count) + for i in range(ring_count, 0, -1): z = f(i - 1) t = math.sqrt(radius**2 - z**2) @@ -238,10 +227,10 @@ def makeCapsule(segment: int = 8, ring_count: int = 2, radius: float = 1.0, heig class TransformConstraintOp: - __MIN_MAX_MAP: Dict[Union[str, Tuple[str, str]], Union[str, Tuple[str, ...]]] = {"ROTATION": "_rot", "SCALE": "_scale"} + __MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"} @staticmethod - def create(constraints: bpy.types.ObjectConstraints, name: str, map_type: str) -> bpy.types.TransformConstraint: + def create(constraints, name, map_type): c = constraints.get(name, None) if c and c.type != "TRANSFORM": constraints.remove(c) @@ -259,7 +248,7 @@ class TransformConstraintOp: return c @classmethod - def min_max_attributes(cls, map_type: str, name_id: str = "") -> Tuple[str, ...]: + def min_max_attributes(cls, map_type, name_id=""): key = (map_type, name_id) ret = cls.__MIN_MAX_MAP.get(key, None) if ret is None: @@ -269,7 +258,7 @@ class TransformConstraintOp: return ret @classmethod - def update_min_max(cls, constraint: bpy.types.TransformConstraint, value: float, influence: Optional[float] = 1) -> None: + def update_min_max(cls, constraint, value, influence=1): c = constraint if not c or c.type != "TRANSFORM": return @@ -293,14 +282,14 @@ class FnObject: raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def mesh_remove_shape_key(mesh_object: Object, shape_key: ShapeKey) -> None: + def mesh_remove_shape_key(mesh_object: bpy.types.Object, shape_key: bpy.types.ShapeKey): assert isinstance(mesh_object.data, bpy.types.Mesh) - key: Key = shape_key.id_data + key: bpy.types.Key = shape_key.id_data assert key == mesh_object.data.shape_keys if mesh_object.animation_data is not None: - fc_curve: FCurve + fc_curve: bpy.types.FCurve for fc_curve in mesh_object.animation_data.drivers: if not fc_curve.data_path.startswith(shape_key.path_from_id()): continue @@ -324,35 +313,43 @@ class FnContext: raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def ensure_context(context: Optional[Context] = None) -> Context: + def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context: return context or bpy.context @staticmethod - def get_active_object(context: Context) -> Optional[Object]: + def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]: + # Added defensive programming for get methods + # Related to: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/176 + if context is None or not hasattr(context, "active_object"): + return None return context.active_object @staticmethod - def set_active_object(context: Context, obj: Object) -> Object: + def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: context.view_layer.objects.active = obj return obj @staticmethod - def set_active_and_select_single_object(context: Context, obj: Object) -> Object: + def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: return FnContext.set_active_object(context, FnContext.select_single_object(context, obj)) @staticmethod - def get_scene_objects(context: Context) -> bpy.types.SceneObjects: + def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects: + # Added defensive programming for get methods + # Added for consistency with get_active_object + if context is None or not hasattr(context, "scene") or not hasattr(context.scene, "objects"): + return [] return context.scene.objects @staticmethod - def ensure_selectable(context: Context, obj: Object) -> Object: + def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: obj.hide_viewport = False obj.hide_select = False obj.hide_set(False) if obj not in context.selectable_objects: - def __layer_check(layer_collection: LayerCollection) -> bool: + def __layer_check(layer_collection: bpy.types.LayerCollection) -> bool: for lc in layer_collection.children: if __layer_check(lc): lc.hide_viewport = False @@ -374,44 +371,44 @@ class FnContext: return obj @staticmethod - def select_object(context: Context, obj: Object) -> Object: + def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: FnContext.ensure_selectable(context, obj).select_set(True) return obj @staticmethod - def select_objects(context: Context, *objects: Object) -> List[Object]: + def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]: return [FnContext.select_object(context, obj) for obj in objects] @staticmethod - def select_single_object(context: Context, obj: Object) -> Object: + def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: for i in context.selected_objects: if i != obj: i.select_set(False) return FnContext.select_object(context, obj) @staticmethod - def link_object(context: Context, obj: Object) -> Object: + def link_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: context.collection.objects.link(obj) return obj @staticmethod - def new_and_link_object(context: Context, name: str, object_data: Optional[ID]) -> Object: + def new_and_link_object(context: bpy.types.Context, name: str, object_data: Optional[bpy.types.ID]) -> bpy.types.Object: return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data)) @staticmethod - def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]: + def duplicate_object(context: bpy.types.Context, object_to_duplicate: bpy.types.Object, target_count: int) -> List[bpy.types.Object]: """ Duplicate object. This function duplicates the given object and returns a list of duplicated objects. Args: - context (Context): The context in which the duplication is performed. - object_to_duplicate (Object): The object to be duplicated. + context (bpy.types.Context): The context in which the duplication is performed. + object_to_duplicate (bpy.types.Object): The object to be duplicated. target_count (int): The desired count of duplicated objects. Returns: - List[Object]: A list of duplicated objects. + List[bpy.types.Object]: A list of duplicated objects. Raises: AssertionError: If the number of selected objects in the context is not equal to 1 or if the selected object is not the same as the object to be duplicated. @@ -435,28 +432,27 @@ class FnContext: last_selected_objects[i].select_set(True) last_selected_objects = context.selected_objects assert len(result_objects) == target_count - logger.debug(f"Duplicated object {object_to_duplicate.name} to create {target_count} objects") return result_objects @staticmethod - def find_user_layer_collection_by_object(context: Context, target_object: Object) -> Optional[LayerCollection]: + def find_user_layer_collection_by_object(context: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]: """ - Finds the layer collection that contains the given target_object in the user's collections. + Find the layer collection that contains the given target_object in the user's collections. Args: - context (Context): The Blender context. - target_object (Object): The target object to find the layer collection for. + context (bpy.types.Context): The Blender context. + target_object (bpy.types.Object): The target object to find the layer collection for. Returns: - Optional[LayerCollection]: The layer collection that contains the target_object, or None if not found. + Optional[bpy.types.LayerCollection]: The layer collection that contains the target_object, or None if not found. """ - scene_layer_collection: LayerCollection = context.view_layer.layer_collection + scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection - def find_layer_collection_by_name(layer_collection: LayerCollection, name: str) -> Optional[LayerCollection]: + def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]: if layer_collection.name == name: return layer_collection - child_layer_collection: LayerCollection + child_layer_collection: bpy.types.LayerCollection for child_layer_collection in layer_collection.children: found = find_layer_collection_by_name(child_layer_collection, name) if found is not None: @@ -464,7 +460,7 @@ class FnContext: return None - user_collection: Collection + user_collection: bpy.types.Collection for user_collection in target_object.users_collection: found = find_layer_collection_by_name(scene_layer_collection, user_collection.name) if found is not None: @@ -474,7 +470,7 @@ class FnContext: @staticmethod @contextlib.contextmanager - def temp_override_active_layer_collection(context: Context, target_object: Object) -> Generator[Context, None, None]: + def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]: """ Context manager to temporarily override the active_layer_collection that contains the target object. @@ -482,11 +478,11 @@ class FnContext: It ensures that the original active_layer_collection is restored after the context is exited. Args: - context (Context): The context in which the active_layer_collection will be overridden. - target_object (Object): The target object whose layer collection will be set as the active_layer_collection. + context (bpy.types.Context): The context in which the active_layer_collection will be overridden. + target_object (bpy.types.Object): The target object whose layer collection will be set as the active_layer_collection. Yields: - Context: The modified context with the active_layer_collection overridden. + bpy.types.Context: The modified context with the active_layer_collection overridden. Example: with FnContext.temp_override_active_layer_collection(context, target_object): @@ -507,24 +503,24 @@ class FnContext: context.view_layer.active_layer_collection = original_layer_collection @staticmethod - def __get_addon_preferences(context: Context) -> Optional[AddonPreferences]: - addon: Addon = context.preferences.addons.get(__package__, None) + def __get_addon_preferences(context: bpy.types.Context) -> Optional[bpy.types.AddonPreferences]: + addon: bpy.types.Addon = context.preferences.addons.get(__package__, None) return addon.preferences if addon else None @staticmethod - def get_addon_preferences_attribute(context: Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE: + def get_addon_preferences_attribute(context: bpy.types.Context, attribute_name: str, default_value: ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE = None) -> ADDON_PREFERENCE_ATTRIBUTE_VALUE_TYPE: return getattr(FnContext.__get_addon_preferences(context), attribute_name, default_value) @staticmethod def temp_override_objects( - context: Context, - window: Optional[Window] = None, - area: Optional[Area] = None, - region: Optional[Region] = None, - active_object: Optional[Object] = None, - selected_objects: Optional[List[Object]] = None, - **keywords: Any, - ) -> Generator[Context, None, None]: + context: bpy.types.Context, + window: Optional[bpy.types.Window] = None, + area: Optional[bpy.types.Area] = None, + region: Optional[bpy.types.Region] = None, + active_object: Optional[bpy.types.Object] = None, + selected_objects: Optional[List[bpy.types.Object]] = None, + **keywords, + ) -> Generator[bpy.types.Context, None, None]: if active_object is not None: keywords["active_object"] = active_object keywords["object"] = active_object diff --git a/core/mmd/core/__init__.py b/core/mmd/core/__init__.py index f3342f2..7a7d347 100644 --- a/core/mmd/core/__init__.py +++ b/core/mmd/core/__init__.py @@ -3,4 +3,4 @@ # This file was originally part of the MMD Tools add-on for Blender # You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. diff --git a/core/mmd/core/bone.py b/core/mmd/core/bone.py index 29b490e..dd19e3d 100644 --- a/core/mmd/core/bone.py +++ b/core/mmd/core/bone.py @@ -1,44 +1,37 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2015 MMD Tools authors +# This file is part of MMD Tools. import math -from typing import TYPE_CHECKING, Iterable, Optional, Set, List, Dict, Tuple, Any, Union, cast +from typing import TYPE_CHECKING, Iterable, Optional, Set import bpy from mathutils import Vector -from bpy.types import Object, EditBone, PoseBone, Constraint, Armature, BoneCollection from .. import bpyutils from ..bpyutils import TransformConstraintOp from ..utils import ItemOp -from ....core.logging_setup import logger if TYPE_CHECKING: - from ..properties.root import MMDRoot, MMDDisplayItemFrame from ..properties.pose_bone import MMDBone + from ..properties.root import MMDDisplayItemFrame, MMDRoot -def remove_constraint(constraints: Any, name: str) -> bool: - """Remove a constraint by name if it exists""" +def remove_constraint(constraints, name): c = constraints.get(name, None) if c: constraints.remove(c) return True return False -def remove_edit_bones(edit_bones: bpy.types.ArmatureEditBones, bone_names: List[str]) -> None: - """Remove edit bones by name""" + +def remove_edit_bones(edit_bones, bone_names): for name in bone_names: b = edit_bones.get(name, None) if b: edit_bones.remove(b) -BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools" +BONE_COLLECTION_CUSTOM_PROPERTY_NAME = "mmd_tools_local" BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL = "special collection" BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL = "normal collection" BONE_COLLECTION_NAME_SHADOW = "mmd_shadow" @@ -48,52 +41,44 @@ SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NA class FnBone: - AUTO_LOCAL_AXIS_ARMS: Tuple[str, ...] = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") - AUTO_LOCAL_AXIS_FINGERS: Tuple[str, ...] = ("親指", "人指", "中指", "薬指", "小指") - AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: Tuple[str, ...] = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") + AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") + AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指") + AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") - def __init__(self) -> None: + def __init__(self): raise NotImplementedError("This class cannot be instantiated.") @staticmethod - def find_pose_bone_by_bone_id(armature_object: Object, bone_id: int) -> Optional[PoseBone]: - """Find a pose bone by its bone ID""" + def find_pose_bone_by_bone_id(armature_object: bpy.types.Object, bone_id: int) -> Optional[bpy.types.PoseBone]: for bone in armature_object.pose.bones: if bone.mmd_bone.bone_id != bone_id: continue return bone - logger.debug(f"Bone with ID {bone_id} not found in armature {armature_object.name}") return None @staticmethod - def __new_bone_id(armature_object: Object) -> int: - """Generate a new unique bone ID""" + def __new_bone_id(armature_object: bpy.types.Object) -> int: return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1 @staticmethod - def get_or_assign_bone_id(pose_bone: PoseBone) -> int: - """Get the bone ID or assign a new one if not set""" + def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int: if pose_bone.mmd_bone.bone_id < 0: pose_bone.mmd_bone.bone_id = FnBone.__new_bone_id(pose_bone.id_data) - logger.debug(f"Assigned new bone ID {pose_bone.mmd_bone.bone_id} to bone {pose_bone.name}") return pose_bone.mmd_bone.bone_id @staticmethod - def __get_selected_pose_bones(armature_object: Object) -> Iterable[PoseBone]: - """Get selected pose bones from the armature""" + def __get_selected_pose_bones(armature_object: bpy.types.Object) -> Iterable[bpy.types.PoseBone]: if armature_object.mode == "EDIT": - bpy.ops.object.mode_set(mode="OBJECT") # update selected bones + bpy.ops.object.mode_set(mode="OBJECT") # update selected bones bpy.ops.object.mode_set(mode="EDIT") # back to edit mode context_selected_bones = bpy.context.selected_pose_bones or bpy.context.selected_bones or [] bones = armature_object.pose.bones return (bones[b.name] for b in context_selected_bones if not bones[b.name].is_mmd_shadow_bone) @staticmethod - def load_bone_fixed_axis(armature_object: Object, enable: bool = True) -> None: - """Load fixed axis settings for selected bones""" - logger.debug(f"Loading bone fixed axis (enable={enable}) for {armature_object.name}") + def load_bone_fixed_axis(armature_object: bpy.types.Object, enable=True): for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone = b.mmd_bone + mmd_bone: MMDBone = b.mmd_bone mmd_bone.enabled_fixed_axis = enable lock_rotation = b.lock_rotation[:] if enable: @@ -108,91 +93,72 @@ class FnBone: b.lock_location = b.lock_scale = (False, False, False) @staticmethod - def setup_special_bone_collections(armature_object: Object) -> Object: - """Set up special bone collections for MMD""" - armature = cast(Armature, armature_object.data) + def setup_special_bone_collections(armature_object: bpy.types.Object) -> bpy.types.Object: + armature: bpy.types.Armature = armature_object.data bone_collections = armature.collections for bone_collection_name in SPECIAL_BONE_COLLECTION_NAMES: if bone_collection_name in bone_collections: continue bone_collection = bone_collections.new(bone_collection_name) FnBone.__set_bone_collection_to_special(bone_collection, is_visible=False) - logger.debug(f"Created special bone collection: {bone_collection_name}") return armature_object @staticmethod - def __is_mmd_tools_bone_collection(bone_collection: BoneCollection) -> bool: - """Check if a bone collection is an MMD Tools collection""" + def __is_mmd_tools_local_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection @staticmethod - def __is_special_bone_collection(bone_collection: BoneCollection) -> bool: - """Check if a bone collection is a special MMD collection""" - return BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL == bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) + def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL @staticmethod - def __set_bone_collection_to_special(bone_collection: BoneCollection, is_visible: bool) -> None: - """Mark a bone collection as special""" + def __set_bone_collection_to_special(bone_collection: bpy.types.BoneCollection, is_visible: bool): bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_SPECIAL bone_collection.is_visible = is_visible @staticmethod - def __is_normal_bone_collection(bone_collection: BoneCollection) -> bool: - """Check if a bone collection is a normal MMD collection""" - return BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL == bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) + def __is_normal_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + return bone_collection.get(BONE_COLLECTION_CUSTOM_PROPERTY_NAME) == BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL @staticmethod - def __set_bone_collection_to_normal(bone_collection: BoneCollection) -> None: - """Mark a bone collection as normal""" + def __set_bone_collection_to_normal(bone_collection: bpy.types.BoneCollection): bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL @staticmethod - def __set_edit_bone_to_special(edit_bone: EditBone, bone_collection_name: str) -> EditBone: - """Set an edit bone to a special collection""" + def __set_edit_bone_to_special(edit_bone: bpy.types.EditBone, bone_collection_name: str) -> bpy.types.EditBone: edit_bone.id_data.collections[bone_collection_name].assign(edit_bone) edit_bone.use_deform = False return edit_bone @staticmethod - def set_edit_bone_to_dummy(edit_bone: EditBone) -> EditBone: - """Set an edit bone as a dummy bone""" - logger.debug(f"Setting bone {edit_bone.name} as dummy bone") + def set_edit_bone_to_dummy(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone: return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_DUMMY) @staticmethod - def set_edit_bone_to_shadow(edit_bone: EditBone) -> EditBone: - """Set an edit bone as a shadow bone""" - logger.debug(f"Setting bone {edit_bone.name} as shadow bone") + def set_edit_bone_to_shadow(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone: return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_SHADOW) @staticmethod - def __unassign_mmd_tools_bone_collections(edit_bone: EditBone) -> EditBone: - """Unassign an edit bone from all MMD Tools collections""" + def __unassign_mmd_tools_local_bone_collections(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone: for bone_collection in edit_bone.collections: - if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + if not FnBone.__is_mmd_tools_local_bone_collection(bone_collection): continue bone_collection.unassign(edit_bone) return edit_bone @staticmethod - def sync_bone_collections_from_display_item_frames(armature_object: Object) -> None: - """Synchronize bone collections from display item frames""" - logger.info(f"Syncing bone collections from display item frames for {armature_object.name}") - armature = cast(Armature, armature_object.data) + def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object): + armature: bpy.types.Armature = armature_object.data bone_collections = armature.collections from .model import FnModel - root_object = FnModel.find_root_object(armature_object) - if not root_object: - logger.error(f"No root object found for armature {armature_object.name}") - return - - mmd_root = root_object.mmd_root + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root bones = armature.bones - used_groups: Set[str] = set() - unassigned_bone_names: Set[str] = {b.name for b in bones} + used_groups = set() + unassigned_bone_names = {b.name for b in bones} for frame in mmd_root.display_item_frames: for item in frame.data: @@ -204,12 +170,11 @@ class FnBone: if bone_collection is None: bone_collection = bone_collections.new(name=group_name) FnBone.__set_bone_collection_to_normal(bone_collection) - logger.debug(f"Created new bone collection: {group_name}") bone_collection.assign(bones[item.name]) for name in unassigned_bone_names: for bc in bones[name].collections: - if not FnBone.__is_mmd_tools_bone_collection(bc): + if not FnBone.__is_mmd_tools_local_bone_collection(bc): continue if not FnBone.__is_normal_bone_collection(bc): continue @@ -219,48 +184,40 @@ class FnBone: for bone_collection in bone_collections.values(): if bone_collection.name in used_groups: continue - if not FnBone.__is_mmd_tools_bone_collection(bone_collection): + if not FnBone.__is_mmd_tools_local_bone_collection(bone_collection): continue if not FnBone.__is_normal_bone_collection(bone_collection): continue - logger.debug(f"Removing unused bone collection: {bone_collection.name}") bone_collections.remove(bone_collection) @staticmethod - def sync_display_item_frames_from_bone_collections(armature_object: Object) -> None: - """Synchronize display item frames from bone collections""" - logger.info(f"Syncing display item frames from bone collections for {armature_object.name}") - armature = cast(Armature, armature_object.data) - bone_collections = armature.collections + def sync_display_item_frames_from_bone_collections(armature_object: bpy.types.Object): + armature: bpy.types.Armature = armature_object.data + bone_collections: bpy.types.BoneCollections = armature.collections from .model import FnModel - root_object = FnModel.find_root_object(armature_object) - if not root_object: - logger.error(f"No root object found for armature {armature_object.name}") - return - - mmd_root = root_object.mmd_root + root_object: bpy.types.Object = FnModel.find_root_object(armature_object) + mmd_root: MMDRoot = root_object.mmd_root display_item_frames = mmd_root.display_item_frames used_frame_index: Set[int] = set() - bone_collection: BoneCollection + bone_collection: bpy.types.BoneCollection for bone_collection in bone_collections: if len(bone_collection.bones) == 0 or FnBone.__is_special_bone_collection(bone_collection): continue bone_collection_name = bone_collection.name - display_item_frame = display_item_frames.get(bone_collection_name) + display_item_frame: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name) if display_item_frame is None: display_item_frame = display_item_frames.add() display_item_frame.name = bone_collection_name display_item_frame.name_e = bone_collection_name - logger.debug(f"Created new display item frame: {bone_collection_name}") used_frame_index.add(display_item_frames.find(bone_collection_name)) ItemOp.resize(display_item_frame.data, len(bone_collection.bones)) - for display_item, bone in zip(display_item_frame.data, bone_collection.bones): + for display_item, bone in zip(display_item_frame.data, bone_collection.bones, strict=False): display_item.type = "BONE" display_item.name = bone.name @@ -271,27 +228,23 @@ class FnBone: if display_item_frame.is_special: if display_item_frame.name != "表情": display_item_frame.data.clear() - logger.debug(f"Cleared special display item frame: {display_item_frame.name}") else: - logger.debug(f"Removing unused display item frame: {display_item_frames[i].name}") display_item_frames.remove(i) mmd_root.active_display_item_frame = 0 @staticmethod - def apply_bone_fixed_axis(armature_object: Object) -> None: - """Apply fixed axis to bones""" - logger.info(f"Applying bone fixed axis for {armature_object.name}") - bone_map: Dict[str, Tuple[Vector, bool, bool]] = {} + def apply_bone_fixed_axis(armature_object: bpy.types.Object): + bone_map = {} for b in armature_object.pose.bones: if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis: continue - mmd_bone = b.mmd_bone + mmd_bone: MMDBone = b.mmd_bone parent_tip = b.parent and not b.parent.is_mmd_shadow_bone and b.parent.mmd_bone.is_tip bone_map[b.name] = (mmd_bone.fixed_axis.normalized(), mmd_bone.is_tip, parent_tip) force_align = True with bpyutils.edit_object(armature_object) as data: - bone: EditBone + bone: bpy.types.EditBone for bone in data.edit_bones: if bone.name not in bone_map: bone.select = False @@ -322,7 +275,6 @@ class FnBone: else: bone_map[bone.name] = (True, True, True) bone.select = True - logger.debug(f"Applied fixed axis to bone: {bone.name}") for bone_name, locks in bone_map.items(): b = armature_object.pose.bones[bone_name] @@ -330,11 +282,9 @@ class FnBone: b.lock_ik_x, b.lock_ik_y, b.lock_ik_z = b.lock_rotation = locks @staticmethod - def load_bone_local_axes(armature_object: Object, enable: bool = True) -> None: - """Load local axes for selected bones""" - logger.debug(f"Loading bone local axes (enable={enable}) for {armature_object.name}") + def load_bone_local_axes(armature_object: bpy.types.Object, enable=True): for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone = b.mmd_bone + mmd_bone: MMDBone = b.mmd_bone mmd_bone.enabled_local_axes = enable if enable: axes = b.bone.matrix_local.to_3x3().transposed() @@ -342,18 +292,16 @@ class FnBone: mmd_bone.local_axis_z = axes[2].xzy @staticmethod - def apply_bone_local_axes(armature_object: Object) -> None: - """Apply local axes to bones""" - logger.info(f"Applying bone local axes for {armature_object.name}") - bone_map: Dict[str, Tuple[Vector, Vector]] = {} + def apply_bone_local_axes(armature_object: bpy.types.Object): + bone_map = {} for b in armature_object.pose.bones: if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: continue - mmd_bone = b.mmd_bone + mmd_bone: MMDBone = b.mmd_bone bone_map[b.name] = (mmd_bone.local_axis_x, mmd_bone.local_axis_z) with bpyutils.edit_object(armature_object) as data: - bone: EditBone + bone: bpy.types.EditBone for bone in data.edit_bones: if bone.name not in bone_map: bone.select = False @@ -361,18 +309,15 @@ class FnBone: local_axis_x, local_axis_z = bone_map[bone.name] FnBone.update_bone_roll(bone, local_axis_x, local_axis_z) bone.select = True - logger.debug(f"Applied local axes to bone: {bone.name}") @staticmethod - def update_bone_roll(edit_bone: EditBone, mmd_local_axis_x: Vector, mmd_local_axis_z: Vector) -> None: - """Update bone roll based on local axes""" + def update_bone_roll(edit_bone: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z): axes = FnBone.get_axes(mmd_local_axis_x, mmd_local_axis_z) idx, val = max([(i, edit_bone.vector.dot(v)) for i, v in enumerate(axes)], key=lambda x: abs(x[1])) edit_bone.align_roll(axes[(idx - 1) % 3 if val < 0 else (idx + 1) % 3]) @staticmethod - def get_axes(mmd_local_axis_x: Vector, mmd_local_axis_z: Vector) -> Tuple[Vector, Vector, Vector]: - """Get axes from local axis vectors""" + def get_axes(mmd_local_axis_x, mmd_local_axis_z): x_axis = Vector(mmd_local_axis_x).normalized().xzy z_axis = Vector(mmd_local_axis_z).normalized().xzy y_axis = z_axis.cross(x_axis).normalized() @@ -380,25 +325,18 @@ class FnBone: return (x_axis, y_axis, z_axis) @staticmethod - def apply_auto_bone_roll(armature: Object) -> None: - """Apply automatic bone roll to appropriate bones""" - logger.info(f"Applying auto bone roll for {armature.name}") - bone_names: List[str] = [] - for b in armature.pose.bones: - if not b.is_mmd_shadow_bone and not b.mmd_bone.enabled_local_axes and FnBone.has_auto_local_axis(b.mmd_bone.name_j): - bone_names.append(b.name) + def apply_auto_bone_roll(armature): + bone_names = [b.name for b in armature.pose.bones if not b.is_mmd_shadow_bone and not b.mmd_bone.enabled_local_axes and FnBone.has_auto_local_axis(b.mmd_bone.name_j)] with bpyutils.edit_object(armature) as data: - bone: EditBone + bone: bpy.types.EditBone for bone in data.edit_bones: if bone.name not in bone_names: continue FnBone.update_auto_bone_roll(bone) bone.select = True - logger.debug(f"Applied auto bone roll to bone: {bone.name}") @staticmethod - def update_auto_bone_roll(edit_bone: EditBone) -> None: - """Update bone roll automatically""" + def update_auto_bone_roll(edit_bone): # make a triangle face (p1,p2,p3) p1 = edit_bone.head.copy() p2 = edit_bone.tail.copy() @@ -419,8 +357,7 @@ class FnBone: FnBone.update_bone_roll(edit_bone, y.xzy, x.xzy) @staticmethod - def has_auto_local_axis(name_j: str) -> bool: - """Check if a bone should have automatic local axis""" + def has_auto_local_axis(name_j): if name_j: if name_j in FnBone.AUTO_LOCAL_AXIS_ARMS or name_j in FnBone.AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: return True @@ -430,11 +367,12 @@ class FnBone: return False @staticmethod - def clean_additional_transformation(armature_object: Object) -> None: - """Clean additional transformation constraints and bones""" - logger.info(f"Cleaning additional transformations for {armature_object.name}") + def clean_additional_transformation(armature_object: bpy.types.Object): + if armature_object.type != "ARMATURE" or armature_object.pose is None: + return + # clean constraints - p_bone: PoseBone + p_bone: bpy.types.PoseBone for p_bone in armature_object.pose.bones: p_bone.mmd_bone.is_additional_transform_dirty = True constraints = p_bone.constraints @@ -450,21 +388,17 @@ class FnBone: "ADDITIONAL_TRANSFORM_INVERT", } - def __is_at_shadow_bone(b: PoseBone) -> bool: + def __is_at_shadow_bone(b): return b.is_mmd_shadow_bone and b.mmd_shadow_bone_type in shadow_bone_types shadow_bone_names = [b.name for b in armature_object.pose.bones if __is_at_shadow_bone(b)] if len(shadow_bone_names) > 0: - logger.debug(f"Removing {len(shadow_bone_names)} shadow bones") with bpyutils.edit_object(armature_object) as data: remove_edit_bones(data.edit_bones, shadow_bone_names) @staticmethod - def apply_additional_transformation(armature_object: Object) -> None: - """Apply additional transformation to bones""" - logger.info(f"Applying additional transformations for {armature_object.name}") - - def __is_dirty_bone(b: PoseBone) -> bool: + def apply_additional_transformation(armature_object: bpy.types.Object): + def __is_dirty_bone(b): if b.is_mmd_shadow_bone: return False mmd_bone = b.mmd_bone @@ -473,10 +407,9 @@ class FnBone: return mmd_bone.is_additional_transform_dirty dirty_bones = [b for b in armature_object.pose.bones if __is_dirty_bone(b)] - logger.debug(f"Found {len(dirty_bones)} dirty bones to process") # setup constraints - shadow_bone_pool: List[Union[_AT_ShadowBoneRemove, _AT_ShadowBoneCreate]] = [] + shadow_bone_pool = [] for p_bone in dirty_bones: sb = FnBone.__setup_constraints(p_bone) if sb: @@ -497,8 +430,7 @@ class FnBone: p_bone.mmd_bone.is_additional_transform_dirty = False @staticmethod - def __setup_constraints(p_bone: PoseBone) -> Optional[Union['_AT_ShadowBoneRemove', '_AT_ShadowBoneCreate']]: - """Set up constraints for additional transformation""" + def __setup_constraints(p_bone): bone_name = p_bone.name mmd_bone = p_bone.mmd_bone influence = mmd_bone.additional_transform_influence @@ -511,18 +443,21 @@ class FnBone: rot = remove_constraint(constraints, "mmd_additional_rotation") loc = remove_constraint(constraints, "mmd_additional_location") if rot or loc: - logger.debug(f"Removing additional transform constraints for bone: {bone_name}") return _AT_ShadowBoneRemove(bone_name) return None - logger.debug(f"Setting up additional transform for bone: {bone_name} targeting {target_bone}") shadow_bone = _AT_ShadowBoneCreate(bone_name, target_bone) - def __config(name: str, mute: bool, map_type: str, value: float) -> None: + def __config(name, mute, map_type, value): if mute: remove_constraint(constraints, name) return c = TransformConstraintOp.create(constraints, name, map_type) + # FIXME: Some bones require specific rotation modes to match MMD behavior. + # Currently using hardcoded bone names as a temporary solution. + # See https://github.com/MMD-Blender/blender_mmd_tools_local/issues/242 + if bone_name in {"左肩C", "右肩C", "肩C.L", "肩C.R", "肩C_L", "肩C_R"}: + c.from_rotation_mode = "ZYX" # Best matches MMD behavior for shoulder bones c.target = p_bone.id_data shadow_bone.add_constraint(c) TransformConstraintOp.update_min_max(c, value, influence) @@ -533,81 +468,62 @@ class FnBone: return shadow_bone @staticmethod - def update_additional_transform_influence(pose_bone: PoseBone) -> None: - """Update the influence of additional transform constraints""" + def update_additional_transform_influence(pose_bone: bpy.types.PoseBone): influence = pose_bone.mmd_bone.additional_transform_influence constraints = pose_bone.constraints c = constraints.get("mmd_additional_rotation", None) TransformConstraintOp.update_min_max(c, math.pi, influence) c = constraints.get("mmd_additional_location", None) TransformConstraintOp.update_min_max(c, 100, influence) - logger.debug(f"Updated additional transform influence for bone: {pose_bone.name} to {influence}") class MigrationFnBone: """Migration Functions for old MMD models broken by bugs or issues""" @staticmethod - def fix_mmd_ik_limit_override(armature_object: Object) -> None: - """Fix IK limit override constraints in old MMD models""" - logger.info(f"Fixing MMD IK limit overrides for {armature_object.name}") - pose_bone: PoseBone + def fix_mmd_ik_limit_override(armature_object: bpy.types.Object): + pose_bone: bpy.types.PoseBone for pose_bone in armature_object.pose.bones: - constraint: Constraint + constraint: bpy.types.Constraint for constraint in pose_bone.constraints: if constraint.type == "LIMIT_ROTATION" and "mmd_ik_limit_override" in constraint.name: constraint.owner_space = "LOCAL" - logger.debug(f"Fixed IK limit override for bone: {pose_bone.name}") class _AT_ShadowBoneRemove: - """Handler for removing shadow bones""" - - def __init__(self, bone_name: str) -> None: - """Initialize with bone name""" + def __init__(self, bone_name): self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name) - def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None: - """Update edit bones by removing shadow bones""" + def update_edit_bones(self, edit_bones): remove_edit_bones(edit_bones, self.__shadow_bone_names) - logger.debug(f"Removed shadow bones: {self.__shadow_bone_names}") - def update_pose_bones(self, pose_bones: Any) -> None: - """Update pose bones (no-op for removal)""" + def update_pose_bones(self, pose_bones): pass class _AT_ShadowBoneCreate: - """Handler for creating shadow bones""" - - def __init__(self, bone_name: str, target_bone_name: str) -> None: - """Initialize with bone names""" + def __init__(self, bone_name, target_bone_name): self.__dummy_bone_name = "_dummy_" + bone_name self.__shadow_bone_name = "_shadow_" + bone_name self.__bone_name = bone_name self.__target_bone_name = target_bone_name - self.__constraint_pool: List[Constraint] = [] + self.__constraint_pool = [] - def __is_well_aligned(self, bone0: EditBone, bone1: EditBone) -> bool: - """Check if two bones are well aligned""" + def __is_well_aligned(self, bone0, bone1): return bone0.x_axis.dot(bone1.x_axis) > 0.99 and bone0.y_axis.dot(bone1.y_axis) > 0.99 - def __update_constraints(self, use_shadow: bool = True) -> None: - """Update constraints to use shadow or target bone""" + def __update_constraints(self, use_shadow=True): subtarget = self.__shadow_bone_name if use_shadow else self.__target_bone_name for c in self.__constraint_pool: c.subtarget = subtarget - def add_constraint(self, constraint: Constraint) -> None: - """Add a constraint to the pool""" + def add_constraint(self, constraint): self.__constraint_pool.append(constraint) - def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None: - """Update edit bones by creating shadow bones""" + def update_edit_bones(self, edit_bones): bone = edit_bones[self.__bone_name] target_bone = edit_bones[self.__target_bone_name] if bone != target_bone and self.__is_well_aligned(bone, target_bone): - logger.debug(f"Bones are well aligned, removing shadow bones for {self.__bone_name}") _AT_ShadowBoneRemove(self.__bone_name).update_edit_bones(edit_bones) return @@ -617,7 +533,6 @@ class _AT_ShadowBoneCreate: dummy.head = target_bone.head dummy.tail = dummy.head + bone.tail - bone.head dummy.roll = bone.roll - logger.debug(f"Created/updated dummy bone: {dummy_bone_name}") shadow_bone_name = self.__shadow_bone_name shadow = edit_bones.get(shadow_bone_name, None) or FnBone.set_edit_bone_to_shadow(edit_bones.new(name=shadow_bone_name)) @@ -625,15 +540,12 @@ class _AT_ShadowBoneCreate: shadow.head = dummy.head shadow.tail = dummy.tail shadow.roll = bone.roll - logger.debug(f"Created/updated shadow bone: {shadow_bone_name}") - def update_pose_bones(self, pose_bones: Any) -> None: - """Update pose bones by setting up shadow bone properties""" + def update_pose_bones(self, pose_bones): if self.__shadow_bone_name not in pose_bones: - logger.debug(f"Shadow bone {self.__shadow_bone_name} not found, using target bone directly") self.__update_constraints(use_shadow=False) return - + dummy_p_bone = pose_bones[self.__dummy_bone_name] dummy_p_bone.is_mmd_shadow_bone = True dummy_p_bone.mmd_shadow_bone_type = "DUMMY" @@ -649,7 +561,5 @@ class _AT_ShadowBoneCreate: c.subtarget = dummy_p_bone.name c.target_space = "POSE" c.owner_space = "POSE" - logger.debug(f"Created copy transforms constraint for shadow bone: {self.__shadow_bone_name}") self.__update_constraints() - logger.debug(f"Updated constraints for shadow bone: {self.__shadow_bone_name}") diff --git a/core/mmd/core/camera.py b/core/mmd/core/camera.py index 824b71f..f693106 100644 --- a/core/mmd/core/camera.py +++ b/core/mmd/core/camera.py @@ -1,26 +1,18 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. import math -from typing import Optional, List, Tuple, Callable, Any, Union +from typing import Optional import bpy -from bpy.types import Object, ID, Camera, Context -from bpy_extras import anim_utils -from mathutils import Vector, Matrix, Euler -import traceback +from mathutils import Matrix, Vector from ..bpyutils import FnContext, Props -from ....core.logging_setup import logger + class FnCamera: @staticmethod - def find_root(obj: Optional[Object]) -> Optional[Object]: - """Find the root object of an MMD camera setup.""" + def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]: if obj is None: return None if FnCamera.is_mmd_camera_root(obj): @@ -30,22 +22,16 @@ class FnCamera: return None @staticmethod - def is_mmd_camera(obj: Object) -> bool: - """Check if an object is an MMD camera.""" + def is_mmd_camera(obj: bpy.types.Object) -> bool: return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None @staticmethod - def is_mmd_camera_root(obj: Object) -> bool: - """Check if an object is an MMD camera root.""" + def is_mmd_camera_root(obj: bpy.types.Object) -> bool: return obj.type == "EMPTY" and obj.mmd_type == "CAMERA" @staticmethod - def add_drivers(camera_object: Object) -> None: - """Add drivers to the camera object for MMD camera functionality.""" - logger.debug(f"Adding drivers to camera: {camera_object.name}") - - def __add_driver(id_data: ID, data_path: str, expression: str, index: int = -1) -> None: - """Add a driver to the specified ID data.""" + def add_drivers(camera_object: bpy.types.Object): + def __add_driver(id_data: bpy.types.ID, data_path: str, expression: str, index: int = -1): d = id_data.driver_add(data_path, index).driver d.type = "SCRIPTED" if "$empty_distance" in expression: @@ -73,46 +59,31 @@ class FnCamera: v.targets[0].data_path = "mmd_camera.angle" expression = expression.replace("$angle", v.name) if "$sensor_height" in expression: - v = d.variables.new() - v.name = "sensor_height" - v.type = "SINGLE_PROP" - v.targets[0].id_type = "CAMERA" - v.targets[0].id = camera_object.data - v.targets[0].data_path = "sensor_height" - expression = expression.replace("$sensor_height", v.name) + # Use fixed sensor_height instead of dynamic reference. + # When controlled by MMD angle, sensor_height shouldn't change. + # This avoids unnecessary dependency cycles. + # Reference: https://github.com/MMD-Blender/blender_mmd_tools_local/issues/227 + current_sensor_height = camera_object.data.sensor_height + expression = expression.replace("$sensor_height", str(current_sensor_height)) d.expression = expression - try: - __add_driver(camera_object.data, "ortho_scale", "25*abs($empty_distance)/45") - __add_driver(camera_object, "rotation_euler", "pi if $is_perspective == False and $empty_distance > 1e-5 else 0", index=1) - __add_driver(camera_object.data, "type", "not $is_perspective") - __add_driver(camera_object.data, "lens", "$sensor_height/tan($angle/2)/2") - logger.debug(f"Successfully added drivers to camera: {camera_object.name}") - except Exception: - logger.error(f"Failed to add drivers to camera {camera_object.name}: {traceback.format_exc()}") + __add_driver(camera_object.data, "ortho_scale", "25*abs($empty_distance)/45") + __add_driver(camera_object, "rotation_euler", "pi if $is_perspective == False and $empty_distance > 1e-5 else 0", index=1) + __add_driver(camera_object.data, "type", "not $is_perspective") + __add_driver(camera_object.data, "lens", "$sensor_height/tan($angle/2)/2") @staticmethod - def remove_drivers(camera_object: Object) -> None: - """Remove drivers from the camera object.""" - logger.debug(f"Removing drivers from camera: {camera_object.name}") - try: - camera_object.data.driver_remove("ortho_scale") - camera_object.driver_remove("rotation_euler") - camera_object.data.driver_remove("ortho_scale") - camera_object.data.driver_remove("lens") - logger.debug(f"Successfully removed drivers from camera: {camera_object.name}") - except Exception: - logger.error(f"Failed to remove drivers from camera {camera_object.name}: {traceback.format_exc()}") + def remove_drivers(camera_object: bpy.types.Object): + camera_object.data.driver_remove("ortho_scale") + camera_object.driver_remove("rotation_euler") + camera_object.data.driver_remove("type") + camera_object.data.driver_remove("lens") class MigrationFnCamera: @staticmethod - def update_mmd_camera() -> None: - """Update all MMD cameras in the scene.""" - logger.info("Updating all MMD cameras in the scene") - updated_count = 0 - + def update_mmd_camera(): for camera_object in bpy.data.objects: if camera_object.type != "CAMERA": continue @@ -122,216 +93,156 @@ class MigrationFnCamera: # It's not a MMD Camera continue - try: - FnCamera.remove_drivers(camera_object) - FnCamera.add_drivers(camera_object) - updated_count += 1 - except Exception: - logger.error(f"Failed to update MMD camera {camera_object.name}: {traceback.format_exc()}") - - logger.info(f"Updated {updated_count} MMD cameras") + FnCamera.remove_drivers(camera_object) + FnCamera.add_drivers(camera_object) class MMDCamera: - def __init__(self, obj: Object): - """Initialize an MMD camera.""" + def __init__(self, obj): root_object = FnCamera.find_root(obj) if root_object is None: - logger.error(f"Object {obj.name} is not an MMD camera") - raise ValueError(f"{obj.name} is not an MMD camera") + raise ValueError(f"{str(obj)} is not MMDCamera") self.__emptyObj = getattr(root_object, "original", obj) - logger.debug(f"Initialized MMD camera with root: {self.__emptyObj.name}") @staticmethod - def isMMDCamera(obj: Object) -> bool: - """Check if an object is an MMD camera.""" + def isMMDCamera(obj: bpy.types.Object) -> bool: return FnCamera.find_root(obj) is not None @staticmethod - def addDrivers(cameraObj: Object) -> None: - """Add drivers to the camera object.""" + def addDrivers(cameraObj: bpy.types.Object): FnCamera.add_drivers(cameraObj) @staticmethod - def removeDrivers(cameraObj: Object) -> None: - """Remove drivers from the camera object. """ + def removeDrivers(cameraObj: bpy.types.Object): if cameraObj.type != "CAMERA": return FnCamera.remove_drivers(cameraObj) @staticmethod - def convertToMMDCamera(cameraObj: Object, scale: float = 1.0) -> 'MMDCamera': - """Convert a camera to an MMD camera.""" - logger.info(f"Converting camera {cameraObj.name} to MMD camera with scale {scale}") - + def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0): if FnCamera.is_mmd_camera(cameraObj): - logger.debug(f"Camera {cameraObj.name} is already an MMD camera") return MMDCamera(cameraObj) - try: - empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) - context = FnContext.ensure_context() - FnContext.link_object(context, empty) + empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) - cameraObj.parent = empty - cameraObj.data.sensor_fit = "VERTICAL" - cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV - cameraObj.data.ortho_scale = 25 * scale - cameraObj.data.clip_end = 500 * scale - setattr(cameraObj.data, Props.display_size, 5 * scale) - cameraObj.location = (0, -45 * scale, 0) - cameraObj.rotation_mode = "XYZ" - cameraObj.rotation_euler = (math.radians(90), 0, 0) - cameraObj.lock_location = (True, False, True) - cameraObj.lock_rotation = (True, True, True) - cameraObj.lock_scale = (True, True, True) - cameraObj.data.dof.focus_object = empty - FnCamera.add_drivers(cameraObj) + cameraObj.parent = empty + cameraObj.data.sensor_fit = "VERTICAL" + cameraObj.data.lens_unit = "MILLIMETERS" # MILLIMETERS, FOV + cameraObj.data.ortho_scale = 25 * scale + cameraObj.data.clip_end = 500 * scale + setattr(cameraObj.data, Props.display_size, 5 * scale) + cameraObj.location = (0, -45 * scale, 0) + cameraObj.rotation_mode = "XYZ" + cameraObj.rotation_euler = (math.radians(90), 0, 0) + cameraObj.lock_location = (True, False, True) + cameraObj.lock_rotation = (True, True, True) + cameraObj.lock_scale = (True, True, True) + cameraObj.data.dof.focus_object = empty + FnCamera.add_drivers(cameraObj) - empty.location = (0, 0, 10 * scale) - empty.rotation_mode = "YXZ" - setattr(empty, Props.empty_display_size, 5 * scale) - empty.lock_scale = (True, True, True) - empty.mmd_type = "CAMERA" - empty.mmd_camera.angle = math.radians(30) - empty.mmd_camera.persp = True - - logger.info(f"Successfully converted {cameraObj.name} to MMD camera") - return MMDCamera(empty) - except Exception: - logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {traceback.format_exc()}") - raise + empty.location = (0, 0, 10 * scale) + empty.rotation_mode = "YXZ" + setattr(empty, Props.empty_display_size, 5 * scale) + empty.lock_scale = (True, True, True) + empty.mmd_type = "CAMERA" + empty.mmd_camera.angle = math.radians(30) + empty.mmd_camera.persp = True + return MMDCamera(empty) @staticmethod - def newMMDCameraAnimation( - cameraObj: Optional[Object], - cameraTarget: Optional[Object] = None, - scale: float = 1.0, - min_distance: float = 0.1 - ) -> 'MMDCamera': - """Create a new MMD camera animation.""" - logger.info(f"Creating new MMD camera animation with scale {scale}") - - try: - scene = bpy.context.scene - mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera")) - FnContext.link_object(FnContext.ensure_context(), mmd_cam) - MMDCamera.convertToMMDCamera(mmd_cam, scale=scale) - mmd_cam_root = mmd_cam.parent + def newMMDCameraAnimation(cameraObj, cameraTarget=None, scale=1.0, min_distance=0.1): + scene = bpy.context.scene + mmd_cam = bpy.data.objects.new(name="Camera", object_data=bpy.data.cameras.new("Camera")) + FnContext.link_object(FnContext.ensure_context(), mmd_cam) + MMDCamera.convertToMMDCamera(mmd_cam, scale=scale) + mmd_cam_root = mmd_cam.parent - _camera_override_func: Optional[Callable[[], Object]] = None - if cameraObj is None: - if scene.camera is None: - scene.camera = mmd_cam - logger.debug("Set scene camera to new MMD camera") - return MMDCamera(mmd_cam_root) - _camera_override_func = lambda: scene.camera + _camera_override_func = None + if cameraObj is None: + if scene.camera is None: + scene.camera = mmd_cam + return MMDCamera(mmd_cam_root) + def _camera_override_func(): + return scene.camera - _target_override_func: Optional[Callable[[Object], Object]] = None - if cameraTarget is None: - _target_override_func = lambda camObj: camObj.data.dof.focus_object or camObj + _target_override_func = None + if cameraTarget is None: + def _target_override_func(camObj): + return camObj.data.dof.focus_object or camObj - action_name = mmd_cam_root.name - parent_action = bpy.data.actions.new(name=action_name) - distance_action = bpy.data.actions.new(name=action_name + "_dis") - FnCamera.remove_drivers(mmd_cam) + action_name = mmd_cam_root.name + parent_action = bpy.data.actions.new(name=action_name) + distance_action = bpy.data.actions.new(name=action_name + "_dis") + FnCamera.remove_drivers(mmd_cam) - from math import atan - from mathutils import Matrix, Vector + render = scene.render + factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x) + matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1])) + neg_z_vector = Vector((0, 0, -1)) + frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current + frame_count = frame_end - frame_start + frames = range(frame_start, frame_end) - render = scene.render - factor = (render.resolution_y * render.pixel_aspect_y) / (render.resolution_x * render.pixel_aspect_x) - matrix_rotation = Matrix(([1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1])) - neg_z_vector = Vector((0, 0, -1)) - frame_start, frame_end, frame_current = scene.frame_start, scene.frame_end + 1, scene.frame_current - frame_count = frame_end - frame_start - frames = range(frame_start, frame_end) + fcurves = [parent_action.fcurves.new(data_path="location", index=i) for i in range(3)] # x, y, z + fcurves.extend(parent_action.fcurves.new(data_path="rotation_euler", index=i) for i in range(3)) # rx, ry, rz + fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.angle")) # fov + fcurves.append(parent_action.fcurves.new(data_path="mmd_camera.is_perspective")) # persp + fcurves.append(distance_action.fcurves.new(data_path="location", index=1)) # dis + for c in fcurves: + c.keyframe_points.add(frame_count) - # Get channelbags for camera actions using Blender 5.0 API - if not parent_action.slots: - parent_slot = parent_action.slots.new(for_id=mmd_cam_root) - else: - parent_slot = parent_action.slots[0] - parent_channelbag = anim_utils.action_ensure_channelbag_for_slot(parent_action, parent_slot) - - if not distance_action.slots: - distance_slot = distance_action.slots.new(for_id=mmd_cam) - else: - distance_slot = distance_action.slots[0] - distance_channelbag = anim_utils.action_ensure_channelbag_for_slot(distance_action, distance_slot) - - fcurves = [] - for i in range(3): - fcurves.append(parent_channelbag.fcurves.new(data_path="location", index=i)) # x, y, z - for i in range(3): - fcurves.append(parent_channelbag.fcurves.new(data_path="rotation_euler", index=i)) # rx, ry, rz - fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.angle")) # fov - fcurves.append(parent_channelbag.fcurves.new(data_path="mmd_camera.is_perspective")) # persp - fcurves.append(distance_channelbag.fcurves.new(data_path="location", index=1)) # dis - for c in fcurves: - c.keyframe_points.add(frame_count) - - logger.debug(f"Processing {frame_count} frames for camera animation") - for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves)): - scene.frame_set(f) - if _camera_override_func: - cameraObj = _camera_override_func() - if _target_override_func: - cameraTarget = _target_override_func(cameraObj) - cam_matrix_world = cameraObj.matrix_world - cam_target_loc = cameraTarget.matrix_world.translation - cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode) - cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector - if cameraObj.data.type == "ORTHO": - cam_dis = -(9 / 5) * cameraObj.data.ortho_scale - if cameraObj.data.sensor_fit != "VERTICAL": - if cameraObj.data.sensor_fit == "HORIZONTAL": - cam_dis *= factor - else: - cam_dis *= min(1, factor) - else: - target_vec = cam_target_loc - cam_matrix_world.translation - cam_dis = -max(target_vec.length * cam_vec.dot(target_vec.normalized()), min_distance) - cam_target_loc = cam_matrix_world.translation - cam_vec * cam_dis - - tan_val = cameraObj.data.sensor_height / cameraObj.data.lens / 2 + for f, x, y, z, rx, ry, rz, fov, persp, dis in zip(frames, *(c.keyframe_points for c in fcurves), strict=False): + scene.frame_set(f) + if _camera_override_func: + cameraObj = _camera_override_func() + if _target_override_func: + cameraTarget = _target_override_func(cameraObj) + cam_matrix_world = cameraObj.matrix_world + cam_target_loc = cameraTarget.matrix_world.translation + cam_rotation = (cam_matrix_world @ matrix_rotation).to_euler(mmd_cam_root.rotation_mode) + cam_vec = cam_matrix_world.to_3x3() @ neg_z_vector + if cameraObj.data.type == "ORTHO": + cam_dis = -(9 / 5) * cameraObj.data.ortho_scale if cameraObj.data.sensor_fit != "VERTICAL": - ratio = cameraObj.data.sensor_width / cameraObj.data.sensor_height if cameraObj.data.sensor_fit == "HORIZONTAL": - tan_val *= factor * ratio - else: # cameraObj.data.sensor_fit == 'AUTO' - tan_val *= min(ratio, factor * ratio) + cam_dis *= factor + else: + cam_dis *= min(1, factor) + else: + target_vec = cam_target_loc - cam_matrix_world.translation + cam_dis = -max(target_vec.length * cam_vec.dot(target_vec.normalized()), min_distance) + cam_target_loc = cam_matrix_world.translation - cam_vec * cam_dis - x.co, y.co, z.co = ((f, i) for i in cam_target_loc) - rx.co, ry.co, rz.co = ((f, i) for i in cam_rotation) - dis.co = (f, cam_dis) - fov.co = (f, 2 * atan(tan_val)) - persp.co = (f, cameraObj.data.type != "ORTHO") - persp.interpolation = "CONSTANT" - for kp in (x, y, z, rx, ry, rz, fov, dis): - kp.interpolation = "LINEAR" + tan_val = cameraObj.data.sensor_height / cameraObj.data.lens / 2 + if cameraObj.data.sensor_fit != "VERTICAL": + ratio = cameraObj.data.sensor_width / cameraObj.data.sensor_height + if cameraObj.data.sensor_fit == "HORIZONTAL": + tan_val *= factor * ratio + else: # cameraObj.data.sensor_fit == 'AUTO' + tan_val *= min(ratio, factor * ratio) - FnCamera.add_drivers(mmd_cam) - mmd_cam_root.animation_data_create().action = parent_action - mmd_cam.animation_data_create().action = distance_action - scene.frame_set(frame_current) - - logger.info(f"Successfully created MMD camera animation with {frame_count} frames") - return MMDCamera(mmd_cam_root) - - except Exception: - logger.error(f"Failed to create MMD camera animation: {traceback.format_exc()}") - raise + x.co, y.co, z.co = ((f, i) for i in cam_target_loc) + rx.co, ry.co, rz.co = ((f, i) for i in cam_rotation) + dis.co = (f, cam_dis) + fov.co = (f, 2 * math.atan(tan_val)) + persp.co = (f, cameraObj.data.type != "ORTHO") + persp.interpolation = "CONSTANT" + for kp in (x, y, z, rx, ry, rz, fov, dis): + kp.interpolation = "LINEAR" - def object(self) -> Object: - """Get the root object of the MMD camera.""" + FnCamera.add_drivers(mmd_cam) + mmd_cam_root.animation_data_create().action = parent_action + mmd_cam.animation_data_create().action = distance_action + scene.frame_set(frame_current) + return MMDCamera(mmd_cam_root) + + def object(self): return self.__emptyObj - def camera(self) -> Object: - """Get the camera object of the MMD camera.""" + def camera(self): for i in self.__emptyObj.children: if i.type == "CAMERA": return i - logger.error(f"No camera found for MMD camera root {self.__emptyObj.name}") - raise KeyError(f"No camera found for MMD camera root {self.__emptyObj.name}") + raise KeyError diff --git a/core/mmd/core/exceptions.py b/core/mmd/core/exceptions.py index c89366a..dc2f011 100644 --- a/core/mmd/core/exceptions.py +++ b/core/mmd/core/exceptions.py @@ -1,14 +1,12 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2016 MMD Tools authors +# This file is part of MMD Tools. + +# Module for custom exceptions class MaterialNotFoundError(KeyError): """Exception raised when a material is not found in the scene""" def __init__(self, *args: object) -> None: - """Constructor for MaterialNotFoundError""" + """Initialize MaterialNotFoundError""" super().__init__(*args) diff --git a/core/mmd/core/lamp.py b/core/mmd/core/lamp.py index 944ee4d..b3902f4 100644 --- a/core/mmd/core/lamp.py +++ b/core/mmd/core/lamp.py @@ -1,53 +1,37 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. import bpy -from typing import Optional, Union, Any, List, Tuple -from bpy.types import Object, Context from ..bpyutils import FnContext, Props -from ....core.logging_setup import logger class MMDLamp: - def __init__(self, obj: Object) -> None: + def __init__(self, obj): if MMDLamp.isLamp(obj): obj = obj.parent if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT": - self.__emptyObj: Object = obj + self.__emptyObj = obj else: - error_msg = f"{str(obj)} is not MMDLamp" - logger.error(error_msg) - raise ValueError(error_msg) + raise ValueError(f"{str(obj)} is not MMDLamp") @staticmethod - def isLamp(obj: Optional[Object]) -> bool: - """Check if the object is a lamp/light object""" - return obj is not None and obj.type in {"LIGHT", "LAMP"} + def isLamp(obj): + return obj and obj.type in {"LIGHT", "LAMP"} @staticmethod - def isMMDLamp(obj: Optional[Object]) -> bool: - """Check if the object is an MMD lamp""" + def isMMDLamp(obj): if MMDLamp.isLamp(obj): obj = obj.parent - return obj is not None and obj.type == "EMPTY" and obj.mmd_type == "LIGHT" + return obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT" @staticmethod - def convertToMMDLamp(lampObj: Object, scale: float = 1.0) -> 'MMDLamp': - """Convert a regular lamp to an MMD lamp""" + def convertToMMDLamp(lampObj, scale=1.0): if MMDLamp.isMMDLamp(lampObj): - logger.debug(f"Object {lampObj.name} is already an MMD lamp") return MMDLamp(lampObj) - logger.info(f"Converting {lampObj.name} to MMD lamp with scale {scale}") - - empty: Object = bpy.data.objects.new(name="MMD_Light", object_data=None) - context = FnContext.ensure_context() - FnContext.link_object(context, empty) + empty = bpy.data.objects.new(name="MMD_Light", object_data=None) + FnContext.link_object(FnContext.ensure_context(), empty) empty.rotation_mode = "XYZ" empty.lock_rotation = (True, True, True) @@ -69,18 +53,13 @@ class MMDLamp: constraint.track_axis = "TRACK_NEGATIVE_Z" constraint.up_axis = "UP_Y" - logger.debug(f"Successfully created MMD lamp from {lampObj.name}") return MMDLamp(empty) - def object(self) -> Object: - """Get the empty object that represents this MMD lamp""" + def object(self): return self.__emptyObj - def lamp(self) -> Object: - """Get the actual lamp/light object""" + def lamp(self): for i in self.__emptyObj.children: if MMDLamp.isLamp(i): return i - error_msg = f"No lamp found in MMD lamp {self.__emptyObj.name}" - logger.error(error_msg) - raise KeyError(error_msg) + raise KeyError diff --git a/core/mmd/core/material.py b/core/mmd/core/material.py index e61d478..fb6a1ae 100644 --- a/core/mmd/core/material.py +++ b/core/mmd/core/material.py @@ -1,13 +1,10 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. -import logging +from ....core.logging_setup import logger import os -from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast, Dict, List, Any, Union, Set +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast import bpy from mathutils import Vector @@ -15,7 +12,6 @@ from mathutils import Vector from ..bpyutils import FnContext from .exceptions import MaterialNotFoundError from .shader import _NodeGroupUtils -from ....core.logging_setup import logger if TYPE_CHECKING: from ..properties.material import MMDMaterial @@ -28,55 +24,51 @@ SPHERE_MODE_SUBTEX = 3 class _DummyTexture: - def __init__(self, image: bpy.types.Image): - self.type: str = "IMAGE" - self.image: bpy.types.Image = image - self.use_mipmap: bool = True + def __init__(self, image): + self.type = "IMAGE" + self.image = image + self.use_mipmap = True class _DummyTextureSlot: - def __init__(self, image: bpy.types.Image): - self.diffuse_color_factor: float = 1 - self.uv_layer: str = "" - self.texture: _DummyTexture = _DummyTexture(image) + def __init__(self, image): + self.diffuse_color_factor = 1 + self.uv_layer = "" + self.texture = _DummyTexture(image) class FnMaterial: __NODES_ARE_READONLY: bool = False def __init__(self, material: bpy.types.Material): - self.__material: bpy.types.Material = material - self._nodes_are_readonly: bool = FnMaterial.__NODES_ARE_READONLY + self.__material = material + self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY @staticmethod - def set_nodes_are_readonly(nodes_are_readonly: bool) -> None: + def set_nodes_are_readonly(nodes_are_readonly: bool): FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly @classmethod - def from_material_id(cls, material_id: str) -> Optional['FnMaterial']: + def from_material_id(cls, material_id: int): for material in bpy.data.materials: if material.mmd_material.material_id == material_id: return cls(material) return None @staticmethod - def clean_materials(obj: bpy.types.Object, can_remove: Callable[[bpy.types.Material], bool]) -> None: + def clean_materials(obj, can_remove: Callable[[bpy.types.Material], bool]): materials = obj.data.materials materials_pop = materials.pop - removed_count = 0 for i in sorted((x for x, m in enumerate(materials) if can_remove(m)), reverse=True): m = materials_pop(index=i) - removed_count += 1 if m.users < 1: bpy.data.materials.remove(m) - - if removed_count > 0: - logger.debug(f"Removed {removed_count} materials from {obj.name}") @staticmethod - def swap_materials(mesh_object: bpy.types.Object, mat1_ref: Union[str, int], mat2_ref: Union[str, int], reverse: bool = False, swap_slots: bool = False) -> Tuple[bpy.types.Material, bpy.types.Material]: + def swap_materials(mesh_object: bpy.types.Object, mat1_ref: str | int, mat2_ref: str | int, reverse=False, swap_slots=False) -> Tuple[bpy.types.Material, bpy.types.Material]: """ - This method will assign the polygons of mat1 to mat2. + Assign the polygons of mat1 to mat2. + If reverse is True it will also swap the polygons assigned to mat2 to mat1. The reference to materials can be indexes or names Finally it will also swap the material slots if the option is given. @@ -94,22 +86,18 @@ class FnMaterial: Raises: MaterialNotFoundError: If one of the materials is not found """ - mesh = cast(bpy.types.Mesh, mesh_object.data) + mesh = cast("bpy.types.Mesh", mesh_object.data) try: # Try to find the materials mat1 = mesh.materials[mat1_ref] mat2 = mesh.materials[mat2_ref] - if None in (mat1, mat2): - raise MaterialNotFoundError() + if None in {mat1, mat2}: + raise MaterialNotFoundError except (KeyError, IndexError) as exc: # Wrap exceptions within our custom ones - raise MaterialNotFoundError() from exc - + raise MaterialNotFoundError from exc mat1_idx = mesh.materials.find(mat1.name) mat2_idx = mesh.materials.find(mat2.name) - - logger.debug(f"Swapping materials: {mat1.name} (idx:{mat1_idx}) <-> {mat2.name} (idx:{mat2_idx}) in {mesh_object.name}") - # Swap polygons for poly in mesh.polygons: if poly.material_index == mat1_idx: @@ -123,37 +111,31 @@ class FnMaterial: return mat1, mat2 @staticmethod - def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]) -> None: - """ - This method will fix the material order. Which is lost after joining meshes. - """ - materials = cast(bpy.types.Mesh, meshObj.data).materials - logger.debug(f"Fixing material order for {meshObj.name}") - + def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]): + """Fix the material order which is lost after joining meshes.""" + materials = cast("bpy.types.Mesh", meshObj.data).materials for new_idx, mat in enumerate(material_names): # Get the material that is currently on this index other_mat = materials[new_idx] if other_mat.name == mat: continue # This is already in place - logger.debug(f"Moving material {mat} to index {new_idx}") FnMaterial.swap_materials(meshObj, mat, new_idx, reverse=True, swap_slots=True) @property - def material_id(self) -> int: - mmd_mat: 'MMDMaterial' = self.__material.mmd_material + def material_id(self): + mmd_mat: MMDMaterial = self.__material.mmd_material if mmd_mat.material_id < 0: max_id = -1 for mat in bpy.data.materials: max_id = max(max_id, mat.mmd_material.material_id) mmd_mat.material_id = max_id + 1 - logger.debug(f"Assigned new material ID {mmd_mat.material_id} to {self.__material.name}") return mmd_mat.material_id @property - def material(self) -> bpy.types.Material: + def material(self): return self.__material - def __same_image_file(self, image: Optional[bpy.types.Image], filepath: str) -> bool: + def __same_image_file(self, image, filepath): if image and image.source == "FILE": # pylint: disable=assignment-from-no-return img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user() @@ -162,19 +144,18 @@ class FnMaterial: # pylint: disable=bare-except try: return os.path.samefile(img_filepath, filepath) - except: - pass + except Exception as e: + logger.warning(f"Failed to compare files '{img_filepath}' and '{filepath}': {e}") return False - def _load_image(self, filepath: str) -> bpy.types.Image: + def _load_image(self, filepath): img = next((i for i in bpy.data.images if self.__same_image_file(i, filepath)), None) if img is None: # pylint: disable=bare-except try: - logger.debug(f"Loading image: {filepath}") img = bpy.data.images.load(filepath) - except: - logger.warning(f"Cannot create a texture for {filepath}. No such file.") + except Exception: + logger.warning("Cannot create a texture for %s. No such file.", filepath) img = bpy.data.images.new(os.path.basename(filepath), 1, 1) img.source = "FILE" img.filepath = filepath @@ -185,46 +166,43 @@ class FnMaterial: img.alpha_mode = "NONE" return img - def update_toon_texture(self) -> None: + def update_toon_texture(self): if self._nodes_are_readonly: return - mmd_mat: 'MMDMaterial' = self.__material.mmd_material + mmd_mat: MMDMaterial = self.__material.mmd_material if mmd_mat.is_shared_toon_texture: shared_toon_folder = FnContext.get_addon_preferences_attribute(FnContext.ensure_context(), "shared_toon_folder", "") toon_path = os.path.join(shared_toon_folder, "toon%02d.bmp" % (mmd_mat.shared_toon_texture + 1)) - logger.debug(f"Using shared toon texture: {toon_path}") - self.create_toon_texture(bpy.path.resolve_ncase(path=toon_path)) + self.create_toon_texture(str(Path(toon_path).resolve())) elif mmd_mat.toon_texture != "": - logger.debug(f"Using custom toon texture: {mmd_mat.toon_texture}") self.create_toon_texture(mmd_mat.toon_texture) else: - logger.debug(f"Removing toon texture from {self.__material.name}") self.remove_toon_texture() - def _mix_diffuse_and_ambient(self, mmd_mat: 'MMDMaterial') -> List[float]: + def _mix_diffuse_and_ambient(self, mmd_mat): r, g, b = mmd_mat.diffuse_color ar, ag, ab = mmd_mat.ambient_color return [min(1.0, 0.5 * r + ar), min(1.0, 0.5 * g + ag), min(1.0, 0.5 * b + ab)] - def update_drop_shadow(self) -> None: + def update_drop_shadow(self): pass - def update_enabled_toon_edge(self) -> None: + def update_enabled_toon_edge(self): if self._nodes_are_readonly: return self.update_edge_color() - def update_edge_color(self) -> None: + def update_edge_color(self): if self._nodes_are_readonly: return mat = self.__material - mmd_mat: 'MMDMaterial' = mat.mmd_material + mmd_mat: MMDMaterial = mat.mmd_material color, alpha = mmd_mat.edge_color[:3], mmd_mat.edge_color[3] line_color = color + (min(alpha, int(mmd_mat.enabled_toon_edge)),) if hasattr(mat, "line_color"): # freestyle line color mat.line_color = line_color - mat_edge: Optional[bpy.types.Material] = bpy.data.materials.get("mmd_edge." + mat.name, None) + mat_edge: bpy.types.Material = bpy.data.materials.get("mmd_edge." + mat.name, None) if mat_edge: mat_edge.mmd_material.edge_color = line_color @@ -235,52 +213,45 @@ class FnMaterial: node_shader.inputs["Color"].default_value = mmd_mat.edge_color if node_shader and "Alpha" in node_shader.inputs: node_shader.inputs["Alpha"].default_value = alpha - - logger.debug(f"Updated edge color for {mat.name}") - def update_edge_weight(self) -> None: + def update_edge_weight(self): pass - def get_texture(self) -> Optional[_DummyTexture]: + def get_texture(self): return self.__get_texture_node("mmd_base_tex", use_dummy=True) - def create_texture(self, filepath: str) -> _DummyTextureSlot: + def create_texture(self, filepath): texture = self.__create_texture_node("mmd_base_tex", filepath, (-4, -1)) - logger.debug(f"Created base texture for {self.__material.name}: {filepath}") return _DummyTextureSlot(texture.image) - def remove_texture(self) -> None: + def remove_texture(self): if self._nodes_are_readonly: return - logger.debug(f"Removing base texture from {self.__material.name}") self.__remove_texture_node("mmd_base_tex") - def get_sphere_texture(self) -> Optional[_DummyTexture]: + def get_sphere_texture(self): return self.__get_texture_node("mmd_sphere_tex", use_dummy=True) - def use_sphere_texture(self, use_sphere: bool, obj: Optional[bpy.types.Object] = None) -> None: + def use_sphere_texture(self, use_sphere, obj=None): if self._nodes_are_readonly: return if use_sphere: - logger.debug(f"Enabling sphere texture for {self.__material.name}") self.update_sphere_texture_type(obj) else: - logger.debug(f"Disabling sphere texture for {self.__material.name}") self.__update_shader_input("Sphere Tex Fac", 0) - def create_sphere_texture(self, filepath: str, obj: Optional[bpy.types.Object] = None) -> _DummyTextureSlot: + def create_sphere_texture(self, filepath, obj=None): texture = self.__create_texture_node("mmd_sphere_tex", filepath, (-2, -2)) - logger.debug(f"Created sphere texture for {self.__material.name}: {filepath}") self.update_sphere_texture_type(obj) return _DummyTextureSlot(texture.image) - def update_sphere_texture_type(self, obj: Optional[bpy.types.Object] = None) -> None: + def update_sphere_texture_type(self, obj=None): if self._nodes_are_readonly: return sphere_texture_type = int(self.material.mmd_material.sphere_texture_type) is_sph_add = sphere_texture_type == 2 - if sphere_texture_type not in (1, 2, 3): + if sphere_texture_type not in {1, 2, 3}: self.__update_shader_input("Sphere Tex Fac", 0) else: self.__update_shader_input("Sphere Tex Fac", 1) @@ -298,62 +269,54 @@ class FnMaterial: nodes, links = mat.node_tree.nodes, mat.node_tree.links if sphere_texture_type == 3: if obj and obj.type == "MESH" and mat in tuple(obj.data.materials): - uv_layers = (l for l in obj.data.uv_layers if not l.name.startswith("_")) + uv_layers = (layer for layer in obj.data.uv_layers if not layer.name.startswith("_")) next(uv_layers, None) # skip base UV subtex_uv = getattr(next(uv_layers, None), "name", "") if subtex_uv != "UV1": - logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex') + logger.info(' * material(%s): object "%s" use UV "%s" for SubTex', mat.name, obj.name, subtex_uv) links.new(nodes["mmd_tex_uv"].outputs["SubTex UV"], texture.inputs["Vector"]) else: links.new(nodes["mmd_tex_uv"].outputs["Sphere UV"], texture.inputs["Vector"]) - - logger.debug(f"Updated sphere texture type for {self.material.name}: {sphere_texture_type}") - def remove_sphere_texture(self) -> None: + def remove_sphere_texture(self): if self._nodes_are_readonly: return - logger.debug(f"Removing sphere texture from {self.__material.name}") self.__remove_texture_node("mmd_sphere_tex") - def get_toon_texture(self) -> Optional[_DummyTexture]: + def get_toon_texture(self): return self.__get_texture_node("mmd_toon_tex", use_dummy=True) - def use_toon_texture(self, use_toon: bool) -> None: + def use_toon_texture(self, use_toon): if self._nodes_are_readonly: return - logger.debug(f"{'Enabling' if use_toon else 'Disabling'} toon texture for {self.__material.name}") self.__update_shader_input("Toon Tex Fac", use_toon) - def create_toon_texture(self, filepath: str) -> _DummyTextureSlot: + def create_toon_texture(self, filepath): texture = self.__create_texture_node("mmd_toon_tex", filepath, (-3, -1.5)) - logger.debug(f"Created toon texture for {self.__material.name}: {filepath}") return _DummyTextureSlot(texture.image) - def remove_toon_texture(self) -> None: + def remove_toon_texture(self): if self._nodes_are_readonly: return - logger.debug(f"Removing toon texture from {self.__material.name}") self.__remove_texture_node("mmd_toon_tex") - def __get_texture_node(self, node_name: str, use_dummy: bool = False) -> Optional[Union[bpy.types.ShaderNodeTexImage, _DummyTexture]]: + def __get_texture_node(self, node_name, use_dummy=False): mat = self.material texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) if isinstance(texture, bpy.types.ShaderNodeTexImage): return _DummyTexture(texture.image) if use_dummy else texture return None - def __remove_texture_node(self, node_name: str) -> None: + def __remove_texture_node(self, node_name): mat = self.material texture = getattr(mat.node_tree, "nodes", {}).get(node_name, None) if isinstance(texture, bpy.types.ShaderNodeTexImage): mat.node_tree.nodes.remove(texture) mat.update_tag() - def __create_texture_node(self, node_name: str, filepath: str, pos: Tuple[float, float]) -> bpy.types.ShaderNodeTexImage: + def __create_texture_node(self, node_name, filepath, pos): texture = self.__get_texture_node(node_name) if texture is None: - from mathutils import Vector - self.__update_shader_nodes() nodes = self.material.node_tree.nodes texture = nodes.new("ShaderNodeTexImage") @@ -365,25 +328,23 @@ class FnMaterial: self.__update_shader_nodes() return texture - def update_ambient_color(self) -> None: + def update_ambient_color(self): if self._nodes_are_readonly: return mat = self.material mmd_mat = mat.mmd_material mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) self.__update_shader_input("Ambient Color", mmd_mat.ambient_color[:] + (1,)) - logger.debug(f"Updated ambient color for {mat.name}") - def update_diffuse_color(self) -> None: + def update_diffuse_color(self): if self._nodes_are_readonly: return mat = self.material mmd_mat = mat.mmd_material mat.diffuse_color[:3] = self._mix_diffuse_and_ambient(mmd_mat) self.__update_shader_input("Diffuse Color", mmd_mat.diffuse_color[:] + (1,)) - logger.debug(f"Updated diffuse color for {mat.name}") - def update_alpha(self) -> None: + def update_alpha(self): if self._nodes_are_readonly: return mat = self.material @@ -401,31 +362,28 @@ class FnMaterial: mat.diffuse_color[3] = mmd_mat.alpha self.__update_shader_input("Alpha", mmd_mat.alpha) self.update_self_shadow_map() - logger.debug(f"Updated alpha for {mat.name}: {mmd_mat.alpha}") - def update_specular_color(self) -> None: + def update_specular_color(self): if self._nodes_are_readonly: return mat = self.material mmd_mat = mat.mmd_material mat.specular_color = mmd_mat.specular_color self.__update_shader_input("Specular Color", mmd_mat.specular_color[:] + (1,)) - logger.debug(f"Updated specular color for {mat.name}") - def update_shininess(self) -> None: + def update_shininess(self): if self._nodes_are_readonly: return mat = self.material mmd_mat = mat.mmd_material mat.roughness = 1 / pow(max(mmd_mat.shininess, 1), 0.37) if hasattr(mat, "metallic"): - mat.metallic = pow(1 - mat.roughness, 2.7) + mat.metallic = 0.0 if hasattr(mat, "specular_hardness"): mat.specular_hardness = mmd_mat.shininess self.__update_shader_input("Reflect", mmd_mat.shininess) - logger.debug(f"Updated shininess for {mat.name}: {mmd_mat.shininess}") - def update_is_double_sided(self) -> None: + def update_is_double_sided(self): if self._nodes_are_readonly: return mat = self.material @@ -435,9 +393,8 @@ class FnMaterial: elif hasattr(mat, "use_backface_culling"): mat.use_backface_culling = not mmd_mat.is_double_sided self.__update_shader_input("Double Sided", mmd_mat.is_double_sided) - logger.debug(f"Updated double-sided setting for {mat.name}: {mmd_mat.is_double_sided}") - def update_self_shadow_map(self) -> None: + def update_self_shadow_map(self): if self._nodes_are_readonly: return mat = self.material @@ -445,24 +402,21 @@ class FnMaterial: cast_shadows = mmd_mat.enabled_self_shadow_map if mmd_mat.alpha > 1e-3 else False if hasattr(mat, "shadow_method"): mat.shadow_method = "HASHED" if cast_shadows else "NONE" - logger.debug(f"Updated self shadow map for {mat.name}: {cast_shadows}") - def update_self_shadow(self) -> None: + def update_self_shadow(self): if self._nodes_are_readonly: return mat = self.material mmd_mat = mat.mmd_material self.__update_shader_input("Self Shadow", mmd_mat.enabled_self_shadow) - logger.debug(f"Updated self shadow for {mat.name}: {mmd_mat.enabled_self_shadow}") @staticmethod - def convert_to_mmd_material(material: bpy.types.Material, context: bpy.types.Context = bpy.context) -> None: + def convert_to_mmd_material(material, context=bpy.context): m, mmd_material = material, material.mmd_material - logger.debug(f"Converting material to MMD material: {material.name}") if m.use_nodes and next((n for n in m.node_tree.nodes if n.name.startswith("mmd_")), None) is None: - def search_tex_image_node(node: bpy.types.ShaderNode) -> Optional[bpy.types.ShaderNodeTexImage]: + def search_tex_image_node(node: bpy.types.ShaderNode): if node.type == "TEX_IMAGE": return node for node_input in node.inputs: @@ -482,6 +436,7 @@ class FnMaterial: preferred_output_node_target = { "CYCLES": "CYCLES", "BLENDER_EEVEE": "EEVEE", + "BLENDER_EEVEE_NEXT": "EEVEE", # Keep for backwards compatibility with 4.x }.get(active_render_engine, "ALL") tex_node = None @@ -499,15 +454,13 @@ class FnMaterial: if tex_node is None: tex_node = next((n for n in m.node_tree.nodes if n.bl_idname == "ShaderNodeTexImage"), None) if tex_node: - logger.debug(f"Found texture node for {material.name}: {tex_node.name}") tex_node.name = "mmd_base_tex" else: # Take the Base Color from BSDF if there's no texture - bsdf_node = next((n for n in m.node_tree.nodes if n.type.startswith('BSDF_')), None) + bsdf_node = next((n for n in m.node_tree.nodes if n.type.startswith("BSDF_")), None) if bsdf_node: - base_color_input = bsdf_node.inputs.get('Base Color') or bsdf_node.inputs.get('Color') + base_color_input = bsdf_node.inputs.get("Base Color") or bsdf_node.inputs.get("Color") if base_color_input: - logger.debug(f"Using BSDF base color for {material.name}") mmd_material.diffuse_color = base_color_input.default_value[:3] # ambient should be half the diffuse mmd_material.ambient_color = [x * 0.5 for x in mmd_material.diffuse_color] @@ -538,12 +491,11 @@ class FnMaterial: # delete bsdf node if it's there if m.use_nodes: - nodes_to_remove = [n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED' or n.type.startswith('BSDF_')] + nodes_to_remove = [n for n in m.node_tree.nodes if n.type == "BSDF_PRINCIPLED" or n.type.startswith("BSDF_")] for n in nodes_to_remove: - logger.debug(f"Removing BSDF node from {material.name}: {n.name}") m.node_tree.nodes.remove(n) - def __update_shader_input(self, name: str, val: Any) -> None: + def __update_shader_input(self, name, val): mat = self.material if mat.name.startswith("mmd_"): # skip mmd_edge.* return @@ -555,34 +507,26 @@ class FnMaterial: val = min(max(val, interface_socket.min_value), interface_socket.max_value) shader.inputs[name].default_value = val - def __update_shader_nodes(self) -> None: + def __update_shader_nodes(self): mat = self.material if mat.node_tree is None: - logger.debug(f"Creating node tree for {mat.name}") - # Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes - # Creating a new material automatically creates a node tree - if mat.node_tree is None: - # Fallback: node tree should exist, but if not, log warning - logger.warning(f"Node tree is None for material {mat.name} - this should not happen") - return + mat.use_nodes = True mat.node_tree.nodes.clear() nodes, links = mat.node_tree.nodes, mat.node_tree.links class _Dummy: - default_value: Any = None - is_linked: bool = True + default_value, is_linked = None, True node_shader = nodes.get("mmd_shader", None) if node_shader is None: - logger.debug(f"Creating MMD shader node for {mat.name}") node_shader: bpy.types.ShaderNodeGroup = nodes.new("ShaderNodeGroup") node_shader.name = "mmd_shader" - node_shader.location = (0, 1500) + node_shader.location = (0, 300) node_shader.width = 200 node_shader.node_tree = self.__get_shader() - mmd_mat: 'MMDMaterial' = mat.mmd_material + mmd_mat: MMDMaterial = mat.mmd_material node_shader.inputs.get("Ambient Color", _Dummy).default_value = mmd_mat.ambient_color[:] + (1,) node_shader.inputs.get("Diffuse Color", _Dummy).default_value = mmd_mat.diffuse_color[:] + (1,) node_shader.inputs.get("Specular Color", _Dummy).default_value = mmd_mat.specular_color[:] + (1,) @@ -594,7 +538,6 @@ class FnMaterial: node_uv = nodes.get("mmd_tex_uv", None) if node_uv is None: - logger.debug(f"Creating MMD UV node for {mat.name}") node_uv: bpy.types.ShaderNodeGroup = nodes.new("ShaderNodeGroup") node_uv.name = "mmd_tex_uv" node_uv.location = node_shader.location + Vector((-5 * 210, -2.5 * 220)) @@ -609,7 +552,7 @@ class FnMaterial: links.new(node_shader.outputs["Shader"], node_output.inputs["Surface"]) for name_id in ("Base", "Toon", "Sphere"): - texture = self.__get_texture_node("mmd_%s_tex" % name_id.lower()) + texture = self.__get_texture_node(f"mmd_{name_id.lower()}_tex") if texture: name_tex_in, name_alpha_in, name_uv_out = (name_id + x for x in (" Tex", " Alpha", " UV")) if not node_shader.inputs.get(name_tex_in, _Dummy).is_linked: @@ -619,13 +562,12 @@ class FnMaterial: if not texture.inputs["Vector"].is_linked: links.new(node_uv.outputs[name_uv_out], texture.inputs["Vector"]) - def __get_shader_uv(self) -> bpy.types.ShaderNodeTree: + def __get_shader_uv(self): group_name = "MMDTexUV" shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") if len(shader.nodes): return shader - logger.debug(f"Creating MMD UV shader node group") ng = _NodeGroupUtils(shader) ############################################################################ @@ -657,13 +599,12 @@ class FnMaterial: return shader - def __get_shader(self) -> bpy.types.ShaderNodeTree: + def __get_shader(self): group_name = "MMDShaderDev" shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") if len(shader.nodes): return shader - logger.debug(f"Creating MMD shader node group") ng = _NodeGroupUtils(shader) ############################################################################ @@ -753,18 +694,15 @@ class FnMaterial: class MigrationFnMaterial: @staticmethod - def update_mmd_shader() -> None: + def update_mmd_shader(): mmd_shader_node_tree: Optional[bpy.types.NodeTree] = bpy.data.node_groups.get("MMDShaderDev") if mmd_shader_node_tree is None: - logger.debug("No MMD shader node tree found, skipping update") return ng = _NodeGroupUtils(mmd_shader_node_tree) if "Color" in ng.node_output.inputs: - logger.debug("MMD shader already has Color output, skipping update") return - logger.info("Updating MMD shader node tree") shader_diffuse: bpy.types.ShaderNodeBsdfDiffuse = [n for n in mmd_shader_node_tree.nodes if n.type == "BSDF_DIFFUSE"][0] node_sphere: bpy.types.ShaderNodeMixRGB = shader_diffuse.inputs["Color"].links[0].from_node node_output: bpy.types.NodeGroupOutput = ng.node_output @@ -773,11 +711,3 @@ class MigrationFnMaterial: ng.new_output_socket("Color", node_sphere.outputs["Color"]) ng.new_output_socket("Alpha", node_alpha.outputs["Value"]) - logger.info("MMD shader node tree updated successfully") - - # Add Self Shadow input if it doesn't exist - if "Self Shadow" not in ng.node_input.outputs: - logger.info("Adding Self Shadow input to MMD shader") - # Find shader_base_mix node to connect Self Shadow - shader_base_mix = shader_alpha_mix.inputs[2].links[0].from_node - ng.new_input_socket("Self Shadow", shader_base_mix.inputs["Fac"], 0, min_max=(0, 1)) diff --git a/core/mmd/core/model.py b/core/mmd/core/model.py index ab22433..b8e6368 100644 --- a/core/mmd/core/model.py +++ b/core/mmd/core/model.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: class FnModel: @staticmethod - def copy_mmd_root(destination_root_object: bpy.types.Object, source_root_object: bpy.types.Object, overwrite: bool = True, replace_name2values: Optional[Dict[str, Dict[Any, Any]]] = None) -> None: + def copy_mmd_root(destination_root_object: bpy.types.Object, source_root_object: bpy.types.Object, overwrite: bool = True, replace_name2values: Dict[str, Dict[Any, Any]] = None): FnModel.__copy_property(destination_root_object.mmd_root, source_root_object.mmd_root, overwrite=overwrite, replace_name2values=replace_name2values or {}) @staticmethod @@ -41,27 +41,29 @@ class FnModel: Optional[bpy.types.Object]: The root object of the model. If the object is not a part of a model, None is returned. Generally, the root object is a object with type == "EMPTY" and mmd_type == "ROOT". """ - while obj is not None: - if hasattr(obj, 'mmd_type') and obj.mmd_type == "ROOT": - return obj + while obj is not None and obj.mmd_type != "ROOT": obj = obj.parent - return None + return obj @staticmethod - def find_armature_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_armature_object(root_object: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: """Find the armature object of the model. Args: - root_object (bpy.types.Object): The root object of the model. + root_object (Optional[bpy.types.Object]): The root object of the model. Returns: Optional[bpy.types.Object]: The armature object of the model. If the model does not have an armature, None is returned. """ + if root_object is None: + return None for o in root_object.children: if o.type == "ARMATURE": return o return None @staticmethod - def find_rigid_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_rigid_group_object(root_object: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + if root_object is None: + return None for o in root_object.children: if o.type == "EMPTY" and o.mmd_type == "RIGID_GRP_OBJ": return o @@ -79,13 +81,17 @@ class FnModel: @staticmethod def ensure_rigid_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + if root_object is None: + raise ValueError("root_object cannot be None") rigid_group_object = FnModel.find_rigid_group_object(root_object) if rigid_group_object is not None: return rigid_group_object return FnModel.__new_group_object(context, name="rigidbodies", mmd_type="RIGID_GRP_OBJ", parent=root_object) @staticmethod - def find_joint_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_joint_group_object(root_object: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + if root_object is None: + return None for o in root_object.children: if o.type == "EMPTY" and o.mmd_type == "JOINT_GRP_OBJ": return o @@ -93,13 +99,17 @@ class FnModel: @staticmethod def ensure_joint_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + if root_object is None: + raise ValueError("root_object cannot be None") joint_group_object = FnModel.find_joint_group_object(root_object) if joint_group_object is not None: return joint_group_object return FnModel.__new_group_object(context, name="joints", mmd_type="JOINT_GRP_OBJ", parent=root_object) @staticmethod - def find_temporary_group_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_temporary_group_object(root_object: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + if root_object is None: + return None for o in root_object.children: if o.type == "EMPTY" and o.mmd_type == "TEMPORARY_GRP_OBJ": return o @@ -107,34 +117,42 @@ class FnModel: @staticmethod def ensure_temporary_group_object(context: bpy.types.Context, root_object: bpy.types.Object) -> bpy.types.Object: + if root_object is None: + raise ValueError("root_object cannot be None") temporary_group_object = FnModel.find_temporary_group_object(root_object) if temporary_group_object is not None: return temporary_group_object return FnModel.__new_group_object(context, name="temporary", mmd_type="TEMPORARY_GRP_OBJ", parent=root_object) @staticmethod - def find_bone_order_mesh_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_bone_order_mesh_object(root_object: Optional[bpy.types.Object]) -> Optional[bpy.types.Object]: + if root_object is None: + return None armature_object = FnModel.find_armature_object(root_object) if armature_object is None: return None for o in armature_object.children: - if o.type == "MESH" and "mmd_bone_order_override" in o.modifiers: + if o.type == "MESH" and "mmd_armature" in o.modifiers: return o return None @staticmethod - def find_mesh_object_by_name(root_object: bpy.types.Object, name: str) -> Optional[bpy.types.Object]: + def find_mesh_object_by_name(root_object: Optional[bpy.types.Object], name: str) -> Optional[bpy.types.Object]: + if root_object is None: + return None if not name: return None - + for o in FnModel.iterate_mesh_objects(root_object): - if o.name == name or (hasattr(o.data, 'name') and o.data.name == name): + if o.name == name or (hasattr(o.data, "name") and o.data.name == name): return o return None @staticmethod - def iterate_child_objects(obj: bpy.types.Object) -> Iterator[bpy.types.Object]: + def iterate_child_objects(obj: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if obj is None: + return iter(()) for child in obj.children: yield child yield from FnModel.iterate_child_objects(child) @@ -157,11 +175,15 @@ class FnModel: return FnModel.iterate_filtered_child_objects(FnModel.is_mesh_object, obj) @staticmethod - def iterate_mesh_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + def iterate_mesh_objects(root_object: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if root_object is None: + return iter(()) return FnModel.__iterate_child_mesh_objects(FnModel.find_armature_object(root_object)) @staticmethod - def iterate_rigid_body_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + def iterate_rigid_body_objects(root_object: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if root_object is None: + return iter(()) if root_object.mmd_root.is_built: return itertools.chain( FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_armature_object(root_object)), @@ -170,13 +192,17 @@ class FnModel: return FnModel.iterate_filtered_child_objects(FnModel.is_rigid_body_object, FnModel.find_rigid_group_object(root_object)) @staticmethod - def iterate_joint_objects(root_object: bpy.types.Object) -> Iterator[bpy.types.Object]: + def iterate_joint_objects(root_object: Optional[bpy.types.Object]) -> Iterator[bpy.types.Object]: + if root_object is None: + return iter(()) return FnModel.iterate_filtered_child_objects(FnModel.is_joint_object, FnModel.find_joint_group_object(root_object)) @staticmethod - def iterate_temporary_objects(root_object: bpy.types.Object, rigid_track_only: bool = False) -> Iterator[bpy.types.Object]: - rigid_body_objects = FnModel.iterate_filtered_child_objects(FnModel.is_temporary_object, FnModel.find_rigid_group_object(root_object)) + def iterate_temporary_objects(root_object: Optional[bpy.types.Object], rigid_track_only: bool = False) -> Iterator[bpy.types.Object]: + if root_object is None: + return iter(()) + rigid_body_objects = FnModel.iterate_filtered_child_objects(FnModel.is_temporary_object, FnModel.find_rigid_group_object(root_object)) if rigid_track_only: return rigid_body_objects @@ -186,11 +212,15 @@ class FnModel: return itertools.chain(rigid_body_objects, FnModel.__iterate_filtered_child_objects_internal(FnModel.is_temporary_object, temporary_group_object)) @staticmethod - def iterate_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: - return (material for mesh_object in FnModel.iterate_mesh_objects(root_object) for material in cast(bpy.types.Mesh, mesh_object.data).materials if material is not None) + def iterate_materials(root_object: Optional[bpy.types.Object]) -> Iterator[bpy.types.Material]: + if root_object is None: + return iter(()) + return (material for mesh_object in FnModel.iterate_mesh_objects(root_object) for material in cast("bpy.types.Mesh", mesh_object.data).materials if material is not None) @staticmethod - def iterate_unique_materials(root_object: bpy.types.Object) -> Iterator[bpy.types.Material]: + def iterate_unique_materials(root_object: Optional[bpy.types.Object]) -> Iterator[bpy.types.Material]: + if root_object is None: + return iter(()) materials: Dict[bpy.types.Material, None] = {} # use dict because set does not guarantee the order materials.update((material, None) for material in FnModel.iterate_materials(root_object)) return iter(materials.keys()) @@ -216,149 +246,511 @@ class FnModel: return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" @staticmethod - def join_models(parent_root_object: bpy.types.Object, child_root_objects: Iterable[bpy.types.Object]) -> None: - logger.info(f"Joining models to parent root: {parent_root_object.name}") - parent_armature_object = FnModel.find_armature_object(parent_root_object) - with bpy.context.temp_override( - active_object=parent_armature_object, - selected_editable_objects=[parent_armature_object], - ): - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + def get_max_bone_id(pose_bones): + """Find maximum bone ID from pose bones, return -1 if no valid IDs found""" + max_bone_id = -1 + for bone in pose_bones: + if not hasattr(bone, "is_mmd_shadow_bone") or not bone.is_mmd_shadow_bone: + max_bone_id = max(max_bone_id, bone.mmd_bone.bone_id) + return max_bone_id - def _change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs: List[Any], pose_bones: List[bpy.types.PoseBone]) -> None: - """This function will also update the references of bone morphs and rotate+/move+.""" - bone_id = bone.mmd_bone.bone_id + @staticmethod + def unsafe_change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs, pose_bones): + """ + Change bone ID and updates all references without validating if new_bone_id is already in use. + If new_bone_id is already in use, it may cause conflicts and corrupt existing bone references. + """ + # Store the original bone_id and change it + bone_id = bone.mmd_bone.bone_id + bone.mmd_bone.bone_id = new_bone_id - # Change Bone ID - bone.mmd_bone.bone_id = new_bone_id - - # Update Relative Bone Morph # Update the reference of bone morph # 更新骨骼表情 - for bone_morph in bone_morphs: - for data in bone_morph.data: - if data.bone_id != bone_id: - continue + # Update all bone_id references in bone morphs + for bone_morph in bone_morphs: + for data in bone_morph.data: + if data.bone_id == bone_id: data.bone_id = new_bone_id - # Update Relative Additional Transform # Update the reference of rotate+/move+ # 更新付与親 - for pose_bone in pose_bones: - if pose_bone.is_mmd_shadow_bone: - continue + # Update all additional_transform_bone_id references in pose bones + for pose_bone in pose_bones: + if not hasattr(pose_bone, "is_mmd_shadow_bone") or not pose_bone.is_mmd_shadow_bone: mmd_bone = pose_bone.mmd_bone - if mmd_bone.additional_transform_bone_id != bone_id: + if mmd_bone.additional_transform_bone_id == bone_id: + mmd_bone.additional_transform_bone_id = new_bone_id + + # Update all display_connection_bone_id references in pose bones + for pose_bone in pose_bones: + if not hasattr(pose_bone, "is_mmd_shadow_bone") or not pose_bone.is_mmd_shadow_bone: + mmd_bone = pose_bone.mmd_bone + if mmd_bone.display_connection_bone_id == bone_id: + mmd_bone.display_connection_bone_id = new_bone_id + + @staticmethod + def safe_change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs, pose_bones): + """ + Change bone ID and updates all references safely by detecting and resolving conflicts automatically. + If new_bone_id is already in use, shifts all conflicting bone IDs sequentially until a gap is found. + """ + # Validate new_bone_id is non-negative + if new_bone_id < 0: + logger.warning(f"Attempted to set negative bone_id ({new_bone_id}) for bone '{bone.name}'. Using 0 instead.") + new_bone_id = 0 + + # Check if new_bone_id is already in use + bones_using_id = [pb for pb in pose_bones if pb.mmd_bone.bone_id == new_bone_id] + + if bones_using_id: + # Find all bones that need to be shifted (those with consecutive IDs starting from new_bone_id) + bones_to_shift = [] + current_id = new_bone_id + + # Sort all pose bones by bone ID + sorted_bones = sorted([pb for pb in pose_bones if pb.mmd_bone.bone_id >= new_bone_id], + key=lambda pb: pb.mmd_bone.bone_id) + + # Add bones to shift until we find a gap + for pb in sorted_bones: + if pb.mmd_bone.bone_id == current_id: + bones_to_shift.append(pb) + current_id += 1 + else: + # Found a gap, stop adding bones + break + + # Sort by bone ID in descending order to avoid conflicts during shifting + bones_to_shift.sort(key=lambda pb: pb.mmd_bone.bone_id, reverse=True) + + # Shift bone IDs upward + for shift_bone in bones_to_shift: + FnModel.unsafe_change_bone_id(shift_bone, shift_bone.mmd_bone.bone_id + 1, bone_morphs, pose_bones) + + # Now change our target bone's ID + FnModel.unsafe_change_bone_id(bone, new_bone_id, bone_morphs, pose_bones) + + @staticmethod + def swap_bone_ids(bone_a, bone_b, bone_morphs, pose_bones): + """Safely swap bone IDs between two bones and update all references""" + # Store original IDs + id_a = bone_a.mmd_bone.bone_id + id_b = bone_b.mmd_bone.bone_id + + # Check for invalid bone IDs + if id_a < 0: + logger.warning(f"Cannot swap bone '{bone_a.name}' with invalid bone_id ({id_a})") + return + if id_b < 0: + logger.warning(f"Cannot swap bone '{bone_b.name}' with invalid bone_id ({id_b})") + return + + # If both bones have the same ID, no swap needed + if id_a == id_b: + return + + # Use temporary ID for three-step swap + temp_id = FnModel.get_max_bone_id(pose_bones) + 1 + FnModel.unsafe_change_bone_id(bone_a, temp_id, bone_morphs, pose_bones) + FnModel.unsafe_change_bone_id(bone_b, id_a, bone_morphs, pose_bones) + FnModel.unsafe_change_bone_id(bone_a, id_b, bone_morphs, pose_bones) + + @staticmethod + def shift_bone_id(old_bone_id: int, new_bone_id: int, bone_morphs, pose_bones): + """ + Shifts a bone to a specified ID position within a fixed bone ID order structure. + Maintains the gap structure of bone IDs unchanged, only changes which bone corresponds to which ID. + Other bones shift positions to accommodate the change while preserving relative order. + """ + if old_bone_id < 0: + logger.warning(f"Cannot shift bone with invalid old_bone_id ({old_bone_id})") + return + if new_bone_id < 0: + logger.warning(f"Cannot shift bone to invalid new_bone_id ({new_bone_id})") + return + if old_bone_id == new_bone_id: + return + + valid_bones = [pb for pb in pose_bones if not (hasattr(pb, "is_mmd_shadow_bone") and pb.is_mmd_shadow_bone) and pb.mmd_bone.bone_id >= 0] + valid_bones.sort(key=lambda pb: pb.mmd_bone.bone_id) + + # Extract current bone IDs (this order structure must remain unchanged) + fixed_bone_ids = [pb.mmd_bone.bone_id for pb in valid_bones] + + # Find the bone to move and target position + old_pos = None + new_pos = None + moving_bone = None + + for i, bone in enumerate(valid_bones): + if bone.mmd_bone.bone_id == old_bone_id: + old_pos = i + moving_bone = bone + if bone.mmd_bone.bone_id == new_bone_id: + new_pos = i + + # If old_bone_id doesn't exist, return directly + if old_pos is None or moving_bone is None: + logger.warning(f"Could not find bone with ID {old_bone_id}") + return + + # If new_bone_id doesn't exist, use safe_change_bone_id instead + if new_pos is None: + FnModel.safe_change_bone_id(moving_bone, new_bone_id, bone_morphs, pose_bones) + return + + # 1. Determine the changes and build the translation map + id_translation_map = {} + bone_to_new_id_map = {} + + if old_pos < new_pos: # Move down (ID increases) + # Bone at old_pos moves to new_pos's ID. + # Bones from old_pos+1 to new_pos shift up to fill the gap. + id_translation_map[old_bone_id] = fixed_bone_ids[new_pos] + bone_to_new_id_map[moving_bone.name] = fixed_bone_ids[new_pos] + for i in range(old_pos, new_pos): + bone_to_shift = valid_bones[i + 1] + target_id = fixed_bone_ids[i] + id_translation_map[bone_to_shift.mmd_bone.bone_id] = target_id + bone_to_new_id_map[bone_to_shift.name] = target_id + else: # Move up (ID decreases) + # Bone at old_pos moves to new_pos's ID. + # Bones from new_pos to old_pos-1 shift down. + id_translation_map[old_bone_id] = fixed_bone_ids[new_pos] + bone_to_new_id_map[moving_bone.name] = fixed_bone_ids[new_pos] + for i in range(new_pos + 1, old_pos + 1): + bone_to_shift = valid_bones[i - 1] + target_id = fixed_bone_ids[i] + id_translation_map[bone_to_shift.mmd_bone.bone_id] = target_id + bone_to_new_id_map[bone_to_shift.name] = target_id + + # 2. Assign the new IDs to the affected bones + for bone_name, new_id in bone_to_new_id_map.items(): + pose_bones[bone_name].mmd_bone.bone_id = new_id + + # 3. Batch update all references (morphs and other bones) + if not id_translation_map: + return + + for bone_morph in bone_morphs: + for data in bone_morph.data: + if data.bone_id in id_translation_map: + data.bone_id = id_translation_map[data.bone_id] + + for pose_bone in pose_bones: + if not (hasattr(pose_bone, "is_mmd_shadow_bone") and pose_bone.is_mmd_shadow_bone): + mmd_bone = pose_bone.mmd_bone + if mmd_bone.additional_transform_bone_id in id_translation_map: + mmd_bone.additional_transform_bone_id = id_translation_map[mmd_bone.additional_transform_bone_id] + if mmd_bone.display_connection_bone_id in id_translation_map: + mmd_bone.display_connection_bone_id = id_translation_map[mmd_bone.display_connection_bone_id] + + @staticmethod + def realign_bone_ids(bone_id_offset: int, bone_morphs, pose_bones): + """Realigns all bone IDs sequentially without gaps and sorts bones in MMD-compatible hierarchy order.""" + # Build bone_id to pose_bone index for fast lookup + bone_id_to_pose_bone = {} + valid_bones = [] + for pose_bone in pose_bones: + if not (hasattr(pose_bone, "is_mmd_shadow_bone") and pose_bone.is_mmd_shadow_bone): + valid_bones.append(pose_bone) + bone_id = pose_bone.mmd_bone.bone_id + if bone_id >= 0: + bone_id_to_pose_bone[bone_id] = pose_bone + + def get_sort_key(bone): + """Generate sorting key that only moves bones violating parent-child rules and additional transform rules""" + transform_order = getattr(bone.mmd_bone, "transform_order", 0) + current_id = bone.mmd_bone.bone_id if bone.mmd_bone.bone_id >= 0 else float("inf") + additional_transform_bone_id = getattr(bone.mmd_bone, "additional_transform_bone_id", -1) + + # Check if this bone violates parent-child order rules + violation_found = False + max_ancestor_id = -1 + parent = bone.parent + + while parent: + if hasattr(parent, "is_mmd_shadow_bone") and parent.is_mmd_shadow_bone: + parent = parent.parent continue - mmd_bone.additional_transform_bone_id = new_bone_id - max_bone_id = max( - ( - b.mmd_bone.bone_id - for o in itertools.chain( - child_root_objects, - [parent_root_object], - ) - for b in FnModel.find_armature_object(o).pose.bones - if not b.is_mmd_shadow_bone - ), - default=-1, - ) + parent_transform_order = getattr(parent.mmd_bone, "transform_order", 0) + parent_id = parent.mmd_bone.bone_id - child_root_object: bpy.types.Object + # The rule that can be solved by sorting: + # if parent.transform_order == child.transform_order, + # then parent.bone_id must be < child.bone_id + if parent_transform_order == transform_order and parent_id >= 0 and current_id >= 0 and parent_id >= current_id: + violation_found = True + max_ancestor_id = max(max_ancestor_id, parent_id) + + parent = parent.parent + + # Check additional transform constraint + # additional_transform_bone_id must be smaller than current bone_id when transform_order is the same + if additional_transform_bone_id >= 0 and current_id >= 0 and additional_transform_bone_id >= current_id: + additional_transform_bone = bone_id_to_pose_bone.get(additional_transform_bone_id) + if additional_transform_bone: + additional_transform_order = getattr(additional_transform_bone.mmd_bone, "transform_order", 0) + # Only apply constraint when transform_order is the same + if additional_transform_order == transform_order: + violation_found = True + max_ancestor_id = max(max_ancestor_id, additional_transform_bone_id) + + if violation_found: + # Move this bone after its ancestors and additional transform dependencies + return (max_ancestor_id + 0.1, current_id, bone.name) + # Keep original position - use current bone_id for sorting + return (current_id, current_id, bone.name) + + # Sort - only bones violating rules will be moved + valid_bones.sort(key=get_sort_key) + + # Create a translation map from old bone_id to new bone_id + id_translation_map = {} + bone_to_new_id_map = {} + for i, bone in enumerate(valid_bones): + new_id = bone_id_offset + i + old_id = bone.mmd_bone.bone_id + if old_id != new_id: + if old_id >= 0: + id_translation_map[old_id] = new_id + bone_to_new_id_map[bone.name] = new_id + + # Assign the new IDs to the bones themselves + for bone in valid_bones: + new_id = bone_to_new_id_map[bone.name] + if bone.mmd_bone.bone_id != new_id: + bone.mmd_bone.bone_id = new_id + + # Batch update all references (morphs and other bones) using the translation map + if not id_translation_map: # No changes needed + return + + for bone_morph in bone_morphs: + for data in bone_morph.data: + if data.bone_id in id_translation_map: + data.bone_id = id_translation_map[data.bone_id] + + for pose_bone in pose_bones: + if not (hasattr(pose_bone, "is_mmd_shadow_bone") and pose_bone.is_mmd_shadow_bone): + mmd_bone = pose_bone.mmd_bone + if mmd_bone.additional_transform_bone_id in id_translation_map: + mmd_bone.additional_transform_bone_id = id_translation_map[mmd_bone.additional_transform_bone_id] + if mmd_bone.display_connection_bone_id in id_translation_map: + mmd_bone.display_connection_bone_id = id_translation_map[mmd_bone.display_connection_bone_id] + + @staticmethod + def clean_invalid_bone_id_references(mmd_root_object) -> int: + """ + Scan all bones and bone morphs to clean up invalid bone ID references. + + This function performs two main tasks: + 1. For bone properties that reference another bone by ID (e.g., + additional_transform_bone_id, display_connection_bone_id), it + resets the ID to -1 if the target bone no longer exists. + 2. For Bone Morphs, it removes individual morph data entries + that reference a bone that no longer exists. + + Args: + mmd_root_object: The MMD root object (should have mmd_root property). + + Returns: + int: The total number of invalid references that were cleaned or removed. + """ + if not mmd_root_object or not hasattr(mmd_root_object, "mmd_root"): + logger.warning("Invalid mmd_root_object provided") + return 0 + + # Find armature + armature = FnModel.find_armature_object(mmd_root_object) + if not armature: + logger.warning(f"Armature not found for MMD model '{mmd_root_object.name}'") + return 0 + + pose_bones = armature.pose.bones + bone_morphs = mmd_root_object.mmd_root.bone_morphs + + if not pose_bones: + return 0 + + cleaned_count = 0 + valid_bone_ids = {b.mmd_bone.bone_id for b in pose_bones if hasattr(b, "mmd_bone") and b.mmd_bone.bone_id >= 0 and not getattr(b, "is_mmd_shadow_bone", False)} + + # Step 2: Clean up ID references on the bones themselves. + for bone in pose_bones: + if not hasattr(bone, "mmd_bone"): + continue + + mmd_bone = bone.mmd_bone + + # --- Clean up Additional Transform --- + ref_bone_id = mmd_bone.additional_transform_bone_id + if ref_bone_id >= 0 and ref_bone_id not in valid_bone_ids: + mmd_bone.additional_transform_bone_id = -1 + mmd_bone.is_additional_transform_dirty = True + cleaned_count += 1 + logger.info(f"Cleaned invalid additional transform reference on bone '{bone.name}' (bone_id: {ref_bone_id} does not exist)") + + # --- Clean up Display Connection --- + ref_bone_id = mmd_bone.display_connection_bone_id + if ref_bone_id >= 0 and ref_bone_id not in valid_bone_ids: + mmd_bone.display_connection_bone_id = -1 + cleaned_count += 1 + logger.info(f"Cleaned invalid display connection reference on bone '{bone.name}' (bone_id: {ref_bone_id} does not exist)") + + # Step 3: Clean up invalid references within Bone Morphs. + if bone_morphs: + for morph in bone_morphs: + morph_data = morph.data + for i in range(len(morph_data) - 1, -1, -1): + item = morph_data[i] + ref_bone_id = item.bone_id + if ref_bone_id >= 0 and ref_bone_id not in valid_bone_ids: + item.bone_id = -1 + cleaned_count += 1 + logger.info(f"Cleaned invalid bone reference on morph '{morph.name}' (bone_id: {ref_bone_id} does not exist)") + + return cleaned_count + + @staticmethod + def join_models(parent_root_object: bpy.types.Object, child_root_objects: Iterable[bpy.types.Object]): + if not parent_root_object or not child_root_objects: + return + + context = FnContext.ensure_context() + + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="DESELECT") + parent_armature_object = FnModel.find_armature_object(parent_root_object) + # Get the maximum bone ID of parent model's armature to avoid ID conflicts during merging + max_bone_id = FnModel.get_max_bone_id(parent_armature_object.pose.bones) + + # Store original transform matrix for parent root object + original_matrix_world = parent_root_object.matrix_world.copy() + parent_root_object.matrix_world = Matrix.Identity(4) + # Apply child transform for child_root_object in child_root_objects: - logger.info(f"Processing child root: {child_root_object.name}") + child_root_object.matrix_world = original_matrix_world.inverted() @ child_root_object.matrix_world + FnContext.set_active_and_select_single_object(context, child_root_object) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + # Reset object visibility + FnContext.set_active_and_select_single_object(context, parent_root_object) + bpy.ops.mmd_tools.reset_object_visibility() + for child_root_object in child_root_objects: + FnContext.set_active_and_select_single_object(context, child_root_object) + bpy.ops.mmd_tools.reset_object_visibility() + + # Store material morph references for all child models + related_meshes = {} + + # Process each child model + for child_root_object in child_root_objects: + if child_root_object is None: + continue + child_armature_object = FnModel.find_armature_object(child_root_object) + if child_armature_object is None: + continue + + bpy.ops.object.mode_set(mode="OBJECT") + + # Update bone IDs child_pose_bones = child_armature_object.pose.bones child_bone_morphs = child_root_object.mmd_root.bone_morphs - for pose_bone in child_pose_bones: - if pose_bone.is_mmd_shadow_bone: - continue - if pose_bone.mmd_bone.bone_id != -1: - max_bone_id += 1 - _change_bone_id(pose_bone, max_bone_id, child_bone_morphs, child_pose_bones) + # Reassign bone IDs to avoid conflicts + FnModel.realign_bone_ids(max_bone_id + 1, child_bone_morphs, child_pose_bones) + max_bone_id = FnModel.get_max_bone_id(child_pose_bones) - child_armature_matrix = child_armature_object.matrix_parent_inverse.copy() - - with bpy.context.temp_override( - active_object=child_armature_object, - selected_editable_objects=[child_armature_object], - ): - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - - # Disconnect mesh dependencies because transform_apply fails when mesh data are multiple used. - related_meshes: Dict['MaterialMorphData', bpy.types.Mesh] = {} + # Save material morph references for this child model for material_morph in child_root_object.mmd_root.material_morphs: for material_morph_data in material_morph.data: if material_morph_data.related_mesh_data is not None: related_meshes[material_morph_data] = material_morph_data.related_mesh_data material_morph_data.related_mesh_data = None - try: - # replace mesh armature modifier.object - mesh: bpy.types.Object - for mesh in FnModel.__iterate_child_mesh_objects(child_armature_object): - with bpy.context.temp_override( - active_object=mesh, - selected_editable_objects=[mesh], - ): - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - finally: - # Restore mesh dependencies - for material_morph in child_root_object.mmd_root.material_morphs: - for material_morph_data in material_morph.data: - material_morph_data.related_mesh_data = related_meshes.get(material_morph_data, None) - # join armatures - with bpy.context.temp_override( - active_object=parent_armature_object, - selected_editable_objects=[parent_armature_object, child_armature_object], - ): - bpy.ops.object.join() - - for mesh in FnModel.__iterate_child_mesh_objects(parent_armature_object): - armature_modifier: bpy.types.ArmatureModifier = mesh.modifiers["mmd_bone_order_override"] if "mmd_bone_order_override" in mesh.modifiers else mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") - if armature_modifier.object is None: - armature_modifier.object = parent_armature_object - mesh.matrix_parent_inverse = child_armature_matrix - - child_rigid_group_object = FnModel.find_rigid_group_object(child_root_object) - if child_rigid_group_object is not None: - parent_rigid_group_object = FnModel.find_rigid_group_object(parent_root_object) - - with bpy.context.temp_override( - object=parent_rigid_group_object, - selected_editable_objects=[parent_rigid_group_object, *FnModel.iterate_rigid_body_objects(child_root_object)], - ): + # Move mesh objects to parent armature using parent_set + mesh_objects = list(FnModel.__iterate_child_mesh_objects(child_armature_object)) + if mesh_objects: + with select_object(parent_armature_object, objects=[parent_armature_object] + mesh_objects): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + + for mesh in mesh_objects: + FnContext.set_active_and_select_single_object(context, mesh) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + for mesh in mesh_objects: + armature_modifier = next((mod for mod in mesh.modifiers if mod.type == "ARMATURE"), None) + if armature_modifier is None: + armature_modifier = mesh.modifiers.new("mmd_armature", "ARMATURE") + armature_modifier.object = parent_armature_object + + # Handle rigid bodies + child_rigid_group_object = FnModel.find_rigid_group_object(child_root_object) + if child_rigid_group_object: + parent_rigid_group_object = FnModel.ensure_rigid_group_object(context, parent_root_object) + rigid_objects = list(FnModel.iterate_rigid_body_objects(child_root_object)) + if rigid_objects: + with select_object(parent_rigid_group_object, objects=[parent_rigid_group_object] + rigid_objects): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) bpy.data.objects.remove(child_rigid_group_object) + # Handle joints child_joint_group_object = FnModel.find_joint_group_object(child_root_object) - if child_joint_group_object is not None: - parent_joint_group_object = FnModel.find_joint_group_object(parent_root_object) - with bpy.context.temp_override( - object=parent_joint_group_object, - selected_editable_objects=[parent_joint_group_object, *FnModel.iterate_joint_objects(child_root_object)], - ): - bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + if child_joint_group_object: + parent_joint_group_object = FnModel.ensure_joint_group_object(context, parent_root_object) + joint_objects = list(FnModel.iterate_joint_objects(child_root_object)) + if joint_objects: + with select_object(parent_joint_group_object, objects=[parent_joint_group_object] + joint_objects): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) bpy.data.objects.remove(child_joint_group_object) + # Handle temporary objects child_temporary_group_object = FnModel.find_temporary_group_object(child_root_object) - if child_temporary_group_object is not None: - parent_temporary_group_object = FnModel.find_temporary_group_object(parent_root_object) - with bpy.context.temp_override( - object=parent_temporary_group_object, - selected_editable_objects=[parent_temporary_group_object, *FnModel.iterate_temporary_objects(child_root_object)], - ): - bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) - - for obj in list(FnModel.iterate_child_objects(child_temporary_group_object)): - bpy.data.objects.remove(obj) + if child_temporary_group_object: + parent_temporary_group_object = FnModel.ensure_temporary_group_object(context, parent_root_object) + temp_objects = list(FnModel.iterate_temporary_objects(child_root_object)) + if temp_objects: + with select_object(parent_temporary_group_object, objects=[parent_temporary_group_object] + temp_objects): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) bpy.data.objects.remove(child_temporary_group_object) + # Copy MMD root properties FnModel.copy_mmd_root(parent_root_object, child_root_object, overwrite=False) - # Remove unused objects from child models - if len(child_root_object.children) == 0: - bpy.data.objects.remove(child_root_object) - - logger.info("Model joining completed successfully") + # Clean additional transform before join + bpy.ops.object.mode_set(mode="OBJECT") + FnContext.set_active_and_select_single_object(context, parent_root_object) + bpy.ops.mmd_tools.clean_additional_transform() + for child_root_object in child_root_objects: + FnContext.set_active_and_select_single_object(context, child_root_object) + bpy.ops.mmd_tools.clean_additional_transform() + + # Join all child armatures to parent armature + bpy.ops.object.mode_set(mode="OBJECT") + child_armature_objects = [FnModel.find_armature_object(child_root) for child_root in child_root_objects if FnModel.find_armature_object(child_root) is not None] + armature_data_to_remove = [child_arm.data for child_arm in child_armature_objects if child_arm.data] + if child_armature_objects: + with select_object(parent_armature_object, objects=[parent_armature_object] + child_armature_objects): + bpy.ops.object.join() + for armature_data in armature_data_to_remove: + if armature_data and armature_data.users == 0: + bpy.data.armatures.remove(armature_data) + + # Apply additional transform after join + FnContext.set_active_object(context, parent_root_object) + bpy.ops.mmd_tools.clean_additional_transform() + bpy.ops.mmd_tools.apply_additional_transform() + + # Remove empty child root objects + for child_root_object in child_root_objects: + assert len(child_root_object.children) == 0 + bpy.data.objects.remove(child_root_object) + + # Restore material morph references for all child models + for material_morph_data, mesh_data in related_meshes.items(): + material_morph_data.related_mesh_data = mesh_data + + # Restore original transform matrix for parent root object + parent_root_object.matrix_world = original_matrix_world @staticmethod def _add_armature_modifier(mesh_object: bpy.types.Object, armature_object: bpy.types.Object) -> bpy.types.ArmatureModifier: @@ -366,23 +758,20 @@ class FnModel: if m.type != "ARMATURE": continue # already has armature modifier. - return cast(bpy.types.ArmatureModifier, m) + return cast("bpy.types.ArmatureModifier", m) - modifier = cast(bpy.types.ArmatureModifier, mesh_object.modifiers.new(name="Armature", type="ARMATURE")) + modifier = cast("bpy.types.ArmatureModifier", mesh_object.modifiers.new(name="Armature", type="ARMATURE")) modifier.object = armature_object modifier.use_vertex_groups = True - modifier.name = "mmd_bone_order_override" + modifier.name = "mmd_armature" return modifier @staticmethod - def attach_mesh_objects(parent_root_object: bpy.types.Object, mesh_objects: Iterable[bpy.types.Object], add_armature_modifier: bool) -> None: - logger.info(f"Attaching mesh objects to {parent_root_object.name}") + def attach_mesh_objects(parent_root_object: bpy.types.Object, mesh_objects: Iterable[bpy.types.Object], add_armature_modifier: bool): armature_object = FnModel.find_armature_object(parent_root_object) if armature_object is None: - error_msg = f"Armature object not found in {parent_root_object.name}" - logger.error(error_msg) - raise ValueError(error_msg) + raise ValueError(f"Armature object not found in {parent_root_object}") def __get_root_object(obj: bpy.types.Object) -> bpy.types.Object: if obj.parent is None: @@ -391,11 +780,6 @@ class FnModel: for mesh_object in mesh_objects: if not FnModel.is_mesh_object(mesh_object): - logger.debug(f"Skipping non-mesh object: {mesh_object.name}") - continue - - if FnModel.find_root_object(mesh_object) is not None: - logger.debug(f"Skipping mesh with existing root: {mesh_object.name}") continue mesh_root_object = __get_root_object(mesh_object) @@ -403,68 +787,31 @@ class FnModel: mesh_root_object.parent_type = "OBJECT" mesh_root_object.parent = armature_object mesh_root_object.matrix_world = original_matrix_world - logger.debug(f"Attached mesh: {mesh_object.name}") if add_armature_modifier: FnModel._add_armature_modifier(mesh_object, armature_object) - logger.debug(f"Added armature modifier to: {mesh_object.name}") @staticmethod - def add_missing_vertex_groups_from_bones(root_object: bpy.types.Object, mesh_object: bpy.types.Object, search_in_all_meshes: bool) -> None: - logger.info(f"Adding missing vertex groups from bones to {mesh_object.name}") - armature_object = FnModel.find_armature_object(root_object) - if armature_object is None: - error_msg = f"Armature object not found in {root_object.name}" - logger.error(error_msg) - raise ValueError(error_msg) - - vertex_group_names: Set[str] = set() - - search_meshes = FnModel.iterate_mesh_objects(root_object) if search_in_all_meshes else [mesh_object] - - for search_mesh in search_meshes: - vertex_group_names.update(search_mesh.vertex_groups.keys()) - - added_count = 0 - pose_bone: bpy.types.PoseBone - for pose_bone in armature_object.pose.bones: - pose_bone_name = pose_bone.name - - if pose_bone_name in vertex_group_names: - continue - - if pose_bone_name.startswith("_"): - continue - - mesh_object.vertex_groups.new(name=pose_bone_name) - added_count += 1 - - logger.debug(f"Added {added_count} missing vertex groups to {mesh_object.name}") - - @staticmethod - def change_mmd_ik_loop_factor(root_object: bpy.types.Object, new_ik_loop_factor: int) -> None: - logger.info(f"Changing IK loop factor to {new_ik_loop_factor}") + def change_mmd_ik_loop_factor(root_object: bpy.types.Object, new_ik_loop_factor: int): mmd_root = root_object.mmd_root old_ik_loop_factor = mmd_root.ik_loop_factor if new_ik_loop_factor == old_ik_loop_factor: - logger.debug("IK loop factor already set to the requested value") return armature_object = FnModel.find_armature_object(root_object) - updated_count = 0 for pose_bone in armature_object.pose.bones: - for constraint in (cast(bpy.types.KinematicConstraint, c) for c in pose_bone.constraints if c.type == "IK"): + for constraint in (cast("bpy.types.KinematicConstraint", c) for c in pose_bone.constraints if c.type == "IK"): iterations = int(constraint.iterations * new_ik_loop_factor / old_ik_loop_factor) - logger.debug(f"Update {constraint.name} of {pose_bone.name}: {constraint.iterations} -> {iterations}") + logger.info("Update %s of %s: %d -> %d", constraint.name, pose_bone.name, constraint.iterations, iterations) constraint.iterations = iterations - updated_count += 1 mmd_root.ik_loop_factor = new_ik_loop_factor - logger.info(f"Updated {updated_count} IK constraints") + + return @staticmethod - def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]) -> None: + def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): destination_rna_properties = destination.bl_rna.properties for name in source.keys(): is_attr = hasattr(source, name) @@ -490,7 +837,7 @@ class FnModel: destination[name] = value @staticmethod - def __copy_collection_property(destination: bpy.types.bpy_prop_collection, source: bpy.types.bpy_prop_collection, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]) -> None: + def __copy_collection_property(destination: bpy.types.bpy_prop_collection, source: bpy.types.bpy_prop_collection, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): if overwrite: destination.clear() @@ -523,19 +870,16 @@ class FnModel: FnModel.__copy_property(destination[index], source[index], overwrite=True, replace_name2values=replace_name2values) @staticmethod - def __copy_property(destination: Union[bpy.types.PropertyGroup, bpy.types.bpy_prop_collection], source: Union[bpy.types.PropertyGroup, bpy.types.bpy_prop_collection], overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]) -> None: + def __copy_property(destination: Union[bpy.types.PropertyGroup, bpy.types.bpy_prop_collection], source: Union[bpy.types.PropertyGroup, bpy.types.bpy_prop_collection], overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): if isinstance(destination, bpy.types.PropertyGroup): FnModel.__copy_property_group(destination, source, overwrite=overwrite, replace_name2values=replace_name2values) elif isinstance(destination, bpy.types.bpy_prop_collection): FnModel.__copy_collection_property(destination, source, overwrite=overwrite, replace_name2values=replace_name2values) else: - error_msg = f"Unsupported destination: {destination}" - logger.error(error_msg) - raise ValueError(error_msg) + raise ValueError(f"Unsupported destination: {destination}") @staticmethod - def initalize_display_item_frames(root_object: bpy.types.Object, reset: bool = True) -> None: - logger.info(f"Initializing display item frames for {root_object.name}") + def initalize_display_item_frames(root_object: bpy.types.Object, reset: bool = True): frames = root_object.mmd_root.display_item_frames if reset and len(frames) > 0: root_object.mmd_root.active_display_item_frame = 0 @@ -558,8 +902,6 @@ class FnModel: if not reset: frames.move(frames.find("Root"), 0) frames.move(frames.find("表情"), 1) - - logger.debug(f"Display item frames initialized with {len(frames)} frames") @staticmethod def get_empty_display_size(root_object: bpy.types.Object) -> float: @@ -570,28 +912,19 @@ class MigrationFnModel: """Migration Functions for old MMD models broken by bugs or issues""" @classmethod - def update_mmd_ik_loop_factor(cls) -> None: - logger.info("Updating MMD IK loop factor for all armatures") - updated_count = 0 + def update_mmd_ik_loop_factor(cls): for armature_object in bpy.data.objects: if armature_object.type != "ARMATURE": continue if "mmd_ik_loop_factor" not in armature_object: - continue + return - root_object = FnModel.find_root_object(armature_object) - if root_object: - root_object.mmd_root.ik_loop_factor = max(armature_object["mmd_ik_loop_factor"], 1) - del armature_object["mmd_ik_loop_factor"] - updated_count += 1 - - logger.info(f"Updated IK loop factor for {updated_count} armatures") + FnModel.find_root_object(armature_object).mmd_root.ik_loop_factor = max(armature_object["mmd_ik_loop_factor"], 1) + del armature_object["mmd_ik_loop_factor"] @staticmethod - def update_avatar_toolkit_version() -> None: - logger.info("Updating Avatar Toolkit version for all MMD root objects") - updated_count = 0 + def update_AVATAR_TOOLKIT_VERSION(): for root_object in bpy.data.objects: if root_object.type != "EMPTY": continue @@ -599,17 +932,14 @@ class MigrationFnModel: if not FnModel.is_root_object(root_object): continue - if "avatar_toolkit_version" in root_object: + if "AVATAR_TOOLKIT_VERSION" in root_object: continue - root_object["avatar_toolkit_version"] = "0.2.1" - updated_count += 1 - - logger.info(f"Updated Avatar Toolkit version for {updated_count} root objects") + root_object["AVATAR_TOOLKIT_VERSION"] = "2.8.0" class Model: - def __init__(self, root_obj: bpy.types.Object) -> None: + def __init__(self, root_obj): if root_obj is None: raise ValueError("must be MMD ROOT type object") if root_obj.mmd_type != "ROOT": @@ -619,26 +949,23 @@ class Model: self.__rigid_grp: Optional[bpy.types.Object] = None self.__joint_grp: Optional[bpy.types.Object] = None self.__temporary_grp: Optional[bpy.types.Object] = None - logger.debug(f"Model initialized with root object: {self.__root.name}") @staticmethod - def create(name: str, name_e: str = "", scale: float = 1, obj_name: Optional[str] = None, armature_object: Optional[bpy.types.Object] = None, add_root_bone: bool = False) -> 'Model': + def create(name: str, name_e: str = "", scale: float = 1, obj_name: Optional[str] = None, armature_object: Optional[bpy.types.Object] = None, add_root_bone: bool = False): if obj_name is None: obj_name = name context = FnContext.ensure_context() - logger.info(f"Creating new MMD model: {name}") root: bpy.types.Object = bpy.data.objects.new(name=obj_name, object_data=None) root.mmd_type = "ROOT" root.mmd_root.name = name root.mmd_root.name_e = name_e - root["avatar_toolkit_version"] = AVATAR_TOOLKIT_VERSION + root["AVATAR_TOOLKIT_VERSION"] = AVATAR_TOOLKIT_VERSION setattr(root, Props.empty_display_size, scale / 0.2) FnContext.link_object(context, root) if armature_object: - logger.debug(f"Using existing armature: {armature_object.name}") m = armature_object.matrix_world armature_object.parent_type = "OBJECT" armature_object.parent = root @@ -646,7 +973,6 @@ class Model: root.matrix_world = m armature_object.matrix_local.identity() else: - logger.debug("Creating new armature") armature_object = bpy.data.objects.new(name=obj_name + "_arm", object_data=bpy.data.armatures.new(name=obj_name)) armature_object.parent = root FnContext.link_object(context, armature_object) @@ -659,12 +985,11 @@ class Model: FnBone.setup_special_bone_collections(armature_object) if add_root_bone: - logger.debug("Adding root bone") bone_name = "全ての親" bone_name_english = "Root" # Create the root bone - with bpyutils.edit_object(armature_object) as data: + with edit_object(armature_object) as data: bone = data.edit_bones.new(name=bone_name) bone.head = (0.0, 0.0, 0.0) bone.tail = (0.0, 0.0, getattr(root, Props.empty_display_size)) @@ -683,26 +1008,24 @@ class Model: bone_collection.assign(data_bone) FnContext.set_active_and_select_single_object(context, root) - logger.info(f"Model created successfully: {name}") return Model(root) @staticmethod def findRoot(obj: bpy.types.Object) -> Optional[bpy.types.Object]: return FnModel.find_root_object(obj) - def initialDisplayFrames(self, reset: bool = True) -> None: + def initialDisplayFrames(self, reset=True): FnModel.initalize_display_item_frames(self.__root, reset=reset) @property - def morph_slider(self) -> Any: + def morph_slider(self): return FnMorph.get_morph_slider(self) - def loadMorphs(self) -> None: - logger.info(f"Loading morphs for model: {self.__root.name}") + def loadMorphs(self): FnMorph.load_morphs(self) - def create_ik_constraint(self, bone: bpy.types.PoseBone, ik_target: bpy.types.PoseBone) -> bpy.types.KinematicConstraint: - """create IK constraint + def create_ik_constraint(self, bone, ik_target): + """Create IK constraint Args: bone: A pose bone to add a IK constraint @@ -713,7 +1036,6 @@ class Model: and subtarget options. """ - logger.debug(f"Creating IK constraint on {bone.name} targeting {ik_target.name}") ik_target_name = ik_target.name ik_const = bone.constraints.new("IK") ik_const.target = self.__arm @@ -742,7 +1064,6 @@ class Model: if self.__rigid_grp is None: self.__rigid_grp = FnModel.find_rigid_group_object(self.__root) if self.__rigid_grp is None: - logger.debug(f"Creating rigid group object for {self.__root.name}") rigids = bpy.data.objects.new(name="rigidbodies", object_data=None) FnContext.link_object(FnContext.ensure_context(), rigids) rigids.mmd_type = "RIGID_GRP_OBJ" @@ -760,7 +1081,6 @@ class Model: if self.__joint_grp is None: self.__joint_grp = FnModel.find_joint_group_object(self.__root) if self.__joint_grp is None: - logger.debug(f"Creating joint group object for {self.__root.name}") joints = bpy.data.objects.new(name="joints", object_data=None) FnContext.link_object(FnContext.ensure_context(), joints) joints.mmd_type = "JOINT_GRP_OBJ" @@ -778,7 +1098,6 @@ class Model: if self.__temporary_grp is None: self.__temporary_grp = FnModel.find_temporary_group_object(self.__root) if self.__temporary_grp is None: - logger.debug(f"Creating temporary group object for {self.__root.name}") temporarys = bpy.data.objects.new(name="temporary", object_data=None) FnContext.link_object(FnContext.ensure_context(), temporarys) temporarys.mmd_type = "TEMPORARY_GRP_OBJ" @@ -792,7 +1111,7 @@ class Model: def meshes(self) -> Iterator[bpy.types.Object]: return FnModel.iterate_mesh_objects(self.__root) - def attachMeshes(self, meshes: Iterator[bpy.types.Object], add_armature_modifier: bool = True) -> None: + def attachMeshes(self, meshes: Iterator[bpy.types.Object], add_armature_modifier: bool = True): FnModel.attach_mesh_objects(self.rootObject(), meshes, add_armature_modifier) def firstMesh(self) -> Optional[bpy.types.Object]: @@ -800,21 +1119,17 @@ class Model: return i return None - def findMesh(self, mesh_name: str) -> Optional[bpy.types.Object]: - """ - Helper method to find a mesh by name - """ + def findMesh(self, mesh_name) -> Optional[bpy.types.Object]: + """Find the mesh by name""" if mesh_name == "": return None for mesh in self.meshes(): - if mesh.name == mesh_name or mesh.data.name == mesh_name: + if mesh_name in {mesh.name, mesh.data.name}: return mesh return None def findMeshByIndex(self, index: int) -> Optional[bpy.types.Object]: - """ - Helper method to find the mesh by index - """ + """Find the mesh by index""" if index < 0: return None for i, mesh in enumerate(self.meshes()): @@ -823,13 +1138,11 @@ class Model: return None def getMeshIndex(self, mesh_name: str) -> int: - """ - Helper method to get the index of a mesh. Returns -1 if not found - """ + """Get the index of a mesh. Returns -1 if not found""" if mesh_name == "": return -1 for i, mesh in enumerate(self.meshes()): - if mesh.name == mesh_name or mesh.data.name == mesh_name: + if mesh_name in {mesh.name, mesh.data.name}: return i return -1 @@ -839,26 +1152,23 @@ class Model: def joints(self) -> Iterator[bpy.types.Object]: return FnModel.iterate_joint_objects(self.__root) - def temporaryObjects(self, rigid_track_only: bool = False) -> Iterator[bpy.types.Object]: + def temporaryObjects(self, rigid_track_only=False) -> Iterator[bpy.types.Object]: return FnModel.iterate_temporary_objects(self.__root, rigid_track_only) def materials(self) -> Iterator[bpy.types.Material]: - """ - Helper method to list all materials in all meshes - """ - materials: Dict[bpy.types.Material, int] = {} # Use dict instead of set to guarantee preserve order + """List all materials in all meshes""" + materials = {} # Use dict instead of set to guarantee preserve order for mesh in self.meshes(): materials.update((slot.material, 0) for slot in mesh.material_slots if slot.material is not None) return iter(materials.keys()) - def renameBone(self, old_bone_name: str, new_bone_name: str) -> None: + def renameBone(self, old_bone_name, new_bone_name): if old_bone_name == new_bone_name: return - logger.info(f"Renaming bone: {old_bone_name} -> {new_bone_name}") armature = self.armature() bone = armature.pose.bones[old_bone_name] bone.name = new_bone_name - new_bone_name = bone.name # Get the actual name (might be adjusted by Blender) + new_bone_name = bone.name mmd_root = self.rootObject().mmd_root for frame in mmd_root.display_item_frames: @@ -869,11 +1179,9 @@ class Model: if old_bone_name in mesh.vertex_groups: mesh.vertex_groups[old_bone_name].name = new_bone_name - def build(self, non_collision_distance_scale: float = 1.5, collision_margin: float = 1e-06) -> None: - logger.info(f"Building physics rig for {self.__root.name}") + def build(self, non_collision_distance_scale=1.5, collision_margin=1e-06): rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) if self.__root.mmd_root.is_built: - logger.info("Model is already built, cleaning first") self.clean() self.__root.mmd_root.is_built = True logger.info("****************************************") @@ -888,8 +1196,7 @@ class Model: logger.info(" Finished building in %f seconds.", time.time() - start_time) rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) - def clean(self) -> None: - logger.info(f"Cleaning physics rig for {self.__root.name}") + def clean(self): rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) logger.info("****************************************") logger.info(" Clean rig") @@ -904,7 +1211,6 @@ class Model: if "mmd_tools_rigid_track" in i.constraints: const = i.constraints["mmd_tools_rigid_track"] i.constraints.remove(const) - logger.debug(f"Removed rigid track constraint from {i.name}") rigid_track_counts = 0 for i in self.rigidBodies(): @@ -918,7 +1224,7 @@ class Model: if rigid_type == rigid_body.MODE_STATIC: i.parent_type = "OBJECT" i.parent = self.rigidGroupObject() - elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + elif rigid_type in {rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE}: arm = relation.target bone_name = relation.subtarget if arm is not None and bone_name != "": @@ -945,42 +1251,39 @@ class Model: mmd_root.is_built = False rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) - def __removeTemporaryObjects(self) -> None: - logger.debug("Removing temporary objects") + def __removeTemporaryObjects(self): with bpy.context.temp_override(selected_objects=tuple(self.temporaryObjects()), active_object=self.rootObject()): bpy.ops.object.delete() - def __restoreTransforms(self, obj: bpy.types.Object) -> None: + def __restoreTransforms(self, obj): for attr in ("location", "rotation_euler"): - attr_name = "__backup_%s__" % attr + attr_name = f"__backup_{attr}__" val = obj.get(attr_name, None) if val is not None: setattr(obj, attr, val) - del obj[attr_name] - logger.debug(f"Restored {attr} for {obj.name}") + # Use property_unset instead of del for Blender 5.0 compatibility + obj.property_unset(attr_name) - def __backupTransforms(self, obj: bpy.types.Object) -> None: + def __backupTransforms(self, obj): for attr in ("location", "rotation_euler"): - attr_name = "__backup_%s__" % attr + attr_name = f"__backup_{attr}__" if attr_name in obj: # should not happen in normal build/clean cycle continue obj[attr_name] = getattr(obj, attr, None) - logger.debug(f"Backed up {attr} for {obj.name}") - def __preBuild(self) -> None: - logger.debug("Pre-build preparation") - self.__fake_parent_map: Dict[bpy.types.Object, List[bpy.types.Object]] = {} - self.__rigid_body_matrix_map: Dict[bpy.types.Object, Any] = {} - self.__empty_parent_map: Dict[bpy.types.Object, bpy.types.Object] = {} + def __preBuild(self): + self.__fake_parent_map = {} + self.__rigid_body_matrix_map = {} + self.__empty_parent_map = {} - no_parents: List[bpy.types.Object] = [] + no_parents = [] for i in self.rigidBodies(): self.__backupTransforms(i) # mute relation relation = i.constraints["mmd_tools_rigid_parent"] relation.mute = True # mute IK - if int(i.mmd_rigid.type) in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + if int(i.mmd_rigid.type) in {rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE}: arm = relation.target bone_name = relation.subtarget if arm is not None and bone_name != "": @@ -993,7 +1296,7 @@ class Model: # update changes of armature constraints bpy.context.scene.frame_set(bpy.context.scene.frame_current) - parented: List[bpy.types.Object] = [] + parented = [] for i in self.joints(): self.__backupTransforms(i) rbc = i.rigid_body_constraint @@ -1011,8 +1314,7 @@ class Model: # assert(len(no_parents) == len(parented)) - def __postBuild(self) -> None: - logger.debug("Post-build finalization") + def __postBuild(self): self.__fake_parent_map = None self.__rigid_body_matrix_map = None @@ -1024,7 +1326,6 @@ class Model: matrix_world = empty.matrix_world empty.parent = rigid_obj empty.matrix_world = matrix_world - logger.debug(f"Parented empty {empty.name} to rigid object {rigid_obj.name}") self.__empty_parent_map = None arm = self.armature() @@ -1033,13 +1334,11 @@ class Model: c = p_bone.constraints.get("mmd_tools_rigid_track", None) if c: c.mute = False - logger.debug(f"Enabled rigid track constraint for {p_bone.name}") - def updateRigid(self, rigid_obj: bpy.types.Object, collision_margin: float) -> None: + def updateRigid(self, rigid_obj: bpy.types.Object, collision_margin: float): assert rigid_obj.mmd_type == "RIGID_BODY" rb = rigid_obj.rigid_body if rb is None: - logger.warning(f"No rigid body for {rigid_obj.name}") return rigid = rigid_obj.mmd_rigid @@ -1088,7 +1387,7 @@ class Model: fake_child.location = t fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) - elif rigid_type in [rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE]: + elif rigid_type in {rigid_body.MODE_DYNAMIC, rigid_body.MODE_DYNAMIC_BONE}: m = target_bone.matrix @ target_bone.bone.matrix_local.inverted() self.__rigid_body_matrix_map[rigid_obj] = m t, r, s = (m @ rigid_obj.matrix_local).decompose() @@ -1138,12 +1437,12 @@ class Model: logger.debug(" * Bone (%s): track target [%s]", target_bone.name, ori_rigid_obj.name) rb.collision_shape = rigid.shape - logger.debug(f"Updated rigid body {rigid_obj.name} with type {rigid_type}") - def __getRigidRange(self, obj: bpy.types.Object) -> float: + @staticmethod + def __getRigidRange(obj): return (Vector(obj.bound_box[0]) - Vector(obj.bound_box[6])).length - def __createNonCollisionConstraint(self, nonCollisionJointTable: List[Tuple[bpy.types.Object, bpy.types.Object]]) -> None: + def __createNonCollisionConstraint(self, nonCollisionJointTable): total_len = len(nonCollisionJointTable) if total_len < 1: return @@ -1152,7 +1451,7 @@ class Model: logger.debug("-" * 60) logger.debug(" creating ncc, counts: %d", total_len) - ncc_obj = bpyutils.createObject(name="ncc", object_data=None) + ncc_obj = createObject(name="ncc", object_data=None) ncc_obj.location = [0, 0, 0] setattr(ncc_obj, Props.empty_display_type, "ARROWS") setattr(ncc_obj, Props.empty_display_size, 0.5 * getattr(self.__root, Props.empty_display_size)) @@ -1164,10 +1463,10 @@ class Model: rb = ncc_obj.rigid_body_constraint rb.disable_collisions = True - ncc_objs = bpyutils.duplicateObject(ncc_obj, total_len) + ncc_objs = duplicateObject(ncc_obj, total_len) logger.debug(" created %d ncc.", len(ncc_objs)) - for ncc_obj, pair in zip(ncc_objs, nonCollisionJointTable): + for ncc_obj, pair in zip(ncc_objs, nonCollisionJointTable, strict=False): rbc = ncc_obj.rigid_body_constraint rbc.object1, rbc.object2 = pair ncc_obj.hide_set(True) @@ -1175,16 +1474,16 @@ class Model: logger.debug(" finish in %f seconds.", time.time() - start_time) logger.debug("-" * 60) - def buildRigids(self, non_collision_distance_scale: float, collision_margin: float) -> List[bpy.types.Object]: + def buildRigids(self, non_collision_distance_scale, collision_margin): logger.debug("--------------------------------") logger.debug(" Build riggings of rigid bodies") logger.debug("--------------------------------") rigid_objects = list(self.rigidBodies()) - rigid_object_groups: List[List[bpy.types.Object]] = [[] for i in range(16)] + rigid_object_groups = [[] for i in range(16)] for i in rigid_objects: rigid_object_groups[i.mmd_rigid.collision_group_number].append(i) - jointMap: Dict[frozenset, bpy.types.Object] = {} + jointMap = {} for joint in self.joints(): rbc = joint.rigid_body_constraint if rbc is None: @@ -1194,8 +1493,8 @@ class Model: logger.info("Creating non collision constraints") # create non collision constraints - nonCollisionJointTable: List[Tuple[bpy.types.Object, bpy.types.Object]] = [] - non_collision_pairs: Set[frozenset] = set() + nonCollisionJointTable = [] + non_collision_pairs = set() rigid_object_cnt = len(rigid_objects) for obj_a in rigid_objects: for n, ignore in enumerate(obj_a.mmd_rigid.collision_group_mask): @@ -1221,8 +1520,7 @@ class Model: self.__createNonCollisionConstraint(nonCollisionJointTable) return rigid_objects - def buildJoints(self) -> None: - logger.info("Building joints") + def buildJoints(self): for i in self.joints(): rbc = i.rigid_body_constraint if rbc is None: @@ -1235,17 +1533,16 @@ class Model: t, r, s = (m @ i.matrix_local).decompose() i.location = t i.rotation_euler = r.to_euler(i.rotation_mode) - logger.debug(f"Built joint: {i.name}") - def __editPhysicsBones(self, editor: Callable[[bpy.types.EditBone], None], target_modes: Set[str]) -> None: + def __editPhysicsBones(self, editor: Callable[[bpy.types.EditBone], None], target_modes: Set[str]): armature_object = self.armature() armature: bpy.types.Armature - with bpyutils.edit_object(armature_object) as armature: + with edit_object(armature_object) as armature: edit_bones = armature.edit_bones rigid_body_object: bpy.types.Object for rigid_body_object in self.rigidBodies(): - mmd_rigid: 'MMDRigidBody' = rigid_body_object.mmd_rigid + mmd_rigid: MMDRigidBody = rigid_body_object.mmd_rigid if mmd_rigid.type not in target_modes: continue @@ -1256,25 +1553,21 @@ class Model: editor(edit_bone) - def disconnectPhysicsBones(self) -> None: - logger.info("Disconnecting physics bones") - def editor(edit_bone: bpy.types.EditBone) -> None: + def disconnectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): rna_prop_ui.rna_idprop_ui_create(edit_bone, "mmd_bone_use_connect", default=edit_bone.use_connect) edit_bone.use_connect = False - logger.debug(f"Disconnected bone: {edit_bone.name}") self.__editPhysicsBones(editor, {str(MODE_DYNAMIC)}) - def connectPhysicsBones(self) -> None: - logger.info("Connecting physics bones") - def editor(edit_bone: bpy.types.EditBone) -> None: + def connectPhysicsBones(self): + def editor(edit_bone: bpy.types.EditBone): mmd_bone_use_connect_str: Optional[str] = edit_bone.get("mmd_bone_use_connect") if mmd_bone_use_connect_str is None: return if not edit_bone.use_connect: # wasn't it overwritten? edit_bone.use_connect = bool(mmd_bone_use_connect_str) - logger.debug(f"Connected bone: {edit_bone.name}") del edit_bone["mmd_bone_use_connect"] self.__editPhysicsBones(editor, {str(MODE_STATIC), str(MODE_DYNAMIC), str(MODE_DYNAMIC_BONE)}) diff --git a/core/mmd/core/morph.py b/core/mmd/core/morph.py index 2af6801..31d3f88 100644 --- a/core/mmd/core/morph.py +++ b/core/mmd/core/morph.py @@ -1,39 +1,34 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2016 MMD Tools authors +# This file is part of MMD Tools. +from ....core.logging_setup import logger +import math import re -from typing import TYPE_CHECKING, Tuple, cast, List, Dict, Optional, Set, Any, Union, Iterator +from typing import TYPE_CHECKING, Tuple, cast import bpy -import numpy as np -from bpy.types import Object, ShapeKey, Material, Mesh, Armature, PoseBone, Constraint from .. import bpyutils, utils from ..bpyutils import FnContext, FnObject, TransformConstraintOp -from ....core.logging_setup import logger if TYPE_CHECKING: from .model import Model class FnMorph: - def __init__(self, morph: Any, model: "Model"): + def __init__(self, morph, model: "Model"): self.__morph = morph self.__rig = model @classmethod - def storeShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None: + def storeShapeKeyOrder(cls, obj, shape_key_names): if len(shape_key_names) < 1: return assert FnContext.get_active_object(FnContext.ensure_context()) == obj if obj.data.shape_keys is None: bpy.ops.object.shape_key_add() - def __move_to_bottom(key_blocks: bpy.types.bpy_prop_collection, name: str) -> None: + def __move_to_bottom(key_blocks, name): obj.active_shape_key_index = key_blocks.find(name) bpy.ops.object.shape_key_move(type="BOTTOM") @@ -45,7 +40,7 @@ class FnMorph: __move_to_bottom(key_blocks, name) @classmethod - def fixShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None: + def fixShapeKeyOrder(cls, obj, shape_key_names): if len(shape_key_names) < 1: return assert FnContext.get_active_object(FnContext.ensure_context()) == obj @@ -60,11 +55,11 @@ class FnMorph: bpy.ops.object.shape_key_move(type="BOTTOM") @staticmethod - def get_morph_slider(rig: "Model") -> "_MorphSlider": + def get_morph_slider(rig): return _MorphSlider(rig) @staticmethod - def category_guess(morph: Any) -> None: + def category_guess(morph): name_lower = morph.name.lower() if "mouth" in name_lower: morph.category = "MOUTH" @@ -75,7 +70,7 @@ class FnMorph: morph.category = "EYE" @classmethod - def load_morphs(cls, rig: "Model") -> None: + def load_morphs(cls, rig): mmd_root = rig.rootObject().mmd_root vertex_morphs = mmd_root.vertex_morphs uv_morphs = mmd_root.uv_morphs @@ -94,7 +89,7 @@ class FnMorph: cls.category_guess(item) @staticmethod - def remove_shape_key(mesh_object: Object, shape_key_name: str) -> None: + def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str): assert isinstance(mesh_object.data, bpy.types.Mesh) shape_keys = mesh_object.data.shape_keys @@ -106,7 +101,7 @@ class FnMorph: FnObject.mesh_remove_shape_key(mesh_object, key_blocks[shape_key_name]) @staticmethod - def copy_shape_key(mesh_object: Object, src_name: str, dest_name: str) -> None: + def copy_shape_key(mesh_object: bpy.types.Object, src_name: str, dest_name: str): assert isinstance(mesh_object.data, bpy.types.Mesh) shape_keys = mesh_object.data.shape_keys @@ -128,13 +123,13 @@ class FnMorph: mesh_object.active_shape_key_index = key_blocks.find(dest_name) @staticmethod - def get_uv_morph_vertex_groups(obj: Object, morph_name: Optional[str] = None, offset_axes: str = "XYZW") -> Iterator[Tuple[bpy.types.VertexGroup, str, str]]: + def get_uv_morph_vertex_groups(obj, morph_name=None, offset_axes="XYZW"): pattern = "UV_%s[+-][%s]$" % (morph_name or ".{1,}", offset_axes or "XYZW") # yield (vertex_group, morph_name, axis),... return ((g, g.name[3:-2], g.name[-2:]) for g in obj.vertex_groups if re.match(pattern, g.name)) @staticmethod - def copy_uv_morph_vertex_groups(obj: Object, src_name: str, dest_name: str) -> None: + def copy_uv_morph_vertex_groups(obj, src_name, dest_name): for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name): obj.vertex_groups.remove(vg) @@ -145,12 +140,12 @@ class FnMorph: obj.vertex_groups.active.name = vg_name.replace(src_name, dest_name) @staticmethod - def overwrite_bone_morphs_from_action_pose(armature_object: Object) -> None: + def overwrite_bone_morphs_from_action_pose(armature_object): armature = armature_object.id_data - + # Use animation_data and action instead of action_pose if armature.animation_data is None or armature.animation_data.action is None: - logger.warning('Armature "%s" has no animation data or action', armature_object.name) + logger.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name) return action = armature.animation_data.action @@ -164,7 +159,7 @@ class FnMorph: bone_morphs = mmd_root.bone_morphs utils.selectAObject(armature_object) - original_mode = bpy.context.object.mode + original_mode = bpy.context.active_object.mode bpy.ops.object.mode_set(mode="POSE") try: for index, pose_marker in enumerate(pose_markers): @@ -175,7 +170,7 @@ class FnMorph: bpy.ops.pose.select_all(action="SELECT") bpy.ops.pose.transforms_clear() - + frame = pose_marker.frame bpy.context.scene.frame_set(int(frame)) @@ -189,9 +184,9 @@ class FnMorph: utils.selectAObject(root) @staticmethod - def clean_uv_morph_vertex_groups(obj: Object) -> None: + def clean_uv_morph_vertex_groups(obj): # remove empty vertex groups of uv morphs - vg_indices: Set[int] = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)} + vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)} vertex_groups = obj.vertex_groups for v in obj.data.vertices: for x in v.groups: @@ -205,8 +200,8 @@ class FnMorph: vertex_groups.remove(vg) @staticmethod - def get_uv_morph_offset_map(obj: Object, morph: Any) -> Dict[int, List[float]]: - offset_map: Dict[int, List[float]] = {} # offset_map[vertex_index] = offset_xyzw + def get_uv_morph_offset_map(obj, morph): + offset_map = {} # offset_map[vertex_index] = offset_xyzw if morph.data_type == "VERTEX_GROUP": scale = morph.vertex_group_scale axis_map = {g.index: x for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj, morph.name)} @@ -221,13 +216,13 @@ class FnMorph: for val in morph.data: i = val.index if i in offset_map: - offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset)] + offset_map[i] = [a + b for a, b in zip(offset_map[i], val.offset, strict=False)] else: offset_map[i] = val.offset return offset_map @staticmethod - def store_uv_morph_data(obj: Object, morph: Any, offsets: Optional[List[Any]] = None, offset_axes: str = "XYZW") -> None: + def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"): vertex_groups = obj.vertex_groups morph_name = getattr(morph, "name", None) if offset_axes: @@ -246,13 +241,13 @@ class FnMorph: max_value = max(max(abs(x) for x in v) for v in offset_map.values() or ([0],)) scale = morph.vertex_group_scale = max(abs(morph.vertex_group_scale), max_value) for idx, offset in offset_map.items(): - for val, axis in zip(offset, "XYZW"): + for val, axis in zip(offset, "XYZW", strict=False): if abs(val) > 1e-4: - vg_name = "UV_{0}{1}{2}".format(morph_name, "-" if val < 0 else "+", axis) + vg_name = f"UV_{morph_name}{'-' if val < 0 else '+'}{axis}" vg = vertex_groups.get(vg_name, None) or vertex_groups.new(name=vg_name) vg.add(index=[idx], weight=abs(val) / scale, type="REPLACE") - def update_mat_related_mesh(self, new_mesh: Optional[Object] = None) -> None: + def update_mat_related_mesh(self, new_mesh=None): for offset in self.__morph.data: # Use the new_mesh if provided meshObj = new_mesh @@ -272,28 +267,28 @@ class FnMorph: offset.related_mesh = meshObj.data.name @staticmethod - def clean_duplicated_material_morphs(mmd_root_object: Object) -> None: + def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object): """Clean duplicated material_morphs and data from mmd_root_object.mmd_root.material_morphs[].data[]""" mmd_root = mmd_root_object.mmd_root - def morph_data_equals(l: Any, r: Any) -> bool: + def morph_data_equals(left, right) -> bool: return ( - l.related_mesh_data == r.related_mesh_data - and l.offset_type == r.offset_type - and l.material == r.material - and all(a == b for a, b in zip(l.diffuse_color, r.diffuse_color)) - and all(a == b for a, b in zip(l.specular_color, r.specular_color)) - and l.shininess == r.shininess - and all(a == b for a, b in zip(l.ambient_color, r.ambient_color)) - and all(a == b for a, b in zip(l.edge_color, r.edge_color)) - and l.edge_weight == r.edge_weight - and all(a == b for a, b in zip(l.texture_factor, r.texture_factor)) - and all(a == b for a, b in zip(l.sphere_texture_factor, r.sphere_texture_factor)) - and all(a == b for a, b in zip(l.toon_texture_factor, r.toon_texture_factor)) + left.related_mesh_data == right.related_mesh_data + and left.offset_type == right.offset_type + and left.material == right.material + and all(a == b for a, b in zip(left.diffuse_color, right.diffuse_color, strict=False)) + and all(a == b for a, b in zip(left.specular_color, right.specular_color, strict=False)) + and left.shininess == right.shininess + and all(a == b for a, b in zip(left.ambient_color, right.ambient_color, strict=False)) + and all(a == b for a, b in zip(left.edge_color, right.edge_color, strict=False)) + and left.edge_weight == right.edge_weight + and all(a == b for a, b in zip(left.texture_factor, right.texture_factor, strict=False)) + and all(a == b for a, b in zip(left.sphere_texture_factor, right.sphere_texture_factor, strict=False)) + and all(a == b for a, b in zip(left.toon_texture_factor, right.toon_texture_factor, strict=False)) ) - def morph_equals(l: Any, r: Any) -> bool: - return len(l.data) == len(r.data) and all(morph_data_equals(a, b) for a, b in zip(l.data, r.data)) + def morph_equals(left, right) -> bool: + return len(left.data) == len(right.data) and all(morph_data_equals(a, b) for a, b in zip(left.data, right.data, strict=False)) # Remove duplicated mmd_root.material_morphs.data[] for material_morph in mmd_root.material_morphs: @@ -327,7 +322,7 @@ class _MorphSlider: def __init__(self, model: "Model"): self.__rig = model - def placeholder(self, create: bool = False, binded: bool = False) -> Optional[Object]: + def placeholder(self, create=False, binded=False): rig = self.__rig root = rig.rootObject() obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None) @@ -345,11 +340,11 @@ class _MorphSlider: return obj @property - def dummy_armature(self) -> Optional[Object]: + def dummy_armature(self): obj = self.placeholder() return self.__dummy_armature(obj) if obj else None - def __dummy_armature(self, obj: Object, create: bool = False) -> Optional[Object]: + def __dummy_armature(self, obj, create=False): arm = next((x for x in obj.children if x.mmd_type == "PLACEHOLDER" and x.type == "ARMATURE"), None) if create and arm is None: arm = bpy.data.objects.new(name=".dummy_armature", object_data=bpy.data.armatures.new(name=".dummy_armature")) @@ -362,7 +357,7 @@ class _MorphSlider: FnBone.setup_special_bone_collections(arm) return arm - def get(self, morph_name: str) -> Optional[ShapeKey]: + def get(self, morph_name): obj = self.placeholder() if obj is None: return None @@ -371,13 +366,13 @@ class _MorphSlider: return None return key_blocks.get(morph_name, None) - def create(self) -> Object: + def create(self): self.__rig.loadMorphs() obj = self.placeholder(create=True) self.__load(obj, self.__rig.rootObject().mmd_root) return obj - def __load(self, obj: Object, mmd_root: Any) -> None: + def __load(self, obj, mmd_root): attr_list = ("group", "vertex", "bone", "uv", "material") morph_sliders = obj.data.shape_keys.key_blocks for m in (x for attr in attr_list for x in getattr(mmd_root, attr + "_morphs", ())): @@ -388,15 +383,15 @@ class _MorphSlider: obj.shape_key_add(name=name, from_mix=False) @staticmethod - def __driver_variables(id_data: Any, path: str, index: int = -1) -> Tuple[Any, Any]: + def __driver_variables(id_data, path, index=-1): d = id_data.driver_add(path, index) variables = d.driver.variables - for x in variables: + for x in reversed(variables): variables.remove(x) return d.driver, variables @staticmethod - def __add_single_prop(variables: Any, id_obj: Object, data_path: str, prefix: str) -> Any: + def __add_single_prop(variables, id_obj, data_path, prefix): var = variables.new() var.name = f"{prefix}{len(variables)}" var.type = "SINGLE_PROP" @@ -407,7 +402,7 @@ class _MorphSlider: return var @staticmethod - def __shape_key_driver_check(key_block: ShapeKey, resolve_path: bool = False) -> bool: + def __shape_key_driver_check(key_block, resolve_path=False): if resolve_path: try: key_block.id_data.path_resolve(key_block.path_from_id()) @@ -421,22 +416,20 @@ class _MorphSlider: d = next((i for i in key_block.id_data.animation_data.drivers if i.data_path == data_path), None) return not d or d.driver.expression == "".join(("*w", "+g", "v")[-1 if i < 1 else i % 2] + str(i + 1) for i in range(len(d.driver.variables))) - def __cleanup(self, names_in_use: Optional[Dict[str, Any]] = None) -> None: - from math import ceil, floor - + def __cleanup(self, names_in_use=None): names_in_use = names_in_use or {} rig = self.__rig morph_sliders = self.placeholder() morph_sliders = morph_sliders.data.shape_keys.key_blocks if morph_sliders else {} for mesh_object in rig.meshes(): - for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast(Tuple[ShapeKey], ())): + for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast("Tuple[bpy.types.ShapeKey]", ())): if kb.name in names_in_use: continue if kb.name.startswith("mmd_bind"): kb.driver_remove("value") ms = morph_sliders[kb.relative_key.name] - kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, floor(ms.value)), max(ms.slider_max, ceil(ms.value)) + kb.relative_key.slider_min, kb.relative_key.slider_max = min(ms.slider_min, math.floor(ms.value)), max(ms.slider_max, math.ceil(ms.value)) kb.relative_key.value = ms.value kb.relative_key.mute = False FnObject.mesh_remove_shape_key(mesh_object, kb) @@ -444,9 +437,9 @@ class _MorphSlider: elif kb.name in morph_sliders and self.__shape_key_driver_check(kb): ms = morph_sliders[kb.name] kb.driver_remove("value") - kb.slider_min, kb.slider_max = min(ms.slider_min, floor(kb.value)), max(ms.slider_max, ceil(kb.value)) + kb.slider_min, kb.slider_max = min(ms.slider_min, math.floor(kb.value)), max(ms.slider_max, math.ceil(kb.value)) - for m in mesh_object.modifiers: # uv morph + for m in reversed(mesh_object.modifiers): # uv morph if m.name.startswith("mmd_bind") and m.name not in names_in_use: mesh_object.modifiers.remove(m) @@ -461,13 +454,13 @@ class _MorphSlider: attributes = set(TransformConstraintOp.min_max_attributes("LOCATION", "to")) attributes |= set(TransformConstraintOp.min_max_attributes("ROTATION", "to")) for b in rig.armature().pose.bones: - for c in b.constraints: + for c in reversed(b.constraints): if c.name.startswith("mmd_bind") and c.name[:-4] not in names_in_use: for attr in attributes: c.driver_remove(attr) b.constraints.remove(c) - def unbind(self) -> None: + def unbind(self): mmd_root = self.__rig.rootObject().mmd_root # after unbind, the weird lag problem will disappear. @@ -490,7 +483,7 @@ class _MorphSlider: b.driver_remove("rotation_quaternion") self.__cleanup() - def bind(self) -> None: + def bind(self): rig = self.__rig root = rig.rootObject() armObj = rig.armature() @@ -504,10 +497,10 @@ class _MorphSlider: morph_sliders = obj.data.shape_keys.key_blocks # data gathering - group_map: Dict[Tuple[str, str], List[List[Any]]] = {} + group_map = {} - shape_key_map: Dict[str, List[Tuple[ShapeKey, str, List[Any]]]] = {} - uv_morph_map: Dict[str, List[Tuple[str, str, str, List[Any]]]] = {} + shape_key_map = {} + uv_morph_map = {} for mesh_object in rig.meshes(): mesh_object.show_only_shape_key = False key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ()) @@ -528,11 +521,11 @@ class _MorphSlider: kb_bind.slider_max = 10 data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"') - groups: List[Any] = [] + groups = [] shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups)) group_map.setdefault(("vertex_morphs", kb_name), []).append(groups) - uv_layers = [l.name for l in mesh_object.data.uv_layers if not l.name.startswith("_")] + uv_layers = [layer.name for layer in mesh_object.data.uv_layers if not layer.name.startswith("_")] uv_layers += [""] * (5 - len(uv_layers)) for vg, morph_name, axis in FnMorph.get_uv_morph_vertex_groups(mesh_object): morph = mmd_root.uv_morphs.get(morph_name, None) @@ -544,7 +537,7 @@ class _MorphSlider: continue name_bind = "mmd_bind%s" % hash(vg.name) - uv_morph_map.setdefault(name_bind, []) + uv_morph_map.setdefault(name_bind, ()) mod = mesh_object.modifiers.get(name_bind, None) or mesh_object.modifiers.new(name=name_bind, type="UV_WARP") mod.show_expanded = False mod.vertex_group = vg.name @@ -557,13 +550,13 @@ class _MorphSlider: else: mod.bone_from, mod.bone_to = name_bind, "mmd_bind_ctrl_base" - bone_offset_map: Dict[str, Tuple[str, Any, str, str, List[Any]]] = {} + bone_offset_map = {} with bpyutils.edit_object(arm) as data: from .bone import FnBone edit_bones = data.edit_bones - def __get_bone(name: str, parent: Optional[bpy.types.EditBone]) -> bpy.types.EditBone: + def __get_bone(name, parent): b = edit_bones.get(name, None) or edit_bones.new(name=name) b.head = (0, 0, 0) b.tail = (0, 0, 1) @@ -580,7 +573,7 @@ class _MorphSlider: continue d.name = name_bind = f"mmd_bind{hash(d)}" b = FnBone.set_edit_bone_to_shadow(__get_bone(name_bind, None)) - groups: List[Any] = [] + groups = [] bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups) group_map.setdefault(("bone_morphs", m.name), []).append(groups) @@ -591,21 +584,21 @@ class _MorphSlider: scale_path = f'mmd_root.uv_morphs["{morph_name}"].vertex_group_scale' name_bind = f"mmd_bind{hash(m.name)}" b = FnBone.set_edit_bone_to_dummy(__get_bone(name_bind, ctrl_base)) - groups: List[Any] = [] + groups = [] uv_morph_map.setdefault(name_bind, []).append((b.name, data_path, scale_path, groups)) group_map.setdefault(("uv_morphs", m.name), []).append(groups) - used_bone_names: Set[str] = set(bone_offset_map.keys()) | set(uv_morph_map.keys()) + used_bone_names = bone_offset_map.keys() | uv_morph_map.keys() used_bone_names.add(ctrl_base.name) - for b in edit_bones: # cleanup + for b in reversed(edit_bones): # cleanup if b.name.startswith("mmd_bind") and b.name not in used_bone_names: edit_bones.remove(b) - material_offset_map: Dict[str, Any] = {} + material_offset_map = {} for m in mmd_root.material_morphs: morph_name = m.name.replace('"', '\\"') data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' - groups: List[Any] = [] + groups = [] group_map.setdefault(("material_morphs", m.name), []).append(groups) material_offset_map.setdefault("group_dict", {})[m.name] = (data_path, groups) for d in m.data: @@ -616,7 +609,7 @@ class _MorphSlider: for m in mmd_root.group_morphs: if len(m.data) != len(set(m.data.keys())): - logger.warning('Found duplicated morph data in Group Morph "%s"', m.name) + logger.warning(' * Found duplicated morph data in Group Morph "%s"', m.name) morph_name = m.name.replace('"', '\\"') morph_path = f'data.shape_keys.key_blocks["{morph_name}"].value' for d in m.data: @@ -627,7 +620,7 @@ class _MorphSlider: self.__cleanup(shape_key_map.keys() | bone_offset_map.keys() | uv_morph_map.keys()) - def __config_groups(variables: Any, expression: str, groups: List[Any]) -> str: + def __config_groups(variables, expression, groups): for g_name, morph_path, factor_path in groups: var = self.__add_single_prop(variables, obj, morph_path, "g") fvar = self.__add_single_prop(variables, root, factor_path, "w") @@ -635,7 +628,7 @@ class _MorphSlider: return expression # vertex morphs - for kb_bind, morph_data_path, groups in (i for l in shape_key_map.values() for i in l): + for kb_bind, morph_data_path, groups in (i for value_list in shape_key_map.values() for i in value_list): driver, variables = self.__driver_variables(kb_bind, "value") var = self.__add_single_prop(variables, obj, morph_data_path, "v") if kb_bind.name.startswith("mmd_bind"): @@ -646,7 +639,7 @@ class _MorphSlider: kb_bind.mute = False # bone morphs - def __config_bone_morph(constraints: bpy.types.ArmatureConstraints, map_type: str, attributes: Set[str], val: float, val_str: str) -> None: + def __config_bone_morph(constraints, map_type, attributes, val, val_str): c_name = f"mmd_bind{hash(data)}.{map_type[:3]}" c = TransformConstraintOp.create(constraints, c_name, map_type) TransformConstraintOp.update_min_max(c, val, None) @@ -660,8 +653,6 @@ class _MorphSlider: sign = "-" if attr.startswith("to_min") else "" driver.expression = f"{sign}{val_str}*({expression})" - from math import pi - attributes_rot = TransformConstraintOp.min_max_attributes("ROTATION", "to") attributes_loc = TransformConstraintOp.min_max_attributes("LOCATION", "to") for morph_name, data, bname, morph_data_path, groups in bone_offset_map.values(): @@ -671,7 +662,7 @@ class _MorphSlider: b.is_mmd_shadow_bone = True b.mmd_shadow_bone_type = "BIND" pb = armObj.pose.bones[data.bone] - __config_bone_morph(pb.constraints, "ROTATION", attributes_rot, pi, "pi") + __config_bone_morph(pb.constraints, "ROTATION", attributes_rot, math.pi, "pi") __config_bone_morph(pb.constraints, "LOCATION", attributes_loc, 100, "100") # uv morphs @@ -680,7 +671,7 @@ class _MorphSlider: b = arm.pose.bones["mmd_bind_ctrl_base"] b.is_mmd_shadow_bone = True b.mmd_shadow_bone_type = "BIND" - for bname, data_path, scale_path, groups in (i for l in uv_morph_map.values() for i in l): + for bname, data_path, scale_path, groups in (i for value_list in uv_morph_map.values() for i in value_list): b = arm.pose.bones[bname] b.is_mmd_shadow_bone = True b.mmd_shadow_bone_type = "BIND" @@ -694,9 +685,9 @@ class _MorphSlider: group_dict = material_offset_map.get("group_dict", {}) - def __config_material_morph(mat: Material, morph_list: List[Tuple[str, Any, str]]) -> None: + def __config_material_morph(mat, morph_list): nodes = _MaterialMorph.setup_morph_nodes(mat, tuple(x[1] for x in morph_list)) - for (morph_name, data, name_bind), node in zip(morph_list, nodes): + for (morph_name, data, name_bind), node in zip(morph_list, nodes, strict=False): node.label, node.name = morph_name, name_bind data_path, groups = group_dict[morph_name] driver, variables = self.__driver_variables(mat.node_tree, node.inputs[0].path_from_id("default_value")) @@ -706,7 +697,7 @@ class _MorphSlider: for mat in (m for m in rig.materials() if m and m.use_nodes and not m.name.startswith("mmd_")): mul_all, add_all = material_offset_map.get("#", ([], [])) if mat.name == "": - logger.warning("Oh no. The material name should never be empty.") + logger.warning("Oh no. The material name should never empty.") mul_list, add_list = [], [] else: mat_name = "#" + mat.name @@ -722,7 +713,7 @@ class _MorphSlider: class MigrationFnMorph: @staticmethod - def update_mmd_morph() -> None: + def update_mmd_morph(): from .material import FnMaterial for root in bpy.data.objects: @@ -733,7 +724,7 @@ class MigrationFnMorph: for morph_data in mat_morph.data: if morph_data.material_data is not None: # SUPPORT_UNTIL: 5 LTS - # The material_id is also no longer used, but for compatibility with older version mmd_tools, keep it. + # The material_id is also no longer used, but for compatibility with older version mmd_tools_local, keep it. if "material_id" not in morph_data.material_data.mmd_material or "material_id" not in morph_data or morph_data.material_data.mmd_material["material_id"] == morph_data["material_id"]: # In the new version, the related_mesh property is no longer used. # Explicitly remove this property to avoid misuse. @@ -741,15 +732,14 @@ class MigrationFnMorph: del morph_data["related_mesh"] continue - else: - # Compat case. The new version mmd_tools saved. And old version mmd_tools edit. Then new version mmd_tools load again. - # Go update path. - pass + # Compat case. The new version mmd_tools_local saved. And old version mmd_tools_local edit. Then new version mmd_tools_local load again. + # Go update path. + pass morph_data.material_data = None if "material_id" in morph_data: mat_id = morph_data["material_id"] - if mat_id != -1: + if mat_id >= 0: fnMat = FnMaterial.from_material_id(mat_id) if fnMat: morph_data.material_data = fnMat.material @@ -764,11 +754,11 @@ class MigrationFnMorph: morph_data.related_mesh_data = bpy.data.meshes[related_mesh] @staticmethod - def ensure_material_id_not_conflict() -> None: - mat_ids_set: Set[int] = set() + def ensure_material_id_not_conflict(): + mat_ids_set = set() # The reference library properties cannot be modified and bypassed in advance. - need_update_mat: List[Material] = [] + need_update_mat = [] for mat in bpy.data.materials: if mat.mmd_material.material_id < 0: continue @@ -783,7 +773,7 @@ class MigrationFnMorph: mat_ids_set.add(mat.mmd_material.material_id) @staticmethod - def compatible_with_old_version_mmd_tools() -> None: + def compatible_with_old_version_mmd_tools_local(): MigrationFnMorph.ensure_material_id_not_conflict() for root in bpy.data.objects: diff --git a/core/mmd/core/pmx/__init__.py b/core/mmd/core/pmx/__init__.py index 7de70bd..36993b5 100644 --- a/core/mmd/core/pmx/__init__.py +++ b/core/mmd/core/pmx/__init__.py @@ -5,7 +5,7 @@ # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. -import logging +from .....core.logging_setup import logger import os import struct @@ -40,7 +40,7 @@ class FileStream: def close(self): if self.__file_obj is not None: - logging.debug('close the file("%s")', self.__path) + logger.debug('close the file("%s")', self.__path) self.__file_obj.close() self.__file_obj = None @@ -260,20 +260,20 @@ class Header: return 4 def load(self, fs): - logging.info('loading pmx header information...') + logger.info('loading pmx header information...') self.sign = fs.readBytes(4) - logging.debug('File signature is %s', self.sign) + logger.debug('File signature is %s', self.sign) if self.sign[:3] != self.PMX_SIGN[:3]: - logging.info('File signature is invalid') - logging.error('This file is unsupported format, or corrupt file.') + logger.info('File signature is invalid') + logger.error('This file is unsupported format, or corrupt file.') raise InvalidFileError('File signature is invalid.') self.version = fs.readFloat() - logging.info('pmx format version: %f', self.version) + logger.info('pmx format version: %f', self.version) if self.version != self.VERSION: - logging.error('PMX version %.1f is unsupported', self.version) + logger.error('PMX version %.1f is unsupported', self.version) raise UnsupportedVersionError('unsupported PMX version: %.1f'%self.version) if fs.readByte() != 8 or self.sign[3] != self.PMX_SIGN[3]: - logging.warning(' * This file might be corrupted.') + logger.warning(' * This file might be corrupted.') self.encoding = Encoding(fs.readByte()) self.additional_uvs = fs.readByte() self.vertex_index_size = fs.readByte() @@ -283,19 +283,19 @@ class Header: self.morph_index_size = fs.readByte() self.rigid_index_size = fs.readByte() - logging.info('----------------------------') - logging.info('pmx header information') - logging.info('----------------------------') - logging.info('pmx version: %.1f', self.version) - logging.info('encoding: %s', str(self.encoding)) - logging.info('number of uvs: %d', self.additional_uvs) - logging.info('vertex index size: %d byte(s)', self.vertex_index_size) - logging.info('texture index: %d byte(s)', self.texture_index_size) - logging.info('material index: %d byte(s)', self.material_index_size) - logging.info('bone index: %d byte(s)', self.bone_index_size) - logging.info('morph index: %d byte(s)', self.morph_index_size) - logging.info('rigid index: %d byte(s)', self.rigid_index_size) - logging.info('----------------------------') + logger.info('----------------------------') + logger.info('pmx header information') + logger.info('----------------------------') + logger.info('pmx version: %.1f', self.version) + logger.info('encoding: %s', str(self.encoding)) + logger.info('number of uvs: %d', self.additional_uvs) + logger.info('vertex index size: %d byte(s)', self.vertex_index_size) + logger.info('texture index: %d byte(s)', self.texture_index_size) + logger.info('material index: %d byte(s)', self.material_index_size) + logger.info('bone index: %d byte(s)', self.bone_index_size) + logger.info('morph index: %d byte(s)', self.morph_index_size) + logger.info('rigid index: %d byte(s)', self.rigid_index_size) + logger.info('----------------------------') def save(self, fs): fs.writeBytes(self.PMX_SIGN) @@ -364,27 +364,27 @@ class Model: self.comment = fs.readStr() self.comment_e = fs.readStr() - logging.info('Model name: %s', self.name) - logging.info('Model name(english): %s', self.name_e) - logging.info('Comment:%s', self.comment) - logging.info('Comment(english):%s', self.comment_e) + logger.info('Model name: %s', self.name) + logger.info('Model name(english): %s', self.name_e) + logger.info('Comment:%s', self.comment) + logger.info('Comment(english):%s', self.comment_e) - logging.info('') - logging.info('------------------------------') - logging.info('Load Vertices') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info('Load Vertices') + logger.info('------------------------------') num_vertices = fs.readInt() self.vertices = [] for i in range(num_vertices): v = Vertex() v.load(fs) self.vertices.append(v) - logging.info('----- Loaded %d vertices', len(self.vertices)) + logger.info('----- Loaded %d vertices', len(self.vertices)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Faces') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Faces') + logger.info('------------------------------') num_faces = fs.readInt() self.faces = [] for i in range(int(num_faces/3)): @@ -392,25 +392,25 @@ class Model: f2 = fs.readVertexIndex() f3 = fs.readVertexIndex() self.faces.append((f3, f2, f1)) - logging.info(' Load %d faces', len(self.faces)) + logger.info(' Load %d faces', len(self.faces)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Textures') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Textures') + logger.info('------------------------------') num_textures = fs.readInt() self.textures = [] for i in range(num_textures): t = Texture() t.load(fs) self.textures.append(t) - logging.info('Texture %d: %s', i, t.path) - logging.info(' ----- Loaded %d textures', len(self.textures)) + logger.info('Texture %d: %s', i, t.path) + logger.info(' ----- Loaded %d textures', len(self.textures)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Materials') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Materials') + logger.info('------------------------------') num_materials = fs.readInt() self.materials = [] for i in range(num_materials): @@ -418,38 +418,38 @@ class Model: m.load(fs, num_textures) self.materials.append(m) - logging.info('Material %d: %s', i, m.name) - logging.debug(' Name(english): %s', m.name_e) - logging.debug(' Comment: %s', m.comment) - logging.debug(' Vertex Count: %d', m.vertex_count) - logging.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse) - logging.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular) - logging.debug(' Shininess: %f', m.shininess) - logging.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient) - logging.debug(' Double Sided: %s', str(m.is_double_sided)) - logging.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow)) - logging.debug(' Self Shadow: %s', str(m.enabled_self_shadow)) - logging.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map)) - logging.debug(' Edge: %s', str(m.enabled_toon_edge)) - logging.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color) - logging.debug(' Edge Size: %.2f', m.edge_size) + logger.info('Material %d: %s', i, m.name) + logger.debug(' Name(english): %s', m.name_e) + logger.debug(' Comment: %s', m.comment) + logger.debug(' Vertex Count: %d', m.vertex_count) + logger.debug(' Diffuse: (%.2f, %.2f, %.2f, %.2f)', *m.diffuse) + logger.debug(' Specular: (%.2f, %.2f, %.2f)', *m.specular) + logger.debug(' Shininess: %f', m.shininess) + logger.debug(' Ambient: (%.2f, %.2f, %.2f)', *m.ambient) + logger.debug(' Double Sided: %s', str(m.is_double_sided)) + logger.debug(' Drop Shadow: %s', str(m.enabled_drop_shadow)) + logger.debug(' Self Shadow: %s', str(m.enabled_self_shadow)) + logger.debug(' Self Shadow Map: %s', str(m.enabled_self_shadow_map)) + logger.debug(' Edge: %s', str(m.enabled_toon_edge)) + logger.debug(' Edge Color: (%.2f, %.2f, %.2f, %.2f)', *m.edge_color) + logger.debug(' Edge Size: %.2f', m.edge_size) if m.texture != -1: - logging.debug(' Texture Index: %d', m.texture) + logger.debug(' Texture Index: %d', m.texture) else: - logging.debug(' Texture: None') + logger.debug(' Texture: None') if m.sphere_texture != -1: - logging.debug(' Sphere Texture Index: %d', m.sphere_texture) - logging.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode) + logger.debug(' Sphere Texture Index: %d', m.sphere_texture) + logger.debug(' Sphere Texture Mode: %d', m.sphere_texture_mode) else: - logging.debug(' Sphere Texture: None') - logging.debug('') + logger.debug(' Sphere Texture: None') + logger.debug('') - logging.info('----- Loaded %d materials.', len(self.materials)) + logger.info('----- Loaded %d materials.', len(self.materials)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Bones') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Bones') + logger.info('------------------------------') num_bones = fs.readInt() self.bones = [] for i in range(num_bones): @@ -457,33 +457,33 @@ class Model: b.load(fs) self.bones.append(b) - logging.info('Bone %d: %s', i, b.name) - logging.debug(' Name(english): %s', b.name_e) - logging.debug(' Location: (%f, %f, %f)', *b.location) - logging.debug(' displayConnection: %s', str(b.displayConnection)) - logging.debug(' Parent: %s', str(b.parent)) - logging.debug(' Transform Order: %s', str(b.transform_order)) - logging.debug(' Rotatable: %s', str(b.isRotatable)) - logging.debug(' Movable: %s', str(b.isMovable)) - logging.debug(' Visible: %s', str(b.visible)) - logging.debug(' Controllable: %s', str(b.isControllable)) - logging.debug(' Additional Location: %s', str(b.hasAdditionalLocation)) - logging.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate)) + logger.info('Bone %d: %s', i, b.name) + logger.debug(' Name(english): %s', b.name_e) + logger.debug(' Location: (%f, %f, %f)', *b.location) + logger.debug(' displayConnection: %s', str(b.displayConnection)) + logger.debug(' Parent: %s', str(b.parent)) + logger.debug(' Transform Order: %s', str(b.transform_order)) + logger.debug(' Rotatable: %s', str(b.isRotatable)) + logger.debug(' Movable: %s', str(b.isMovable)) + logger.debug(' Visible: %s', str(b.visible)) + logger.debug(' Controllable: %s', str(b.isControllable)) + logger.debug(' Additional Location: %s', str(b.hasAdditionalLocation)) + logger.debug(' Additional Rotation: %s', str(b.hasAdditionalRotate)) if b.additionalTransform is not None: - logging.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform) - logging.debug(' IK: %s', str(b.isIK)) + logger.debug(' Additional Transform: Bone:%d, influence: %f', *b.additionalTransform) + logger.debug(' IK: %s', str(b.isIK)) if b.isIK: - logging.debug(' Unit Angle: %f', b.rotationConstraint) - logging.debug(' Target: %d', b.target) + logger.debug(' Unit Angle: %f', b.rotationConstraint) + logger.debug(' Target: %d', b.target) for j, link in enumerate(b.ik_links): - logging.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle)) - logging.debug('') - logging.info('----- Loaded %d bones.', len(self.bones)) + logger.debug(' IK Link %d: %d, %s - %s', j, link.target, str(link.minimumAngle), str(link.maximumAngle)) + logger.debug('') + logger.info('----- Loaded %d bones.', len(self.bones)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Morphs') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Morphs') + logger.info('------------------------------') num_morph = fs.readInt() self.morphs = [] display_categories = {0: 'System', 1: 'Eyebrow', 2: 'Eye', 3: 'Mouth', 4: 'Other'} @@ -491,16 +491,16 @@ class Model: m = Morph.create(fs) self.morphs.append(m) - logging.info('%s %d: %s', m.__class__.__name__, i, m.name) - logging.debug(' Name(english): %s', m.name_e) - logging.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category) - logging.debug('') - logging.info('----- Loaded %d morphs.', len(self.morphs)) + logger.info('%s %d: %s', m.__class__.__name__, i, m.name) + logger.debug(' Name(english): %s', m.name_e) + logger.debug(' Category: %s (%d)', display_categories.get(m.category, '#Invalid'), m.category) + logger.debug('') + logger.info('----- Loaded %d morphs.', len(self.morphs)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Display Items') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Display Items') + logger.info('------------------------------') num_disp = fs.readInt() self.display = [] for i in range(num_disp): @@ -508,15 +508,15 @@ class Model: d.load(fs) self.display.append(d) - logging.info('Display Item %d: %s', i, d.name) - logging.debug(' Name(english): %s', d.name_e) - logging.debug('') - logging.info('----- Loaded %d display items.', len(self.display)) + logger.info('Display Item %d: %s', i, d.name) + logger.debug(' Name(english): %s', d.name_e) + logger.debug('') + logger.info('----- Loaded %d display items.', len(self.display)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Rigid Bodies') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Rigid Bodies') + logger.info('------------------------------') num_rigid = fs.readInt() self.rigids = [] rigid_types = {0: 'Sphere', 1: 'Box', 2: 'Capsule'} @@ -525,27 +525,27 @@ class Model: r = Rigid() r.load(fs) self.rigids.append(r) - logging.info('Rigid Body %d: %s', i, r.name) - logging.debug(' Name(english): %s', r.name_e) - logging.debug(' Type: %s', rigid_types[r.type]) - logging.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode) - logging.debug(' Related bone: %s', r.bone) - logging.debug(' Collision group: %d', r.collision_group_number) - logging.debug(' Collision group mask: 0x%x', r.collision_group_mask) - logging.debug(' Size: (%f, %f, %f)', *r.size) - logging.debug(' Location: (%f, %f, %f)', *r.location) - logging.debug(' Rotation: (%f, %f, %f)', *r.rotation) - logging.debug(' Mass: %f', r.mass) - logging.debug(' Bounce: %f', r.bounce) - logging.debug(' Friction: %f', r.friction) - logging.debug('') + logger.info('Rigid Body %d: %s', i, r.name) + logger.debug(' Name(english): %s', r.name_e) + logger.debug(' Type: %s', rigid_types[r.type]) + logger.debug(' Mode: %s (%d)', rigid_modes.get(r.mode, '#Invalid'), r.mode) + logger.debug(' Related bone: %s', r.bone) + logger.debug(' Collision group: %d', r.collision_group_number) + logger.debug(' Collision group mask: 0x%x', r.collision_group_mask) + logger.debug(' Size: (%f, %f, %f)', *r.size) + logger.debug(' Location: (%f, %f, %f)', *r.location) + logger.debug(' Rotation: (%f, %f, %f)', *r.rotation) + logger.debug(' Mass: %f', r.mass) + logger.debug(' Bounce: %f', r.bounce) + logger.debug(' Friction: %f', r.friction) + logger.debug('') - logging.info('----- Loaded %d rigid bodies.', len(self.rigids)) + logger.info('----- Loaded %d rigid bodies.', len(self.rigids)) - logging.info('') - logging.info('------------------------------') - logging.info(' Load Joints') - logging.info('------------------------------') + logger.info('') + logger.info('------------------------------') + logger.info(' Load Joints') + logger.info('------------------------------') num_joints = fs.readInt() self.joints = [] for i in range(num_joints): @@ -553,19 +553,19 @@ class Model: j.load(fs) self.joints.append(j) - logging.info('Joint %d: %s', i, j.name) - logging.debug(' Name(english): %s', j.name_e) - logging.debug(' Rigid A: %s', j.src_rigid) - logging.debug(' Rigid B: %s', j.dest_rigid) - logging.debug(' Location: (%f, %f, %f)', *j.location) - logging.debug(' Rotation: (%f, %f, %f)', *j.rotation) - logging.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location)) - logging.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation)) - logging.debug(' Spring: (%f, %f, %f)', *j.spring_constant) - logging.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant) - logging.debug('') + logger.info('Joint %d: %s', i, j.name) + logger.debug(' Name(english): %s', j.name_e) + logger.debug(' Rigid A: %s', j.src_rigid) + logger.debug(' Rigid B: %s', j.dest_rigid) + logger.debug(' Location: (%f, %f, %f)', *j.location) + logger.debug(' Rotation: (%f, %f, %f)', *j.rotation) + logger.debug(' Location Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_location + j.maximum_location)) + logger.debug(' Rotation Limit: (%f, %f, %f) - (%f, %f, %f)', *(j.minimum_rotation + j.maximum_rotation)) + logger.debug(' Spring: (%f, %f, %f)', *j.spring_constant) + logger.debug(' Spring(rotation): (%f, %f, %f)', *j.spring_rotation_constant) + logger.debug('') - logging.info('----- Loaded %d joints.', len(self.joints)) + logger.info('----- Loaded %d joints.', len(self.joints)) def save(self, fs): fs.writeStr(self.name) @@ -574,7 +574,7 @@ class Model: fs.writeStr(self.comment) fs.writeStr(self.comment_e) - logging.info('''exportings pmx model data... + logger.info('''exportings pmx model data... name: %s name(english): %s comment: @@ -583,62 +583,62 @@ comment(english): %s ''', self.name, self.name_e, self.comment, self.comment_e) - logging.info('exporting vertices... %d', len(self.vertices)) + logger.info('exporting vertices... %d', len(self.vertices)) fs.writeInt(len(self.vertices)) for i in self.vertices: i.save(fs) - logging.info('finished exporting vertices.') + logger.info('finished exporting vertices.') - logging.info('exporting faces... %d', len(self.faces)) + logger.info('exporting faces... %d', len(self.faces)) fs.writeInt(len(self.faces)*3) for f3, f2, f1 in self.faces: fs.writeVertexIndex(f1) fs.writeVertexIndex(f2) fs.writeVertexIndex(f3) - logging.info('finished exporting faces.') + logger.info('finished exporting faces.') - logging.info('exporting textures... %d', len(self.textures)) + logger.info('exporting textures... %d', len(self.textures)) fs.writeInt(len(self.textures)) for i in self.textures: i.save(fs) - logging.info('finished exporting textures.') + logger.info('finished exporting textures.') - logging.info('exporting materials... %d', len(self.materials)) + logger.info('exporting materials... %d', len(self.materials)) fs.writeInt(len(self.materials)) for i in self.materials: i.save(fs) - logging.info('finished exporting materials.') + logger.info('finished exporting materials.') - logging.info('exporting bones... %d', len(self.bones)) + logger.info('exporting bones... %d', len(self.bones)) fs.writeInt(len(self.bones)) for i in self.bones: i.save(fs) - logging.info('finished exporting bones.') + logger.info('finished exporting bones.') - logging.info('exporting morphs... %d', len(self.morphs)) + logger.info('exporting morphs... %d', len(self.morphs)) fs.writeInt(len(self.morphs)) for i in self.morphs: i.save(fs) - logging.info('finished exporting morphs.') + logger.info('finished exporting morphs.') - logging.info('exporting display items... %d', len(self.display)) + logger.info('exporting display items... %d', len(self.display)) fs.writeInt(len(self.display)) for i in self.display: i.save(fs) - logging.info('finished exporting display items.') + logger.info('finished exporting display items.') - logging.info('exporting rigid bodies... %d', len(self.rigids)) + logger.info('exporting rigid bodies... %d', len(self.rigids)) fs.writeInt(len(self.rigids)) for i in self.rigids: i.save(fs) - logging.info('finished exporting rigid bodies.') + logger.info('finished exporting rigid bodies.') - logging.info('exporting joints... %d', len(self.joints)) + logger.info('exporting joints... %d', len(self.joints)) fs.writeInt(len(self.joints)) for i in self.joints: i.save(fs) - logging.info('finished exporting joints.') - logging.info('finished exporting the model.') + logger.info('finished exporting joints.') + logger.info('finished exporting the model.') def __repr__(self): @@ -803,7 +803,7 @@ class Texture: except ValueError: relPath = self.path relPath = relPath.replace(os.path.sep, '\\') # always save using windows path conventions - logging.info('writing to pmx file the relative texture path: %s', relPath) + logger.info('writing to pmx file the relative texture path: %s', relPath) fs.writeStr(relPath) class SharedTexture(Texture): @@ -1170,7 +1170,7 @@ class Morph: name = fs.readStr() name_e = fs.readStr() - logging.debug('morph: %s', name) + logger.debug('morph: %s', name) category = fs.readSignedByte() typeIndex = fs.readSignedByte() ret = _CLASSES[typeIndex](name, name_e, category, type_index = typeIndex) @@ -1399,7 +1399,7 @@ class Display: else: raise Exception('invalid value.') self.data.append((disp_type, index)) - logging.debug('the number of display elements: %d', len(self.data)) + logger.debug('the number of display elements: %d', len(self.data)) def save(self, fs): fs.writeStr(self.name) @@ -1595,12 +1595,12 @@ class Joint: def load(path): with FileReadStream(path) as fs: - logging.info('****************************************') - logging.info(' mmd_tools.pmx module') - logging.info('----------------------------------------') - logging.info(' Start to load model data form a pmx file') - logging.info(' by the mmd_tools.pmx modlue.') - logging.info('') + logger.info('****************************************') + logger.info(' mmd_tools.pmx module') + logger.info('----------------------------------------') + logger.info(' Start to load model data form a pmx file') + logger.info(' by the mmd_tools.pmx modlue.') + logger.info('') header = Header() header.load(fs) fs.setHeader(header) @@ -1608,12 +1608,12 @@ def load(path): try: model.load(fs) except struct.error as e: - logging.error(' * Corrupted file: %s', e) + logger.error(' * Corrupted file: %s', e) #raise - logging.info(' Finished loading.') - logging.info('----------------------------------------') - logging.info(' mmd_tools.pmx module') - logging.info('****************************************') + logger.info(' Finished loading.') + logger.info('----------------------------------------') + logger.info(' mmd_tools.pmx module') + logger.info('****************************************') return model def save(path, model, add_uv_count=0): diff --git a/core/mmd/core/pmx/importer.py b/core/mmd/core/pmx/importer.py index dd601ea..1349dc7 100644 --- a/core/mmd/core/pmx/importer.py +++ b/core/mmd/core/pmx/importer.py @@ -6,6 +6,7 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import collections +import math import os import time from typing import TYPE_CHECKING, List, Optional, Dict, Tuple, Set, Callable, Any, Union, FrozenSet, Iterator @@ -103,7 +104,7 @@ class PMXImporter: obj_name = self.__safe_name(bpy.path.display_name(pmxModel.filepath), max_length=54) logger.info(f"Creating objects for model: {obj_name}") - self.__rig = Model.create(pmxModel.name, pmxModel.name_e, self.__scale or 1.0, obj_name) + self.__rig = Model.create(pmxModel.name, pmxModel.name_e, self.__scale, obj_name) root = self.__rig.rootObject() mmd_root: 'MMDRoot' = root.mmd_root self.__root = root @@ -192,7 +193,7 @@ class PMXImporter: mesh: Mesh = self.__meshObj.data mesh.vertices.add(count=vertex_count) - mesh.vertices.foreach_set("co", tuple(i for pv in pmx_vertices for i in (Vector(pv.co).xzy * (self.__scale or 1.0)))) + mesh.vertices.foreach_set("co", tuple(i for pv in pmx_vertices for i in (Vector(pv.co).xzy * self.__scale))) vertex_group_table = self.__vertexGroupTable if not vertex_group_table: @@ -249,9 +250,9 @@ class PMXImporter: for i, pv in self.__sdefVertices.items(): w = pv.weight.weights - sdefC.data[i].co = Vector(w.c).xzy * (self.__scale or 1.0) - sdefR0.data[i].co = Vector(w.r0).xzy * (self.__scale or 1.0) - sdefR1.data[i].co = Vector(w.r1).xzy * (self.__scale or 1.0) + sdefC.data[i].co = Vector(w.c).xzy * self.__scale + sdefR0.data[i].co = Vector(w.r0).xzy * self.__scale + sdefR1.data[i].co = Vector(w.r1).xzy * self.__scale logger.debug(f"Stored {len(self.__sdefVertices)} SDEF vertices in shape keys") @@ -290,13 +291,13 @@ class PMXImporter: # Create bones for i in pmx_bones: bone = data.edit_bones.new(name=i.name) - loc = _VectorXZY(i.location) * (self.__scale or 1.0) + loc = _VectorXZY(i.location) * self.__scale bone.head = loc editBoneTable.append(bone) nameTable.append(bone.name) # Set parent relationships - for i, (b_bone, m_bone) in enumerate(zip(editBoneTable, pmx_bones)): + for i, (b_bone, m_bone) in enumerate(zip(editBoneTable, pmx_bones, strict=False)): if m_bone.parent != -1: if i not in dependency_cycle_ik_bones: b_bone.parent = editBoneTable[m_bone.parent] @@ -304,18 +305,18 @@ class PMXImporter: b_bone.parent = editBoneTable[m_bone.parent].parent # Set tail positions - for b_bone, m_bone in zip(editBoneTable, pmx_bones): + for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False): if isinstance(m_bone.displayConnection, int): if m_bone.displayConnection != -1: b_bone.tail = editBoneTable[m_bone.displayConnection].head else: b_bone.tail = b_bone.head else: - loc = _VectorXZY(m_bone.displayConnection) * (self.__scale or 1.0) + loc = _VectorXZY(m_bone.displayConnection) * self.__scale b_bone.tail = b_bone.head + loc # Check and fix IK links - for b_bone, m_bone in zip(editBoneTable, pmx_bones): + for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False): if m_bone.isIK and m_bone.target != -1: logger.debug(f"Checking IK links of {b_bone.name}") b_target = editBoneTable[m_bone.target] @@ -333,30 +334,30 @@ class PMXImporter: b_bone_link.tail = b_bone_link.head + loc # Fix too short bones - for b_bone, m_bone in zip(editBoneTable, pmx_bones): + for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False): # Set the length of too short bones to 1 because Blender delete them. if b_bone.length < 0.001: if not self.__apply_bone_fixed_axis and m_bone.axis is not None: fixed_axis = Vector(m_bone.axis) if fixed_axis.length: - b_bone.tail = b_bone.head + fixed_axis.xzy.normalized() * (self.__scale or 1.0) + b_bone.tail = b_bone.head + fixed_axis.xzy.normalized() * self.__scale else: - b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0) + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale else: - b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0) + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale if m_bone.displayConnection != -1 and m_bone.displayConnection != [0.0, 0.0, 0.0]: logger.debug(f"Special tip bone {b_bone.name}, display {str(m_bone.displayConnection)}") specialTipBones.append(b_bone.name) # Update bone roll - for b_bone, m_bone in zip(editBoneTable, pmx_bones): + for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False): if m_bone.localCoordinate is not None: FnBone.update_bone_roll(b_bone, m_bone.localCoordinate.x_axis, m_bone.localCoordinate.z_axis) elif FnBone.has_auto_local_axis(m_bone.name): FnBone.update_auto_bone_roll(b_bone) # Set bone connections - for b_bone, m_bone in zip(editBoneTable, pmx_bones): + for b_bone, m_bone in zip(editBoneTable, pmx_bones, strict=False): if isinstance(m_bone.displayConnection, int) and m_bone.displayConnection >= 0: t = editBoneTable[m_bone.displayConnection] if t.parent is None or t.parent != b_bone: @@ -590,7 +591,7 @@ class PMXImporter: ) for i, (rigid, rigid_obj) in enumerate(zip(self.__model.rigids, rigid_pool)): - loc = Vector(rigid.location).xzy * (self.__scale or 1.0) + loc = Vector(rigid.location).xzy * self.__scale rot = Vector(rigid.rotation).xzy * -1 size = Vector(rigid.size).xzy if rigid.type == pmx.Rigid.TYPE_BOX else Vector(rigid.size) @@ -599,7 +600,7 @@ class PMXImporter: shape_type=rigid.type, location=loc, rotation=rot, - size=size * (self.__scale or 1.0), + size=size * self.__scale, dynamics_type=rigid.mode, name=rigid.name, name_e=rigid.name_e, @@ -637,7 +638,7 @@ class PMXImporter: ) for i, (joint, joint_obj) in enumerate(zip(self.__model.joints, joint_pool)): - loc = Vector(joint.location).xzy * (self.__scale or 1.0) + loc = Vector(joint.location).xzy * self.__scale rot = Vector(joint.rotation).xzy * -1 obj = FnRigidBody.setup_joint_object( @@ -648,8 +649,8 @@ class PMXImporter: rotation=rot, rigid_a=self.__rigidTable.get(joint.src_rigid, None), rigid_b=self.__rigidTable.get(joint.dest_rigid, None), - maximum_location=Vector(joint.maximum_location).xzy * (self.__scale or 1.0), - minimum_location=Vector(joint.minimum_location).xzy * (self.__scale or 1.0), + maximum_location=Vector(joint.maximum_location).xzy * self.__scale, + minimum_location=Vector(joint.minimum_location).xzy * self.__scale, maximum_rotation=Vector(joint.minimum_rotation).xzy * -1, minimum_rotation=Vector(joint.maximum_rotation).xzy * -1, spring_linear=Vector(joint.spring_constant).xzy, @@ -746,18 +747,22 @@ class PMXImporter: mesh.polygons.foreach_set("use_smooth", (True,) * len(pmxModel.faces)) mesh.polygons.foreach_set("material_index", material_indices) - uv_layers = mesh.uv_layers - uv_layer = uv_layers.new() + uv_textures, uv_layers = getattr(mesh, "uv_textures", mesh.uv_layers), mesh.uv_layers + uv_tex = uv_textures.new() + uv_layer = uv_layers[uv_tex.name] uv_table = {vi: self.flipUV_V(v.uv) for vi, v in enumerate(pmxModel.vertices)} uv_layer.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in uv_table[i])) + if hasattr(mesh, "uv_textures"): + for bf, mi in zip(uv_tex.data, material_indices, strict=False): + bf.image = self.__imageTable.get(mi, None) if pmxModel.header and pmxModel.header.additional_uvs: logger.info(f"Importing {pmxModel.header.additional_uvs} additional UVs") zw_data_map = collections.OrderedDict() split_uvzw = lambda uvi: (self.flipUV_V(uvi[:2]), uvi[2:]) for i in range(pmxModel.header.additional_uvs): - add_uv = uv_layers.new(name="UV" + str(i + 1)) + add_uv = uv_layers[uv_textures.new(name="UV" + str(i + 1)).name] logger.info(f" - {add_uv.name}...(uv channels)") uv_table = {vi: split_uvzw(v.additional_uvs[i]) for vi, v in enumerate(pmxModel.vertices)} add_uv.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in uv_table[i][0])) @@ -767,10 +772,11 @@ class PMXImporter: zw_data_map["_" + add_uv.name] = {k: self.flipUV_V(v[1]) for k, v in uv_table.items()} for name, zw_table in zw_data_map.items(): logger.info(f" - {name}...(zw channels of {name[1:]})") - add_zw = uv_layers.new(name=name) + add_zw = uv_textures.new(name=name) if add_zw is None: logger.warning("\t* Lost zw channels") continue + add_zw = uv_layers[add_zw.name] add_zw.data.foreach_set("uv", tuple(v for i in loop_indices_orig for v in zw_table[i])) self.__fixOverlappingFaceMaterials(mesh.materials, mesh.vertices, loop_indices, material_indices) @@ -825,14 +831,18 @@ class PMXImporter: logger.debug(f"Found {len(vertex_morphs)} vertex morphs") for morph in vertex_morphs: - shapeKey = self.__meshObj.shape_key_add(name=morph.name) + shapeKey = self.__meshObj.shape_key_add(name=morph.name, from_mix=False) + shapeKey.value = 0.0 # Set shape key value to 0 (inactive) on import vtx_morph = mmd_root.vertex_morphs.add() vtx_morph.name = morph.name vtx_morph.name_e = morph.name_e vtx_morph.category = categories.get(morph.category, "OTHER") for md in morph.offsets: - shapeKeyPoint = shapeKey.data[md.index] - shapeKeyPoint.co += Vector(md.offset).xzy * (self.__scale or 1.0) + if md.index < len(shapeKey.data): + shapeKeyPoint = shapeKey.data[md.index] + shapeKeyPoint.co += Vector(md.offset).xzy * self.__scale + else: + logger.warning(f"Morph {morph.name} has out-of-range vertex index: {md.index}") logger.debug(f"Imported vertex morph: {morph.name} with {len(morph.offsets)} offsets") def __importMaterialMorphs(self) -> None: @@ -893,7 +903,7 @@ class PMXImporter: data = bone_morph.data.add() bl_bone = self.__boneTable[morph_data.index] data.bone = bl_bone.name - converter = BoneConverter(bl_bone, self.__scale or 1.0) + converter = BoneConverter(bl_bone, self.__scale) data.location = converter.convert_location(morph_data.location_offset) data.rotation = converter.convert_rotation(morph_data.rotation_offset) valid_offsets += 1 @@ -996,12 +1006,19 @@ class PMXImporter: armModifier = meshObj.modifiers.new(name="Armature", type="ARMATURE") armModifier.object = armObj armModifier.use_vertex_groups = True - armModifier.name = "mmd_bone_order_override" - armModifier.show_render = armModifier.show_viewport = len(meshObj.data.vertices) > 0 + armModifier.name = "mmd_armature" logger.debug("Armature modifier added") def __assignCustomNormals(self) -> None: """Assign custom normals to the mesh""" + # NOTE: This uses the older Blender API instead of the newer mesh.attributes approach + # because it requires "INT16_2D" format for proper functionality. + # Manual calculation of normals in INT16_2D format is overly complex. + # The newer implementation was removed in commit [ad47b9a] due to these issues. + # The current implementation uses normals_split_custom_set() with 179-degree sharp edge + # marking as a workaround. While not ideal, this remains the most practical solution + # for preserving custom normals in most cases. + if not self.__meshObj or not self.__model: logger.error("Mesh object or model not created") return @@ -1009,17 +1026,41 @@ class PMXImporter: mesh: Mesh = self.__meshObj.data logger.info("Setting custom normals...") + # CRITICAL: Mark sharp edges (based on angle) BEFORE setting custom normals + # For mesh.normals_split_custom_set() to work as expected, two conditions must be met: + # 1. The normal vectors must be non-zero (mentioned in Blender documentation) + # 2. Some edges must be marked as sharp (NOT mentioned in Blender documentation) + # An angle of 179 degrees is confirmed to be sufficient to preserve all custom normals. + # 180 degrees does not work because it misses some sharp edges required for normals_split_custom_set to work 100% correctly. + current_mode = bpy.context.active_object.mode if bpy.context.active_object else 'OBJECT' + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="DESELECT") + bpy.context.view_layer.objects.active = self.__meshObj + + # Mark sharp edges + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="DESELECT") + bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(179)) + bpy.ops.mesh.mark_sharp() + bpy.ops.object.mode_set(mode="OBJECT") + + # Logging + total_edges = len(mesh.edges) + sharp_edges = sum(1 for edge in mesh.edges if edge.use_edge_sharp) + percentage = (sharp_edges / total_edges) * 100 if total_edges > 0 else 0 + logger.info(f" - Marked {sharp_edges}/{total_edges} ({percentage:.2f}%) sharp edges with angle: 179 degrees") + if self.__vertex_map: verts, faces = self.__model.vertices, self.__model.faces custom_normals = [(Vector(verts[i].normal).xzy).normalized() for f in faces for i in f] mesh.normals_split_custom_set(custom_normals) - logger.debug(f"Set {len(custom_normals)} custom normals using face data") else: custom_normals = [(Vector(v.normal).xzy).normalized() for v in self.__model.vertices] mesh.normals_split_custom_set_from_vertices(custom_normals) - logger.debug(f"Set {len(custom_normals)} custom normals from vertices") - logger.info("Custom normals set successfully") + bpy.ops.object.mode_set(mode=current_mode) + logger.info(" - Done!!") + # Continue without custom normals - mesh will use auto-calculated normals def __renameLRBones(self, use_underscore: bool) -> None: """Rename bones with left/right naming convention""" diff --git a/core/mmd/core/rigid_body.py b/core/mmd/core/rigid_body.py index 4f6f5f3..6404d6a 100644 --- a/core/mmd/core/rigid_body.py +++ b/core/mmd/core/rigid_body.py @@ -1,17 +1,13 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. -from typing import List, Optional, Tuple, Union, Dict, Any, Set, cast +from ....core.logging_setup import logger +from typing import List, Optional import bpy -from mathutils import Euler, Vector, Matrix +from mathutils import Euler, Vector from ..bpyutils import FnContext, Props -from ....core.logging_setup import logger SHAPE_SPHERE = 0 SHAPE_BOX = 1 @@ -22,30 +18,25 @@ MODE_DYNAMIC = 1 MODE_DYNAMIC_BONE = 2 -def shapeType(collision_shape: str) -> int: - """Convert collision shape name to type index""" +def shapeType(collision_shape): return ("SPHERE", "BOX", "CAPSULE").index(collision_shape) -def collisionShape(shape_type: int) -> str: - """Convert shape type index to collision shape name""" +def collisionShape(shape_type): return ("SPHERE", "BOX", "CAPSULE")[shape_type] -def setRigidBodyWorldEnabled(enable: bool) -> bool: - """Enable or disable the rigid body world and return previous state""" +def setRigidBodyWorldEnabled(enable): if bpy.ops.rigidbody.world_add.poll(): - logger.debug("Creating rigid body world") bpy.ops.rigidbody.world_add() rigidbody_world = bpy.context.scene.rigidbody_world enabled = rigidbody_world.enabled rigidbody_world.enabled = enable - logger.debug(f"Rigid body world enabled: {enable} (was: {enabled})") return enabled class RigidBodyMaterial: - COLORS: List[int] = [ + COLORS = [ 0x7FDDD4, 0xF0E68C, 0xEE82EE, @@ -65,12 +56,10 @@ class RigidBodyMaterial: ] @classmethod - def getMaterial(cls, number: int) -> bpy.types.Material: - """Get or create a material for rigid bodies with the specified number""" + def getMaterial(cls, number): number = int(number) - material_name = f"mmd_tools_rigid_{number}" + material_name = "mmd_tools_rigid_%d" % (number) if material_name not in bpy.data.materials: - logger.debug(f"Creating rigid body material: {material_name}") mat = bpy.data.materials.new(material_name) color = cls.COLORS[number] mat.diffuse_color[:3] = [((0xFF0000 & color) >> 16) / float(255), ((0x00FF00 & color) >> 8) / float(255), (0x0000FF & color) / float(255)] @@ -82,7 +71,7 @@ class RigidBodyMaterial: mat.shadow_method = "NONE" mat.use_backface_culling = True mat.show_transparent_back = False - # Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes + mat.use_nodes = True nodes, links = mat.node_tree.nodes, mat.node_tree.links nodes.clear() node_color = nodes.new("ShaderNodeBackground") @@ -97,11 +86,9 @@ class RigidBodyMaterial: class FnRigidBody: @staticmethod def new_rigid_body_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int) -> List[bpy.types.Object]: - """Create multiple rigid body objects parented to the specified object""" if count < 1: return [] - logger.debug(f"Creating {count} rigid body objects parented to {parent_object.name}") obj = FnRigidBody.new_rigid_body_object(context, parent_object) if count == 1: @@ -111,8 +98,6 @@ class FnRigidBody: @staticmethod def new_rigid_body_object(context: bpy.types.Context, parent_object: bpy.types.Object) -> bpy.types.Object: - """Create a new rigid body object parented to the specified object""" - logger.debug(f"Creating new rigid body object parented to {parent_object.name}") obj = FnContext.new_and_link_object(context, name="Rigidbody", object_data=bpy.data.meshes.new(name="Rigidbody")) obj.parent = parent_object obj.mmd_type = "RIGID_BODY" @@ -130,11 +115,11 @@ class FnRigidBody: @staticmethod def setup_rigid_body_object( obj: bpy.types.Object, - shape_type: int, + shape_type: str, location: Vector, rotation: Euler, size: Vector, - dynamics_type: int, + dynamics_type: str, collision_group_number: Optional[int] = None, collision_group_mask: Optional[List[bool]] = None, name: Optional[str] = None, @@ -146,8 +131,6 @@ class FnRigidBody: linear_damping: Optional[float] = None, bounce: Optional[float] = None, ) -> bpy.types.Object: - """Set up a rigid body object with the specified parameters""" - logger.debug(f"Setting up rigid body object: {obj.name}") obj.location = location obj.rotation_euler = rotation @@ -189,35 +172,31 @@ class FnRigidBody: return obj @staticmethod - def get_rigid_body_size(obj: bpy.types.Object) -> Tuple[float, float, float]: - """Get the size of a rigid body object based on its shape type""" + def get_rigid_body_size(obj: bpy.types.Object): assert obj.mmd_type == "RIGID_BODY" x0, y0, z0 = obj.bound_box[0] x1, y1, z1 = obj.bound_box[6] - assert x1 >= x0 and y1 >= y0 and z1 >= z0 + if not (x1 >= x0 and y1 >= y0 and z1 >= z0): + logger.warning(f"Rigid body '{obj.name}' has invalid bounding box coordinates, using default size") + return (1.0, 1.0, 1.0) shape = obj.mmd_rigid.shape if shape == "SPHERE": radius = (z1 - z0) / 2 return (radius, 0.0, 0.0) - elif shape == "BOX": + if shape == "BOX": x, y, z = (x1 - x0) / 2, (y1 - y0) / 2, (z1 - z0) / 2 return (x, y, z) - elif shape == "CAPSULE": + if shape == "CAPSULE": diameter = x1 - x0 radius = diameter / 2 height = abs((z1 - z0) - diameter) return (radius, height, 0.0) - else: - error_msg = f"Invalid shape type: {shape}" - logger.error(error_msg) - raise ValueError(error_msg) + raise ValueError(f"Invalid shape type: {shape}") @staticmethod def new_joint_object(context: bpy.types.Context, parent_object: bpy.types.Object, empty_display_size: float) -> bpy.types.Object: - """Create a new joint object parented to the specified object""" - logger.debug(f"Creating new joint object parented to {parent_object.name}") obj = FnContext.new_and_link_object(context, name="Joint", object_data=None) obj.parent = parent_object obj.mmd_type = "JOINT" @@ -249,11 +228,9 @@ class FnRigidBody: @staticmethod def new_joint_objects(context: bpy.types.Context, parent_object: bpy.types.Object, count: int, empty_display_size: float) -> List[bpy.types.Object]: - """Create multiple joint objects parented to the specified object""" if count < 1: return [] - logger.debug(f"Creating {count} joint objects parented to {parent_object.name}") obj = FnRigidBody.new_joint_object(context, parent_object, empty_display_size) if count == 1: @@ -277,8 +254,6 @@ class FnRigidBody: name: str, name_e: Optional[str] = None, ) -> bpy.types.Object: - """Set up a joint object with the specified parameters""" - logger.debug(f"Setting up joint object: {obj.name} with name {name}") obj.name = f"J.{name}" obj.location = location diff --git a/core/mmd/core/sdef.py b/core/mmd/core/sdef.py index 2c15ce1..e705462 100644 --- a/core/mmd/core/sdef.py +++ b/core/mmd/core/sdef.py @@ -1,52 +1,42 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2018 MMD Tools authors +# This file is part of MMD Tools. -import logging +from ....core.logging_setup import logger import time -from typing import Dict, List, Tuple, Set, Optional, Any, Union, cast, TypeVar, Callable import bpy import numpy as np -from mathutils import Matrix, Vector, Quaternion, Euler -from bpy.types import Object, PoseBone, Pose, ShapeKey, Modifier, VertexGroup +from mathutils import Matrix, Vector from ..bpyutils import FnObject -from ....core.logging_setup import logger -T = TypeVar('T') -def _hash(v: Union[Object, PoseBone, Pose]) -> int: +def _hash(v): if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)): return hash(type(v).__name__ + v.name) - elif isinstance(v, bpy.types.Pose): + if isinstance(v, bpy.types.Pose): return hash(type(v).__name__ + v.id_data.name) - else: - raise NotImplementedError("hash") + raise NotImplementedError("hash") class FnSDEF: - g_verts: Dict[int, Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]] = {} # global cache - g_shapekey_data: Dict[int, Optional[np.ndarray]] = {} - g_bone_check: Dict[int, Dict[Union[Tuple[int, int], str], Union[Tuple[Matrix, Matrix], bool]]] = {} - __g_armature_check: Dict[int, Optional[int]] = {} - SHAPEKEY_NAME: str = "mmd_sdef_skinning" - MASK_NAME: str = "mmd_sdef_mask" + g_verts = {} # global cache + g_shapekey_data = {} + g_bone_check = {} + __g_armature_check = {} + SHAPEKEY_NAME = "mmd_sdef_skinning" + MASK_NAME = "mmd_sdef_mask" - def __init__(self) -> None: + def __init__(self): raise NotImplementedError("not allowed") @classmethod - def __init_cache(cls, obj: Object, shapekey: ShapeKey) -> bool: + def __init_cache(cls, obj, shapekey): key = _hash(obj) obj = getattr(obj, "original", obj) - mod = obj.modifiers.get("mmd_bone_order_override") + mod = obj.modifiers.get("mmd_armature") key_armature = _hash(mod.object.pose) if mod and mod.type == "ARMATURE" and mod.object else None if key not in cls.g_verts or cls.__g_armature_check.get(key) != key_armature: - logger.debug(f"Initializing SDEF cache for {obj.name}") cls.g_verts[key] = cls.__find_vertices(obj) cls.g_bone_check[key] = {} cls.__g_armature_check[key] = key_armature @@ -55,7 +45,7 @@ class FnSDEF: return False @classmethod - def __check_bone_update(cls, obj: Object, bone0: PoseBone, bone1: PoseBone) -> bool: + def __check_bone_update(cls, obj, bone0, bone1): check = cls.g_bone_check[_hash(obj)] key = (_hash(bone0), _hash(bone1)) if key not in check or (bone0.matrix, bone1.matrix) != check[key]: @@ -64,21 +54,20 @@ class FnSDEF: return False @classmethod - def mute_sdef_set(cls, obj: Object, mute: bool) -> None: + def mute_sdef_set(cls, obj, mute): key_blocks = getattr(obj.data.shape_keys, "key_blocks", ()) if cls.SHAPEKEY_NAME in key_blocks: shapekey = key_blocks[cls.SHAPEKEY_NAME] shapekey.mute = mute if cls.has_sdef_data(obj): - logger.debug(f"Setting SDEF mute state to {mute} for {obj.name}") cls.__init_cache(obj, shapekey) cls.__sdef_muted(obj, shapekey) @classmethod - def __sdef_muted(cls, obj: Object, shapekey: ShapeKey) -> bool: + def __sdef_muted(cls, obj, shapekey): mute = shapekey.mute if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"): - mod = obj.modifiers.get("mmd_bone_order_override") + mod = obj.modifiers.get("mmd_armature") if mod and mod.type == "ARMATURE": if not mute and cls.MASK_NAME not in obj.vertex_groups and obj.mode != "EDIT": mask = tuple(i for v in cls.g_verts[_hash(obj)].values() for i in v[3]) @@ -87,33 +76,32 @@ class FnSDEF: mod.invert_vertex_group = True shapekey.vertex_group = cls.MASK_NAME cls.g_bone_check[_hash(obj)]["sdef_mute"] = mute - logger.debug(f"SDEF mute state updated to {mute} for {obj.name}") return mute @staticmethod - def has_sdef_data(obj: Object) -> bool: - mod = obj.modifiers.get("mmd_bone_order_override") + def has_sdef_data(obj): + if obj is None or not hasattr(obj, "modifiers") or not hasattr(obj, "data") or obj.data is None: + return False + mod = obj.modifiers.get("mmd_armature") if mod and mod.type == "ARMATURE" and mod.object: kb = getattr(obj.data.shape_keys, "key_blocks", None) return kb and "mmd_sdef_c" in kb and "mmd_sdef_r0" in kb and "mmd_sdef_r1" in kb return False @classmethod - def __find_vertices(cls, obj: Object) -> Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]: + def __find_vertices(cls, obj): if not cls.has_sdef_data(obj): + logger.debug(f"SDEF vertex search skipped for '{obj.name}': No SDEF data found") return {} - vertices: Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]] = {} - pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones - bone_map: Dict[int, PoseBone] = {g.index: pose_bones[g.name] for g in obj.vertex_groups if g.name in pose_bones} + vertices = {} + pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones + bone_map = {g.index: pose_bones[g.name] for g in obj.vertex_groups if g.name in pose_bones} sdef_c = obj.data.shape_keys.key_blocks["mmd_sdef_c"].data sdef_r0 = obj.data.shape_keys.key_blocks["mmd_sdef_r0"].data sdef_r1 = obj.data.shape_keys.key_blocks["mmd_sdef_r1"].data vd = obj.data.vertices - logger.debug(f"Finding SDEF vertices for {obj.name}") - vertex_count = 0 - for i in range(len(sdef_c)): if vd[i].co != sdef_c[i].co: bgs = [g for g in vd[i].groups if g.group in bone_map and g.weight] # bone groups @@ -122,7 +110,7 @@ class FnSDEF: # preprocessing w0, w1 = bgs[0].weight, bgs[1].weight # w0 + w1 == 1 - w0 = w0 / (w0 + w1) + w0 /= (w0 + w1) w1 = 1 - w0 c, r0, r1 = sdef_c[i].co, sdef_r0[i].co, sdef_r1[i].co @@ -136,19 +124,22 @@ class FnSDEF: vertices[key] = (bone_map[bgs[0].group], bone_map[bgs[1].group], [], []) vertices[key][2].append((i, w0, w1, vd[i].co - c, (c + r0) / 2, (c + r1) / 2)) vertices[key][3].append(i) - vertex_count += 1 - - logger.debug(f"Found {vertex_count} SDEF vertices in {obj.name}") return vertices @classmethod - def driver_function_wrap(cls, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float: + def driver_function_wrap(cls, obj_name, bulk_update, use_skip, use_scale): + if obj_name not in bpy.data.objects: + logger.warning(f"SDEF driver wrap: Object '{obj_name}' not found") + return 0.0 obj = bpy.data.objects[obj_name] shapekey = obj.data.shape_keys.key_blocks[cls.SHAPEKEY_NAME] return cls.driver_function(shapekey, obj_name, bulk_update, use_skip, use_scale) @classmethod - def driver_function(cls, shapekey: ShapeKey, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float: + def driver_function(cls, shapekey, obj_name, bulk_update, use_skip, use_scale): + if obj_name not in bpy.data.objects: + logger.warning(f"SDEF driver: Object '{obj_name}' not found, driver will be inactive") + return 0.0 obj = bpy.data.objects[obj_name] if getattr(shapekey.id_data, "is_evaluated", False): # For Blender 2.8x, we should use evaluated object, and the only reference is the "obj" variable of SDEF driver @@ -159,7 +150,7 @@ class FnSDEF: if cls.__sdef_muted(obj, shapekey): return 0.0 - pose_bones = obj.modifiers.get("mmd_bone_order_override").object.pose.bones + pose_bones = obj.modifiers.get("mmd_armature").object.pose.bones if not bulk_update: shapekey_data = shapekey.data if use_scale: @@ -200,8 +191,6 @@ class FnSDEF: else: # bulk update shapekey_data = cls.g_shapekey_data[_hash(obj)] if shapekey_data is None: - import numpy as np - shapekey_data = np.zeros(len(shapekey.data) * 3, dtype=np.float32) shapekey.data.foreach_get("co", shapekey_data) shapekey_data = cls.g_shapekey_data[_hash(obj)] = shapekey_data.reshape(len(shapekey.data), 3) @@ -220,15 +209,15 @@ class FnSDEF: rot1 = -rot1 s0, s1 = mat0.to_scale(), mat1.to_scale() - def scale(mat_rot: Matrix, w0: float, w1: float) -> Matrix: + def scale(mat_rot, w0, w1, s0, s1): s = s0 * w0 + s1 * w1 return mat_rot @ Matrix([(s[0], 0, 0), (0, s[1], 0), (0, 0, s[2])]) - def offset(mat_rot: Matrix, pos_c: Vector, vid: int) -> Vector: + def offset(mat_rot, pos_c, vid): delta = sum(((key.data[vid].co - key.relative_key.data[vid].co) * key.value for key in key_blocks), Vector()) # assuming key.vertex_group = '' return (mat_rot @ (pos_c + delta)) - delta - shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] + shapekey_data[vids] = [offset(scale((rot0 * w0 + rot1 * w1).normalized().to_matrix(), w0, w1, s0, s1), pos_c, vid) + (mat0 @ cr0) * w0 + (mat1 @ cr1) * w1 for vid, w0, w1, pos_c, cr0, cr1 in sdef_data] else: # bulk update for bone0, bone1, sdef_data, vids in cls.g_verts[_hash(obj)].values(): @@ -247,19 +236,16 @@ class FnSDEF: return 1.0 # shapkey value @classmethod - def register_driver_function(cls) -> None: - """Register driver functions in Blender's driver namespace.""" + def register_driver_function(cls): if "mmd_sdef_driver" not in bpy.app.driver_namespace: - logger.debug("Registering SDEF driver function") bpy.app.driver_namespace["mmd_sdef_driver"] = cls.driver_function if "mmd_sdef_driver_wrap" not in bpy.app.driver_namespace: - logger.debug("Registering SDEF driver wrapper function") bpy.app.driver_namespace["mmd_sdef_driver_wrap"] = cls.driver_function_wrap - BENCH_LOOP: int = 10 + BENCH_LOOP = 10 @classmethod - def __get_benchmark_result(cls, obj: Object, shapkey: ShapeKey, use_scale: bool, use_skip: bool) -> bool: + def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip): # warmed up cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale) cls.driver_function(shapkey, obj.name, bulk_update=False, use_skip=False, use_scale=use_scale) @@ -273,15 +259,15 @@ class FnSDEF: cls.driver_function(shapkey, obj.name, bulk_update=True, use_skip=False, use_scale=use_scale) bulk_time = time.time() - t result = default_time > bulk_time - logger.info(f"SDEF benchmark for {obj.name}: default {default_time:.4f}s vs bulk_update {bulk_time:.4f}s => bulk_update={result}") + logger.info("FnSDEF:benchmark: default %.4f vs bulk_update %.4f => bulk_update=%s", default_time, bulk_time, result) return result @classmethod - def bind(cls, obj: Object, bulk_update: Optional[bool] = None, use_skip: bool = True, use_scale: bool = False) -> bool: + def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False): # Unbind first cls.unbind(obj) if not cls.has_sdef_data(obj): - logger.debug(f"Object {obj.name} does not have SDEF data") + logger.debug(f"SDEF bind skipped for '{obj.name}': No SDEF data found") return False # Create the shapekey for the driver shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False) @@ -300,50 +286,41 @@ class FnSDEF: ov.type = "SINGLE_PROP" ov.targets[0].id = obj ov.targets[0].data_path = "name" - if not bulk_update and use_skip: # FIXME: force disable use_skip=True for bulk_update=False on 2.8 - use_skip = False - mod = obj.modifiers.get("mmd_bone_order_override") + mod = obj.modifiers.get("mmd_armature") variables = f.driver.variables - for name in set(data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)): # add required bones for dependency graph + for name in {data[i].name for data in cls.g_verts[_hash(obj)].values() for i in range(2)}: # add required bones for dependency graph var = variables.new() var.type = "TRANSFORMS" var.targets[0].id = mod.object var.targets[0].bone_target = name f.driver.use_self = True - param = (bulk_update, use_skip, use_scale) - f.driver.expression = "mmd_sdef_driver(self, obj, bulk_update={}, use_skip={}, use_scale={})".format(*param) - logger.info(f"Successfully bound SDEF to {obj.name} with bulk_update={bulk_update}, use_skip={use_skip}, use_scale={use_scale}") + f.driver.expression = f"mmd_sdef_driver(self, obj, bulk_update={bulk_update}, use_skip={use_skip}, use_scale={use_scale})" return True @classmethod - def unbind(cls, obj: Object) -> None: + def unbind(cls, obj): if obj.data.shape_keys: if cls.SHAPEKEY_NAME in obj.data.shape_keys.key_blocks: - logger.debug(f"Removing SDEF shape key from {obj.name}") FnObject.mesh_remove_shape_key(obj, obj.data.shape_keys.key_blocks[cls.SHAPEKEY_NAME]) for mod in obj.modifiers: if mod.type == "ARMATURE" and mod.vertex_group == cls.MASK_NAME: - logger.debug(f"Clearing SDEF vertex group from modifier in {obj.name}") mod.vertex_group = "" mod.invert_vertex_group = False break if cls.MASK_NAME in obj.vertex_groups: - logger.debug(f"Removing SDEF vertex group from {obj.name}") obj.vertex_groups.remove(obj.vertex_groups[cls.MASK_NAME]) cls.clear_cache(obj) @classmethod - def clear_cache(cls, obj: Optional[Object] = None, unused_only: bool = False) -> None: + def clear_cache(cls, obj=None, unused_only=False): if unused_only: - valid_keys = set(_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj) - removed_keys = cls.g_verts.keys() - valid_keys - for key in removed_keys: + valid_keys = {_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj} + for key in cls.g_verts.keys() - valid_keys: del cls.g_verts[key] for key in cls.g_shapekey_data.keys() - cls.g_verts.keys(): del cls.g_shapekey_data[key] for key in cls.g_bone_check.keys() - cls.g_verts.keys(): del cls.g_bone_check[key] - logger.debug(f"Cleared {len(removed_keys)} unused SDEF cache entries") elif obj: key = _hash(obj) if key in cls.g_verts: @@ -352,9 +329,7 @@ class FnSDEF: del cls.g_shapekey_data[key] if key in cls.g_bone_check: del cls.g_bone_check[key] - logger.debug(f"Cleared SDEF cache for {obj.name}") else: - logger.debug("Cleared all SDEF cache") cls.g_verts = {} cls.g_bone_check = {} cls.g_shapekey_data = {} diff --git a/core/mmd/core/shader.py b/core/mmd/core/shader.py index 7636980..ab744bd 100644 --- a/core/mmd/core/shader.py +++ b/core/mmd/core/shader.py @@ -1,37 +1,26 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2019 MMD Tools authors +# This file is part of MMD Tools. + +from typing import Optional, Tuple, cast -from typing import Optional, Tuple, cast, List, Dict, Any, Union import bpy -from bpy.types import ( - ShaderNodeTree, - ShaderNode, - NodeGroupInput, - NodeGroupOutput, - Material -) -from ....core.logging_setup import logger class _NodeTreeUtils: - def __init__(self, shader: ShaderNodeTree): + def __init__(self, shader: bpy.types.ShaderNodeTree): self.shader = shader - self.nodes: bpy.types.bpy_prop_collection[ShaderNode] = shader.nodes # type: ignore + self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore[assignment] self.links = shader.links - def _find_node(self, node_type: str) -> Optional[ShaderNode]: + def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]: return next((n for n in self.nodes if n.bl_idname == node_type), None) - def new_node(self, idname: str, pos: Tuple[int, int]) -> ShaderNode: - node: ShaderNode = self.nodes.new(idname) + def new_node(self, idname: str, pos: Tuple[int, int]) -> bpy.types.ShaderNode: + node: bpy.types.ShaderNode = self.nodes.new(idname) node.location = (pos[0] * 210, pos[1] * 220) return node - def new_math_node(self, operation: str, pos: Tuple[int, int], value1: Optional[float] = None, value2: Optional[float] = None) -> ShaderNode: + def new_math_node(self, operation, pos, value1=None, value2=None): node = self.new_node("ShaderNodeMath", pos) node.operation = operation if value1 is not None: @@ -40,7 +29,7 @@ class _NodeTreeUtils: node.inputs[1].default_value = value2 return node - def new_vector_math_node(self, operation: str, pos: Tuple[int, int], vector1: Optional[Tuple[float, float, float, float]] = None, vector2: Optional[Tuple[float, float, float, float]] = None) -> ShaderNode: + def new_vector_math_node(self, operation, pos, vector1=None, vector2=None): node = self.new_node("ShaderNodeVectorMath", pos) node.operation = operation if vector1 is not None: @@ -49,7 +38,7 @@ class _NodeTreeUtils: node.inputs[1].default_value = vector2 return node - def new_mix_node(self, blend_type: str, pos: Tuple[int, int], fac: Optional[float] = None, color1: Optional[Tuple[float, float, float, float]] = None, color2: Optional[Tuple[float, float, float, float]] = None) -> ShaderNode: + def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None): node = self.new_node("ShaderNodeMixRGB", pos) node.blend_type = blend_type if fac is not None: @@ -61,30 +50,30 @@ class _NodeTreeUtils: return node -SOCKET_TYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "NodeSocketFloat"} +SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"} -SOCKET_SUBTYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "FACTOR"} +SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"} class _NodeGroupUtils(_NodeTreeUtils): - def __init__(self, shader: ShaderNodeTree): + def __init__(self, shader: bpy.types.ShaderNodeTree): super().__init__(shader) - self.__node_input: Optional[NodeGroupInput] = None - self.__node_output: Optional[NodeGroupOutput] = None + self.__node_input: Optional[bpy.types.NodeGroupInput] = None + self.__node_output: Optional[bpy.types.NodeGroupOutput] = None @property - def node_input(self) -> NodeGroupInput: + def node_input(self) -> bpy.types.NodeGroupInput: if not self.__node_input: - self.__node_input = cast(NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) + self.__node_input = cast("bpy.types.NodeGroupInput", self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) return self.__node_input @property - def node_output(self) -> NodeGroupOutput: + def node_output(self) -> bpy.types.NodeGroupOutput: if not self.__node_output: - self.__node_output = cast(NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) + self.__node_output = cast("bpy.types.NodeGroupOutput", self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) return self.__node_output - def hide_nodes(self, hide_sockets: bool = True) -> None: + def hide_nodes(self, hide_sockets=True): skip_nodes = {self.__node_input, self.__node_output} for n in (x for x in self.nodes if x not in skip_nodes): n.hide = True @@ -95,22 +84,22 @@ class _NodeGroupUtils(_NodeTreeUtils): for s in n.outputs: s.hide = not s.is_linked - def new_input_socket(self, io_name: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> None: + def new_input_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None): self.__new_io("INPUT", self.node_input.outputs, io_name, socket, default_val, min_max, socket_type) - def new_output_socket(self, io_name: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> None: + def new_output_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None): self.__new_io("OUTPUT", self.node_output.inputs, io_name, socket, default_val, min_max, socket_type) - def __new_io(self, in_out: str, io_sockets: bpy.types.bpy_prop_collection, io_name: str, socket: Optional[bpy.types.NodeSocket], default_val: Optional[Union[float, Tuple[float, float, float, float]]] = None, min_max: Optional[Tuple[float, float]] = None, socket_type: Optional[str] = None) -> None: + def __new_io(self, in_out, io_sockets, io_name, socket, default_val=None, min_max=None, socket_type=None): if io_name not in io_sockets: - idname = socket_type or (socket.bl_idname if socket else "NodeSocketFloat") + idname = socket_type or socket.bl_idname interface_socket = self.shader.interface.new_socket(name=io_name, in_out=in_out, socket_type=SOCKET_TYPE_MAPPING.get(idname, idname)) if idname in SOCKET_SUBTYPE_MAPPING: interface_socket.subtype = SOCKET_SUBTYPE_MAPPING.get(idname, "") if not min_max: if idname.endswith("Factor") or io_name.endswith("Alpha"): interface_socket.min_value, interface_socket.max_value = 0, 1 - elif idname.endswith("Float") or idname.endswith("Vector"): + elif idname.endswith(("Float", "Vector")): interface_socket.min_value, interface_socket.max_value = -10, 10 if socket is not None: self.links.new(io_sockets[io_name], socket) @@ -122,18 +111,14 @@ class _NodeGroupUtils(_NodeTreeUtils): class _MaterialMorph: @classmethod - def update_morph_inputs(cls, material: Optional[Material], morph: Any) -> None: - """Update material morph inputs based on morph data""" + def update_morph_inputs(cls, material, morph): if material and material.node_tree and morph.name in material.node_tree.nodes: - logger.debug(f"Updating morph inputs for {morph.name} in {material.name}") cls.__update_node_inputs(material.node_tree.nodes[morph.name], morph) cls.update_morph_inputs(bpy.data.materials.get("mmd_edge." + material.name, None), morph) @classmethod - def setup_morph_nodes(cls, material: Material, morphs: List[Any]) -> List[ShaderNode]: - """Set up morph nodes for a material""" + def setup_morph_nodes(cls, material, morphs): node, nodes = None, [] - logger.debug(f"Setting up {len(morphs)} morph nodes for {material.name}") for m in morphs: node = cls.__morph_node_add(material, m, node) nodes.append(node) @@ -149,25 +134,23 @@ class _MaterialMorph: return nodes @classmethod - def reset_morph_links(cls, node: ShaderNode) -> None: - """Reset morph links for a node""" - logger.debug(f"Resetting morph links for {node.name}") + def reset_morph_links(cls, node): cls.__update_morph_links(node, reset=True) @classmethod - def __update_morph_links(cls, node: ShaderNode, reset: bool = False) -> None: + def __update_morph_links(cls, node, reset=False): nodes, links = node.id_data.nodes, node.id_data.links if reset: - if any(l.from_node.name.startswith("mmd_bind") for i in node.inputs for l in i.links): + if any(link.from_node.name.startswith("mmd_bind") for i in node.inputs for link in i.links): return - def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None: + def __init_link(socket_morph, socket_shader): if socket_shader and socket_morph.is_linked: links.new(socket_morph.links[0].from_socket, socket_shader) else: - def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None: + def __init_link(socket_morph, socket_shader): if socket_shader: if socket_shader.is_linked: links.new(socket_shader.links[0].from_socket, socket_morph) @@ -192,8 +175,7 @@ class _MaterialMorph: __init_link(node.inputs["Edge1 A"], shader.inputs["Alpha"]) @classmethod - def __update_node_inputs(cls, node: ShaderNode, morph: Any) -> None: - """Update node inputs based on morph data""" + def __update_node_inputs(cls, node, morph): node.inputs["Ambient2"].default_value[:3] = morph.ambient_color[:3] node.inputs["Diffuse2"].default_value[:3] = morph.diffuse_color[:3] node.inputs["Specular2"].default_value[:3] = morph.specular_color[:3] @@ -211,8 +193,7 @@ class _MaterialMorph: node.inputs["Sphere2 A"].default_value = morph.sphere_texture_factor[3] @classmethod - def __morph_node_add(cls, material: Material, morph: Optional[Any], prev_node: Optional[ShaderNode]) -> Optional[ShaderNode]: - """Add a morph node to a material""" + def __morph_node_add(cls, material, morph, prev_node): nodes, links = material.node_tree.nodes, material.node_tree.links shader = nodes.get("mmd_shader", None) @@ -237,9 +218,8 @@ class _MaterialMorph: return node # connect last node to shader if shader: - logger.debug(f"Connecting last node to shader for {material.name}") - def __soft_link(socket_out: Optional[bpy.types.NodeSocket], socket_in: Optional[bpy.types.NodeSocket]) -> None: + def __soft_link(socket_out, socket_in): if socket_out and socket_in: links.new(socket_out, socket_in) @@ -261,14 +241,12 @@ class _MaterialMorph: return shader @classmethod - def __get_shader(cls, morph_type: str) -> ShaderNodeTree: - """Get or create a shader node group for the specified morph type""" + def __get_shader(cls, morph_type): group_name = "MMDMorph" + morph_type shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") if len(shader.nodes): return shader - logger.info(f"Creating new shader node group: {group_name}") ng = _NodeGroupUtils(shader) links = ng.links @@ -279,18 +257,18 @@ class _MaterialMorph: ng.new_input_socket("Fac", None, 0, socket_type="NodeSocketFloat") ng.new_node("NodeGroupOutput", (3, 0)) - def __blend_color_add(id_name: str, pos: Tuple[int, int], tag: str = "") -> ShaderNode: + def __blend_color_add(id_name, pos, tag=""): # MA_RAMP_MULT: ColorMul = Color1 * (Fac * Color2 + (1 - Fac)) # MA_RAMP_ADD: ColorAdd = Color1 + Fac * Color2 # https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/source/blender/blenkernel/intern/material.c#L1400 node_mix = ng.new_mix_node("MULTIPLY" if use_mul else "ADD", (pos[0] + 1, pos[1])) links.new(node_input.outputs["Fac"], node_mix.inputs["Fac"]) - ng.new_input_socket("%s1" % id_name + tag, node_mix.inputs["Color1"]) - ng.new_input_socket("%s2" % id_name + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector") + ng.new_input_socket(f"{id_name}1" + tag, node_mix.inputs["Color1"]) + ng.new_input_socket(f"{id_name}2" + tag, node_mix.inputs["Color2"], socket_type="NodeSocketVector") ng.new_output_socket(id_name + tag, node_mix.outputs["Color"]) return node_mix - def __blend_tex_color(id_name: str, pos: Tuple[int, int], node_tex_rgb: ShaderNode, node_tex_a_output: bpy.types.NodeSocket) -> None: + def __blend_tex_color(id_name, pos, node_tex_rgb, node_tex_a_output): # Tex Color = tex_rgb * tex_a + (1 - tex_a) # : tex_rgb = TexRGB * ColorMul + ColorAdd # : tex_a = TexA * ValueMul + ValueAdd @@ -313,7 +291,7 @@ class _MaterialMorph: ng.new_output_socket(id_name + " Tex", node_add.outputs[0], socket_type="NodeSocketColor") ng.new_output_socket(id_name + " Tex Add", node_scale.outputs[0], socket_type="NodeSocketColor") - def __add_sockets(id_name: str, input1: bpy.types.NodeSocket, input2: bpy.types.NodeSocket, output: bpy.types.NodeSocket, tag: str = "") -> None: + def __add_sockets(id_name, input1, input2, output, tag=""): ng.new_input_socket(f"{id_name}1{tag}", input1, use_mul) ng.new_input_socket(f"{id_name}2{tag}", input2, use_mul) ng.new_output_socket(f"{id_name}{tag}", output) @@ -362,5 +340,4 @@ class _MaterialMorph: __blend_tex_color("Sphere", (pos_x + 3, -3.5), sphere_rgb, separate_basea_toona_spherea.outputs[2]) ng.hide_nodes() - logger.debug(f"Shader node group {group_name} created successfully") return ng.shader diff --git a/core/mmd/core/translations.py b/core/mmd/core/translations.py index 6574ba0..efa2099 100644 --- a/core/mmd/core/translations.py +++ b/core/mmd/core/translations.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2021 MMD Tools authors +# This file is part of MMD Tools. import itertools import re @@ -33,11 +29,7 @@ class MMDTranslationElementType(Enum): class MMDDataHandlerABC(ABC): - @classmethod - @property - @abstractmethod - def type_name(cls) -> str: - pass + type_name: str @classmethod @abstractmethod @@ -67,7 +59,8 @@ class MMDDataHandlerABC(ABC): @classmethod @abstractmethod def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: - """Returns (name, name_j, name_e)""" + """Return (name, name_j, name_e)""" + pass @classmethod def is_restorable(cls, mmd_translation_element: "MMDTranslationElement") -> bool: @@ -75,7 +68,7 @@ class MMDDataHandlerABC(ABC): @classmethod def check_data_visible(cls, filter_selected: bool, filter_visible: bool, select: bool, hide: bool) -> bool: - return filter_selected and not select or filter_visible and hide + return (filter_selected and not select) or (filter_visible and hide) @classmethod def prop_restorable(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", prop_name: str, original_value: str, index: int): @@ -86,7 +79,7 @@ class MMDDataHandlerABC(ABC): row.label(text="", icon="BLANK1") return - op = row.operator("mmd_tools.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH") + op = row.operator("mmd_tools_local.restore_mmd_translation_element_name", text="", icon="FILE_REFRESH") op.index = index op.prop_name = prop_name op.restore_value = original_value @@ -100,10 +93,7 @@ class MMDDataHandlerABC(ABC): class MMDBoneHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.BONE.name + type_name = MMDTranslationElementType.BONE.name @classmethod def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): @@ -114,18 +104,18 @@ class MMDBoneHandler(MMDDataHandlerABC): cls.prop_restorable(prop_row, mmd_translation_element, "name", pose_bone.name, index) cls.prop_restorable(prop_row, mmd_translation_element, "name_j", pose_bone.mmd_bone.name_j, index) cls.prop_restorable(prop_row, mmd_translation_element, "name_e", pose_bone.mmd_bone.name_e, index) - row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.bone.select else "RESTRICT_SELECT_ON") - row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if pose_bone.bone.hide else "HIDE_OFF") + row.prop(pose_bone.bone, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if pose_bone.select else "RESTRICT_SELECT_ON") + row.prop(pose_bone.bone, "hide", text="", emboss=False, icon_only=True) @classmethod def collect_data(cls, mmd_translation: "MMDTranslation"): armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) pose_bone: bpy.types.PoseBone for index, pose_bone in enumerate(armature_object.pose.bones): - if not any(c.is_visible for c in pose_bone.bone.collections): + if pose_bone.bone.hide or (pose_bone.bone.collections and not any(c.is_visible for c in pose_bone.bone.collections)): continue - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.BONE.name mmd_translation_element.object = armature_object mmd_translation_element.data_path = f"pose.bones[{index}]" @@ -140,14 +130,14 @@ class MMDBoneHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.BONE.name: continue pose_bone: bpy.types.PoseBone = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) - if cls.check_data_visible(filter_selected, filter_visible, pose_bone.bone.select, pose_bone.bone.hide): + if cls.check_data_visible(filter_selected, filter_visible, pose_bone.select, pose_bone.bone.hide): continue if check_blank_name(mmd_translation_element.name_j, mmd_translation_element.name_e): @@ -156,7 +146,7 @@ class MMDBoneHandler(MMDDataHandlerABC): if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod @@ -176,14 +166,11 @@ class MMDBoneHandler(MMDDataHandlerABC): class MMDMorphHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.MORPH.name + type_name = MMDTranslationElementType.MORPH.name @classmethod def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): - morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) row = layout.row(align=True) row.label(text="", icon="SHAPEKEY_DATA") prop_row = row.row() @@ -198,7 +185,7 @@ class MMDMorphHandler(MMDDataHandlerABC): @classmethod def collect_data(cls, mmd_translation: "MMDTranslation"): root_object: bpy.types.Object = mmd_translation.id_data - mmd_root: "MMDRoot" = root_object.mmd_root + mmd_root: MMDRoot = root_object.mmd_root for morphs_name, morphs in { "material_morphs": mmd_root.material_morphs, @@ -207,9 +194,9 @@ class MMDMorphHandler(MMDDataHandlerABC): "vertex_morphs": mmd_root.vertex_morphs, "group_morphs": mmd_root.group_morphs, }.items(): - morph: "_MorphBase" + morph: _MorphBase for index, morph in enumerate(morphs): - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.MORPH.name mmd_translation_element.object = root_object mmd_translation_element.data_path = f"mmd_root.{morphs_name}[{index}]" @@ -228,24 +215,24 @@ class MMDMorphHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.MORPH.name: continue - morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) if check_blank_name(morph.name, morph.name_e): continue if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod def set_names(cls, mmd_translation_element: "MMDTranslationElement", name: Optional[str], name_j: Optional[str], name_e: Optional[str]): - morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) if name is not None: morph.name = name if name_e is not None: @@ -253,15 +240,12 @@ class MMDMorphHandler(MMDDataHandlerABC): @classmethod def get_names(cls, mmd_translation_element: "MMDTranslationElement") -> Tuple[str, str, str]: - morph: "_MorphBase" = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) + morph: _MorphBase = mmd_translation_element.object.path_resolve(mmd_translation_element.data_path) return (morph.name, "", morph.name_e) class MMDMaterialHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.MATERIAL.name + type_name = MMDTranslationElementType.MATERIAL.name @classmethod def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): @@ -274,7 +258,7 @@ class MMDMaterialHandler(MMDDataHandlerABC): cls.prop_restorable(prop_row, mmd_translation_element, "name_j", material.mmd_material.name_j, index) cls.prop_restorable(prop_row, mmd_translation_element, "name_e", material.mmd_material.name_e, index) row.prop(mesh_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mesh_object.select_get() else "RESTRICT_SELECT_ON") - row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mesh_object.hide_get() else "HIDE_OFF") + row.prop(mesh_object, "hide", text="", emboss=False, icon_only=True) MATERIAL_DATA_PATH_EXTRACT = re.compile(r"data\.materials\[(?P\d*)\]") @@ -293,7 +277,7 @@ class MMDMaterialHandler(MMDDataHandlerABC): if not hasattr(material, "mmd_material"): continue - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.MATERIAL.name mmd_translation_element.object = mesh_object mmd_translation_element.data_path = f"data.materials[{index}]" @@ -314,7 +298,7 @@ class MMDMaterialHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.MATERIAL.name: continue @@ -330,7 +314,7 @@ class MMDMaterialHandler(MMDDataHandlerABC): if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod @@ -350,10 +334,7 @@ class MMDMaterialHandler(MMDDataHandlerABC): class MMDDisplayHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.DISPLAY.name + type_name = MMDTranslationElementType.DISPLAY.name @classmethod def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): @@ -366,7 +347,7 @@ class MMDDisplayHandler(MMDDataHandlerABC): cls.prop_disabled(prop_row, mmd_translation_element, "name") cls.prop_disabled(prop_row, mmd_translation_element, "name_e") row.prop(mmd_translation_element.object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if mmd_translation_element.object.select_get() else "RESTRICT_SELECT_ON") - row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if mmd_translation_element.object.hide_get() else "HIDE_OFF") + row.prop(mmd_translation_element.object, "hide", text="", emboss=False, icon_only=True) DISPLAY_DATA_PATH_EXTRACT = re.compile(r"data\.collections\[(?P\d*)\]") @@ -375,7 +356,7 @@ class MMDDisplayHandler(MMDDataHandlerABC): armature_object: bpy.types.Object = FnModel.find_armature_object(mmd_translation.id_data) bone_collection: bpy.types.BoneCollection for index, bone_collection in enumerate(armature_object.data.collections): - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.DISPLAY.name mmd_translation_element.object = armature_object mmd_translation_element.data_path = f"data.collections[{index}]" @@ -396,7 +377,7 @@ class MMDDisplayHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.DISPLAY.name: continue @@ -412,7 +393,7 @@ class MMDDisplayHandler(MMDDataHandlerABC): if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod @@ -428,10 +409,7 @@ class MMDDisplayHandler(MMDDataHandlerABC): class MMDPhysicsHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.PHYSICS.name + type_name = MMDTranslationElementType.PHYSICS.name @classmethod def draw_item(cls, layout: bpy.types.UILayout, mmd_translation_element: "MMDTranslationElement", index: int): @@ -451,7 +429,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): cls.prop_restorable(prop_row, mmd_translation_element, "name_j", mmd_object.name_j, index) cls.prop_restorable(prop_row, mmd_translation_element, "name_e", mmd_object.name_e, index) row.prop(obj, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if obj.select_get() else "RESTRICT_SELECT_ON") - row.prop(obj, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if obj.hide_get() else "HIDE_OFF") + row.prop(obj, "hide", text="", emboss=False, icon_only=True) @classmethod def collect_data(cls, mmd_translation: "MMDTranslation"): @@ -460,7 +438,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): obj: bpy.types.Object for obj in model.rigidBodies(): - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name mmd_translation_element.object = obj mmd_translation_element.data_path = "mmd_rigid" @@ -470,7 +448,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): obj: bpy.types.Object for obj in model.joints(): - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.PHYSICS.name mmd_translation_element.object = obj mmd_translation_element.data_path = "mmd_joint" @@ -484,7 +462,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.PHYSICS.name: continue @@ -504,7 +482,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod @@ -536,10 +514,7 @@ class MMDPhysicsHandler(MMDDataHandlerABC): class MMDInfoHandler(MMDDataHandlerABC): - @classmethod - @property - def type_name(cls) -> str: - return MMDTranslationElementType.INFO.name + type_name = MMDTranslationElementType.INFO.name TYPE_TO_ICONS = { "EMPTY": "EMPTY_DATA", @@ -557,7 +532,7 @@ class MMDInfoHandler(MMDDataHandlerABC): cls.prop_disabled(prop_row, mmd_translation_element, "name") cls.prop_disabled(prop_row, mmd_translation_element, "name_e") row.prop(info_object, "select", text="", emboss=False, icon_only=True, icon="RESTRICT_SELECT_OFF" if info_object.select_get() else "RESTRICT_SELECT_ON") - row.prop(info_object, "hide", text="", emboss=False, icon_only=True, icon="HIDE_ON" if info_object.hide_get() else "HIDE_OFF") + row.prop(info_object, "hide", text="", emboss=False, icon_only=True) @classmethod def collect_data(cls, mmd_translation: "MMDTranslation"): @@ -568,7 +543,7 @@ class MMDInfoHandler(MMDDataHandlerABC): info_objects.append(armature_object) for info_object in itertools.chain(info_objects, FnModel.iterate_mesh_objects(root_object)): - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements.add() + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements.add() mmd_translation_element.type = MMDTranslationElementType.INFO.name mmd_translation_element.object = info_object mmd_translation_element.data_path = "" @@ -582,7 +557,7 @@ class MMDInfoHandler(MMDDataHandlerABC): @classmethod def update_query(cls, mmd_translation: "MMDTranslation", filter_selected: bool, filter_visible: bool, check_blank_name: Callable[[str, str], bool]): - mmd_translation_element: "MMDTranslationElement" + mmd_translation_element: MMDTranslationElement for index, mmd_translation_element in enumerate(mmd_translation.translation_elements): if mmd_translation_element.type != MMDTranslationElementType.INFO.name: continue @@ -597,7 +572,7 @@ class MMDInfoHandler(MMDDataHandlerABC): if mmd_translation.filter_restorable and not cls.is_restorable(mmd_translation_element): continue - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices.add() + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices.add() mmd_translation_element_index.value = index @classmethod @@ -627,10 +602,10 @@ MMD_DATA_TYPE_TO_HANDLERS: Dict[str, MMDDataHandlerABC] = {h.type_name: h for h class FnTranslations: @staticmethod def apply_translations(root_object: bpy.types.Object): - mmd_translation: "MMDTranslation" = root_object.mmd_root.translation - mmd_translation_element_index: "MMDTranslationElementIndex" + mmd_translation: MMDTranslation = root_object.mmd_root.translation + mmd_translation_element_index: MMDTranslationElementIndex for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value] handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] name, name_j, name_e = handler.get_names(mmd_translation_element) handler.set_names( @@ -642,7 +617,7 @@ class FnTranslations: @staticmethod def execute_translation_batch(root_object: bpy.types.Object) -> Tuple[Dict[str, str], Optional[bpy.types.Text]]: - mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + mmd_translation: MMDTranslation = root_object.mmd_root.translation batch_operation_script = mmd_translation.batch_operation_script if not batch_operation_script: return ({}, None) @@ -657,9 +632,9 @@ class FnTranslations: batch_operation_script_ast = compile(mmd_translation.batch_operation_script, "", "eval") batch_operation_target: str = mmd_translation.batch_operation_target - mmd_translation_element_index: "MMDTranslationElementIndex" + mmd_translation_element_index: MMDTranslationElementIndex for mmd_translation_element_index in mmd_translation.filtered_translation_element_indices: - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value] handler: MMDDataHandlerABC = MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type] @@ -684,7 +659,7 @@ class FnTranslations: "org_name_j": org_name_j, "org_name_e": org_name_e, }, - ) + ), ) if batch_operation_target == "BLENDER": @@ -701,8 +676,8 @@ class FnTranslations: if mmd_translation.filtered_translation_element_indices_active_index < 0: return - mmd_translation_element_index: "MMDTranslationElementIndex" = mmd_translation.filtered_translation_element_indices[mmd_translation.filtered_translation_element_indices_active_index] - mmd_translation_element: "MMDTranslationElement" = mmd_translation.translation_elements[mmd_translation_element_index.value] + mmd_translation_element_index: MMDTranslationElementIndex = mmd_translation.filtered_translation_element_indices[mmd_translation.filtered_translation_element_indices_active_index] + mmd_translation_element: MMDTranslationElement = mmd_translation.translation_elements[mmd_translation_element_index.value] MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].update_index(mmd_translation_element) @@ -724,7 +699,7 @@ class FnTranslations: filter_visible: bool = mmd_translation.filter_visible def check_blank_name(name_j: str, name_e: str) -> bool: - return filter_japanese_blank and name_j or filter_english_blank and name_e + return (filter_japanese_blank and name_j) or (filter_english_blank and name_e) for handler in MMD_DATA_HANDLERS: if handler.type_name in mmd_translation.filter_types: diff --git a/core/mmd/core/vmd/__init__.py b/core/mmd/core/vmd/__init__.py index f3342f2..7a7d347 100644 --- a/core/mmd/core/vmd/__init__.py +++ b/core/mmd/core/vmd/__init__.py @@ -3,4 +3,4 @@ # This file was originally part of the MMD Tools add-on for Blender # You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. diff --git a/core/mmd/core/vmd/importer.py b/core/mmd/core/vmd/importer.py index 0c865ef..74be845 100644 --- a/core/mmd/core/vmd/importer.py +++ b/core/mmd/core/vmd/importer.py @@ -5,7 +5,7 @@ # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. -import logging +from .....core.logging_setup import logger import math import os from typing import Union @@ -261,7 +261,7 @@ class VMDImporter: def __init__(self, filepath, scale=1.0, bone_mapper=None, use_pose_mode=False, convert_mmd_camera=True, convert_mmd_lamp=True, frame_margin=5, use_mirror=False, use_NLA=False): self.__vmdFile = vmd.File() self.__vmdFile.load(filepath=filepath) - logging.debug(str(self.__vmdFile.header)) + logger.debug(str(self.__vmdFile.header)) self.__scale = scale self.__convert_mmd_camera = convert_mmd_camera self.__convert_mmd_lamp = convert_mmd_lamp @@ -381,7 +381,7 @@ class VMDImporter: def __assignToArmature(self, armObj, action_name=None): boneAnim = self.__vmdFile.boneAnimation - logging.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name) + logger.info("---- bone animations:%5d target: %s", len(boneAnim), armObj.name) if len(boneAnim) < 1: return @@ -412,9 +412,9 @@ class VMDImporter: continue bone = pose_bones.get(name, None) if bone is None: - logging.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames)) + logger.warning("WARNING: not found bone %s (%d frames)", name, len(keyFrames)) continue - logging.info("(bone) frames:%5d name: %s", len(keyFrames), name) + logger.info("(bone) frames:%5d name: %s", len(keyFrames), name) assert bone_name_table.get(bone.name, name) == name bone_name_table[bone.name] = name @@ -480,9 +480,9 @@ class VMDImporter: # property animation propertyAnim = self.__vmdFile.propertyAnimation if len(propertyAnim) > 0: - logging.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name) + logger.info("---- IK animations:%5d target: %s", len(propertyAnim), armObj.name) for keyFrame in propertyAnim: - logging.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states) + logger.debug("(IK) frame:%5d list: %s", keyFrame.frame_number, keyFrame.ik_states) frame = keyFrame.frame_number + self.__frame_margin for ikName, enable in keyFrame.ik_states: bone = pose_bones.get(ikName, None) @@ -516,7 +516,7 @@ class VMDImporter: def __assignToMesh(self, meshObj, action_name=None): shapeKeyAnim = self.__vmdFile.shapeKeyAnimation - logging.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name) + logger.info("---- morph animations:%5d target: %s", len(shapeKeyAnim), meshObj.name) if len(shapeKeyAnim) < 1: return @@ -530,9 +530,9 @@ class VMDImporter: for name, keyFrames in shapeKeyAnim.items(): if name not in shapeKeyDict: - logging.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames)) + logger.warning("WARNING: not found shape key %s (%d frames)", name, len(keyFrames)) continue - logging.info("(mesh) frames:%5d name: %s", len(keyFrames), name) + logger.info("(mesh) frames:%5d name: %s", len(keyFrames), name) shapeKey = shapeKeyDict[name] channelbag = self.__get_channelbag(action, meshObj.data.shape_keys) fcurve = channelbag.fcurves.new(data_path='key_blocks["%s"].value' % shapeKey.name) @@ -549,14 +549,14 @@ class VMDImporter: def __assignToRoot(self, rootObj, action_name=None): propertyAnim = self.__vmdFile.propertyAnimation - logging.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name) + logger.info("---- display animations:%5d target: %s", len(propertyAnim), rootObj.name) if len(propertyAnim) < 1: return action_name = action_name or rootObj.name action = bpy.data.actions.new(name=action_name) - logging.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim]) + logger.debug("(Display) list(frame, show): %s", [(keyFrame.frame_number, bool(keyFrame.visible)) for keyFrame in propertyAnim]) for keyFrame in propertyAnim: self.__keyframe_insert(action, "mmd_root.show_meshes", keyFrame.frame_number + self.__frame_margin, float(keyFrame.visible), rootObj) @@ -579,7 +579,7 @@ class VMDImporter: cameraObj = mmdCameraInstance.camera() cameraAnim = self.__vmdFile.cameraAnimation - logging.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name) + logger.info("(camera) frames:%5d name: %s", len(cameraAnim), mmdCamera.name) if len(cameraAnim) < 1: return @@ -650,7 +650,7 @@ class VMDImporter: lampObj = mmdLampInstance.lamp() lampAnim = self.__vmdFile.lampAnimation - logging.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name) + logger.info("(lamp) frames:%5d name: %s", len(lampAnim), mmdLamp.name) if len(lampAnim) < 1: return diff --git a/core/mmd/cycles_converter.py b/core/mmd/cycles_converter.py index 3fb5471..ae12236 100644 --- a/core/mmd/cycles_converter.py +++ b/core/mmd/cycles_converter.py @@ -1,48 +1,39 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2012 MMD Tools authors +# This file is part of MMD Tools. -from typing import Iterable, Optional, Any, List, Tuple, Union +from typing import Iterable, Optional import bpy -from bpy.types import Material, NodeTree, Node, NodeSocket, ShaderNodeGroup, ShaderNodeOutputMaterial, NodeLink -from ..logging_setup import logger -from .core.shader import _NodeGroupUtils from .core.material import FnMaterial +from .core.shader import _NodeGroupUtils -def __switchToCyclesRenderEngine() -> None: +def __switchToCyclesRenderEngine(): if bpy.context.scene.render.engine != "CYCLES": - logger.debug("Switching render engine to Cycles") bpy.context.scene.render.engine = "CYCLES" -def __exposeNodeTreeInput(in_socket: NodeSocket, name: str, default_value: Any, node_input: Node, shader: NodeTree) -> None: +def __exposeNodeTreeInput(in_socket, name, default_value, node_input, shader): _NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value) -def __exposeNodeTreeOutput(out_socket: NodeSocket, name: str, node_output: Node, shader: NodeTree) -> None: +def __exposeNodeTreeOutput(out_socket, name, node_output, shader): _NodeGroupUtils(shader).new_output_socket(name, out_socket) -def __getMaterialOutput(nodes: bpy.types.Nodes, bl_idname: str) -> Node: +def __getMaterialOutput(nodes, bl_idname): o = next((n for n in nodes if n.bl_idname == bl_idname and n.is_active_output), None) or nodes.new(bl_idname) o.is_active_output = True return o -def create_MMDAlphaShader() -> NodeTree: +def create_MMDAlphaShader(): __switchToCyclesRenderEngine() if "MMDAlphaShader" in bpy.data.node_groups: - logger.debug("Using existing MMDAlphaShader node group") return bpy.data.node_groups["MMDAlphaShader"] - logger.info("Creating new MMDAlphaShader node group") shader = bpy.data.node_groups.new(name="MMDAlphaShader", type="ShaderNodeTree") node_input = shader.nodes.new("NodeGroupInput") @@ -64,28 +55,26 @@ def create_MMDAlphaShader() -> NodeTree: return shader -def create_MMDBasicShader() -> NodeTree: +def create_MMDBasicShader(): __switchToCyclesRenderEngine() if "MMDBasicShader" in bpy.data.node_groups: - logger.debug("Using existing MMDBasicShader node group") return bpy.data.node_groups["MMDBasicShader"] - logger.info("Creating new MMDBasicShader node group") - shader: NodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree") + shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree") - node_input: Node = shader.nodes.new("NodeGroupInput") - node_output: Node = shader.nodes.new("NodeGroupOutput") + node_input: bpy.types.NodeGroupInput = shader.nodes.new("NodeGroupInput") + node_output: bpy.types.NodeGroupOutput = shader.nodes.new("NodeGroupOutput") node_output.location.x += 250 node_input.location.x -= 500 - dif: Node = shader.nodes.new("ShaderNodeBsdfDiffuse") + dif: bpy.types.ShaderNodeBsdfDiffuse = shader.nodes.new("ShaderNodeBsdfDiffuse") dif.location.x -= 250 dif.location.y += 150 - glo: Node = shader.nodes.new("ShaderNodeBsdfAnisotropic") + glo: bpy.types.ShaderNodeBsdfAnisotropic = shader.nodes.new("ShaderNodeBsdfAnisotropic") glo.location.x -= 250 glo.location.y -= 150 - mix: Node = shader.nodes.new("ShaderNodeMixShader") + mix: bpy.types.ShaderNodeMixShader = shader.nodes.new("ShaderNodeMixShader") shader.links.new(mix.inputs[1], dif.outputs["BSDF"]) shader.links.new(mix.inputs[2], glo.outputs["BSDF"]) @@ -98,63 +87,62 @@ def create_MMDBasicShader() -> NodeTree: return shader -def __enum_linked_nodes(node: Node) -> Iterable[Node]: +def __enum_linked_nodes(node: bpy.types.Node) -> Iterable[bpy.types.Node]: yield node if node.parent: yield node.parent - for n in set(l.from_node for i in node.inputs for l in i.links): + for n in {link.from_node for i in node.inputs for link in i.links}: yield from __enum_linked_nodes(n) -def __cleanNodeTree(material: Material) -> None: - logger.debug(f"Cleaning node tree for material: {material.name}") +def __cleanNodeTree(material: bpy.types.Material): nodes = material.node_tree.nodes - node_names = set(n.name for n in nodes) + node_names = {n.name for n in nodes} for o in (n for n in nodes if n.bl_idname in {"ShaderNodeOutput", "ShaderNodeOutputMaterial"}): if any(i.is_linked for i in o.inputs): - node_names -= set(linked.name for linked in __enum_linked_nodes(o)) + node_names -= {linked.name for linked in __enum_linked_nodes(o)} for name in node_names: nodes.remove(nodes[name]) -def convertToCyclesShader(obj: bpy.types.Object, use_principled: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None: - logger.info(f"Converting {obj.name} to Cycles shader (use_principled={use_principled}, clean_nodes={clean_nodes})") +def convertToCyclesShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001): __switchToCyclesRenderEngine() convertToBlenderShader(obj, use_principled, clean_nodes, subsurface) -def convertToBlenderShader(obj: bpy.types.Object, use_principled: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None: +def convertToBlenderShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001): for i in obj.material_slots: if not i.material: continue - # Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes - if not i.material.node_tree or len(i.material.node_tree.nodes) == 0: - logger.debug(f"Setting up node tree for material: {i.material.name}") + # use_nodes is deprecated in 5.0 but always returns True and setting it is safe + if not i.material.use_nodes: + i.material.use_nodes = True __convertToMMDBasicShader(i.material) if use_principled: - logger.debug(f"Converting material to Principled BSDF: {i.material.name}") __convertToPrincipledBsdf(i.material, subsurface) if clean_nodes: __cleanNodeTree(i.material) -def convertToMMDShader(obj: bpy.types.Object) -> None: + +def convertToMMDShader(obj): """BSDF -> MMDShaderDev conversion.""" - logger.info(f"Converting {obj.name} to MMD shader") for i in obj.material_slots: if not i.material: continue - # Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes + # use_nodes is deprecated in 5.0 but always returns True and setting it is safe + if not i.material.use_nodes: + i.material.use_nodes = True FnMaterial.convert_to_mmd_material(i.material) -def __convertToMMDBasicShader(material: Material) -> None: - logger.debug(f"Converting material to MMD Basic Shader: {material.name}") + +def __convertToMMDBasicShader(material: bpy.types.Material): # TODO: test me mmd_basic_shader_grp = create_MMDBasicShader() mmd_alpha_shader_grp = create_MMDAlphaShader() - if not any(filter(lambda x: isinstance(x, ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)): + if not any(filter(lambda x: isinstance(x, bpy.types.ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)): # Add nodes for Cycles Render - shader: ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") + shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") shader.node_tree = mmd_basic_shader_grp shader.inputs[0].default_value[:3] = material.diffuse_color[:3] shader.inputs[1].default_value[:3] = material.specular_color[:3] @@ -169,8 +157,7 @@ def __convertToMMDBasicShader(material: Material) -> None: alpha_value = material.diffuse_color[3] if alpha_value < 1.0: - logger.debug(f"Material has alpha: {material.name}, alpha={alpha_value}") - alpha_shader: ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") + alpha_shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") alpha_shader.location.x = shader.location.x + 250 alpha_shader.location.y = shader.location.y - 150 alpha_shader.node_tree = mmd_alpha_shader_grp @@ -178,22 +165,21 @@ def __convertToMMDBasicShader(material: Material) -> None: material.node_tree.links.new(alpha_shader.inputs[0], outplug) outplug = alpha_shader.outputs[0] - material_output: ShaderNodeOutputMaterial = __getMaterialOutput(material.node_tree.nodes, "ShaderNodeOutputMaterial") + material_output: bpy.types.ShaderNodeOutputMaterial = __getMaterialOutput(material.node_tree.nodes, "ShaderNodeOutputMaterial") material.node_tree.links.new(material_output.inputs["Surface"], outplug) material_output.location.x = shader.location.x + 500 material_output.location.y = shader.location.y - 150 -def __convertToPrincipledBsdf(material: Material, subsurface: float) -> None: - logger.debug(f"Converting material to Principled BSDF: {material.name}") +def __convertToPrincipledBsdf(material: bpy.types.Material, subsurface: float): node_names = set() - for s in (n for n in material.node_tree.nodes if isinstance(n, ShaderNodeGroup)): + for s in (n for n in material.node_tree.nodes if isinstance(n, bpy.types.ShaderNodeGroup)): if s.node_tree.name == "MMDBasicShader": - l: NodeLink - for l in s.outputs[0].links: - to_node = l.to_node + link: bpy.types.NodeLink + for link in s.outputs[0].links: + to_node = link.to_node # assuming there is no bpy.types.NodeReroute between MMDBasicShader and MMDAlphaShader - if isinstance(to_node, ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader": + if isinstance(to_node, bpy.types.ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader": __switchToPrincipledBsdf(material.node_tree, s, subsurface, node_alpha=to_node) node_names.add(to_node.name) else: @@ -208,9 +194,8 @@ def __convertToPrincipledBsdf(material: Material, subsurface: float) -> None: nodes.remove(nodes[name]) -def __switchToPrincipledBsdf(node_tree: NodeTree, node_basic: ShaderNodeGroup, subsurface: float, node_alpha: Optional[ShaderNodeGroup] = None) -> None: - logger.debug(f"Switching to Principled BSDF: {node_basic.name}") - shader: Node = node_tree.nodes.new("ShaderNodeBsdfPrincipled") +def __switchToPrincipledBsdf(node_tree: bpy.types.NodeTree, node_basic: bpy.types.ShaderNodeGroup, subsurface: float, node_alpha: Optional[bpy.types.ShaderNodeGroup] = None): + shader: bpy.types.ShaderNodeBsdfPrincipled = node_tree.nodes.new("ShaderNodeBsdfPrincipled") shader.parent = node_basic.parent shader.location.x = node_basic.location.x shader.location.y = node_basic.location.y @@ -238,7 +223,7 @@ def __switchToPrincipledBsdf(node_tree: NodeTree, node_basic: ShaderNodeGroup, s if alpha_socket_name in node_alpha.inputs: if "Alpha" in shader.inputs: - shader.inputs["Alpha"].default_value = node_alpha.inputs[alpha_socket_name].default_value + shader.inputs["Alpha"].default_value = node_alpha.inputs["Alpha"].default_value if node_alpha.inputs[alpha_socket_name].is_linked: node_tree.links.new(node_alpha.inputs[alpha_socket_name].links[0].from_socket, shader.inputs["Alpha"]) else: @@ -254,5 +239,5 @@ def __switchToPrincipledBsdf(node_tree: NodeTree, node_basic: ShaderNodeGroup, s node_tree.links.new(node_alpha.inputs[alpha_socket_name].links[0].from_socket, node_invert.inputs[1]) node_tree.links.new(node_invert.outputs[0], shader.inputs["Transmission"]) - for l in output_links: - node_tree.links.new(shader.outputs[0], l.to_socket) + for link in output_links: + node_tree.links.new(shader.outputs[0], link.to_socket) diff --git a/core/mmd/operators/__init__.py b/core/mmd/operators/__init__.py index f3342f2..7a7d347 100644 --- a/core/mmd/operators/__init__.py +++ b/core/mmd/operators/__init__.py @@ -3,4 +3,4 @@ # This file was originally part of the MMD Tools add-on for Blender # You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. \ No newline at end of file +# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. diff --git a/core/mmd/operators/fileio.py b/core/mmd/operators/fileio.py new file mode 100644 index 0000000..189e716 --- /dev/null +++ b/core/mmd/operators/fileio.py @@ -0,0 +1,1209 @@ +# Copyright 2014 MMD Tools authors +# This file is part of MMD Tools. + +from ....core.logging_setup import logger +import os +import re +import time +import traceback + +import bpy +from bpy.types import Operator, OperatorFileListElement +from bpy_extras.io_utils import ExportHelper, ImportHelper + +# from .. import auto_scene_setup # Not used in Avatar Toolkit +from ..core.camera import MMDCamera +from ..core.lamp import MMDLamp +from ..core.model import FnModel, Model +# from ..core.pmd import importer as pmd_importer # PMD not supported in Avatar Toolkit +# from ..core.pmx import exporter as pmx_exporter # PMX export not supported in Avatar Toolkit +from ..core.pmx import importer as pmx_importer +# from ..core.vmd import exporter as vmd_exporter # VMD export not supported in Avatar Toolkit +from ..core.vmd import importer as vmd_importer +# from ..core.vpd import exporter as vpd_exporter # VPD not supported in Avatar Toolkit +# from ..core.vpd import importer as vpd_importer # VPD not supported in Avatar Toolkit +from ..translations import DictionaryEnum +from ..utils import makePmxBoneMap + +LOG_LEVEL_ITEMS = [ + ("DEBUG", "4. DEBUG", "", 1), + ("INFO", "3. INFO", "", 2), + ("WARNING", "2. WARNING", "", 3), + ("ERROR", "1. ERROR", "", 4), +] + + +def log_handler(log_level, filepath=None): + if filepath is None: + handler = logger.StreamHandler() + else: + handler = logger.FileHandler(filepath, mode="w", encoding="utf-8") + formatter = logger.Formatter("%(message)s") + handler.setFormatter(formatter) + return handler + + +def _update_types(cls, prop): + types = cls.types.copy() + + if "PHYSICS" in types: + types.add("ARMATURE") + if "DISPLAY" in types: + types.add("ARMATURE") + if "MORPHS" in types: + types.add("ARMATURE") + types.add("MESH") + + if types != cls.types: + cls.types = types # trigger update + + +def get_addon_package_name(): + """Get the root package name for addon preferences""" + current_package = __package__ + parts = current_package.split(".") + try: + index = parts.index("mmd_tools_local") + return ".".join(parts[: index + 1]) + except ValueError: + pass + return current_package + + +def get_preset_directories(operator_bl_idname): + """Get preset directories for an operator""" + preset_dirs = [] + + try: + # Try the official API first + official_dirs = bpy.utils.preset_paths(operator_bl_idname) + preset_dirs.extend(official_dirs) + + # Add manual preset paths as fallback + scripts_dir = bpy.utils.user_resource("SCRIPTS") + config_dir = bpy.utils.user_resource("CONFIG") + + manual_preset_paths = [ + os.path.join(scripts_dir, "presets", "operator", operator_bl_idname), + os.path.join(config_dir, "presets", "operator", operator_bl_idname), + ] + + for path in manual_preset_paths: + if os.path.exists(path) and path not in preset_dirs: + preset_dirs.append(path) + + except Exception: + pass + + return preset_dirs + + +def apply_operator_preset(operator, preset_name): + """Apply a saved preset to an operator instance""" + if not preset_name: + return False + + try: + preset_dirs = get_preset_directories(operator.__class__.bl_idname) + + if not preset_dirs: + return False + + # Look for the preset file + preset_file = None + for path in preset_dirs: + potential_file = os.path.join(path, preset_name + ".py") + if os.path.exists(potential_file): + preset_file = potential_file + break + + if not preset_file: + return False + + # Execute preset with proper context + with bpy.context.temp_override(active_operator=operator): + try: + with open(preset_file, encoding="utf-8") as f: + preset_code = f.read() + + namespace = {"bpy": bpy} + exec(preset_code, namespace) + return True + + except Exception: + return False + + except Exception: + return False + + +def get_available_presets(operator_bl_idname): + """Get list of available presets for an operator""" + presets = [] + + try: + preset_dirs = get_preset_directories(operator_bl_idname) + + for preset_dir in preset_dirs: + try: + for filename in os.listdir(preset_dir): + if filename.endswith(".py"): + preset_name = filename[:-3] # Remove .py extension + if preset_name not in presets: + presets.append(preset_name) + except Exception: + continue + + return sorted(presets) + + except Exception: + return [] + + +def load_default_settings_from_preferences(operator, context, preset_property_name): + """Load default settings from preferences using preset""" + try: + addon_package = get_addon_package_name() + addon_prefs = context.preferences.addons.get(addon_package) + + if not addon_prefs: + return False + + prefs = addon_prefs.preferences + + # Check if the preset property exists + if not hasattr(prefs, preset_property_name): + return False + + # Apply preset if specified + preset_name = getattr(prefs, preset_property_name, "") + if preset_name and apply_operator_preset(operator, preset_name): + return True + + return False + + except Exception: + return False + + +def get_armature_display_items(self, context): + # https://docs.blender.org/api/current/bpy.props.html#bpy.props.EnumProperty + # self & context are required, even though they are not used in function + enum_items = bpy.types.Armature.bl_rna.properties["display_type"].enum_items + return [(item.identifier, item.name, "") for item in enum_items] + + +class PreferencesMixin: + """Mixin for operators that load default settings from preferences""" + + _preferences_applied = False + + def load_preferences_on_invoke(self, context, preset_property_name): + """Load preferences on first invoke""" + self._preferences_were_applied = getattr(self.__class__, "_preferences_applied", False) + if not self._preferences_were_applied: + if load_default_settings_from_preferences(self, context, preset_property_name): + self.__class__._preferences_applied = True + + def restore_preferences_on_cancel(self): + """Restore preferences state on cancel""" + self.__class__._preferences_applied = self._preferences_were_applied + + +class ImportPmx(Operator, ImportHelper, PreferencesMixin): + bl_idname = "mmd_tools.import_model" + bl_label = "Import Model File (.pmd, .pmx)" + bl_description = "Import model file(s) (.pmd, .pmx)" + bl_options = {"REGISTER", "UNDO", "PRESET"} + + files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) + directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) + + filename_ext = ".pmx" + filter_glob: bpy.props.StringProperty(default="*.pmx;*.pmd", options={"HIDDEN"}) + + types: bpy.props.EnumProperty( + name="Types", + description="Select which parts will be imported", + options={"ENUM_FLAG"}, + items=[ + ("MESH", "Mesh", "Mesh", 1), + ("ARMATURE", "Armature", "Armature", 2), + ("PHYSICS", "Physics", "Rigidbodies and joints (include Armature)", 4), + ("DISPLAY", "Display", "Display frames (include Armature)", 8), + ("MORPHS", "Morphs", "Morphs (include Armature and Mesh)", 16), + ], + default={ + "MESH", + "ARMATURE", + "PHYSICS", + "DISPLAY", + "MORPHS", + }, + update=_update_types, + ) + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for importing the model", + default=0.08, + ) + clean_model: bpy.props.BoolProperty( + name="Clean Model", + description="Remove unused vertices and duplicated/invalid faces", + default=True, + ) + remove_doubles: bpy.props.BoolProperty( + name="Remove Doubles", + description="Merge duplicated vertices and faces.\nWarning: This will perform global vertex merging instead of per-material vertex merging which may break mesh geometry, material boundaries, and distort the UV map. Use with caution.", + default=False, + ) + import_adduv2_as_vertex_colors: bpy.props.BoolProperty( + name="Import Vertex Colors", + description="Import ADD UV2 data as vertex colors. When enabled, the UV2 layer will still be created.", + default=False, + ) + fix_bone_order: bpy.props.BoolProperty( + name="Fix Bone Order", + description="Automatically fix bone order after import. This ensures bones are ordered correctly for MMD compatibility.", + default=True, + ) + fix_ik_links: bpy.props.BoolProperty( + name="Fix IK Links", + description="Fix IK links to be blender suitable", + default=False, + ) + ik_loop_factor: bpy.props.IntProperty( + name="IK Loop Factor", + description="Scaling factor of MMD IK loop", + min=1, + soft_max=10, + max=100, + default=5, + ) + apply_bone_fixed_axis: bpy.props.BoolProperty( + name="Apply Bone Fixed Axis", + description="Apply bone's fixed axis to be blender suitable", + default=False, + ) + rename_bones: bpy.props.BoolProperty( + name="Rename Bones - L / R Suffix", + description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", + default=True, + ) + use_underscore: bpy.props.BoolProperty( + name="Rename Bones - Use Underscore", + description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", + default=False, + ) + dictionary: bpy.props.EnumProperty( + name="Rename Bones To English", + items=DictionaryEnum.get_dictionary_items, + description="Translate bone names from Japanese to English using selected dictionary", + ) + bone_disp_mode: bpy.props.EnumProperty( + name="Bone Display Mode", + items=get_armature_display_items, + description="Change how bones look in viewport.", + ) + use_mipmap: bpy.props.BoolProperty( + name="use MIP maps for UV textures", + description="Specify if mipmaps will be generated", + default=True, + ) + sph_blend_factor: bpy.props.FloatProperty( + name="influence of .sph textures", + description="The diffuse color factor of texture slot for .sph textures", + default=1.0, + ) + spa_blend_factor: bpy.props.FloatProperty( + name="influence of .spa textures", + description="The diffuse color factor of texture slot for .spa textures", + default=1.0, + ) + add_rigid_body_world: bpy.props.BoolProperty( + name="Add Rigid Body World", + description="Automatically add Rigid Body World to the scene when importing physics.", + default=True, + ) + log_level: bpy.props.EnumProperty( + name="Log level", + description="Select log level", + items=LOG_LEVEL_ITEMS, + default="INFO", + ) + save_log: bpy.props.BoolProperty( + name="Create a log file", + description="Create a log file", + default=False, + ) + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_pmx_import_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def execute(self, context): + try: + self.__translator = DictionaryEnum.get_translator(self.dictionary) + if self.directory: + for f in self.files: + self.filepath = os.path.join(self.directory, f.name) + self._do_execute(context) + elif self.filepath: + self._do_execute(context) + except Exception: + logger.exception("Error occurred") + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + return {"FINISHED"} + + def _do_execute(self, context): + # Avatar Toolkit uses its own logger system, skip log configuration + # logger level and handlers are managed globally + + try: + importer_cls = pmx_importer.PMXImporter + if re.search(r"\.pmd$", self.filepath, flags=re.IGNORECASE): + # PMD format not supported in Avatar Toolkit + self.report({"ERROR"}, "PMD format is not supported. Please convert to PMX format.") + return {"CANCELLED"} + + importer_cls().execute( + filepath=self.filepath, + types=self.types, + scale=self.scale, + clean_model=self.clean_model, + remove_doubles=self.remove_doubles, + import_adduv2_as_vertex_colors=self.import_adduv2_as_vertex_colors, + fix_bone_order=self.fix_bone_order, + fix_ik_links=self.fix_ik_links, + ik_loop_factor=self.ik_loop_factor, + apply_bone_fixed_axis=self.apply_bone_fixed_axis, + rename_LR_bones=self.rename_bones, + use_underscore=self.use_underscore, + bone_disp_mode=self.bone_disp_mode, + translator=self.__translator, + use_mipmap=self.use_mipmap, + sph_blend_factor=self.sph_blend_factor, + spa_blend_factor=self.spa_blend_factor, + add_rigid_body_world=self.add_rigid_body_world, + ) + self.report({"INFO"}, f'Imported MMD model from "{self.filepath}"') + except Exception: + logger.exception("Error occurred") + raise + + return {"FINISHED"} + + +class ImportVmd(Operator, ImportHelper, PreferencesMixin): + bl_idname = "mmd_tools.import_vmd" + bl_label = "Import VMD File (.vmd)" + bl_description = "Import a VMD file to selected objects (.vmd)\nBehavior varies depending on the selected object:\n- Select the root (cross under the model): imports both armature and morph animations\n- Select the model: imports only morph animation\n- Select the armature: imports only armature animation" + bl_options = {"REGISTER", "UNDO", "PRESET"} + + files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) + directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) + + filename_ext = ".vmd" + filter_glob: bpy.props.StringProperty(default="*.vmd", options={"HIDDEN"}) + + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for importing the motion", + default=0.08, + ) + margin: bpy.props.IntProperty( + name="Margin", + description="Number of frames to add before the motion starts (only applies if current frame is 0 or 1)", + min=0, + default=0, + ) + bone_mapper: bpy.props.EnumProperty( + name="Bone Mapper", + description="Select bone mapper", + items=[ + ("BLENDER", "Blender", "Use blender bone name", 0), + ("PMX", "PMX", "Use japanese name of MMD bone", 1), + ("RENAMED_BONES", "Renamed bones", "Rename the bone of motion data to be blender suitable", 2), + ], + default="PMX", + ) + rename_bones: bpy.props.BoolProperty( + name="Rename Bones - L / R Suffix", + description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", + default=True, + ) + use_underscore: bpy.props.BoolProperty( + name="Rename Bones - Use Underscore", + description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", + default=False, + ) + dictionary: bpy.props.EnumProperty( + name="Rename Bones To English", + items=DictionaryEnum.get_dictionary_items, + description="Translate bone names from Japanese to English using selected dictionary", + ) + use_pose_mode: bpy.props.BoolProperty( + name="Treat Current Pose as Rest Pose", + description="You can pose the model to fit the original pose of a motion data, such as T-Pose or A-Pose", + default=False, + options={"SKIP_SAVE"}, + ) + use_mirror: bpy.props.BoolProperty( + name="Mirror Motion", + description="Import the motion by using X-Axis mirror", + default=False, + ) + update_scene_settings: bpy.props.BoolProperty( + name="Update scene settings", + description="Update frame range and frame rate (30 fps)", + default=True, + ) + create_new_action: bpy.props.BoolProperty( + name="Create New Action", + description="Create a new action when importing VMD, otherwise add keyframes to existing actions if available. Note: This option is ignored when 'Use NLA' is enabled.", + default=False, + ) + use_nla: bpy.props.BoolProperty( + name="Use NLA", + description="Import the motion as NLA strips", + default=False, + ) + detect_camera_changes: bpy.props.BoolProperty( + name="Detect Camera Cut", + description="When the interval between camera keyframes is 1 frame, change the interpolation to CONSTANT. This is useful when making a 60fps video, as it helps prevent unwanted smoothing between rapid camera cuts.", + default=True, + ) + detect_lamp_changes: bpy.props.BoolProperty( + # TODO: Update all instances of "lamp" to "light" throughout the repository to align with Blender 2.80+ API changes. + # This includes: + # - Variable names and references + # - Class/type checks (LAMP -> LIGHT) + # - Documentation and comments + # - Function parameters and return values + # This change is necessary since Blender 2.80 renamed the "Lamp" type to "Light". + name="Detect Light Cut", + description="When the interval between light keyframes is 1 frame, change the interpolation to CONSTANT. This is useful when making a 60fps video, as it helps prevent unwanted smoothing during sudden lighting changes.", + default=True, + ) + log_level: bpy.props.EnumProperty( + name="Log level", + description="Select log level", + items=LOG_LEVEL_ITEMS, + default="INFO", + ) + save_log: bpy.props.BoolProperty( + name="Create a log file", + description="Create a log file", + default=False, + ) + + @classmethod + def poll(cls, context): + return len(context.selected_objects) > 0 + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_vmd_import_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def draw(self, context): + layout = self.layout + layout.prop(self, "scale") + layout.prop(self, "margin") + layout.prop(self, "create_new_action") + layout.prop(self, "use_nla") + + layout.prop(self, "bone_mapper") + if self.bone_mapper == "RENAMED_BONES": + layout.prop(self, "rename_bones") + layout.prop(self, "use_underscore") + layout.prop(self, "dictionary") + layout.prop(self, "use_pose_mode") + layout.prop(self, "use_mirror") + layout.prop(self, "detect_camera_changes") + layout.prop(self, "detect_lamp_changes") + + layout.prop(self, "update_scene_settings") + + layout.prop(self, "log_level") + layout.prop(self, "save_log") + + def execute(self, context): + logger = logger.getLogger() + logger.setLevel(self.log_level) + handler = None + if self.save_log: + handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.import.log") + logger.addHandler(handler) + + try: + selected_objects = set(context.selected_objects) + for i in frozenset(selected_objects): + root = FnModel.find_root_object(i) + if root == i: + rig = Model(root) + armature = rig.armature() + if armature is not None: + selected_objects.add(armature) + placeholder = rig.morph_slider.placeholder() + if placeholder is not None: + selected_objects.add(placeholder) + selected_objects |= set(rig.meshes()) + + bone_mapper = None + if self.bone_mapper == "PMX": + bone_mapper = makePmxBoneMap + elif self.bone_mapper == "RENAMED_BONES": + bone_mapper = vmd_importer.RenamedBoneMapper( + rename_LR_bones=self.rename_bones, + use_underscore=self.use_underscore, + translator=DictionaryEnum.get_translator(self.dictionary), + ).init + + if self.files: + if self.create_new_action: + for obj in selected_objects: + self.__reset_all_animations(obj) + + for file in self.files: + start_time = time.time() + importer = vmd_importer.VMDImporter( + filepath=os.path.join(self.directory, file.name), + scale=self.scale, + bone_mapper=bone_mapper, + use_pose_mode=self.use_pose_mode, + frame_margin=self.margin, + use_mirror=self.use_mirror, + use_nla=self.use_nla, + detect_camera_changes=self.detect_camera_changes, + detect_lamp_changes=self.detect_lamp_changes, + ) + + for i in selected_objects: + importer.assign(i) + logger.info(" Finished importing motion in %f seconds.", time.time() - start_time) + + if self.update_scene_settings: + # auto_scene_setup.setupFrameRanges() # Not available in Avatar Toolkit + # auto_scene_setup.setupFps() # Not available in Avatar Toolkit + pass + context.scene.frame_set(context.scene.frame_current) + + except Exception: + logger.exception("Error occurred") + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + finally: + if handler: + logger.removeHandler(handler) + + return {"FINISHED"} + + def __reset_all_animations(self, target_obj): + """Reset all animation states for the target object and related MMD model objects""" + root_object = FnModel.find_root_object(target_obj) + objects_to_process = set() + + if root_object: + objects_to_process.add(root_object) + objects_to_process.add(target_obj) + # Add armature object + armature_object = FnModel.find_armature_object(root_object) + if armature_object: + objects_to_process.add(armature_object) + # Add all mesh objects + objects_to_process.update(FnModel.iterate_mesh_objects(root_object)) + # Add other group objects if they exist + rigid_group = FnModel.find_rigid_group_object(root_object) + if rigid_group: + objects_to_process.add(rigid_group) + joint_group = FnModel.find_joint_group_object(root_object) + if joint_group: + objects_to_process.add(joint_group) + temporary_group = FnModel.find_temporary_group_object(root_object) + if temporary_group: + objects_to_process.add(temporary_group) + else: + objects_to_process.add(target_obj) + + # STEP 1: Clear all existing actions first + for obj in objects_to_process: + # Clear object's own actions + if obj.animation_data: + obj.animation_data.action = None + + # Clear Shape Keys actions + if hasattr(obj, "data") and hasattr(obj.data, "shape_keys") and obj.data.shape_keys: + if obj.data.shape_keys.animation_data: + obj.data.shape_keys.animation_data.action = None + + # Clear light data actions + if obj.type == "LIGHT" and obj.data.animation_data: + obj.data.animation_data.action = None + + # STEP 2: Reset all properties to default states + for obj in objects_to_process: + if obj.type == "ARMATURE": + # Reset armature pose + for bone in obj.pose.bones: + bone.location = (0.0, 0.0, 0.0) + bone.rotation_quaternion = (1.0, 0.0, 0.0, 0.0) + bone.rotation_euler = (0.0, 0.0, 0.0) + bone.rotation_axis_angle = (0.0, 0.0, 1.0, 0.0) + bone.scale = (1.0, 1.0, 1.0) + + # Reset IK settings to default + if hasattr(bone, "mmd_ik_toggle"): + bone.mmd_ik_toggle = True + + elif obj.type == "MESH" and getattr(obj.data, "shape_keys", None): + # Reset mesh morphs + for shape_key in obj.data.shape_keys.key_blocks: + if shape_key.name != "Basis": # Don't reset basis shape key + shape_key.value = 0.0 + + elif hasattr(obj, "mmd_type") and obj.mmd_type == "ROOT": + # Reset root display state + if hasattr(obj, "mmd_root") and hasattr(obj.mmd_root, "show_meshes"): + obj.mmd_root.show_meshes = True # Default to show meshes + + +# VPD format not supported in Avatar Toolkit - classes below are disabled +class ImportVpd(Operator, ImportHelper, PreferencesMixin): + _is_registered = True # Prevent auto_load from registering this + bl_idname = "mmd_tools.import_vpd" + bl_label = "Import VPD File (.vpd)" + bl_description = "Import VPD file(s) to selected rig's Action Pose (.vpd)\nBehavior varies depending on the selected object:\n- Select the root (cross under the model): applies both armature pose and morphs\n- Select the model: applies only morphs\n- Select the armature: applies only armature pose" + bl_options = {"REGISTER", "UNDO", "PRESET"} + + files: bpy.props.CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"}) + directory: bpy.props.StringProperty(maxlen=1024, subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"}) + + filename_ext = ".vpd" + filter_glob: bpy.props.StringProperty(default="*.vpd", options={"HIDDEN"}) + + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for importing the pose", + default=0.08, + ) + bone_mapper: bpy.props.EnumProperty( + name="Bone Mapper", + description="Select bone mapper", + items=[ + ("BLENDER", "Blender", "Use blender bone name", 0), + ("PMX", "PMX", "Use japanese name of MMD bone", 1), + ("RENAMED_BONES", "Renamed bones", "Rename the bone of pose data to be blender suitable", 2), + ], + default="PMX", + ) + rename_bones: bpy.props.BoolProperty( + name="Rename Bones - L / R Suffix", + description="Use Blender naming conventions for Left / Right paired bones. Required for features like mirror editing and pose mirroring to function properly.", + default=True, + ) + use_underscore: bpy.props.BoolProperty( + name="Rename Bones - Use Underscore", + description="Will not use dot, e.g. if renaming bones, will use _R instead of .R", + default=False, + ) + dictionary: bpy.props.EnumProperty( + name="Rename Bones To English", + items=DictionaryEnum.get_dictionary_items, + description="Translate bone names from Japanese to English using selected dictionary", + ) + use_pose_mode: bpy.props.BoolProperty( + name="Treat Current Pose as Rest Pose", + description="You can pose the model to fit the original pose of a pose data, such as T-Pose or A-Pose", + default=False, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + return len(context.selected_objects) > 0 + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_vpd_import_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def draw(self, context): + layout = self.layout + layout.prop(self, "scale") + + layout.prop(self, "bone_mapper") + if self.bone_mapper == "RENAMED_BONES": + layout.prop(self, "rename_bones") + layout.prop(self, "use_underscore") + layout.prop(self, "dictionary") + layout.prop(self, "use_pose_mode") + + def execute(self, context): + selected_objects = set(context.selected_objects) + for i in frozenset(selected_objects): + root = FnModel.find_root_object(i) + if root == i: + rig = Model(root) + armature = rig.armature() + if armature is not None: + selected_objects.add(armature) + placeholder = rig.morph_slider.placeholder() + if placeholder is not None: + selected_objects.add(placeholder) + selected_objects |= set(rig.meshes()) + + bone_mapper = None + if self.bone_mapper == "PMX": + bone_mapper = makePmxBoneMap + elif self.bone_mapper == "RENAMED_BONES": + bone_mapper = vmd_importer.RenamedBoneMapper( + rename_LR_bones=self.rename_bones, + use_underscore=self.use_underscore, + translator=DictionaryEnum.get_translator(self.dictionary), + ).init + + for f in self.files: + importer = vpd_importer.VPDImporter( + filepath=os.path.join(self.directory, f.name), + scale=self.scale, + bone_mapper=bone_mapper, + use_pose_mode=self.use_pose_mode, + ) + for i in selected_objects: + importer.assign(i) + return {"FINISHED"} + + +class ExportPmx(Operator, ExportHelper, PreferencesMixin): + _is_registered = True # Prevent auto_load from registering this (PMX export not supported) + bl_idname = "mmd_tools.export_pmx" + bl_label = "Export PMX File (.pmx)" + bl_description = "Export selected MMD model(s) to PMX file(s) (.pmx)" + bl_options = {"PRESET"} + + filename_ext = ".pmx" + filter_glob: bpy.props.StringProperty(default="*.pmx", options={"HIDDEN"}) + + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for exporting the model", + default=12.5, + ) + copy_textures_mode: bpy.props.EnumProperty( + name="Copy Textures", + description="Choose how to handle texture files during export", + items=[ + ("NONE", "Don't Copy", "Don't copy texture files", 0), + ("SKIP_EXISTING", "Copy (Skip Existing)", "Copy textures but skip files that already exist", 1), + ("OVERWRITE", "Copy (Overwrite)", "Copy textures and overwrite existing files", 2), + ], + default="SKIP_EXISTING", + ) + sort_materials: bpy.props.BoolProperty( + name="Sort Materials", + description="Sort materials for alpha blending. WARNING: Will not work if you have transparent meshes inside the model. E.g. blush meshes", + default=False, + ) + disable_specular: bpy.props.BoolProperty( + name="Disable SPH/SPA", + description="Disables all the Specular Map textures. It is required for some MME Shaders.", + default=False, + ) + visible_meshes_only: bpy.props.BoolProperty( + name="Visible Meshes Only", + description="Export visible meshes only", + default=False, + ) + export_vertex_colors_as_adduv2: bpy.props.BoolProperty( + name="Export Vertex Colors", + description="Export vertex colors as ADD UV2 data. This allows vertex color data to be preserved in the PMX file format. When enabled, existing ADD UV2 data on the model will be skipped during export.", + default=False, + ) + fix_bone_order: bpy.props.BoolProperty( + name="Fix Bone Order", + description="Automatically fix bone order before export. This ensures bones are ordered correctly for MMD compatibility.", + default=True, + ) + overwrite_bone_morphs_from_action_pose: bpy.props.BoolProperty( + name="Overwrite Bone Morphs", + description="Overwrite the bone morphs from active Action Pose before exporting.", + default=False, + ) + translate_in_presets: bpy.props.BoolProperty( + name="(Experimental) Translate in Presets", + description="Translate in presets before exporting.", + default=False, + ) + normal_handling: bpy.props.EnumProperty( + name="Normal Handling", + description="Choose how to handle normals during export. This affects vertex count, edge count, and mesh topology by splitting vertices and edges to preserve split normals.", + items=[ + ("PRESERVE_ALL_NORMALS", "Preserve All Normals", "Export existing normals without any changes. This option performs NO automatic smoothing; only use it if you have already manually smoothed and perfected your normals. When using this option, please verify if the vertex count of the exported model has significantly increased or is within a reasonable range to prevent excessive geometry destruction and an overly fragmented model.", 0), + ("SMOOTH_KEEP_SHARP", "Smooth (Keep Sharp)", "Shade smooth, keep sharp edges. Balances vertex count and normal preservation.", 1), + ("SMOOTH_ALL_NORMALS", "Smooth All Normals", "Force smooths all normals, ignoring any sharp edges. This will result in a completely smooth-shaded model and minimum vertex count.", 2), + ], + default="SMOOTH_KEEP_SHARP", + ) + sort_vertices: bpy.props.EnumProperty( + name="Sort Vertices", + description="Choose the method to sort vertices", + items=[ + ("NONE", "None", "No sorting", 0), + ("BLENDER", "Blender", "Use blender's internal vertex order", 1), + ("CUSTOM", "Custom", 'Use custom vertex weight of vertex group "mmd_vertex_order"', 2), + ], + default="NONE", + ) + ik_angle_limits: bpy.props.EnumProperty( + name="IK Angle Limits", + description="Choose how to handle IK angle limits during export", + items=[ + ( + "EXPORT_ALL", + "Export All Limits", + "Export all existing IK angle limits using current priority system: " + "mmd_ik_limit_override -> Blender IK limits -> other sources. " + "If mmd_ik_limit_override disables an axis but Blender IK limits exist for that axis, " + "the Blender limits will still be exported. This maintains backward compatibility " + "with existing workflows", + 0, + ), + ( + "IGNORE_ALL", + "Ignore All Limits", + "Completely ignore all IK angle limits from any source during export. " + "No angle restrictions will be written to the PMX file, regardless of " + "mmd_ik_limit_override, Blender IK limits, or other constraint settings. " + "Useful when you want to rely entirely on MMD v9.19+ fixed axis feature instead", + 1, + ), + ( + "OVERRIDE_CONTROLLED", + "Override Controlled", + "Use mmd_ik_limit_override constraints as the sole authority for IK limits. " + "When mmd_ik_limit_override exists: only its enabled axes export limits, " + "disabled axes export no limits (ignoring Blender IK limits). " + "When mmd_ik_limit_override doesn't exist: fall back to Blender IK limits. " + "This makes mmd_ik_limit_override act as a true 'override' that completely " + "controls whether limits are exported, enabling fine-grained per-bone control", + 2, + ), + ], + default="EXPORT_ALL", + ) + log_level: bpy.props.EnumProperty( + name="Log level", + description="Select log level", + items=LOG_LEVEL_ITEMS, + default="DEBUG", + ) + save_log: bpy.props.BoolProperty( + name="Create a log file", + description="Create a log file", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj is not None and obj in context.selected_objects and FnModel.find_root_object(obj) + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_pmx_export_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def execute(self, context): + try: + folder = os.path.dirname(self.filepath) + models = {FnModel.find_root_object(i) for i in context.selected_objects} + for root in models: + if root is None: + continue + # use original self.filepath when export only one model + # otherwise, use root object's name as file name + if len(models) > 1: + model_name = bpy.path.clean_name(root.name) + model_folder = os.path.join(folder, model_name) + os.makedirs(model_folder, exist_ok=True) + self.filepath = os.path.join(model_folder, model_name + ".pmx") + self._do_execute(context, root) + except Exception: + logger.exception("Error occurred") + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + return {"FINISHED"} + + def _do_execute(self, context, root): + logger = logger.getLogger() + logger.setLevel(self.log_level) + handler = None + if self.save_log: + handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.export.log") + logger.addHandler(handler) + + arm = FnModel.find_armature_object(root) + if arm is None: + self.report({"ERROR"}, f'[Skipped] The armature object of MMD model "{root.name}" can\'t be found') + return {"CANCELLED"} + orig_pose_position = None + if not root.mmd_root.is_built: # use 'REST' pose when the model is not built + orig_pose_position = arm.data.pose_position + arm.data.pose_position = "REST" + arm.update_tag() + context.scene.frame_set(context.scene.frame_current) + + try: + meshes = FnModel.iterate_mesh_objects(root) + if self.visible_meshes_only: + meshes = (x for x in meshes if x in context.visible_objects) + pmx_exporter.export( + filepath=self.filepath, + scale=self.scale, + root=root, + armature=FnModel.find_armature_object(root), + meshes=meshes, + rigid_bodies=FnModel.iterate_rigid_body_objects(root), + joints=FnModel.iterate_joint_objects(root), + copy_textures_mode=self.copy_textures_mode, + fix_bone_order=self.fix_bone_order, + overwrite_bone_morphs_from_action_pose=self.overwrite_bone_morphs_from_action_pose, + translate_in_presets=self.translate_in_presets, + sort_materials=self.sort_materials, + sort_vertices=self.sort_vertices, + disable_specular=self.disable_specular, + export_vertex_colors_as_adduv2=self.export_vertex_colors_as_adduv2, + normal_handling=self.normal_handling, + ik_angle_limits=self.ik_angle_limits, + ) + self.report({"INFO"}, f'Exported MMD model "{root.name}" to "{self.filepath}"') + except Exception: + logger.exception("Error occurred") + raise + finally: + if orig_pose_position: + arm.data.pose_position = orig_pose_position + if handler: + logger.removeHandler(handler) + + return {"FINISHED"} + + +class ExportVmd(Operator, ExportHelper, PreferencesMixin): + _is_registered = True # Prevent auto_load from registering this (VMD export not supported) + bl_idname = "mmd_tools.export_vmd" + bl_label = "Export VMD File (.vmd)" + bl_description = "Export motion data of active object to a VMD file (.vmd)\nBehavior varies depending on the active object:\n- Active object is the root (cross under the model): exports both armature and morph animations\n- Active object is the model: exports only morph animation\n- Active object is the armature: exports only armature animation" + bl_options = {"PRESET"} + + filename_ext = ".vmd" + filter_glob: bpy.props.StringProperty(default="*.vmd", options={"HIDDEN"}) + + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for exporting the motion", + default=12.5, + ) + use_pose_mode: bpy.props.BoolProperty( + name="Treat Current Pose as Rest Pose", + description="You can pose the model to export a motion data to different pose base, such as T-Pose or A-Pose", + default=False, + options={"SKIP_SAVE"}, + ) + use_frame_range: bpy.props.BoolProperty( + name="Use Frame Range", + description="Export frames only in the frame range of context scene", + default=False, + ) + preserve_curves: bpy.props.BoolProperty( + name="Preserve Animation Curves", + description="Add additional keyframes to accurately preserve animation curves. Blender's bezier handles are more flexible than the VMD format. Complex handle settings will be lost during export unless additional keyframes are added to approximate the original curves.", + default=False, + ) + log_level: bpy.props.EnumProperty( + name="Log level", + description="Select log level", + items=LOG_LEVEL_ITEMS, + default="INFO", + ) + save_log: bpy.props.BoolProperty( + name="Create a log file", + description="Create a log file", + default=False, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + + if obj.mmd_type == "ROOT": + return True + if obj.mmd_type == "NONE" and (obj.type == "ARMATURE" or getattr(obj.data, "shape_keys", None)): + return True + if MMDCamera.isMMDCamera(obj) or MMDLamp.isMMDLamp(obj): + return True + + return False + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_vmd_export_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def execute(self, context): + logger = logger.getLogger() + logger.setLevel(self.log_level) + handler = None + if self.save_log: + handler = log_handler(self.log_level, filepath=self.filepath + ".mmd_tools_local.export.log") + logger.addHandler(handler) + + try: + params = { + "filepath": self.filepath, + "scale": self.scale, + "use_pose_mode": self.use_pose_mode, + "use_frame_range": self.use_frame_range, + "preserve_curves": self.preserve_curves, + } + + obj = context.active_object + if obj.mmd_type == "ROOT": + rig = Model(obj) + params["mesh"] = rig.morph_slider.placeholder(binded=True) or rig.firstMesh() + params["armature"] = rig.armature() + params["model_name"] = obj.mmd_root.name or obj.name + elif getattr(obj.data, "shape_keys", None): + params["mesh"] = obj + params["model_name"] = obj.name + elif obj.type == "ARMATURE": + params["armature"] = obj + params["model_name"] = obj.name + else: + for i in context.selected_objects: + if MMDCamera.isMMDCamera(i): + params["camera"] = i + elif MMDLamp.isMMDLamp(i): + params["lamp"] = i + + start_time = time.time() + vmd_exporter.VMDExporter().export(**params) + logger.info(" Finished exporting motion in %f seconds.", time.time() - start_time) + except Exception: + logger.exception("Error occurred") + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + finally: + if handler: + logger.removeHandler(handler) + return {"FINISHED"} + + +class ExportVpd(Operator, ExportHelper, PreferencesMixin): + _is_registered = True # Prevent auto_load from registering this (VPD not supported) + bl_idname = "mmd_tools.export_vpd" + bl_label = "Export VPD File (.vpd)" + bl_description = "Export active rig's Action Pose to VPD file(s) (.vpd)\nBehavior varies depending on the active object:\n- Active object is the root (cross under the model): exports both armature pose and morphs\n- Active object is the model: exports only morphs\n- Active object is the armature: exports only armature pose" + bl_options = {"PRESET"} + + filename_ext = ".vpd" + filter_glob: bpy.props.StringProperty(default="*.vpd", options={"HIDDEN"}) + + scale: bpy.props.FloatProperty( + name="Scale", + description="Scaling factor for exporting the pose", + default=12.5, + ) + pose_type: bpy.props.EnumProperty( + name="Pose Type", + description="Choose the pose type to export", + items=[ + ("CURRENT", "Current Pose", "Current pose of the rig", 0), + ("ACTIVE", "Active Pose", "Active pose of the rig's Action Pose", 1), + ("ALL", "All Poses", "All poses of the rig's Action Pose (the pose name will be the file name)", 2), + ], + default="CURRENT", + ) + use_pose_mode: bpy.props.BoolProperty( + name="Treat Current Pose as Rest Pose", + description="You can pose the model to export a pose data to different pose base, such as T-Pose or A-Pose", + default=False, + options={"SKIP_SAVE"}, + ) + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + + if obj.mmd_type == "ROOT": + return True + if obj.mmd_type == "NONE" and (obj.type == "ARMATURE" or getattr(obj.data, "shape_keys", None)): + return True + + return False + + def invoke(self, context, event): + self.load_preferences_on_invoke(context, "default_vpd_export_preset") + return super().invoke(context, event) + + def cancel(self, context): + self.restore_preferences_on_cancel() + return super().cancel(context) if hasattr(super(), "cancel") else None + + def draw(self, context): + layout = self.layout + layout.prop(self, "scale") + layout.prop(self, "pose_type", expand=True) + if self.pose_type != "CURRENT": + layout.prop(self, "use_pose_mode") + + def execute(self, context): + params = { + "filepath": self.filepath, + "scale": self.scale, + "pose_type": self.pose_type, + "use_pose_mode": self.use_pose_mode, + } + + obj = context.active_object + if obj.mmd_type == "ROOT": + rig = Model(obj) + params["mesh"] = rig.morph_slider.placeholder(binded=True) or rig.firstMesh() + params["armature"] = rig.armature() + params["model_name"] = obj.mmd_root.name or obj.name + elif getattr(obj.data, "shape_keys", None): + params["mesh"] = obj + params["model_name"] = obj.name + elif obj.type == "ARMATURE": + params["armature"] = obj + params["model_name"] = obj.name + + try: + vpd_exporter.VPDExporter().export(**params) + except Exception: + logger.exception("Error occurred") + err_msg = traceback.format_exc() + self.report({"ERROR"}, err_msg) + return {"FINISHED"} diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py index efaaf97..bb4f789 100644 --- a/core/mmd/operators/material.py +++ b/core/mmd/operators/material.py @@ -1,22 +1,16 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. + +from collections import defaultdict import bpy -from bpy.props import BoolProperty, StringProperty, FloatProperty -from bpy.types import Operator, Context, Object, Material - -from typing import Set, Dict, Any, List, Tuple, Optional, Union, cast +from bpy.props import BoolProperty, StringProperty +from bpy.types import Operator from .. import cycles_converter from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial from ..core.shader import _NodeGroupUtils -from ....core.logging_setup import logger -import traceback class ConvertMaterialsForCycles(Operator): @@ -25,14 +19,14 @@ class ConvertMaterialsForCycles(Operator): bl_description = "Convert materials of selected objects for Cycles." bl_options = {"REGISTER", "UNDO"} - use_principled: BoolProperty( + use_principled: bpy.props.BoolProperty( name="Convert to Principled BSDF", description="Convert MMD shader nodes to Principled BSDF as well if enabled", default=False, options={"SKIP_SAVE"}, ) - clean_nodes: BoolProperty( + clean_nodes: bpy.props.BoolProperty( name="Clean Nodes", description="Remove redundant nodes as well if enabled. Disable it to keep node data.", default=False, @@ -40,27 +34,22 @@ class ConvertMaterialsForCycles(Operator): ) @classmethod - def poll(cls, context: Context) -> bool: - return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None + def poll(cls, context): + return any(x.type == "MESH" for x in context.selected_objects) - def draw(self, context: Context) -> None: + def draw(self, context): layout = self.layout layout.prop(self, "use_principled") layout.prop(self, "clean_nodes") - def execute(self, context: Context) -> Set[str]: + def execute(self, context): try: context.scene.render.engine = "CYCLES" except Exception: - logger.error(f"Failed to change to Cycles render engine: {traceback.format_exc()}") self.report({"ERROR"}, " * Failed to change to Cycles render engine.") return {"CANCELLED"} - - logger.info(f"Converting materials for Cycles with principled={self.use_principled}, clean_nodes={self.clean_nodes}") for obj in (x for x in context.selected_objects if x.type == "MESH"): - logger.debug(f"Converting materials for object: {obj.name}") cycles_converter.convertToCyclesShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes) - return {"FINISHED"} @@ -70,21 +59,21 @@ class ConvertMaterials(Operator): bl_description = "Convert materials of selected objects." bl_options = {"REGISTER", "UNDO"} - use_principled: BoolProperty( + use_principled: bpy.props.BoolProperty( name="Convert to Principled BSDF", description="Convert MMD shader nodes to Principled BSDF as well if enabled", default=True, options={"SKIP_SAVE"}, ) - clean_nodes: BoolProperty( + clean_nodes: bpy.props.BoolProperty( name="Clean Nodes", description="Remove redundant nodes as well if enabled. Disable it to keep node data.", default=True, options={"SKIP_SAVE"}, ) - subsurface: FloatProperty( + subsurface: bpy.props.FloatProperty( name="Subsurface", default=0.001, soft_min=0.000, @@ -94,41 +83,130 @@ class ConvertMaterials(Operator): ) @classmethod - def poll(cls, context: Context) -> bool: - return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None + def poll(cls, context): + return any(x.type == "MESH" for x in context.selected_objects) - def execute(self, context: Context) -> Set[str]: - logger.info(f"Converting materials with principled={self.use_principled}, clean_nodes={self.clean_nodes}, subsurface={self.subsurface}") + def execute(self, context): for obj in context.selected_objects: if obj.type != "MESH": continue - logger.debug(f"Converting materials for object: {obj.name}") cycles_converter.convertToBlenderShader(obj, use_principled=self.use_principled, clean_nodes=self.clean_nodes, subsurface=self.subsurface) return {"FINISHED"} -class ConvertBSDFMaterials(Operator): - bl_idname = 'mmd_tools.convert_bsdf_materials' - bl_label = 'Convert Blender Materials' - bl_description = 'Convert materials of selected objects.' - bl_options = {'REGISTER', 'UNDO'} + +class MergeMaterials(Operator): + bl_idname = "mmd_tools.merge_materials" + bl_label = "Merge Materials" + bl_description = "Merge materials with the same texture in selected objects. Only merges materials with exactly one texture node. Materials with no texture or with multiple textures are not merged. Please convert to Blender materials first." + bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: Context) -> bool: - return next((x for x in context.selected_objects if x.type == 'MESH'), None) is not None + def poll(cls, context): + return any(x.type == "MESH" for x in context.selected_objects) - def execute(self, context: Context) -> Set[str]: - logger.info("Converting BSDF materials to MMD shader") + def execute(self, context): + # Process all selected mesh objects for obj in context.selected_objects: - if obj.type != 'MESH': + if obj.type != "MESH": + continue + + self.merge_materials_for_object(context, obj) + + return {"FINISHED"} + + def merge_materials_for_object(self, context, obj): + """Merge materials with same texture for a single object""" + if not obj.data.materials: + self.report({"INFO"}, f"Object '{obj.name}' has no materials") + return + + # Map texture paths to material indices and names + texture_to_materials = defaultdict(list) + + # Check each material + for i, material in enumerate(obj.data.materials): + # use_nodes is deprecated in 5.0 but always returns True, so check is safe + if not material or not material.use_nodes: + continue + + # 1. Check texture node count (must be exactly 1) + texture_nodes = [node for node in material.node_tree.nodes if node.type == "TEX_IMAGE"] + if len(texture_nodes) != 1: + continue + + # 2. Record texture path and material info + texture_node = texture_nodes[0] + if texture_node.image: + texture_path = bpy.path.abspath(texture_node.image.filepath) + texture_to_materials[texture_path].append({"index": i, "name": material.name}) + + # Find material groups that need merging + materials_to_merge = {path: materials for path, materials in texture_to_materials.items() if len(materials) > 1} + + if not materials_to_merge: + self.report({"INFO"}, f"No materials to merge in object '{obj.name}'") + return + + # Process each texture group + context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode="EDIT") + merge_details = [] + for texture_path, materials in materials_to_merge.items(): + # Use first material as target + target_material = materials[0] + target_index = target_material["index"] + target_name = target_material["name"] + + source_materials = [] + + # Reassign faces from other materials to target material + for source_material in materials[1:]: + source_index = source_material["index"] + source_name = source_material["name"] + source_materials.append(source_name) + + bpy.ops.mesh.select_all(action="DESELECT") + obj.active_material_index = source_index + bpy.ops.object.material_slot_select() + obj.active_material_index = target_index + bpy.ops.object.material_slot_assign() + + # Record merge details + texture_name = bpy.path.basename(texture_path) + merge_details.append({"texture": texture_name, "target": target_name, "sources": source_materials}) + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.material_slot_remove_unused() + + merged_count = sum(len(details["sources"]) for details in merge_details) + self.report({"INFO"}, f"Object '{obj.name}': Merged {merged_count} materials") + + for details in merge_details: + sources_text = ", ".join(details["sources"]) + self.report({"INFO"}, f"Same Texture '{details['texture']}': Merged materials [{sources_text}] into '{details['target']}'") + + +class ConvertBSDFMaterials(Operator): + bl_idname = "mmd_tools.convert_bsdf_materials" + bl_label = "Convert Blender Materials" + bl_description = "Convert materials of selected objects." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return any(x.type == "MESH" for x in context.selected_objects) + + def execute(self, context): + for obj in context.selected_objects: + if obj.type != "MESH": continue - logger.debug(f"Converting BSDF materials for object: {obj.name}") cycles_converter.convertToMMDShader(obj) - return {'FINISHED'} + return {"FINISHED"} + class _OpenTextureBase: """Create a texture for mmd model material.""" - bl_options: Set[str] = {"REGISTER", "UNDO", "INTERNAL"} + bl_options = {"REGISTER", "UNDO", "INTERNAL"} filepath: StringProperty( name="File Path", @@ -142,7 +220,7 @@ class _OpenTextureBase: options={"HIDDEN"}, ) - def invoke(self, context: Context, event: Any) -> Set[str]: + def invoke(self, context, event): context.window_manager.fileselect_add(self) return {"RUNNING_MODAL"} @@ -152,13 +230,8 @@ class OpenTexture(Operator, _OpenTextureBase): bl_label = "Open Texture" bl_description = "Create main texture of active material" - def execute(self, context: Context) -> Set[str]: + def execute(self, context): mat = context.active_object.active_material - if not mat: - logger.error("No active material found") - return {"CANCELLED"} - - logger.info(f"Creating texture for material: {mat.name} from {self.filepath}") fnMat = FnMaterial(mat) fnMat.create_texture(self.filepath) return {"FINISHED"} @@ -172,13 +245,8 @@ class RemoveTexture(Operator): bl_description = "Remove main texture of active material" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: Context) -> Set[str]: + def execute(self, context): mat = context.active_object.active_material - if not mat: - logger.error("No active material found") - return {"CANCELLED"} - - logger.info(f"Removing texture from material: {mat.name}") fnMat = FnMaterial(mat) fnMat.remove_texture() return {"FINISHED"} @@ -191,13 +259,8 @@ class OpenSphereTextureSlot(Operator, _OpenTextureBase): bl_label = "Open Sphere Texture" bl_description = "Create sphere texture of active material" - def execute(self, context: Context) -> Set[str]: + def execute(self, context): mat = context.active_object.active_material - if not mat: - logger.error("No active material found") - return {"CANCELLED"} - - logger.info(f"Creating sphere texture for material: {mat.name} from {self.filepath}") fnMat = FnMaterial(mat) fnMat.create_sphere_texture(self.filepath, context.active_object) return {"FINISHED"} @@ -211,13 +274,8 @@ class RemoveSphereTexture(Operator): bl_description = "Remove sphere texture of active material" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: Context) -> Set[str]: + def execute(self, context): mat = context.active_object.active_material - if not mat: - logger.error("No active material found") - return {"CANCELLED"} - - logger.info(f"Removing sphere texture from material: {mat.name}") fnMat = FnMaterial(mat) fnMat.remove_sphere_texture() return {"FINISHED"} @@ -230,21 +288,17 @@ class MoveMaterialUp(Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context): obj = context.active_object - valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE" - return bool(valid_mesh and obj.active_material_index > 0) + return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index > 0 - def execute(self, context: Context) -> Set[str]: + def execute(self, context): obj = context.active_object current_idx = obj.active_material_index prev_index = current_idx - 1 - - logger.debug(f"Moving material {current_idx} up to position {prev_index} for object {obj.name}") try: FnMaterial.swap_materials(obj, current_idx, prev_index, reverse=True, swap_slots=True) except MaterialNotFoundError: - logger.error(f"Materials not found for indices {current_idx} and {prev_index}") self.report({"ERROR"}, "Materials not found") return {"CANCELLED"} obj.active_material_index = prev_index @@ -259,21 +313,17 @@ class MoveMaterialDown(Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context): obj = context.active_object - valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE" - return bool(valid_mesh and obj.active_material_index < len(obj.material_slots) - 1) + return obj is not None and obj.type == "MESH" and obj.mmd_type == "NONE" and obj.active_material_index < len(obj.material_slots) - 1 - def execute(self, context: Context) -> Set[str]: + def execute(self, context): obj = context.active_object current_idx = obj.active_material_index next_index = current_idx + 1 - - logger.debug(f"Moving material {current_idx} down to position {next_index} for object {obj.name}") try: FnMaterial.swap_materials(obj, current_idx, next_index, reverse=True, swap_slots=True) except MaterialNotFoundError: - logger.error(f"Materials not found for indices {current_idx} and {next_index}") self.report({"ERROR"}, "Materials not found") return {"CANCELLED"} obj.active_material_index = next_index @@ -296,31 +346,26 @@ class EdgePreviewSetup(Operator): default="CREATE", ) - def execute(self, context: Context) -> Set[str]: + def execute(self, context): from ..core.model import FnModel root = FnModel.find_root_object(context.active_object) if root is None: - logger.error("No MMD model root found") self.report({"ERROR"}, "Select a MMD model") return {"CANCELLED"} if self.action == "CLEAN": - logger.info(f"Cleaning toon edge for model: {root.name}") for obj in FnModel.iterate_mesh_objects(root): self.__clean_toon_edge(obj) else: from ..bpyutils import Props - logger.info(f"Creating toon edge for model: {root.name}") scale = 0.2 * getattr(root, Props.empty_display_size) counts = sum(self.__create_toon_edge(obj, scale) for obj in FnModel.iterate_mesh_objects(root)) - logger.info(f"Created {counts} toon edge(s)") self.report({"INFO"}, "Created %d toon edge(s)" % counts) return {"FINISHED"} - def __clean_toon_edge(self, obj: Object) -> None: - logger.debug(f"Cleaning toon edge for object: {obj.name}") + def __clean_toon_edge(self, obj): if "mmd_edge_preview" in obj.modifiers: obj.modifiers.remove(obj.modifiers["mmd_edge_preview"]) @@ -329,8 +374,7 @@ class EdgePreviewSetup(Operator): FnMaterial.clean_materials(obj, can_remove=lambda m: m and m.name.startswith("mmd_edge.")) - def __create_toon_edge(self, obj: Object, scale: float = 1.0) -> int: - logger.debug(f"Creating toon edge for object: {obj.name} with scale {scale}") + def __create_toon_edge(self, obj, scale=1.0): self.__clean_toon_edge(obj) materials = obj.data.materials material_offset = len(materials) @@ -355,10 +399,10 @@ class EdgePreviewSetup(Operator): mod.vertex_group = "mmd_edge_preview" return len(materials) - material_offset - def __create_edge_preview_group(self, obj: Object) -> None: + def __create_edge_preview_group(self, obj): vertices, materials = obj.data.vertices, obj.data.materials weight_map = {i: m.mmd_material.edge_weight for i, m in enumerate(materials) if m} - scale_map: Dict[int, float] = {} + scale_map = {} vg_scale_index = obj.vertex_groups.find("mmd_edge_scale") if vg_scale_index >= 0: scale_map = {v.index: g.weight for v in vertices for g in v.groups if g.group == vg_scale_index} @@ -367,7 +411,7 @@ class EdgePreviewSetup(Operator): weight = scale_map.get(i, 1.0) * weight_map.get(mi, 1.0) * 0.02 vg_edge_preview.add(index=[i], weight=weight, type="REPLACE") - def __get_edge_material(self, mat_name: str, edge_color: Tuple[float, float, float, float], materials: List[Material]) -> Material: + def __get_edge_material(self, mat_name, edge_color, materials): if mat_name in materials: return materials[mat_name] mat = bpy.data.materials.get(mat_name, None) @@ -385,8 +429,8 @@ class EdgePreviewSetup(Operator): self.__make_shader(mat) return mat - def __make_shader(self, m: Material) -> None: - # Note: material.use_nodes is deprecated in Blender 5.0 - materials always use nodes + def __make_shader(self, m): + m.use_nodes = True nodes, links = m.node_tree.nodes, m.node_tree.links node_shader = nodes.get("mmd_edge_preview", None) @@ -406,7 +450,7 @@ class EdgePreviewSetup(Operator): node_shader.inputs["Color"].default_value = m.mmd_material.edge_color node_shader.inputs["Alpha"].default_value = m.mmd_material.edge_color[3] - def __get_edge_preview_shader(self) -> bpy.types.NodeTree: + def __get_edge_preview_shader(self): group_name = "MMDEdgePreview" shader = bpy.data.node_groups.get(group_name, None) or bpy.data.node_groups.new(name=group_name, type="ShaderNodeTree") if len(shader.nodes): @@ -414,8 +458,8 @@ class EdgePreviewSetup(Operator): ng = _NodeGroupUtils(shader) - node_input = ng.new_node("NodeGroupInput", (-5, 0)) - node_output = ng.new_node("NodeGroupOutput", (3, 0)) + ng.new_node("NodeGroupInput", (-5, 0)) + ng.new_node("NodeGroupOutput", (3, 0)) ############################################################################ node_color = ng.new_node("ShaderNodeMixRGB", (-1, -1.5)) diff --git a/core/mmd/operators/misc.py b/core/mmd/operators/misc.py index 83cfeff..a2f7996 100644 --- a/core/mmd/operators/misc.py +++ b/core/mmd/operators/misc.py @@ -1,22 +1,15 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. import re -from typing import List, Dict, Any, Set, Optional, Tuple, Union, Type import bpy -from bpy.types import Context, Object, Operator, ShapeKey from .. import utils from ..bpyutils import FnContext, FnObject from ..core.bone import FnBone from ..core.model import FnModel, Model from ..core.morph import FnMorph -from ....core.logging_setup import logger class SelectObject(bpy.types.Operator): @@ -32,8 +25,7 @@ class SelectObject(bpy.types.Operator): options={"HIDDEN", "SKIP_SAVE"}, ) - def execute(self, context: Context) -> Set[str]: - logger.debug(f"Selecting object: {self.name}") + def execute(self, context): utils.selectAObject(context.scene.objects[self.name]) return {"FINISHED"} @@ -47,43 +39,41 @@ class MoveObject(bpy.types.Operator, utils.ItemMoveOp): __PREFIX_REGEXP = re.compile(r"(?P[0-9A-Z]{3}_)(?P.*)") @classmethod - def set_index(cls, obj: Object, index: int) -> None: + def set_index(cls, obj, index): m = cls.__PREFIX_REGEXP.match(obj.name) name = m.group("name") if m else obj.name - obj.name = "%s_%s" % (utils.int2base(index, 36, 3), name) + obj.name = f"{utils.int2base(index, 36, 3)}_{name}" @classmethod - def get_name(cls, obj: Object, prefix: Optional[str] = None) -> str: + def get_name(cls, obj, prefix=None): m = cls.__PREFIX_REGEXP.match(obj.name) name = m.group("name") if m else obj.name return name[len(prefix) :] if prefix and name.startswith(prefix) else name @classmethod - def normalize_indices(cls, objects: List[Object]) -> None: + def normalize_indices(cls, objects): for i, x in enumerate(objects): cls.set_index(x, i) @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context): return context.active_object is not None - def execute(self, context: Context) -> Set[str]: + def execute(self, context): obj = context.active_object objects = self.__get_objects(obj) if obj not in objects: - logger.error(f'Cannot move object "{obj.name}"') self.report({"ERROR"}, f'Can not move object "{obj.name}"') return {"CANCELLED"} objects.sort(key=lambda x: x.name) - logger.debug(f"Moving object {obj.name} {self.type}") self.move(objects, objects.index(obj), self.type) self.normalize_indices(objects) return {"FINISHED"} - def __get_objects(self, obj: Object) -> Any: + def __get_objects(self, obj): class __MovableList(list): - def move(self, index_old: int, index_new: int) -> None: + def move(self, index_old, index_new): item = self[index_old] self.remove(item) self.insert(index_new, item) @@ -108,43 +98,40 @@ class CleanShapeKeys(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context): return any(o.type == "MESH" for o in context.selected_objects) @staticmethod - def __can_remove(key_block: ShapeKey) -> bool: + def __can_remove(key_block): if key_block.relative_key == key_block: return False # Basis - for v0, v1 in zip(key_block.relative_key.data, key_block.data): + for v0, v1 in zip(key_block.relative_key.data, key_block.data, strict=False): if v0.co != v1.co: return False return True - def __shape_key_clean(self, obj: Object, key_blocks: List[ShapeKey]) -> None: + def __shape_key_clean(self, obj, key_blocks): for kb in key_blocks: if self.__can_remove(kb): - logger.debug(f"Removing unused shape key: {kb.name} from {obj.name}") FnObject.mesh_remove_shape_key(obj, kb) if len(key_blocks) == 1: - logger.debug(f"Removing single shape key: {key_blocks[0].name} from {obj.name}") FnObject.mesh_remove_shape_key(obj, key_blocks[0]) - def execute(self, context: Context) -> Set[str]: - logger.info("Cleaning shape keys for selected objects") - obj: Object + def execute(self, context): + obj: bpy.types.Object for obj in context.selected_objects: if obj.type != "MESH" or obj.data.shape_keys is None: continue if not obj.data.shape_keys.use_relative: continue # not be considered yet - logger.debug(f"Processing shape keys for {obj.name}") self.__shape_key_clean(obj, obj.data.shape_keys.key_blocks) return {"FINISHED"} class SeparateByMaterials(bpy.types.Operator): bl_idname = "mmd_tools.separate_by_materials" - bl_label = "Separate By Materials" + bl_label = "Sep by Mat(High Risk)" + bl_description = "Separate by Materials (High Risk)\nSeparate the mesh into multiple objects based on materials.\nHIGH RISK & BUGGY: This operation is not reversible and may cause various issues. It splits adjacent geometry by material, and merging later will not reconnect shared edges.\nKnown issues include potential mesh corruption, UV mapping problems, and other unpredictable behaviors. Use with extreme caution and backup your work first." bl_options = {"REGISTER", "UNDO"} clean_shape_keys: bpy.props.BoolProperty( @@ -153,26 +140,32 @@ class SeparateByMaterials(bpy.types.Operator): default=True, ) - @classmethod - def poll(cls, context: Context) -> bool: - obj = context.active_object - return obj and obj.type == "MESH" + keep_normals: bpy.props.BoolProperty( + name="Keep Normals", + default=True, + ) - def __separate_by_materials(self, obj: Object) -> None: - logger.info(f"Separating {obj.name} by materials") - utils.separateByMaterials(obj) + @classmethod + def poll(cls, context): + obj = context.active_object + return obj is not None and obj.type == "MESH" + + def __separate_by_materials(self, obj): + utils.separateByMaterials(obj, self.keep_normals) if self.clean_shape_keys: - logger.debug("Cleaning shape keys after separation") bpy.ops.mmd_tools.clean_shape_keys() - def execute(self, context: Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) + + # Sep by Mat crashes Blender if used after morph assembly + rig = Model(root) + rig.morph_slider.unbind() + if root is None: - logger.debug("No root object found, separating single object") self.__separate_by_materials(obj) else: - logger.debug(f"Root object found: {root.name}, preparing for separation") bpy.ops.mmd_tools.clear_temp_materials() bpy.ops.mmd_tools.clear_uv_morph_view() @@ -185,11 +178,9 @@ class SeparateByMaterials(bpy.types.Operator): if len(mesh.data.materials) > 0: mat = mesh.data.materials[0] idx = mat_names.index(getattr(mat, "name", None)) - logger.debug(f"Setting index {idx} for mesh {mesh.name}") MoveObject.set_index(mesh, idx) for morph in root.mmd_root.material_morphs: - logger.debug(f"Updating material morph: {morph.name}") FnMorph(morph, rig).update_mat_related_mesh() utils.clearUnusedMeshes() return {"FINISHED"} @@ -207,15 +198,13 @@ class JoinMeshes(bpy.types.Operator): default=True, ) - def execute(self, context: Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) if root is None: - logger.error("No MMD model found") self.report({"ERROR"}, "Select a MMD model") return {"CANCELLED"} - logger.info(f"Joining meshes for model: {root.name}") bpy.ops.mmd_tools.clear_temp_materials() bpy.ops.mmd_tools.clear_uv_morph_view() @@ -223,11 +212,9 @@ class JoinMeshes(bpy.types.Operator): rig = Model(root) meshes_list = sorted(rig.meshes(), key=lambda x: x.name) if not meshes_list: - logger.error("No meshes found in the model") self.report({"ERROR"}, "The model does not have any meshes") return {"CANCELLED"} active_mesh = meshes_list[0] - logger.debug(f"Found {len(meshes_list)} meshes, using {active_mesh.name} as active") FnContext.select_objects(context, *meshes_list) FnContext.set_active_object(context, active_mesh) @@ -236,19 +223,15 @@ class JoinMeshes(bpy.types.Operator): for m in meshes_list[1:]: for mat in m.data.materials: if mat not in active_mesh.data.materials[:]: - logger.debug(f"Adding material {mat.name} to active mesh") active_mesh.data.materials.append(mat) # Join selected meshes - logger.debug("Joining meshes") bpy.ops.object.join() if self.sort_shape_keys: - logger.debug("Sorting shape keys") FnMorph.fixShapeKeyOrder(active_mesh, root.mmd_root.vertex_morphs.keys()) active_mesh.active_shape_key_index = 0 for morph in root.mmd_root.material_morphs: - logger.debug(f"Updating material morph: {morph.name}") FnMorph(morph, rig).update_mat_related_mesh(active_mesh) utils.clearUnusedMeshes() return {"FINISHED"} @@ -262,20 +245,17 @@ class AttachMeshesToMMD(bpy.types.Operator): add_armature_modifier: bpy.props.BoolProperty(default=True) - def execute(self, context: Context) -> Set[str]: + def execute(self, context: bpy.types.Context): root = FnModel.find_root_object(context.active_object) if root is None: - logger.error("No MMD model found") self.report({"ERROR"}, "Select a MMD model") return {"CANCELLED"} armObj = FnModel.find_armature_object(root) if armObj is None: - logger.error("Model armature not found") self.report({"ERROR"}, "Model Armature not found") return {"CANCELLED"} - logger.info(f"Attaching meshes to model: {root.name}") FnModel.attach_mesh_objects(root, context.visible_objects, self.add_armature_modifier) return {"FINISHED"} @@ -295,18 +275,18 @@ class ChangeMMDIKLoopFactor(bpy.types.Operator): ) @classmethod - def poll(cls, context: Context) -> bool: - return FnModel.find_root_object(context.active_object) is not None + def poll(cls, context): + root = FnModel.find_root_object(context.active_object) + return root is not None - def invoke(self, context: Context, event: Any) -> Set[str]: + def invoke(self, context, event): root_object = FnModel.find_root_object(context.active_object) self.mmd_ik_loop_factor = root_object.mmd_root.ik_loop_factor vm = context.window_manager return vm.invoke_props_dialog(self) - def execute(self, context: Context) -> Set[str]: + def execute(self, context): root_object = FnModel.find_root_object(context.active_object) - logger.info(f"Changing IK loop factor to {self.mmd_ik_loop_factor} for model: {root_object.name}") FnModel.change_mmd_ik_loop_factor(root_object, self.mmd_ik_loop_factor) return {"FINISHED"} @@ -318,22 +298,21 @@ class RecalculateBoneRoll(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context): obj = context.active_object - return obj and obj.type == "ARMATURE" + return obj is not None and obj.type == "ARMATURE" - def invoke(self, context: Context, event: Any) -> Set[str]: + def invoke(self, context, event): vm = context.window_manager return vm.invoke_props_dialog(self) - def draw(self, context: Context) -> None: + def draw(self, context): layout = self.layout c = layout.column() c.label(text="This operation will break existing f-curve/action.", icon="QUESTION") c.label(text="Click [OK] to run the operation.") - def execute(self, context: Context) -> Set[str]: + def execute(self, context): arm = context.active_object - logger.info(f"Recalculating bone roll for armature: {arm.name}") FnBone.apply_auto_bone_roll(arm) return {"FINISHED"} diff --git a/core/mmd/operators/model_edit.py b/core/mmd/operators/model_edit.py index 632ae5e..ef373b9 100644 --- a/core/mmd/operators/model_edit.py +++ b/core/mmd/operators/model_edit.py @@ -1,32 +1,27 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2022 MMD Tools authors +# This file is part of MMD Tools. import itertools from operator import itemgetter -from typing import Dict, List, Optional, Set, Tuple, Any +from typing import Dict, List, Optional, Set import bmesh import bpy import numpy as np -import numpy.typing as npt -from bpy.types import Context, Object, Operator, EditBone, Mesh, Armature +from mathutils import Matrix -from ..bpyutils import FnContext +from ..bpyutils import FnContext, select_object from ..core.model import FnModel, Model -from ....core.logging_setup import logger -class MessageException(Exception): - """Class for error with message.""" +class NoModelSelectedError(Exception): + """Raised when no MMD model is selected.""" class ModelJoinByBonesOperator(bpy.types.Operator): bl_idname = "mmd_tools.model_join_by_bones" bl_label = "Model Join by Bones" + bl_description = "Join multiple MMD models into one.\n\nWARNING: To align models before joining, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the models to be in a clean state." bl_options = {"REGISTER", "UNDO"} join_type: bpy.props.EnumProperty( @@ -39,8 +34,8 @@ class ModelJoinByBonesOperator(bpy.types.Operator): ) @classmethod - def poll(cls, context: Context) -> bool: - active_object: Optional[Object] = context.active_object + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object if context.mode != "POSE": return False @@ -56,22 +51,19 @@ class ModelJoinByBonesOperator(bpy.types.Operator): return len(context.selected_pose_bones) > 0 - def invoke(self, context: Context, event: Any) -> Set[str]: + def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) - def execute(self, context: Context) -> Set[str]: + def execute(self, context: bpy.types.Context): try: - logger.info("Starting model join by bones operation") self.join(context) - logger.info("Model join by bones completed successfully") - except MessageException as ex: - logger.error(f"Model join by bones failed: {str(ex)}") + except NoModelSelectedError as ex: self.report(type={"ERROR"}, message=str(ex)) return {"CANCELLED"} return {"FINISHED"} - def join(self, context: Context) -> None: + def join(self, context: bpy.types.Context): bpy.ops.object.mode_set(mode="OBJECT") parent_root_object = FnModel.find_root_object(context.active_object) @@ -79,23 +71,35 @@ class ModelJoinByBonesOperator(bpy.types.Operator): child_root_objects.remove(parent_root_object) if parent_root_object is None or len(child_root_objects) == 0: - raise MessageException("No MMD Models selected") + raise NoModelSelectedError("No MMD Models selected") - logger.debug(f"Joining {len(child_root_objects)} models into parent model: {parent_root_object.name}") - with FnContext.temp_override_active_layer_collection(context, parent_root_object): - FnModel.join_models(parent_root_object, child_root_objects) + # Save original active_layer_collection + orig_active_layer_collection = context.view_layer.active_layer_collection + # Find layer collection containing parent_root_object and set it as active + layer_collection = FnContext.find_user_layer_collection_by_object(context, parent_root_object) + if layer_collection: + context.view_layer.active_layer_collection = layer_collection + + # Execute the join operation + FnModel.join_models(parent_root_object, child_root_objects) + + # Restore original active_layer_collection + context.view_layer.active_layer_collection = orig_active_layer_collection + + bpy.ops.object.mode_set(mode="OBJECT") + parent_armature_object = FnModel.find_armature_object(parent_root_object) + FnContext.set_active_and_select_single_object(context, parent_armature_object) bpy.ops.object.mode_set(mode="EDIT") bpy.ops.armature.parent_set(type="OFFSET") # Connect child bones if self.join_type == "CONNECTED": - parent_edit_bone: EditBone = context.active_bone - child_edit_bones: Set[EditBone] = set(context.selected_bones) + parent_edit_bone: bpy.types.EditBone = context.active_bone + child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones) child_edit_bones.remove(parent_edit_bone) - logger.debug(f"Connecting {len(child_edit_bones)} child bones to parent bone: {parent_edit_bone.name}") - child_edit_bone: EditBone + child_edit_bone: bpy.types.EditBone for child_edit_bone in child_edit_bones: child_edit_bone.use_connect = True @@ -105,6 +109,7 @@ class ModelJoinByBonesOperator(bpy.types.Operator): class ModelSeparateByBonesOperator(bpy.types.Operator): bl_idname = "mmd_tools.model_separate_by_bones" bl_label = "Model Separate by Bones" + bl_description = "Separate MMD model into multiple models based on selected bones.\n\nWARNING: This operation will split meshes, armatures, rigid bodies and joints. To move models before separating, only adjust the root (cross under the model) transformation. Do not move armatures, meshes, rigid bodies, or joints directly before separating as they will not move together.\n\nIMPORTANT: Don't use any of the 'Assembly' functions before using this function. This function requires the model to be in a clean state." bl_options = {"REGISTER", "UNDO"} separate_armature: bpy.props.BoolProperty(name="Separate Armature", default=True) @@ -120,8 +125,8 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): ) @classmethod - def poll(cls, context: Context) -> bool: - active_object: Optional[Object] = context.active_object + def poll(cls, context: bpy.types.Context): + active_object: Optional[bpy.types.Object] = context.active_object if context.mode != "POSE": return False @@ -137,155 +142,183 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): return len(context.selected_pose_bones) > 0 - def invoke(self, context: Context, event: Any) -> Set[str]: + def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) - def execute(self, context: Context) -> Set[str]: + def execute(self, context: bpy.types.Context): try: - logger.info("Starting model separate by bones operation") self.separate(context) - logger.info("Model separate by bones completed successfully") - except MessageException as ex: - logger.error(f"Model separate by bones failed: {str(ex)}") + except NoModelSelectedError as ex: self.report(type={"ERROR"}, message=str(ex)) return {"CANCELLED"} return {"FINISHED"} - def separate(self, context: Context) -> None: + def separate(self, context: bpy.types.Context): weight_threshold: float = self.weight_threshold mmd_scale = 0.08 - target_armature_object: Object = context.active_object - logger.debug(f"Target armature: {target_armature_object.name}") + target_armature_object: bpy.types.Object = context.active_object bpy.ops.object.mode_set(mode="EDIT") - root_bones: Set[EditBone] = set(context.selected_bones) - logger.debug(f"Selected root bones: {len(root_bones)}") - + root_bones: Set[bpy.types.EditBone] = set(context.selected_bones) if self.include_descendant_bones: - logger.debug("Including descendant bones") + original_active_bone = context.active_bone for edit_bone in root_bones: - with context.temp_override(active_bone=edit_bone): - bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1) + context.active_object.data.edit_bones.active = edit_bone + bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1) + self._select_related_ik_bones(target_armature_object) + if original_active_bone: + context.active_object.data.edit_bones.active = original_active_bone - separate_bones: Dict[str, EditBone] = {b.name: b for b in context.selected_bones} - deform_bones: Dict[str, EditBone] = {b.name: b for b in target_armature_object.data.edit_bones if b.use_deform} - logger.debug(f"Total bones to separate: {len(separate_bones)}") - - mmd_root_object: Object = FnModel.find_root_object(context.active_object) + separate_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in context.selected_bones} + deform_bones: Dict[str, bpy.types.EditBone] = {b.name: b for b in target_armature_object.data.edit_bones if b.use_deform} + mmd_root_object: bpy.types.Object = FnModel.find_root_object(context.active_object) mmd_model = Model(mmd_root_object) - mmd_model_mesh_objects: List[Object] = list(mmd_model.meshes()) - logger.debug(f"Found {len(mmd_model_mesh_objects)} mesh objects in model") - - mesh_selection_result = self.select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold) - mmd_model_mesh_objects = list(mesh_selection_result.keys()) - logger.debug(f"Selected {len(mmd_model_mesh_objects)} mesh objects with weighted vertices") - - # separate armature bones - separate_armature_object: Optional[Object] - if self.separate_armature: - logger.debug("Separating armature") - target_armature_object.select_set(True) - bpy.ops.armature.separate() - separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object]), None) - if separate_armature_object: - logger.debug(f"Created separate armature: {separate_armature_object.name}") + mmd_model_mesh_objects: List[bpy.types.Object] = list(mmd_model.meshes()) + mmd_model_mesh_objects = list(self._select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys()) bpy.ops.object.mode_set(mode="OBJECT") - # collect separate rigid bodies - separate_rigid_bodies: Set[Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones} - logger.debug(f"Found {len(separate_rigid_bodies)} rigid bodies to separate") + # Store original transform matrix for root object + original_matrix_world = mmd_root_object.matrix_world.copy() + mmd_root_object.matrix_world = Matrix.Identity(4) + + # Reset object visibility + FnContext.set_active_and_select_single_object(context, mmd_root_object) + bpy.ops.mmd_tools.reset_object_visibility() + + # Clean additional transform + FnContext.set_active_and_select_single_object(context, mmd_root_object) + bpy.ops.mmd_tools.clean_additional_transform() + + # Create new separate model first + separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, obj_name=mmd_root_object.name, add_root_bone=False) + separate_model.initialDisplayFrames() + separate_root_object = separate_model.rootObject() + separate_root_object.matrix_world = mmd_root_object.matrix_world + separate_model_armature_object = separate_model.armature() + + # Now separate armature bones from original model + separate_armature_object: Optional[bpy.types.Object] = None + if self.separate_armature: + FnContext.set_active_and_select_single_object(context, target_armature_object) + bpy.ops.object.mode_set(mode="EDIT") + + # Re-select the bones that should be separated (they might have been deselected) + for bone_name in separate_bones.keys(): + if bone_name in target_armature_object.data.edit_bones: + target_armature_object.data.edit_bones[bone_name].select = True + + bpy.ops.armature.separate() + separate_armature_object = next(iter([a for a in context.selected_objects if a != target_armature_object and a.type == "ARMATURE"]), None) + bpy.ops.object.mode_set(mode="OBJECT") + + # Collect separate rigid bodies + separate_rigid_bodies: Set[bpy.types.Object] = {rigid_body_object for rigid_body_object in mmd_model.rigidBodies() if rigid_body_object.mmd_rigid.bone in separate_bones} boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all - # collect separate joints - separate_joints: Set[Object] = { + # Collect separate joints + separate_joints: Set[bpy.types.Object] = { joint_object for joint_object in mmd_model.joints() if boundary_joint_owner_condition( [ joint_object.rigid_body_constraint.object1 in separate_rigid_bodies, joint_object.rigid_body_constraint.object2 in separate_rigid_bodies, - ] + ], ) } - logger.debug(f"Found {len(separate_joints)} joints to separate") - separate_mesh_objects: Set[Object] - model2separate_mesh_objects: Dict[Object, Object] - if len(mmd_model_mesh_objects) == 0: - logger.debug("No mesh objects to separate") - separate_mesh_objects = set() - model2separate_mesh_objects = dict() - else: - # select meshes - logger.debug("Selecting meshes for separation") - obj: Object + separate_mesh_objects: List[bpy.types.Object] = [] + model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] = {} + if len(mmd_model_mesh_objects) > 0: + # Find a single unique attribute name that doesn't conflict with any existing attributes. + all_attribute_names = {attr.name for obj in mmd_model_mesh_objects for attr in obj.data.attributes} + temp_normal_name = "mmd_temp_normal" + i = 0 + while temp_normal_name in all_attribute_names: + temp_normal_name = f"mmd_temp_normal.{i:03d}" + i += 1 + + # Backup custom normals to the unique temporary attribute. + for mesh_obj in mmd_model_mesh_objects: + mesh_data = mesh_obj.data + existing_custom_normal = mesh_data.attributes.get("custom_normal") + if not existing_custom_normal: + continue + + if existing_custom_normal.data_type == "INT16_2D": + normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16) + existing_custom_normal.data.foreach_get("value", normals_data) + temp_normal_attr = mesh_data.attributes.new(temp_normal_name, "INT16_2D", "CORNER") + temp_normal_attr.data.foreach_set("value", normals_data) + else: + raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'") + + # Select meshes + obj: bpy.types.Object for obj in context.view_layer.objects: obj.select_set(obj in mmd_model_mesh_objects) context.view_layer.objects.active = mmd_model_mesh_objects[0] - # separate mesh by selected vertices - logger.debug("Separating meshes by selected vertices") + # Separate mesh by selected vertices bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.separate(type="SELECTED") - separate_mesh_objects: List[Object] = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects] + separate_mesh_objects = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects] bpy.ops.object.mode_set(mode="OBJECT") - logger.debug(f"Created {len(separate_mesh_objects)} separate mesh objects") - model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects)) + model2separate_mesh_objects = dict(zip(mmd_model_mesh_objects, separate_mesh_objects, strict=False)) - logger.debug(f"Creating new model with scale {mmd_scale}") - separate_model: Model = Model.create(mmd_root_object.mmd_root.name, mmd_root_object.mmd_root.name_e, mmd_scale, add_root_bone=False) + # Restore normal data for all meshes (original and separated) + all_mesh_objects = list(mmd_model_mesh_objects) + list(separate_mesh_objects) + for mesh_obj in all_mesh_objects: + mesh_data = mesh_obj.data + temp_normal_attr = mesh_data.attributes.get(temp_normal_name) + if not temp_normal_attr: + continue - separate_model.initialDisplayFrames() - separate_root_object = separate_model.rootObject() - separate_root_object.matrix_world = mmd_root_object.matrix_world - separate_model_armature_object = separate_model.armature() - logger.debug(f"Created separate model with root: {separate_root_object.name}") + try: + if temp_normal_attr.data_type == "INT16_2D": + normals_data = np.empty(len(mesh_data.loops) * 2, dtype=np.int16) + temp_normal_attr.data.foreach_get("value", normals_data) + custom_normal_attr = mesh_data.attributes.get("custom_normal") + if not custom_normal_attr: + custom_normal_attr = mesh_data.attributes.new("custom_normal", "INT16_2D", "CORNER") + custom_normal_attr.data.foreach_set("value", normals_data) + else: + raise TypeError(f"Unsupported custom_normal data type: '{temp_normal_attr.data_type}'. Supported types: 'INT16_2D'") + finally: + mesh_data.attributes.remove(temp_normal_attr) - if self.separate_armature: - logger.debug("Joining separate armature to new model") - with context.temp_override( - active_object=separate_model_armature_object, - selected_editable_objects=[separate_model_armature_object, separate_armature_object], - ): + if self.separate_armature and separate_armature_object: + separate_armature_data = separate_armature_object.data + with select_object(separate_model_armature_object, objects=[separate_model_armature_object, separate_armature_object]): bpy.ops.object.join() + if separate_armature_data.users == 0: + bpy.data.armatures.remove(separate_armature_data) - # add mesh - logger.debug("Parenting separate mesh objects to new model") - with context.temp_override( - object=separate_model_armature_object, - selected_editable_objects=[separate_model_armature_object, *separate_mesh_objects], - ): - bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + if separate_mesh_objects: + with select_object(separate_model_armature_object, objects=[separate_model_armature_object] + separate_mesh_objects): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) - # replace mesh armature modifier.object - logger.debug("Updating armature modifiers on separate meshes") + # Replace mesh armature modifier.object for separate_mesh in separate_mesh_objects: armature_modifier: Optional[bpy.types.ArmatureModifier] = next(iter([m for m in separate_mesh.modifiers if m.type == "ARMATURE"]), None) if armature_modifier is None: - logger.debug(f"Creating new armature modifier for {separate_mesh.name}") - armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_bone_order_override", "ARMATURE") + armature_modifier: bpy.types.ArmatureModifier = separate_mesh.modifiers.new("mmd_armature", "ARMATURE") armature_modifier.object = separate_model_armature_object - logger.debug("Parenting rigid bodies to new model") - with context.temp_override( - object=separate_model.rigidGroupObject(), - selected_editable_objects=[separate_model.rigidGroupObject(), *separate_rigid_bodies], - ): - bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + if separate_rigid_bodies: + with select_object(separate_model.rigidGroupObject(), objects=[separate_model.rigidGroupObject()] + list(separate_rigid_bodies)): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) - logger.debug("Parenting joints to new model") - with context.temp_override( - object=separate_model.jointGroupObject(), - selected_editable_objects=[separate_model.jointGroupObject(), *separate_joints], - ): - bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) + if separate_joints: + with select_object(separate_model.jointGroupObject(), objects=[separate_model.jointGroupObject()] + list(separate_joints)): + bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) - # move separate objects to new collection + # Move separate objects to new collection mmd_layer_collection = FnContext.find_user_layer_collection_by_object(context, mmd_root_object) assert mmd_layer_collection is not None @@ -293,31 +326,42 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): assert separate_layer_collection is not None if mmd_layer_collection.name != separate_layer_collection.name: - logger.debug(f"Moving objects from collection {mmd_layer_collection.name} to {separate_layer_collection.name}") for separate_object in itertools.chain(separate_mesh_objects, separate_rigid_bodies, separate_joints): - separate_layer_collection.collection.objects.link(separate_object) - mmd_layer_collection.collection.objects.unlink(separate_object) + if separate_object.name not in separate_layer_collection.collection.objects: + separate_layer_collection.collection.objects.link(separate_object) + if separate_object.name in mmd_layer_collection.collection.objects: + mmd_layer_collection.collection.objects.unlink(separate_object) - logger.debug("Copying MMD root properties") FnModel.copy_mmd_root( separate_root_object, mmd_root_object, overwrite=True, replace_name2values={ - # replace related_mesh property values - "related_mesh": {m.data.name: s.data.name for m, s in model2separate_mesh_objects.items()} + # Replace related_mesh property values + "related_mesh": {m.data.name: s.data.name for m, s in model2separate_mesh_objects.items()}, }, ) - def select_weighted_vertices(self, mmd_model_mesh_objects: List[Object], separate_bones: Dict[str, EditBone], deform_bones: Dict[str, EditBone], weight_threshold: float) -> Dict[Object, int]: - """Select vertices weighted to the bones to be separated""" - logger.debug(f"Selecting vertices weighted to {len(separate_bones)} bones with threshold {weight_threshold}") - mesh2selected_vertex_count: Dict[Object, int] = dict() + # Apply additional transform + FnContext.set_active_and_select_single_object(context, mmd_root_object) + bpy.ops.mmd_tools.apply_additional_transform() + FnContext.set_active_and_select_single_object(context, separate_root_object) + bpy.ops.mmd_tools.apply_additional_transform() + + # Restore original transform matrix for root object + mmd_root_object.matrix_world = original_matrix_world + separate_root_object.matrix_world = original_matrix_world + + # End state + FnContext.set_active_and_select_single_object(context, separate_root_object) + + def _select_weighted_vertices(self, mmd_model_mesh_objects: List[bpy.types.Object], separate_bones: Dict[str, bpy.types.EditBone], deform_bones: Dict[str, bpy.types.EditBone], weight_threshold: float) -> Dict[bpy.types.Object, int]: + mesh2selected_vertex_count: Dict[bpy.types.Object, int] = {} target_bmesh: bmesh.types.BMesh = bmesh.new() for mesh_object in mmd_model_mesh_objects: vertex_groups: bpy.types.VertexGroups = mesh_object.vertex_groups - mesh: Mesh = mesh_object.data + mesh: bpy.types.Mesh = mesh_object.data target_bmesh.from_mesh(mesh, face_normals=False) target_bmesh.select_mode |= {"VERT"} deform_layer = target_bmesh.verts.layers.deform.verify() @@ -344,7 +388,6 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): vert.select_set(True) if selected_vertex_count > 0: - logger.debug(f"Selected {selected_vertex_count} vertices in mesh {mesh_object.name}") mesh2selected_vertex_count[mesh_object] = selected_vertex_count target_bmesh.select_flush_mode() target_bmesh.to_mesh(mesh) @@ -352,3 +395,61 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): target_bmesh.clear() return mesh2selected_vertex_count + + def _select_related_ik_bones(self, armature_object: bpy.types.Object) -> None: + """ + Expand the current selection to include any full IK systems that are + partially selected. An IK system includes the chain bones, the IK + target bone, and the pole target bone. + + NOTE: This method operates entirely in EDIT mode and avoids mode switching + to prevent segmentation faults. + """ + edit_bones = armature_object.data.edit_bones + initial_selection_names = {b.name for b in edit_bones if b.select} + + # Access pose bones constraints directly without mode switching + pose_bones = armature_object.pose.bones + + # Find all complete IK systems + ik_systems = [] + + for pose_bone in pose_bones: + for constraint in pose_bone.constraints: + if constraint.type == "IK": + # Build the set of bones in this IK system + system_bones = {pose_bone.name} + + # Add the main IK Target bone + if constraint.target and constraint.subtarget: + system_bones.add(constraint.subtarget) + + # Add the Pole Target bone + if constraint.pole_target and constraint.pole_subtarget: + system_bones.add(constraint.pole_subtarget) + + # Add all other bones in the IK chain + current_bone_name = pose_bone.name + chain_count = constraint.chain_count + + # Walk up the parent chain + for _ in range(chain_count - 1): + if current_bone_name not in edit_bones: + break + current_bone = edit_bones[current_bone_name] + if not current_bone.parent: + break + current_bone_name = current_bone.parent.name + system_bones.add(current_bone_name) + + ik_systems.append(system_bones) + + # Expand selection to include any related, full IK systems + final_selection_names = set(initial_selection_names) + for system in ik_systems: + if not system.isdisjoint(initial_selection_names): + final_selection_names.update(system) + + # Apply the final selection + for bone in edit_bones: + bone.select = bone.name in final_selection_names diff --git a/core/mmd/operators/morph.py b/core/mmd/operators/morph.py index 13cd4cf..aa12fe8 100644 --- a/core/mmd/operators/morph.py +++ b/core/mmd/operators/morph.py @@ -1,30 +1,26 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2015 MMD Tools authors +# This file is part of MMD Tools. -from typing import Optional, cast, List, Dict, Any, Set, Tuple, Union +from collections import namedtuple +from typing import Optional, cast import bpy from mathutils import Quaternion, Vector -from ..core.model import FnModel from .. import bpyutils, utils from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial +from ..core.model import FnModel from ..core.morph import FnMorph from ..utils import ItemMoveOp, ItemOp -from ....logging_setup import logger # Util functions -def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float]: +def divide_vector_components(vec1, vec2): if len(vec1) != len(vec2): raise ValueError("Vectors should have the same number of components") result = [] - for v1, v2 in zip(vec1, vec2): + for v1, v2 in zip(vec1, vec2, strict=False): if v2 == 0: if v1 == 0: v2 = 1 # If we have a 0/0 case we change the divisor to 1 @@ -34,17 +30,17 @@ def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float return result -def multiply_vector_components(vec1: List[float], vec2: List[float]) -> List[float]: +def multiply_vector_components(vec1, vec2): if len(vec1) != len(vec2): raise ValueError("Vectors should have the same number of components") result = [] - for v1, v2 in zip(vec1, vec2): + for v1, v2 in zip(vec1, vec2, strict=False): result.append(v1 * v2) return result -def special_division(n1: float, n2: float) -> float: - """This function returns 0 in case of 0/0. If non-zero divided by zero case is found, an Exception is raised""" +def special_division(n1, n2): + """Return 0 in case of 0/0. If non-zero divided by zero case is found, an Exception is raised""" if n2 == 0: if n1 == 0: n2 = 1 @@ -59,7 +55,7 @@ class AddMorph(bpy.types.Operator): bl_description = "Add a morph item to active morph list" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -69,7 +65,6 @@ class AddMorph(bpy.types.Operator): morph.name = "New Morph" if morph_type.startswith("uv"): morph.data_type = "VERTEX_GROUP" - logger.debug(f"Added new morph of type {morph_type}") return {"FINISHED"} @@ -86,7 +81,7 @@ class RemoveMorph(bpy.types.Operator): options={"SKIP_SAVE"}, ) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -101,21 +96,19 @@ class RemoveMorph(bpy.types.Operator): if self.all: morphs.clear() mmd_root.active_morph = 0 - logger.debug(f"Removed all morphs of type {morph_type}") else: morphs.remove(mmd_root.active_morph) mmd_root.active_morph = max(0, mmd_root.active_morph - 1) - logger.debug(f"Removed morph at index {mmd_root.active_morph} of type {morph_type}") return {"FINISHED"} class MoveMorph(bpy.types.Operator, ItemMoveOp): bl_idname = "mmd_tools.morph_move" bl_label = "Move Morph" - bl_description = "Move active morph item up/down in the list" + bl_description = "Move active morph item up/down in the list. This will not affect the morph order in exported PMX files (use Display Panel order instead)." bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -124,7 +117,6 @@ class MoveMorph(bpy.types.Operator, ItemMoveOp): mmd_root.active_morph, self.type, ) - logger.debug(f"Moved morph to index {mmd_root.active_morph}") return {"FINISHED"} @@ -134,7 +126,7 @@ class CopyMorph(bpy.types.Operator): bl_description = "Make a copy of active morph in the list" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -146,7 +138,7 @@ class CopyMorph(bpy.types.Operator): if morph is None: return {"CANCELLED"} - name_orig, name_tmp = morph.name, "_tmp%s" % str(morph.as_pointer()) + name_orig, name_tmp = morph.name, f"_tmp{str(morph.as_pointer())}" if morph_type.startswith("vertex"): for obj in FnModel.iterate_mesh_objects(root): @@ -161,7 +153,6 @@ class CopyMorph(bpy.types.Operator): for k, v in morph.items(): morph_new[k] = v if k != "name" else name_tmp morph_new.name = name_orig + "_copy" # trigger name check - logger.debug(f"Copied morph {name_orig} to {morph_new.name}") return {"FINISHED"} @@ -171,17 +162,14 @@ class OverwriteBoneMorphsFromActionPose(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): root = FnModel.find_root_object(context.active_object) - if root is None: - return False + return root is not None and root.mmd_root.active_morph_type == "bone_morphs" - return root.mmd_root.active_morph_type == "bone_morphs" - - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): root = FnModel.find_root_object(context.active_object) FnMorph.overwrite_bone_morphs_from_action_pose(FnModel.find_armature_object(root)) - logger.info("Overwrote bone morphs from active action pose") + return {"FINISHED"} @@ -191,7 +179,7 @@ class AddMorphOffset(bpy.types.Operator): bl_description = "Add a morph offset item to the list" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -216,7 +204,6 @@ class AddMorphOffset(bpy.types.Operator): item.location = pose_bone.location item.rotation = pose_bone.rotation_quaternion - logger.debug(f"Added morph offset to {morph_type}") return {"FINISHED"} @@ -233,7 +220,7 @@ class RemoveMorphOffset(bpy.types.Operator): options={"SKIP_SAVE"}, ) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -250,21 +237,17 @@ class RemoveMorphOffset(bpy.types.Operator): if morph_type.startswith("vertex"): for obj in FnModel.iterate_mesh_objects(root): FnMorph.remove_shape_key(obj, morph.name) - logger.debug(f"Removed all vertex morph offsets for {morph.name}") return {"FINISHED"} - elif morph_type.startswith("uv"): + if morph_type.startswith("uv"): if morph.data_type == "VERTEX_GROUP": for obj in FnModel.iterate_mesh_objects(root): FnMorph.store_uv_morph_data(obj, morph) - logger.debug(f"Removed all UV morph offsets for {morph.name}") return {"FINISHED"} morph.data.clear() morph.active_data = 0 - logger.debug(f"Cleared all morph offsets for {morph.name}") else: morph.data.remove(morph.active_data) morph.active_data = max(0, morph.active_data - 1) - logger.debug(f"Removed morph offset at index {morph.active_data}") return {"FINISHED"} @@ -280,7 +263,7 @@ class InitMaterialOffset(bpy.types.Operator): default=0, ) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -292,7 +275,6 @@ class InitMaterialOffset(bpy.types.Operator): mat_data.specular_color = mat_data.ambient_color = (val,) * 3 mat_data.shininess = mat_data.edge_weight = val mat_data.texture_factor = mat_data.toon_texture_factor = mat_data.sphere_texture_factor = (val,) * 4 - logger.debug(f"Initialized material offset with value {val}") return {"FINISHED"} @@ -302,7 +284,7 @@ class ApplyMaterialOffset(bpy.types.Operator): bl_description = "Calculates the offsets and apply them, then the temporary material is removed" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -340,7 +322,6 @@ class ApplyMaterialOffset(bpy.types.Operator): except ZeroDivisionError: mat_data.offset_type = "ADD" # If there is any 0 division we automatically switch it to type ADD - logger.warning("Zero division detected, switching to ADD offset type") except ValueError: self.report({"ERROR"}, "An unexpected error happened") # We should stop on our tracks and re-raise the exception @@ -358,7 +339,6 @@ class ApplyMaterialOffset(bpy.types.Operator): mat_data.edge_weight = work_mmd_mat.edge_weight - base_mmd_mat.edge_weight FnMaterial.clean_materials(meshObj, can_remove=lambda m: m == work_mat) - logger.info(f"Applied material offset for {mat_data.material}") return {"FINISHED"} @@ -368,7 +348,7 @@ class CreateWorkMaterial(bpy.types.Operator): bl_description = "Creates a temporary material to edit this offset" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -385,12 +365,12 @@ class CreateWorkMaterial(bpy.types.Operator): base_mat = meshObj.data.materials.get(mat_data.material, None) if base_mat is None: - self.report({"ERROR"}, 'Material "%s" not found' % mat_data.material) + self.report({"ERROR"}, f'Material "{mat_data.material}" not found') return {"CANCELLED"} work_mat_name = base_mat.name + "_temp" if work_mat_name in bpy.data.materials: - self.report({"ERROR"}, 'Temporary material "%s" is in use' % work_mat_name) + self.report({"ERROR"}, f'Temporary material "{work_mat_name}" is in use') return {"CANCELLED"} work_mat = base_mat.copy() @@ -427,7 +407,6 @@ class CreateWorkMaterial(bpy.types.Operator): work_mmd_mat.edge_color = list(edge_offset) work_mmd_mat.edge_weight += mat_data.edge_weight - logger.info(f"Created work material {work_mat_name}") return {"FINISHED"} @@ -437,24 +416,23 @@ class ClearTempMaterials(bpy.types.Operator): bl_description = "Clears all the temporary materials" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None for meshObj in FnModel.iterate_mesh_objects(root): - def __pre_remove(m: Optional[bpy.types.Material]) -> bool: + def __pre_remove(m, meshObj=meshObj): if m and "_temp" in m.name: base_mat_name = m.name.split("_temp")[0] try: FnMaterial.swap_materials(meshObj, m.name, base_mat_name) return True except MaterialNotFoundError: - self.report({"WARNING"}, "Base material for %s was not found" % m.name) + self.report({"WARNING"}, f"Base material for {m.name} was not found") return False FnMaterial.clean_materials(meshObj, can_remove=__pre_remove) - logger.info("Cleared all temporary materials") return {"FINISHED"} @@ -464,7 +442,7 @@ class ViewBoneMorph(bpy.types.Operator): bl_description = "View the result of active bone morph" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -475,12 +453,10 @@ class ViewBoneMorph(bpy.types.Operator): for morph_data in morph.data: p_bone: Optional[bpy.types.PoseBone] = armature.pose.bones.get(morph_data.bone, None) if p_bone: - # Blender 5.0: use pose bone select property directly p_bone.select = True mtx = (p_bone.matrix_basis.to_3x3() @ Quaternion(*morph_data.rotation.to_axis_angle()).to_matrix()).to_4x4() mtx.translation = p_bone.location + morph_data.location p_bone.matrix_basis = mtx - logger.info(f"Viewing bone morph: {morph.name}") return {"FINISHED"} @@ -490,14 +466,13 @@ class ClearBoneMorphView(bpy.types.Operator): bl_description = "Reset transforms of all bones to their default values" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None armature = FnModel.find_armature_object(root) for p_bone in armature.pose.bones: p_bone.matrix_basis.identity() - logger.info("Cleared bone morph view") return {"FINISHED"} @@ -507,7 +482,7 @@ class ApplyBoneMorph(bpy.types.Operator): bl_description = "Apply current pose to active bone morph" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -522,11 +497,9 @@ class ApplyBoneMorph(bpy.types.Operator): item.bone = p_bone.name item.location = p_bone.location item.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion() - # Blender 5.0: use pose bone select property directly p_bone.select = True else: p_bone.select = False - logger.info(f"Applied current pose to bone morph: {morph.name}") return {"FINISHED"} @@ -536,7 +509,7 @@ class SelectRelatedBone(bpy.types.Operator): bl_description = "Select the bone assigned to this offset in the armature" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -545,7 +518,6 @@ class SelectRelatedBone(bpy.types.Operator): morph = mmd_root.bone_morphs[mmd_root.active_morph] morph_data = morph.data[morph.active_data] utils.selectSingleBone(context, armature, morph_data.bone) - logger.debug(f"Selected bone: {morph_data.bone}") return {"FINISHED"} @@ -555,7 +527,7 @@ class EditBoneOffset(bpy.types.Operator): bl_description = "Applies the location and rotation of this offset to the bone" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -568,7 +540,6 @@ class EditBoneOffset(bpy.types.Operator): mtx.translation = morph_data.location p_bone.matrix_basis = mtx utils.selectSingleBone(context, armature, p_bone.name) - logger.debug(f"Edited bone offset for {p_bone.name}") return {"FINISHED"} @@ -578,7 +549,7 @@ class ApplyBoneOffset(bpy.types.Operator): bl_description = "Stores the current bone location and rotation into this offset" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -590,7 +561,6 @@ class ApplyBoneOffset(bpy.types.Operator): p_bone = armature.pose.bones[morph_data.bone] morph_data.location = p_bone.location morph_data.rotation = p_bone.rotation_quaternion if p_bone.rotation_mode == "QUATERNION" else p_bone.matrix_basis.to_quaternion() - logger.debug(f"Applied bone offset for {p_bone.name}") return {"FINISHED"} @@ -600,7 +570,7 @@ class ViewUVMorph(bpy.types.Operator): bl_description = "View the result of active UV morph on current mesh object" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -618,11 +588,11 @@ class ViewUVMorph(bpy.types.Operator): selected = meshObj.select_get() with bpyutils.select_object(meshObj): - mesh = cast(bpy.types.Mesh, meshObj.data) + mesh = cast("bpy.types.Mesh", meshObj.data) morph = mmd_root.uv_morphs[mmd_root.active_morph] uv_textures = mesh.uv_layers - base_uv_layers = [l for l in mesh.uv_layers if not l.name.startswith("_")] + base_uv_layers = [layer for layer in mesh.uv_layers if not layer.name.startswith("_")] if morph.uv_index >= len(base_uv_layers): self.report({"ERROR"}, "Invalid uv index: %d" % morph.uv_index) return {"CANCELLED"} @@ -632,7 +602,7 @@ class ViewUVMorph(bpy.types.Operator): uv_textures.active = uv_textures[uv_layer_name] uv_layer_name = uv_textures.active.name - uv_tex = uv_textures.new(name="__uv.%s" % uv_layer_name) + uv_tex = uv_textures.new(name=f"__uv.{uv_layer_name}") if uv_tex is None: self.report({"ERROR"}, "Failed to create a temporary uv layer") return {"CANCELLED"} @@ -642,17 +612,15 @@ class ViewUVMorph(bpy.types.Operator): if len(offsets) > 0: base_uv_data = mesh.uv_layers.active.data temp_uv_data = mesh.uv_layers[uv_tex.name].data - for i, l in enumerate(mesh.loops): - # Blender 5.0+: UV selection is now stored in face-corner attributes - # Skipping UV selection assignment as it's not critical for morph preview - select = l.vertex_index in offsets + for i, loop in enumerate(mesh.loops): + select = temp_uv_data[i].select = loop.vertex_index in offsets if select: - temp_uv_data[i].uv = base_uv_data[i].uv + offsets[l.vertex_index] + temp_uv_data[i].uv = base_uv_data[i].uv + offsets[loop.vertex_index] uv_textures.active = uv_tex + uv_tex.active_render = True meshObj.hide_set(False) meshObj.select_set(selected) - logger.info(f"Viewing UV morph: {morph.name}") return {"FINISHED"} @@ -662,24 +630,24 @@ class ClearUVMorphView(bpy.types.Operator): bl_description = "Clear all temporary data of UV morphs" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None for m in FnModel.iterate_mesh_objects(root): mesh = m.data - uv_layers = mesh.uv_layers - for t in list(uv_layers): # Create a copy to iterate safely + uv_textures = getattr(mesh, "uv_textures", mesh.uv_layers) + for t in reversed(uv_textures): if t.name.startswith("__uv."): - uv_layers.remove(t) - if len(uv_layers) > 0: - # Only set active_index - uv_layers.active_index = 0 + uv_textures.remove(t) + if len(uv_textures) > 0: + uv_textures[0].active_render = True + uv_textures.active_index = 0 animation_data = mesh.animation_data if animation_data: nla_tracks = animation_data.nla_tracks - for t in nla_tracks: + for t in reversed(nla_tracks): if t.name.startswith("__uv."): nla_tracks.remove(t) if animation_data.action and animation_data.action.name.startswith("__uv."): @@ -687,10 +655,9 @@ class ClearUVMorphView(bpy.types.Operator): if animation_data.action is None and len(nla_tracks) == 0: mesh.animation_data_clear() - for act in bpy.data.actions: + for act in reversed(bpy.data.actions): if act.name.startswith("__uv.") and act.users < 1: bpy.data.actions.remove(act) - logger.info("Cleared UV morph view") return {"FINISHED"} @@ -701,20 +668,20 @@ class EditUVMorph(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): obj = context.active_object - if obj.type != "MESH": + if obj is None or obj.type != "MESH": return False active_uv_layer = obj.data.uv_layers.active - return active_uv_layer and active_uv_layer.name.startswith("__uv.") + return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.") - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object meshObj = obj selected = meshObj.select_get() with bpyutils.select_object(meshObj): - mesh = cast(bpy.types.Mesh, meshObj.data) + mesh = cast("bpy.types.Mesh", meshObj.data) bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_mode(type="VERT", action="ENABLE") bpy.ops.mesh.reveal() # unhide all vertices @@ -722,16 +689,15 @@ class EditUVMorph(bpy.types.Operator): bpy.ops.object.mode_set(mode="OBJECT") vertices = mesh.vertices - for l, d in zip(mesh.loops, mesh.uv_layers.active.data): + for loop, d in zip(mesh.loops, mesh.uv_layers.active.data, strict=False): if d.select: - vertices[l.vertex_index].select = True + vertices[loop.vertex_index].select = True polygons = mesh.polygons polygons.active = getattr(next((p for p in polygons if all(vertices[i].select for i in p.vertices)), None), "index", polygons.active) bpy.ops.object.mode_set(mode="EDIT") meshObj.select_set(selected) - logger.info("Editing UV morph") return {"FINISHED"} @@ -742,14 +708,14 @@ class ApplyUVMorph(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): obj = context.active_object - if obj.type != "MESH": + if obj is None or obj.type != "MESH": return False active_uv_layer = obj.data.uv_layers.active - return active_uv_layer and active_uv_layer.name.startswith("__uv.") + return active_uv_layer is not None and active_uv_layer.name.startswith("__uv.") - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -757,34 +723,31 @@ class ApplyUVMorph(bpy.types.Operator): selected = meshObj.select_get() with bpyutils.select_object(meshObj): - mesh = cast(bpy.types.Mesh, meshObj.data) + mesh = cast("bpy.types.Mesh", meshObj.data) morph = mmd_root.uv_morphs[mmd_root.active_morph] base_uv_name = mesh.uv_layers.active.name[5:] if base_uv_name not in mesh.uv_layers: - self.report({"ERROR"}, ' * UV map "%s" not found' % base_uv_name) + self.report({"ERROR"}, f' * UV map "{base_uv_name}" not found') return {"CANCELLED"} base_uv_data = mesh.uv_layers[base_uv_name].data temp_uv_data = mesh.uv_layers.active.data axis_type = "ZW" if base_uv_name.startswith("_") else "XY" - from collections import namedtuple - __OffsetData = namedtuple("OffsetData", "index, offset") offsets = {} vertices = mesh.vertices - for l, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data): - if vertices[l.vertex_index].select and l.vertex_index not in offsets: + for loop, i0, i1 in zip(mesh.loops, base_uv_data, temp_uv_data, strict=False): + if vertices[loop.vertex_index].select and loop.vertex_index not in offsets: dx, dy = i1.uv - i0.uv if abs(dx) > 0.0001 or abs(dy) > 0.0001: - offsets[l.vertex_index] = __OffsetData(l.vertex_index, (dx, dy, dx, dy)) + offsets[loop.vertex_index] = __OffsetData(loop.vertex_index, (dx, dy, dx, dy)) FnMorph.store_uv_morph_data(meshObj, morph, offsets.values(), axis_type) morph.data_type = "VERTEX_GROUP" meshObj.select_set(selected) - logger.info(f"Applied UV morph: {morph.name}") return {"FINISHED"} @@ -795,12 +758,339 @@ class CleanDuplicatedMaterialMorphs(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return FnModel.find_root_object(context.active_object) is not None + def poll(cls, context): + root = FnModel.find_root_object(context.active_object) + return root is not None - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context: bpy.types.Context): mmd_root_object = FnModel.find_root_object(context.active_object) FnMorph.clean_duplicated_material_morphs(mmd_root_object) - logger.info("Cleaned duplicated material morphs") return {"FINISHED"} + + +class ConvertBoneMorphToVertexMorph(bpy.types.Operator): + bl_idname = "mmd_tools.convert_bone_morph_to_vertex_morph" + bl_label = "Convert To Vertex Morph" + bl_description = "Convert a bone morph into a single vertex morph by applying the bone transformations.\nIf a corresponding vertex morph already exists, it will be updated." + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + root = FnModel.find_root_object(context.active_object) + if root is None: + return False + mmd_root = root.mmd_root + if mmd_root.active_morph_type != "bone_morphs": + return False + morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph) + return morph is not None and len(morph.data) > 0 + + def execute(self, context): + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + + # Get the active bone morph + bone_morph = ItemOp.get_by_index(mmd_root.bone_morphs, mmd_root.active_morph) + if bone_morph is None: + self.report({"ERROR"}, "No active bone morph") + return {"CANCELLED"} + + original_name = bone_morph.name + target_name = original_name + + # Add 'B' suffix if necessary + if not original_name.endswith("B"): + bone_morph.name = original_name + "B" + target_name = original_name + else: + # If already has B suffix, use name without B + target_name = original_name[:-1] + + try: + # Step 1: import + from ..core.model import Model + + rig = Model(root) + + # Ensure morph slider is bound + bpy.ops.mmd_tools.morph_slider_setup(type="BIND") + + # Re-obtain placeholder object + placeholder_obj = rig.morph_slider.placeholder() + if placeholder_obj is None or placeholder_obj.data.shape_keys is None: + self.report({"ERROR"}, "Failed to create morph slider system") + return {"CANCELLED"} + + shape_keys = placeholder_obj.data.shape_keys + key_blocks = shape_keys.key_blocks + + # Step 2: Check if target bone morph exists + current_morph_name = bone_morph.name + if current_morph_name not in key_blocks: + self.report({"ERROR"}, f"Bone morph '{current_morph_name}' not found in morph sliders") + return {"CANCELLED"} + + # Step 3: Save all current morph values + original_values = {} + for key_block in key_blocks: + if key_block.name != "--- morph sliders ---": + original_values[key_block.name] = key_block.value + + # Step 4: Set all morphs to 0 + for key_block in key_blocks: + if key_block.name != "--- morph sliders ---": + key_block.value = 0 + + # Step 5: Set target bone morph to 1.0 + key_blocks[current_morph_name].value = 1.0 + + # Step 6: Use Armature Modifier's "Apply as Shape Key" functionality + created_shape_keys = [] + for mesh_obj in FnModel.iterate_mesh_objects(root): + # Switch to this mesh object + context.view_layer.objects.active = mesh_obj + + # Ensure mesh object has shape keys + if mesh_obj.data.shape_keys is None: + mesh_obj.shape_key_add(name="Basis", from_mix=False) + + # Delete existing shape key with same name + if target_name in mesh_obj.data.shape_keys.key_blocks: + idx = mesh_obj.data.shape_keys.key_blocks.find(target_name) + if idx >= 0: + mesh_obj.active_shape_key_index = idx + bpy.ops.object.shape_key_remove() + + # Find armature modifier + armature_modifier = None + for modifier in mesh_obj.modifiers: + if modifier.type == "ARMATURE": + armature_modifier = modifier + break + + if armature_modifier is None: + self.report({"WARNING"}, f"No armature modifier found on mesh '{mesh_obj.name}'") + continue + + # Use Apply as Shape Key functionality, keeping the modifier + bpy.ops.object.modifier_apply_as_shapekey(modifier=armature_modifier.name, keep_modifier=True) + + # Rename the newly created shape key to target name + shape_key_blocks = mesh_obj.data.shape_keys.key_blocks + new_shape_key = shape_key_blocks[-1] # Latest created shape key + new_shape_key.name = target_name + new_shape_key.value = 0.0 # Set to 0 to avoid double effect + + created_shape_keys.append((mesh_obj.name, target_name)) + self.report({"INFO"}, f"Created shape key '{target_name}' on mesh '{mesh_obj.name}'") + + # Step 7: Restore all original morph values + for key_name, original_value in original_values.items(): + if key_name in key_blocks: + key_blocks[key_name].value = original_value + + # Step 8: Create or update vertex morph entry + vertex_morph_exists = False + for i, morph in enumerate(mmd_root.vertex_morphs): + if morph.name == target_name: + vertex_morph_exists = True + mmd_root.active_morph_type = "vertex_morphs" + mmd_root.active_morph = i + break + + if not vertex_morph_exists: + mmd_root.active_morph_type = "vertex_morphs" + morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph) + morph.name = target_name + + # Step 9: Add to facial expression display frame + facial_frame = None + for frame in mmd_root.display_item_frames: + if frame.name == "表情": + facial_frame = frame + break + + if facial_frame: + morph_exists_in_frame = False + for item in facial_frame.data: + if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs": + morph_exists_in_frame = True + break + + if not morph_exists_in_frame: + new_item = facial_frame.data.add() + new_item.type = "MORPH" + new_item.morph_type = "vertex_morphs" + new_item.name = target_name + + facial_frame.active_item = len(facial_frame.data) - 1 + + for i, frame in enumerate(mmd_root.display_item_frames): + if frame.name == "表情": + mmd_root.active_display_item_frame = i + break + + # UNBIND + bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND") + + # Success message + shape_key_info = ", ".join([f"{mesh}:{key}" for mesh, key in created_shape_keys]) + self.report({"INFO"}, f"Successfully converted bone morph '{original_name}' to vertex morph '{target_name}'. Created shape keys: {shape_key_info}") + + except Exception as e: + self.report({"ERROR"}, f"Error during conversion: {str(e)}") + return {"CANCELLED"} + + return {"FINISHED"} + + +class ConvertGroupMorphToVertexMorph(bpy.types.Operator): + bl_idname = "mmd_tools.convert_group_morph_to_vertex_morph" + bl_label = "Convert To Vertex Morph" + bl_description = "Convert a group morph into a single vertex morph by merging only the vertex morphs within the group.\nIf a corresponding vertex morph already exists, it will be updated." + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + @classmethod + def poll(cls, context): + root = FnModel.find_root_object(context.active_object) + if root is None: + return False + mmd_root = root.mmd_root + if mmd_root.active_morph_type != "group_morphs": + return False + morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph) + return morph is not None and len(morph.data) > 0 + + def execute(self, context): + bpy.ops.mmd_tools.morph_slider_setup(type="UNBIND") + + obj = context.active_object + root = FnModel.find_root_object(obj) + mmd_root = root.mmd_root + + # Get the active group morph + group_morph = ItemOp.get_by_index(mmd_root.group_morphs, mmd_root.active_morph) + if group_morph is None: + self.report({"ERROR"}, "No active group morph") + return {"CANCELLED"} + + # Check if the group morph contains any vertex morphs to convert + has_vertex_morphs = False + for offset in group_morph.data: + if offset.morph_type == "vertex_morphs": + has_vertex_morphs = True + break + + if not has_vertex_morphs: + self.report({"ERROR"}, "The group morph does not contain any vertex morphs to convert") + return {"CANCELLED"} + + original_name = group_morph.name + target_name = original_name + + # Add 'G' suffix if necessary + if not original_name.endswith("G"): + group_morph.name = original_name + "G" + target_name = original_name + else: + # If already has G suffix, use name without G + target_name = original_name[:-1] + + # First, reset all shape keys to zero + for obj in FnModel.iterate_mesh_objects(root): + if obj.data.shape_keys: + for kb in obj.data.shape_keys.key_blocks: + kb.value = 0 + + # Apply only the vertex morphs from the group morph + for offset in group_morph.data: + if offset.morph_type == "vertex_morphs": + # Find the vertex morph by name + vertex_morph = getattr(root.mmd_root, offset.morph_type).get(offset.name) + if vertex_morph: + # Apply this morph at the specified factor + for obj in FnModel.iterate_mesh_objects(root): + if obj.data.shape_keys: + kb = obj.data.shape_keys.key_blocks.get(offset.name) + if kb: + kb.value = offset.factor + + # Now add a new shape key from mix for each mesh + for obj in FnModel.iterate_mesh_objects(root): + if obj.data.shape_keys: + # Make this the active object + context.view_layer.objects.active = obj + + # Remove existing shape key if it exists + if target_name in obj.data.shape_keys.key_blocks: + idx = obj.data.shape_keys.key_blocks.find(target_name) + if idx >= 0: + obj.active_shape_key_index = idx + bpy.ops.object.shape_key_remove() + + # Add shape key from mix + bpy.ops.object.shape_key_add(from_mix=True) + + # Rename the newly created shape key + new_key = obj.data.shape_keys.key_blocks[-1] + new_key.name = target_name + + # Check if a vertex morph with the target name already exists + vertex_morph_exists = False + for i, morph in enumerate(mmd_root.vertex_morphs): + if morph.name == target_name: + vertex_morph_exists = True + mmd_root.active_morph_type = "vertex_morphs" + mmd_root.active_morph = i + break + + # If not, create a new vertex morph + if not vertex_morph_exists: + # Switch to vertex morphs panel + mmd_root.active_morph_type = "vertex_morphs" + + # Add new vertex morph + morph, mmd_root.active_morph = ItemOp.add_after(mmd_root.vertex_morphs, mmd_root.active_morph) + morph.name = target_name + + # Add the new vertex morph to the facial display frame + facial_frame = None + for frame in mmd_root.display_item_frames: + if frame.name == "表情": # This is the facial display frame + facial_frame = frame + break + + if facial_frame: + # Check if this morph is already in the facial frame + morph_exists_in_frame = False + for item in facial_frame.data: + if item.type == "MORPH" and item.name == target_name and item.morph_type == "vertex_morphs": + morph_exists_in_frame = True + break + + # If not, add it + if not morph_exists_in_frame: + new_item = facial_frame.data.add() + new_item.type = "MORPH" + new_item.morph_type = "vertex_morphs" + new_item.name = target_name + + # Make this the active item in the facial frame + facial_frame.active_item = len(facial_frame.data) - 1 + + # Set the facial frame as active + for i, frame in enumerate(mmd_root.display_item_frames): + if frame.name == "表情": + mmd_root.active_display_item_frame = i + break + + # Reset all shape keys + for obj in FnModel.iterate_mesh_objects(root): + if obj.data.shape_keys: + for kb in obj.data.shape_keys.key_blocks: + kb.value = 0 + + self.report({"INFO"}, f"Successfully converted vertex morphs in group to vertex morph '{target_name}' and added to facial display frame") + return {"FINISHED"} diff --git a/core/mmd/operators/rigid_body.py b/core/mmd/operators/rigid_body.py index ef91c47..cb009eb 100644 --- a/core/mmd/operators/rigid_body.py +++ b/core/mmd/operators/rigid_body.py @@ -1,12 +1,8 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2015 MMD Tools authors +# This file is part of MMD Tools. import math -from typing import Dict, Optional, Tuple, cast, Set, List, Any, Union, Generator +from typing import Dict, Optional, Tuple, cast import bpy from mathutils import Euler, Vector @@ -16,7 +12,6 @@ from ..bpyutils import FnContext, Props from ..core import rigid_body from ..core.model import FnModel, Model from ..core.rigid_body import FnRigidBody -from ...logging_setup import logger class SelectRigidBody(bpy.types.Operator): @@ -44,15 +39,15 @@ class SelectRigidBody(bpy.types.Operator): default=False, ) - def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: + def invoke(self, context, event): vm = context.window_manager return vm.invoke_props_dialog(self) @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): return FnModel.is_rigid_body_object(context.active_object) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) if root is None: @@ -174,7 +169,7 @@ class AddRigidBody(bpy.types.Operator): default=0.1, ) - def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None) -> bpy.types.Object: + def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None): name_j: str = self.name_j name_e: str = self.name_e size = self.size.copy() @@ -227,7 +222,7 @@ class AddRigidBody(bpy.types.Operator): ) @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): root_object = FnModel.find_root_object(context.active_object) if root_object is None: return False @@ -238,11 +233,11 @@ class AddRigidBody(bpy.types.Operator): return True - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): active_object = context.active_object - root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) - armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) + root_object = cast("bpy.types.Object", FnModel.find_root_object(active_object)) + armature_object = cast("bpy.types.Object", FnModel.find_armature_object(root_object)) if active_object != armature_object: FnContext.select_single_object(context, root_object).select_set(False) @@ -255,17 +250,15 @@ class AddRigidBody(bpy.types.Operator): armature_object.select_set(False) if len(selected_pose_bones) > 0: - logger.info(f"Adding rigid bodies to {len(selected_pose_bones)} selected bones") for pose_bone in selected_pose_bones: rigid = self.__add_rigid_body(context, root_object, pose_bone) rigid.select_set(True) else: - logger.info("Adding a single rigid body without bone attachment") rigid = self.__add_rigid_body(context, root_object) rigid.select_set(True) return {"FINISHED"} - def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: + def invoke(self, context, event): no_bone = True if context.selected_bones and len(context.selected_bones) > 0: no_bone = False @@ -291,13 +284,12 @@ class RemoveRigidBody(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): return FnModel.is_rigid_body_object(context.active_object) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) - logger.info(f"Removing rigid body: {obj.name}") utils.selectAObject(obj) # ensure this is the only one object select bpy.ops.object.delete(use_global=True) if root: @@ -310,8 +302,7 @@ class RigidBodyBake(bpy.types.Operator): bl_label = "Bake" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: - logger.info("Baking rigid body simulation") + def execute(self, context: bpy.types.Context): with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True) @@ -323,8 +314,7 @@ class RigidBodyDeleteBake(bpy.types.Operator): bl_label = "Delete Bake" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context) -> Set[str]: - logger.info("Deleting rigid body simulation bake") + def execute(self, context: bpy.types.Context): with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): bpy.ops.ptcache.free_bake("INVOKE_DEFAULT") @@ -387,7 +377,7 @@ class AddJoint(bpy.types.Operator): min=0, ) - def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]) -> Generator[Tuple[bpy.types.Object, bpy.types.Object], None, None]: + def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]): obj_seq = tuple(bone_map.keys()) for rigid_a, bone_a in bone_map.items(): for rigid_b, bone_b in bone_map.items(): @@ -400,7 +390,7 @@ class AddJoint(bpy.types.Operator): else: yield obj_seq - def __add_joint(self, context: bpy.types.Context, root_object: bpy.types.Object, rigid_pair: Tuple[bpy.types.Object, bpy.types.Object], bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]) -> bpy.types.Object: + def __add_joint(self, context: bpy.types.Context, root_object: bpy.types.Object, rigid_pair: Tuple[bpy.types.Object, bpy.types.Object], bone_map): loc: Optional[Vector] = None rot = Euler((0.0, 0.0, 0.0)) rigid_a, rigid_b = rigid_pair @@ -438,7 +428,7 @@ class AddJoint(bpy.types.Operator): ) @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): root_object = FnModel.find_root_object(context.active_object) if root_object is None: return False @@ -449,11 +439,11 @@ class AddJoint(bpy.types.Operator): return True - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): active_object = context.active_object - root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) - armature_object = cast(bpy.types.Object, FnModel.find_armature_object(root_object)) - bones = cast(bpy.types.Armature, armature_object.data).bones + root_object = cast("bpy.types.Object", FnModel.find_root_object(active_object)) + armature_object = cast("bpy.types.Object", FnModel.find_armature_object(root_object)) + bones = cast("bpy.types.Armature", armature_object.data).bones bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]] = {r: bones.get(r.mmd_rigid.bone, None) for r in FnModel.iterate_rigid_body_objects(root_object) if r.select_get()} if len(bone_map) < 2: @@ -462,19 +452,15 @@ class AddJoint(bpy.types.Operator): FnContext.select_single_object(context, root_object).select_set(False) if context.scene.rigidbody_world is None: - logger.info("Creating rigid body world") bpy.ops.rigidbody.world_add() - joint_count = 0 for pair in self.__enumerate_rigid_pair(bone_map): joint = self.__add_joint(context, root_object, pair, bone_map) joint.select_set(True) - joint_count += 1 - - logger.info(f"Added {joint_count} joints between rigid bodies") + return {"FINISHED"} - def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: + def invoke(self, context, event): vm = context.window_manager return vm.invoke_props_dialog(self) @@ -486,13 +472,12 @@ class RemoveJoint(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context: bpy.types.Context) -> bool: + def poll(cls, context): return FnModel.is_joint_object(context.active_object) - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): obj = context.active_object root = FnModel.find_root_object(obj) - logger.info(f"Removing joint: {obj.name}") utils.selectAObject(obj) # ensure this is the only one object select bpy.ops.object.delete(use_global=True) if root: @@ -507,7 +492,7 @@ class UpdateRigidBodyWorld(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @staticmethod - def __get_rigid_body_world_objects() -> Tuple[bpy.types.Collection, bpy.types.Collection]: + def __get_rigid_body_world_objects(): rigid_body.setRigidBodyWorldEnabled(True) rbw = bpy.context.scene.rigidbody_world if not rbw.collection: @@ -522,21 +507,21 @@ class UpdateRigidBodyWorld(bpy.types.Operator): return rbw.collection.objects, rbw.constraints.objects - def execute(self, context: bpy.types.Context) -> Set[str]: + def execute(self, context): scene = context.scene scene_objs = set(scene.objects) scene_objs.union(o for x in scene.objects if x.instance_type == "COLLECTION" and x.instance_collection for o in x.instance_collection.objects) - def _update_group(obj: bpy.types.Object, group: bpy.types.Collection) -> bool: + def _update_group(obj, group): if obj in scene_objs: if obj not in group.values(): group.link(obj) return True - elif obj in group.values(): + if obj in group.values(): group.unlink(obj) return False - def _references(obj: bpy.types.Object) -> Generator[bpy.types.Object, None, None]: + def _references(obj): yield obj if getattr(obj, "proxy", None): yield from _references(obj.proxy) @@ -553,7 +538,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator): # Object.rigid_body are removed, # but Object.rigid_body_constraint are retained. # Therefore, it must be checked with Object.mmd_type. - logger.info("Updating rigid body world objects") for i in (x for x in objects if x.mmd_type == "RIGID_BODY"): if not _update_group(i, rb_objs): continue @@ -568,7 +552,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator): # TODO Modify mmd_rigid to allow recovery of the remaining rigidbody parameters. # mass, friction, restitution, linear_dumping, angular_dumping - logger.info("Updating rigid body constraints") for i in (x for x in objects if x.rigid_body_constraint): if not _update_group(i, rbc_objs): continue @@ -579,7 +562,6 @@ class UpdateRigidBodyWorld(bpy.types.Operator): rbc.object2 = rb_map.get(rbc.object2, rbc.object2) if need_rebuild_physics: - logger.info("Rebuilding physics for models") for root_object in scene.objects: if root_object.mmd_type != "ROOT": continue diff --git a/core/mmd/operators/sdef.py b/core/mmd/operators/sdef.py index bb46807..7e84008 100644 --- a/core/mmd/operators/sdef.py +++ b/core/mmd/operators/sdef.py @@ -1,23 +1,18 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2018 MMD Tools authors +# This file is part of MMD Tools. -from typing import Set, Tuple +from typing import Set import bpy -from bpy.types import Operator, Context, Object +from bpy.types import Operator from ..core.model import FnModel from ..core.sdef import FnSDEF -from ....core.logging_setup import logger -def _get_target_objects(context: Context) -> Tuple[Set[Object], Set[Object]]: - root_objects: Set[Object] = set() - selected_objects: Set[Object] = set() +def _get_target_objects(context): + root_objects: Set[bpy.types.Object] = set() + selected_objects: Set[bpy.types.Object] = set() for i in context.selected_objects: if i.type == "MESH": selected_objects.add(i) @@ -41,13 +36,11 @@ class ResetSDEFCache(Operator): bl_description = "Reset MMD SDEF cache of selected objects and clean unused cache" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: Context) -> Set[str]: + def execute(self, context): target_meshes, _ = _get_target_objects(context) - logger.info(f"Resetting SDEF cache for {len(target_meshes)} objects") for i in target_meshes: FnSDEF.clear_cache(i) FnSDEF.clear_cache(unused_only=True) - logger.debug("SDEF cache reset completed") return {"FINISHED"} @@ -78,20 +71,19 @@ class BindSDEF(Operator): default=False, ) - def invoke(self, context: Context, event: bpy.types.Event) -> Set[str]: + def invoke(self, context, event): vm = context.window_manager return vm.invoke_props_dialog(self) - def execute(self, context: Context) -> Set[str]: + # TODO: Utility Functionalize + def execute(self, context): target_meshes, root_objects = _get_target_objects(context) - logger.info(f"Binding SDEF for {len(target_meshes)} objects with mode={self.mode}, skip={self.use_skip}, scale={self.use_scale}") for r in root_objects: r.mmd_root.use_sdef = True param = ((None, False, True)[int(self.mode)], self.use_skip, self.use_scale) count = sum(FnSDEF.bind(i, *param) for i in target_meshes) - logger.info(f"Successfully bound SDEF for {count} of {len(target_meshes)} meshes") self.report({"INFO"}, f"Binded {count} of {len(target_meshes)} selected mesh(es)") return {"FINISHED"} @@ -102,15 +94,13 @@ class UnbindSDEF(Operator): bl_description = "Unbind MMD SDEF data of selected objects" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: Context) -> Set[str]: + # TODO: Utility Functionalize + def execute(self, context): target_meshes, root_objects = _get_target_objects(context) - logger.info(f"Unbinding SDEF for {len(target_meshes)} objects") - for i in target_meshes: FnSDEF.unbind(i) for r in root_objects: r.mmd_root.use_sdef = False - logger.debug("SDEF unbinding completed") return {"FINISHED"} diff --git a/core/mmd/operators/translations.py b/core/mmd/operators/translations.py index 371427c..c3012bd 100644 --- a/core/mmd/operators/translations.py +++ b/core/mmd/operators/translations.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2021 MMD Tools authors +# This file is part of MMD Tools. +import csv +import os from typing import TYPE_CHECKING, cast import bpy @@ -14,7 +12,11 @@ from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations from ..translations import DictionaryEnum if TYPE_CHECKING: - from ..properties.translations import MMDTranslation, MMDTranslationElement, MMDTranslationElementIndex + from ..properties.translations import ( + MMDTranslation, + MMDTranslationElement, + MMDTranslationElementIndex, + ) class TranslateMMDModel(bpy.types.Operator): @@ -77,7 +79,8 @@ class TranslateMMDModel(bpy.types.Operator): @classmethod def poll(cls, context): obj = context.active_object - return obj in context.selected_objects and FnModel.find_root_object(obj) + root = FnModel.find_root_object(obj) + return obj is not None and obj in context.selected_objects and root is not None def invoke(self, context, event): vm = context.window_manager @@ -87,7 +90,7 @@ class TranslateMMDModel(bpy.types.Operator): try: self.__translator = DictionaryEnum.get_translator(self.dictionary) except Exception as e: - self.report({"ERROR"}, "Failed to load dictionary: %s" % e) + self.report({"ERROR"}, f"Failed to load dictionary: {e}") return {"CANCELLED"} obj = context.active_object @@ -96,7 +99,7 @@ class TranslateMMDModel(bpy.types.Operator): if "MMD" in self.modes: for i in self.types: - getattr(self, "translate_%s" % i.lower())(rig) + getattr(self, f"translate_{i.lower()}")(rig) if "BLENDER" in self.modes: self.translate_blender_names(rig) @@ -104,7 +107,11 @@ class TranslateMMDModel(bpy.types.Operator): translator = self.__translator txt = translator.save_fails() if translator.fails: - self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(translator.fails), txt.name)) + self.report( + {"WARNING"}, + "Failed to translate %d names, see '%s' in text editor" + % (len(translator.fails), txt.name), + ) return {"FINISHED"} def translate(self, name_j, name_e): @@ -130,7 +137,7 @@ class TranslateMMDModel(bpy.types.Operator): if "DISPLAY" in self.types: g: bpy.types.BoneCollection - for g in cast(bpy.types.Armature, rig.armature().data).collections: + for g in cast("bpy.types.Armature", rig.armature().data).collections: g.name = self.translate(g.name, g.name) if "PHYSICS" in self.types: @@ -153,7 +160,9 @@ class TranslateMMDModel(bpy.types.Operator): comment_text = bpy.data.texts.get(mmd_root.comment_text, None) comment_e_text = bpy.data.texts.get(mmd_root.comment_e_text, None) if comment_text and comment_e_text: - comment_e = self.translate(comment_text.as_string(), comment_e_text.as_string()) + comment_e = self.translate( + comment_text.as_string(), comment_e_text.as_string(), + ) comment_e_text.from_string(comment_e) def translate_bone(self, rig): @@ -167,7 +176,7 @@ class TranslateMMDModel(bpy.types.Operator): mmd_root = rig.rootObject().mmd_root attr_list = ("group", "vertex", "bone", "uv", "material") prefix_list = ("G_", "", "B_", "UV_", "M_") - for attr, prefix in zip(attr_list, prefix_list): + for attr, prefix in zip(attr_list, prefix_list, strict=False): for m in getattr(mmd_root, attr + "_morphs", []): m.name_e = self.translate(m.name, m.name_e) if not prefix: @@ -182,7 +191,9 @@ class TranslateMMDModel(bpy.types.Operator): for m in rig.materials(): if m is None: continue - m.mmd_material.name_e = self.translate(m.mmd_material.name_j, m.mmd_material.name_e) + m.mmd_material.name_e = self.translate( + m.mmd_material.name_j, m.mmd_material.name_e, + ) def translate_display(self, rig): mmd_root = rig.rootObject().mmd_root @@ -200,10 +211,24 @@ class TranslateMMDModel(bpy.types.Operator): DEFAULT_SHOW_ROW_COUNT = 20 -class MMD_TOOLS_UL_MMDTranslationElementIndex(bpy.types.UIList): - def draw_item(self, context, layout: bpy.types.UILayout, data, mmd_translation_element_index: "MMDTranslationElementIndex", icon, active_data, active_propname, index: int): - mmd_translation_element: "MMDTranslationElement" = data.translation_elements[mmd_translation_element_index.value] - MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(layout, mmd_translation_element, index) +class MMD_TOOLS_LOCAL_UL_MMDTranslationElementIndex(bpy.types.UIList): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data, + mmd_translation_element_index: "MMDTranslationElementIndex", + icon, + active_data, + active_propname, + index: int, + ): + mmd_translation_element: MMDTranslationElement = data.translation_elements[ + mmd_translation_element_index.value + ] + MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item( + layout, mmd_translation_element, index, + ) class RestoreMMDDataReferenceOperator(bpy.types.Operator): @@ -216,9 +241,15 @@ class RestoreMMDDataReferenceOperator(bpy.types.Operator): restore_value: bpy.props.StringProperty() def execute(self, context: bpy.types.Context): - root_object = FnModel.find_root_object(context.object) - mmd_translation_element_index = root_object.mmd_root.translation.filtered_translation_element_indices[self.index].value - mmd_translation_element = root_object.mmd_root.translation.translation_elements[mmd_translation_element_index] + root_object = FnModel.find_root_object(context.active_object) + mmd_translation_element_index = ( + root_object.mmd_root.translation.filtered_translation_element_indices[ + self.index + ].value + ) + mmd_translation_element = root_object.mmd_root.translation.translation_elements[ + mmd_translation_element_index + ] setattr(mmd_translation_element, self.prop_name, self.restore_value) return {"FINISHED"} @@ -231,7 +262,8 @@ class GlobalTranslationPopup(bpy.types.Operator): @classmethod def poll(cls, context): - return FnModel.find_root_object(context.object) is not None + root = FnModel.find_root_object(context.active_object) + return root is not None def draw(self, _context): layout = self.layout @@ -244,13 +276,33 @@ class GlobalTranslationPopup(bpy.types.Operator): group = row.row(align=True, heading="is Blank:") group.alignment = "RIGHT" - group.prop(mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese") + group.prop( + mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese", + ) group.prop(mmd_translation, "filter_english_blank", toggle=True, text="English") group = row.row(align=True) - group.prop(mmd_translation, "filter_restorable", toggle=True, icon="FILE_REFRESH", icon_only=True) - group.prop(mmd_translation, "filter_selected", toggle=True, icon="RESTRICT_SELECT_OFF", icon_only=True) - group.prop(mmd_translation, "filter_visible", toggle=True, icon="HIDE_OFF", icon_only=True) + group.prop( + mmd_translation, + "filter_restorable", + toggle=True, + icon="FILE_REFRESH", + icon_only=True, + ) + group.prop( + mmd_translation, + "filter_selected", + toggle=True, + icon="RESTRICT_SELECT_OFF", + icon_only=True, + ) + group.prop( + mmd_translation, + "filter_visible", + toggle=True, + icon="HIDE_OFF", + icon_only=True, + ) col = layout.column(align=True) box = col.box().column(align=True) @@ -262,11 +314,14 @@ class GlobalTranslationPopup(bpy.types.Operator): row.label(text="", icon="RESTRICT_SELECT_OFF") row.label(text="", icon="HIDE_OFF") - if len(mmd_translation.filtered_translation_element_indices) > DEFAULT_SHOW_ROW_COUNT: + if ( + len(mmd_translation.filtered_translation_element_indices) + > DEFAULT_SHOW_ROW_COUNT + ): row.label(text="", icon="BLANK1") col.template_list( - "MMD_TOOLS_UL_MMDTranslationElementIndex", + "mmd_tools_UL_MMDTranslationElementIndex", "", mmd_translation, "filtered_translation_element_indices", @@ -281,7 +336,12 @@ class GlobalTranslationPopup(bpy.types.Operator): box.separator() row = box.row() - row.prop(mmd_translation, "batch_operation_script_preset", text="Preset", icon="CON_TRANSFORM_CACHE") + row.prop( + mmd_translation, + "batch_operation_script_preset", + text="Preset", + icon="CON_TRANSFORM_CACHE", + ) row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute") box.separator() @@ -289,18 +349,25 @@ class GlobalTranslationPopup(bpy.types.Operator): translation_box.label(text="Dictionaries:", icon="HELP") row = translation_box.row() row.prop(mmd_translation, "dictionary", text="to_english") - # row.operator(ExecuteTranslationScriptOperator.bl_idname, text='Write to .csv') translation_box.separator() row = translation_box.row() row.prop(mmd_translation, "dictionary", text="replace") + # CSV import/export + box.separator() + translation_box = box.box().column(align=True) + translation_box.label(text="CSV:", icon="FILE_TEXT") + row = translation_box.row() + row.operator(ImportTranslationCSVOperator.bl_idname, text="Import CSV") + row.operator(ExportTranslationCSVOperator.bl_idname, text="Export CSV") + def invoke(self, context: bpy.types.Context, _event): - root_object = FnModel.find_root_object(context.object) + root_object = FnModel.find_root_object(context.active_object) if root_object is None: return {"CANCELLED"} - mmd_translation: "MMDTranslation" = root_object.mmd_root.translation + mmd_translation: MMDTranslation = root_object.mmd_root.translation self._mmd_translation = mmd_translation FnTranslations.clear_data(mmd_translation) FnTranslations.collect_data(mmd_translation) @@ -309,7 +376,7 @@ class GlobalTranslationPopup(bpy.types.Operator): return context.window_manager.invoke_props_dialog(self, width=800) def execute(self, context): - root_object = FnModel.find_root_object(context.object) + root_object = FnModel.find_root_object(context.active_object) if root_object is None: return {"CANCELLED"} @@ -325,12 +392,175 @@ class ExecuteTranslationBatchOperator(bpy.types.Operator): bl_options = {"INTERNAL"} def execute(self, context: bpy.types.Context): - root = FnModel.find_root_object(context.object) + root = FnModel.find_root_object(context.active_object) if root is None: return {"CANCELLED"} fails, text = FnTranslations.execute_translation_batch(root) if fails: - self.report({"WARNING"}, "Failed to translate %d names, see '%s' in text editor" % (len(fails), text.name)) + self.report( + {"WARNING"}, + "Failed to translate %d names, see '%s' in text editor" + % (len(fails), text.name), + ) return {"FINISHED"} + + +class ExportTranslationCSVOperator(bpy.types.Operator): + bl_idname = "mmd_tools.export_translation_csv" + bl_description = "Export CSV for external translation." + bl_label = "Export Translation CSV" + + filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) + filename_ext = ".csv" + filepath: bpy.props.StringProperty( + name="File Path", + description="Path to save the translation CSV", + subtype="FILE_PATH", + default="mmd_translation.csv", + ) + + def _ensure_csv_extension(self): + """Ensure the file path ends with a .csv extension (case-insensitive).""" + if not self.filepath.lower().endswith(".csv"): + self.filepath = bpy.path.ensure_ext(self.filepath, ".csv") + + def invoke(self, context, event): + self._ensure_csv_extension() + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context): + self._ensure_csv_extension() + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + self.report({"ERROR"}, "Root object not found") + return {"CANCELLED"} + + mmd_translation = root_object.mmd_root.translation + + try: + with open(self.filepath, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["type", "blender", "japanese", "english"]) + for idx in mmd_translation.filtered_translation_element_indices: + element = mmd_translation.translation_elements[idx.value] + writer.writerow( + [element.type, element.name, element.name_j, element.name_e], + ) + except Exception as e: + self.report({"ERROR"}, f"Failed to write CSV: {e}") + return {"CANCELLED"} + + self.report({"INFO"}, f"Exported to {os.path.basename(self.filepath)}") + return {"FINISHED"} + + +class ImportTranslationCSVOperator(bpy.types.Operator): + bl_idname = "mmd_tools.import_translation_csv" + bl_description = "Import translated CSV." + bl_label = "Import Translation CSV" + + only_update_english_name: bpy.props.BoolProperty( + name="Only Update English Name", + description="(Enabled by default) Only update English name (name_e). otherwise, update all names when different", + default=True, + ) + + filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) + filepath: bpy.props.StringProperty( + name="File Path", + description="Path to import the translation CSV", + subtype="FILE_PATH", + default="*.csv", + ) + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context): + root_object = FnModel.find_root_object(context.active_object) + if root_object is None: + self.report({"ERROR"}, "Root object not found") + return {"CANCELLED"} + + mmd_translation = root_object.mmd_root.translation + updated_count = 0 + warnings = [] + + try: + with open(self.filepath, encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + required_headers = {"blender", "japanese", "english"} + if not required_headers.issubset(set(reader.fieldnames or [])): + missing = required_headers - set(reader.fieldnames or []) + self.report( + {"ERROR"}, + f"Missing required headers in CSV: {', '.join(missing)}", + ) + return {"CANCELLED"} + + visible_indices = [ + i.value + for i in mmd_translation.filtered_translation_element_indices + ] + translation_elements_list = list(mmd_translation.translation_elements) + row_count = 0 + + for row in reader: + if row_count >= len(visible_indices): + row_count += 1 + continue + + element = translation_elements_list[visible_indices[row_count]] + + b_name = row.get("blender", "").strip() + j_name = row.get("japanese", "").strip() + e_name = row.get("english", "").strip() + + updated = False + if self.only_update_english_name: + if element.name_e != e_name: + element.name_e = e_name + updated = True + else: + if element.name != b_name: + element.name = b_name + updated = True + if element.name_j != j_name: + element.name_j = j_name + updated = True + if element.name_e != e_name: + element.name_e = e_name + updated = True + + if updated: + updated_count += 1 + + row_count += 1 + + # Output warnings + if row_count > len(visible_indices): + warnings.append( + f"{row_count - len(visible_indices)} extra lines in CSV! (ignored)", + ) + elif row_count < len(visible_indices): + warnings.append( + f"{len(visible_indices) - row_count} missing lines in CSV! (aborted translation)", + ) + except Exception as e: + self.report({"ERROR"}, f"Failed to read CSV: {e}") + return {"CANCELLED"} + + FnTranslations.update_query(mmd_translation) + + msg = f"Imported {updated_count} entries from CSV" + if warnings: + for w in warnings: + self.report({"WARNING"}, w) + msg += " with warnings" + + self.report({"INFO"}, msg) + return {"FINISHED"} diff --git a/core/mmd/operators/view.py b/core/mmd/operators/view.py index d9779c3..7e6e2eb 100644 --- a/core/mmd/operators/view.py +++ b/core/mmd/operators/view.py @@ -1,47 +1,43 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. import re -from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type, Iterator -from bpy.types import Operator, Context -from mathutils import Matrix, Vector, Quaternion - -from ...logging_setup import logger +from bpy.types import Operator +from mathutils import Matrix, Quaternion class _SetShadingBase: - bl_options: Set[str] = {"REGISTER", "UNDO"} + bl_options = {"REGISTER", "UNDO"} @staticmethod - def _get_view3d_spaces(context: Context) -> Iterator[Any]: + def _get_view3d_spaces(context): if getattr(context.area, "type", None) == "VIEW_3D": return (context.area.spaces[0],) return (area.spaces[0] for area in getattr(context.screen, "areas", ()) if area.type == "VIEW_3D") @staticmethod - def _reset_color_management(context: Context, use_display_device: bool = True) -> None: + def _reset_color_management(context, use_display_device=True): try: context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device] except TypeError: pass @staticmethod - def _reset_material_shading(context: Context, use_shadeless: bool = False) -> None: - # Note: material.use_nodes and material.use_shadeless are deprecated in Blender 5.0 - # Materials always use nodes now, and shadeless is handled differently - # This method is kept for compatibility but no longer modifies materials - pass + def _reset_material_shading(context, use_shadeless=False): + for i in (x for x in context.scene.objects if x.type == "MESH" and x.mmd_type == "NONE"): + for s in i.material_slots: + if s.material is None: + continue + # use_nodes is deprecated in 5.0 but harmless to set + s.material.use_nodes = False + s.material.use_shadeless = use_shadeless - def execute(self, context: Context) -> Dict[str, str]: + def execute(self, context): + # Changed from BLENDER_EEVEE_NEXT to BLENDER_EEVEE for Blender 5.0 context.scene.render.engine = "BLENDER_EEVEE" - logger.debug(f"Setting render engine to BLENDER_EEVEE") - shading_mode: Optional[str] = getattr(self, "_shading_mode", None) + shading_mode = getattr(self, "_shading_mode", None) for space in self._get_view3d_spaces(context): shading = space.shading shading.type = "SOLID" @@ -49,40 +45,39 @@ class _SetShadingBase: shading.color_type = "TEXTURE" if shading_mode else "MATERIAL" shading.show_object_outline = False shading.show_backface_culling = False - logger.debug(f"Applied shading mode: {shading_mode or 'DEFAULT'}") return {"FINISHED"} class SetGLSLShading(Operator, _SetShadingBase): - bl_idname: str = "mmd_tools.set_glsl_shading" - bl_label: str = "GLSL View" - bl_description: str = "Use GLSL shading with additional lighting" + bl_idname = "mmd_tools.set_glsl_shading" + bl_label = "GLSL View" + bl_description = "Use GLSL shading with additional lighting" - _shading_mode: str = "GLSL" + _shading_mode = "GLSL" class SetShadelessGLSLShading(Operator, _SetShadingBase): - bl_idname: str = "mmd_tools.set_shadeless_glsl_shading" - bl_label: str = "Shadeless GLSL View" - bl_description: str = "Use only toon shading" + bl_idname = "mmd_tools.set_shadeless_glsl_shading" + bl_label = "Shadeless GLSL View" + bl_description = "Use only toon shading" - _shading_mode: str = "SHADELESS" + _shading_mode = "SHADELESS" class ResetShading(Operator, _SetShadingBase): - bl_idname: str = "mmd_tools.reset_shading" - bl_label: str = "Reset View" - bl_description: str = "Reset to default Blender shading" + bl_idname = "mmd_tools.reset_shading" + bl_label = "Reset View" + bl_description = "Reset to default Blender shading" class FlipPose(Operator): - bl_idname: str = "mmd_tools.flip_pose" - bl_label: str = "Flip Pose" - bl_description: str = "Apply the current pose of selected bones to matching bone on opposite side of X-Axis." - bl_options: Set[str] = {"REGISTER", "UNDO"} + bl_idname = "mmd_tools.flip_pose" + bl_label = "Flip Pose" + bl_description = "Apply the current pose of selected bones to matching bone on opposite side of X-Axis." + bl_options = {"REGISTER", "UNDO"} # https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html - __LR_REGEX: List[Dict[str, Any]] = [ + __LR_REGEX = [ {"re": re.compile(r"^(.+)(RIGHT|LEFT)(\.\d+)?$", re.IGNORECASE), "lr": 1}, {"re": re.compile(r"^(.+)([\.\- _])(L|R)(\.\d+)?$", re.IGNORECASE), "lr": 2}, {"re": re.compile(r"^(LEFT|RIGHT)(.+)$", re.IGNORECASE), "lr": 0}, @@ -90,7 +85,7 @@ class FlipPose(Operator): {"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1}, {"re": re.compile(r"^(左|右)(.+)$"), "lr": 0}, ] - __LR_MAP: Dict[str, str] = { + __LR_MAP = { "RIGHT": "LEFT", "Right": "Left", "right": "left", @@ -106,7 +101,7 @@ class FlipPose(Operator): } @classmethod - def flip_name(cls, name: str) -> str: + def flip_name(cls, name): for regex in cls.__LR_REGEX: match = regex["re"].match(name) if match: @@ -124,15 +119,15 @@ class FlipPose(Operator): return "" @staticmethod - def __cmul(vec1: Union[Vector, Quaternion], vec2: Tuple[float, float, float, float]) -> Union[Vector, Quaternion]: - return type(vec1)([x * y for x, y in zip(vec1, vec2)]) + def __cmul(vec1, vec2): + return type(vec1)([x * y for x, y in zip(vec1, vec2, strict=False)]) @staticmethod - def __matrix_compose(loc: Vector, rot: Quaternion, scale: Vector) -> Matrix: + def __matrix_compose(loc, rot, scale): return (Matrix.Translation(loc) @ rot.to_matrix().to_4x4()) @ Matrix([(scale[0], 0, 0, 0), (0, scale[1], 0, 0), (0, 0, scale[2], 0), (0, 0, 0, 1)]) @classmethod - def __flip_pose(cls, matrix_basis: Matrix, bone_src: Any, bone_dest: Any) -> None: + def __flip_pose(cls, matrix_basis, bone_src, bone_dest): m = bone_dest.bone.matrix_local.to_3x3().transposed() mi = bone_src.bone.matrix_local.to_3x3().transposed().inverted() if bone_src != bone_dest else m.inverted() loc, rot, scale = matrix_basis.decompose() @@ -141,16 +136,12 @@ class FlipPose(Operator): bone_dest.matrix_basis = cls.__matrix_compose(m @ loc, Quaternion(m @ rot.axis, rot.angle).normalized(), scale) @classmethod - def poll(cls, context: Context) -> bool: - return context.active_object and context.active_object.type == "ARMATURE" and context.active_object.mode == "POSE" + def poll(cls, context): + obj = context.active_object + return obj is not None and obj.type == "ARMATURE" and obj.mode == "POSE" - def execute(self, context: Context) -> Dict[str, str]: - logger.info("Executing flip pose operation") + def execute(self, context): pose_bones = context.active_object.pose.bones for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]: - flip_name = self.flip_name(b.name) - target_bone = pose_bones.get(flip_name, b) - logger.debug(f"Flipping pose from {b.name} to {target_bone.name}") - self.__flip_pose(mat, b, target_bone) - logger.info("Flip pose operation completed") + self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b)) return {"FINISHED"} diff --git a/core/mmd/properties/material.py b/core/mmd/properties/material.py index b597c5d..93009e1 100644 --- a/core/mmd/properties/material.py +++ b/core/mmd/properties/material.py @@ -1,90 +1,82 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. import bpy -from typing import Optional, Set, Dict, Any, List, Tuple, Union, Type from .. import utils from ..core import material from ..core.material import FnMaterial from ..core.model import FnModel from . import patch_library_overridable -from ....core.logging_setup import logger -def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_ambient_color() -def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_diffuse_color() -def _mmd_material_update_alpha(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_alpha(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_alpha() -def _mmd_material_update_specular_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_specular_color(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_specular_color() -def _mmd_material_update_shininess(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_shininess(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_shininess() -def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_is_double_sided() -def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context: bpy.types.Context) -> None: +def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context): FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object) -def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_toon_texture() -def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_drop_shadow() -def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_self_shadow_map() -def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_self_shadow() -def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_enabled_toon_edge() -def _mmd_material_update_edge_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_edge_color(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_edge_color() -def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context: bpy.types.Context) -> None: +def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context): FnMaterial(prop.id_data).update_edge_weight() -def _mmd_material_get_name_j(prop: "MMDMaterial") -> str: +def _mmd_material_get_name_j(prop: "MMDMaterial"): return prop.get("name_j", "") -def _mmd_material_set_name_j(prop: "MMDMaterial", value: str) -> None: +def _mmd_material_set_name_j(prop: "MMDMaterial", value: str): prop_value = value if prop_value and prop_value != prop.get("name_j"): root = FnModel.find_root_object(bpy.context.active_object) if root is None: - logger.debug(f"No root object found, using unique name for material: {value}") prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in bpy.data.materials}) else: - logger.debug(f"Root object found, using unique name for material within model: {value}") prop_value = utils.unique_name(value, {mat.mmd_material.name_j for mat in FnModel.iterate_materials(root)}) prop["name_j"] = prop_value @@ -279,15 +271,13 @@ class MMDMaterial(bpy.types.PropertyGroup): description="Comment", ) - def is_id_unique(self) -> bool: + def is_id_unique(self): return self.material_id < 0 or not next((m for m in bpy.data.materials if m.mmd_material != self and m.mmd_material.material_id == self.material_id), None) @staticmethod - def register() -> None: - logger.debug("Registering MMD material properties") + def register(): bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial)) @staticmethod - def unregister() -> None: - logger.debug("Unregistering MMD material properties") + def unregister(): del bpy.types.Material.mmd_material diff --git a/core/mmd/properties/morph.py b/core/mmd/properties/morph.py index e2be89b..cbbd443 100644 --- a/core/mmd/properties/morph.py +++ b/core/mmd/properties/morph.py @@ -1,38 +1,34 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2015 MMD Tools authors +# This file is part of MMD Tools. import bpy -from typing import Optional, List, Dict, Any, Set, Tuple, Union, TypeVar, Type -from bpy.types import PropertyGroup, Object, ShapeKey from .. import utils from ..core.bone import FnBone from ..core.material import FnMaterial from ..core.model import FnModel, Model from ..core.morph import FnMorph -from ....core.logging_setup import logger def _morph_base_get_name(prop: "_MorphBase") -> str: return prop.get("name", "") -def _morph_base_set_name(prop: "_MorphBase", value: str) -> None: +def _morph_base_set_name(prop: "_MorphBase", value: str): mmd_root = prop.id_data.mmd_root - morph_type = "%s_morphs" % prop.bl_rna.identifier[:-5].lower() + # morph_type = mmd_root.active_morph_type + morph_type = f"{prop.bl_rna.identifier[:-5].lower()}_morphs" + # assert(prop.bl_rna.identifier.endswith('Morph')) + # logging.debug('_set_name: %s %s %s', prop, value, morph_type) prop_name = prop.get("name", None) if prop_name == value: return - used_names: Set[str] = {x.name for x in getattr(mmd_root, morph_type) if x != prop} + used_names = {x.name for x in getattr(mmd_root, morph_type) if x != prop} value = utils.unique_name(value, used_names) if prop_name is not None: if morph_type == "vertex_morphs": - kb_list: Dict[str, List[ShapeKey]] = {} + kb_list = {} for mesh in FnModel.iterate_mesh_objects(prop.id_data): for kb in getattr(mesh.data.shape_keys, "key_blocks", ()): kb_list.setdefault(kb.name, []).append(kb) @@ -43,7 +39,7 @@ def _morph_base_set_name(prop: "_MorphBase", value: str) -> None: kb.name = value elif morph_type == "uv_morphs": - vg_list: Dict[str, List[Any]] = {} + vg_list = {} for mesh in FnModel.iterate_mesh_objects(prop.id_data): for vg, n, x in FnMorph.get_uv_morph_vertex_groups(mesh): vg_list.setdefault(n, []).append(vg) @@ -72,7 +68,6 @@ def _morph_base_set_name(prop: "_MorphBase", value: str) -> None: kb.name = value prop["name"] = value - logger.debug(f"Renamed morph from '{prop_name}' to '{value}'") class _MorphBase: @@ -101,12 +96,16 @@ class _MorphBase: ) +def _bone_morph_data_update_bone_id(prop: "BoneMorphData", context: bpy.types.Context): + pass # Empty function is sufficient to trigger UI update + + def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: - bone_id: int = prop.get("bone_id", -1) + bone_id = prop.get("bone_id", -1) if bone_id < 0: return "" - root_object: Object = prop.id_data - armature_object: Optional[Object] = FnModel.find_armature_object(root_object) + root_object = prop.id_data + armature_object = FnModel.find_armature_object(root_object) if armature_object is None: return "" pose_bone = FnBone.find_pose_bone_by_bone_id(armature_object, bone_id) @@ -115,9 +114,9 @@ def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: return pose_bone.name -def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str) -> None: - root: Object = prop.id_data - arm: Optional[Object] = FnModel.find_armature_object(root) +def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str): + root = prop.id_data + arm = FnModel.find_armature_object(root) # Load the library_override file. This function is triggered when loading, but the arm obj cannot be found. # The arm obj is exist, but the relative relationship has not yet been established. @@ -125,14 +124,13 @@ def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str) -> None: return if value not in arm.pose.bones.keys(): - prop["bone_id"] = -1 + prop.bone_id = -1 return pose_bone = arm.pose.bones[value] - prop["bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) - logger.debug(f"Set bone morph data bone to '{value}' with ID {prop['bone_id']}") + prop.bone_id = FnBone.get_or_assign_bone_id(pose_bone) -def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context: bpy.types.Context) -> None: +def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context): if not prop.name.startswith("mmd_bind"): return arm = FnModel(prop.id_data).morph_slider.dummy_armature @@ -141,12 +139,9 @@ def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context if bone: bone.location = prop.location bone.rotation_quaternion = prop.rotation.__class__(*prop.rotation.to_axis_angle()) # Fix for consistency - logger.debug(f"Updated bone morph data location/rotation for '{prop.name}'") class BoneMorphData(bpy.types.PropertyGroup): - """ """ - bone: bpy.props.StringProperty( name="Bone", description="Target bone", @@ -156,6 +151,7 @@ class BoneMorphData(bpy.types.PropertyGroup): bone_id: bpy.props.IntProperty( name="Bone ID", + update=_bone_morph_data_update_bone_id, ) location: bpy.props.FloatVectorProperty( @@ -191,61 +187,53 @@ class BoneMorph(_MorphBase, bpy.types.PropertyGroup): ) -def _material_morph_data_get_material(prop: "MaterialMorphData") -> str: - mat_p = prop.get("material_data", None) - if mat_p is not None: - return mat_p.name +def _material_morph_data_get_material(prop: "MaterialMorphData"): + mat_data = prop.get("material_data", None) + if mat_data is not None: + return mat_data.name return "" -def _material_morph_data_set_material(prop: "MaterialMorphData", value: str) -> None: +def _material_morph_data_set_material(prop: "MaterialMorphData", value: str): if value not in bpy.data.materials: - prop["material_data"] = None - prop["material_id"] = -1 - logger.debug(f"Material '{value}' not found, setting material_data to None") + prop.material_data = None + prop.material_id = -1 else: mat = bpy.data.materials[value] fnMat = FnMaterial(mat) - prop["material_data"] = mat - prop["material_id"] = fnMat.material_id - logger.debug(f"Set material morph data material to '{value}' with ID {fnMat.material_id}") + prop.material_data = mat + prop.material_id = fnMat.material_id -def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str) -> None: +def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str): mesh = FnModel.find_mesh_object_by_name(prop.id_data, value) if mesh is not None: - prop["related_mesh_data"] = mesh.data - logger.debug(f"Set material morph data related mesh to '{value}'") + prop.related_mesh_data = mesh.data else: - prop["related_mesh_data"] = None - logger.debug(f"Mesh '{value}' not found, setting related_mesh_data to None") + prop.related_mesh_data = None -def _material_morph_data_get_related_mesh(prop: "MaterialMorphData") -> str: - mesh_p = prop.get("related_mesh_data", None) - if mesh_p is not None: - return mesh_p.name +def _material_morph_data_get_related_mesh(prop): + mesh_data = prop.get("related_mesh_data", None) + if mesh_data is not None: + return mesh_data.name return "" -def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context: bpy.types.Context) -> None: +def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context): if not prop.name.startswith("mmd_bind"): return from ..core.shader import _MaterialMorph - mat = prop["material_data"] - if mat is not None: - _MaterialMorph.update_morph_inputs(mat, prop) - logger.debug(f"Updated material morph modifiable values for '{prop.name}'") + mat_data = prop.get("material_data", None) + if mat_data is not None: + _MaterialMorph.update_morph_inputs(mat_data, prop) else: - for mat in FnModel(prop.id_data).materials(): - _MaterialMorph.update_morph_inputs(mat, prop) - logger.debug(f"Updated material morph modifiable values for all materials") + for mat_data in FnModel(prop.id_data).materials(): + _MaterialMorph.update_morph_inputs(mat_data, prop) class MaterialMorphData(bpy.types.PropertyGroup): - """ """ - related_mesh: bpy.props.StringProperty( name="Related Mesh", description="Stores a reference to the mesh where this morph data belongs to", @@ -416,6 +404,9 @@ class UVMorphOffset(bpy.types.PropertyGroup): name="UV Offset", description="UV offset", size=4, + # min=-1, + # max=1, + # precision=3, step=0.1, default=[0, 0, 0, 0], ) diff --git a/core/mmd/properties/pose_bone.py b/core/mmd/properties/pose_bone.py index 84a71ef..498ed7b 100644 --- a/core/mmd/properties/pose_bone.py +++ b/core/mmd/properties/pose_bone.py @@ -1,37 +1,31 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. + +from typing import cast -from typing import cast, Optional, Any, Union import bpy -from bpy.types import Context, PropertyGroup, PoseBone, Object, Armature from ..core.bone import FnBone from . import patch_library_overridable -from ....core.logging_setup import logger -def _mmd_bone_update_additional_transform(prop: "MMDBone", context: Context) -> None: - prop["is_additional_transform_dirty"] = True +def _mmd_bone_update_additional_transform(prop: "MMDBone", context: bpy.types.Context): + prop.is_additional_transform_dirty = True + # Apply additional transform (Assembly -> Bone button) (Very Slow) p_bone = context.active_pose_bone if p_bone and p_bone.mmd_bone.as_pointer() == prop.as_pointer(): - logger.debug(f"Applying additional transformation for {p_bone.name}") FnBone.apply_additional_transformation(prop.id_data) -def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: Context) -> None: +def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: bpy.types.Context): pose_bone = context.active_pose_bone if pose_bone and pose_bone.mmd_bone.as_pointer() == prop.as_pointer(): - logger.debug(f"Updating additional transform influence for {pose_bone.name}") FnBone.update_additional_transform_influence(pose_bone) else: - prop["is_additional_transform_dirty"] = True + prop.is_additional_transform_dirty = True -def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str: +def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"): arm = prop.id_data bone_id = prop.get("additional_transform_bone_id", -1) if bone_id < 0: @@ -42,17 +36,57 @@ def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str: return pose_bone.name -def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str) -> None: +def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str): arm = prop.id_data - prop["is_additional_transform_dirty"] = True + prop.is_additional_transform_dirty = True + if value not in arm.pose.bones.keys(): - prop["additional_transform_bone_id"] = -1 + prop.additional_transform_bone_id = -1 return + pose_bone = arm.pose.bones[value] - prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) + target_bone_id = FnBone.get_or_assign_bone_id(pose_bone) + + if prop.bone_id == target_bone_id: + prop.additional_transform_bone_id = -1 + return + + prop.additional_transform_bone_id = target_bone_id -class MMDBone(PropertyGroup): +def _mmd_bone_update_display_connection(prop: "MMDBone", context: bpy.types.Context): + pass # Empty function is sufficient to trigger UI update + + +def _mmd_bone_get_display_connection_bone(prop: "MMDBone"): + arm = prop.id_data + bone_id = prop.get("display_connection_bone_id", -1) + if bone_id < 0: + return "" + pose_bone = FnBone.find_pose_bone_by_bone_id(arm, bone_id) + if pose_bone is None: + return "" + return pose_bone.name + + +def _mmd_bone_set_display_connection_bone(prop: "MMDBone", value: str): + arm = prop.id_data + + if value not in arm.pose.bones.keys(): + prop.display_connection_bone_id = -1 + return + + pose_bone = arm.pose.bones[value] + target_bone_id = FnBone.get_or_assign_bone_id(pose_bone) + + if prop.bone_id == target_bone_id: + prop.display_connection_bone_id = -1 + return + + prop.display_connection_bone_id = target_bone_id + + +class MMDBone(bpy.types.PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -188,12 +222,35 @@ class MMDBone(PropertyGroup): is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True) - def is_id_unique(self) -> bool: + display_connection_bone: bpy.props.StringProperty( + name="Display Connection Bone", + description="Target bone for display connection", + set=_mmd_bone_set_display_connection_bone, + get=_mmd_bone_get_display_connection_bone, + ) + + display_connection_bone_id: bpy.props.IntProperty( + name="Display Connection Bone ID", + description="Bone ID for display connection (PMX displayConnection)", + default=-1, + update=_mmd_bone_update_display_connection, + ) + + display_connection_type: bpy.props.EnumProperty( + name="Display Connection Type", + description="Type of display connection", + items=[ + ("BONE", "Bone", "Connected to a bone"), + ("OFFSET", "Offset", "Connected to an offset position"), + ], + default="OFFSET", + ) + + def is_id_unique(self): return self.bone_id < 0 or not next((b for b in self.id_data.pose.bones if b.mmd_bone != self and b.mmd_bone.bone_id == self.bone_id), None) @staticmethod - def register() -> None: - logger.debug("Registering MMDBone properties") + def register(): bpy.types.PoseBone.mmd_bone = patch_library_overridable(bpy.props.PointerProperty(type=MMDBone)) bpy.types.PoseBone.is_mmd_shadow_bone = patch_library_overridable(bpy.props.BoolProperty(name="is_mmd_shadow_bone", default=False)) bpy.types.PoseBone.mmd_shadow_bone_type = patch_library_overridable(bpy.props.StringProperty(name="mmd_shadow_bone_type")) @@ -203,25 +260,24 @@ class MMDBone(PropertyGroup): description="MMD IK toggle is used to import/export animation of IK on-off", update=_pose_bone_update_mmd_ik_toggle, default=True, - ) + ), ) @staticmethod - def unregister() -> None: - logger.debug("Unregistering MMDBone properties") + def unregister(): del bpy.types.PoseBone.mmd_ik_toggle del bpy.types.PoseBone.mmd_shadow_bone_type del bpy.types.PoseBone.is_mmd_shadow_bone del bpy.types.PoseBone.mmd_bone -def _pose_bone_update_mmd_ik_toggle(prop: PoseBone, _context: Any) -> None: +def _pose_bone_update_mmd_ik_toggle(prop: bpy.types.PoseBone, _context): v = prop.mmd_ik_toggle - armature_object = cast(Object, prop.id_data) + armature_object = cast("bpy.types.Object", prop.id_data) for b in armature_object.pose.bones: for c in b.constraints: if c.type == "IK" and c.subtarget == prop.name: - logger.debug(f"Updating IK toggle for {b.name} {c.name}") + # logging.debug(' %s %s', b.name, c.name) c.influence = v b = b if c.use_tail else b.parent for b in ([b] + b.parent_recursive)[: c.chain_count]: diff --git a/core/mmd/properties/rigid_body.py b/core/mmd/properties/rigid_body.py index 87ef14d..e417ec9 100644 --- a/core/mmd/properties/rigid_body.py +++ b/core/mmd/properties/rigid_body.py @@ -1,42 +1,35 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. """Properties for rigid bodies and joints""" import bpy -from typing import Optional, Any, Set, List, Dict, Tuple, Union -from bpy.types import Context, Object, PropertyGroup, Material from .. import bpyutils from ..core import rigid_body -from ..core.rigid_body import RigidBodyMaterial, FnRigidBody from ..core.model import FnModel +from ..core.rigid_body import FnRigidBody, RigidBodyMaterial from . import patch_library_overridable -from ....core.logging_setup import logger -def _updateCollisionGroup(prop: PropertyGroup, _context: Context) -> None: - obj: Object = prop.id_data - materials: List[Material] = obj.data.materials +def _updateCollisionGroup(prop, _context): + obj = prop.id_data + materials = obj.data.materials if len(materials) == 0: materials.append(RigidBodyMaterial.getMaterial(prop.collision_group_number)) else: obj.material_slots[0].material = RigidBodyMaterial.getMaterial(prop.collision_group_number) -def _updateType(prop: PropertyGroup, _context: Context) -> None: - obj: Object = prop.id_data +def _updateType(prop, _context): + obj = prop.id_data rb = obj.rigid_body if rb: rb.kinematic = int(prop.type) == rigid_body.MODE_STATIC -def _updateShape(prop: PropertyGroup, _context: Context) -> None: - obj: Object = prop.id_data +def _updateShape(prop, _context): + obj = prop.id_data if len(obj.data.vertices) > 0: size = prop.size @@ -47,8 +40,8 @@ def _updateShape(prop: PropertyGroup, _context: Context) -> None: rb.collision_shape = prop.shape -def _get_bone(prop: PropertyGroup) -> str: - obj: Object = prop.id_data +def _get_bone(prop): + obj = prop.id_data relation = obj.constraints.get("mmd_tools_rigid_parent", None) if relation: arm = relation.target @@ -58,9 +51,9 @@ def _get_bone(prop: PropertyGroup) -> str: return prop.get("bone", "") -def _set_bone(prop: PropertyGroup, value: str) -> None: - bone_name: str = value - obj: Object = prop.id_data +def _set_bone(prop, value): + bone_name = value + obj = prop.id_data relation = obj.constraints.get("mmd_tools_rigid_parent", None) if relation is None: relation = obj.constraints.new("CHILD_OF") @@ -81,21 +74,24 @@ def _set_bone(prop: PropertyGroup, value: str) -> None: prop["bone"] = bone_name -def _get_size(prop: PropertyGroup) -> Tuple[float, float, float]: +def _get_size(prop): if prop.id_data.mmd_type != "RIGID_BODY": return (0, 0, 0) return FnRigidBody.get_rigid_body_size(prop.id_data) -def _set_size(prop: PropertyGroup, value: Tuple[float, float, float]) -> None: - obj: Object = prop.id_data +def _set_size(prop, value): + obj = prop.id_data assert obj.mode == "OBJECT" # not support other mode yet - shape: str = prop.shape + shape = prop.shape mesh = obj.data rb = obj.rigid_body - if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape: + current_size = FnRigidBody.get_rigid_body_size(obj) + is_zero_size = all(abs(s) < 1e-6 for s in current_size) + + if len(mesh.vertices) == 0 or rb is None or rb.collision_shape != shape or is_zero_size: if shape == "SPHERE": bpyutils.makeSphere( radius=value[0], @@ -149,15 +145,15 @@ def _set_size(prop: PropertyGroup, value: Tuple[float, float, float]) -> None: mesh.update() -def _get_rigid_name(prop: PropertyGroup) -> str: +def _get_rigid_name(prop): return prop.get("name", "") -def _set_rigid_name(prop: PropertyGroup, value: str) -> None: +def _set_rigid_name(prop, value): prop["name"] = value -class MMDRigidBody(PropertyGroup): +class MMDRigidBody(bpy.types.PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -230,18 +226,16 @@ class MMDRigidBody(PropertyGroup): ) @staticmethod - def register() -> None: - logger.debug("Registering MMDRigidBody property") + def register(): bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody)) @staticmethod - def unregister() -> None: - logger.debug("Unregistering MMDRigidBody property") + def unregister(): del bpy.types.Object.mmd_rigid -def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None: - obj: Object = prop.id_data +def _updateSpringLinear(prop, context): + obj = prop.id_data rbc = obj.rigid_body_constraint if rbc: rbc.spring_stiffness_x = prop.spring_linear[0] @@ -249,8 +243,8 @@ def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None: rbc.spring_stiffness_z = prop.spring_linear[2] -def _updateSpringAngular(prop: PropertyGroup, context: Context) -> None: - obj: Object = prop.id_data +def _updateSpringAngular(prop, context): + obj = prop.id_data rbc = obj.rigid_body_constraint if rbc and hasattr(rbc, "use_spring_ang_x"): rbc.spring_stiffness_ang_x = prop.spring_angular[0] @@ -258,7 +252,7 @@ def _updateSpringAngular(prop: PropertyGroup, context: Context) -> None: rbc.spring_stiffness_ang_z = prop.spring_angular[2] -class MMDJoint(PropertyGroup): +class MMDJoint(bpy.types.PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -292,12 +286,9 @@ class MMDJoint(PropertyGroup): ) @staticmethod - def register() -> None: - logger.debug("Registering MMDJoint property") + def register(): bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint)) @staticmethod - def unregister() -> None: - logger.debug("Unregistering MMDJoint property") + def unregister(): del bpy.types.Object.mmd_joint - diff --git a/core/mmd/properties/root.py b/core/mmd/properties/root.py index 679a9ff..7a89fa4 100644 --- a/core/mmd/properties/root.py +++ b/core/mmd/properties/root.py @@ -1,16 +1,10 @@ -# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# This file is part of MMD Tools. """Properties for MMD model root object""" import bpy -from typing import Optional, List, Dict, Any, Set, Tuple, Union, Type, TypeVar, cast -from .. import utils from ..bpyutils import FnContext from ..core.material import FnMaterial from ..core.model import FnModel @@ -18,18 +12,19 @@ from ..core.sdef import FnSDEF from . import patch_library_overridable from .morph import BoneMorph, GroupMorph, MaterialMorph, UVMorph, VertexMorph from .translations import MMDTranslation -from ....core.logging_setup import logger + +IS_BLENDER_50_UP = bpy.app.version >= (5, 0) -def __driver_variables(constraint: bpy.types.Constraint, path: str, index: int = -1) -> Tuple[bpy.types.Driver, Any]: +def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1): d = constraint.driver_add(path, index) variables = d.driver.variables - for x in variables: + for x in reversed(variables): variables.remove(x) return d.driver, variables -def __add_single_prop(variables: Any, id_obj: bpy.types.Object, data_path: str, prefix: str) -> Any: +def __add_single_prop(variables, id_obj, data_path, prefix): var = variables.new() var.name = prefix + str(len(variables)) var.type = "SINGLE_PROP" @@ -40,18 +35,17 @@ def __add_single_prop(variables: Any, id_obj: bpy.types.Object, data_path: str, return var -def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleUsePropertyDriver(self: "MMDRoot", _context): root_object: bpy.types.Object = self.id_data armature_object = FnModel.find_armature_object(root_object) if armature_object is None: - ik_map: Dict[Any, Tuple[Any, Any]] = {} + ik_map = {} else: bones = armature_object.pose.bones ik_map = {bones[c.subtarget]: (b, c) for b in bones for c in b.constraints if c.type == "IK" and c.is_valid and c.subtarget in bones} if self.use_property_driver: - logger.debug("Enabling property drivers for %s", root_object.name) for ik, (b, c) in ik_map.items(): driver, variables = __driver_variables(c, "influence") driver.expression = "%s" % __add_single_prop(variables, ik.id_data, ik.path_from_id("mmd_ik_toggle"), "use_ik").name @@ -66,7 +60,6 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> No driver, variables = __driver_variables(i, prop_hide) driver.expression = "not %s" % __add_single_prop(variables, root_object, "mmd_root.show_meshes", "show").name else: - logger.debug("Disabling property drivers for %s", root_object.name) for ik, (b, c) in ik_map.items(): c.driver_remove("influence") b = b if c.use_tail else b.parent @@ -84,35 +77,31 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> No # =========================================== -def _toggleUseToonTexture(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleUseToonTexture(self: "MMDRoot", _context): use_toon = self.use_toon_texture - logger.debug("Toggling toon texture to %s for %s", use_toon, self.id_data.name) for i in FnModel.iterate_mesh_objects(self.id_data): for m in i.data.materials: if m: FnMaterial(m).use_toon_texture(use_toon) -def _toggleUseSphereTexture(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleUseSphereTexture(self: "MMDRoot", _context): use_sphere = self.use_sphere_texture - logger.debug("Toggling sphere texture to %s for %s", use_sphere, self.id_data.name) for i in FnModel.iterate_mesh_objects(self.id_data): for m in i.data.materials: if m: FnMaterial(m).use_sphere_texture(use_sphere, i) -def _toggleUseSDEF(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleUseSDEF(self: "MMDRoot", _context): mute_sdef = not self.use_sdef - logger.debug("Toggling SDEF to %s for %s", not mute_sdef, self.id_data.name) for i in FnModel.iterate_mesh_objects(self.id_data): FnSDEF.mute_sdef_set(i, mute_sdef) -def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context) -> None: +def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context): root = self.id_data hide = not self.show_meshes - logger.debug("Toggling mesh visibility to %s for %s", not hide, root.name) for i in FnModel.iterate_mesh_objects(self.id_data): i.hide_set(hide) i.hide_render = hide @@ -120,30 +109,27 @@ def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context) -> No FnContext.set_active_object(context, root) -def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context) -> None: +def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context): root = self.id_data hide = not self.show_rigid_bodies - logger.debug("Toggling rigid body visibility to %s for %s", not hide, root.name) for i in FnModel.iterate_rigid_body_objects(root): i.hide_set(hide) if hide and context.active_object is None: FnContext.set_active_object(context, root) -def _toggleVisibilityOfJoints(self: "MMDRoot", context: bpy.types.Context) -> None: +def _toggleVisibilityOfJoints(self: "MMDRoot", context): root_object = self.id_data hide = not self.show_joints - logger.debug("Toggling joint visibility to %s for %s", not hide, root_object.name) for i in FnModel.iterate_joint_objects(root_object): i.hide_set(hide) if hide and context.active_object is None: FnContext.set_active_object(context, root_object) -def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Context) -> None: +def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Context): root_object: bpy.types.Object = self.id_data hide = not self.show_temporary_objects - logger.debug("Toggling temporary object visibility to %s for %s", not hide, root_object.name) with FnContext.temp_override_active_layer_collection(context, root_object): for i in FnModel.iterate_temporary_objects(root_object): i.hide_set(hide) @@ -151,48 +137,45 @@ def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Cont FnContext.set_active_object(context, root_object) -def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context): root = self.id_data show_names = root.mmd_root.show_names_of_rigid_bodies - logger.debug("Toggling rigid body names to %s for %s", show_names, root.name) for i in FnModel.iterate_rigid_body_objects(root): i.show_name = show_names -def _toggleShowNamesOfJoints(self: "MMDRoot", _context: bpy.types.Context) -> None: +def _toggleShowNamesOfJoints(self: "MMDRoot", _context): root = self.id_data show_names = root.mmd_root.show_names_of_joints - logger.debug("Toggling joint names to %s for %s", show_names, root.name) for i in FnModel.iterate_joint_objects(root): i.show_name = show_names -def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool) -> None: +def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool): root = prop.id_data arm = FnModel.find_armature_object(root) if arm is None: return if not v and bpy.context.active_object == arm: FnContext.set_active_object(bpy.context, root) - logger.debug("Setting armature visibility to %s for %s", v, root.name) arm.hide_set(not v) -def _getVisibilityOfMMDRigArmature(prop: "MMDRoot") -> bool: +def _getVisibilityOfMMDRigArmature(prop: "MMDRoot"): if prop.id_data.mmd_type != "ROOT": return False arm = FnModel.find_armature_object(prop.id_data) - return arm and not arm.hide_get() + return arm is not None and not arm.hide_get() -def _setActiveRigidbodyObject(prop: "MMDRoot", v: int) -> None: +def _setActiveRigidbodyObject(prop: "MMDRoot", v: int): obj = FnContext.get_scene_objects(bpy.context)[v] if FnModel.is_rigid_body_object(obj): FnContext.set_active_and_select_single_object(bpy.context, obj) prop["active_rigidbody_object_index"] = v -def _getActiveRigidbodyObject(prop: "MMDRoot") -> int: +def _getActiveRigidbodyObject(prop: "MMDRoot"): context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_rigid_body_object(active_obj): @@ -200,14 +183,14 @@ def _getActiveRigidbodyObject(prop: "MMDRoot") -> int: return prop.get("active_rigidbody_object_index", 0) -def _setActiveJointObject(prop: "MMDRoot", v: int) -> None: +def _setActiveJointObject(prop: "MMDRoot", v: int): obj = FnContext.get_scene_objects(bpy.context)[v] if FnModel.is_joint_object(obj): FnContext.set_active_and_select_single_object(bpy.context, obj) prop["active_joint_object_index"] = v -def _getActiveJointObject(prop: "MMDRoot") -> int: +def _getActiveJointObject(prop: "MMDRoot"): context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_joint_object(active_obj): @@ -215,26 +198,26 @@ def _getActiveJointObject(prop: "MMDRoot") -> int: return prop.get("active_joint_object_index", 0) -def _setActiveMorph(prop: "MMDRoot", v: bool) -> None: +def _setActiveMorph(prop: "MMDRoot", v: bool): if "active_morph_indices" not in prop: prop["active_morph_indices"] = [0] * 5 prop["active_morph_indices"][prop.get("active_morph_type", 3)] = v -def _getActiveMorph(prop: "MMDRoot") -> int: +def _getActiveMorph(prop: "MMDRoot"): if "active_morph_indices" in prop: return prop["active_morph_indices"][prop.get("active_morph_type", 3)] return 0 -def _setActiveMeshObject(prop: "MMDRoot", v: int) -> None: +def _setActiveMeshObject(prop: "MMDRoot", v: int): obj = FnContext.get_scene_objects(bpy.context)[v] if FnModel.is_mesh_object(obj): FnContext.set_active_and_select_single_object(bpy.context, obj) prop["active_mesh_index"] = v -def _getActiveMeshObject(prop: "MMDRoot") -> int: +def _getActiveMeshObject(prop: "MMDRoot"): context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_mesh_object(active_obj): @@ -393,6 +376,18 @@ class MMDRoot(bpy.types.PropertyGroup): update=_toggleShowNamesOfJoints, ) + show_japanese_name: bpy.props.BoolProperty( + name="Japanese name", + description="Toggle Japanese name display", + default=True, + ) + + show_english_name: bpy.props.BoolProperty( + name="English name", + description="Toggle English name display", + default=True, + ) + use_toon_texture: bpy.props.BoolProperty( name="Use Toon Texture", description="Use toon texture", @@ -453,6 +448,15 @@ class MMDRoot(bpy.types.PropertyGroup): default=0, ) + # ************************* + # Bone + # ************************* + active_bone_index: bpy.props.IntProperty( + name="Active Bone Index", + description="Index of the active bone in the armature", + default=0, + ) + # ************************* # Morph # ************************* @@ -513,29 +517,40 @@ class MMDRoot(bpy.types.PropertyGroup): @staticmethod def __get_select(prop: bpy.types.Object) -> bool: - utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") + # TODO: Object.select is deprecated since v4.0.0, use Object.select_get() method instead + # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_get() method instead") return prop.select_get() @staticmethod def __set_select(prop: bpy.types.Object, value: bool) -> None: - utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") + # TODO: Object.select is deprecated since v4.0.0, use Object.select_set() method instead + # utils.warn_deprecation("Object.select", "v4.0.0", "Use Object.select_set() method instead") prop.select_set(value) @staticmethod def __get_hide(prop: bpy.types.Object) -> bool: - utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") + # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_get() method instead + # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_get() method instead") return prop.hide_get() @staticmethod def __set_hide(prop: bpy.types.Object, value: bool) -> None: - utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") + # TODO: Object.hide is deprecated since v4.0.0, use Object.hide_set() method instead + # utils.warn_deprecation("Object.hide", "v4.0.0", "Use Object.hide_set() method instead") prop.hide_set(value) if prop.hide_viewport != value: prop.hide_viewport = value @staticmethod - def register() -> None: - logger.debug("Registering MMDRoot property group") + def __get_pose_bone_select(prop: bpy.types.PoseBone) -> bool: + return prop.bone.select + + @staticmethod + def __set_pose_bone_select(prop: bpy.types.PoseBone, value: bool) -> None: + prop.bone.select = value + + @staticmethod + def register(): bpy.types.Object.mmd_type = patch_library_overridable( bpy.props.EnumProperty( name="Type", @@ -557,7 +572,7 @@ class MMDRoot(bpy.types.PropertyGroup): ("SPRING_CONSTRAINT", "Spring Constraint", "", 53), ("SPRING_GOAL", "Spring Goal", "", 54), ], - ) + ), ) bpy.types.Object.mmd_root = patch_library_overridable(bpy.props.PointerProperty(type=MMDRoot)) @@ -570,7 +585,7 @@ class MMDRoot(bpy.types.PropertyGroup): "ANIMATABLE", "LIBRARY_EDITABLE", }, - ) + ), ) bpy.types.Object.hide = patch_library_overridable( bpy.props.BoolProperty( @@ -581,13 +596,29 @@ class MMDRoot(bpy.types.PropertyGroup): "ANIMATABLE", "LIBRARY_EDITABLE", }, - ) + ), ) + if not IS_BLENDER_50_UP: + bpy.types.PoseBone.select = patch_library_overridable( + bpy.props.BoolProperty( + name="Select", + description="Pose bone selection state (compatibility layer for Blender 4.x, forwards to bone.select)", + get=MMDRoot.__get_pose_bone_select, + set=MMDRoot.__set_pose_bone_select, + options={ + "SKIP_SAVE", + "ANIMATABLE", + "LIBRARY_EDITABLE", + }, + ), + ) + @staticmethod - def unregister() -> None: - logger.debug("Unregistering MMDRoot property group") + def unregister(): del bpy.types.Object.hide del bpy.types.Object.select del bpy.types.Object.mmd_root del bpy.types.Object.mmd_type + if not IS_BLENDER_50_UP: + del bpy.types.PoseBone.select diff --git a/core/mmd/properties/translations.py b/core/mmd/properties/translations.py index a70a9fc..f1029c0 100644 --- a/core/mmd/properties/translations.py +++ b/core/mmd/properties/translations.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2021 MMD Tools authors +# This file is part of MMD Tools. from typing import Dict, List, Optional, Tuple diff --git a/core/mmd/translations.py b/core/mmd/translations.py index 267891a..486dac1 100644 --- a/core/mmd/translations.py +++ b/core/mmd/translations.py @@ -1,25 +1,17 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2016 MMD Tools authors +# This file is part of MMD Tools. import csv +from ...core.logging_setup import logger +import os import time -from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set +from collections import OrderedDict import bpy -from bpy.types import Text, Context from .bpyutils import FnContext -from ..logging_setup import logger -# Type definitions for translation tuples -TranslationTuple = Tuple[str, str] -TranslationList = List[TranslationTuple] - -jp_half_to_full_tuples: TranslationList = ( +jp_half_to_full_tuples = ( ("ヴ", "ヴ"), ("ガ", "ガ"), ("ギ", "ギ"), @@ -109,7 +101,7 @@ jp_half_to_full_tuples: TranslationList = ( ("ン", "ン"), ) -jp_to_en_tuples: TranslationList = [ +jp_to_en_tuples = [ ("全ての親", "ParentNode"), ("操作中心", "ControlNode"), ("センター", "Center"), @@ -299,30 +291,22 @@ jp_to_en_tuples: TranslationList = [ ] -def translateFromJp(name: str) -> str: - """Translate a Japanese name to English using the translation tuples.""" - logger.debug(f"Translating from Japanese: {name}") - for tuple in jp_to_en_tuples: - if tuple[0] in name: - name = name.replace(tuple[0], tuple[1]) - logger.debug(f"Translation result: {name}") +def translateFromJp(name): + for t in jp_to_en_tuples: + if t[0] in name: + name = name.replace(t[0], t[1]) return name -def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bool = False) -> 'MMDTranslator': - """Get a translator instance with the specified CSV file.""" +def getTranslator(csvfile="", keep_order=False): translator = MMDTranslator() if isinstance(csvfile, bpy.types.Text): - logger.debug(f"Loading translator from Text object: {csvfile.name}") translator.load_from_stream(csvfile) elif isinstance(csvfile, dict): - logger.debug(f"Loading translator from dictionary with {len(csvfile)} entries") translator.csv_tuples.extend(csvfile.items()) elif csvfile in bpy.data.texts.keys(): - logger.debug(f"Loading translator from text data: {csvfile}") translator.load_from_stream(bpy.data.texts[csvfile]) else: - logger.debug(f"Loading translator from file: {csvfile}") translator.load(csvfile) if not keep_order: @@ -332,20 +316,16 @@ def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bo class MMDTranslator: - """Handles translation of Japanese text to English for MMD models.""" - - def __init__(self) -> None: - self.__csv_tuples: List[Tuple[str, str]] = [] - self.__fails: Dict[str, str] = {} + def __init__(self): + self.__csv_tuples = [] + self.__fails = {} @staticmethod - def default_csv_filepath() -> str: - """Get the default CSV filepath for translations.""" + def default_csv_filepath(): return __file__[:-3] + ".csv" @staticmethod - def get_csv_text(text_name: Optional[str] = None) -> Text: - """Get or create a Text object for CSV data.""" + def get_csv_text(text_name=None): text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath()) csv_text = bpy.data.texts.get(text_name, None) if csv_text is None: @@ -353,88 +333,67 @@ class MMDTranslator: return csv_text @staticmethod - def replace_from_tuples(name: str, tuples: List[Tuple[str, str]]) -> str: - """Replace parts of a string based on translation tuples.""" + def replace_from_tuples(name, tuples): for pair in tuples: if pair[0] in name: name = name.replace(pair[0], pair[1]) return name @property - def csv_tuples(self) -> List[Tuple[str, str]]: - """Get the CSV tuples.""" + def csv_tuples(self): return self.__csv_tuples @property - def fails(self) -> Dict[str, str]: - """Get the failed translations.""" + def fails(self): return self.__fails - def sort(self) -> None: - """Sort the CSV tuples by length (longest first) and then alphabetically.""" - logger.debug("Sorting translation tuples") + def sort(self): self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row)) - def update(self) -> None: - """Update the CSV tuples, removing duplicates.""" - from collections import OrderedDict - + def update(self): count_old = len(self.__csv_tuples) tuples_dict = OrderedDict((row[0], row) for row in self.__csv_tuples if len(row) >= 2 and row[0]) self.__csv_tuples.clear() self.__csv_tuples.extend(tuples_dict.values()) - logger.info("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old) + logger.info(" - removed items:\t%d\t(of %d)", count_old - len(self.__csv_tuples), count_old) - def half_to_full(self, name: str) -> str: - """Convert half-width Japanese characters to full-width.""" + def half_to_full(self, name): return self.replace_from_tuples(name, jp_half_to_full_tuples) - def is_translated(self, name: str) -> bool: - """Check if a string is already translated (contains only ASCII characters).""" + def is_translated(self, name): try: name.encode("ascii", errors="strict") except UnicodeEncodeError: return False return True - def translate(self, name: str, default: Optional[str] = None, from_full_width: bool = True) -> str: - """Translate a string from Japanese to English.""" - logger.debug(f"Translating: {name}") + def translate(self, name, default=None, from_full_width=True): if from_full_width: name = self.half_to_full(name) name_new = self.replace_from_tuples(name, self.__csv_tuples) if default is not None and not self.is_translated(name_new): - logger.warning(f"Translation failed for: {name}") self.__fails[name] = name_new return default return name_new - def save_fails(self, text_name: Optional[str] = None) -> Text: - """Save failed translations to a Text object.""" + def save_fails(self, text_name=None): text_name = text_name or (__name__ + ".fails") txt = self.get_csv_text(text_name) fmt = '"%s","%s"' items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row)) txt.from_string("\n".join(fmt % (k, v) for k, v in items)) - logger.info(f"Saved {len(items)} failed translations to {text_name}") return txt - def load_from_stream(self, csvfile: Union[Text, Iterator[str]] = None) -> None: - """Load translations from a stream.""" + def load_from_stream(self, csvfile=None): csvfile = csvfile or self.get_csv_text() if isinstance(csvfile, bpy.types.Text): - csvfile = (l.body + "\n" for l in csvfile.lines) + csvfile = (line.body + "\n" for line in csvfile.lines) spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True) csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2] self.__csv_tuples = csv_tuples - logger.info("Loaded %d translation items", len(self.__csv_tuples)) + logger.info(" - load items:\t%d", len(self.__csv_tuples)) - def save_to_stream(self, csvfile: Union[Text, TextIO] = None) -> None: - """Save translations to a stream. - - Args: - csvfile: The CSV file or stream to save to - """ + def save_to_stream(self, csvfile=None): csvfile = csvfile or self.get_csv_text() lineterminator = "\r\n" if isinstance(csvfile, bpy.types.Text): @@ -442,38 +401,27 @@ class MMDTranslator: lineterminator = "\n" spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL) spamwriter.writerows(self.__csv_tuples) - logger.info("Saved %d translation items", len(self.__csv_tuples)) + logger.info(" - save items:\t%d", len(self.__csv_tuples)) - def load(self, filepath: Optional[str] = None) -> None: - """Load translations from a file.""" + def load(self, filepath=None): filepath = filepath or self.default_csv_filepath() - logger.info("Loading CSV file: %s", filepath) - try: - with open(filepath, "rt", encoding="utf-8", newline="") as csvfile: - self.load_from_stream(csvfile) - except Exception as e: - logger.error(f"Failed to load CSV file: {e}") + logger.info("Loading csv file:\t%s", filepath) + with open(filepath, encoding="utf-8", newline="") as csvfile: + self.load_from_stream(csvfile) - def save(self, filepath: Optional[str] = None) -> None: - """Save translations to a file.""" + def save(self, filepath=None): filepath = filepath or self.default_csv_filepath() - logger.info("Saving CSV file: %s", filepath) - try: - with open(filepath, "wt", encoding="utf-8", newline="") as csvfile: - self.save_to_stream(csvfile) - except Exception as e: - logger.error(f"Failed to save CSV file: {e}") + logger.info("Saving csv file:\t%s", filepath) + with open(filepath, "w", encoding="utf-8", newline="") as csvfile: + self.save_to_stream(csvfile) class DictionaryEnum: - """Handles dictionary enumeration for UI.""" - - __items_ttl: float = 0.0 - __items_cache: Optional[List[Tuple[str, str, str, int]]] = None + __items_ttl = 0.0 + __items_cache = None @staticmethod - def get_dictionary_items(prop: Any, context: Context) -> List[Tuple[str, str, str, Union[int, str], int]]: - """Get dictionary items for UI enumeration.""" + def get_dictionary_items(prop, context): if DictionaryEnum.__items_ttl > time.time(): return DictionaryEnum.__items_cache @@ -487,8 +435,6 @@ class DictionaryEnum: for txt_name in sorted(x.name for x in bpy.data.texts if x.name.lower().endswith(".csv")): items.append((txt_name, txt_name, f"bpy.data.texts['{txt_name}']", "TEXT", len(items))) - import os - folder = FnContext.get_addon_preferences_attribute(context, "dictionary_folder", "") if os.path.isdir(folder): for filename in sorted(x for x in os.listdir(folder) if x.lower().endswith(".csv")): @@ -498,19 +444,12 @@ class DictionaryEnum: if "dictionary" in prop: prop["dictionary"] = min(prop["dictionary"], len(items) - 1) - - logger.debug(f"Found {len(items)} dictionary items") return items @staticmethod - def get_translator(dictionary: str) -> Optional[MMDTranslator]: - """Get a translator for the specified dictionary.""" + def get_translator(dictionary): if dictionary == "DISABLED": - logger.debug("Translation disabled") return None if dictionary == "INTERNAL": - logger.debug("Using internal dictionary") return getTranslator(dict(jp_to_en_tuples)) - - logger.debug(f"Using dictionary: {dictionary}") return getTranslator(dictionary) diff --git a/core/mmd/utils.py b/core/mmd/utils.py index 4bfb478..a701d28 100644 --- a/core/mmd/utils.py +++ b/core/mmd/utils.py @@ -1,23 +1,20 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file was originally part of the MMD Tools add-on for Blender -# You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools -# Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. -# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. +# Copyright 2012 MMD Tools authors +# This file is part of MMD Tools. +from ...core.logging_setup import logger import os import re -from typing import Callable, Dict, List, Optional, Set, Tuple, Union, Any +import string +from typing import Callable, Optional, Set import bpy -from bpy.types import Object, Bone, PoseBone, Mesh, VertexGroup +import numpy as np -from ..logging_setup import logger from .bpyutils import FnContext -## 指定したオブジェクトのみを選択状態かつアクティブにする -def selectAObject(obj: Object) -> None: +# 指定したオブジェクトのみを選択状態かつアクティブにする +def selectAObject(obj): try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: @@ -27,14 +24,14 @@ def selectAObject(obj: Object) -> None: FnContext.set_active_object(FnContext.ensure_context(), obj) -## 現在のモードを指定したオブジェクトのEdit Modeに変更する -def enterEditMode(obj: Object) -> None: +# 現在のモードを指定したオブジェクトのEdit Modeに変更する +def enterEditMode(obj): selectAObject(obj) if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") -def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: +def setParentToBone(obj, parent, bone_name): selectAObject(obj) FnContext.set_active_object(FnContext.ensure_context(), parent) bpy.ops.object.mode_set(mode="POSE") @@ -43,11 +40,11 @@ def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: bpy.ops.object.mode_set(mode="OBJECT") -def selectSingleBone(context: bpy.types.Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None: +def selectSingleBone(context, armature, bone_name, reset_pose=False): try: bpy.ops.object.mode_set(mode="OBJECT") - except: - pass + except Exception as e: + logger.warning(f"Failed to set object mode: {e}") for i in context.selected_objects: i.select_set(False) FnContext.set_active_object(context, armature) @@ -55,21 +52,21 @@ def selectSingleBone(context: bpy.types.Context, armature: Object, bone_name: st if reset_pose: for p_bone in armature.pose.bones: p_bone.matrix_basis.identity() - # Blender 5.0: bone selection in Pose mode now uses pose.bones[].select - armature_bones: bpy.types.ArmatureBones = armature.data.bones + for p_bone in armature.pose.bones: - p_bone.select = p_bone.name == bone_name - if p_bone.select: - armature_bones.active = armature_bones[p_bone.name] - p_bone.hide = False + is_target = p_bone.name == bone_name + p_bone.select = is_target + if is_target: + armature.data.bones.active = p_bone.bone + p_bone.bone.hide = False -__CONVERT_NAME_TO_L_REGEXP = re.compile("^(.*)左(.*)$") -__CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$") +__CONVERT_NAME_TO_L_REGEXP = re.compile(r"^(.*)左(.*)$") +__CONVERT_NAME_TO_R_REGEXP = re.compile(r"^(.*)右(.*)$") -## 日本語で左右を命名されている名前をblender方式のL(R)に変更する -def convertNameToLR(name: str, use_underscore: bool = False) -> str: +# 日本語で左右を命名されている名前をblender方式のL(R)に変更する +def convertNameToLR(name, use_underscore=False): m = __CONVERT_NAME_TO_L_REGEXP.match(name) delimiter = "_" if use_underscore else "." if m: @@ -84,7 +81,7 @@ __CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[lL])(?P(?P[._])[rR])(?P($|(?P=separator)))") -def convertLRToName(name: str) -> str: +def convertLRToName(name): match = __CONVERT_L_TO_NAME_REGEXP.search(name) if match: return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}" @@ -96,8 +93,8 @@ def convertLRToName(name: str) -> str: return name -## src_vertex_groupのWeightをdest_vertex_groupにaddする -def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_group_name: str) -> None: +# src_vertex_groupのWeightをdest_vertex_groupにaddする +def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name): mesh = meshObj.data src_vertex_group = meshObj.vertex_groups[src_vertex_group_name] dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name] @@ -111,43 +108,73 @@ def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_gr pass -def separateByMaterials(meshObj: Object) -> None: - if len(meshObj.data.materials) < 2: +def separateByMaterials(meshObj: bpy.types.Object, keep_normals: bool = False): + meshData = meshObj.data + if len(meshData.materials) < 2: selectAObject(meshObj) return - matrix_parent_inverse = meshObj.matrix_parent_inverse.copy() - prev_parent = meshObj.parent - dummy_parent = bpy.data.objects.new(name="tmp", object_data=None) - meshObj.parent = dummy_parent - meshObj.active_shape_key_index = 0 + + dummy_parent = None try: - enterEditMode(meshObj) - bpy.ops.mesh.select_all(action="SELECT") - bpy.ops.mesh.separate(type="MATERIAL") + dummy_parent = bpy.data.objects.new(name="tmp", object_data=None) + matrix_parent_inverse = meshObj.matrix_parent_inverse.copy() + prev_parent = meshObj.parent + meshObj.parent = dummy_parent + meshObj.active_shape_key_index = 0 + mmd_normal_name = None # To avoid conflict ("mmd_normal.001", etc.) + if keep_normals: + existing_custom_normal = meshData.attributes.get("custom_normal") + if existing_custom_normal: + if existing_custom_normal.data_type == "INT16_2D": + normals_data = np.empty(len(meshData.loops) * 2, dtype=np.int16) + existing_custom_normal.data.foreach_get("value", normals_data) + mmd_normal = meshData.attributes.new("mmd_normal", "INT16_2D", "CORNER") + mmd_normal_name = mmd_normal.name + mmd_normal.data.foreach_set("value", normals_data) + else: + raise TypeError(f"Unsupported custom_normal data type: '{existing_custom_normal.data_type}'. Supported types: 'INT16_2D'") + + try: + enterEditMode(meshObj) + bpy.ops.mesh.separate(type="MATERIAL") + finally: + bpy.ops.object.mode_set(mode="OBJECT") + + for i in dummy_parent.children: + materials = i.data.materials + i.name = getattr(materials[0], "name", "None") if len(materials) else "None" + i.parent = prev_parent + i.matrix_parent_inverse = matrix_parent_inverse + + if keep_normals and mmd_normal_name: + mmd_normal = i.data.attributes.get(mmd_normal_name) + if mmd_normal: + if mmd_normal.data_type == "INT16_2D": + normals_data = np.empty(len(i.data.loops) * 2, dtype=np.int16) + mmd_normal.data.foreach_get("value", normals_data) + custom_normal_attr = i.data.attributes.get("custom_normal") + if not custom_normal_attr: + custom_normal_attr = i.data.attributes.new("custom_normal", "INT16_2D", "CORNER") + custom_normal_attr.data.foreach_set("value", normals_data) + else: + raise TypeError(f"Unsupported custom_normal data type: '{mmd_normal.data_type}'. Supported types: 'INT16_2D'") + i.data.attributes.remove(mmd_normal) finally: - bpy.ops.object.mode_set(mode="OBJECT") - for i in dummy_parent.children: - materials = i.data.materials - i.name = getattr(materials[0], "name", "None") if len(materials) else "None" - i.parent = prev_parent - i.matrix_parent_inverse = matrix_parent_inverse - bpy.data.objects.remove(dummy_parent) + if dummy_parent and dummy_parent.name in bpy.data.objects: + bpy.data.objects.remove(dummy_parent) -def clearUnusedMeshes() -> None: - meshes_to_delete = [] - for mesh in bpy.data.meshes: - if mesh.users == 0: - meshes_to_delete.append(mesh) +def clearUnusedMeshes(): + meshes_to_delete = [mesh for mesh in bpy.data.meshes if mesh.users == 0] for mesh in meshes_to_delete: bpy.data.meshes.remove(mesh) -## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を +# Boneのカスタムプロパティにname_jが存在する場合、name_jの値を # それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成 -def makePmxBoneMap(armObj: Object) -> Dict[str, PoseBone]: - # Maintain backward compatibility with mmd_tools v0.4.x or older. +def makePmxBoneMap(armObj): + # Maintain backward compatibility with mmd_tools_local v0.4.x or older. return {(i.mmd_bone.name_j or i.get("mmd_bone_name_j", i.get("name_j", i.name))): i for i in armObj.pose.bones} @@ -155,7 +182,7 @@ __REMOVE_PREFIX_DIGITS_REGEXP = re.compile(r"\.\d{1,}$") def unique_name(name: str, used_names: Set[str]) -> str: - """Helper function for storing unique names. + """Generate a unique name from the given name. This function is a limited and simplified version of bpy_extras.io_utils.unique_name. Args: @@ -175,13 +202,11 @@ def unique_name(name: str, used_names: Set[str]) -> str: return new_name -def int2base(x: int, base: int, width: int = 0) -> str: +def int2base(x, base, width=0): """ - Method to convert an int to a base + Convert an int to a base Source: http://stackoverflow.com/questions/2267362 """ - import string - digs = string.digits + string.ascii_uppercase assert 2 <= base <= len(digs) digits, negtive = "", False @@ -198,7 +223,7 @@ def int2base(x: int, base: int, width: int = 0) -> str: return digits -def saferelpath(path: str, start: str, strategy: str = "inside") -> str: +def saferelpath(path, start, strategy="inside"): """ On Windows relpath will raise a ValueError when trying to calculate the relative path to a @@ -225,15 +250,16 @@ def saferelpath(path: str, start: str, strategy: str = "inside") -> str: return os.path.relpath(path, start) + class ItemOp: @staticmethod - def get_by_index(items: bpy.types.bpy_prop_collection, index: int) -> Optional[Any]: + def get_by_index(items, index): if 0 <= index < len(items): return items[index] return None @staticmethod - def resize(items: bpy.types.bpy_prop_collection, length: int) -> None: + def resize(items: bpy.types.bpy_prop_collection, length: int): count = length - len(items) if count > 0: for i in range(count): @@ -243,7 +269,7 @@ class ItemOp: items.remove(length) @staticmethod - def add_after(items: bpy.types.bpy_prop_collection, index: int) -> Tuple[Any, int]: + def add_after(items, index): index_end = len(items) index = max(0, min(index_end, index + 1)) items.add() @@ -265,8 +291,7 @@ class ItemMoveOp: ) @staticmethod - def move(items: bpy.types.bpy_prop_collection, index: int, move_type: str, - index_min: int = 0, index_max: Optional[int] = None) -> int: + def move(items, index, move_type, index_min=0, index_max=None): if index_max is None: index_max = len(items) - 1 else: @@ -276,7 +301,7 @@ class ItemMoveOp: if index < index_min: items.move(index, index_min) return index_min - elif index > index_max: + if index > index_max: items.move(index, index_max) return index_max @@ -295,8 +320,8 @@ class ItemMoveOp: return index_new -def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None) -> Callable: - """Decorator to mark a function as deprecated. +def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None): + """Mark a function as deprecated. Args: deprecated_in (Optional[str]): Version in which the function was deprecated. details (Optional[str]): Additional details about the deprecation. @@ -304,8 +329,8 @@ def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = Non Callable: The decorated function. """ - def _function_wrapper(function: Callable) -> Callable: - def _inner_wrapper(*args: Any, **kwargs: Any) -> Any: + def _function_wrapper(function: Callable): + def _inner_wrapper(*args, **kwargs): warn_deprecation(function.__name__, deprecated_in, details) return function(*args, **kwargs) @@ -315,7 +340,7 @@ def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = Non def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, details: Optional[str] = None) -> None: - """Reports a deprecation warning. + """Report a deprecation warning. Args: function_name (str): Name of the deprecated function. deprecated_in (Optional[str]): Version in which the function was deprecated. @@ -329,3 +354,7 @@ def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, de stack_info=True, stacklevel=4, ) + + # import warnings # pylint: disable=import-outside-toplevel + + # warnings.warn(f"""{function_name}is deprecated{f" since {deprecated_in}" if deprecated_in else ""}{f": {details}" if details else ""}""", category=DeprecationWarning, stacklevel=2)