diff --git a/core/mmd/core/bone.py b/core/mmd/core/bone.py index 73fa58c..45b16fd 100644 --- a/core/mmd/core/bone.py +++ b/core/mmd/core/bone.py @@ -6,21 +6,24 @@ # 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 ....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: bpy.types.ConstraintSequence, name: str) -> bool: + """Remove a constraint by name if it exists""" c = constraints.get(name, None) if c: constraints.remove(c) @@ -28,7 +31,8 @@ def remove_constraint(constraints, name): 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 +49,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 +90,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 +109,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 +176,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 +205,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 +224,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 +272,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 +323,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 +331,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 +343,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 +362,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 +381,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 +420,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 +431,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 +451,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 +474,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 +498,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 +512,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 +534,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: bpy.types.ArmaturePoseBones) -> 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 +618,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,9 +626,12 @@ class _AT_ShadowBoneCreate: shadow.head = dummy.head shadow.tail = dummy.tail shadow.roll = bone.roll + logger.debug(f"Created/updated shadow bone: {shadow_bone_name}") - def update_pose_bones(self, pose_bones): + def update_pose_bones(self, pose_bones: bpy.types.ArmaturePoseBones) -> 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 @@ -560,5 +650,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..752520c 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}")