diff --git a/blender_manifest.toml b/blender_manifest.toml index b6e9679..77dd551 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -3,7 +3,7 @@ schema_version = "1.0.0" id = "avatar_toolkit" -version = "0.2.1" +version = "0.3.0" name = "Avatar Toolkit" tagline = "A modern tool for importing and optimizing models for VRChat, Resonite, and other similar games." maintainer = "Team NekoNeo" diff --git a/core/armature_validation.py b/core/armature_validation.py index ad1212b..9abf0d7 100644 --- a/core/armature_validation.py +++ b/core/armature_validation.py @@ -25,12 +25,18 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio non_standard_messages: List[str] = [] scale_messages: List[str] = [] + # Check if this is a PMX model + is_pmx_model = False + if armature and hasattr(armature, 'mmd_type') or (hasattr(armature, 'parent') and armature.parent and hasattr(armature.parent, 'mmd_type')): + is_pmx_model = True + logger.debug("Detected PMX model, using specialized validation") + if validation_mode == 'NONE': logger.debug("Validation mode is NONE, skipping validation") if detailed_messages: - return True, [], False, [], [], [] + return True, [t("Validation.mode.none")], False, [], [], [] else: - return True, [], False + return True, [t("Validation.mode.none")], False if not armature or armature.type != 'ARMATURE' or not armature.data.bones: logger.warning("Basic armature check failed") @@ -125,6 +131,21 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio non_standard_messages.append(t("Armature.validation.standardize_note.line2")) non_standard_messages.append(t("Armature.validation.standardize_note.line3")) + # Special handling for PMX models + if is_pmx_model: + logger.info("PMX model detected, applying specialized validation") + # For PMX models, we'll be more lenient with validation + # and provide specific guidance for these models + if not messages: + messages = [t("Armature.validation.pmx_model_detected")] + + # Add PMX-specific messages + if validation_mode == 'STRICT': + messages.append(t("Armature.validation.pmx_model_strict")) + messages.append(t("Armature.validation.pmx_model_standardize")) + else: + messages.append(t("Armature.validation.pmx_model_basic")) + # Combine messages in correct order messages.extend(non_standard_messages) @@ -149,6 +170,10 @@ def validate_armature(armature: Object, detailed_messages: bool = False) -> Unio else: return True, messages, True + # Ensure messages has at least one element + if not messages: + messages = [t("Armature.validation.unknown_format")] + logger.info(f"Armature validation complete. Valid: {is_valid}") if detailed_messages: return is_valid, messages, False, hierarchy_messages, scale_messages, non_standard_messages diff --git a/core/lamp.py b/core/lamp.py deleted file mode 100644 index 10593d3..0000000 --- a/core/lamp.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 MMD Tools authors -# This file is part of MMD Tools. - -import bpy - -from ..bpyutils import FnContext, Props - - -class MMDLamp: - def __init__(self, obj): - if MMDLamp.isLamp(obj): - obj = obj.parent - if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT": - self.__emptyObj = obj - else: - raise ValueError("%s is not MMDLamp" % str(obj)) - - @staticmethod - def isLamp(obj): - return obj and obj.type in {"LIGHT", "LAMP"} - - @staticmethod - def isMMDLamp(obj): - if MMDLamp.isLamp(obj): - obj = obj.parent - return obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT" - - @staticmethod - def convertToMMDLamp(lampObj, scale=1.0): - if MMDLamp.isMMDLamp(lampObj): - return MMDLamp(lampObj) - - 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) - setattr(empty, Props.empty_display_size, 0.4) - empty.scale = [10 * scale] * 3 - empty.mmd_type = "LIGHT" - empty.location = (0, 0, 11 * scale) - - lampObj.parent = empty - lampObj.data.color = (0.602, 0.602, 0.602) - lampObj.location = (0.5, -0.5, 1.0) - lampObj.rotation_mode = "XYZ" - lampObj.rotation_euler = (0, 0, 0) - lampObj.lock_rotation = (True, True, True) - - constraint = lampObj.constraints.new(type="TRACK_TO") - constraint.name = "mmd_lamp_track" - constraint.target = empty - constraint.track_axis = "TRACK_NEGATIVE_Z" - constraint.up_axis = "UP_Y" - - return MMDLamp(empty) - - def object(self): - return self.__emptyObj - - def lamp(self): - for i in self.__emptyObj.children: - if MMDLamp.isLamp(i): - return i - raise KeyError diff --git a/core/mmd/bpyutils.py b/core/mmd/bpyutils.py index c5c9d76..3bc6d28 100644 --- a/core/mmd/bpyutils.py +++ b/core/mmd/bpyutils.py @@ -6,9 +6,13 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import contextlib -from typing import Generator, List, Optional, TypeVar +from typing import Generator, List, Optional, TypeVar, Any, Set, Tuple, Dict, Union 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 class Props: # For API changes of only name changed properties @@ -20,7 +24,7 @@ class Props: # For API changes of only name changed properties class __EditMode: - def __init__(self, obj): + def __init__(self, obj: Object): if not isinstance(obj, bpy.types.Object): raise ValueError self.__prevMode = obj.mode @@ -30,10 +34,10 @@ class __EditMode: if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") - def __enter__(self): + def __enter__(self) -> Any: return self.__obj.data - def __exit__(self, type, value, traceback): + def __exit__(self, type: Any, value: Any, traceback: Any) -> None: if self.__prevMode == "EDIT": bpy.ops.object.mode_set(mode="OBJECT") # update edited data bpy.ops.object.mode_set(mode=self.__prevMode) @@ -41,17 +45,18 @@ class __EditMode: class __SelectObjects: - def __init__(self, active_object: bpy.types.Object, selected_objects: Optional[List[bpy.types.Object]] = None): + def __init__(self, active_object: Object, selected_objects: Optional[List[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 - contenxt = FnContext.ensure_context() + context = FnContext.ensure_context() - for i in contenxt.selected_objects: + for i in context.selected_objects: i.select_set(False) self.__active_object = active_object @@ -60,23 +65,23 @@ class __SelectObjects: self.__hides: List[bool] = [] for i in self.__selected_objects: self.__hides.append(i.hide_get()) - FnContext.select_object(contenxt, i) - FnContext.set_active_object(contenxt, active_object) + FnContext.select_object(context, i) + FnContext.set_active_object(context, active_object) - def __enter__(self) -> bpy.types.Object: + def __enter__(self) -> Object: return self.__active_object - def __exit__(self, type, value, traceback): + 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 setParent(obj, parent): +def setParent(obj: Object, parent: Object) -> None: with select_object(parent, objects=[parent, obj]): bpy.ops.object.parent_set(type="OBJECT", xmirror=False, keep_transform=False) -def setParentToBone(obj, parent, bone_name): +def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: with select_object(parent, objects=[parent, obj]): bpy.ops.object.mode_set(mode="POSE") parent.data.bones.active = parent.data.bones[bone_name] @@ -84,7 +89,7 @@ def setParentToBone(obj, parent, bone_name): bpy.ops.object.mode_set(mode="OBJECT") -def edit_object(obj): +def edit_object(obj: Object) -> __EditMode: """Set the object interaction mode to 'EDIT' It is recommended to use 'edit_object' with 'with' statement like the following code. @@ -95,7 +100,7 @@ def edit_object(obj): return __EditMode(obj) -def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object]] = None): +def select_object(obj: Object, objects: Optional[List[Object]] = None) -> __SelectObjects: """Select objects. It is recommended to use 'select_object' with 'with' statement like the following code. @@ -108,20 +113,23 @@ def select_object(obj: bpy.types.Object, objects: Optional[List[bpy.types.Object return __SelectObjects(obj, objects) -def duplicateObject(obj, total_len): +def duplicateObject(obj: Object, total_len: int) -> List[Object]: return FnContext.duplicate_object(FnContext.ensure_context(), obj, total_len) -def createObject(name="Object", object_data=None, target_scene=None): +def createObject(name: str = "Object", object_data: Optional[ID] = None, target_scene: Optional[bpy.types.Scene] = None) -> Object: context = FnContext.ensure_context(target_scene) return FnContext.set_active_object(context, FnContext.new_and_link_object(context, name, object_data)) -def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None): +def makeSphere(segment: int = 8, ring_count: int = 5, radius: float = 1.0, target_object: Optional[Object] = None) -> Object: import bmesh 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 = target_object.data bm = bmesh.new() @@ -138,12 +146,15 @@ def makeSphere(segment=8, ring_count=5, radius=1.0, target_object=None): return target_object -def makeBox(size=(1, 1, 1), target_object=None): +def makeBox(size: Tuple[float, float, float] = (1, 1, 1), target_object: Optional[Object] = None) -> Object: import bmesh from mathutils import Matrix 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 = target_object.data bm = bmesh.new() @@ -159,13 +170,16 @@ def makeBox(size=(1, 1, 1), target_object=None): return target_object -def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=None): +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 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}") + height = max(height, 1e-3) mesh = target_object.data @@ -224,10 +238,10 @@ def makeCapsule(segment=8, ring_count=2, radius=1.0, height=1.0, target_object=N class TransformConstraintOp: - __MIN_MAX_MAP = {"ROTATION": "_rot", "SCALE": "_scale"} + __MIN_MAX_MAP: Dict[Union[str, Tuple[str, str]], Union[str, Tuple[str, ...]]] = {"ROTATION": "_rot", "SCALE": "_scale"} @staticmethod - def create(constraints, name, map_type): + def create(constraints: bpy.types.ObjectConstraints, name: str, map_type: str) -> bpy.types.TransformConstraint: c = constraints.get(name, None) if c and c.type != "TRANSFORM": constraints.remove(c) @@ -245,7 +259,7 @@ class TransformConstraintOp: return c @classmethod - def min_max_attributes(cls, map_type, name_id=""): + def min_max_attributes(cls, map_type: str, name_id: str = "") -> Tuple[str, ...]: key = (map_type, name_id) ret = cls.__MIN_MAX_MAP.get(key, None) if ret is None: @@ -255,7 +269,7 @@ class TransformConstraintOp: return ret @classmethod - def update_min_max(cls, constraint, value, influence=1): + def update_min_max(cls, constraint: bpy.types.TransformConstraint, value: float, influence: Optional[float] = 1) -> None: c = constraint if not c or c.type != "TRANSFORM": return @@ -279,14 +293,14 @@ class FnObject: raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def mesh_remove_shape_key(mesh_object: bpy.types.Object, shape_key: bpy.types.ShapeKey): + def mesh_remove_shape_key(mesh_object: Object, shape_key: ShapeKey) -> None: assert isinstance(mesh_object.data, bpy.types.Mesh) - key: bpy.types.Key = shape_key.id_data + key: Key = shape_key.id_data assert key == mesh_object.data.shape_keys if mesh_object.animation_data is not None: - fc_curve: bpy.types.FCurve + fc_curve: FCurve for fc_curve in mesh_object.animation_data.drivers: if not fc_curve.data_path.startswith(shape_key.path_from_id()): continue @@ -310,35 +324,35 @@ class FnContext: raise NotImplementedError("This class is not expected to be instantiated.") @staticmethod - def ensure_context(context: Optional[bpy.types.Context] = None) -> bpy.types.Context: + def ensure_context(context: Optional[Context] = None) -> Context: return context or bpy.context @staticmethod - def get_active_object(context: bpy.types.Context) -> Optional[bpy.types.Object]: + def get_active_object(context: Context) -> Optional[Object]: return context.active_object @staticmethod - def set_active_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def set_active_object(context: Context, obj: Object) -> Object: context.view_layer.objects.active = obj return obj @staticmethod - def set_active_and_select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def set_active_and_select_single_object(context: Context, obj: Object) -> Object: return FnContext.set_active_object(context, FnContext.select_single_object(context, obj)) @staticmethod - def get_scene_objects(context: bpy.types.Context) -> bpy.types.SceneObjects: + def get_scene_objects(context: Context) -> bpy.types.SceneObjects: return context.scene.objects @staticmethod - def ensure_selectable(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def ensure_selectable(context: Context, obj: Object) -> 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: bpy.types.LayerCollection) -> bool: + def __layer_check(layer_collection: LayerCollection) -> bool: for lc in layer_collection.children: if __layer_check(lc): lc.hide_viewport = False @@ -360,44 +374,44 @@ class FnContext: return obj @staticmethod - def select_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def select_object(context: Context, obj: Object) -> Object: FnContext.ensure_selectable(context, obj).select_set(True) return obj @staticmethod - def select_objects(context: bpy.types.Context, *objects: bpy.types.Object) -> List[bpy.types.Object]: + def select_objects(context: Context, *objects: Object) -> List[Object]: return [FnContext.select_object(context, obj) for obj in objects] @staticmethod - def select_single_object(context: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def select_single_object(context: Context, obj: Object) -> 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: bpy.types.Context, obj: bpy.types.Object) -> bpy.types.Object: + def link_object(context: Context, obj: Object) -> Object: context.collection.objects.link(obj) return obj @staticmethod - def new_and_link_object(context: bpy.types.Context, name: str, object_data: Optional[bpy.types.ID]) -> bpy.types.Object: + def new_and_link_object(context: Context, name: str, object_data: Optional[ID]) -> Object: return FnContext.link_object(context, bpy.data.objects.new(name=name, object_data=object_data)) @staticmethod - def duplicate_object(context: bpy.types.Context, object_to_duplicate: bpy.types.Object, target_count: int) -> List[bpy.types.Object]: + def duplicate_object(context: Context, object_to_duplicate: Object, target_count: int) -> List[Object]: """ Duplicate object. This function duplicates the given object and returns a list of duplicated objects. Args: - context (bpy.types.Context): The context in which the duplication is performed. - object_to_duplicate (bpy.types.Object): The object to be duplicated. + context (Context): The context in which the duplication is performed. + object_to_duplicate (Object): The object to be duplicated. target_count (int): The desired count of duplicated objects. Returns: - List[bpy.types.Object]: A list of duplicated objects. + List[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. @@ -421,27 +435,28 @@ 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: bpy.types.Context, target_object: bpy.types.Object) -> Optional[bpy.types.LayerCollection]: + def find_user_layer_collection_by_object(context: Context, target_object: Object) -> Optional[LayerCollection]: """ Finds the layer collection that contains the given target_object in the user's collections. Args: - context (bpy.types.Context): The Blender context. - target_object (bpy.types.Object): The target object to find the layer collection for. + context (Context): The Blender context. + target_object (Object): The target object to find the layer collection for. Returns: - Optional[bpy.types.LayerCollection]: The layer collection that contains the target_object, or None if not found. + Optional[LayerCollection]: The layer collection that contains the target_object, or None if not found. """ - scene_layer_collection: bpy.types.LayerCollection = context.view_layer.layer_collection + scene_layer_collection: LayerCollection = context.view_layer.layer_collection - def find_layer_collection_by_name(layer_collection: bpy.types.LayerCollection, name: str) -> Optional[bpy.types.LayerCollection]: + def find_layer_collection_by_name(layer_collection: LayerCollection, name: str) -> Optional[LayerCollection]: if layer_collection.name == name: return layer_collection - child_layer_collection: bpy.types.LayerCollection + child_layer_collection: LayerCollection for child_layer_collection in layer_collection.children: found = find_layer_collection_by_name(child_layer_collection, name) if found is not None: @@ -449,7 +464,7 @@ class FnContext: return None - user_collection: bpy.types.Collection + user_collection: 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: @@ -459,7 +474,7 @@ class FnContext: @staticmethod @contextlib.contextmanager - def temp_override_active_layer_collection(context: bpy.types.Context, target_object: bpy.types.Object) -> Generator[bpy.types.Context, None, None]: + def temp_override_active_layer_collection(context: Context, target_object: Object) -> Generator[Context, None, None]: """ Context manager to temporarily override the active_layer_collection that contains the target object. @@ -467,11 +482,11 @@ class FnContext: It ensures that the original active_layer_collection is restored after the context is exited. Args: - 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. + 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. Yields: - bpy.types.Context: The modified context with the active_layer_collection overridden. + Context: The modified context with the active_layer_collection overridden. Example: with FnContext.temp_override_active_layer_collection(context, target_object): @@ -492,24 +507,24 @@ class FnContext: context.view_layer.active_layer_collection = original_layer_collection @staticmethod - def __get_addon_preferences(context: bpy.types.Context) -> Optional[bpy.types.AddonPreferences]: - addon: bpy.types.Addon = context.preferences.addons.get(__package__, None) + def __get_addon_preferences(context: Context) -> Optional[AddonPreferences]: + addon: Addon = context.preferences.addons.get(__package__, None) return addon.preferences if addon else None @staticmethod - 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: + def get_addon_preferences_attribute(context: 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: 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]: + 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]: if active_object is not None: keywords["active_object"] = active_object keywords["object"] = active_object diff --git a/core/mmd/core/bone.py b/core/mmd/core/bone.py index 73fa58c..29b490e 100644 --- a/core/mmd/core/bone.py +++ b/core/mmd/core/bone.py @@ -6,29 +6,32 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import math -from typing import TYPE_CHECKING, Iterable, Optional, Set +from typing import TYPE_CHECKING, Iterable, Optional, Set, List, Dict, Tuple, Any, Union, cast 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 -def remove_constraint(constraints, name): +def remove_constraint(constraints: Any, name: str) -> bool: + """Remove a constraint by name if it exists""" c = constraints.get(name, None) if c: constraints.remove(c) return True return False - -def remove_edit_bones(edit_bones, bone_names): +def remove_edit_bones(edit_bones: bpy.types.ArmatureEditBones, bone_names: List[str]) -> None: + """Remove edit bones by name""" for name in bone_names: b = edit_bones.get(name, None) if b: @@ -45,33 +48,39 @@ SPECIAL_BONE_COLLECTION_NAMES = [BONE_COLLECTION_NAME_SHADOW, BONE_COLLECTION_NA class FnBone: - AUTO_LOCAL_AXIS_ARMS = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") - AUTO_LOCAL_AXIS_FINGERS = ("親指", "人指", "中指", "薬指", "小指") - AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") + AUTO_LOCAL_AXIS_ARMS: Tuple[str, ...] = ("左肩", "左腕", "左ひじ", "左手首", "右腕", "右肩", "右ひじ", "右手首") + AUTO_LOCAL_AXIS_FINGERS: Tuple[str, ...] = ("親指", "人指", "中指", "薬指", "小指") + AUTO_LOCAL_AXIS_SEMI_STANDARD_ARMS: Tuple[str, ...] = ("左腕捩", "左手捩", "左肩P", "左ダミー", "右腕捩", "右手捩", "右肩P", "右ダミー") - def __init__(self): + def __init__(self) -> None: raise NotImplementedError("This class cannot be instantiated.") @staticmethod - def find_pose_bone_by_bone_id(armature_object: bpy.types.Object, bone_id: int) -> Optional[bpy.types.PoseBone]: + def find_pose_bone_by_bone_id(armature_object: Object, bone_id: int) -> Optional[PoseBone]: + """Find a pose bone by its bone ID""" 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: bpy.types.Object) -> int: + def __new_bone_id(armature_object: Object) -> int: + """Generate a new unique bone ID""" return max(b.mmd_bone.bone_id for b in armature_object.pose.bones) + 1 @staticmethod - def get_or_assign_bone_id(pose_bone: bpy.types.PoseBone) -> int: + def get_or_assign_bone_id(pose_bone: PoseBone) -> int: + """Get the bone ID or assign a new one if not set""" 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: bpy.types.Object) -> Iterable[bpy.types.PoseBone]: + def __get_selected_pose_bones(armature_object: Object) -> Iterable[PoseBone]: + """Get selected pose bones from the armature""" if armature_object.mode == "EDIT": bpy.ops.object.mode_set(mode="OBJECT") # update selected bones bpy.ops.object.mode_set(mode="EDIT") # back to edit mode @@ -80,9 +89,11 @@ class FnBone: 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: bpy.types.Object, enable=True): + 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}") for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone: MMDBone = b.mmd_bone + mmd_bone = b.mmd_bone mmd_bone.enabled_fixed_axis = enable lock_rotation = b.lock_rotation[:] if enable: @@ -97,53 +108,66 @@ class FnBone: b.lock_location = b.lock_scale = (False, False, False) @staticmethod - def setup_special_bone_collections(armature_object: bpy.types.Object) -> bpy.types.Object: - armature: bpy.types.Armature = armature_object.data + def setup_special_bone_collections(armature_object: Object) -> Object: + """Set up special bone collections for MMD""" + armature = cast(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: bpy.types.BoneCollection) -> bool: + def __is_mmd_tools_bone_collection(bone_collection: BoneCollection) -> bool: + """Check if a bone collection is an MMD Tools collection""" return BONE_COLLECTION_CUSTOM_PROPERTY_NAME in bone_collection @staticmethod - def __is_special_bone_collection(bone_collection: bpy.types.BoneCollection) -> bool: + 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) @staticmethod - def __set_bone_collection_to_special(bone_collection: bpy.types.BoneCollection, is_visible: bool): + def __set_bone_collection_to_special(bone_collection: BoneCollection, is_visible: bool) -> None: + """Mark a bone collection as special""" 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: bpy.types.BoneCollection) -> bool: + 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) @staticmethod - def __set_bone_collection_to_normal(bone_collection: bpy.types.BoneCollection): + def __set_bone_collection_to_normal(bone_collection: BoneCollection) -> None: + """Mark a bone collection as normal""" bone_collection[BONE_COLLECTION_CUSTOM_PROPERTY_NAME] = BONE_COLLECTION_CUSTOM_PROPERTY_VALUE_NORMAL @staticmethod - def __set_edit_bone_to_special(edit_bone: bpy.types.EditBone, bone_collection_name: str) -> bpy.types.EditBone: + def __set_edit_bone_to_special(edit_bone: EditBone, bone_collection_name: str) -> EditBone: + """Set an edit bone to a special collection""" 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: bpy.types.EditBone) -> bpy.types.EditBone: + 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") return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_DUMMY) @staticmethod - def set_edit_bone_to_shadow(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone: + 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") return FnBone.__set_edit_bone_to_special(edit_bone, BONE_COLLECTION_NAME_SHADOW) @staticmethod - def __unassign_mmd_tools_bone_collections(edit_bone: bpy.types.EditBone) -> bpy.types.EditBone: + def __unassign_mmd_tools_bone_collections(edit_bone: EditBone) -> EditBone: + """Unassign an edit bone from all MMD Tools collections""" for bone_collection in edit_bone.collections: if not FnBone.__is_mmd_tools_bone_collection(bone_collection): continue @@ -151,18 +175,24 @@ class FnBone: return edit_bone @staticmethod - def sync_bone_collections_from_display_item_frames(armature_object: bpy.types.Object): - armature: bpy.types.Armature = armature_object.data + 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) bone_collections = armature.collections from .model import FnModel - root_object: bpy.types.Object = FnModel.find_root_object(armature_object) - mmd_root: MMDRoot = root_object.mmd_root + 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 bones = armature.bones - used_groups = set() - unassigned_bone_names = {b.name for b in bones} + used_groups: Set[str] = set() + unassigned_bone_names: Set[str] = {b.name for b in bones} for frame in mmd_root.display_item_frames: for item in frame.data: @@ -174,6 +204,7 @@ 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: @@ -192,32 +223,40 @@ class FnBone: 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: bpy.types.Object): - armature: bpy.types.Armature = armature_object.data - bone_collections: bpy.types.BoneCollections = armature.collections + 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 from .model import FnModel - root_object: bpy.types.Object = FnModel.find_root_object(armature_object) - mmd_root: MMDRoot = root_object.mmd_root + 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 display_item_frames = mmd_root.display_item_frames used_frame_index: Set[int] = set() - bone_collection: bpy.types.BoneCollection + bone_collection: 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: Optional[MMDDisplayItemFrame] = display_item_frames.get(bone_collection_name) + display_item_frame = 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)) @@ -232,23 +271,27 @@ 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: bpy.types.Object): - bone_map = {} + 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]] = {} for b in armature_object.pose.bones: if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_fixed_axis: continue - mmd_bone: MMDBone = b.mmd_bone + mmd_bone = 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: bpy.types.EditBone + bone: EditBone for bone in data.edit_bones: if bone.name not in bone_map: bone.select = False @@ -279,6 +322,7 @@ 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] @@ -286,9 +330,11 @@ 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: bpy.types.Object, enable=True): + 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}") for b in FnBone.__get_selected_pose_bones(armature_object): - mmd_bone: MMDBone = b.mmd_bone + mmd_bone = b.mmd_bone mmd_bone.enabled_local_axes = enable if enable: axes = b.bone.matrix_local.to_3x3().transposed() @@ -296,16 +342,18 @@ class FnBone: mmd_bone.local_axis_z = axes[2].xzy @staticmethod - def apply_bone_local_axes(armature_object: bpy.types.Object): - bone_map = {} + 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]] = {} for b in armature_object.pose.bones: if b.is_mmd_shadow_bone or not b.mmd_bone.enabled_local_axes: continue - mmd_bone: MMDBone = b.mmd_bone + mmd_bone = 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: bpy.types.EditBone + bone: EditBone for bone in data.edit_bones: if bone.name not in bone_map: bone.select = False @@ -313,15 +361,18 @@ 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: bpy.types.EditBone, mmd_local_axis_x, mmd_local_axis_z): + 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""" 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, mmd_local_axis_z): + def get_axes(mmd_local_axis_x: Vector, mmd_local_axis_z: Vector) -> Tuple[Vector, Vector, Vector]: + """Get axes from local axis vectors""" 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() @@ -329,21 +380,25 @@ class FnBone: return (x_axis, y_axis, z_axis) @staticmethod - def apply_auto_bone_roll(armature): - bone_names = [] + 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) with bpyutils.edit_object(armature) as data: - bone: bpy.types.EditBone + bone: 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): + def update_auto_bone_roll(edit_bone: EditBone) -> None: + """Update bone roll automatically""" # make a triangle face (p1,p2,p3) p1 = edit_bone.head.copy() p2 = edit_bone.tail.copy() @@ -364,7 +419,8 @@ class FnBone: FnBone.update_bone_roll(edit_bone, y.xzy, x.xzy) @staticmethod - def has_auto_local_axis(name_j): + def has_auto_local_axis(name_j: str) -> bool: + """Check if a bone should have automatic local axis""" 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 @@ -374,9 +430,11 @@ class FnBone: return False @staticmethod - def clean_additional_transformation(armature_object: bpy.types.Object): + def clean_additional_transformation(armature_object: Object) -> None: + """Clean additional transformation constraints and bones""" + logger.info(f"Cleaning additional transformations for {armature_object.name}") # clean constraints - p_bone: bpy.types.PoseBone + p_bone: PoseBone for p_bone in armature_object.pose.bones: p_bone.mmd_bone.is_additional_transform_dirty = True constraints = p_bone.constraints @@ -392,17 +450,21 @@ class FnBone: "ADDITIONAL_TRANSFORM_INVERT", } - def __is_at_shadow_bone(b): + def __is_at_shadow_bone(b: PoseBone) -> bool: 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: bpy.types.Object): - def __is_dirty_bone(b): + 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: if b.is_mmd_shadow_bone: return False mmd_bone = b.mmd_bone @@ -411,9 +473,10 @@ 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 = [] + shadow_bone_pool: List[Union[_AT_ShadowBoneRemove, _AT_ShadowBoneCreate]] = [] for p_bone in dirty_bones: sb = FnBone.__setup_constraints(p_bone) if sb: @@ -434,7 +497,8 @@ class FnBone: p_bone.mmd_bone.is_additional_transform_dirty = False @staticmethod - def __setup_constraints(p_bone): + def __setup_constraints(p_bone: PoseBone) -> Optional[Union['_AT_ShadowBoneRemove', '_AT_ShadowBoneCreate']]: + """Set up constraints for additional transformation""" bone_name = p_bone.name mmd_bone = p_bone.mmd_bone influence = mmd_bone.additional_transform_influence @@ -447,12 +511,14 @@ 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, mute, map_type, value): + def __config(name: str, mute: bool, map_type: str, value: float) -> None: if mute: remove_constraint(constraints, name) return @@ -467,62 +533,81 @@ class FnBone: return shadow_bone @staticmethod - def update_additional_transform_influence(pose_bone: bpy.types.PoseBone): + def update_additional_transform_influence(pose_bone: PoseBone) -> None: + """Update the influence of additional transform constraints""" 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: bpy.types.Object): - pose_bone: bpy.types.PoseBone + 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 for pose_bone in armature_object.pose.bones: - constraint: bpy.types.Constraint + constraint: 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: - def __init__(self, bone_name): + """Handler for removing shadow bones""" + + def __init__(self, bone_name: str) -> None: + """Initialize with bone name""" self.__shadow_bone_names = ("_dummy_" + bone_name, "_shadow_" + bone_name) - def update_edit_bones(self, edit_bones): + def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None: + """Update edit bones by removing shadow 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): + def update_pose_bones(self, pose_bones: Any) -> None: + """Update pose bones (no-op for removal)""" pass class _AT_ShadowBoneCreate: - def __init__(self, bone_name, target_bone_name): + """Handler for creating shadow bones""" + + def __init__(self, bone_name: str, target_bone_name: str) -> None: + """Initialize with bone names""" 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 = [] + self.__constraint_pool: List[Constraint] = [] - def __is_well_aligned(self, bone0, bone1): + def __is_well_aligned(self, bone0: EditBone, bone1: EditBone) -> bool: + """Check if two bones are well aligned""" 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=True): + def __update_constraints(self, use_shadow: bool = True) -> None: + """Update constraints to use shadow or target bone""" 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): + def add_constraint(self, constraint: Constraint) -> None: + """Add a constraint to the pool""" self.__constraint_pool.append(constraint) - def update_edit_bones(self, edit_bones): + def update_edit_bones(self, edit_bones: bpy.types.ArmatureEditBones) -> None: + """Update edit bones by creating shadow 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 @@ -532,6 +617,7 @@ 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)) @@ -539,12 +625,15 @@ 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): + def update_pose_bones(self, pose_bones: Any) -> None: + """Update pose bones by setting up shadow bone properties""" 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" @@ -560,5 +649,7 @@ 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 9c5b2bd..4c45d80 100644 --- a/core/mmd/core/camera.py +++ b/core/mmd/core/camera.py @@ -6,16 +6,19 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import math -from typing import Optional +from typing import Optional, List, Tuple, Callable, Any, Union import bpy +from bpy.types import Object, ID, Camera, Context +from mathutils import Vector, Matrix, Euler from ..bpyutils import FnContext, Props - +from ....core.logging_setup import logger class FnCamera: @staticmethod - def find_root(obj: bpy.types.Object) -> Optional[bpy.types.Object]: + def find_root(obj: Optional[Object]) -> Optional[Object]: + """Find the root object of an MMD camera setup.""" if obj is None: return None if FnCamera.is_mmd_camera_root(obj): @@ -25,16 +28,22 @@ class FnCamera: return None @staticmethod - def is_mmd_camera(obj: bpy.types.Object) -> bool: + def is_mmd_camera(obj: Object) -> bool: + """Check if an object is an MMD camera.""" return obj.type == "CAMERA" and FnCamera.find_root(obj.parent) is not None @staticmethod - def is_mmd_camera_root(obj: bpy.types.Object) -> bool: + def is_mmd_camera_root(obj: Object) -> bool: + """Check if an object is an MMD camera root.""" return obj.type == "EMPTY" and obj.mmd_type == "CAMERA" @staticmethod - def add_drivers(camera_object: bpy.types.Object): - def __add_driver(id_data: bpy.types.ID, data_path: str, expression: str, index: int = -1): + 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.""" d = id_data.driver_add(data_path, index).driver d.type = "SCRIPTED" if "$empty_distance" in expression: @@ -72,22 +81,36 @@ class FnCamera: d.expression = expression - __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") + 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 as e: + logger.error(f"Failed to add drivers to camera {camera_object.name}: {str(e)}") @staticmethod - 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("ortho_scale") - camera_object.data.driver_remove("lens") + 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 as e: + logger.error(f"Failed to remove drivers from camera {camera_object.name}: {str(e)}") class MigrationFnCamera: @staticmethod - def update_mmd_camera(): + def update_mmd_camera() -> None: + """Update all MMD cameras in the scene.""" + logger.info("Updating all MMD cameras in the scene") + updated_count = 0 + for camera_object in bpy.data.objects: if camera_object.type != "CAMERA": continue @@ -97,161 +120,203 @@ class MigrationFnCamera: # It's not a MMD Camera continue - FnCamera.remove_drivers(camera_object) - FnCamera.add_drivers(camera_object) + try: + FnCamera.remove_drivers(camera_object) + FnCamera.add_drivers(camera_object) + updated_count += 1 + except Exception as e: + logger.error(f"Failed to update MMD camera {camera_object.name}: {str(e)}") + + logger.info(f"Updated {updated_count} MMD cameras") class MMDCamera: - def __init__(self, obj): + def __init__(self, obj: Object): + """Initialize an MMD camera.""" root_object = FnCamera.find_root(obj) if root_object is None: - raise ValueError("%s is not MMDCamera" % str(obj)) + logger.error(f"Object {obj.name} is not an MMD camera") + raise ValueError(f"{obj.name} is not an MMD camera") self.__emptyObj = getattr(root_object, "original", obj) + logger.debug(f"Initialized MMD camera with root: {self.__emptyObj.name}") @staticmethod - def isMMDCamera(obj: bpy.types.Object) -> bool: + def isMMDCamera(obj: Object) -> bool: + """Check if an object is an MMD camera.""" return FnCamera.find_root(obj) is not None @staticmethod - def addDrivers(cameraObj: bpy.types.Object): + def addDrivers(cameraObj: Object) -> None: + """Add drivers to the camera object.""" FnCamera.add_drivers(cameraObj) @staticmethod - def removeDrivers(cameraObj: bpy.types.Object): + def removeDrivers(cameraObj: Object) -> None: + """Remove drivers from the camera object. """ if cameraObj.type != "CAMERA": return FnCamera.remove_drivers(cameraObj) @staticmethod - def convertToMMDCamera(cameraObj: bpy.types.Object, scale=1.0): + 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}") + if FnCamera.is_mmd_camera(cameraObj): + logger.debug(f"Camera {cameraObj.name} is already an MMD camera") return MMDCamera(cameraObj) - empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) - FnContext.link_object(FnContext.ensure_context(), empty) + try: + empty = bpy.data.objects.new(name="MMD_Camera", object_data=None) + context = FnContext.ensure_context() + FnContext.link_object(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 - return MMDCamera(empty) + 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 as e: + logger.error(f"Failed to convert camera {cameraObj.name} to MMD camera: {str(e)}") + raise @staticmethod - 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 + 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 - _camera_override_func = None - if cameraObj is None: - if scene.camera is None: - scene.camera = mmd_cam - return MMDCamera(mmd_cam_root) - _camera_override_func = lambda: scene.camera + _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 - _target_override_func = None - if cameraTarget is None: - _target_override_func = lambda camObj: camObj.data.dof.focus_object or camObj + _target_override_func: Optional[Callable[[Object], Object]] = None + if cameraTarget is None: + _target_override_func = lambda camObj: 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 math import atan + from mathutils import Matrix, Vector - 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 = [] + for i in range(3): + fcurves.append(parent_action.fcurves.new(data_path="location", index=i)) # x, y, z + for i in range(3): + fcurves.append(parent_action.fcurves.new(data_path="rotation_euler", index=i)) # 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) - fcurves = [] - for i in range(3): - fcurves.append(parent_action.fcurves.new(data_path="location", index=i)) # x, y, z - for i in range(3): - fcurves.append(parent_action.fcurves.new(data_path="rotation_euler", index=i)) # 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) + 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 - 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 + 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": - 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 *= factor * ratio + else: # cameraObj.data.sensor_fit == 'AUTO' + tan_val *= min(ratio, factor * ratio) - 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) + 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" - 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" + 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 as e: + logger.error(f"Failed to create MMD camera animation: {str(e)}") + raise - 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): + def object(self) -> Object: + """Get the root object of the MMD camera.""" return self.__emptyObj - def camera(self): + def camera(self) -> Object: + """Get the camera object of the MMD camera.""" for i in self.__emptyObj.children: if i.type == "CAMERA": return i - raise KeyError + 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}") diff --git a/core/mmd/core/lamp.py b/core/mmd/core/lamp.py index 549a83b..944ee4d 100644 --- a/core/mmd/core/lamp.py +++ b/core/mmd/core/lamp.py @@ -6,36 +6,48 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. 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): + def __init__(self, obj: Object) -> None: if MMDLamp.isLamp(obj): obj = obj.parent if obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT": - self.__emptyObj = obj + self.__emptyObj: Object = obj else: - raise ValueError("%s is not MMDLamp" % str(obj)) + error_msg = f"{str(obj)} is not MMDLamp" + logger.error(error_msg) + raise ValueError(error_msg) @staticmethod - def isLamp(obj): - return obj and obj.type in {"LIGHT", "LAMP"} + 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"} @staticmethod - def isMMDLamp(obj): + def isMMDLamp(obj: Optional[Object]) -> bool: + """Check if the object is an MMD lamp""" if MMDLamp.isLamp(obj): obj = obj.parent - return obj and obj.type == "EMPTY" and obj.mmd_type == "LIGHT" + return obj is not None and obj.type == "EMPTY" and obj.mmd_type == "LIGHT" @staticmethod - def convertToMMDLamp(lampObj, scale=1.0): + def convertToMMDLamp(lampObj: Object, scale: float = 1.0) -> 'MMDLamp': + """Convert a regular lamp to an MMD lamp""" if MMDLamp.isMMDLamp(lampObj): + logger.debug(f"Object {lampObj.name} is already an MMD lamp") return MMDLamp(lampObj) - empty = bpy.data.objects.new(name="MMD_Light", object_data=None) - FnContext.link_object(FnContext.ensure_context(), empty) + 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.rotation_mode = "XYZ" empty.lock_rotation = (True, True, True) @@ -57,13 +69,18 @@ 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): + def object(self) -> Object: + """Get the empty object that represents this MMD lamp""" return self.__emptyObj - def lamp(self): + def lamp(self) -> Object: + """Get the actual lamp/light object""" for i in self.__emptyObj.children: if MMDLamp.isLamp(i): return i - raise KeyError + error_msg = f"No lamp found in MMD lamp {self.__emptyObj.name}" + logger.error(error_msg) + raise KeyError(error_msg) diff --git a/core/mmd/core/material.py b/core/mmd/core/material.py index 68fba09..6706e7e 100644 --- a/core/mmd/core/material.py +++ b/core/mmd/core/material.py @@ -7,7 +7,7 @@ import logging import os -from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, cast, Dict, List, Any, Union, Set import bpy from mathutils import Vector @@ -15,6 +15,7 @@ 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 @@ -27,48 +28,53 @@ SPHERE_MODE_SUBTEX = 3 class _DummyTexture: - def __init__(self, image): - self.type = "IMAGE" - self.image = image - self.use_mipmap = True + def __init__(self, image: bpy.types.Image): + self.type: str = "IMAGE" + self.image: bpy.types.Image = image + self.use_mipmap: bool = True class _DummyTextureSlot: - def __init__(self, image): - self.diffuse_color_factor = 1 - self.uv_layer = "" - self.texture = _DummyTexture(image) + def __init__(self, image: bpy.types.Image): + self.diffuse_color_factor: float = 1 + self.uv_layer: str = "" + self.texture: _DummyTexture = _DummyTexture(image) class FnMaterial: __NODES_ARE_READONLY: bool = False def __init__(self, material: bpy.types.Material): - self.__material = material - self._nodes_are_readonly = FnMaterial.__NODES_ARE_READONLY + self.__material: bpy.types.Material = material + self._nodes_are_readonly: bool = FnMaterial.__NODES_ARE_READONLY @staticmethod - def set_nodes_are_readonly(nodes_are_readonly: bool): + def set_nodes_are_readonly(nodes_are_readonly: bool) -> None: FnMaterial.__NODES_ARE_READONLY = nodes_are_readonly @classmethod - def from_material_id(cls, material_id: str): + def from_material_id(cls, material_id: str) -> Optional['FnMaterial']: for material in bpy.data.materials: if material.mmd_material.material_id == material_id: return cls(material) return None @staticmethod - def clean_materials(obj, can_remove: Callable[[bpy.types.Material], bool]): + def clean_materials(obj: bpy.types.Object, can_remove: Callable[[bpy.types.Material], bool]) -> None: 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: str | int, mat2_ref: str | int, reverse=False, swap_slots=False) -> Tuple[bpy.types.Material, bpy.types.Material]: + 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]: """ This method will assign the polygons of mat1 to mat2. If reverse is True it will also swap the polygons assigned to mat2 to mat1. @@ -98,8 +104,12 @@ class FnMaterial: except (KeyError, IndexError) as exc: # Wrap exceptions within our custom ones 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: @@ -113,33 +123,37 @@ class FnMaterial: return mat1, mat2 @staticmethod - def fixMaterialOrder(meshObj: bpy.types.Object, material_names: Iterable[str]): + 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}") + 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): - mmd_mat: MMDMaterial = self.__material.mmd_material + def material_id(self) -> int: + 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): + def material(self) -> bpy.types.Material: return self.__material - def __same_image_file(self, image, filepath): + def __same_image_file(self, image: Optional[bpy.types.Image], filepath: str) -> bool: if image and image.source == "FILE": # pylint: disable=assignment-from-no-return img_filepath = bpy.path.abspath(image.filepath) # image.filepath_from_user() @@ -152,14 +166,15 @@ class FnMaterial: pass return False - def _load_image(self, filepath): + def _load_image(self, filepath: str) -> bpy.types.Image: 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: - logging.warning("Cannot create a texture for %s. No such file.", filepath) + logger.warning(f"Cannot create a texture for {filepath}. No such file.") img = bpy.data.images.new(os.path.basename(filepath), 1, 1) img.source = "FILE" img.filepath = filepath @@ -170,43 +185,46 @@ class FnMaterial: img.alpha_mode = "NONE" return img - def update_toon_texture(self): + def update_toon_texture(self) -> None: 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)) 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): + def _mix_diffuse_and_ambient(self, mmd_mat: 'MMDMaterial') -> List[float]: 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): + def update_drop_shadow(self) -> None: pass - def update_enabled_toon_edge(self): + def update_enabled_toon_edge(self) -> None: if self._nodes_are_readonly: return self.update_edge_color() - def update_edge_color(self): + def update_edge_color(self) -> None: 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: bpy.types.Material = bpy.data.materials.get("mmd_edge." + mat.name, None) + mat_edge: Optional[bpy.types.Material] = bpy.data.materials.get("mmd_edge." + mat.name, None) if mat_edge: mat_edge.mmd_material.edge_color = line_color @@ -217,39 +235,46 @@ 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): + def update_edge_weight(self) -> None: pass - def get_texture(self): + def get_texture(self) -> Optional[_DummyTexture]: return self.__get_texture_node("mmd_base_tex", use_dummy=True) - def create_texture(self, filepath): + def create_texture(self, filepath: str) -> _DummyTextureSlot: 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): + def remove_texture(self) -> None: 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): + def get_sphere_texture(self) -> Optional[_DummyTexture]: return self.__get_texture_node("mmd_sphere_tex", use_dummy=True) - def use_sphere_texture(self, use_sphere, obj=None): + def use_sphere_texture(self, use_sphere: bool, obj: Optional[bpy.types.Object] = None) -> 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, obj=None): + def create_sphere_texture(self, filepath: str, obj: Optional[bpy.types.Object] = None) -> _DummyTextureSlot: 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=None): + def update_sphere_texture_type(self, obj: Optional[bpy.types.Object] = None) -> None: if self._nodes_are_readonly: return sphere_texture_type = int(self.material.mmd_material.sphere_texture_type) @@ -277,48 +302,54 @@ class FnMaterial: next(uv_layers, None) # skip base UV subtex_uv = getattr(next(uv_layers, None), "name", "") if subtex_uv != "UV1": - logging.info(' * material(%s): object "%s" use UV "%s" for SubTex', mat.name, obj.name, subtex_uv) + logger.info(f'Material({mat.name}): object "{obj.name}" use UV "{subtex_uv}" for SubTex') 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): + def remove_sphere_texture(self) -> None: 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): + def get_toon_texture(self) -> Optional[_DummyTexture]: return self.__get_texture_node("mmd_toon_tex", use_dummy=True) - def use_toon_texture(self, use_toon): + def use_toon_texture(self, use_toon: bool) -> None: 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): + def create_toon_texture(self, filepath: str) -> _DummyTextureSlot: 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): + def remove_toon_texture(self) -> None: 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, use_dummy=False): + def __get_texture_node(self, node_name: str, use_dummy: bool = False) -> Optional[Union[bpy.types.ShaderNodeTexImage, _DummyTexture]]: 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): + def __remove_texture_node(self, node_name: str) -> None: 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, filepath, pos): + def __create_texture_node(self, node_name: str, filepath: str, pos: Tuple[float, float]) -> bpy.types.ShaderNodeTexImage: texture = self.__get_texture_node(node_name) if texture is None: from mathutils import Vector @@ -334,23 +365,25 @@ class FnMaterial: self.__update_shader_nodes() return texture - def update_ambient_color(self): + def update_ambient_color(self) -> None: 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): + def update_diffuse_color(self) -> None: 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): + def update_alpha(self) -> None: if self._nodes_are_readonly: return mat = self.material @@ -368,16 +401,18 @@ 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): + def update_specular_color(self) -> None: 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): + def update_shininess(self) -> None: if self._nodes_are_readonly: return mat = self.material @@ -388,8 +423,9 @@ class FnMaterial: 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): + def update_is_double_sided(self) -> None: if self._nodes_are_readonly: return mat = self.material @@ -399,8 +435,9 @@ 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): + def update_self_shadow_map(self) -> None: if self._nodes_are_readonly: return mat = self.material @@ -408,21 +445,24 @@ 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): + def update_self_shadow(self) -> None: 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, context=bpy.context): + def convert_to_mmd_material(material: bpy.types.Material, context: bpy.types.Context = bpy.context) -> None: 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): + def search_tex_image_node(node: bpy.types.ShaderNode) -> Optional[bpy.types.ShaderNodeTexImage]: if node.type == "TEX_IMAGE": return node for node_input in node.inputs: @@ -459,6 +499,7 @@ 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 @@ -466,6 +507,7 @@ class FnMaterial: if bsdf_node: 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] @@ -498,9 +540,10 @@ class FnMaterial: 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_')] 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, val): + def __update_shader_input(self, name: str, val: Any) -> None: mat = self.material if mat.name.startswith("mmd_"): # skip mmd_edge.* return @@ -512,26 +555,29 @@ 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): + def __update_shader_nodes(self) -> None: mat = self.material if mat.node_tree is None: + logger.debug(f"Creating node tree for {mat.name}") mat.use_nodes = True mat.node_tree.nodes.clear() nodes, links = mat.node_tree.nodes, mat.node_tree.links class _Dummy: - default_value, is_linked = None, True + default_value: Any = None + is_linked: bool = 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.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,) @@ -543,6 +589,7 @@ 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)) @@ -567,12 +614,13 @@ 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): + def __get_shader_uv(self) -> bpy.types.ShaderNodeTree: 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) ############################################################################ @@ -604,12 +652,13 @@ class FnMaterial: return shader - def __get_shader(self): + def __get_shader(self) -> bpy.types.ShaderNodeTree: 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) ############################################################################ @@ -699,15 +748,18 @@ class FnMaterial: class MigrationFnMaterial: @staticmethod - def update_mmd_shader(): + def update_mmd_shader() -> None: 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 @@ -716,3 +768,11 @@ 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 103d52f..ab22433 100644 --- a/core/mmd/core/model.py +++ b/core/mmd/core/model.py @@ -6,9 +6,8 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import itertools -import logging import time -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Optional, Set, TypeGuard, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, Optional, Set, TypeGuard, Union, cast, List, Tuple import bpy import idprop @@ -20,15 +19,17 @@ from ..bpyutils import FnContext, Props from . import rigid_body from .morph import FnMorph from .rigid_body import MODE_DYNAMIC, MODE_DYNAMIC_BONE, MODE_STATIC +from ....core.logging_setup import logger if TYPE_CHECKING: from ..properties.morph import MaterialMorphData from ..properties.rigid_body import MMDRigidBody + from bpy.types import Context, Object, PropertyGroup, Material, Mesh, Armature, EditBone, PoseBone, KinematicConstraint class FnModel: @staticmethod - 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): + 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: FnModel.__copy_property(destination_root_object.mmd_root, source_root_object.mmd_root, overwrite=overwrite, replace_name2values=replace_name2values or {}) @staticmethod @@ -40,9 +41,11 @@ 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 and obj.mmd_type != "ROOT": + while obj is not None: + if hasattr(obj, 'mmd_type') and obj.mmd_type == "ROOT": + return obj obj = obj.parent - return obj + return None @staticmethod def find_armature_object(root_object: bpy.types.Object) -> Optional[bpy.types.Object]: @@ -213,7 +216,8 @@ 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]): + 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, @@ -221,7 +225,7 @@ class FnModel: ): bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - def _change_bone_id(bone: bpy.types.PoseBone, new_bone_id: int, bone_morphs, pose_bones): + 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 @@ -259,6 +263,7 @@ class FnModel: child_root_object: bpy.types.Object for child_root_object in child_root_objects: + logger.info(f"Processing child root: {child_root_object.name}") child_armature_object = FnModel.find_armature_object(child_root_object) child_pose_bones = child_armature_object.pose.bones child_bone_morphs = child_root_object.mmd_root.bone_morphs @@ -279,7 +284,7 @@ class FnModel: 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] = {} + related_meshes: Dict['MaterialMorphData', bpy.types.Mesh] = {} 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: @@ -352,6 +357,8 @@ class FnModel: # 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") @staticmethod def _add_armature_modifier(mesh_object: bpy.types.Object, armature_object: bpy.types.Object) -> bpy.types.ArmatureModifier: @@ -369,10 +376,13 @@ class FnModel: return modifier @staticmethod - def attach_mesh_objects(parent_root_object: bpy.types.Object, mesh_objects: Iterable[bpy.types.Object], add_armature_modifier: bool): + 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}") armature_object = FnModel.find_armature_object(parent_root_object) if armature_object is None: - raise ValueError(f"Armature object not found in {parent_root_object}") + error_msg = f"Armature object not found in {parent_root_object.name}" + logger.error(error_msg) + raise ValueError(error_msg) def __get_root_object(obj: bpy.types.Object) -> bpy.types.Object: if obj.parent is None: @@ -381,9 +391,11 @@ 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) @@ -391,15 +403,20 @@ 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): + 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: - raise ValueError(f"Armature object not found in {root_object}") + 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() @@ -408,6 +425,7 @@ class FnModel: 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 @@ -419,28 +437,34 @@ class FnModel: 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): + 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}") 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"): iterations = int(constraint.iterations * new_ik_loop_factor / old_ik_loop_factor) - logging.info("Update %s of %s: %d -> %d", constraint.name, pose_bone.name, constraint.iterations, iterations) + logger.debug(f"Update {constraint.name} of {pose_bone.name}: {constraint.iterations} -> {iterations}") constraint.iterations = iterations + updated_count += 1 mmd_root.ik_loop_factor = new_ik_loop_factor - - return + logger.info(f"Updated {updated_count} IK constraints") @staticmethod - def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]): + def __copy_property_group(destination: bpy.types.PropertyGroup, source: bpy.types.PropertyGroup, overwrite: bool, replace_name2values: Dict[str, Dict[Any, Any]]) -> None: destination_rna_properties = destination.bl_rna.properties for name in source.keys(): is_attr = hasattr(source, name) @@ -466,7 +490,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]]): + 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: if overwrite: destination.clear() @@ -499,16 +523,19 @@ 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]]): + 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: 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: - raise ValueError(f"Unsupported destination: {destination}") + error_msg = f"Unsupported destination: {destination}" + logger.error(error_msg) + raise ValueError(error_msg) @staticmethod - def initalize_display_item_frames(root_object: bpy.types.Object, reset: bool = True): + 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}") frames = root_object.mmd_root.display_item_frames if reset and len(frames) > 0: root_object.mmd_root.active_display_item_frame = 0 @@ -531,6 +558,8 @@ 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: @@ -541,19 +570,28 @@ class MigrationFnModel: """Migration Functions for old MMD models broken by bugs or issues""" @classmethod - def update_mmd_ik_loop_factor(cls): + def update_mmd_ik_loop_factor(cls) -> None: + logger.info("Updating MMD IK loop factor for all armatures") + updated_count = 0 for armature_object in bpy.data.objects: if armature_object.type != "ARMATURE": continue if "mmd_ik_loop_factor" not in armature_object: - return + continue - 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"] + 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") @staticmethod - def update_avatar_toolkit_version(): + def update_avatar_toolkit_version() -> None: + logger.info("Updating Avatar Toolkit version for all MMD root objects") + updated_count = 0 for root_object in bpy.data.objects: if root_object.type != "EMPTY": continue @@ -565,10 +603,13 @@ class MigrationFnModel: continue root_object["avatar_toolkit_version"] = "0.2.1" + updated_count += 1 + + logger.info(f"Updated Avatar Toolkit version for {updated_count} root objects") class Model: - def __init__(self, root_obj): + def __init__(self, root_obj: bpy.types.Object) -> None: if root_obj is None: raise ValueError("must be MMD ROOT type object") if root_obj.mmd_type != "ROOT": @@ -578,13 +619,15 @@ 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): + 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': 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" @@ -595,6 +638,7 @@ class Model: 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 @@ -602,6 +646,7 @@ 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) @@ -614,6 +659,7 @@ class Model: FnBone.setup_special_bone_collections(armature_object) if add_root_bone: + logger.debug("Adding root bone") bone_name = "全ての親" bone_name_english = "Root" @@ -637,34 +683,37 @@ 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=True): + def initialDisplayFrames(self, reset: bool = True) -> None: FnModel.initalize_display_item_frames(self.__root, reset=reset) @property - def morph_slider(self): + def morph_slider(self) -> Any: return FnMorph.get_morph_slider(self) - def loadMorphs(self): + def loadMorphs(self) -> None: + logger.info(f"Loading morphs for model: {self.__root.name}") FnMorph.load_morphs(self) - def create_ik_constraint(self, bone, ik_target): + def create_ik_constraint(self, bone: bpy.types.PoseBone, ik_target: bpy.types.PoseBone) -> bpy.types.KinematicConstraint: """create IK constraint Args: bone: A pose bone to add a IK constraint - id_target: A pose bone for IK target + ik_target: A pose bone for IK target Returns: The bpy.types.KinematicConstraint object created. It is set target 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 @@ -693,6 +742,7 @@ 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" @@ -710,6 +760,7 @@ 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" @@ -727,6 +778,7 @@ 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" @@ -740,7 +792,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): + def attachMeshes(self, meshes: Iterator[bpy.types.Object], add_armature_modifier: bool = True) -> None: FnModel.attach_mesh_objects(self.rootObject(), meshes, add_armature_modifier) def firstMesh(self) -> Optional[bpy.types.Object]: @@ -748,7 +800,7 @@ class Model: return i return None - def findMesh(self, mesh_name) -> Optional[bpy.types.Object]: + def findMesh(self, mesh_name: str) -> Optional[bpy.types.Object]: """ Helper method to find a mesh by name """ @@ -787,25 +839,26 @@ class Model: def joints(self) -> Iterator[bpy.types.Object]: return FnModel.iterate_joint_objects(self.__root) - def temporaryObjects(self, rigid_track_only=False) -> Iterator[bpy.types.Object]: + def temporaryObjects(self, rigid_track_only: bool = 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 = {} # Use dict instead of set to guarantee preserve order + materials: Dict[bpy.types.Material, int] = {} # 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, new_bone_name): + def renameBone(self, old_bone_name: str, new_bone_name: str) -> None: 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 + new_bone_name = bone.name # Get the actual name (might be adjusted by Blender) mmd_root = self.rootObject().mmd_root for frame in mmd_root.display_item_frames: @@ -816,28 +869,31 @@ 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=1.5, collision_margin=1e-06): + 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}") 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 - logging.info("****************************************") - logging.info(" Build rig") - logging.info("****************************************") + logger.info("****************************************") + logger.info(" Build rig") + logger.info("****************************************") start_time = time.time() self.__preBuild() self.disconnectPhysicsBones() self.buildRigids(non_collision_distance_scale, collision_margin) self.buildJoints() self.__postBuild() - logging.info(" Finished building in %f seconds.", time.time() - start_time) + logger.info(" Finished building in %f seconds.", time.time() - start_time) rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) - def clean(self): + def clean(self) -> None: + logger.info(f"Cleaning physics rig for {self.__root.name}") rigidbody_world_enabled = rigid_body.setRigidBodyWorldEnabled(False) - logging.info("****************************************") - logging.info(" Clean rig") - logging.info("****************************************") + logger.info("****************************************") + logger.info(" Clean rig") + logger.info("****************************************") start_time = time.time() pose_bones = [] @@ -848,13 +904,14 @@ 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(): rigid_type = int(i.mmd_rigid.type) if "mmd_tools_rigid_parent" not in i.constraints: rigid_track_counts += 1 - logging.info('%3d# Create a "CHILD_OF" constraint for %s', rigid_track_counts, i.name) + logger.info('%3d# Create a "CHILD_OF" constraint for %s', rigid_track_counts, i.name) i.mmd_rigid.bone = i.mmd_rigid.bone relation = i.constraints["mmd_tools_rigid_parent"] relation.mute = True @@ -884,35 +941,39 @@ class Model: mmd_root = self.rootObject().mmd_root if mmd_root.show_temporary_objects: mmd_root.show_temporary_objects = False - logging.info(" Finished cleaning in %f seconds.", time.time() - start_time) + logger.info(" Finished cleaning in %f seconds.", time.time() - start_time) mmd_root.is_built = False rigid_body.setRigidBodyWorldEnabled(rigidbody_world_enabled) - def __removeTemporaryObjects(self): + def __removeTemporaryObjects(self) -> None: + logger.debug("Removing temporary objects") with bpy.context.temp_override(selected_objects=tuple(self.temporaryObjects()), active_object=self.rootObject()): bpy.ops.object.delete() - def __restoreTransforms(self, obj): + def __restoreTransforms(self, obj: bpy.types.Object) -> None: for attr in ("location", "rotation_euler"): attr_name = "__backup_%s__" % 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}") - def __backupTransforms(self, obj): + def __backupTransforms(self, obj: bpy.types.Object) -> None: for attr in ("location", "rotation_euler"): attr_name = "__backup_%s__" % 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): - self.__fake_parent_map = {} - self.__rigid_body_matrix_map = {} - self.__empty_parent_map = {} + 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] = {} - no_parents = [] + no_parents: List[bpy.types.Object] = [] for i in self.rigidBodies(): self.__backupTransforms(i) # mute relation @@ -932,7 +993,7 @@ class Model: # update changes of armature constraints bpy.context.scene.frame_set(bpy.context.scene.frame_current) - parented = [] + parented: List[bpy.types.Object] = [] for i in self.joints(): self.__backupTransforms(i) rbc = i.rigid_body_constraint @@ -950,7 +1011,8 @@ class Model: # assert(len(no_parents) == len(parented)) - def __postBuild(self): + def __postBuild(self) -> None: + logger.debug("Post-build finalization") self.__fake_parent_map = None self.__rigid_body_matrix_map = None @@ -962,6 +1024,7 @@ 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() @@ -970,11 +1033,13 @@ 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): + def updateRigid(self, rigid_obj: bpy.types.Object, collision_margin: float) -> None: 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 @@ -1018,7 +1083,7 @@ class Model: fake_children = self.__fake_parent_map.get(rigid_obj, None) if fake_children: for fake_child in fake_children: - logging.debug(" - fake_child: %s", fake_child.name) + logger.debug(" - fake_child: %s", fake_child.name) t, r, s = (m @ fake_child.matrix_local).decompose() fake_child.location = t fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) @@ -1032,7 +1097,7 @@ class Model: fake_children = self.__fake_parent_map.get(rigid_obj, None) if fake_children: for fake_child in fake_children: - logging.debug(" - fake_child: %s", fake_child.name) + logger.debug(" - fake_child: %s", fake_child.name) t, r, s = (m @ fake_child.matrix_local).decompose() fake_child.location = t fake_child.rotation_euler = r.to_euler(fake_child.rotation_mode) @@ -1062,7 +1127,7 @@ class Model: ori_rigid_obj = self.__empty_parent_map[empty] ori_rb = ori_rigid_obj.rigid_body if ori_rb and rb.mass > ori_rb.mass: - logging.debug(" * Bone (%s): change target from [%s] to [%s]", target_bone.name, ori_rigid_obj.name, rigid_obj.name) + logger.debug(" * Bone (%s): change target from [%s] to [%s]", target_bone.name, ori_rigid_obj.name, rigid_obj.name) # re-parenting rigid_obj.mmd_rigid.bone = bone_name rigid_obj.constraints.remove(relation) @@ -1070,21 +1135,22 @@ class Model: # revert change ori_rigid_obj.mmd_rigid.bone = bone_name else: - logging.debug(" * Bone (%s): track target [%s]", target_bone.name, ori_rigid_obj.name) + 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): + def __getRigidRange(self, obj: bpy.types.Object) -> float: return (Vector(obj.bound_box[0]) - Vector(obj.bound_box[6])).length - def __createNonCollisionConstraint(self, nonCollisionJointTable): + def __createNonCollisionConstraint(self, nonCollisionJointTable: List[Tuple[bpy.types.Object, bpy.types.Object]]) -> None: total_len = len(nonCollisionJointTable) if total_len < 1: return start_time = time.time() - logging.debug("-" * 60) - logging.debug(" creating ncc, counts: %d", total_len) + logger.debug("-" * 60) + logger.debug(" creating ncc, counts: %d", total_len) ncc_obj = bpyutils.createObject(name="ncc", object_data=None) ncc_obj.location = [0, 0, 0] @@ -1099,26 +1165,26 @@ class Model: rb.disable_collisions = True ncc_objs = bpyutils.duplicateObject(ncc_obj, total_len) - logging.debug(" created %d ncc.", len(ncc_objs)) + logger.debug(" created %d ncc.", len(ncc_objs)) for ncc_obj, pair in zip(ncc_objs, nonCollisionJointTable): rbc = ncc_obj.rigid_body_constraint rbc.object1, rbc.object2 = pair ncc_obj.hide_set(True) ncc_obj.hide_select = True - logging.debug(" finish in %f seconds.", time.time() - start_time) - logging.debug("-" * 60) + logger.debug(" finish in %f seconds.", time.time() - start_time) + logger.debug("-" * 60) - def buildRigids(self, non_collision_distance_scale, collision_margin): - logging.debug("--------------------------------") - logging.debug(" Build riggings of rigid bodies") - logging.debug("--------------------------------") + def buildRigids(self, non_collision_distance_scale: float, collision_margin: float) -> List[bpy.types.Object]: + logger.debug("--------------------------------") + logger.debug(" Build riggings of rigid bodies") + logger.debug("--------------------------------") rigid_objects = list(self.rigidBodies()) - rigid_object_groups = [[] for i in range(16)] + rigid_object_groups: List[List[bpy.types.Object]] = [[] for i in range(16)] for i in rigid_objects: rigid_object_groups[i.mmd_rigid.collision_group_number].append(i) - jointMap = {} + jointMap: Dict[frozenset, bpy.types.Object] = {} for joint in self.joints(): rbc = joint.rigid_body_constraint if rbc is None: @@ -1126,10 +1192,10 @@ class Model: rbc.disable_collisions = False jointMap[frozenset((rbc.object1, rbc.object2))] = joint - logging.info("Creating non collision constraints") + logger.info("Creating non collision constraints") # create non collision constraints - nonCollisionJointTable = [] - non_collision_pairs = set() + nonCollisionJointTable: List[Tuple[bpy.types.Object, bpy.types.Object]] = [] + non_collision_pairs: Set[frozenset] = 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): @@ -1150,12 +1216,13 @@ class Model: nonCollisionJointTable.append((obj_a, obj_b)) non_collision_pairs.add(pair) for cnt, i in enumerate(rigid_objects): - logging.info("%3d/%3d: Updating rigid body %s", cnt + 1, rigid_object_cnt, i.name) + logger.info("%3d/%3d: Updating rigid body %s", cnt + 1, rigid_object_cnt, i.name) self.updateRigid(i, collision_margin) self.__createNonCollisionConstraint(nonCollisionJointTable) return rigid_objects - def buildJoints(self): + def buildJoints(self) -> None: + logger.info("Building joints") for i in self.joints(): rbc = i.rigid_body_constraint if rbc is None: @@ -1168,8 +1235,9 @@ 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]): + def __editPhysicsBones(self, editor: Callable[[bpy.types.EditBone], None], target_modes: Set[str]) -> None: armature_object = self.armature() armature: bpy.types.Armature @@ -1177,7 +1245,7 @@ class Model: 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 @@ -1188,21 +1256,25 @@ class Model: editor(edit_bone) - def disconnectPhysicsBones(self): - def editor(edit_bone: bpy.types.EditBone): + def disconnectPhysicsBones(self) -> None: + logger.info("Disconnecting physics bones") + def editor(edit_bone: bpy.types.EditBone) -> None: 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): - def editor(edit_bone: bpy.types.EditBone): + def connectPhysicsBones(self) -> None: + logger.info("Connecting physics bones") + def editor(edit_bone: bpy.types.EditBone) -> None: 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 aaa707e..2af6801 100644 --- a/core/mmd/core/morph.py +++ b/core/mmd/core/morph.py @@ -5,33 +5,35 @@ # 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 import re -from typing import TYPE_CHECKING, Tuple, cast +from typing import TYPE_CHECKING, Tuple, cast, List, Dict, Optional, Set, Any, Union, Iterator 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, model: "Model"): + def __init__(self, morph: Any, model: "Model"): self.__morph = morph self.__rig = model @classmethod - def storeShapeKeyOrder(cls, obj, shape_key_names): + def storeShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None: 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, name): + def __move_to_bottom(key_blocks: bpy.types.bpy_prop_collection, name: str) -> None: obj.active_shape_key_index = key_blocks.find(name) bpy.ops.object.shape_key_move(type="BOTTOM") @@ -43,7 +45,7 @@ class FnMorph: __move_to_bottom(key_blocks, name) @classmethod - def fixShapeKeyOrder(cls, obj, shape_key_names): + def fixShapeKeyOrder(cls, obj: Object, shape_key_names: List[str]) -> None: if len(shape_key_names) < 1: return assert FnContext.get_active_object(FnContext.ensure_context()) == obj @@ -58,11 +60,11 @@ class FnMorph: bpy.ops.object.shape_key_move(type="BOTTOM") @staticmethod - def get_morph_slider(rig): + def get_morph_slider(rig: "Model") -> "_MorphSlider": return _MorphSlider(rig) @staticmethod - def category_guess(morph): + def category_guess(morph: Any) -> None: name_lower = morph.name.lower() if "mouth" in name_lower: morph.category = "MOUTH" @@ -73,7 +75,7 @@ class FnMorph: morph.category = "EYE" @classmethod - def load_morphs(cls, rig): + def load_morphs(cls, rig: "Model") -> None: mmd_root = rig.rootObject().mmd_root vertex_morphs = mmd_root.vertex_morphs uv_morphs = mmd_root.uv_morphs @@ -92,7 +94,7 @@ class FnMorph: cls.category_guess(item) @staticmethod - def remove_shape_key(mesh_object: bpy.types.Object, shape_key_name: str): + def remove_shape_key(mesh_object: Object, shape_key_name: str) -> None: assert isinstance(mesh_object.data, bpy.types.Mesh) shape_keys = mesh_object.data.shape_keys @@ -104,7 +106,7 @@ class FnMorph: FnObject.mesh_remove_shape_key(mesh_object, key_blocks[shape_key_name]) @staticmethod - def copy_shape_key(mesh_object: bpy.types.Object, src_name: str, dest_name: str): + def copy_shape_key(mesh_object: Object, src_name: str, dest_name: str) -> None: assert isinstance(mesh_object.data, bpy.types.Mesh) shape_keys = mesh_object.data.shape_keys @@ -126,13 +128,13 @@ class FnMorph: mesh_object.active_shape_key_index = key_blocks.find(dest_name) @staticmethod - def get_uv_morph_vertex_groups(obj, morph_name=None, offset_axes="XYZW"): + def get_uv_morph_vertex_groups(obj: Object, morph_name: Optional[str] = None, offset_axes: str = "XYZW") -> Iterator[Tuple[bpy.types.VertexGroup, str, str]]: 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, src_name, dest_name): + def copy_uv_morph_vertex_groups(obj: Object, src_name: str, dest_name: str) -> None: for vg, n, x in FnMorph.get_uv_morph_vertex_groups(obj, dest_name): obj.vertex_groups.remove(vg) @@ -143,12 +145,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): + def overwrite_bone_morphs_from_action_pose(armature_object: Object) -> None: 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: - logging.warning('[WARNING] armature "%s" has no animation data or action', armature_object.name) + logger.warning('Armature "%s" has no animation data or action', armature_object.name) return action = armature.animation_data.action @@ -187,9 +189,9 @@ class FnMorph: utils.selectAObject(root) @staticmethod - def clean_uv_morph_vertex_groups(obj): + def clean_uv_morph_vertex_groups(obj: Object) -> None: # remove empty vertex groups of uv morphs - vg_indices = {g.index for g, n, x in FnMorph.get_uv_morph_vertex_groups(obj)} + vg_indices: Set[int] = {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: @@ -203,8 +205,8 @@ class FnMorph: vertex_groups.remove(vg) @staticmethod - def get_uv_morph_offset_map(obj, morph): - offset_map = {} # offset_map[vertex_index] = offset_xyzw + 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 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)} @@ -225,7 +227,7 @@ class FnMorph: return offset_map @staticmethod - def store_uv_morph_data(obj, morph, offsets=None, offset_axes="XYZW"): + def store_uv_morph_data(obj: Object, morph: Any, offsets: Optional[List[Any]] = None, offset_axes: str = "XYZW") -> None: vertex_groups = obj.vertex_groups morph_name = getattr(morph, "name", None) if offset_axes: @@ -250,7 +252,7 @@ class FnMorph: 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=None): + def update_mat_related_mesh(self, new_mesh: Optional[Object] = None) -> None: for offset in self.__morph.data: # Use the new_mesh if provided meshObj = new_mesh @@ -270,11 +272,11 @@ class FnMorph: offset.related_mesh = meshObj.data.name @staticmethod - def clean_duplicated_material_morphs(mmd_root_object: bpy.types.Object): + def clean_duplicated_material_morphs(mmd_root_object: Object) -> None: """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, r) -> bool: + def morph_data_equals(l: Any, r: Any) -> bool: return ( l.related_mesh_data == r.related_mesh_data and l.offset_type == r.offset_type @@ -290,7 +292,7 @@ class FnMorph: and all(a == b for a, b in zip(l.toon_texture_factor, r.toon_texture_factor)) ) - def morph_equals(l, r) -> bool: + 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)) # Remove duplicated mmd_root.material_morphs.data[] @@ -325,7 +327,7 @@ class _MorphSlider: def __init__(self, model: "Model"): self.__rig = model - def placeholder(self, create=False, binded=False): + def placeholder(self, create: bool = False, binded: bool = False) -> Optional[Object]: rig = self.__rig root = rig.rootObject() obj = next((x for x in root.children if x.mmd_type == "PLACEHOLDER" and x.type == "MESH"), None) @@ -343,11 +345,11 @@ class _MorphSlider: return obj @property - def dummy_armature(self): + def dummy_armature(self) -> Optional[Object]: obj = self.placeholder() return self.__dummy_armature(obj) if obj else None - def __dummy_armature(self, obj, create=False): + def __dummy_armature(self, obj: Object, create: bool = False) -> Optional[Object]: 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")) @@ -360,7 +362,7 @@ class _MorphSlider: FnBone.setup_special_bone_collections(arm) return arm - def get(self, morph_name): + def get(self, morph_name: str) -> Optional[ShapeKey]: obj = self.placeholder() if obj is None: return None @@ -369,13 +371,13 @@ class _MorphSlider: return None return key_blocks.get(morph_name, None) - def create(self): + def create(self) -> Object: self.__rig.loadMorphs() obj = self.placeholder(create=True) self.__load(obj, self.__rig.rootObject().mmd_root) return obj - def __load(self, obj, mmd_root): + def __load(self, obj: Object, mmd_root: Any) -> None: 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", ())): @@ -386,7 +388,7 @@ class _MorphSlider: obj.shape_key_add(name=name, from_mix=False) @staticmethod - def __driver_variables(id_data, path, index=-1): + def __driver_variables(id_data: Any, path: str, index: int = -1) -> Tuple[Any, Any]: d = id_data.driver_add(path, index) variables = d.driver.variables for x in variables: @@ -394,7 +396,7 @@ class _MorphSlider: return d.driver, variables @staticmethod - def __add_single_prop(variables, id_obj, data_path, prefix): + def __add_single_prop(variables: Any, id_obj: Object, data_path: str, prefix: str) -> Any: var = variables.new() var.name = f"{prefix}{len(variables)}" var.type = "SINGLE_PROP" @@ -405,7 +407,7 @@ class _MorphSlider: return var @staticmethod - def __shape_key_driver_check(key_block, resolve_path=False): + def __shape_key_driver_check(key_block: ShapeKey, resolve_path: bool = False) -> bool: if resolve_path: try: key_block.id_data.path_resolve(key_block.path_from_id()) @@ -419,7 +421,7 @@ 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=None): + def __cleanup(self, names_in_use: Optional[Dict[str, Any]] = None) -> None: from math import ceil, floor names_in_use = names_in_use or {} @@ -427,7 +429,7 @@ class _MorphSlider: 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[bpy.types.ShapeKey], ())): + for kb in getattr(mesh_object.data.shape_keys, "key_blocks", cast(Tuple[ShapeKey], ())): if kb.name in names_in_use: continue @@ -465,7 +467,7 @@ class _MorphSlider: c.driver_remove(attr) b.constraints.remove(c) - def unbind(self): + def unbind(self) -> None: mmd_root = self.__rig.rootObject().mmd_root # after unbind, the weird lag problem will disappear. @@ -488,7 +490,7 @@ class _MorphSlider: b.driver_remove("rotation_quaternion") self.__cleanup() - def bind(self): + def bind(self) -> None: rig = self.__rig root = rig.rootObject() armObj = rig.armature() @@ -502,10 +504,10 @@ class _MorphSlider: morph_sliders = obj.data.shape_keys.key_blocks # data gathering - group_map = {} + group_map: Dict[Tuple[str, str], List[List[Any]]] = {} - shape_key_map = {} - uv_morph_map = {} + shape_key_map: Dict[str, List[Tuple[ShapeKey, str, List[Any]]]] = {} + uv_morph_map: Dict[str, List[Tuple[str, str, str, List[Any]]]] = {} for mesh_object in rig.meshes(): mesh_object.show_only_shape_key = False key_blocks = getattr(mesh_object.data.shape_keys, "key_blocks", ()) @@ -526,7 +528,7 @@ class _MorphSlider: kb_bind.slider_max = 10 data_path = 'data.shape_keys.key_blocks["%s"].value' % kb_name.replace('"', '\\"') - groups = [] + groups: List[Any] = [] shape_key_map.setdefault(name_bind, []).append((kb_bind, data_path, groups)) group_map.setdefault(("vertex_morphs", kb_name), []).append(groups) @@ -542,7 +544,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 @@ -555,13 +557,13 @@ class _MorphSlider: else: mod.bone_from, mod.bone_to = name_bind, "mmd_bind_ctrl_base" - bone_offset_map = {} + bone_offset_map: Dict[str, Tuple[str, Any, str, str, List[Any]]] = {} with bpyutils.edit_object(arm) as data: from .bone import FnBone edit_bones = data.edit_bones - def __get_bone(name, parent): + def __get_bone(name: str, parent: Optional[bpy.types.EditBone]) -> bpy.types.EditBone: b = edit_bones.get(name, None) or edit_bones.new(name=name) b.head = (0, 0, 0) b.tail = (0, 0, 1) @@ -578,7 +580,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 = [] + groups: List[Any] = [] bone_offset_map[name_bind] = (m.name, d, b.name, data_path, groups) group_map.setdefault(("bone_morphs", m.name), []).append(groups) @@ -589,21 +591,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 = [] + groups: List[Any] = [] 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 = bone_offset_map.keys() | uv_morph_map.keys() + used_bone_names: Set[str] = set(bone_offset_map.keys()) | set(uv_morph_map.keys()) used_bone_names.add(ctrl_base.name) for b in edit_bones: # cleanup if b.name.startswith("mmd_bind") and b.name not in used_bone_names: edit_bones.remove(b) - material_offset_map = {} + material_offset_map: Dict[str, Any] = {} for m in mmd_root.material_morphs: morph_name = m.name.replace('"', '\\"') data_path = f'data.shape_keys.key_blocks["{morph_name}"].value' - groups = [] + groups: List[Any] = [] 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: @@ -614,7 +616,7 @@ class _MorphSlider: for m in mmd_root.group_morphs: if len(m.data) != len(set(m.data.keys())): - logging.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: @@ -625,7 +627,7 @@ class _MorphSlider: self.__cleanup(shape_key_map.keys() | bone_offset_map.keys() | uv_morph_map.keys()) - def __config_groups(variables, expression, groups): + def __config_groups(variables: Any, expression: str, groups: List[Any]) -> str: 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") @@ -644,7 +646,7 @@ class _MorphSlider: kb_bind.mute = False # bone morphs - def __config_bone_morph(constraints, map_type, attributes, val, val_str): + def __config_bone_morph(constraints: bpy.types.ArmatureConstraints, map_type: str, attributes: Set[str], val: float, val_str: str) -> None: 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) @@ -692,7 +694,7 @@ class _MorphSlider: group_dict = material_offset_map.get("group_dict", {}) - def __config_material_morph(mat, morph_list): + def __config_material_morph(mat: Material, morph_list: List[Tuple[str, Any, str]]) -> None: 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): node.label, node.name = morph_name, name_bind @@ -704,7 +706,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 == "": - logging.warning("Oh no. The material name should never empty.") + logger.warning("Oh no. The material name should never be empty.") mul_list, add_list = [], [] else: mat_name = "#" + mat.name @@ -720,7 +722,7 @@ class _MorphSlider: class MigrationFnMorph: @staticmethod - def update_mmd_morph(): + def update_mmd_morph() -> None: from .material import FnMaterial for root in bpy.data.objects: @@ -762,11 +764,11 @@ class MigrationFnMorph: morph_data.related_mesh_data = bpy.data.meshes[related_mesh] @staticmethod - def ensure_material_id_not_conflict(): - mat_ids_set = set() + def ensure_material_id_not_conflict() -> None: + mat_ids_set: Set[int] = set() # The reference library properties cannot be modified and bypassed in advance. - need_update_mat = [] + need_update_mat: List[Material] = [] for mat in bpy.data.materials: if mat.mmd_material.material_id < 0: continue @@ -781,7 +783,7 @@ class MigrationFnMorph: mat_ids_set.add(mat.mmd_material.material_id) @staticmethod - def compatible_with_old_version_mmd_tools(): + def compatible_with_old_version_mmd_tools() -> None: MigrationFnMorph.ensure_material_id_not_conflict() for root in bpy.data.objects: diff --git a/core/mmd/core/pmx/importer.py b/core/mmd/core/pmx/importer.py index d1916a8..bb3a2cb 100644 --- a/core/mmd/core/pmx/importer.py +++ b/core/mmd/core/pmx/importer.py @@ -6,12 +6,13 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import collections -import logging import os import time -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, Dict, Tuple, Set, Callable, Any, Union, FrozenSet, Iterator import bpy +import numpy as np +from bpy.types import Object, Material, Mesh, Text, EditBone, PoseBone, ShapeKey from mathutils import Matrix, Vector from ... import bpyutils, utils @@ -24,6 +25,7 @@ from ..morph import FnMorph from ..rigid_body import FnRigidBody from ..vmd.importer import BoneConverter from ...operators.misc import MoveObject +from .....core.logging_setup import logger if TYPE_CHECKING: from ...properties.pose_bone import MMDBone @@ -31,13 +33,13 @@ if TYPE_CHECKING: class PMXImporter: - CATEGORIES = { + CATEGORIES: Dict[int, str] = { 0: "SYSTEM", 1: "EYEBROW", 2: "EYE", 3: "MOUTH", } - MORPH_TYPES = { + MORPH_TYPES: Dict[int, str] = { 0: "group_morphs", 1: "vertex_morphs", 2: "bone_morphs", @@ -49,46 +51,61 @@ class PMXImporter: 8: "material_morphs", } - def __init__(self): - self.__model = None - self.__targetContext = FnContext.ensure_context() + def __init__(self) -> None: + self.__model: Optional[pmx.Model] = None + self.__targetContext: bpy.types.Context = FnContext.ensure_context() - self.__scale = None + self.__scale: Optional[float] = None - self.__root: Optional[bpy.types.Object] = None - self.__armObj: Optional[bpy.types.Object] = None - self.__meshObj: Optional[bpy.types.Object] = None + self.__root: Optional[Object] = None + self.__armObj: Optional[Object] = None + self.__meshObj: Optional[Object] = None + self.__rig: Optional[Model] = None - self.__vertexGroupTable = None - self.__textureTable = None - self.__rigidTable = None + self.__vertexGroupTable: Optional[List[bpy.types.VertexGroup]] = None + self.__textureTable: Optional[List[str]] = None + self.__rigidTable: Dict[int, Object] = {} - self.__boneTable = [] - self.__materialTable = [] - self.__imageTable = {} + self.__boneTable: List[PoseBone] = [] + self.__materialTable: List[Material] = [] + self.__imageTable: Dict[int, bpy.types.Image] = {} - self.__sdefVertices = {} # pmx vertices - self.__blender_ik_links = set() - self.__vertex_map = None + self.__sdefVertices: Dict[int, pmx.Vertex] = {} # pmx vertices + self.__blender_ik_links: Set[int] = set() + self.__vertex_map: Optional[List[Tuple[int, int]]] = None - self.__materialFaceCountTable = None + self.__materialFaceCountTable: Optional[List[int]] = None + self.__fix_IK_links: bool = False + self.__apply_bone_fixed_axis: bool = False + self.__translator: Optional[Any] = None + self.__use_mipmap: bool = True + self.__sph_blend_factor: float = 1.0 + self.__spa_blend_factor: float = 1.0 @staticmethod - def __safe_name(name, max_length=59): + def __safe_name(name: str, max_length: int = 59) -> str: + """Create a safe name that won't exceed Blender's name length limits""" return str(bytes(name, "utf8")[:max_length], "utf8", errors="replace") @staticmethod - def flipUV_V(uv): + def flipUV_V(uv: Tuple[float, float]) -> Tuple[float, float]: + """Flip the V coordinate of a UV pair""" u, v = uv return u, 1.0 - v - def __createObjects(self): + def __createObjects(self) -> None: """Create main objects and link them to scene.""" + if not self.__model: + logger.error("No PMX model loaded") + return + pmxModel = self.__model obj_name = self.__safe_name(bpy.path.display_name(pmxModel.filepath), max_length=54) - self.__rig = Model.create(pmxModel.name, pmxModel.name_e, self.__scale, obj_name) + 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) root = self.__rig.rootObject() - mmd_root: MMDRoot = root.mmd_root + mmd_root: 'MMDRoot' = root.mmd_root self.__root = root self.__armObj = self.__rig.armature() @@ -100,46 +117,92 @@ class PMXImporter: txt = bpy.data.texts.new(obj_name + "_e") txt.from_string(pmxModel.comment_e.replace("\r", "")) mmd_root.comment_e_text = txt.name + + logger.debug(f"Created root object: {root.name}, armature: {self.__armObj.name}") - def __createMeshObject(self): + def __createMeshObject(self) -> None: + """Create a mesh object for the model""" + if not self.__root: + logger.error("Root object not created") + return + model_name = self.__root.name - self.__meshObj = bpy.data.objects.new(name=model_name + "_mesh", object_data=bpy.data.meshes.new(name=model_name)) + logger.info(f"Creating mesh object for model: {model_name}") + + self.__meshObj = bpy.data.objects.new( + name=model_name + "_mesh", + object_data=bpy.data.meshes.new(name=model_name) + ) self.__meshObj.parent = self.__armObj FnContext.link_object(self.__targetContext, self.__meshObj) + + logger.debug(f"Created mesh object: {self.__meshObj.name}") - def __createBasisShapeKey(self): + def __createBasisShapeKey(self) -> None: + """Create a basis shape key if it doesn't exist""" + if not self.__meshObj: + logger.error("Mesh object not created") + return + if self.__meshObj.data.shape_keys: assert len(self.__meshObj.data.vertices) > 0 assert len(self.__meshObj.data.shape_keys.key_blocks) > 1 + logger.debug("Basis shape key already exists") return + + logger.info("Creating basis shape key") FnContext.set_active_object(self.__targetContext, self.__meshObj) bpy.ops.object.shape_key_add() - def __importVertexGroup(self): + def __importVertexGroup(self) -> None: + """Import vertex groups from the PMX model""" + if not self.__meshObj or not self.__model: + logger.error("Mesh object or model not created") + return + + logger.info("Importing vertex groups") vgroups = self.__meshObj.vertex_groups self.__vertexGroupTable = [vgroups.new(name=i.name) for i in self.__model.bones] or [vgroups.new(name="NO BONES")] + logger.debug(f"Created {len(self.__vertexGroupTable)} vertex groups") - def __importVertices(self): + def __importVertices(self) -> None: + """Import vertices from the PMX model""" + if not self.__model or not self.__meshObj: + logger.error("Model or mesh object not created") + return + self.__importVertexGroup() pmxModel = self.__model pmx_vertices = pmxModel.vertices vertex_count = len(pmx_vertices) vertex_map = self.__vertex_map + + logger.info(f"Importing {vertex_count} vertices") + if vertex_map: indices = collections.OrderedDict(vertex_map).keys() pmx_vertices = tuple(pmxModel.vertices[x] for x in indices) vertex_count = len(indices) + logger.debug(f"Using vertex map, new vertex count: {vertex_count}") + if vertex_count < 1: + logger.warning("No vertices to import") return - mesh: bpy.types.Mesh = self.__meshObj.data + 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))) + mesh.vertices.foreach_set("co", tuple(i for pv in pmx_vertices for i in (Vector(pv.co).xzy * (self.__scale or 1.0)))) vertex_group_table = self.__vertexGroupTable + if not vertex_group_table: + logger.error("Vertex group table not created") + return + vg_edge_scale = self.__meshObj.vertex_groups.new(name="mmd_edge_scale") vg_vertex_order = self.__meshObj.vertex_groups.new(name="mmd_vertex_order") + + logger.debug("Processing vertex weights") for i, pv in enumerate(pmx_vertices): pv_bones, pv_weights, idx = pv.weight.bones, pv.weight.weights, (i,) @@ -165,61 +228,74 @@ class PMXImporter: for bone, weight in zip(pv_bones, pv_weights): vertex_group_table[bone].add(index=idx, weight=weight, type="ADD") else: - raise Exception("unkown bone weight type.") + logger.error(f"Unknown bone weight type for vertex {i}") + raise Exception("Unknown bone weight type.") vg_edge_scale.lock_weight = True vg_vertex_order.lock_weight = True + logger.debug(f"Processed {len(pmx_vertices)} vertices") - def __storeVerticesSDEF(self): + def __storeVerticesSDEF(self) -> None: + """Store SDEF vertex data in shape keys""" if len(self.__sdefVertices) < 1: + logger.debug("No SDEF vertices to store") return + logger.info(f"Storing {len(self.__sdefVertices)} SDEF vertices") self.__createBasisShapeKey() sdefC = self.__meshObj.shape_key_add(name="mmd_sdef_c") sdefR0 = self.__meshObj.shape_key_add(name="mmd_sdef_r0") sdefR1 = self.__meshObj.shape_key_add(name="mmd_sdef_r1") + for i, pv in self.__sdefVertices.items(): w = pv.weight.weights - 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 - logging.info("Stored %d SDEF vertices", len(self.__sdefVertices)) + 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) + + logger.debug(f"Stored {len(self.__sdefVertices)} SDEF vertices in shape keys") - def __importTextures(self): + def __importTextures(self) -> None: + """Import textures from the PMX model""" + if not self.__model: + logger.error("Model not loaded") + return + pmxModel = self.__model + logger.info(f"Importing {len(pmxModel.textures)} textures") self.__textureTable = [] for i in pmxModel.textures: - self.__textureTable.append(bpy.path.resolve_ncase(path=i.path)) + resolved_path = bpy.path.resolve_ncase(path=i.path) + self.__textureTable.append(resolved_path) + logger.debug(f"Imported texture: {resolved_path}") - def __createEditBones(self, obj, pmx_bones): - """create EditBones from pmx file data. + def __createEditBones(self, obj: Object, pmx_bones: List[pmx.Bone]) -> Tuple[List[str], List[str]]: + """Create EditBones from pmx file data. @return the list of bone names which can be accessed by the bone index of pmx data. """ - editBoneTable = [] - nameTable = [] - specialTipBones = [] - dependency_cycle_ik_bones = [] - # for i, p_bone in enumerate(pmx_bones): - # if p_bone.isIK: - # if p_bone.target != -1: - # t = pmx_bones[p_bone.target] - # if p_bone.parent == t.parent: - # dependency_cycle_ik_bones.append(i) + editBoneTable: List[EditBone] = [] + nameTable: List[str] = [] + specialTipBones: List[str] = [] + dependency_cycle_ik_bones: List[int] = [] + + logger.info(f"Creating {len(pmx_bones)} edit bones") from math import isfinite - def _VectorXZY(v): + def _VectorXZY(v: List[float]) -> Vector: return Vector(v).xzy if all(isfinite(n) for n in v) else Vector((0, 0, 0)) with bpyutils.edit_object(obj) as data: + # Create bones for i in pmx_bones: bone = data.edit_bones.new(name=i.name) - loc = _VectorXZY(i.location) * self.__scale + loc = _VectorXZY(i.location) * (self.__scale or 1.0) 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)): if m_bone.parent != -1: if i not in dependency_cycle_ik_bones: @@ -227,6 +303,7 @@ class PMXImporter: else: b_bone.parent = editBoneTable[m_bone.parent].parent + # Set tail positions for b_bone, m_bone in zip(editBoneTable, pmx_bones): if isinstance(m_bone.displayConnection, int): if m_bone.displayConnection != -1: @@ -234,12 +311,13 @@ class PMXImporter: else: b_bone.tail = b_bone.head else: - loc = _VectorXZY(m_bone.displayConnection) * self.__scale + loc = _VectorXZY(m_bone.displayConnection) * (self.__scale or 1.0) b_bone.tail = b_bone.head + loc + # Check and fix IK links for b_bone, m_bone in zip(editBoneTable, pmx_bones): if m_bone.isIK and m_bone.target != -1: - logging.debug(" - checking IK links of %s", b_bone.name) + logger.debug(f"Checking IK links of {b_bone.name}") b_target = editBoneTable[m_bone.target] for i in range(len(m_bone.ik_links)): b_bone_link = editBoneTable[m_bone.ik_links[i].target] @@ -247,34 +325,37 @@ class PMXImporter: b_bone_tail = b_target if i == 0 else editBoneTable[m_bone.ik_links[i - 1].target] loc = b_bone_tail.head - b_bone_link.head if loc.length < 0.001: - logging.warning(" ** unsolved IK link %s **", b_bone_link.name) + logger.warning(f"Unsolved IK link {b_bone_link.name}") elif b_bone_tail.parent != b_bone_link: - logging.warning(" ** skipped IK link %s **", b_bone_link.name) + logger.warning(f"Skipped IK link {b_bone_link.name}") elif (b_bone_link.tail - b_bone_tail.head).length > 1e-4: - logging.debug(" * fix IK link %s", b_bone_link.name) + logger.debug(f"Fixed IK link {b_bone_link.name}") b_bone_link.tail = b_bone_link.head + loc + # Fix too short bones for b_bone, m_bone in zip(editBoneTable, pmx_bones): # 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 + b_bone.tail = b_bone.head + fixed_axis.xzy.normalized() * (self.__scale or 1.0) else: - b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0) else: - b_bone.tail = b_bone.head + Vector((0, 0, 1)) * self.__scale + b_bone.tail = b_bone.head + Vector((0, 0, 1)) * (self.__scale or 1.0) if m_bone.displayConnection != -1 and m_bone.displayConnection != [0.0, 0.0, 0.0]: - logging.debug(" * special tip bone %s, display %s", b_bone.name, str(m_bone.displayConnection)) + 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): 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): if isinstance(m_bone.displayConnection, int) and m_bone.displayConnection >= 0: t = editBoneTable[m_bone.displayConnection] @@ -286,19 +367,23 @@ class PMXImporter: continue if not m_bone.isMovable: continue - logging.warning(" * connected: %s (%d)-> %s", b_bone.name, len(b_bone.children), t.name) + logger.warning(f"Connected: {b_bone.name} ({len(b_bone.children)})-> {t.name}") t.use_connect = True + logger.debug(f"Created {len(nameTable)} bones, {len(specialTipBones)} special tip bones") return nameTable, specialTipBones - def __sortPoseBonesByBoneIndex(self, pose_bones: List[bpy.types.PoseBone], bone_names): - r: List[bpy.types.PoseBone] = [] + def __sortPoseBonesByBoneIndex(self, pose_bones: List[PoseBone], bone_names: List[str]) -> List[PoseBone]: + """Sort pose bones by their bone index in the PMX model""" + r: List[PoseBone] = [] for i in bone_names: r.append(pose_bones[i]) return r @staticmethod - def convertIKLimitAngles(min_angle, max_angle, bone_matrix, invert=False): + def convertIKLimitAngles(min_angle: List[float], max_angle: List[float], + bone_matrix: Matrix, invert: bool = False) -> Tuple[Vector, Vector]: + """Convert IK limit angles from PMX to Blender space""" mat = bone_matrix.to_3x3() * -1 mat[1], mat[2] = mat[2].copy(), mat[1].copy() mat.transpose() @@ -325,15 +410,14 @@ class PMXImporter: new_min_angle[i], new_max_angle[i] = new_max_angle[i], new_min_angle[i] return new_min_angle, new_max_angle - def __applyIk(self, index, pmx_bone, pose_bones): - """create a IK bone constraint + def __applyIk(self, index: int, pmx_bone: pmx.Bone, pose_bones: List[PoseBone]) -> None: + """Create an IK bone constraint If the IK bone and the target bone is separated, a dummy IK target bone is created as a child of the IK bone. @param index the bone index @param pmx_bone pmx.Bone @param pose_bones the list of PoseBones sorted by the bone index """ - - # for tracking mmd ik target, simple explaination: + # for tracking mmd ik target, simple explanation: # + Root # | + link1 # | + link0 (ik_constraint_bone) <- ik constraint, chain_count=2 @@ -348,22 +432,25 @@ class PMXImporter: ik_target = pose_bones[pmx_bone.target] ik_constraint_bone = ik_target.parent is_valid_ik = False + + logger.debug(f"Applying IK for bone {ik_bone.name}, target: {ik_target.name}") + if len(pmx_bone.ik_links) > 0: ik_constraint_bone_real = pose_bones[pmx_bone.ik_links[0].target] if ik_constraint_bone_real == ik_target: if len(pmx_bone.ik_links) > 1: ik_constraint_bone_real = pose_bones[pmx_bone.ik_links[1].target] del pmx_bone.ik_links[0] - logging.warning(" * fix IK settings of IK bone (%s)", ik_bone.name) + logger.warning(f"Fixed IK settings of IK bone ({ik_bone.name})") is_valid_ik = ik_constraint_bone == ik_constraint_bone_real if not is_valid_ik: ik_constraint_bone = ik_constraint_bone_real - logging.warning(" * IK bone (%s) warning: IK target (%s) is not a child of IK link 0 (%s)", ik_bone.name, ik_target.name, ik_constraint_bone.name) + logger.warning(f"IK bone ({ik_bone.name}) warning: IK target ({ik_target.name}) is not a child of IK link 0 ({ik_constraint_bone.name})") elif any(pose_bones[i.target].parent != pose_bones[j.target] for i, j in zip(pmx_bone.ik_links, pmx_bone.ik_links[1:])): - logging.warning(" * Invalid IK bone (%s): IK chain does not follow parent-child relationship", ik_bone.name) + logger.warning(f"Invalid IK bone ({ik_bone.name}): IK chain does not follow parent-child relationship") return if ik_constraint_bone is None or len(pmx_bone.ik_links) < 1: - logging.warning(" * Invalid IK bone (%s)", ik_bone.name) + logger.warning(f"Invalid IK bone ({ik_bone.name})") return c = ik_target.constraints.new(type="DAMPED_TRACK") @@ -391,7 +478,7 @@ class PMXImporter: c = ik_bone.constraints.new(type="LIMIT_ROTATION") c.mute = True c.influence = 0 - c.name = "mmd_ik_limit_custom%d" % idx + c.name = f"mmd_ik_limit_custom{idx}" use_limits = c.use_limit_x = c.use_limit_y = c.use_limit_z = i.maximumAngle is not None if use_limits: minimum, maximum = self.convertIKLimitAngles(i.minimumAngle, i.maximumAngle, pose_bones[i.target].bone.matrix_local) @@ -419,15 +506,23 @@ class PMXImporter: c.use_limit_y = bone.ik_max_y != c.max_y or bone.ik_min_y != c.min_y c.use_limit_z = bone.ik_max_z != c.max_z or bone.ik_min_z != c.min_z - def __importBones(self): + def __importBones(self) -> None: + """Import bones from the PMX model""" + if not self.__model or not self.__armObj: + logger.error("Model or armature object not created") + return + pmxModel = self.__model + logger.info(f"Importing {len(pmxModel.bones)} bones") boneNameTable, specialTipBones = self.__createEditBones(self.__armObj, pmxModel.bones) pose_bones = self.__sortPoseBonesByBoneIndex(self.__armObj.pose.bones, boneNameTable) self.__boneTable = pose_bones + + # Process bones in transform order for i, pmx_bone in sorted(enumerate(pmxModel.bones), key=lambda x: x[1].transform_order): b_bone = pose_bones[i] - mmd_bone: MMDBone = b_bone.mmd_bone + mmd_bone: 'MMDBone' = b_bone.mmd_bone mmd_bone.name_j = b_bone.name # pmx_bone.name mmd_bone.name_e = pmx_bone.name_e mmd_bone.is_controllable = pmx_bone.isControllable @@ -472,14 +567,29 @@ class PMXImporter: b_bone.lock_rotation = [True, False, True] b_bone.lock_location = [True, True, True] b_bone.lock_scale = [True, True, True] + + logger.debug(f"Processed {len(pose_bones)} bones") - def __importRigids(self): + def __importRigids(self) -> None: + """Import rigid bodies from the PMX model""" + if not self.__model or not self.__rig: + logger.error("Model or rig not created") + return + start_time = time.time() self.__rigidTable = {} context = FnContext.ensure_context() - rigid_pool = FnRigidBody.new_rigid_body_objects(context, FnModel.ensure_rigid_group_object(context, self.__rig.rootObject()), len(self.__model.rigids)) + + logger.info(f"Importing {len(self.__model.rigids)} rigid bodies") + + rigid_pool = FnRigidBody.new_rigid_body_objects( + context, + FnModel.ensure_rigid_group_object(context, self.__rig.rootObject()), + len(self.__model.rigids) + ) + for i, (rigid, rigid_obj) in enumerate(zip(self.__model.rigids, rigid_pool)): - loc = Vector(rigid.location).xzy * self.__scale + loc = Vector(rigid.location).xzy * (self.__scale or 1.0) rot = Vector(rigid.rotation).xzy * -1 size = Vector(rigid.size).xzy if rigid.type == pmx.Rigid.TYPE_BOX else Vector(rigid.size) @@ -488,7 +598,7 @@ class PMXImporter: shape_type=rigid.type, location=loc, rotation=rot, - size=size * self.__scale, + size=size * (self.__scale or 1.0), dynamics_type=rigid.mode, name=rigid.name, name_e=rigid.name_e, @@ -505,14 +615,28 @@ class PMXImporter: MoveObject.set_index(obj, i) self.__rigidTable[i] = obj - logging.debug("Finished importing rigid bodies in %f seconds.", time.time() - start_time) + logger.debug(f"Finished importing rigid bodies in {time.time() - start_time:.2f} seconds") - def __importJoints(self): + def __importJoints(self) -> None: + """Import joints from the PMX model""" + if not self.__model or not self.__rig: + logger.error("Model or rig not created") + return + start_time = time.time() context = FnContext.ensure_context() - joint_pool = FnRigidBody.new_joint_objects(context, FnModel.ensure_joint_group_object(context, self.__rig.rootObject()), len(self.__model.joints), FnModel.get_empty_display_size(self.__rig.rootObject())) + + logger.info(f"Importing {len(self.__model.joints)} joints") + + joint_pool = FnRigidBody.new_joint_objects( + context, + FnModel.ensure_joint_group_object(context, self.__rig.rootObject()), + len(self.__model.joints), + FnModel.get_empty_display_size(self.__rig.rootObject()) + ) + for i, (joint, joint_obj) in enumerate(zip(self.__model.joints, joint_pool)): - loc = Vector(joint.location).xzy * self.__scale + loc = Vector(joint.location).xzy * (self.__scale or 1.0) rot = Vector(joint.rotation).xzy * -1 obj = FnRigidBody.setup_joint_object( @@ -523,8 +647,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, - minimum_location=Vector(joint.minimum_location).xzy * self.__scale, + 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_rotation=Vector(joint.minimum_rotation).xzy * -1, minimum_rotation=Vector(joint.maximum_rotation).xzy * -1, spring_linear=Vector(joint.spring_constant).xzy, @@ -533,12 +657,18 @@ class PMXImporter: obj.hide_set(True) MoveObject.set_index(obj, i) - logging.debug("Finished importing joints in %f seconds.", time.time() - start_time) + logger.debug(f"Finished importing joints in {time.time() - start_time:.2f} seconds") - def __importMaterials(self): + def __importMaterials(self) -> None: + """Import materials from the PMX model""" + if not self.__model or not self.__meshObj: + logger.error("Model or mesh object not created") + return + self.__importTextures() pmxModel = self.__model + logger.info(f"Importing {len(pmxModel.materials)} materials") self.__materialFaceCountTable = [] for i in pmxModel.materials: @@ -587,11 +717,20 @@ class PMXImporter: if i.sphere_texture_mode == 3 and getattr(pmxModel.header, "additional_uvs", 0): texture_slot.uv_layer = "UV1" # for SubTexture mmd_mat.sphere_texture_type = str(i.sphere_texture_mode) + + logger.debug(f"Created {len(self.__materialTable)} materials") - def __importFaces(self): + def __importFaces(self) -> None: + """Import faces from the PMX model""" + if not self.__model or not self.__meshObj: + logger.error("Model or mesh object not created") + return + pmxModel = self.__model mesh = self.__meshObj.data vertex_map = self.__vertex_map + + logger.info(f"Importing {len(pmxModel.faces)} faces") loop_indices_orig = tuple(i for f in pmxModel.faces for i in f) loop_indices = tuple(vertex_map[i][1] for i in loop_indices_orig) if vertex_map else loop_indices_orig @@ -617,38 +756,44 @@ class PMXImporter: bf.image = self.__imageTable.get(mi, None) if pmxModel.header and pmxModel.header.additional_uvs: - logging.info("Importing %d additional uvs", 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[uv_textures.new(name="UV" + str(i + 1)).name] - logging.info(" - %s...(uv channels)", add_uv.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])) if not any(any(s[1]) for s in uv_table.values()): - logging.info("\t- zw are all zeros: %s", add_uv.name) + logger.info(f"\t- zw are all zeros: {add_uv.name}") else: 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(): - logging.info(" - %s...(zw channels of %s)", name, name[1:]) + logger.info(f" - {name}...(zw channels of {name[1:]})") add_zw = uv_textures.new(name=name) if add_zw is None: - logging.warning("\t* Lost zw channels") + 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) + logger.debug(f"Imported {len(pmxModel.faces)} faces") - def __fixOverlappingFaceMaterials(self, materials, vertices, loop_indices, material_indices): - # FIXME: This is not the best way to setup blend_method, might just work for some common cases. And FnMaterial.update_alpha() is still using 'HASHED'. + def __fixOverlappingFaceMaterials(self, materials: List[Material], + vertices: bpy.types.MeshVertices, + loop_indices: Tuple[int, ...], + material_indices: Tuple[int, ...]) -> None: + """Fix overlapping face materials by setting appropriate blend methods""" + # FIXME: This is not the best way to setup blend_method, might just work for some common cases. # For EEVEE, basically users should know which blend_method is best for each material of their models. # For Cycles, users have to offset or delete those z-fighting faces to fix it manually. - check = {} + logger.info("Fixing overlapping face materials") + check: Dict[Tuple[float, ...], int] = {} mi_skip = -1 - _vi_cache = {} + _vi_cache: Dict[int, Tuple[float, float, float]] = {} - def _rounded_co_vi(vi): + def _rounded_co_vi(vi: int) -> Tuple[float, float, float]: if vi not in _vi_cache: vco = vertices[vi].co _vi_cache[vi] = (round(vco[0], 6), round(vco[1], 6), round(vco[2], 6)) @@ -663,16 +808,27 @@ class PMXImporter: if verts not in check: check[verts] = mi elif check[verts] < mi: - logging.debug(" >> fix blend method of material: %s", materials[mi].name) + logger.debug(f"Fixing blend method of material: {materials[mi].name}") materials[mi].blend_method = "BLEND" materials[mi].show_transparent_back = False mi_skip = mi - def __importVertexMorphs(self): + def __importVertexMorphs(self) -> None: + """Import vertex morphs from the PMX model""" + if not self.__model or not self.__root or not self.__meshObj: + logger.error("Model, root, or mesh object not created") + return + mmd_root = self.__root.mmd_root categories = self.CATEGORIES + + logger.info("Importing vertex morphs") self.__createBasisShapeKey() - for morph in (x for x in self.__model.morphs if isinstance(x, pmx.VertexMorph)): + + vertex_morphs = [x for x in self.__model.morphs if isinstance(x, pmx.VertexMorph)] + logger.debug(f"Found {len(vertex_morphs)} vertex morphs") + + for morph in vertex_morphs: shapeKey = self.__meshObj.shape_key_add(name=morph.name) vtx_morph = mmd_root.vertex_morphs.add() vtx_morph.name = morph.name @@ -680,12 +836,22 @@ class PMXImporter: 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 + shapeKeyPoint.co += Vector(md.offset).xzy * (self.__scale or 1.0) + logger.debug(f"Imported vertex morph: {morph.name} with {len(morph.offsets)} offsets") - def __importMaterialMorphs(self): + def __importMaterialMorphs(self) -> None: + """Import material morphs from the PMX model""" + if not self.__model or not self.__root or not self.__meshObj: + logger.error("Model, root, or mesh object not created") + return + mmd_root = self.__root.mmd_root categories = self.CATEGORIES - for morph in (x for x in self.__model.morphs if isinstance(x, pmx.MaterialMorph)): + + material_morphs = [x for x in self.__model.morphs if isinstance(x, pmx.MaterialMorph)] + logger.info(f"Importing {len(material_morphs)} material morphs") + + for morph in material_morphs: mat_morph = mmd_root.material_morphs.add() mat_morph.name = morph.name mat_morph.name_e = morph.name_e @@ -705,31 +871,53 @@ class PMXImporter: data.texture_factor = morph_data.texture_factor data.sphere_texture_factor = morph_data.sphere_texture_factor data.toon_texture_factor = morph_data.toon_texture_factor + logger.debug(f"Imported material morph: {morph.name} with {len(morph.offsets)} offsets") - def __importBoneMorphs(self): + def __importBoneMorphs(self) -> None: + """Import bone morphs from the PMX model""" + if not self.__model or not self.__root: + logger.error("Model or root object not created") + return + mmd_root = self.__root.mmd_root categories = self.CATEGORIES - for morph in (x for x in self.__model.morphs if isinstance(x, pmx.BoneMorph)): + + bone_morphs = [x for x in self.__model.morphs if isinstance(x, pmx.BoneMorph)] + logger.info(f"Importing {len(bone_morphs)} bone morphs") + + for morph in bone_morphs: bone_morph = mmd_root.bone_morphs.add() bone_morph.name = morph.name bone_morph.name_e = morph.name_e bone_morph.category = categories.get(morph.category, "OTHER") + valid_offsets = 0 for morph_data in morph.offsets: if not (0 <= morph_data.index < len(self.__boneTable)): continue data = bone_morph.data.add() bl_bone = self.__boneTable[morph_data.index] data.bone = bl_bone.name - converter = BoneConverter(bl_bone, self.__scale) + converter = BoneConverter(bl_bone, self.__scale or 1.0) data.location = converter.convert_location(morph_data.location_offset) data.rotation = converter.convert_rotation(morph_data.rotation_offset) + valid_offsets += 1 + logger.debug(f"Imported bone morph: {morph.name} with {valid_offsets} valid offsets") - def __importUVMorphs(self): + def __importUVMorphs(self) -> None: + """Import UV morphs from the PMX model""" + if not self.__model or not self.__root or not self.__meshObj: + logger.error("Model, root, or mesh object not created") + return + mmd_root = self.__root.mmd_root categories = self.CATEGORIES __OffsetData = collections.namedtuple("OffsetData", "index, offset") __convert_offset = lambda x: (x[0], -x[1], x[2], -x[3]) - for morph in (x for x in self.__model.morphs if isinstance(x, pmx.UVMorph)): + + uv_morphs = [x for x in self.__model.morphs if isinstance(x, pmx.UVMorph)] + logger.info(f"Importing {len(uv_morphs)} UV morphs") + + for morph in uv_morphs: uv_morph = mmd_root.uv_morphs.add() uv_morph.name = morph.name uv_morph.name_e = morph.name_e @@ -739,17 +927,28 @@ class PMXImporter: offsets = (__OffsetData(d.index, __convert_offset(d.offset)) for d in morph.offsets) FnMorph.store_uv_morph_data(self.__meshObj, uv_morph, offsets, "") uv_morph.data_type = "VERTEX_GROUP" + logger.debug(f"Imported UV morph: {morph.name} with {len(morph.offsets)} offsets") - def __importGroupMorphs(self): + def __importGroupMorphs(self) -> None: + """Import group morphs from the PMX model""" + if not self.__model or not self.__root: + logger.error("Model or root object not created") + return + mmd_root = self.__root.mmd_root categories = self.CATEGORIES morph_types = self.MORPH_TYPES pmx_morphs = self.__model.morphs - for morph in (x for x in pmx_morphs if isinstance(x, pmx.GroupMorph)): + + group_morphs = [x for x in pmx_morphs if isinstance(x, pmx.GroupMorph)] + logger.info(f"Importing {len(group_morphs)} group morphs") + + for morph in group_morphs: group_morph = mmd_root.group_morphs.add() group_morph.name = morph.name group_morph.name_e = morph.name_e group_morph.category = categories.get(morph.category, "OTHER") + valid_offsets = 0 for morph_data in morph.offsets: if not (0 <= morph_data.morph < len(pmx_morphs)): continue @@ -758,11 +957,20 @@ class PMXImporter: data.name = m.name data.morph_type = morph_types[m.type_index()] data.factor = morph_data.factor + valid_offsets += 1 + logger.debug(f"Imported group morph: {morph.name} with {valid_offsets} valid offsets") - def __importDisplayFrames(self): + def __importDisplayFrames(self) -> None: + """Import display frames from the PMX model""" + if not self.__model or not self.__root or not self.__armObj: + logger.error("Model, root, or armature object not created") + return + pmxModel = self.__model root = self.__root morph_types = self.MORPH_TYPES + + logger.info(f"Importing {len(pmxModel.display)} display frames") for i in pmxModel.display: frame = root.mmd_root.display_item_frames.add() @@ -780,52 +988,107 @@ class PMXImporter: item.name = morph.name item.morph_type = morph_types[morph.type_index()] else: + logger.error(f"Unknown display item type: {disp_type}") raise Exception("Unknown display item type.") FnBone.sync_bone_collections_from_display_item_frames(self.__armObj) + logger.debug("Synchronized bone collections from display frames") - def __addArmatureModifier(self, meshObj, armObj): - # TODO: move to model.py + def __addArmatureModifier(self, meshObj: Object, armObj: Object) -> None: + """Add an armature modifier to the mesh object""" + logger.info(f"Adding armature modifier to {meshObj.name}") 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 + logger.debug("Armature modifier added") - def __assignCustomNormals(self): - mesh: bpy.types.Mesh = self.__meshObj.data - logging.info("Setting custom normals...") + def __assignCustomNormals(self) -> None: + """Assign custom normals to the mesh""" + if not self.__meshObj or not self.__model: + logger.error("Mesh object or model not created") + return + + mesh: Mesh = self.__meshObj.data + logger.info("Setting custom normals...") + 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) - logging.info(" - Done!!") + logger.debug(f"Set {len(custom_normals)} custom normals from vertices") + + logger.info("Custom normals set successfully") - def __renameLRBones(self, use_underscore): + def __renameLRBones(self, use_underscore: bool) -> None: + """Rename bones with left/right naming convention""" + if not self.__armObj: + logger.error("Armature object not created") + return + + logger.info("Renaming bones with L/R convention") pose_bones = self.__armObj.pose.bones for i in pose_bones: - self.__rig.renameBone(i.name, utils.convertNameToLR(i.name, use_underscore)) - # self.__meshObj.vertex_groups[i.mmd_bone.name_j].name = i.name + new_name = utils.convertNameToLR(i.name, use_underscore) + if new_name != i.name: + logger.debug(f"Renaming bone: {i.name} -> {new_name}") + self.__rig.renameBone(i.name, new_name) - def __translateBoneNames(self): + def __translateBoneNames(self) -> None: + """Translate bone names using the provided translator""" + if not self.__armObj or not self.__translator: + logger.error("Armature object or translator not available") + return + + logger.info("Translating bone names") pose_bones = self.__armObj.pose.bones for i in pose_bones: - self.__rig.renameBone(i.name, self.__translator.translate(i.name)) + translated_name = self.__translator.translate(i.name) + if translated_name != i.name: + logger.debug(f"Translating bone: {i.name} -> {translated_name}") + self.__rig.renameBone(i.name, translated_name) - def __fixRepeatedMorphName(self): - used_names = set() + def __fixRepeatedMorphName(self) -> None: + """Fix repeated morph names to ensure uniqueness""" + if not self.__model: + logger.error("Model not loaded") + return + + logger.info("Fixing repeated morph names") + used_names: Set[str] = set() + renamed_count = 0 + for m in self.__model.morphs: - m.name = utils.unique_name(m.name or "Morph", used_names) + original_name = m.name or "Morph" + m.name = utils.unique_name(original_name, used_names) + if m.name != original_name: + renamed_count += 1 + logger.debug(f"Renamed morph: {original_name} -> {m.name}") used_names.add(m.name) + + if renamed_count > 0: + logger.info(f"Renamed {renamed_count} morphs to ensure unique names") - def execute(self, **args): + def execute(self, **args: Any) -> None: + """Execute the PMX import process""" + start_time = time.time() + if "pmx" in args: self.__model = args["pmx"] + logger.info("Using provided PMX model") else: - self.__model = pmx.load(args["filepath"]) + filepath = args.get("filepath", "") + if not filepath: + logger.error("No filepath provided") + return + logger.info(f"Loading PMX model from: {filepath}") + self.__model = pmx.load(filepath) + self.__fixRepeatedMorphName() types = args.get("types", set()) @@ -839,21 +1102,24 @@ class PMXImporter: self.__apply_bone_fixed_axis = args.get("apply_bone_fixed_axis", False) self.__translator = args.get("translator", None) - logging.info("****************************************") - logging.info(" mmd_tools.import_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("") - - start_time = time.time() + logger.info("****************************************") + logger.info(" mmd_tools.import_pmx module") + logger.info("----------------------------------------") + logger.info(" Start to load model data from a pmx file") + logger.info(" by the mmd_tools.pmx module.") + logger.info("") + logger.info(f" Scale: {self.__scale}") + logger.info(f" Types to import: {types}") self.__createObjects() if "MESH" in types: + logger.info("Importing mesh data") if clean_model: + logger.info("Cleaning PMX model") _PMXCleaner.clean(self.__model, "MORPHS" not in types) if remove_doubles: + logger.info("Removing doubles from PMX model") self.__vertex_map = _PMXCleaner.remove_doubles(self.__model, "MORPHS" not in types) self.__createMeshObject() self.__importVertices() @@ -864,6 +1130,7 @@ class PMXImporter: self.__storeVerticesSDEF() if "ARMATURE" in types: + logger.info("Importing armature data") # for tracking bone order if "MESH" not in types: self.__createMeshObject() @@ -875,19 +1142,24 @@ class PMXImporter: if self.__translator: self.__translateBoneNames() if self.__apply_bone_fixed_axis: + logger.info("Applying bone fixed axis") FnBone.apply_bone_fixed_axis(self.__armObj) FnBone.apply_additional_transformation(self.__armObj) if "PHYSICS" in types: + logger.info("Importing physics data") self.__importRigids() self.__importJoints() if "DISPLAY" in types: + logger.info("Importing display frames") self.__importDisplayFrames() else: + logger.info("Initializing default display frames") self.__rig.initialDisplayFrames() if "MORPHS" in types: + logger.info("Importing morphs") self.__importGroupMorphs() self.__importVertexMorphs() self.__importBoneMorphs() @@ -897,20 +1169,23 @@ class PMXImporter: if self.__meshObj: self.__addArmatureModifier(self.__meshObj, self.__armObj) + logger.info("Adjusting IK loop factor") FnModel.change_mmd_ik_loop_factor(self.__root, args.get("ik_loop_factor", 1)) # bpy.context.scene.gravity[2] = -9.81 * 10 * self.__scale utils.selectAObject(self.__root) - logging.info(" Finished importing the model in %f seconds.", time.time() - start_time) - logging.info("----------------------------------------") - logging.info(" mmd_tools.import_pmx module") - logging.info("****************************************") + elapsed_time = time.time() - start_time + logger.info(f" Finished importing the model in {elapsed_time:.2f} seconds.") + logger.info("----------------------------------------") + logger.info(" mmd_tools.import_pmx module") + logger.info("****************************************") class _PMXCleaner: @classmethod - def clean(cls, pmx_model, mesh_only): - logging.info("Cleaning PMX data...") + def clean(cls, pmx_model: pmx.Model, mesh_only: bool) -> None: + """Clean PMX data by removing unused vertices and faces""" + logger.info("Cleaning PMX data...") pmx_faces = pmx_model.faces pmx_vertices = pmx_model.vertices @@ -920,7 +1195,7 @@ class _PMXCleaner: index_map = {v: v for f in pmx_faces for v in f} is_index_clean = len(index_map) == len(pmx_vertices) if is_index_clean: - logging.info(" (vertices is clean)") + logger.info(" (vertices are clean)") else: new_vertex_count = 0 for v in sorted(index_map): @@ -928,7 +1203,7 @@ class _PMXCleaner: pmx_vertices[new_vertex_count] = pmx_vertices[v] index_map[v] = new_vertex_count new_vertex_count += 1 - logging.warning(" - removed %d vertices", len(pmx_vertices) - new_vertex_count) + logger.warning(f" - removed {len(pmx_vertices) - new_vertex_count} vertices") del pmx_vertices[new_vertex_count:] # update vertex indices of faces @@ -936,24 +1211,25 @@ class _PMXCleaner: f[:] = [index_map[v] for v in f] if mesh_only: - logging.info(" - Done (mesh only)!!") + logger.info(" - Done (mesh only)!!") return if not is_index_clean: # clean vertex/uv morphs - def __update_index(x): + def __update_index(x: Any) -> bool: x.index = index_map.get(x.index, None) return x.index is not None cls.__clean_pmx_morphs(pmx_model.morphs, __update_index) - logging.info(" - Done!!") + logger.info(" - Done!!") @classmethod - def remove_doubles(cls, pmx_model, mesh_only): - logging.info("Removing doubles...") + def remove_doubles(cls, pmx_model: pmx.Model, mesh_only: bool) -> Optional[List[Tuple[int, int]]]: + """Remove duplicate vertices from the PMX model""" + logger.info("Removing doubles...") pmx_vertices = pmx_model.vertices - vertex_map = [None] * len(pmx_vertices) + vertex_map: List[List[Tuple[Any, ...]]] = [None] * len(pmx_vertices) # gather vertex data for i, v in enumerate(pmx_vertices): vertex_map[i] = [tuple(v.co)] @@ -964,7 +1240,7 @@ class _PMXCleaner: for x in m.offsets: vertex_map[x.index].append((i,) + tuple(x.offset)) # generate vertex merging table - keys = {} + keys: Dict[Tuple[Any, ...], Tuple[int, int]] = {} for i, v in enumerate(vertex_map): k = tuple(v) if k in keys: @@ -974,9 +1250,9 @@ class _PMXCleaner: counts = len(vertex_map) - len(keys) keys.clear() if counts: - logging.warning(" - %d vertices will be removed", counts) + logger.warning(f" - {counts} vertices will be removed") else: - logging.info(" - Done (no changes)!!") + logger.info(" - Done (no changes)!!") return None # clean face @@ -985,24 +1261,27 @@ class _PMXCleaner: cls.__clean_pmx_faces(pmx_model.faces, pmx_model.materials, face_key_func) if mesh_only: - logging.info(" - Done (mesh only)!!") + logger.info(" - Done (mesh only)!!") else: # clean vertex/uv morphs - def __update_index(x): + def __update_index(x: Any) -> bool: indices = vertex_map[x.index] x.index = indices[1] if x.index == indices[0] else None return x.index is not None cls.__clean_pmx_morphs(pmx_model.morphs, __update_index) - logging.info(" - Done!!") + logger.info(" - Done!!") return vertex_map @staticmethod - def __clean_pmx_faces(pmx_faces, pmx_materials, face_key_func): + def __clean_pmx_faces(pmx_faces: List[List[int]], + pmx_materials: List[pmx.Material], + face_key_func: Callable[[List[int]], FrozenSet[Any]]) -> None: + """Clean PMX faces by removing duplicates and invalid faces""" new_face_count = 0 face_iter = iter(pmx_faces) for mat in pmx_materials: - used_faces = set() + used_faces: Set[FrozenSet[Any]] = set() new_vertex_count = 0 for i in range(int(mat.vertex_count / 3)): f = next(face_iter) @@ -1018,13 +1297,15 @@ class _PMXCleaner: mat.vertex_count = new_vertex_count face_iter = None if new_face_count == len(pmx_faces): - logging.info(" (faces is clean)") + logger.info(" (faces are clean)") else: - logging.warning(" - removed %d faces", len(pmx_faces) - new_face_count) + logger.warning(f" - removed {len(pmx_faces) - new_face_count} faces") del pmx_faces[new_face_count:] @staticmethod - def __clean_pmx_morphs(pmx_morphs, index_update_func): + def __clean_pmx_morphs(pmx_morphs: List[Union[pmx.VertexMorph, pmx.UVMorph, Any]], + index_update_func: Callable[[Any], bool]) -> None: + """Clean PMX morphs by updating indices and removing invalid offsets""" for m in pmx_morphs: if not isinstance(m, pmx.VertexMorph) and not isinstance(m, pmx.UVMorph): continue @@ -1032,4 +1313,4 @@ class _PMXCleaner: m.offsets = [x for x in m.offsets if index_update_func(x)] counts = old_len - len(m.offsets) if counts: - logging.warning(' - removed %d (of %d) offsets of "%s"', counts, old_len, m.name) + logger.warning(f' - removed {counts} (of {old_len}) offsets of "{m.name}"') diff --git a/core/mmd/core/rigid_body.py b/core/mmd/core/rigid_body.py index ec3aeb8..edb0de5 100644 --- a/core/mmd/core/rigid_body.py +++ b/core/mmd/core/rigid_body.py @@ -5,12 +5,13 @@ # 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. -from typing import List, Optional +from typing import List, Optional, Tuple, Union, Dict, Any, Set, cast import bpy -from mathutils import Euler, Vector +from mathutils import Euler, Vector, Matrix from ..bpyutils import FnContext, Props +from ....core.logging_setup import logger SHAPE_SPHERE = 0 SHAPE_BOX = 1 @@ -21,25 +22,30 @@ MODE_DYNAMIC = 1 MODE_DYNAMIC_BONE = 2 -def shapeType(collision_shape): +def shapeType(collision_shape: str) -> int: + """Convert collision shape name to type index""" return ("SPHERE", "BOX", "CAPSULE").index(collision_shape) -def collisionShape(shape_type): +def collisionShape(shape_type: int) -> str: + """Convert shape type index to collision shape name""" return ("SPHERE", "BOX", "CAPSULE")[shape_type] -def setRigidBodyWorldEnabled(enable): +def setRigidBodyWorldEnabled(enable: bool) -> bool: + """Enable or disable the rigid body world and return previous state""" 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 = [ + COLORS: List[int] = [ 0x7FDDD4, 0xF0E68C, 0xEE82EE, @@ -59,10 +65,12 @@ class RigidBodyMaterial: ] @classmethod - def getMaterial(cls, number): + def getMaterial(cls, number: int) -> bpy.types.Material: + """Get or create a material for rigid bodies with the specified number""" number = int(number) - material_name = "mmd_tools_rigid_%d" % (number) + material_name = f"mmd_tools_rigid_{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)] @@ -89,9 +97,11 @@ 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: @@ -101,6 +111,8 @@ 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" @@ -118,11 +130,11 @@ class FnRigidBody: @staticmethod def setup_rigid_body_object( obj: bpy.types.Object, - shape_type: str, + shape_type: int, location: Vector, rotation: Euler, size: Vector, - dynamics_type: str, + dynamics_type: int, collision_group_number: Optional[int] = None, collision_group_mask: Optional[List[bool]] = None, name: Optional[str] = None, @@ -134,6 +146,8 @@ 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 @@ -175,7 +189,8 @@ class FnRigidBody: return obj @staticmethod - def get_rigid_body_size(obj: bpy.types.Object): + 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""" assert obj.mmd_type == "RIGID_BODY" x0, y0, z0 = obj.bound_box[0] @@ -195,10 +210,14 @@ class FnRigidBody: height = abs((z1 - z0) - diameter) return (radius, height, 0.0) else: - raise ValueError(f"Invalid shape type: {shape}") + error_msg = f"Invalid shape type: {shape}" + logger.error(error_msg) + raise ValueError(error_msg) @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" @@ -230,9 +249,11 @@ 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: @@ -256,6 +277,8 @@ 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 4e4f768..2c15ce1 100644 --- a/core/mmd/core/sdef.py +++ b/core/mmd/core/sdef.py @@ -7,14 +7,19 @@ import logging import time +from typing import Dict, List, Tuple, Set, Optional, Any, Union, cast, TypeVar, Callable import bpy -from mathutils import Matrix, Vector +import numpy as np +from mathutils import Matrix, Vector, Quaternion, Euler +from bpy.types import Object, PoseBone, Pose, ShapeKey, Modifier, VertexGroup from ..bpyutils import FnObject +from ....core.logging_setup import logger +T = TypeVar('T') -def _hash(v): +def _hash(v: Union[Object, PoseBone, Pose]) -> int: if isinstance(v, (bpy.types.Object, bpy.types.PoseBone)): return hash(type(v).__name__ + v.name) elif isinstance(v, bpy.types.Pose): @@ -24,23 +29,24 @@ def _hash(v): class FnSDEF: - g_verts = {} # global cache - g_shapekey_data = {} - g_bone_check = {} - __g_armature_check = {} - SHAPEKEY_NAME = "mmd_sdef_skinning" - MASK_NAME = "mmd_sdef_mask" + 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" - def __init__(self): + def __init__(self) -> None: raise NotImplementedError("not allowed") @classmethod - def __init_cache(cls, obj, shapekey): + def __init_cache(cls, obj: Object, shapekey: ShapeKey) -> bool: key = _hash(obj) obj = getattr(obj, "original", obj) mod = obj.modifiers.get("mmd_bone_order_override") 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 @@ -49,7 +55,7 @@ class FnSDEF: return False @classmethod - def __check_bone_update(cls, obj, bone0, bone1): + def __check_bone_update(cls, obj: Object, bone0: PoseBone, bone1: PoseBone) -> bool: check = cls.g_bone_check[_hash(obj)] key = (_hash(bone0), _hash(bone1)) if key not in check or (bone0.matrix, bone1.matrix) != check[key]: @@ -58,17 +64,18 @@ class FnSDEF: return False @classmethod - def mute_sdef_set(cls, obj, mute): + def mute_sdef_set(cls, obj: Object, mute: bool) -> None: 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, shapekey): + def __sdef_muted(cls, obj: Object, shapekey: ShapeKey) -> bool: mute = shapekey.mute if mute != cls.g_bone_check[_hash(obj)].get("sdef_mute"): mod = obj.modifiers.get("mmd_bone_order_override") @@ -80,10 +87,11 @@ 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): + def has_sdef_data(obj: Object) -> bool: mod = obj.modifiers.get("mmd_bone_order_override") if mod and mod.type == "ARMATURE" and mod.object: kb = getattr(obj.data.shape_keys, "key_blocks", None) @@ -91,18 +99,21 @@ class FnSDEF: return False @classmethod - def __find_vertices(cls, obj): + def __find_vertices(cls, obj: Object) -> Dict[Tuple[int, int], Tuple[PoseBone, PoseBone, List[Tuple[int, float, float, Vector, Vector, Vector]], List[int]]]: if not cls.has_sdef_data(obj): return {} - vertices = {} + 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 = {g.index: pose_bones[g.name] for g in obj.vertex_groups if g.name in 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} 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 @@ -125,16 +136,19 @@ 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, bulk_update, use_skip, use_scale): + def driver_function_wrap(cls, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float: 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, obj_name, bulk_update, use_skip, use_scale): + def driver_function(cls, shapekey: ShapeKey, obj_name: str, bulk_update: bool, use_skip: bool, use_scale: bool) -> float: 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 @@ -206,11 +220,11 @@ class FnSDEF: rot1 = -rot1 s0, s1 = mat0.to_scale(), mat1.to_scale() - def scale(mat_rot, w0, w1): + def scale(mat_rot: Matrix, w0: float, w1: float) -> Matrix: 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, pos_c, vid): + def offset(mat_rot: Matrix, pos_c: Vector, vid: int) -> Vector: 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 @@ -233,16 +247,19 @@ class FnSDEF: return 1.0 # shapkey value @classmethod - def register_driver_function(cls): + def register_driver_function(cls) -> None: + """Register driver functions in Blender's driver namespace.""" 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 = 10 + BENCH_LOOP: int = 10 @classmethod - def __get_benchmark_result(cls, obj, shapkey, use_scale, use_skip): + def __get_benchmark_result(cls, obj: Object, shapkey: ShapeKey, use_scale: bool, use_skip: bool) -> bool: # 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) @@ -256,14 +273,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 - logging.info("FnSDEF:benchmark: default %.4f vs bulk_update %.4f => bulk_update=%s", default_time, bulk_time, result) + logger.info(f"SDEF benchmark for {obj.name}: default {default_time:.4f}s vs bulk_update {bulk_time:.4f}s => bulk_update={result}") return result @classmethod - def bind(cls, obj, bulk_update=None, use_skip=True, use_scale=False): + def bind(cls, obj: Object, bulk_update: Optional[bool] = None, use_skip: bool = True, use_scale: bool = False) -> bool: # Unbind first cls.unbind(obj) if not cls.has_sdef_data(obj): + logger.debug(f"Object {obj.name} does not have SDEF data") return False # Create the shapekey for the driver shapekey = obj.shape_key_add(name=cls.SHAPEKEY_NAME, from_mix=False) @@ -294,32 +312,38 @@ class FnSDEF: 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}") return True @classmethod - def unbind(cls, obj): + def unbind(cls, obj: Object) -> None: 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=None, unused_only=False): + def clear_cache(cls, obj: Optional[Object] = None, unused_only: bool = False) -> None: if unused_only: valid_keys = set(_hash(i) for i in bpy.data.objects if i.type == "MESH" and i != obj) - for key in cls.g_verts.keys() - valid_keys: + removed_keys = cls.g_verts.keys() - valid_keys + for key in removed_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: @@ -328,7 +352,9 @@ 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 9d32742..7636980 100644 --- a/core/mmd/core/shader.py +++ b/core/mmd/core/shader.py @@ -5,25 +5,33 @@ # 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. -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: bpy.types.ShaderNodeTree): + def __init__(self, shader: ShaderNodeTree): self.shader = shader - self.nodes: bpy.types.bpy_prop_collection[bpy.types.ShaderNode] = shader.nodes # type: ignore + self.nodes: bpy.types.bpy_prop_collection[ShaderNode] = shader.nodes # type: ignore self.links = shader.links - def _find_node(self, node_type: str) -> Optional[bpy.types.ShaderNode]: + def _find_node(self, node_type: str) -> Optional[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]) -> bpy.types.ShaderNode: - node: bpy.types.ShaderNode = self.nodes.new(idname) + def new_node(self, idname: str, pos: Tuple[int, int]) -> ShaderNode: + node: ShaderNode = self.nodes.new(idname) node.location = (pos[0] * 210, pos[1] * 220) return node - def new_math_node(self, operation, pos, value1=None, value2=None): + def new_math_node(self, operation: str, pos: Tuple[int, int], value1: Optional[float] = None, value2: Optional[float] = None) -> ShaderNode: node = self.new_node("ShaderNodeMath", pos) node.operation = operation if value1 is not None: @@ -32,7 +40,7 @@ class _NodeTreeUtils: node.inputs[1].default_value = value2 return node - def new_vector_math_node(self, operation, pos, vector1=None, vector2=None): + 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: node = self.new_node("ShaderNodeVectorMath", pos) node.operation = operation if vector1 is not None: @@ -41,7 +49,7 @@ class _NodeTreeUtils: node.inputs[1].default_value = vector2 return node - def new_mix_node(self, blend_type, pos, fac=None, color1=None, color2=None): + 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: node = self.new_node("ShaderNodeMixRGB", pos) node.blend_type = blend_type if fac is not None: @@ -53,30 +61,30 @@ class _NodeTreeUtils: return node -SOCKET_TYPE_MAPPING = {"NodeSocketFloatFactor": "NodeSocketFloat"} +SOCKET_TYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "NodeSocketFloat"} -SOCKET_SUBTYPE_MAPPING = {"NodeSocketFloatFactor": "FACTOR"} +SOCKET_SUBTYPE_MAPPING: Dict[str, str] = {"NodeSocketFloatFactor": "FACTOR"} class _NodeGroupUtils(_NodeTreeUtils): - def __init__(self, shader: bpy.types.ShaderNodeTree): + def __init__(self, shader: ShaderNodeTree): super().__init__(shader) - self.__node_input: Optional[bpy.types.NodeGroupInput] = None - self.__node_output: Optional[bpy.types.NodeGroupOutput] = None + self.__node_input: Optional[NodeGroupInput] = None + self.__node_output: Optional[NodeGroupOutput] = None @property - def node_input(self) -> bpy.types.NodeGroupInput: + def node_input(self) -> NodeGroupInput: if not self.__node_input: - self.__node_input = cast(bpy.types.NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) + self.__node_input = cast(NodeGroupInput, self._find_node("NodeGroupInput") or self.new_node("NodeGroupInput", (-2, 0))) return self.__node_input @property - def node_output(self) -> bpy.types.NodeGroupOutput: + def node_output(self) -> NodeGroupOutput: if not self.__node_output: - self.__node_output = cast(bpy.types.NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) + self.__node_output = cast(NodeGroupOutput, self._find_node("NodeGroupOutput") or self.new_node("NodeGroupOutput", (2, 0))) return self.__node_output - def hide_nodes(self, hide_sockets=True): + def hide_nodes(self, hide_sockets: bool = True) -> None: 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 @@ -87,15 +95,15 @@ class _NodeGroupUtils(_NodeTreeUtils): for s in n.outputs: s.hide = not s.is_linked - def new_input_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None): + 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: self.__new_io("INPUT", self.node_input.outputs, io_name, socket, default_val, min_max, socket_type) - def new_output_socket(self, io_name, socket, default_val=None, min_max=None, socket_type=None): + 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: self.__new_io("OUTPUT", self.node_output.inputs, io_name, socket, default_val, min_max, socket_type) - def __new_io(self, in_out, io_sockets, io_name, socket, default_val=None, min_max=None, socket_type=None): + 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: if io_name not in io_sockets: - idname = socket_type or socket.bl_idname + idname = socket_type or (socket.bl_idname if socket else "NodeSocketFloat") 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, "") @@ -114,14 +122,18 @@ class _NodeGroupUtils(_NodeTreeUtils): class _MaterialMorph: @classmethod - def update_morph_inputs(cls, material, morph): + def update_morph_inputs(cls, material: Optional[Material], morph: Any) -> None: + """Update material morph inputs based on morph data""" 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, morphs): + def setup_morph_nodes(cls, material: Material, morphs: List[Any]) -> List[ShaderNode]: + """Set up morph nodes for a material""" 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) @@ -137,23 +149,25 @@ class _MaterialMorph: return nodes @classmethod - def reset_morph_links(cls, node): + def reset_morph_links(cls, node: ShaderNode) -> None: + """Reset morph links for a node""" + logger.debug(f"Resetting morph links for {node.name}") cls.__update_morph_links(node, reset=True) @classmethod - def __update_morph_links(cls, node, reset=False): + def __update_morph_links(cls, node: ShaderNode, reset: bool = False) -> None: 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): return - def __init_link(socket_morph, socket_shader): + def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None: if socket_shader and socket_morph.is_linked: links.new(socket_morph.links[0].from_socket, socket_shader) else: - def __init_link(socket_morph, socket_shader): + def __init_link(socket_morph: bpy.types.NodeSocket, socket_shader: Optional[bpy.types.NodeSocket]) -> None: if socket_shader: if socket_shader.is_linked: links.new(socket_shader.links[0].from_socket, socket_morph) @@ -178,7 +192,8 @@ class _MaterialMorph: __init_link(node.inputs["Edge1 A"], shader.inputs["Alpha"]) @classmethod - def __update_node_inputs(cls, node, morph): + def __update_node_inputs(cls, node: ShaderNode, morph: Any) -> None: + """Update node inputs based on morph data""" 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] @@ -196,7 +211,8 @@ class _MaterialMorph: node.inputs["Sphere2 A"].default_value = morph.sphere_texture_factor[3] @classmethod - def __morph_node_add(cls, material, morph, prev_node): + def __morph_node_add(cls, material: Material, morph: Optional[Any], prev_node: Optional[ShaderNode]) -> Optional[ShaderNode]: + """Add a morph node to a material""" nodes, links = material.node_tree.nodes, material.node_tree.links shader = nodes.get("mmd_shader", None) @@ -221,8 +237,9 @@ 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, socket_in): + def __soft_link(socket_out: Optional[bpy.types.NodeSocket], socket_in: Optional[bpy.types.NodeSocket]) -> None: if socket_out and socket_in: links.new(socket_out, socket_in) @@ -244,12 +261,14 @@ class _MaterialMorph: return shader @classmethod - def __get_shader(cls, morph_type): + def __get_shader(cls, morph_type: str) -> ShaderNodeTree: + """Get or create a shader node group for the specified 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 @@ -260,7 +279,7 @@ class _MaterialMorph: ng.new_input_socket("Fac", None, 0, socket_type="NodeSocketFloat") ng.new_node("NodeGroupOutput", (3, 0)) - def __blend_color_add(id_name, pos, tag=""): + def __blend_color_add(id_name: str, pos: Tuple[int, int], tag: str = "") -> ShaderNode: # 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 @@ -271,7 +290,7 @@ class _MaterialMorph: ng.new_output_socket(id_name + tag, node_mix.outputs["Color"]) return node_mix - def __blend_tex_color(id_name, pos, node_tex_rgb, node_tex_a_output): + def __blend_tex_color(id_name: str, pos: Tuple[int, int], node_tex_rgb: ShaderNode, node_tex_a_output: bpy.types.NodeSocket) -> None: # Tex Color = tex_rgb * tex_a + (1 - tex_a) # : tex_rgb = TexRGB * ColorMul + ColorAdd # : tex_a = TexA * ValueMul + ValueAdd @@ -294,7 +313,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, input1, input2, output, tag=""): + def __add_sockets(id_name: str, input1: bpy.types.NodeSocket, input2: bpy.types.NodeSocket, output: bpy.types.NodeSocket, tag: str = "") -> None: 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) @@ -343,4 +362,5 @@ 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/cycles_converter.py b/core/mmd/cycles_converter.py index 2a8e531..5f10140 100644 --- a/core/mmd/cycles_converter.py +++ b/core/mmd/cycles_converter.py @@ -5,39 +5,44 @@ # 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. -from typing import Iterable, Optional +from typing import Iterable, Optional, Any, List, Tuple, Union 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 -def __switchToCyclesRenderEngine(): +def __switchToCyclesRenderEngine() -> None: if bpy.context.scene.render.engine != "CYCLES": + logger.debug("Switching render engine to Cycles") bpy.context.scene.render.engine = "CYCLES" -def __exposeNodeTreeInput(in_socket, name, default_value, node_input, shader): +def __exposeNodeTreeInput(in_socket: NodeSocket, name: str, default_value: Any, node_input: Node, shader: NodeTree) -> None: _NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value) -def __exposeNodeTreeOutput(out_socket, name, node_output, shader): +def __exposeNodeTreeOutput(out_socket: NodeSocket, name: str, node_output: Node, shader: NodeTree) -> None: _NodeGroupUtils(shader).new_output_socket(name, out_socket) -def __getMaterialOutput(nodes, bl_idname): +def __getMaterialOutput(nodes: bpy.types.Nodes, bl_idname: str) -> Node: 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(): +def create_MMDAlphaShader() -> NodeTree: __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") @@ -59,26 +64,28 @@ def create_MMDAlphaShader(): return shader -def create_MMDBasicShader(): +def create_MMDBasicShader() -> NodeTree: __switchToCyclesRenderEngine() if "MMDBasicShader" in bpy.data.node_groups: + logger.debug("Using existing MMDBasicShader node group") return bpy.data.node_groups["MMDBasicShader"] - shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree") + logger.info("Creating new MMDBasicShader node group") + shader: NodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree") - node_input: bpy.types.NodeGroupInput = shader.nodes.new("NodeGroupInput") - node_output: bpy.types.NodeGroupOutput = shader.nodes.new("NodeGroupOutput") + node_input: Node = shader.nodes.new("NodeGroupInput") + node_output: Node = shader.nodes.new("NodeGroupOutput") node_output.location.x += 250 node_input.location.x -= 500 - dif: bpy.types.ShaderNodeBsdfDiffuse = shader.nodes.new("ShaderNodeBsdfDiffuse") + dif: Node = shader.nodes.new("ShaderNodeBsdfDiffuse") dif.location.x -= 250 dif.location.y += 150 - glo: bpy.types.ShaderNodeBsdfAnisotropic = shader.nodes.new("ShaderNodeBsdfAnisotropic") + glo: Node = shader.nodes.new("ShaderNodeBsdfAnisotropic") glo.location.x -= 250 glo.location.y -= 150 - mix: bpy.types.ShaderNodeMixShader = shader.nodes.new("ShaderNodeMixShader") + mix: Node = shader.nodes.new("ShaderNodeMixShader") shader.links.new(mix.inputs[1], dif.outputs["BSDF"]) shader.links.new(mix.inputs[2], glo.outputs["BSDF"]) @@ -91,7 +98,7 @@ def create_MMDBasicShader(): return shader -def __enum_linked_nodes(node: bpy.types.Node) -> Iterable[bpy.types.Node]: +def __enum_linked_nodes(node: Node) -> Iterable[Node]: yield node if node.parent: yield node.parent @@ -99,7 +106,8 @@ def __enum_linked_nodes(node: bpy.types.Node) -> Iterable[bpy.types.Node]: yield from __enum_linked_nodes(n) -def __cleanNodeTree(material: bpy.types.Material): +def __cleanNodeTree(material: Material) -> None: + logger.debug(f"Cleaning node tree for material: {material.name}") nodes = material.node_tree.nodes node_names = set(n.name for n in nodes) for o in (n for n in nodes if n.bl_idname in {"ShaderNodeOutput", "ShaderNodeOutputMaterial"}): @@ -109,40 +117,46 @@ def __cleanNodeTree(material: bpy.types.Material): nodes.remove(nodes[name]) -def convertToCyclesShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001): +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})") __switchToCyclesRenderEngine() convertToBlenderShader(obj, use_principled, clean_nodes, subsurface) -def convertToBlenderShader(obj: bpy.types.Object, use_principled=False, clean_nodes=False, subsurface=0.001): +def convertToBlenderShader(obj: bpy.types.Object, use_principled: bool = False, clean_nodes: bool = False, subsurface: float = 0.001) -> None: for i in obj.material_slots: if not i.material: continue if not i.material.use_nodes: + logger.debug(f"Enabling nodes for material: {i.material.name}") 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): +def convertToMMDShader(obj: bpy.types.Object) -> None: """BSDF -> MMDShaderDev conversion.""" + logger.info(f"Converting {obj.name} to MMD shader") for i in obj.material_slots: if not i.material: continue if not i.material.use_nodes: + logger.debug(f"Enabling nodes for material: {i.material.name}") i.material.use_nodes = True FnMaterial.convert_to_mmd_material(i.material) -def __convertToMMDBasicShader(material: bpy.types.Material): +def __convertToMMDBasicShader(material: Material) -> None: + logger.debug(f"Converting material to MMD Basic Shader: {material.name}") # TODO: test me mmd_basic_shader_grp = create_MMDBasicShader() mmd_alpha_shader_grp = create_MMDAlphaShader() - if not any(filter(lambda x: isinstance(x, bpy.types.ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)): + if not any(filter(lambda x: isinstance(x, ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)): # Add nodes for Cycles Render - shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") + shader: 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] @@ -157,7 +171,8 @@ def __convertToMMDBasicShader(material: bpy.types.Material): alpha_value = material.diffuse_color[3] if alpha_value < 1.0: - alpha_shader: bpy.types.ShaderNodeGroup = material.node_tree.nodes.new("ShaderNodeGroup") + logger.debug(f"Material has alpha: {material.name}, alpha={alpha_value}") + alpha_shader: 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 @@ -165,21 +180,22 @@ def __convertToMMDBasicShader(material: bpy.types.Material): material.node_tree.links.new(alpha_shader.inputs[0], outplug) outplug = alpha_shader.outputs[0] - material_output: bpy.types.ShaderNodeOutputMaterial = __getMaterialOutput(material.node_tree.nodes, "ShaderNodeOutputMaterial") + material_output: 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: bpy.types.Material, subsurface: float): +def __convertToPrincipledBsdf(material: Material, subsurface: float) -> None: + logger.debug(f"Converting material to Principled BSDF: {material.name}") node_names = set() - for s in (n for n in material.node_tree.nodes if isinstance(n, bpy.types.ShaderNodeGroup)): + for s in (n for n in material.node_tree.nodes if isinstance(n, ShaderNodeGroup)): if s.node_tree.name == "MMDBasicShader": - l: bpy.types.NodeLink + l: NodeLink for l in s.outputs[0].links: to_node = l.to_node # assuming there is no bpy.types.NodeReroute between MMDBasicShader and MMDAlphaShader - if isinstance(to_node, bpy.types.ShaderNodeGroup) and to_node.node_tree.name == "MMDAlphaShader": + if isinstance(to_node, 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: @@ -194,8 +210,9 @@ def __convertToPrincipledBsdf(material: bpy.types.Material, subsurface: float): nodes.remove(nodes[name]) -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") +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") shader.parent = node_basic.parent shader.location.x = node_basic.location.x shader.location.y = node_basic.location.y diff --git a/core/mmd/operators/material.py b/core/mmd/operators/material.py index 23f2d49..a6ea15a 100644 --- a/core/mmd/operators/material.py +++ b/core/mmd/operators/material.py @@ -6,13 +6,16 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import bpy -from bpy.props import BoolProperty, StringProperty -from bpy.types import Operator +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 .. import cycles_converter from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial from ..core.shader import _NodeGroupUtils +from ....core.logging_setup import logger class ConvertMaterialsForCycles(Operator): @@ -21,14 +24,14 @@ class ConvertMaterialsForCycles(Operator): bl_description = "Convert materials of selected objects for Cycles." bl_options = {"REGISTER", "UNDO"} - use_principled: bpy.props.BoolProperty( + use_principled: 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: bpy.props.BoolProperty( + clean_nodes: BoolProperty( name="Clean Nodes", description="Remove redundant nodes as well if enabled. Disable it to keep node data.", default=False, @@ -36,22 +39,27 @@ class ConvertMaterialsForCycles(Operator): ) @classmethod - def poll(cls, context): - return next((x for x in context.selected_objects if x.type == "MESH"), None) + def poll(cls, context: Context) -> bool: + return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None - def draw(self, context): + def draw(self, context: Context) -> None: layout = self.layout layout.prop(self, "use_principled") layout.prop(self, "clean_nodes") - def execute(self, context): + def execute(self, context: Context) -> Set[str]: try: context.scene.render.engine = "CYCLES" - except: + except Exception as e: + logger.error(f"Failed to change to Cycles render engine: {str(e)}") 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"} @@ -61,21 +69,21 @@ class ConvertMaterials(Operator): bl_description = "Convert materials of selected objects." bl_options = {"REGISTER", "UNDO"} - use_principled: bpy.props.BoolProperty( + use_principled: 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: bpy.props.BoolProperty( + clean_nodes: BoolProperty( name="Clean Nodes", description="Remove redundant nodes as well if enabled. Disable it to keep node data.", default=True, options={"SKIP_SAVE"}, ) - subsurface: bpy.props.FloatProperty( + subsurface: FloatProperty( name="Subsurface", default=0.001, soft_min=0.000, @@ -85,13 +93,15 @@ class ConvertMaterials(Operator): ) @classmethod - def poll(cls, context): - return next((x for x in context.selected_objects if x.type == "MESH"), None) + def poll(cls, context: Context) -> bool: + return next((x for x in context.selected_objects if x.type == "MESH"), None) is not None - def execute(self, context): + 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}") 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"} @@ -102,20 +112,22 @@ class ConvertBSDFMaterials(Operator): bl_options = {'REGISTER', 'UNDO'} @classmethod - def poll(cls, context): - return next((x for x in context.selected_objects if x.type == 'MESH'), None) + def poll(cls, context: Context) -> bool: + return next((x for x in context.selected_objects if x.type == 'MESH'), None) is not None - def execute(self, context): + def execute(self, context: Context) -> Set[str]: + logger.info("Converting BSDF materials to MMD shader") 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'} class _OpenTextureBase: """Create a texture for mmd model material.""" - bl_options = {"REGISTER", "UNDO", "INTERNAL"} + bl_options: Set[str] = {"REGISTER", "UNDO", "INTERNAL"} filepath: StringProperty( name="File Path", @@ -129,7 +141,7 @@ class _OpenTextureBase: options={"HIDDEN"}, ) - def invoke(self, context, event): + def invoke(self, context: Context, event: Any) -> Set[str]: context.window_manager.fileselect_add(self) return {"RUNNING_MODAL"} @@ -139,8 +151,13 @@ class OpenTexture(Operator, _OpenTextureBase): bl_label = "Open Texture" bl_description = "Create main texture of active material" - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -154,8 +171,13 @@ class RemoveTexture(Operator): bl_description = "Remove main texture of active material" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -168,8 +190,13 @@ class OpenSphereTextureSlot(Operator, _OpenTextureBase): bl_label = "Open Sphere Texture" bl_description = "Create sphere texture of active material" - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -183,8 +210,13 @@ class RemoveSphereTexture(Operator): bl_description = "Remove sphere texture of active material" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -197,18 +229,21 @@ class MoveMaterialUp(Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: obj = context.active_object valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE" - return valid_mesh and obj.active_material_index > 0 + return bool(valid_mesh and obj.active_material_index > 0) - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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 @@ -223,18 +258,21 @@ class MoveMaterialDown(Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: obj = context.active_object valid_mesh = obj and obj.type == "MESH" and obj.mmd_type == "NONE" - return valid_mesh and obj.active_material_index < len(obj.material_slots) - 1 + return bool(valid_mesh and obj.active_material_index < len(obj.material_slots) - 1) - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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 @@ -257,26 +295,31 @@ class EdgePreviewSetup(Operator): default="CREATE", ) - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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): + def __clean_toon_edge(self, obj: Object) -> None: + logger.debug(f"Cleaning toon edge for object: {obj.name}") if "mmd_edge_preview" in obj.modifiers: obj.modifiers.remove(obj.modifiers["mmd_edge_preview"]) @@ -285,7 +328,8 @@ class EdgePreviewSetup(Operator): FnMaterial.clean_materials(obj, can_remove=lambda m: m and m.name.startswith("mmd_edge.")) - def __create_toon_edge(self, obj, scale=1.0): + 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}") self.__clean_toon_edge(obj) materials = obj.data.materials material_offset = len(materials) @@ -310,10 +354,10 @@ class EdgePreviewSetup(Operator): mod.vertex_group = "mmd_edge_preview" return len(materials) - material_offset - def __create_edge_preview_group(self, obj): + def __create_edge_preview_group(self, obj: Object) -> None: 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 = {} + scale_map: Dict[int, float] = {} 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} @@ -322,7 +366,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, edge_color, materials): + def __get_edge_material(self, mat_name: str, edge_color: Tuple[float, float, float, float], materials: List[Material]) -> Material: if mat_name in materials: return materials[mat_name] mat = bpy.data.materials.get(mat_name, None) @@ -340,7 +384,7 @@ class EdgePreviewSetup(Operator): self.__make_shader(mat) return mat - def __make_shader(self, m): + def __make_shader(self, m: Material) -> None: m.use_nodes = True nodes, links = m.node_tree.nodes, m.node_tree.links @@ -361,7 +405,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): + def __get_edge_preview_shader(self) -> bpy.types.NodeTree: 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): diff --git a/core/mmd/operators/misc.py b/core/mmd/operators/misc.py index c59815e..83cfeff 100644 --- a/core/mmd/operators/misc.py +++ b/core/mmd/operators/misc.py @@ -6,14 +6,17 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. 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): @@ -29,7 +32,8 @@ class SelectObject(bpy.types.Operator): options={"HIDDEN", "SKIP_SAVE"}, ) - def execute(self, context): + def execute(self, context: Context) -> Set[str]: + logger.debug(f"Selecting object: {self.name}") utils.selectAObject(context.scene.objects[self.name]) return {"FINISHED"} @@ -43,41 +47,43 @@ class MoveObject(bpy.types.Operator, utils.ItemMoveOp): __PREFIX_REGEXP = re.compile(r"(?P[0-9A-Z]{3}_)(?P.*)") @classmethod - def set_index(cls, obj, index): + def set_index(cls, obj: Object, index: int) -> None: 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) @classmethod - def get_name(cls, obj, prefix=None): + def get_name(cls, obj: Object, prefix: Optional[str] = None) -> str: 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): + def normalize_indices(cls, objects: List[Object]) -> None: for i, x in enumerate(objects): cls.set_index(x, i) @classmethod - def poll(cls, context): - return context.active_object + def poll(cls, context: Context) -> bool: + return context.active_object is not None - def execute(self, context): + def execute(self, context: Context) -> Set[str]: obj = context.active_object objects = self.__get_objects(obj) if obj not in objects: - self.report({"ERROR"}, 'Can not move object "%s"' % obj.name) + 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): + def __get_objects(self, obj: Object) -> Any: class __MovableList(list): - def move(self, index_old, index_new): + def move(self, index_old: int, index_new: int) -> None: item = self[index_old] self.remove(item) self.insert(index_new, item) @@ -102,11 +108,11 @@ class CleanShapeKeys(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: return any(o.type == "MESH" for o in context.selected_objects) @staticmethod - def __can_remove(key_block): + def __can_remove(key_block: ShapeKey) -> bool: if key_block.relative_key == key_block: return False # Basis for v0, v1 in zip(key_block.relative_key.data, key_block.data): @@ -114,20 +120,24 @@ class CleanShapeKeys(bpy.types.Operator): return False return True - def __shape_key_clean(self, obj, key_blocks): + def __shape_key_clean(self, obj: Object, key_blocks: List[ShapeKey]) -> None: 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): - obj: bpy.types.Object + def execute(self, context: Context) -> Set[str]: + logger.info("Cleaning shape keys for selected objects") + obj: 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"} @@ -144,21 +154,25 @@ class SeparateByMaterials(bpy.types.Operator): ) @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: obj = context.active_object return obj and obj.type == "MESH" - def __separate_by_materials(self, obj): + def __separate_by_materials(self, obj: Object) -> None: + logger.info(f"Separating {obj.name} by materials") utils.separateByMaterials(obj) if self.clean_shape_keys: + logger.debug("Cleaning shape keys after separation") bpy.ops.mmd_tools.clean_shape_keys() - def execute(self, context): + def execute(self, context: Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) 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() @@ -171,9 +185,11 @@ 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"} @@ -191,13 +207,15 @@ class JoinMeshes(bpy.types.Operator): default=True, ) - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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() @@ -205,9 +223,11 @@ 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) @@ -216,15 +236,19 @@ 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"} @@ -238,17 +262,20 @@ class AttachMeshesToMMD(bpy.types.Operator): add_armature_modifier: bpy.props.BoolProperty(default=True) - def execute(self, context: bpy.types.Context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -268,17 +295,18 @@ class ChangeMMDIKLoopFactor(bpy.types.Operator): ) @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: return FnModel.find_root_object(context.active_object) is not None - def invoke(self, context, event): + def invoke(self, context: Context, event: Any) -> Set[str]: 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): + def execute(self, context: Context) -> Set[str]: 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"} @@ -290,21 +318,22 @@ class RecalculateBoneRoll(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: Context) -> bool: obj = context.active_object return obj and obj.type == "ARMATURE" - def invoke(self, context, event): + def invoke(self, context: Context, event: Any) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) - def draw(self, context): + def draw(self, context: Context) -> None: 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): + def execute(self, context: Context) -> Set[str]: 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.py b/core/mmd/operators/model.py index 16fe3ba..c4edf30 100644 --- a/core/mmd/operators/model.py +++ b/core/mmd/operators/model.py @@ -6,10 +6,12 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import bpy +from typing import Optional, Set, Dict, Any, List, Tuple, Union from ..bpyutils import FnContext from ..core.bone import FnBone, MigrationFnBone from ..core.model import FnModel, Model +from ....core.logging_setup import logger class MorphSliderSetup(bpy.types.Operator): @@ -29,18 +31,22 @@ class MorphSliderSetup(bpy.types.Operator): default="CREATE", ) - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None + logger.debug(f"Executing MorphSliderSetup with type: {self.type}") with FnContext.temp_override_active_layer_collection(context, root_object): rig = Model(root_object) if self.type == "BIND": + logger.info(f"Binding morph sliders for {root_object.name}") rig.morph_slider.bind() elif self.type == "UNBIND": + logger.info(f"Unbinding morph sliders for {root_object.name}") rig.morph_slider.unbind() else: + logger.info(f"Creating morph sliders for {root_object.name}") rig.morph_slider.create() FnContext.set_active_object(context, active_object) @@ -53,10 +59,11 @@ class CleanRiggingObjects(bpy.types.Operator): bl_description = "Delete temporary physics objects of selected object and revert physics to default MMD state" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: root_object = FnModel.find_root_object(context.active_object) assert root_object is not None + logger.info(f"Cleaning rig for {root_object.name}") rig = Model(root_object) rig.clean() FnContext.set_active_object(context, root_object) @@ -86,9 +93,10 @@ class BuildRig(bpy.types.Operator): default=1e-06, ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: root_object = FnModel.find_root_object(context.active_object) + logger.info(f"Building rig for {root_object.name} with non_collision_distance_scale={self.non_collision_distance_scale}, collision_margin={self.collision_margin}") with FnContext.temp_override_active_layer_collection(context, root_object): rig = Model(root_object) rig.build(self.non_collision_distance_scale, self.collision_margin) @@ -103,11 +111,14 @@ class CleanAdditionalTransformConstraints(bpy.types.Operator): bl_description = "Delete shadow bones of selected object and revert bones to default MMD state" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None - FnBone.clean_additional_transformation(FnModel.find_armature_object(root_object)) + + logger.info(f"Cleaning additional transform constraints for {root_object.name}") + armature_object = FnModel.find_armature_object(root_object) + FnBone.clean_additional_transformation(armature_object) FnContext.set_active_object(context, active_object) return {"FINISHED"} @@ -118,11 +129,12 @@ class ApplyAdditionalTransformConstraints(bpy.types.Operator): bl_description = "Translate appended bones of selected object for Blender" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None + logger.info(f"Applying additional transform constraints for {root_object.name}") armature_object = FnModel.find_armature_object(root_object) assert armature_object is not None @@ -149,12 +161,14 @@ class SetupBoneFixedAxes(bpy.types.Operator): default="LOAD", ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: armature_object = context.active_object if not armature_object or armature_object.type != "ARMATURE": self.report({"ERROR"}, "Active object is not an armature object") + logger.error("Setup Bone Fixed Axis failed: Active object is not an armature object") return {"CANCELLED"} + logger.info(f"Setting up bone fixed axes with type: {self.type}") if self.type == "APPLY": FnBone.apply_bone_fixed_axis(armature_object) FnBone.apply_additional_transformation(armature_object) @@ -180,12 +194,14 @@ class SetupBoneLocalAxes(bpy.types.Operator): default="LOAD", ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: armature_object = context.active_object if not armature_object or armature_object.type != "ARMATURE": self.report({"ERROR"}, "Active object is not an armature object") + logger.error("Setup Bone Local Axes failed: Active object is not an armature object") return {"CANCELLED"} + logger.info(f"Setting up bone local axes with type: {self.type}") if self.type == "APPLY": FnBone.apply_bone_local_axes(armature_object) FnBone.apply_additional_transformation(armature_object) @@ -207,16 +223,18 @@ class AddMissingVertexGroupsFromBones(bpy.types.Operator): ) @classmethod - def poll(cls, context: bpy.types.Context): + def poll(cls, context: bpy.types.Context) -> bool: return FnModel.find_root_object(context.active_object) is not None - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object: bpy.types.Object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None + logger.info(f"Adding missing vertex groups from bones for {root_object.name}, search_in_all_meshes={self.search_in_all_meshes}") bone_order_mesh_object = FnModel.find_bone_order_mesh_object(root_object) if bone_order_mesh_object is None: + logger.error("Failed to find bone order mesh object") return {"CANCELLED"} FnModel.add_missing_vertex_groups_from_bones(root_object, bone_order_mesh_object, self.search_in_all_meshes) @@ -246,12 +264,13 @@ class CreateMMDModelRoot(bpy.types.Operator): default=0.08, ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: + logger.info(f"Creating MMD model root object with name_j={self.name_j}, name_e={self.name_e}, scale={self.scale}") rig = Model.create(self.name_j, self.name_e, self.scale, add_root_bone=True) rig.initialDisplayFrames() return {"FINISHED"} - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) @@ -305,15 +324,16 @@ class ConvertToMMDModel(bpy.types.Operator): ) @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: obj = context.active_object return obj and obj.type == "ARMATURE" and obj.mode != "EDIT" - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: + logger.info(f"Converting to MMD model with scale={self.scale}, convert_material_nodes={self.convert_material_nodes}") # TODO convert some basic MMD properties armature_object = context.active_object scale = self.scale @@ -321,29 +341,31 @@ class ConvertToMMDModel(bpy.types.Operator): root_object = FnModel.find_root_object(armature_object) if root_object is None or root_object != armature_object.parent: + logger.debug("Creating new MMD model") Model.create(model_name, model_name, scale, armature_object=armature_object) self.__attach_meshes_to(armature_object, FnContext.get_scene_objects(context)) self.__configure_rig(context, Model(armature_object.parent)) return {"FINISHED"} - def __attach_meshes_to(self, armature_object: bpy.types.Object, objects: bpy.types.SceneObjects): - def __is_child_of_armature(mesh): + def __attach_meshes_to(self, armature_object: bpy.types.Object, objects: bpy.types.SceneObjects) -> None: + def __is_child_of_armature(mesh: bpy.types.Object) -> bool: if mesh.parent is None: return False return mesh.parent == armature_object or __is_child_of_armature(mesh.parent) - def __is_using_armature(mesh): + def __is_using_armature(mesh: bpy.types.Object) -> bool: for m in mesh.modifiers: if m.type == "ARMATURE" and m.object == armature_object: return True return False - def __get_root(mesh): + def __get_root(mesh: bpy.types.Object) -> bpy.types.Object: if mesh.parent is None: return mesh return __get_root(mesh.parent) + attached_count = 0 for x in objects: if __is_using_armature(x) and not __is_child_of_armature(x): x_root = __get_root(x) @@ -351,27 +373,35 @@ class ConvertToMMDModel(bpy.types.Operator): x_root.parent_type = "OBJECT" x_root.parent = armature_object x_root.matrix_world = m + attached_count += 1 + + logger.debug(f"Attached {attached_count} meshes to armature") - def __configure_rig(self, context: bpy.types.Context, mmd_model: Model): + def __configure_rig(self, context: bpy.types.Context, mmd_model: Model) -> None: root_object = mmd_model.rootObject() armature_object = mmd_model.armature() mesh_objects = tuple(mmd_model.meshes()) + logger.info(f"Configuring rig for {root_object.name} with {len(mesh_objects)} meshes") mmd_model.loadMorphs() if self.middle_joint_bones_lock: vertex_groups = {g.name for mesh in mesh_objects for g in mesh.vertex_groups} + locked_bones = 0 for pose_bone in armature_object.pose.bones: if not pose_bone.parent: continue if not pose_bone.bone.use_connect and pose_bone.name not in vertex_groups: continue pose_bone.lock_location = (True, True, True) + locked_bones += 1 + logger.debug(f"Locked {locked_bones} middle joint bones") from ..core.material import FnMaterial FnMaterial.set_nodes_are_readonly(not self.convert_material_nodes) try: + converted_materials = 0 for m in (x for mesh in mesh_objects for x in mesh.data.materials if x): FnMaterial.convert_to_mmd_material(m, context) mmd_material = m.mmd_material @@ -384,6 +414,8 @@ class ConvertToMMDModel(bpy.types.Operator): line_color = list(m.line_color) mmd_material.enabled_toon_edge = line_color[3] >= self.edge_threshold mmd_material.edge_color = line_color[:3] + [max(line_color[3], self.edge_alpha_min)] + converted_materials += 1 + logger.debug(f"Converted {converted_materials} materials") finally: FnMaterial.set_nodes_are_readonly(False) from .display_item import DisplayItemQuickSetup @@ -400,16 +432,17 @@ class ResetObjectVisibility(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context: bpy.types.Context): + def poll(cls, context: bpy.types.Context) -> bool: active_object: bpy.types.Object = context.active_object return FnModel.find_root_object(active_object) is not None - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object: bpy.types.Object = context.active_object mmd_root_object = FnModel.find_root_object(active_object) assert mmd_root_object is not None mmd_root = mmd_root_object.mmd_root + logger.info(f"Resetting object visibility for {mmd_root_object.name}") mmd_root_object.hide_set(False) rigid_group_object = FnModel.find_rigid_group_object(mmd_root_object) @@ -440,11 +473,12 @@ class AssembleAll(bpy.types.Operator): bl_label = "Assemble All" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None + logger.info(f"Assembling all components for {root_object.name}") with FnContext.temp_override_active_layer_collection(context, root_object) as context: rig = Model(root_object) MigrationFnBone.fix_mmd_ik_limit_override(rig.armature()) @@ -452,6 +486,7 @@ class AssembleAll(bpy.types.Operator): rig.build() rig.morph_slider.bind() + logger.debug("Binding SDEF weights") with context.temp_override(selected_objects=[active_object]): bpy.ops.mmd_tools.sdef_bind() root_object.mmd_root.use_property_driver = True @@ -466,13 +501,15 @@ class DisassembleAll(bpy.types.Operator): bl_label = "Disassemble All" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = FnModel.find_root_object(active_object) assert root_object is not None + logger.info(f"Disassembling all components for {root_object.name}") with FnContext.temp_override_active_layer_collection(context, root_object) as context: root_object.mmd_root.use_property_driver = False + logger.debug("Unbinding SDEF weights") with context.temp_override(selected_objects=[active_object]): bpy.ops.mmd_tools.sdef_unbind() diff --git a/core/mmd/operators/model_edit.py b/core/mmd/operators/model_edit.py index ca21046..632ae5e 100644 --- a/core/mmd/operators/model_edit.py +++ b/core/mmd/operators/model_edit.py @@ -7,13 +7,17 @@ import itertools from operator import itemgetter -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, Tuple, Any import bmesh import bpy +import numpy as np +import numpy.typing as npt +from bpy.types import Context, Object, Operator, EditBone, Mesh, Armature from ..bpyutils import FnContext from ..core.model import FnModel, Model +from ....core.logging_setup import logger class MessageException(Exception): @@ -35,8 +39,8 @@ class ModelJoinByBonesOperator(bpy.types.Operator): ) @classmethod - def poll(cls, context: bpy.types.Context): - active_object: Optional[bpy.types.Object] = context.active_object + def poll(cls, context: Context) -> bool: + active_object: Optional[Object] = context.active_object if context.mode != "POSE": return False @@ -52,19 +56,22 @@ class ModelJoinByBonesOperator(bpy.types.Operator): return len(context.selected_pose_bones) > 0 - def invoke(self, context, event): + def invoke(self, context: Context, event: Any) -> Set[str]: return context.window_manager.invoke_props_dialog(self) - def execute(self, context: bpy.types.Context): + def execute(self, context: Context) -> Set[str]: 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)}") self.report(type={"ERROR"}, message=str(ex)) return {"CANCELLED"} return {"FINISHED"} - def join(self, context: bpy.types.Context): + def join(self, context: Context) -> None: bpy.ops.object.mode_set(mode="OBJECT") parent_root_object = FnModel.find_root_object(context.active_object) @@ -74,6 +81,7 @@ class ModelJoinByBonesOperator(bpy.types.Operator): if parent_root_object is None or len(child_root_objects) == 0: raise MessageException("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) @@ -82,11 +90,12 @@ class ModelJoinByBonesOperator(bpy.types.Operator): # Connect child bones if self.join_type == "CONNECTED": - parent_edit_bone: bpy.types.EditBone = context.active_bone - child_edit_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + parent_edit_bone: EditBone = context.active_bone + child_edit_bones: Set[EditBone] = set(context.selected_bones) child_edit_bones.remove(parent_edit_bone) - child_edit_bone: bpy.types.EditBone + logger.debug(f"Connecting {len(child_edit_bones)} child bones to parent bone: {parent_edit_bone.name}") + child_edit_bone: EditBone for child_edit_bone in child_edit_bones: child_edit_bone.use_connect = True @@ -111,8 +120,8 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): ) @classmethod - def poll(cls, context: bpy.types.Context): - active_object: Optional[bpy.types.Object] = context.active_object + def poll(cls, context: Context) -> bool: + active_object: Optional[Object] = context.active_object if context.mode != "POSE": return False @@ -128,56 +137,70 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): return len(context.selected_pose_bones) > 0 - def invoke(self, context, event): + def invoke(self, context: Context, event: Any) -> Set[str]: return context.window_manager.invoke_props_dialog(self) - def execute(self, context: bpy.types.Context): + def execute(self, context: Context) -> Set[str]: 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)}") self.report(type={"ERROR"}, message=str(ex)) return {"CANCELLED"} return {"FINISHED"} - def separate(self, context: bpy.types.Context): + def separate(self, context: Context) -> None: weight_threshold: float = self.weight_threshold mmd_scale = 0.08 - target_armature_object: bpy.types.Object = context.active_object + target_armature_object: Object = context.active_object + logger.debug(f"Target armature: {target_armature_object.name}") bpy.ops.object.mode_set(mode="EDIT") - root_bones: Set[bpy.types.EditBone] = set(context.selected_bones) + root_bones: Set[EditBone] = set(context.selected_bones) + logger.debug(f"Selected root bones: {len(root_bones)}") if self.include_descendant_bones: + logger.debug("Including descendant bones") for edit_bone in root_bones: with context.temp_override(active_bone=edit_bone): bpy.ops.armature.select_similar(type="CHILDREN", threshold=0.1) - 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} + 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: bpy.types.Object = FnModel.find_root_object(context.active_object) + mmd_root_object: Object = FnModel.find_root_object(context.active_object) mmd_model = Model(mmd_root_object) - mmd_model_mesh_objects: List[bpy.types.Object] = list(mmd_model.meshes()) + mmd_model_mesh_objects: List[Object] = list(mmd_model.meshes()) + logger.debug(f"Found {len(mmd_model_mesh_objects)} mesh objects in model") - mmd_model_mesh_objects = list(self.select_weighted_vertices(mmd_model_mesh_objects, separate_bones, deform_bones, weight_threshold).keys()) + 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[bpy.types.Object] + 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}") 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} + 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") boundary_joint_owner_condition = any if self.boundary_joint_owner == "DESTINATION" else all # collect separate joints - separate_joints: Set[bpy.types.Object] = { + separate_joints: Set[Object] = { joint_object for joint_object in mmd_model.joints() if boundary_joint_owner_condition( @@ -187,35 +210,43 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): ] ) } + logger.debug(f"Found {len(separate_joints)} joints to separate") - separate_mesh_objects: Set[bpy.types.Object] - model2separate_mesh_objects: Dict[bpy.types.Object, bpy.types.Object] + 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 - obj: bpy.types.Object + logger.debug("Selecting meshes for separation") + obj: 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") bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.separate(type="SELECTED") - separate_mesh_objects: List[bpy.types.Object] = [m for m in context.selected_objects if m.type == "MESH" and m not in mmd_model_mesh_objects] + separate_mesh_objects: List[Object] = [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)) + 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) 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}") 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], @@ -223,6 +254,7 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): bpy.ops.object.join() # 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], @@ -230,19 +262,23 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) # replace mesh armature modifier.object + logger.debug("Updating armature modifiers on separate meshes") 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.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) + logger.debug("Parenting joints to new model") with context.temp_override( object=separate_model.jointGroupObject(), selected_editable_objects=[separate_model.jointGroupObject(), *separate_joints], @@ -257,10 +293,12 @@ 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) + logger.debug("Copying MMD root properties") FnModel.copy_mmd_root( separate_root_object, mmd_root_object, @@ -271,13 +309,15 @@ class ModelSeparateByBonesOperator(bpy.types.Operator): }, ) - 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] = dict() + 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() 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: bpy.types.Mesh = mesh_object.data + mesh: 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() @@ -304,6 +344,7 @@ 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) diff --git a/core/mmd/operators/morph.py b/core/mmd/operators/morph.py index 1b34420..1201659 100644 --- a/core/mmd/operators/morph.py +++ b/core/mmd/operators/morph.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. -from typing import Optional, cast +from typing import Optional, cast, List, Dict, Any, Set, Tuple, Union import bpy from mathutils import Quaternion, Vector @@ -16,10 +16,11 @@ from ..core.exceptions import MaterialNotFoundError from ..core.material import FnMaterial from ..core.morph import FnMorph from ..utils import ItemMoveOp, ItemOp +from ....logging_setup import logger # Util functions -def divide_vector_components(vec1, vec2): +def divide_vector_components(vec1: List[float], vec2: List[float]) -> List[float]: if len(vec1) != len(vec2): raise ValueError("Vectors should have the same number of components") result = [] @@ -33,7 +34,7 @@ def divide_vector_components(vec1, vec2): return result -def multiply_vector_components(vec1, vec2): +def multiply_vector_components(vec1: List[float], vec2: List[float]) -> List[float]: if len(vec1) != len(vec2): raise ValueError("Vectors should have the same number of components") result = [] @@ -42,7 +43,7 @@ def multiply_vector_components(vec1, vec2): return result -def special_division(n1, n2): +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""" if n2 == 0: if n1 == 0: @@ -58,7 +59,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -68,6 +69,7 @@ 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"} @@ -84,7 +86,7 @@ class RemoveMorph(bpy.types.Operator): options={"SKIP_SAVE"}, ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -99,9 +101,11 @@ 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"} @@ -111,7 +115,7 @@ class MoveMorph(bpy.types.Operator, ItemMoveOp): bl_description = "Move active morph item up/down in the list" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -120,6 +124,7 @@ 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"} @@ -129,7 +134,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -156,6 +161,7 @@ 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"} @@ -165,17 +171,17 @@ class OverwriteBoneMorphsFromActionPose(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: root = FnModel.find_root_object(context.active_object) if root is None: return False return root.mmd_root.active_morph_type == "bone_morphs" - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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"} @@ -185,7 +191,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -210,6 +216,7 @@ 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"} @@ -226,7 +233,7 @@ class RemoveMorphOffset(bpy.types.Operator): options={"SKIP_SAVE"}, ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -243,17 +250,21 @@ 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.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"} @@ -269,7 +280,7 @@ class InitMaterialOffset(bpy.types.Operator): default=0, ) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -281,6 +292,7 @@ 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"} @@ -290,7 +302,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -328,6 +340,7 @@ 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 @@ -345,6 +358,7 @@ 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"} @@ -354,7 +368,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -413,6 +427,7 @@ 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"} @@ -422,13 +437,13 @@ class ClearTempMaterials(bpy.types.Operator): bl_description = "Clears all the temporary materials" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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): + def __pre_remove(m: Optional[bpy.types.Material]) -> bool: if m and "_temp" in m.name: base_mat_name = m.name.split("_temp")[0] try: @@ -439,6 +454,7 @@ class ClearTempMaterials(bpy.types.Operator): return False FnMaterial.clean_materials(meshObj, can_remove=__pre_remove) + logger.info("Cleared all temporary materials") return {"FINISHED"} @@ -448,7 +464,7 @@ class ViewBoneMorph(bpy.types.Operator): bl_description = "View the result of active bone morph" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -463,6 +479,7 @@ class ViewBoneMorph(bpy.types.Operator): 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"} @@ -472,13 +489,14 @@ 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): + def execute(self, context: bpy.types.Context) -> Set[str]: 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"} @@ -488,7 +506,7 @@ class ApplyBoneMorph(bpy.types.Operator): bl_description = "Apply current pose to active bone morph" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -506,6 +524,7 @@ class ApplyBoneMorph(bpy.types.Operator): p_bone.bone.select = True else: p_bone.bone.select = False + logger.info(f"Applied current pose to bone morph: {morph.name}") return {"FINISHED"} @@ -515,7 +534,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -524,6 +543,7 @@ 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"} @@ -533,7 +553,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -546,6 +566,7 @@ 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"} @@ -555,7 +576,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -567,6 +588,7 @@ 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"} @@ -576,7 +598,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): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -627,6 +649,7 @@ class ViewUVMorph(bpy.types.Operator): uv_tex.active_render = True meshObj.hide_set(False) meshObj.select_set(selected) + logger.info(f"Viewing UV morph: {morph.name}") return {"FINISHED"} @@ -636,7 +659,7 @@ class ClearUVMorphView(bpy.types.Operator): bl_description = "Clear all temporary data of UV morphs" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) assert root is not None @@ -664,6 +687,7 @@ class ClearUVMorphView(bpy.types.Operator): for act in 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"} @@ -674,14 +698,14 @@ class EditUVMorph(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: obj = context.active_object if obj.type != "MESH": return False active_uv_layer = obj.data.uv_layers.active return active_uv_layer and active_uv_layer.name.startswith("__uv.") - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object meshObj = obj @@ -704,6 +728,7 @@ class EditUVMorph(bpy.types.Operator): bpy.ops.object.mode_set(mode="EDIT") meshObj.select_set(selected) + logger.info("Editing UV morph") return {"FINISHED"} @@ -714,14 +739,14 @@ class ApplyUVMorph(bpy.types.Operator): bl_options = {"REGISTER", "UNDO", "INTERNAL"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: obj = context.active_object if obj.type != "MESH": return False active_uv_layer = obj.data.uv_layers.active return active_uv_layer and active_uv_layer.name.startswith("__uv.") - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) mmd_root = root.mmd_root @@ -756,6 +781,7 @@ class ApplyUVMorph(bpy.types.Operator): morph.data_type = "VERTEX_GROUP" meshObj.select_set(selected) + logger.info(f"Applied UV morph: {morph.name}") return {"FINISHED"} @@ -766,11 +792,12 @@ class CleanDuplicatedMaterialMorphs(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: return FnModel.find_root_object(context.active_object) is not None - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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"} diff --git a/core/mmd/operators/rigid_body.py b/core/mmd/operators/rigid_body.py index 22e3515..ef91c47 100644 --- a/core/mmd/operators/rigid_body.py +++ b/core/mmd/operators/rigid_body.py @@ -6,7 +6,7 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import math -from typing import Dict, Optional, Tuple, cast +from typing import Dict, Optional, Tuple, cast, Set, List, Any, Union, Generator import bpy from mathutils import Euler, Vector @@ -16,6 +16,7 @@ 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): @@ -43,15 +44,15 @@ class SelectRigidBody(bpy.types.Operator): default=False, ) - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: return FnModel.is_rigid_body_object(context.active_object) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: obj = context.active_object root = FnModel.find_root_object(obj) if root is None: @@ -173,7 +174,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): + def __add_rigid_body(self, context: bpy.types.Context, root_object: bpy.types.Object, pose_bone: Optional[bpy.types.PoseBone] = None) -> bpy.types.Object: name_j: str = self.name_j name_e: str = self.name_e size = self.size.copy() @@ -226,7 +227,7 @@ class AddRigidBody(bpy.types.Operator): ) @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: root_object = FnModel.find_root_object(context.active_object) if root_object is None: return False @@ -237,7 +238,7 @@ class AddRigidBody(bpy.types.Operator): return True - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: active_object = context.active_object root_object = cast(bpy.types.Object, FnModel.find_root_object(active_object)) @@ -254,15 +255,17 @@ 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, event): + def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: no_bone = True if context.selected_bones and len(context.selected_bones) > 0: no_bone = False @@ -288,12 +291,13 @@ class RemoveRigidBody(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: return FnModel.is_rigid_body_object(context.active_object) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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: @@ -306,7 +310,8 @@ class RigidBodyBake(bpy.types.Operator): bl_label = "Bake" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: + logger.info("Baking rigid body simulation") with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): bpy.ops.ptcache.bake("INVOKE_DEFAULT", bake=True) @@ -318,7 +323,8 @@ class RigidBodyDeleteBake(bpy.types.Operator): bl_label = "Delete Bake" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def execute(self, context: bpy.types.Context): + def execute(self, context: bpy.types.Context) -> Set[str]: + logger.info("Deleting rigid body simulation bake") with context.temp_override(scene=context.scene, point_cache=context.scene.rigidbody_world.point_cache): bpy.ops.ptcache.free_bake("INVOKE_DEFAULT") @@ -381,7 +387,7 @@ class AddJoint(bpy.types.Operator): min=0, ) - def __enumerate_rigid_pair(self, bone_map: Dict[bpy.types.Object, Optional[bpy.types.Bone]]): + 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]: obj_seq = tuple(bone_map.keys()) for rigid_a, bone_a in bone_map.items(): for rigid_b, bone_b in bone_map.items(): @@ -394,7 +400,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): + 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: loc: Optional[Vector] = None rot = Euler((0.0, 0.0, 0.0)) rigid_a, rigid_b = rigid_pair @@ -432,7 +438,7 @@ class AddJoint(bpy.types.Operator): ) @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: root_object = FnModel.find_root_object(context.active_object) if root_object is None: return False @@ -443,7 +449,7 @@ class AddJoint(bpy.types.Operator): return True - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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)) @@ -456,15 +462,19 @@ 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, event): + def invoke(self, context: bpy.types.Context, event: Any) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) @@ -476,12 +486,13 @@ class RemoveJoint(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: return FnModel.is_joint_object(context.active_object) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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: @@ -496,7 +507,7 @@ class UpdateRigidBodyWorld(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} @staticmethod - def __get_rigid_body_world_objects(): + def __get_rigid_body_world_objects() -> Tuple[bpy.types.Collection, bpy.types.Collection]: rigid_body.setRigidBodyWorldEnabled(True) rbw = bpy.context.scene.rigidbody_world if not rbw.collection: @@ -511,12 +522,12 @@ class UpdateRigidBodyWorld(bpy.types.Operator): return rbw.collection.objects, rbw.constraints.objects - def execute(self, context): + def execute(self, context: bpy.types.Context) -> Set[str]: 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, group): + def _update_group(obj: bpy.types.Object, group: bpy.types.Collection) -> bool: if obj in scene_objs: if obj not in group.values(): group.link(obj) @@ -525,7 +536,7 @@ class UpdateRigidBodyWorld(bpy.types.Operator): group.unlink(obj) return False - def _references(obj): + def _references(obj: bpy.types.Object) -> Generator[bpy.types.Object, None, None]: yield obj if getattr(obj, "proxy", None): yield from _references(obj.proxy) @@ -542,6 +553,7 @@ 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 @@ -556,6 +568,7 @@ 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 @@ -566,6 +579,7 @@ 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 e38badd..bb46807 100644 --- a/core/mmd/operators/sdef.py +++ b/core/mmd/operators/sdef.py @@ -5,18 +5,19 @@ # 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. -from typing import Set +from typing import Set, Tuple import bpy -from bpy.types import Operator +from bpy.types import Operator, Context, Object from ..core.model import FnModel from ..core.sdef import FnSDEF +from ....core.logging_setup import logger -def _get_target_objects(context): - root_objects: Set[bpy.types.Object] = set() - selected_objects: Set[bpy.types.Object] = set() +def _get_target_objects(context: Context) -> Tuple[Set[Object], Set[Object]]: + root_objects: Set[Object] = set() + selected_objects: Set[Object] = set() for i in context.selected_objects: if i.type == "MESH": selected_objects.add(i) @@ -40,11 +41,13 @@ 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): + def execute(self, context: Context) -> Set[str]: 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"} @@ -75,19 +78,20 @@ class BindSDEF(Operator): default=False, ) - def invoke(self, context, event): + def invoke(self, context: Context, event: bpy.types.Event) -> Set[str]: vm = context.window_manager return vm.invoke_props_dialog(self) - # TODO: Utility Functionalize - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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"} @@ -98,13 +102,15 @@ class UnbindSDEF(Operator): bl_description = "Unbind MMD SDEF data of selected objects" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - # TODO: Utility Functionalize - def execute(self, context): + def execute(self, context: Context) -> Set[str]: 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/view.py b/core/mmd/operators/view.py index 0072312..3e82cf4 100644 --- a/core/mmd/operators/view.py +++ b/core/mmd/operators/view.py @@ -6,29 +6,32 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import re +from typing import Optional, Tuple, List, Set, Dict, Any, Generator, Callable, Union, Type, Iterator -from bpy.types import Operator -from mathutils import Matrix +from bpy.types import Operator, Context +from mathutils import Matrix, Vector, Quaternion + +from ...logging_setup import logger class _SetShadingBase: - bl_options = {"REGISTER", "UNDO"} + bl_options: Set[str] = {"REGISTER", "UNDO"} @staticmethod - def _get_view3d_spaces(context): + def _get_view3d_spaces(context: Context) -> Iterator[Any]: 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, use_display_device=True): + def _reset_color_management(context: Context, use_display_device: bool = True) -> None: try: context.scene.display_settings.display_device = ("None", "sRGB")[use_display_device] except TypeError: pass @staticmethod - def _reset_material_shading(context, use_shadeless=False): + def _reset_material_shading(context: Context, use_shadeless: bool = False) -> None: 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: @@ -36,10 +39,11 @@ class _SetShadingBase: s.material.use_nodes = False s.material.use_shadeless = use_shadeless - def execute(self, context): + def execute(self, context: Context) -> Dict[str, str]: context.scene.render.engine = "BLENDER_EEVEE_NEXT" + logger.debug(f"Setting render engine to BLENDER_EEVEE_NEXT") - shading_mode = getattr(self, "_shading_mode", None) + shading_mode: Optional[str] = getattr(self, "_shading_mode", None) for space in self._get_view3d_spaces(context): shading = space.shading shading.type = "SOLID" @@ -47,39 +51,40 @@ 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 = "mmd_tools.set_glsl_shading" - bl_label = "GLSL View" - bl_description = "Use GLSL shading with additional lighting" + bl_idname: str = "mmd_tools.set_glsl_shading" + bl_label: str = "GLSL View" + bl_description: str = "Use GLSL shading with additional lighting" - _shading_mode = "GLSL" + _shading_mode: str = "GLSL" class SetShadelessGLSLShading(Operator, _SetShadingBase): - bl_idname = "mmd_tools.set_shadeless_glsl_shading" - bl_label = "Shadeless GLSL View" - bl_description = "Use only toon shading" + bl_idname: str = "mmd_tools.set_shadeless_glsl_shading" + bl_label: str = "Shadeless GLSL View" + bl_description: str = "Use only toon shading" - _shading_mode = "SHADELESS" + _shading_mode: str = "SHADELESS" class ResetShading(Operator, _SetShadingBase): - bl_idname = "mmd_tools.reset_shading" - bl_label = "Reset View" - bl_description = "Reset to default Blender shading" + bl_idname: str = "mmd_tools.reset_shading" + bl_label: str = "Reset View" + bl_description: str = "Reset to default Blender shading" class FlipPose(Operator): - 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"} + 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"} # https://docs.blender.org/manual/en/dev/rigging/armatures/bones/editing/naming.html - __LR_REGEX = [ + __LR_REGEX: List[Dict[str, Any]] = [ {"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}, @@ -87,7 +92,7 @@ class FlipPose(Operator): {"re": re.compile(r"^(.+)(左|右)(\.\d+)?$"), "lr": 1}, {"re": re.compile(r"^(左|右)(.+)$"), "lr": 0}, ] - __LR_MAP = { + __LR_MAP: Dict[str, str] = { "RIGHT": "LEFT", "Right": "Left", "right": "left", @@ -103,7 +108,7 @@ class FlipPose(Operator): } @classmethod - def flip_name(cls, name): + def flip_name(cls, name: str) -> str: for regex in cls.__LR_REGEX: match = regex["re"].match(name) if match: @@ -121,17 +126,15 @@ class FlipPose(Operator): return "" @staticmethod - def __cmul(vec1, vec2): + 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)]) @staticmethod - def __matrix_compose(loc, rot, scale): + def __matrix_compose(loc: Vector, rot: Quaternion, scale: Vector) -> Matrix: 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, bone_src, bone_dest): - from mathutils import Quaternion - + def __flip_pose(cls, matrix_basis: Matrix, bone_src: Any, bone_dest: Any) -> None: 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() @@ -140,11 +143,16 @@ 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): + def poll(cls, context: Context) -> bool: return context.active_object and context.active_object.type == "ARMATURE" and context.active_object.mode == "POSE" - def execute(self, context): + def execute(self, context: Context) -> Dict[str, str]: + logger.info("Executing flip pose operation") pose_bones = context.active_object.pose.bones for b, mat in [(x, x.matrix_basis.copy()) for x in context.selected_pose_bones]: - self.__flip_pose(mat, b, pose_bones.get(self.flip_name(b.name), b)) + 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") return {"FINISHED"} diff --git a/core/mmd/properties/material.py b/core/mmd/properties/material.py index d3df3a3..b597c5d 100644 --- a/core/mmd/properties/material.py +++ b/core/mmd/properties/material.py @@ -6,81 +6,85 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. 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): +def _mmd_material_update_ambient_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_ambient_color() -def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context): +def _mmd_material_update_diffuse_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_diffuse_color() -def _mmd_material_update_alpha(prop: "MMDMaterial", _context): +def _mmd_material_update_alpha(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_alpha() -def _mmd_material_update_specular_color(prop: "MMDMaterial", _context): +def _mmd_material_update_specular_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_specular_color() -def _mmd_material_update_shininess(prop: "MMDMaterial", _context): +def _mmd_material_update_shininess(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_shininess() -def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context): +def _mmd_material_update_is_double_sided(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_is_double_sided() -def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context): +def _mmd_material_update_sphere_texture_type(prop: "MMDMaterial", context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_sphere_texture_type(context.active_object) -def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context): +def _mmd_material_update_toon_texture(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_toon_texture() -def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context): +def _mmd_material_update_enabled_drop_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_drop_shadow() -def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context): +def _mmd_material_update_enabled_self_shadow_map(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_self_shadow_map() -def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context): +def _mmd_material_update_enabled_self_shadow(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_self_shadow() -def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context): +def _mmd_material_update_enabled_toon_edge(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_enabled_toon_edge() -def _mmd_material_update_edge_color(prop: "MMDMaterial", _context): +def _mmd_material_update_edge_color(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_edge_color() -def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context): +def _mmd_material_update_edge_weight(prop: "MMDMaterial", _context: bpy.types.Context) -> None: FnMaterial(prop.id_data).update_edge_weight() -def _mmd_material_get_name_j(prop: "MMDMaterial"): +def _mmd_material_get_name_j(prop: "MMDMaterial") -> str: return prop.get("name_j", "") -def _mmd_material_set_name_j(prop: "MMDMaterial", value: str): +def _mmd_material_set_name_j(prop: "MMDMaterial", value: str) -> None: 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 @@ -275,13 +279,15 @@ class MMDMaterial(bpy.types.PropertyGroup): description="Comment", ) - def is_id_unique(self): + def is_id_unique(self) -> bool: 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(): + def register() -> None: + logger.debug("Registering MMD material properties") bpy.types.Material.mmd_material = patch_library_overridable(bpy.props.PointerProperty(type=MMDMaterial)) @staticmethod - def unregister(): + def unregister() -> None: + logger.debug("Unregistering MMD material properties") del bpy.types.Material.mmd_material diff --git a/core/mmd/properties/morph.py b/core/mmd/properties/morph.py index ba94350..e2be89b 100644 --- a/core/mmd/properties/morph.py +++ b/core/mmd/properties/morph.py @@ -6,33 +6,33 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. 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): +def _morph_base_set_name(prop: "_MorphBase", value: str) -> None: mmd_root = prop.id_data.mmd_root - # morph_type = mmd_root.active_morph_type morph_type = "%s_morphs" % prop.bl_rna.identifier[:-5].lower() - # 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 = {x.name for x in getattr(mmd_root, morph_type) if x != prop} + used_names: Set[str] = {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 = {} + kb_list: Dict[str, List[ShapeKey]] = {} 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 +43,7 @@ def _morph_base_set_name(prop: "_MorphBase", value: str): kb.name = value elif morph_type == "uv_morphs": - vg_list = {} + vg_list: Dict[str, List[Any]] = {} 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,6 +72,7 @@ def _morph_base_set_name(prop: "_MorphBase", value: str): kb.name = value prop["name"] = value + logger.debug(f"Renamed morph from '{prop_name}' to '{value}'") class _MorphBase: @@ -101,11 +102,11 @@ class _MorphBase: def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: - bone_id = prop.get("bone_id", -1) + bone_id: int = prop.get("bone_id", -1) if bone_id < 0: return "" - root_object = prop.id_data - armature_object = FnModel.find_armature_object(root_object) + root_object: Object = prop.id_data + armature_object: Optional[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) @@ -114,9 +115,9 @@ def _bone_morph_data_get_bone(prop: "BoneMorphData") -> str: return pose_bone.name -def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str): - root = prop.id_data - arm = FnModel.find_armature_object(root) +def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str) -> None: + root: Object = prop.id_data + arm: Optional[Object] = 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. @@ -128,9 +129,10 @@ def _bone_morph_data_set_bone(prop: "BoneMorphData", value: str): 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']}") -def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context): +def _bone_morph_data_update_location_or_rotation(prop: "BoneMorphData", _context: bpy.types.Context) -> None: if not prop.name.startswith("mmd_bind"): return arm = FnModel(prop.id_data).morph_slider.dummy_armature @@ -139,6 +141,7 @@ 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): @@ -188,40 +191,44 @@ class BoneMorph(_MorphBase, bpy.types.PropertyGroup): ) -def _material_morph_data_get_material(prop: "MaterialMorphData"): +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 return "" -def _material_morph_data_set_material(prop: "MaterialMorphData", value: str): +def _material_morph_data_set_material(prop: "MaterialMorphData", value: str) -> None: 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") 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}") -def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str): +def _material_morph_data_set_related_mesh(prop: "MaterialMorphData", value: str) -> None: 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}'") else: prop["related_mesh_data"] = None + logger.debug(f"Mesh '{value}' not found, setting related_mesh_data to None") -def _material_morph_data_get_related_mesh(prop): +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 return "" -def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context): +def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _context: bpy.types.Context) -> None: if not prop.name.startswith("mmd_bind"): return from ..core.shader import _MaterialMorph @@ -229,9 +236,11 @@ def _material_morph_data_update_modifiable_values(prop: "MaterialMorphData", _co 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}'") 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") class MaterialMorphData(bpy.types.PropertyGroup): @@ -407,9 +416,6 @@ 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 3584c42..84a71ef 100644 --- a/core/mmd/properties/pose_bone.py +++ b/core/mmd/properties/pose_bone.py @@ -5,29 +5,33 @@ # 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. -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: bpy.types.Context): +def _mmd_bone_update_additional_transform(prop: "MMDBone", context: Context) -> None: prop["is_additional_transform_dirty"] = True 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: bpy.types.Context): +def _mmd_bone_update_additional_transform_influence(prop: "MMDBone", context: Context) -> None: 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 -def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"): +def _mmd_bone_get_additional_transform_bone(prop: "MMDBone") -> str: arm = prop.id_data bone_id = prop.get("additional_transform_bone_id", -1) if bone_id < 0: @@ -38,7 +42,7 @@ def _mmd_bone_get_additional_transform_bone(prop: "MMDBone"): return pose_bone.name -def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str): +def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str) -> None: arm = prop.id_data prop["is_additional_transform_dirty"] = True if value not in arm.pose.bones.keys(): @@ -48,7 +52,7 @@ def _mmd_bone_set_additional_transform_bone(prop: "MMDBone", value: str): prop["additional_transform_bone_id"] = FnBone.get_or_assign_bone_id(pose_bone) -class MMDBone(bpy.types.PropertyGroup): +class MMDBone(PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -184,11 +188,12 @@ class MMDBone(bpy.types.PropertyGroup): is_additional_transform_dirty: bpy.props.BoolProperty(name="", default=True) - def is_id_unique(self): + def is_id_unique(self) -> bool: 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(): + def register() -> None: + logger.debug("Registering MMDBone properties") 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")) @@ -202,20 +207,21 @@ class MMDBone(bpy.types.PropertyGroup): ) @staticmethod - def unregister(): + def unregister() -> None: + logger.debug("Unregistering MMDBone properties") 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: bpy.types.PoseBone, _context): +def _pose_bone_update_mmd_ik_toggle(prop: PoseBone, _context: Any) -> None: v = prop.mmd_ik_toggle - armature_object = cast(bpy.types.Object, prop.id_data) + armature_object = cast(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: - # logging.debug(' %s %s', b.name, c.name) + logger.debug(f"Updating IK toggle for {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 3941657..87ef14d 100644 --- a/core/mmd/properties/rigid_body.py +++ b/core/mmd/properties/rigid_body.py @@ -8,32 +8,35 @@ """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 . import patch_library_overridable +from ....core.logging_setup import logger -def _updateCollisionGroup(prop, _context): - obj = prop.id_data - materials = obj.data.materials +def _updateCollisionGroup(prop: PropertyGroup, _context: Context) -> None: + obj: Object = prop.id_data + materials: List[Material] = 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, _context): - obj = prop.id_data +def _updateType(prop: PropertyGroup, _context: Context) -> None: + obj: Object = prop.id_data rb = obj.rigid_body if rb: rb.kinematic = int(prop.type) == rigid_body.MODE_STATIC -def _updateShape(prop, _context): - obj = prop.id_data +def _updateShape(prop: PropertyGroup, _context: Context) -> None: + obj: Object = prop.id_data if len(obj.data.vertices) > 0: size = prop.size @@ -44,8 +47,8 @@ def _updateShape(prop, _context): rb.collision_shape = prop.shape -def _get_bone(prop): - obj = prop.id_data +def _get_bone(prop: PropertyGroup) -> str: + obj: Object = prop.id_data relation = obj.constraints.get("mmd_tools_rigid_parent", None) if relation: arm = relation.target @@ -55,9 +58,9 @@ def _get_bone(prop): return prop.get("bone", "") -def _set_bone(prop, value): - bone_name = value - obj = prop.id_data +def _set_bone(prop: PropertyGroup, value: str) -> None: + bone_name: str = value + obj: Object = prop.id_data relation = obj.constraints.get("mmd_tools_rigid_parent", None) if relation is None: relation = obj.constraints.new("CHILD_OF") @@ -78,16 +81,16 @@ def _set_bone(prop, value): prop["bone"] = bone_name -def _get_size(prop): +def _get_size(prop: PropertyGroup) -> Tuple[float, float, float]: 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, value): - obj = prop.id_data +def _set_size(prop: PropertyGroup, value: Tuple[float, float, float]) -> None: + obj: Object = prop.id_data assert obj.mode == "OBJECT" # not support other mode yet - shape = prop.shape + shape: str = prop.shape mesh = obj.data rb = obj.rigid_body @@ -146,15 +149,15 @@ def _set_size(prop, value): mesh.update() -def _get_rigid_name(prop): +def _get_rigid_name(prop: PropertyGroup) -> str: return prop.get("name", "") -def _set_rigid_name(prop, value): +def _set_rigid_name(prop: PropertyGroup, value: str) -> None: prop["name"] = value -class MMDRigidBody(bpy.types.PropertyGroup): +class MMDRigidBody(PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -227,16 +230,18 @@ class MMDRigidBody(bpy.types.PropertyGroup): ) @staticmethod - def register(): + def register() -> None: + logger.debug("Registering MMDRigidBody property") bpy.types.Object.mmd_rigid = patch_library_overridable(bpy.props.PointerProperty(type=MMDRigidBody)) @staticmethod - def unregister(): + def unregister() -> None: + logger.debug("Unregistering MMDRigidBody property") del bpy.types.Object.mmd_rigid -def _updateSpringLinear(prop, context): - obj = prop.id_data +def _updateSpringLinear(prop: PropertyGroup, context: Context) -> None: + obj: Object = prop.id_data rbc = obj.rigid_body_constraint if rbc: rbc.spring_stiffness_x = prop.spring_linear[0] @@ -244,8 +249,8 @@ def _updateSpringLinear(prop, context): rbc.spring_stiffness_z = prop.spring_linear[2] -def _updateSpringAngular(prop, context): - obj = prop.id_data +def _updateSpringAngular(prop: PropertyGroup, context: Context) -> None: + obj: Object = 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] @@ -253,7 +258,7 @@ def _updateSpringAngular(prop, context): rbc.spring_stiffness_ang_z = prop.spring_angular[2] -class MMDJoint(bpy.types.PropertyGroup): +class MMDJoint(PropertyGroup): name_j: bpy.props.StringProperty( name="Name", description="Japanese Name", @@ -287,9 +292,12 @@ class MMDJoint(bpy.types.PropertyGroup): ) @staticmethod - def register(): + def register() -> None: + logger.debug("Registering MMDJoint property") bpy.types.Object.mmd_joint = patch_library_overridable(bpy.props.PointerProperty(type=MMDJoint)) @staticmethod - def unregister(): + def unregister() -> None: + logger.debug("Unregistering MMDJoint property") del bpy.types.Object.mmd_joint + diff --git a/core/mmd/properties/root.py b/core/mmd/properties/root.py index 8188ed1..679a9ff 100644 --- a/core/mmd/properties/root.py +++ b/core/mmd/properties/root.py @@ -8,6 +8,7 @@ """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 @@ -17,9 +18,10 @@ 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 -def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1): +def __driver_variables(constraint: bpy.types.Constraint, path: str, index: int = -1) -> Tuple[bpy.types.Driver, Any]: d = constraint.driver_add(path, index) variables = d.driver.variables for x in variables: @@ -27,7 +29,7 @@ def __driver_variables(constraint: bpy.types.Constraint, path: str, index=-1): return d.driver, variables -def __add_single_prop(variables, id_obj, data_path, prefix): +def __add_single_prop(variables: Any, id_obj: bpy.types.Object, data_path: str, prefix: str) -> Any: var = variables.new() var.name = prefix + str(len(variables)) var.type = "SINGLE_PROP" @@ -38,17 +40,18 @@ def __add_single_prop(variables, id_obj, data_path, prefix): return var -def _toggleUsePropertyDriver(self: "MMDRoot", _context): +def _toggleUsePropertyDriver(self: "MMDRoot", _context: bpy.types.Context) -> None: root_object: bpy.types.Object = self.id_data armature_object = FnModel.find_armature_object(root_object) if armature_object is None: - ik_map = {} + ik_map: Dict[Any, Tuple[Any, Any]] = {} 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 @@ -63,6 +66,7 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context): 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 @@ -80,31 +84,35 @@ def _toggleUsePropertyDriver(self: "MMDRoot", _context): # =========================================== -def _toggleUseToonTexture(self: "MMDRoot", _context): +def _toggleUseToonTexture(self: "MMDRoot", _context: bpy.types.Context) -> None: 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): +def _toggleUseSphereTexture(self: "MMDRoot", _context: bpy.types.Context) -> None: 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): +def _toggleUseSDEF(self: "MMDRoot", _context: bpy.types.Context) -> None: 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): +def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context) -> None: 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 @@ -112,27 +120,30 @@ def _toggleVisibilityOfMeshes(self: "MMDRoot", context: bpy.types.Context): FnContext.set_active_object(context, root) -def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context): +def _toggleVisibilityOfRigidBodies(self: "MMDRoot", context: bpy.types.Context) -> None: 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): +def _toggleVisibilityOfJoints(self: "MMDRoot", context: bpy.types.Context) -> None: 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): +def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Context) -> None: 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) @@ -140,45 +151,48 @@ def _toggleVisibilityOfTemporaryObjects(self: "MMDRoot", context: bpy.types.Cont FnContext.set_active_object(context, root_object) -def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context): +def _toggleShowNamesOfRigidBodies(self: "MMDRoot", _context: bpy.types.Context) -> None: 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): +def _toggleShowNamesOfJoints(self: "MMDRoot", _context: bpy.types.Context) -> None: 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): +def _setVisibilityOfMMDRigArmature(prop: "MMDRoot", v: bool) -> None: 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"): +def _getVisibilityOfMMDRigArmature(prop: "MMDRoot") -> bool: if prop.id_data.mmd_type != "ROOT": return False arm = FnModel.find_armature_object(prop.id_data) return arm and not arm.hide_get() -def _setActiveRigidbodyObject(prop: "MMDRoot", v: int): +def _setActiveRigidbodyObject(prop: "MMDRoot", v: int) -> None: 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"): +def _getActiveRigidbodyObject(prop: "MMDRoot") -> int: context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_rigid_body_object(active_obj): @@ -186,14 +200,14 @@ def _getActiveRigidbodyObject(prop: "MMDRoot"): return prop.get("active_rigidbody_object_index", 0) -def _setActiveJointObject(prop: "MMDRoot", v: int): +def _setActiveJointObject(prop: "MMDRoot", v: int) -> None: 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"): +def _getActiveJointObject(prop: "MMDRoot") -> int: context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_joint_object(active_obj): @@ -201,26 +215,26 @@ def _getActiveJointObject(prop: "MMDRoot"): return prop.get("active_joint_object_index", 0) -def _setActiveMorph(prop: "MMDRoot", v: bool): +def _setActiveMorph(prop: "MMDRoot", v: bool) -> None: 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"): +def _getActiveMorph(prop: "MMDRoot") -> int: 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): +def _setActiveMeshObject(prop: "MMDRoot", v: int) -> None: 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"): +def _getActiveMeshObject(prop: "MMDRoot") -> int: context = bpy.context active_obj = FnContext.get_active_object(context) if FnModel.is_mesh_object(active_obj): @@ -520,7 +534,8 @@ class MMDRoot(bpy.types.PropertyGroup): prop.hide_viewport = value @staticmethod - def register(): + def register() -> None: + logger.debug("Registering MMDRoot property group") bpy.types.Object.mmd_type = patch_library_overridable( bpy.props.EnumProperty( name="Type", @@ -570,7 +585,8 @@ class MMDRoot(bpy.types.PropertyGroup): ) @staticmethod - def unregister(): + def unregister() -> None: + logger.debug("Unregistering MMDRoot property group") del bpy.types.Object.hide del bpy.types.Object.select del bpy.types.Object.mmd_root diff --git a/core/mmd/translations.py b/core/mmd/translations.py index b7f5e3c..267891a 100644 --- a/core/mmd/translations.py +++ b/core/mmd/translations.py @@ -6,14 +6,20 @@ # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import csv -import logging import time +from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set import bpy +from bpy.types import Text, Context from .bpyutils import FnContext +from ..logging_setup import logger -jp_half_to_full_tuples = ( +# Type definitions for translation tuples +TranslationTuple = Tuple[str, str] +TranslationList = List[TranslationTuple] + +jp_half_to_full_tuples: TranslationList = ( ("ヴ", "ヴ"), ("ガ", "ガ"), ("ギ", "ギ"), @@ -103,7 +109,7 @@ jp_half_to_full_tuples = ( ("ン", "ン"), ) -jp_to_en_tuples = [ +jp_to_en_tuples: TranslationList = [ ("全ての親", "ParentNode"), ("操作中心", "ControlNode"), ("センター", "Center"), @@ -293,22 +299,30 @@ jp_to_en_tuples = [ ] -def translateFromJp(name): +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}") return name -def getTranslator(csvfile="", keep_order=False): +def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bool = False) -> 'MMDTranslator': + """Get a translator instance with the specified CSV file.""" 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: @@ -318,16 +332,20 @@ def getTranslator(csvfile="", keep_order=False): class MMDTranslator: - def __init__(self): - self.__csv_tuples = [] - self.__fails = {} + """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] = {} @staticmethod - def default_csv_filepath(): + def default_csv_filepath() -> str: + """Get the default CSV filepath for translations.""" return __file__[:-3] + ".csv" @staticmethod - def get_csv_text(text_name=None): + def get_csv_text(text_name: Optional[str] = None) -> Text: + """Get or create a Text object for CSV data.""" 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: @@ -335,69 +353,88 @@ class MMDTranslator: return csv_text @staticmethod - def replace_from_tuples(name, tuples): + def replace_from_tuples(name: str, tuples: List[Tuple[str, str]]) -> str: + """Replace parts of a string based on translation tuples.""" for pair in tuples: if pair[0] in name: name = name.replace(pair[0], pair[1]) return name @property - def csv_tuples(self): + def csv_tuples(self) -> List[Tuple[str, str]]: + """Get the CSV tuples.""" return self.__csv_tuples @property - def fails(self): + def fails(self) -> Dict[str, str]: + """Get the failed translations.""" return self.__fails - def sort(self): + def sort(self) -> None: + """Sort the CSV tuples by length (longest first) and then alphabetically.""" + logger.debug("Sorting translation tuples") self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row)) - def update(self): + def update(self) -> None: + """Update the CSV tuples, removing duplicates.""" from collections import OrderedDict 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()) - logging.info(" - removed items:\t%d\t(of %d)", count_old - len(self.__csv_tuples), count_old) + logger.info("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old) - def half_to_full(self, name): + def half_to_full(self, name: str) -> str: + """Convert half-width Japanese characters to full-width.""" return self.replace_from_tuples(name, jp_half_to_full_tuples) - def is_translated(self, name): + def is_translated(self, name: str) -> bool: + """Check if a string is already translated (contains only ASCII characters).""" try: name.encode("ascii", errors="strict") except UnicodeEncodeError: return False return True - def translate(self, name, default=None, from_full_width=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}") 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=None): + def save_fails(self, text_name: Optional[str] = None) -> Text: + """Save failed translations to a Text object.""" 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=None): + def load_from_stream(self, csvfile: Union[Text, Iterator[str]] = None) -> None: + """Load translations from a stream.""" csvfile = csvfile or self.get_csv_text() if isinstance(csvfile, bpy.types.Text): csvfile = (l.body + "\n" for l 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 - logging.info(" - load items:\t%d", len(self.__csv_tuples)) + logger.info("Loaded %d translation items", len(self.__csv_tuples)) - def save_to_stream(self, csvfile=None): + 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 + """ csvfile = csvfile or self.get_csv_text() lineterminator = "\r\n" if isinstance(csvfile, bpy.types.Text): @@ -405,27 +442,38 @@ class MMDTranslator: lineterminator = "\n" spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL) spamwriter.writerows(self.__csv_tuples) - logging.info(" - save items:\t%d", len(self.__csv_tuples)) + logger.info("Saved %d translation items", len(self.__csv_tuples)) - def load(self, filepath=None): + def load(self, filepath: Optional[str] = None) -> None: + """Load translations from a file.""" filepath = filepath or self.default_csv_filepath() - logging.info("Loading csv file:\t%s", filepath) - with open(filepath, "rt", encoding="utf-8", newline="") as csvfile: - self.load_from_stream(csvfile) + 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}") - def save(self, filepath=None): + def save(self, filepath: Optional[str] = None) -> None: + """Save translations to a file.""" filepath = filepath or self.default_csv_filepath() - logging.info("Saving csv file:\t%s", filepath) - with open(filepath, "wt", encoding="utf-8", newline="") as csvfile: - self.save_to_stream(csvfile) + 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}") class DictionaryEnum: - __items_ttl = 0.0 - __items_cache = None + """Handles dictionary enumeration for UI.""" + + __items_ttl: float = 0.0 + __items_cache: Optional[List[Tuple[str, str, str, int]]] = None @staticmethod - def get_dictionary_items(prop, context): + def get_dictionary_items(prop: Any, context: Context) -> List[Tuple[str, str, str, Union[int, str], int]]: + """Get dictionary items for UI enumeration.""" if DictionaryEnum.__items_ttl > time.time(): return DictionaryEnum.__items_cache @@ -437,7 +485,7 @@ class DictionaryEnum: items.append(("INTERNAL", "Internal Dictionary", "The dictionary defined in " + __name__, len(items))) 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, "bpy.data.texts['%s']" % txt_name, "TEXT", len(items))) + items.append((txt_name, txt_name, f"bpy.data.texts['{txt_name}']", "TEXT", len(items))) import os @@ -450,12 +498,19 @@ 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): + def get_translator(dictionary: str) -> Optional[MMDTranslator]: + """Get a translator for the specified 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 c4006ac..6d6f731 100644 --- a/core/mmd/utils.py +++ b/core/mmd/utils.py @@ -5,18 +5,19 @@ # 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 import os import re -from typing import Callable, Optional, Set +from typing import Callable, Dict, List, Optional, Set, Tuple, Union, Any import bpy +from bpy.types import Object, Bone, PoseBone, Mesh, VertexGroup +from ..logging_setup import logger from .bpyutils import FnContext ## 指定したオブジェクトのみを選択状態かつアクティブにする -def selectAObject(obj): +def selectAObject(obj: Object) -> None: try: bpy.ops.object.mode_set(mode="OBJECT") except Exception: @@ -27,13 +28,13 @@ def selectAObject(obj): ## 現在のモードを指定したオブジェクトのEdit Modeに変更する -def enterEditMode(obj): +def enterEditMode(obj: Object) -> None: selectAObject(obj) if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") -def setParentToBone(obj, parent, bone_name): +def setParentToBone(obj: Object, parent: Object, bone_name: str) -> None: selectAObject(obj) FnContext.set_active_object(FnContext.ensure_context(), parent) bpy.ops.object.mode_set(mode="POSE") @@ -42,7 +43,7 @@ def setParentToBone(obj, parent, bone_name): bpy.ops.object.mode_set(mode="OBJECT") -def selectSingleBone(context, armature, bone_name, reset_pose=False): +def selectSingleBone(context: bpy.types.Context, armature: Object, bone_name: str, reset_pose: bool = False) -> None: try: bpy.ops.object.mode_set(mode="OBJECT") except: @@ -55,7 +56,7 @@ def selectSingleBone(context, armature, bone_name, reset_pose=False): for p_bone in armature.pose.bones: p_bone.matrix_basis.identity() armature_bones: bpy.types.ArmatureBones = armature.data.bones - i: bpy.types.Bone + i: Bone for i in armature_bones: i.select = i.name == bone_name i.select_head = i.select_tail = i.select @@ -69,7 +70,7 @@ __CONVERT_NAME_TO_R_REGEXP = re.compile("^(.*)右(.*)$") ## 日本語で左右を命名されている名前をblender方式のL(R)に変更する -def convertNameToLR(name, use_underscore=False): +def convertNameToLR(name: str, use_underscore: bool = False) -> str: m = __CONVERT_NAME_TO_L_REGEXP.match(name) delimiter = "_" if use_underscore else "." if m: @@ -84,7 +85,7 @@ __CONVERT_L_TO_NAME_REGEXP = re.compile(r"(?P(?P[._])[lL])(?P(?P[._])[rR])(?P($|(?P=separator)))") -def convertLRToName(name): +def convertLRToName(name: str) -> str: match = __CONVERT_L_TO_NAME_REGEXP.search(name) if match: return f"左{name[0:match.start()]}{match['after']}{name[match.end():]}" @@ -97,7 +98,7 @@ def convertLRToName(name): ## src_vertex_groupのWeightをdest_vertex_groupにaddする -def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name): +def mergeVertexGroup(meshObj: Object, src_vertex_group_name: str, dest_vertex_group_name: str) -> None: mesh = meshObj.data src_vertex_group = meshObj.vertex_groups[src_vertex_group_name] dest_vertex_group = meshObj.vertex_groups[dest_vertex_group_name] @@ -111,7 +112,7 @@ def mergeVertexGroup(meshObj, src_vertex_group_name, dest_vertex_group_name): pass -def separateByMaterials(meshObj: bpy.types.Object): +def separateByMaterials(meshObj: Object) -> None: if len(meshObj.data.materials) < 2: selectAObject(meshObj) return @@ -134,7 +135,7 @@ def separateByMaterials(meshObj: bpy.types.Object): bpy.data.objects.remove(dummy_parent) -def clearUnusedMeshes(): +def clearUnusedMeshes() -> None: meshes_to_delete = [] for mesh in bpy.data.meshes: if mesh.users == 0: @@ -146,7 +147,7 @@ def clearUnusedMeshes(): ## Boneのカスタムプロパティにname_jが存在する場合、name_jの値を # それ以外の場合は通常のbone名をキーとしたpose_boneへの辞書を作成 -def makePmxBoneMap(armObj): +def makePmxBoneMap(armObj: Object) -> Dict[str, PoseBone]: # Maintain backward compatibility with mmd_tools 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} @@ -175,7 +176,7 @@ def unique_name(name: str, used_names: Set[str]) -> str: return new_name -def int2base(x, base, width=0): +def int2base(x: int, base: int, width: int = 0) -> str: """ Method to convert an int to a base Source: http://stackoverflow.com/questions/2267362 @@ -198,7 +199,7 @@ def int2base(x, base, width=0): return digits -def saferelpath(path, start, strategy="inside"): +def saferelpath(path: str, start: str, strategy: str = "inside") -> str: """ On Windows relpath will raise a ValueError when trying to calculate the relative path to a @@ -227,13 +228,13 @@ def saferelpath(path, start, strategy="inside"): class ItemOp: @staticmethod - def get_by_index(items, index): + def get_by_index(items: bpy.types.bpy_prop_collection, index: int) -> Optional[Any]: if 0 <= index < len(items): return items[index] return None @staticmethod - def resize(items: bpy.types.bpy_prop_collection, length: int): + def resize(items: bpy.types.bpy_prop_collection, length: int) -> None: count = length - len(items) if count > 0: for i in range(count): @@ -243,7 +244,7 @@ class ItemOp: items.remove(length) @staticmethod - def add_after(items, index): + def add_after(items: bpy.types.bpy_prop_collection, index: int) -> Tuple[Any, int]: index_end = len(items) index = max(0, min(index_end, index + 1)) items.add() @@ -265,7 +266,8 @@ class ItemMoveOp: ) @staticmethod - def move(items, index, move_type, index_min=0, index_max=None): + def move(items: bpy.types.bpy_prop_collection, index: int, move_type: str, + index_min: int = 0, index_max: Optional[int] = None) -> int: if index_max is None: index_max = len(items) - 1 else: @@ -294,7 +296,7 @@ class ItemMoveOp: return index_new -def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None): +def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = None) -> Callable: """Decorator to mark a function as deprecated. Args: deprecated_in (Optional[str]): Version in which the function was deprecated. @@ -303,8 +305,8 @@ def deprecated(deprecated_in: Optional[str] = None, details: Optional[str] = Non Callable: The decorated function. """ - def _function_wrapper(function: Callable): - def _inner_wrapper(*args, **kwargs): + def _function_wrapper(function: Callable) -> Callable: + def _inner_wrapper(*args: Any, **kwargs: Any) -> Any: warn_deprecation(function.__name__, deprecated_in, details) return function(*args, **kwargs) @@ -320,7 +322,7 @@ def warn_deprecation(function_name: str, deprecated_in: Optional[str] = None, de deprecated_in (Optional[str]): Version in which the function was deprecated. details (Optional[str]): Additional details about the deprecation. """ - logging.warning( + logger.warning( "%s is deprecated%s%s", function_name, f" since {deprecated_in}" if deprecated_in else "", @@ -328,7 +330,3 @@ 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) diff --git a/core/updater.py b/core/updater.py index 125cc7a..e1c30ec 100644 --- a/core/updater.py +++ b/core/updater.py @@ -20,7 +20,7 @@ GITHUB_REPO = "teamneoneko/Avatar-Toolkit" # Define which version series this installation can update to # For example: ["0.1"] means only look for 0.1.x updates # ["0.2", "0.3"] would look for both 0.2.x and 0.3.x updates -ALLOWED_VERSION_SERIES = ["0.2"] +ALLOWED_VERSION_SERIES = ["0.3"] is_checking_for_update: bool = False update_needed: bool = False diff --git a/cycles_converter.py b/cycles_converter.py deleted file mode 100644 index f0d391a..0000000 --- a/cycles_converter.py +++ /dev/null @@ -1,240 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2012 MMD Tools authors -# This file is part of MMD Tools. - -from typing import Iterable, Optional - -import bpy - -from .core.shader import _NodeGroupUtils -from .core.material import FnMaterial - - -def __switchToCyclesRenderEngine(): - if bpy.context.scene.render.engine != "CYCLES": - bpy.context.scene.render.engine = "CYCLES" - - -def __exposeNodeTreeInput(in_socket, name, default_value, node_input, shader): - _NodeGroupUtils(shader).new_input_socket(name, in_socket, default_value) - - -def __exposeNodeTreeOutput(out_socket, name, node_output, shader): - _NodeGroupUtils(shader).new_output_socket(name, out_socket) - - -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(): - __switchToCyclesRenderEngine() - - if "MMDAlphaShader" in bpy.data.node_groups: - return bpy.data.node_groups["MMDAlphaShader"] - - shader = bpy.data.node_groups.new(name="MMDAlphaShader", type="ShaderNodeTree") - - node_input = shader.nodes.new("NodeGroupInput") - node_output = shader.nodes.new("NodeGroupOutput") - node_output.location.x += 250 - node_input.location.x -= 500 - - trans = shader.nodes.new("ShaderNodeBsdfTransparent") - trans.location.x -= 250 - trans.location.y += 150 - mix = shader.nodes.new("ShaderNodeMixShader") - - shader.links.new(mix.inputs[1], trans.outputs["BSDF"]) - - __exposeNodeTreeInput(mix.inputs[2], "Shader", None, node_input, shader) - __exposeNodeTreeInput(mix.inputs["Fac"], "Alpha", 1.0, node_input, shader) - __exposeNodeTreeOutput(mix.outputs["Shader"], "Shader", node_output, shader) - - return shader - - -def create_MMDBasicShader(): - __switchToCyclesRenderEngine() - - if "MMDBasicShader" in bpy.data.node_groups: - return bpy.data.node_groups["MMDBasicShader"] - - shader: bpy.types.ShaderNodeTree = bpy.data.node_groups.new(name="MMDBasicShader", type="ShaderNodeTree") - - 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: bpy.types.ShaderNodeBsdfDiffuse = shader.nodes.new("ShaderNodeBsdfDiffuse") - dif.location.x -= 250 - dif.location.y += 150 - glo: bpy.types.ShaderNodeBsdfAnisotropic = shader.nodes.new("ShaderNodeBsdfAnisotropic") - glo.location.x -= 250 - glo.location.y -= 150 - 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"]) - - __exposeNodeTreeInput(dif.inputs["Color"], "diffuse", [1.0, 1.0, 1.0, 1.0], node_input, shader) - __exposeNodeTreeInput(glo.inputs["Color"], "glossy", [1.0, 1.0, 1.0, 1.0], node_input, shader) - __exposeNodeTreeInput(glo.inputs["Roughness"], "glossy_rough", 0.0, node_input, shader) - __exposeNodeTreeInput(mix.inputs["Fac"], "reflection", 0.02, node_input, shader) - __exposeNodeTreeOutput(mix.outputs["Shader"], "shader", node_output, shader) - - return shader - - -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): - yield from __enum_linked_nodes(n) - - -def __cleanNodeTree(material: bpy.types.Material): - nodes = material.node_tree.nodes - node_names = set(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)) - for name in node_names: - nodes.remove(nodes[name]) - - -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=False, clean_nodes=False, subsurface=0.001): - for i in obj.material_slots: - if not i.material: - continue - if not i.material.use_nodes: - i.material.use_nodes = True - __convertToMMDBasicShader(i.material) - if use_principled: - __convertToPrincipledBsdf(i.material, subsurface) - if clean_nodes: - __cleanNodeTree(i.material) - -def convertToMMDShader(obj): - """BSDF -> MMDShaderDev conversion.""" - for i in obj.material_slots: - if not i.material: - continue - if not i.material.use_nodes: - i.material.use_nodes = True - FnMaterial.convert_to_mmd_material(i.material) - -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, bpy.types.ShaderNodeGroup) and x.node_tree.name in {"MMDBasicShader", "MMDAlphaShader"}, material.node_tree.nodes)): - # Add nodes for Cycles Render - 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] - shader.inputs["glossy_rough"].default_value = 1.0 / getattr(material, "specular_hardness", 50) - outplug = shader.outputs[0] - - location = shader.location.copy() - location.x -= 1000 - - alpha_value = 1.0 - if len(material.diffuse_color) > 3: - alpha_value = material.diffuse_color[3] - - if alpha_value < 1.0: - 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 - alpha_shader.inputs[1].default_value = alpha_value - material.node_tree.links.new(alpha_shader.inputs[0], outplug) - outplug = alpha_shader.outputs[0] - - 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: bpy.types.Material, subsurface: float): - node_names = set() - for s in (n for n in material.node_tree.nodes if isinstance(n, bpy.types.ShaderNodeGroup)): - if s.node_tree.name == "MMDBasicShader": - l: bpy.types.NodeLink - for l in s.outputs[0].links: - to_node = l.to_node - # assuming there is no bpy.types.NodeReroute between MMDBasicShader and 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: - __switchToPrincipledBsdf(material.node_tree, s, subsurface) - node_names.add(s.name) - elif s.node_tree.name == "MMDShaderDev": - __switchToPrincipledBsdf(material.node_tree, s, subsurface) - node_names.add(s.name) - # remove MMD shader nodes - nodes = material.node_tree.nodes - for name in node_names: - nodes.remove(nodes[name]) - - -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 - - alpha_socket_name = "Alpha" - if node_basic.node_tree.name == "MMDShaderDev": - node_alpha, alpha_socket_name = node_basic, "Base Alpha" - if "Base Tex" in node_basic.inputs and node_basic.inputs["Base Tex"].is_linked: - node_tree.links.new(node_basic.inputs["Base Tex"].links[0].from_socket, shader.inputs["Base Color"]) - elif "Diffuse Color" in node_basic.inputs: - shader.inputs["Base Color"].default_value[:3] = node_basic.inputs["Diffuse Color"].default_value[:3] - elif "diffuse" in node_basic.inputs: - shader.inputs["Base Color"].default_value[:3] = node_basic.inputs["diffuse"].default_value[:3] - if node_basic.inputs["diffuse"].is_linked: - node_tree.links.new(node_basic.inputs["diffuse"].links[0].from_socket, shader.inputs["Base Color"]) - - shader.inputs["IOR"].default_value = 1.0 - shader.inputs["Subsurface Weight"].default_value = subsurface - - output_links = node_basic.outputs[0].links - if node_alpha: - output_links = node_alpha.outputs[0].links - shader.parent = node_alpha.parent or shader.parent - shader.location.x = node_alpha.location.x - - 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 - 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: - shader.inputs["Transmission"].default_value = 1 - node_alpha.inputs[alpha_socket_name].default_value - if node_alpha.inputs[alpha_socket_name].is_linked: - node_invert = node_tree.nodes.new("ShaderNodeMath") - node_invert.parent = shader.parent - node_invert.location.x = node_alpha.location.x - 250 - node_invert.location.y = node_alpha.location.y - 300 - node_invert.operation = "SUBTRACT" - node_invert.use_clamp = True - node_invert.inputs[0].default_value = 1 - 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) diff --git a/resources/translations/en_US.json b/resources/translations/en_US.json index f8c0517..efc827d 100644 --- a/resources/translations/en_US.json +++ b/resources/translations/en_US.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.2.1)", + "AvatarToolkit.label": "Avatar Toolkit (Alpha 0.3.0)", "AvatarToolkit.desc1": "Avatar Toolkit is in Early Access there", "AvatarToolkit.desc2": "will be issues, if you find any issues,", "AvatarToolkit.desc3": "please report it on our Github.", @@ -63,6 +63,13 @@ "PoseMode.basis": "Basis", "Armature.validation.no_armature": "No armature selected", + "Armature.validation.pmx_model_detected": "PMX model detected. Japanese bone names may not match standard naming conventions.", + "Armature.validation.pmx_model_strict": "Consider using the 'Standardize Armature' option to convert Japanese bone names to standard names.", + "Armature.validation.pmx_model_standardize": "This will make the model compatible with standard avatar systems.", + "Armature.validation.pmx_model_basic": "PMX models use Japanese bone names which may not match standard naming conventions.", + "Armature.validation.unknown_format": "Unknown armature format detected.", + "Validation.mode.none": "Validation is disabled in settings.", + "Validation.no_messages": "No validation messages available.", "Armature.validation.not_armature": "Selected object is not an armature", "Armature.validation.no_bones": "Armature has no bones", "Armature.validation.basic_check_failed": "Basic armature validation failed", diff --git a/resources/translations/ja_JP.json b/resources/translations/ja_JP.json index a37a9a9..7429eec 100644 --- a/resources/translations/ja_JP.json +++ b/resources/translations/ja_JP.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "アバターツールキット (アルファ 0.2.1)", + "AvatarToolkit.label": "アバターツールキット (アルファ 0.3.0)", "AvatarToolkit.desc1": "アバターツールキットは早期アクセス中であり、", "AvatarToolkit.desc2": "問題が発生する可能性があります。問題を見つけた場合は、", "AvatarToolkit.desc3": "GitHubで報告してください。", @@ -63,6 +63,13 @@ "PoseMode.basis": "基本形", "Armature.validation.no_armature": "アーマチュアが選択されていません", + "Armature.validation.pmx_model_detected": "PMXモデルが検出されました。日本語の骨名が標準の命名規則と一致しない場合があります。", + "Armature.validation.pmx_model_strict": "「アーマチュアの標準化」オプションを使用して、日本語の骨名を標準名に変換することを検討してください。", + "Armature.validation.pmx_model_standardize": "これにより、モデルが標準的なアバターシステムと互換性を持つようになります。", + "Armature.validation.pmx_model_basic": "PMXモデルは日本語の骨名を使用しており、標準の命名規則と一致しない場合があります。", + "Armature.validation.unknown_format": "不明なアーマチュア形式が検出されました。", + "Validation.mode.none": "検証は設定で無効になっています。", + "Validation.no_messages": "検証メッセージはありません。", "Armature.validation.not_armature": "選択されたオブジェクトはアーマチュアではありません", "Armature.validation.no_bones": "アーマチュアにボーンがありません", "Armature.validation.basic_check_failed": "基本的なアーマチュア検証に失敗しました", diff --git a/resources/translations/ko_KR.json b/resources/translations/ko_KR.json index c8408cc..38e2938 100644 --- a/resources/translations/ko_KR.json +++ b/resources/translations/ko_KR.json @@ -1,7 +1,7 @@ { "authors": ["Avatar Toolkit Team"], "messages": { - "AvatarToolkit.label": "아바타 툴킷 (알파 0.2.1)", + "AvatarToolkit.label": "아바타 툴킷 (알파 0.3.0)", "AvatarToolkit.desc1": "아바타 툴킷은 초기 액세스 단계에 있으므로", "AvatarToolkit.desc2": "문제가 있을 수 있습니다. 문제를 발견하시면", "AvatarToolkit.desc3": "Github에 보고해 주세요.", @@ -63,6 +63,13 @@ "PoseMode.basis": "기본", "Armature.validation.no_armature": "선택된 아마추어 없음", + "Armature.validation.pmx_model_detected": "PMX 모델이 감지되었습니다. 일본어 본 이름이 표준 명명 규칙과 일치하지 않을 수 있습니다.", + "Armature.validation.pmx_model_strict": "'아마추어 표준화' 옵션을 사용하여 일본어 본 이름을 표준 이름으로 변환하는 것을 고려하세요.", + "Armature.validation.pmx_model_standardize": "이렇게 하면 모델이 표준 아바타 시스템과 호환됩니다.", + "Armature.validation.pmx_model_basic": "PMX 모델은 일본어 본 이름을 사용하며 표준 명명 규칙과 일치하지 않을 수 있습니다.", + "Armature.validation.unknown_format": "알 수 없는 아마추어 형식이 감지되었습니다.", + "Validation.mode.none": "유효성 검사가 설정에서 비활성화되었습니다.", + "Validation.no_messages": "사용 가능한 유효성 검사 메시지가 없습니다.", "Armature.validation.not_armature": "선택된 객체가 아마추어가 아님", "Armature.validation.no_bones": "아마추어에 본이 없음", "Armature.validation.basic_check_failed": "기본 아마추어 검증 실패", diff --git a/ui/quick_access_panel.py b/ui/quick_access_panel.py index d0d6755..2f8f625 100644 --- a/ui/quick_access_panel.py +++ b/ui/quick_access_panel.py @@ -89,16 +89,33 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): if active_armature: is_valid, messages, is_acceptable, hierarchy_messages, scale_messages, non_standard_messages = validate_armature(active_armature, detailed_messages=True) + # Check if this is a PMX model + is_pmx_model = False + if hasattr(active_armature, 'mmd_type') or (hasattr(active_armature, 'parent') and active_armature.parent and hasattr(active_armature.parent, 'mmd_type')): + is_pmx_model = True + info_box = col.box() + # If it's a PMX model, display a prominent notice + if is_pmx_model: + pmx_box = info_box.box() + pmx_box.label(text=t("Armature.validation.pmx_model_detected"), icon='INFO') + + validation_mode = context.scene.avatar_toolkit.validation_mode + if validation_mode == 'STRICT': + pmx_box.label(text=t("Armature.validation.pmx_model_strict")) + pmx_box.label(text=t("Armature.validation.pmx_model_standardize")) + else: + pmx_box.label(text=t("Armature.validation.pmx_model_basic")) + if not is_valid: # Display non-standard bones and hierarchy issues - if len(messages) > 1: + if messages and len(messages) > 0: # Found Bones section validation_box = info_box.box() row = validation_box.row() row.prop(props, "show_found_bones", text=t("Validation.section.found_bones"), icon='TRIA_DOWN' if props.show_found_bones else 'TRIA_RIGHT', emboss=False) - if props.show_found_bones: + if props.show_found_bones and len(messages) > 0: for line in messages[0].split('\n'): validation_box.label(text=line) @@ -127,15 +144,31 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): row.prop(props, "show_non_standard", text=t("Validation.section.non_standard"), icon='TRIA_DOWN' if props.show_non_standard else 'TRIA_RIGHT', emboss=False) if props.show_non_standard: - if non_standard_messages: + if non_standard_messages and len(non_standard_messages) > 0: for message in non_standard_messages: for line in message.split('\n'): sub_row = validation_box.row() sub_row.alert = True sub_row.label(text=line) else: - sub_row = validation_box.row() - sub_row.label(text=t("Validation.no_non_standard_issues")) + # For PMX models, if no non-standard messages but it's a PMX model, + # we should still indicate there might be non-standard bones + if is_pmx_model: + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=t("Armature.validation.pmx_model_basic")) + + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=t("Armature.validation.pmx_model_strict")) + + sub_row = validation_box.row() + sub_row.alert = True + sub_row.label(text=t("Armature.validation.pmx_model_standardize")) + + else: + sub_row = validation_box.row() + sub_row.label(text=t("Validation.no_non_standard_issues")) # Hierarchy Issues section validation_box = info_box.box() @@ -190,9 +223,14 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): row.label(text=msg.name) else: # If no specific issues, show acceptable message - info_box.label(text=messages[0], icon='INFO') - info_box.label(text=messages[1]) - info_box.label(text=messages[2]) + if messages and len(messages) > 0: + info_box.label(text=messages[0], icon='INFO') + if len(messages) > 1: + info_box.label(text=messages[1]) + if len(messages) > 2: + info_box.label(text=messages[2]) + else: + info_box.label(text=t("Validation.no_messages"), icon='INFO') elif is_valid and not is_acceptable: row = info_box.row() split = row.split(factor=0.6) @@ -204,9 +242,16 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): info_box.label(text=t("QuickAccess.pose_bones_available"), icon='POSE_HLT') elif is_valid and is_acceptable: # Show acceptable standard message - info_box.label(text=messages[0], icon='INFO') - info_box.label(text=messages[1]) - info_box.label(text=messages[2]) + if messages and len(messages) > 0: + info_box.label(text=messages[0], icon='INFO') + + # Only try to access additional messages if they exist + if len(messages) > 1: + info_box.label(text=messages[1]) + if len(messages) > 2: + info_box.label(text=messages[2]) + else: + info_box.label(text=t("Validation.no_messages"), icon='INFO') # Add standardize button standardize_box = info_box.box() @@ -252,3 +297,4 @@ class AvatarToolKit_PT_QuickAccessPanel(Panel): button_row.scale_y = 1.5 button_row.operator(AvatarToolKit_OT_Import.bl_idname, text=t("QuickAccess.import"), icon='IMPORT') button_row.operator(AvatarToolKit_OT_ExportMenu.bl_idname, text=t("QuickAccess.export"), icon='EXPORT') +